Skip to content

Commit 207137e

Browse files
authored
fix: add an option value to the snapshot (#362)
Fixes #185 The standard selector value is not included in the AXNode, so add it separately.
1 parent 2e0961f commit 207137e

File tree

4 files changed

+118
-18
lines changed

4 files changed

+118
-18
lines changed

src/McpContext.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,10 @@ export class McpContext implements Context {
272272
return page.getDefaultNavigationTimeout();
273273
}
274274

275+
getAXNodeByUid(uid: string) {
276+
return this.#textSnapshot?.idToNode.get(uid);
277+
}
278+
275279
async getElementByUid(uid: string): Promise<ElementHandle<Element>> {
276280
if (!this.#textSnapshot?.idToNode.size) {
277281
throw new Error(
@@ -334,6 +338,16 @@ export class McpContext implements Context {
334338
? node.children.map(child => assignIds(child))
335339
: [],
336340
};
341+
342+
// The AXNode for an option doesn't contain its `value`.
343+
// Therefore, set text content of the option as value.
344+
if (node.role === 'option') {
345+
const optionText = node.name;
346+
if (optionText) {
347+
nodeWithId.value = optionText.toString();
348+
}
349+
}
350+
337351
idToNode.set(nodeWithId.id, nodeWithId);
338352
return nodeWithId;
339353
};

src/tools/ToolDefinition.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import type {Dialog, ElementHandle, Page} from 'puppeteer-core';
88
import z from 'zod';
99

10+
import type {TextSnapshotNode} from '../McpContext.js';
1011
import type {TraceResult} from '../trace-processing/parse.js';
1112

1213
import type {ToolCategories} from './categories.js';
@@ -68,6 +69,7 @@ export type Context = Readonly<{
6869
closePage(pageIdx: number): Promise<void>;
6970
setSelectedPageIdx(idx: number): void;
7071
getElementByUid(uid: string): Promise<ElementHandle<Element>>;
72+
getAXNodeByUid(uid: string): TextSnapshotNode | undefined;
7173
setNetworkConditions(conditions: string | null): void;
7274
setCpuThrottlingRate(rate: number): void;
7375
saveTemporaryFile(

src/tools/input.ts

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import type {ElementHandle} from 'puppeteer-core';
88
import z from 'zod';
99

10+
import type {McpContext, TextSnapshotNode} from '../McpContext.js';
11+
1012
import {ToolCategories} from './categories.js';
1113
import {defineTool} from './ToolDefinition.js';
1214

@@ -78,6 +80,61 @@ export const hover = defineTool({
7880
},
7981
});
8082

83+
// The AXNode for an option doesn't contain its `value`. We set text content of the option as value.
84+
// If the form is a combobox, we need to find the correct option by its text value.
85+
// To do that, loop through the children while checking which child's text matches the requested value (requested value is actually the text content).
86+
// When the correct option is found, use the element handle to get the real value.
87+
async function selectOption(
88+
handle: ElementHandle,
89+
aXNode: TextSnapshotNode,
90+
value: string,
91+
) {
92+
let optionFound = false;
93+
for (const child of aXNode.children) {
94+
if (child.role === 'option' && child.name === value && child.value) {
95+
optionFound = true;
96+
const childHandle = await child.elementHandle();
97+
if (childHandle) {
98+
try {
99+
const childValueHandle = await childHandle.getProperty('value');
100+
try {
101+
const childValue = await childValueHandle.jsonValue();
102+
if (childValue) {
103+
await handle.asLocator().fill(childValue.toString());
104+
}
105+
} finally {
106+
void childValueHandle.dispose();
107+
}
108+
break;
109+
} finally {
110+
void childHandle.dispose();
111+
}
112+
}
113+
}
114+
}
115+
if (!optionFound) {
116+
throw new Error(`Could not find option with text "${value}"`);
117+
}
118+
}
119+
120+
async function fillFormElement(
121+
uid: string,
122+
value: string,
123+
context: McpContext,
124+
) {
125+
const handle = await context.getElementByUid(uid);
126+
try {
127+
const aXNode = context.getAXNodeByUid(uid);
128+
if (aXNode && aXNode.role === 'combobox') {
129+
await selectOption(handle, aXNode, value);
130+
} else {
131+
await handle.asLocator().fill(value);
132+
}
133+
} finally {
134+
void handle.dispose();
135+
}
136+
}
137+
81138
export const fill = defineTool({
82139
name: 'fill',
83140
description: `Type text into a input, text area or select an option from a <select> element.`,
@@ -94,16 +151,15 @@ export const fill = defineTool({
94151
value: z.string().describe('The value to fill in'),
95152
},
96153
handler: async (request, response, context) => {
97-
const handle = await context.getElementByUid(request.params.uid);
98-
try {
99-
await context.waitForEventsAfterAction(async () => {
100-
await handle.asLocator().fill(request.params.value);
101-
});
102-
response.appendResponseLine(`Successfully filled out the element`);
103-
response.setIncludeSnapshot(true);
104-
} finally {
105-
void handle.dispose();
106-
}
154+
await context.waitForEventsAfterAction(async () => {
155+
await fillFormElement(
156+
request.params.uid,
157+
request.params.value,
158+
context as McpContext,
159+
);
160+
});
161+
response.appendResponseLine(`Successfully filled out the element`);
162+
response.setIncludeSnapshot(true);
107163
},
108164
});
109165

@@ -155,14 +211,13 @@ export const fillForm = defineTool({
155211
},
156212
handler: async (request, response, context) => {
157213
for (const element of request.params.elements) {
158-
const handle = await context.getElementByUid(element.uid);
159-
try {
160-
await context.waitForEventsAfterAction(async () => {
161-
await handle.asLocator().fill(element.value);
162-
});
163-
} finally {
164-
void handle.dispose();
165-
}
214+
await context.waitForEventsAfterAction(async () => {
215+
await fillFormElement(
216+
element.uid,
217+
element.value,
218+
context as McpContext,
219+
);
220+
});
166221
}
167222
response.appendResponseLine(`Successfully filled out the form`);
168223
response.setIncludeSnapshot(true);

tests/tools/input.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,35 @@ describe('input', () => {
202202
assert.ok(await page.$('text/test'));
203203
});
204204
});
205+
206+
it('fills out a select by text', async () => {
207+
await withBrowser(async (response, context) => {
208+
const page = context.getSelectedPage();
209+
await page.setContent(
210+
`<!DOCTYPE html><select><option value="v1">one</option><option value="v2">two</option></select>`,
211+
);
212+
await context.createTextSnapshot();
213+
await fill.handler(
214+
{
215+
params: {
216+
uid: '1_1',
217+
value: 'two',
218+
},
219+
},
220+
response,
221+
context,
222+
);
223+
assert.strictEqual(
224+
response.responseLines[0],
225+
'Successfully filled out the element',
226+
);
227+
assert.ok(response.includeSnapshot);
228+
const selectedValue = await page.evaluate(
229+
() => document.querySelector('select')!.value,
230+
);
231+
assert.strictEqual(selectedValue, 'v2');
232+
});
233+
});
205234
});
206235

207236
describe('drags', () => {

0 commit comments

Comments
 (0)