Skip to content

Commit 0b7142a

Browse files
committed
fix(input): stop native select option clicks timing out
1 parent c9c1683 commit 0b7142a

4 files changed

Lines changed: 183 additions & 4 deletions

File tree

docs/tool-reference.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<!-- AUTO GENERATED DO NOT EDIT - run 'npm run gen' to update-->
22

3-
# Chrome DevTools MCP Tool Reference (~7005 cl100k_base tokens)
3+
# Chrome DevTools MCP Tool Reference (~7033 cl100k_base tokens)
44

55
- **[Input automation](#input-automation)** (9 tools)
66
- [`click`](#click)
@@ -49,7 +49,7 @@
4949

5050
### `click`
5151

52-
**Description:** Clicks on the provided element
52+
**Description:** Clicks a page element such as a button, link, checkbox, or other interactive control. To choose an option from a native &lt;select&gt; element, use [`fill`](#fill) instead.
5353

5454
**Parameters:**
5555

src/bin/cliDefinitions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ export type Commands = Record<
2424
>;
2525
export const commands: Commands = {
2626
click: {
27-
description: 'Clicks on the provided element',
27+
description:
28+
'Clicks a page element such as a button, link, checkbox, or other interactive control. To choose an option from a native <select> element, use fill instead.',
2829
category: 'Input automation',
2930
args: {
3031
uid: {

src/tools/input.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,38 @@ function handleActionError(error: unknown, uid: string) {
4242
);
4343
}
4444

45+
async function selectNativeSelectOption(handle: ElementHandle<Element>) {
46+
return await handle.evaluate(node => {
47+
if (!(node instanceof HTMLOptionElement)) {
48+
return false;
49+
}
50+
51+
const select = node.closest('select');
52+
if (!select || select.multiple || select.disabled || node.disabled) {
53+
return false;
54+
}
55+
56+
const parentElement = node.parentElement;
57+
if (
58+
parentElement instanceof HTMLOptGroupElement &&
59+
parentElement.disabled
60+
) {
61+
return false;
62+
}
63+
64+
const wasSelected = node.selected;
65+
node.selected = true;
66+
if (!wasSelected) {
67+
select.dispatchEvent(new Event('input', {bubbles: true}));
68+
select.dispatchEvent(new Event('change', {bubbles: true}));
69+
}
70+
return true;
71+
});
72+
}
73+
4574
export const click = definePageTool({
4675
name: 'click',
47-
description: `Clicks on the provided element`,
76+
description: `Clicks a page element such as a button, link, checkbox, or other interactive control. To choose an option from a native <select> element, use fill instead.`,
4877
annotations: {
4978
category: ToolCategory.INPUT,
5079
readOnlyHint: false,
@@ -61,8 +90,18 @@ export const click = definePageTool({
6190
handler: async (request, response) => {
6291
const uid = request.params.uid;
6392
const handle = await request.page.getElementByUid(uid);
93+
const aXNode = request.page.getAXNodeByUid(uid);
94+
const shouldSelectNativeOption =
95+
!request.params.dblClick && aXNode?.role === 'option';
6496
try {
6597
await request.page.waitForEventsAfterAction(async () => {
98+
if (
99+
shouldSelectNativeOption &&
100+
(await selectNativeSelectOption(handle))
101+
) {
102+
return;
103+
}
104+
66105
await handle.asLocator().click({
67106
count: request.params.dblClick ? 2 : 1,
68107
});

tests/tools/input.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,145 @@ describe('input', () => {
226226
assert.notStrictEqual(response.snapshotParams, undefined);
227227
});
228228
});
229+
230+
it('selects a collapsed native select option by option uid', async () => {
231+
await withMcpContext(async (response, context) => {
232+
const page = context.getSelectedPptrPage();
233+
await page.setContent(
234+
html`<select onchange="document.body.dataset.selected = this.value">
235+
<option value="v1">one</option>
236+
<option value="v2">two</option>
237+
</select>`,
238+
);
239+
const mcpPage = context.getSelectedMcpPage();
240+
mcpPage.textSnapshot = await TextSnapshot.create(mcpPage);
241+
const optionNode = [...mcpPage.textSnapshot.idToNode.values()].find(
242+
node => node.role === 'option' && node.name === 'two',
243+
);
244+
assert.ok(optionNode);
245+
246+
await click.handler(
247+
{
248+
params: {
249+
uid: optionNode.id,
250+
},
251+
page: mcpPage,
252+
},
253+
response,
254+
context,
255+
);
256+
257+
assert.strictEqual(
258+
response.responseLines[0],
259+
'Successfully clicked on the element',
260+
);
261+
assert.deepStrictEqual(
262+
await page.evaluate(() => {
263+
const select = document.querySelector('select');
264+
return {
265+
selectedValue: select?.value,
266+
changeEventValue: document.body.dataset.selected,
267+
};
268+
}),
269+
{
270+
selectedValue: 'v2',
271+
changeEventValue: 'v2',
272+
},
273+
);
274+
});
275+
});
276+
277+
it('selects a collapsed native optgroup option by option uid', async () => {
278+
await withMcpContext(async (response, context) => {
279+
const page = context.getSelectedPptrPage();
280+
await page.setContent(
281+
html`<select onchange="document.body.dataset.selected = this.value">
282+
<optgroup label="Numbers">
283+
<option value="v1">one</option>
284+
<option value="v2">two</option>
285+
</optgroup>
286+
</select>`,
287+
);
288+
const mcpPage = context.getSelectedMcpPage();
289+
mcpPage.textSnapshot = await TextSnapshot.create(mcpPage);
290+
const optionNode = [...mcpPage.textSnapshot.idToNode.values()].find(
291+
node => node.role === 'option' && node.name === 'two',
292+
);
293+
assert.ok(optionNode);
294+
295+
await click.handler(
296+
{
297+
params: {
298+
uid: optionNode.id,
299+
},
300+
page: mcpPage,
301+
},
302+
response,
303+
context,
304+
);
305+
306+
assert.strictEqual(
307+
response.responseLines[0],
308+
'Successfully clicked on the element',
309+
);
310+
assert.deepStrictEqual(
311+
await page.evaluate(() => {
312+
const select = document.querySelector('select');
313+
return {
314+
selectedValue: select?.value,
315+
changeEventValue: document.body.dataset.selected,
316+
};
317+
}),
318+
{
319+
selectedValue: 'v2',
320+
changeEventValue: 'v2',
321+
},
322+
);
323+
});
324+
});
325+
326+
it('clicks custom ARIA option elements through the normal click path', async () => {
327+
await withMcpContext(async (response, context) => {
328+
const page = context.getSelectedPptrPage();
329+
await page.setContent(
330+
html`<div role="listbox">
331+
<div
332+
role="option"
333+
tabindex="0"
334+
onclick="document.body.dataset.clicked = this.textContent.trim()"
335+
>
336+
custom two
337+
</div>
338+
</div>`,
339+
);
340+
const mcpPage = context.getSelectedMcpPage();
341+
mcpPage.textSnapshot = await TextSnapshot.create(mcpPage);
342+
const optionNode = [...mcpPage.textSnapshot.idToNode.values()].find(
343+
node => node.role === 'option' && node.name === 'custom two',
344+
);
345+
assert.ok(optionNode);
346+
347+
await click.handler(
348+
{
349+
params: {
350+
uid: optionNode.id,
351+
},
352+
page: mcpPage,
353+
},
354+
response,
355+
context,
356+
);
357+
358+
assert.strictEqual(
359+
response.responseLines[0],
360+
'Successfully clicked on the element',
361+
);
362+
assert.strictEqual(
363+
await page.evaluate(() => document.body.dataset.clicked),
364+
'custom two',
365+
);
366+
});
367+
});
229368
});
230369

231370
describe('hover', () => {

0 commit comments

Comments
 (0)