Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/tools/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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,
});
Expand Down
139 changes: 139 additions & 0 deletions tests/tools/input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down