fix(input): stop native select option clicks from timing out (#1960)

## Summary

Teach the `click` tool to handle accessibility snapshot targets that
point at an `option` inside a native, single-select `<select>` element.

When the target is a native select option, `click` now selects that
option through the owning `<select>` element instead of asking Puppeteer
to click an option node that has no clickable box while the dropdown is
collapsed. Other option-like targets, including custom ARIA options,
continue through the normal click path.

## Motivation

Fixes #1941.

The text snapshot can expose native `<option>` nodes with stable uids.
This makes it natural for an agent to call `click` on an option uid
after seeing the desired option in the snapshot.

However, a collapsed native `<select>` does not expose its child
`<option>` nodes as directly clickable page boxes. In that state, the
option can exist in the DOM and accessibility tree while still having no
clickable geometry. Puppeteer therefore waits for the option to become
interactive and eventually times out.

The existing `fill` tool already handles selecting native `<select>`
options. This change keeps that guidance explicit in the `click` tool
description, while also making `click(option_uid)` robust for the native
select case that appears in the snapshot.

## Changes

- Added a constrained native select fallback in `click`:
  - Only runs for single-click requests.
  - Only runs when the snapshot node role is `option`.
- Only handles real `HTMLOptionElement` nodes owned by a native
`<select>`.
- Does not handle disabled selects, disabled options, disabled
optgroups, or multi-selects.
- Falls back to the existing locator click behavior for all other
targets.
- Dispatches `input` and `change` events when selecting a different
native option.
- Updated the `click` tool description to steer agents toward `fill` for
native `<select>` option selection.
- Regenerated CLI/tool reference docs with `npm run gen`.
- Added regression coverage for:
  - Clicking an option uid in a collapsed native `<select>`.
  - Clicking an option uid inside a native `<optgroup>`.
- Clicking a custom ARIA `role="option"` element through the normal
click path.

## Test Plan

Passed:

```bash
npm run test -- tests/tools/input.test.ts
npm run check-format
```

Also attempted:

```bash
npm run test
```

The full suite hit unrelated local failures outside this change area:

- `tests/tools/network.test.ts`: snapshot ordering difference for
redirected requests.
- `tests/tools/screenshot.test.ts`: `Page.captureScreenshot` failed for
the large full-page screenshot case with `Page is too large`.

The changed input tool tests pass locally.

## Risk

Low. The fallback is intentionally narrow:

- It is gated by the accessibility role being `option`.
- It verifies the DOM node is an actual `HTMLOptionElement`.
- It only applies to native, non-disabled, single-select `<select>`
controls.
- It does not reinterpret custom combobox/listbox implementations.
- It preserves the existing locator click path for non-native option
targets.

The main behavior change is that `click(option_uid)` can now succeed for
native collapsed dropdowns that previously timed out.

## Related Issue

Closes #1941.

## Maintainer Context

This targets a small but high-impact mismatch between the snapshot
representation and browser interaction semantics.

The snapshot correctly exposes native options because they exist in the
accessibility tree. The click implementation previously treated that uid
like any other clickable element, but collapsed native options do not
have normal clickable layout boxes. This PR makes the native select case
explicit without expanding `click` into a general custom dropdown
heuristic.

The tool description still recommends `fill` for native `<select>`
selection so agents are guided toward the more direct tool. The code
fallback exists for the common case where an agent already selected an
option uid from the snapshot.
This commit is contained in:
cooleryu
2026-05-04 17:12:43 +08:00
committed by GitHub
parent 2dcd641ff5
commit 510ec0f48b
2 changed files with 192 additions and 0 deletions
+53
View File
@@ -42,6 +42,49 @@ function handleActionError(error: unknown, uid: string) {
);
}
async function selectNativeSelectOption(handle: ElementHandle<Element>) {
const selectHandle = await handle.evaluateHandle(node => {
if (!(node instanceof HTMLOptionElement)) {
return null;
}
const select = node.closest('select');
if (!select || select.multiple || select.disabled || node.disabled) {
return null;
}
const parentElement = node.parentElement;
if (
parentElement instanceof HTMLOptGroupElement &&
parentElement.disabled
) {
return null;
}
return select;
});
try {
const select = selectHandle.asElement() as ElementHandle<Element> | null;
if (!select) {
return false;
}
const valueHandle = await handle.getProperty('value');
try {
const value = await valueHandle.jsonValue();
if (typeof value !== 'string') {
return false;
}
await select.asLocator().fill(value);
} finally {
void valueHandle.dispose();
}
return true;
} finally {
void selectHandle.dispose();
}
}
export const click = definePageTool({
name: 'click',
description: `Clicks on the provided element`,
@@ -62,8 +105,18 @@ export const click = definePageTool({
handler: async (request, response) => {
const uid = request.params.uid;
const handle = await request.page.getElementByUid(uid);
const aXNode = request.page.getAXNodeByUid(uid);
const shouldSelectNativeOption =
!request.params.dblClick && aXNode?.role === 'option';
try {
await request.page.waitForEventsAfterAction(async () => {
if (
shouldSelectNativeOption &&
(await selectNativeSelectOption(handle))
) {
return;
}
await handle.asLocator().click({
count: request.params.dblClick ? 2 : 1,
});
+139
View File
@@ -226,6 +226,145 @@ describe('input', () => {
assert.notStrictEqual(response.snapshotParams, undefined);
});
});
it('selects a collapsed native select option by option uid', async () => {
await withMcpContext(async (response, context) => {
const page = context.getSelectedPptrPage();
await page.setContent(
html`<select onchange="document.body.dataset.selected = this.value">
<option value="v1">one</option>
<option value="v2">two</option>
</select>`,
);
const mcpPage = context.getSelectedMcpPage();
mcpPage.textSnapshot = await TextSnapshot.create(mcpPage);
const optionNode = [...mcpPage.textSnapshot.idToNode.values()].find(
node => node.role === 'option' && node.name === 'two',
);
assert.ok(optionNode);
await click.handler(
{
params: {
uid: optionNode.id,
},
page: mcpPage,
},
response,
context,
);
assert.strictEqual(
response.responseLines[0],
'Successfully clicked on the element',
);
assert.deepStrictEqual(
await page.evaluate(() => {
const select = document.querySelector('select');
return {
selectedValue: select?.value,
changeEventValue: document.body.dataset.selected,
};
}),
{
selectedValue: 'v2',
changeEventValue: 'v2',
},
);
});
});
it('selects a collapsed native optgroup option by option uid', async () => {
await withMcpContext(async (response, context) => {
const page = context.getSelectedPptrPage();
await page.setContent(
html`<select onchange="document.body.dataset.selected = this.value">
<optgroup label="Numbers">
<option value="v1">one</option>
<option value="v2">two</option>
</optgroup>
</select>`,
);
const mcpPage = context.getSelectedMcpPage();
mcpPage.textSnapshot = await TextSnapshot.create(mcpPage);
const optionNode = [...mcpPage.textSnapshot.idToNode.values()].find(
node => node.role === 'option' && node.name === 'two',
);
assert.ok(optionNode);
await click.handler(
{
params: {
uid: optionNode.id,
},
page: mcpPage,
},
response,
context,
);
assert.strictEqual(
response.responseLines[0],
'Successfully clicked on the element',
);
assert.deepStrictEqual(
await page.evaluate(() => {
const select = document.querySelector('select');
return {
selectedValue: select?.value,
changeEventValue: document.body.dataset.selected,
};
}),
{
selectedValue: 'v2',
changeEventValue: 'v2',
},
);
});
});
it('clicks custom ARIA option elements through the normal click path', async () => {
await withMcpContext(async (response, context) => {
const page = context.getSelectedPptrPage();
await page.setContent(
html`<div role="listbox">
<div
role="option"
tabindex="0"
onclick="document.body.dataset.clicked = this.textContent.trim()"
>
custom two
</div>
</div>`,
);
const mcpPage = context.getSelectedMcpPage();
mcpPage.textSnapshot = await TextSnapshot.create(mcpPage);
const optionNode = [...mcpPage.textSnapshot.idToNode.values()].find(
node => node.role === 'option' && node.name === 'custom two',
);
assert.ok(optionNode);
await click.handler(
{
params: {
uid: optionNode.id,
},
page: mcpPage,
},
response,
context,
);
assert.strictEqual(
response.responseLines[0],
'Successfully clicked on the element',
);
assert.strictEqual(
await page.evaluate(() => document.body.dataset.clicked),
'custom two',
);
});
});
});
describe('hover', () => {