diff --git a/src/tools/input.ts b/src/tools/input.ts index 697048779..e00ddb9af 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -42,6 +42,49 @@ function handleActionError(error: unknown, uid: string) { ); } +async function selectNativeSelectOption(handle: ElementHandle) { + 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 | 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, }); diff --git a/tests/tools/input.test.ts b/tests/tools/input.test.ts index b0033ec4d..637241c38 100644 --- a/tests/tools/input.test.ts +++ b/tests/tools/input.test.ts @@ -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``, + ); + 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``, + ); + 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`
+
+ custom two +
+
`, + ); + 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', () => {