chromedevtools--chrome-devtools-mcp
510ec0f48b
## 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.
1078 行
32 KiB
TypeScript
1078 行
32 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import assert from 'node:assert';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import {describe, it} from 'node:test';
|
|
|
|
import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js';
|
|
import {McpResponse} from '../../src/McpResponse.js';
|
|
import {TextSnapshot} from '../../src/TextSnapshot.js';
|
|
import {
|
|
click,
|
|
hover,
|
|
fill,
|
|
drag,
|
|
fillForm,
|
|
uploadFile,
|
|
pressKey,
|
|
clickAt,
|
|
typeText,
|
|
} from '../../src/tools/input.js';
|
|
import {parseKey} from '../../src/utils/keyboard.js';
|
|
import {serverHooks} from '../server.js';
|
|
import {html, withMcpContext} from '../utils.js';
|
|
|
|
describe('input', () => {
|
|
const server = serverHooks();
|
|
|
|
describe('click', () => {
|
|
it('clicks', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(
|
|
html`<button onclick="this.innerText = 'clicked';">test</button>`,
|
|
);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await click.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully clicked on the element',
|
|
);
|
|
assert.ok(response.includeSnapshot);
|
|
assert.ok(await page.$('text/clicked'));
|
|
});
|
|
});
|
|
it('double clicks', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(
|
|
html`<button ondblclick="this.innerText = 'dblclicked';"
|
|
>test</button
|
|
>`,
|
|
);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await click.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
dblClick: true,
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully double clicked on the element',
|
|
);
|
|
assert.ok(response.includeSnapshot);
|
|
assert.ok(await page.$('text/dblclicked'));
|
|
});
|
|
});
|
|
it('waits for navigation', async () => {
|
|
const resolveNavigation = Promise.withResolvers<void>();
|
|
server.addHtmlRoute(
|
|
'/link',
|
|
html`<a href="/navigated">Navigate page</a>`,
|
|
);
|
|
server.addRoute('/navigated', async (_req, res) => {
|
|
await resolveNavigation.promise;
|
|
res.write(html`<main>I was navigated</main>`);
|
|
res.end();
|
|
});
|
|
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.goto(server.getRoute('/link'));
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
const clickPromise = click.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
const [t1, t2] = await Promise.all([
|
|
clickPromise.then(() => Date.now()),
|
|
new Promise<number>(res => {
|
|
setTimeout(() => {
|
|
resolveNavigation.resolve();
|
|
res(Date.now());
|
|
}, 300);
|
|
}),
|
|
]);
|
|
|
|
assert(t1 > t2, 'Waited for navigation');
|
|
});
|
|
});
|
|
|
|
it('waits for stable DOM', async () => {
|
|
server.addHtmlRoute(
|
|
'/unstable',
|
|
html`
|
|
<button>Click to change to see time</button>
|
|
<script>
|
|
const button = document.querySelector('button');
|
|
button.addEventListener('click', () => {
|
|
setTimeout(() => {
|
|
button.textContent = Date.now();
|
|
}, 50);
|
|
});
|
|
</script>
|
|
`,
|
|
);
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.goto(server.getRoute('/unstable'));
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
const handlerResolveTime = await click
|
|
.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
)
|
|
.then(() => Date.now());
|
|
const buttonChangeTime = await page.evaluate(() => {
|
|
const button = document.querySelector('button');
|
|
return Number(button?.textContent);
|
|
});
|
|
|
|
assert(handlerResolveTime > buttonChangeTime, 'Waited for navigation');
|
|
});
|
|
});
|
|
|
|
it('does not include snapshot by default', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(
|
|
html`<button onclick="this.innerText = 'clicked';">test</button>`,
|
|
);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await click.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully clicked on the element',
|
|
);
|
|
assert.strictEqual(response.snapshotParams, undefined);
|
|
});
|
|
});
|
|
|
|
it('includes snapshot if includeSnapshot is true', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(
|
|
html`<button onclick="this.innerText = 'clicked';">test</button>`,
|
|
);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await click.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
includeSnapshot: true,
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully clicked on the element',
|
|
);
|
|
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', () => {
|
|
it('hovers', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(
|
|
html`<button onmouseover="this.innerText = 'hovered';">test</button>`,
|
|
);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await hover.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully hovered over the element',
|
|
);
|
|
assert.ok(response.includeSnapshot);
|
|
assert.ok(await page.$('text/hovered'));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('click_at', () => {
|
|
it('clicks at coordinates', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(
|
|
html`<div
|
|
style="width: 100px; height: 100px; background: red;"
|
|
onclick="this.innerText = 'clicked'"
|
|
></div>`,
|
|
);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await clickAt.handler(
|
|
{
|
|
params: {
|
|
x: 50,
|
|
y: 50,
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully clicked at the coordinates',
|
|
);
|
|
assert.ok(response.includeSnapshot);
|
|
assert.ok(await page.$('text/clicked'));
|
|
});
|
|
});
|
|
|
|
it('double clicks at coordinates', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(
|
|
html`<div
|
|
style="width: 100px; height: 100px; background: red;"
|
|
ondblclick="this.innerText = 'dblclicked'"
|
|
></div>`,
|
|
);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await clickAt.handler(
|
|
{
|
|
params: {
|
|
x: 50,
|
|
y: 50,
|
|
dblClick: true,
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully double clicked at the coordinates',
|
|
);
|
|
assert.ok(response.includeSnapshot);
|
|
assert.ok(await page.$('text/dblclicked'));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('fill', () => {
|
|
it('fills out an input', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(html`<input />`);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await fill.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
value: 'test',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully filled out the element',
|
|
);
|
|
assert.ok(response.includeSnapshot);
|
|
assert.ok(await page.$('text/test'));
|
|
});
|
|
});
|
|
|
|
it('fills out a select by text', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(
|
|
html`<select
|
|
><option value="v1">one</option
|
|
><option value="v2">two</option></select
|
|
>`,
|
|
);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await fill.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
value: 'two',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully filled out the element',
|
|
);
|
|
assert.ok(response.includeSnapshot);
|
|
const selectedValue = await page.evaluate(
|
|
() => document.querySelector('select')!.value,
|
|
);
|
|
assert.strictEqual(selectedValue, 'v2');
|
|
});
|
|
});
|
|
|
|
it('fills out a textarea marked as combobox', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(html`<textarea role="combobox"></textarea>`);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await fill.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
value: '1',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully filled out the element',
|
|
);
|
|
assert.ok(response.includeSnapshot);
|
|
assert.ok(
|
|
await page.evaluate(() => {
|
|
return document.body.querySelector('textarea')?.value === '1';
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('fills out a textarea with long text', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(html`<textarea></textarea>`);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
page.setDefaultTimeout(1000);
|
|
await fill.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
value: '1'.repeat(3000),
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully filled out the element',
|
|
);
|
|
assert.ok(response.includeSnapshot);
|
|
assert.ok(
|
|
await page.evaluate(() => {
|
|
return (
|
|
document.body.querySelector('textarea')?.value.length === 3_000
|
|
);
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('types text', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(html`<textarea></textarea>`);
|
|
await page.click('textarea');
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await typeText.handler(
|
|
{
|
|
params: {
|
|
text: 'test',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(response.responseLines[0], 'Typed text "test"');
|
|
assert.strictEqual(
|
|
await page.evaluate(() => {
|
|
return document.body.querySelector('textarea')?.value;
|
|
}),
|
|
'test',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('types text with submit key', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(html`<textarea></textarea>`);
|
|
await page.click('textarea');
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await typeText.handler(
|
|
{
|
|
params: {
|
|
text: 'test',
|
|
submitKey: 'Tab',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Typed text "test + Tab"',
|
|
);
|
|
assert.strictEqual(
|
|
await page.evaluate(() => {
|
|
return document.body.querySelector('textarea')?.value;
|
|
}),
|
|
'test',
|
|
);
|
|
assert.ok(
|
|
await page.evaluate(() => {
|
|
return (
|
|
document.body.querySelector('textarea') !== document.activeElement
|
|
);
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('errors on invalid submit key', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(html`<textarea></textarea>`);
|
|
await page.click('textarea');
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
try {
|
|
await typeText.handler(
|
|
{
|
|
params: {
|
|
text: 'test',
|
|
submitKey: 'XXX',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
} catch (err) {
|
|
assert.strictEqual(err.message, 'Unknown key: "XXX"');
|
|
}
|
|
});
|
|
});
|
|
|
|
it('reproduction: fill isolation', async () => {
|
|
await withMcpContext(async (_response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(
|
|
html`<form>
|
|
<input
|
|
id="email"
|
|
value="user@test.com"
|
|
/>
|
|
<input
|
|
id="password"
|
|
type="password"
|
|
/>
|
|
</form>`,
|
|
);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
|
|
// Fill email
|
|
const response1 = new McpResponse({} as ParsedArguments);
|
|
await fill.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1', // email input
|
|
value: 'new@test.com',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response1,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
response1.responseLines[0],
|
|
'Successfully filled out the element',
|
|
);
|
|
|
|
// Fill password
|
|
const response2 = new McpResponse({} as ParsedArguments);
|
|
await fill.handler(
|
|
{
|
|
params: {
|
|
uid: '1_2', // password input
|
|
value: 'secret',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response2,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
response2.responseLines[0],
|
|
'Successfully filled out the element',
|
|
);
|
|
|
|
// Verify values
|
|
const values = await page.evaluate(() => {
|
|
return {
|
|
email: (document.getElementById('email') as HTMLInputElement).value,
|
|
password: (document.getElementById('password') as HTMLInputElement)
|
|
.value,
|
|
};
|
|
});
|
|
|
|
assert.strictEqual(
|
|
values.email,
|
|
'new@test.com',
|
|
'Email should be updated correctly',
|
|
);
|
|
assert.strictEqual(
|
|
values.password,
|
|
'secret',
|
|
'Password should be updated correctly',
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('drags', () => {
|
|
it('drags one element onto another', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(
|
|
html`<div
|
|
role="button"
|
|
id="drag"
|
|
draggable="true"
|
|
>drag me</div
|
|
>
|
|
<div
|
|
id="drop"
|
|
aria-label="drop"
|
|
style="width: 100px; height: 100px; border: 1px solid black;"
|
|
ondrop="this.innerText = 'dropped';"
|
|
>
|
|
</div>
|
|
<script>
|
|
drag.addEventListener('dragstart', event => {
|
|
event.dataTransfer.setData('text/plain', event.target.id);
|
|
});
|
|
drop.addEventListener('dragover', event => {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'move';
|
|
});
|
|
drop.addEventListener('drop', event => {
|
|
event.preventDefault();
|
|
const data = event.dataTransfer.getData('text/plain');
|
|
event.target.appendChild(document.getElementById(data));
|
|
});
|
|
</script>`,
|
|
);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await drag.handler(
|
|
{
|
|
params: {
|
|
from_uid: '1_1',
|
|
to_uid: '1_2',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.ok(response.includeSnapshot);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully dragged an element',
|
|
);
|
|
assert.ok(await page.$('text/dropped'));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('fill form', () => {
|
|
it('successfully fills out the form', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(
|
|
html`<form>
|
|
<label
|
|
>username<input
|
|
name="username"
|
|
type="text"
|
|
/></label>
|
|
<label
|
|
>email<input
|
|
name="email"
|
|
type="text"
|
|
/></label>
|
|
<input
|
|
type="submit"
|
|
value="Submit"
|
|
/>
|
|
</form>`,
|
|
);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await fillForm.handler(
|
|
{
|
|
params: {
|
|
elements: [
|
|
{
|
|
uid: '1_2',
|
|
value: 'test',
|
|
},
|
|
{
|
|
uid: '1_4',
|
|
value: 'test2',
|
|
},
|
|
],
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.ok(response.includeSnapshot);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully filled out the form',
|
|
);
|
|
assert.deepStrictEqual(
|
|
await page.evaluate(() => {
|
|
return [
|
|
// @ts-expect-error missing types
|
|
document.querySelector('input[name=username]').value,
|
|
// @ts-expect-error missing types
|
|
document.querySelector('input[name=email]').value,
|
|
];
|
|
}),
|
|
['test', 'test2'],
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('uploadFile', () => {
|
|
it('uploads a file to a file input', async () => {
|
|
const testFilePath = path.join(process.cwd(), 'test.txt');
|
|
await fs.writeFile(testFilePath, 'test file content');
|
|
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(
|
|
html`<form>
|
|
<input
|
|
type="file"
|
|
id="file-input"
|
|
/>
|
|
</form>`,
|
|
);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await uploadFile.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
filePath: testFilePath,
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.ok(response.includeSnapshot);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
`File uploaded from ${testFilePath}.`,
|
|
);
|
|
});
|
|
|
|
await fs.unlink(testFilePath);
|
|
});
|
|
|
|
it('uploads a file when clicking an element opens a file uploader', async () => {
|
|
const testFilePath = path.join(process.cwd(), 'test.txt');
|
|
await fs.writeFile(testFilePath, 'test file content');
|
|
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(
|
|
html`<button id="file-chooser-button">Upload file</button>
|
|
<input
|
|
type="file"
|
|
id="file-input"
|
|
style="display: none;"
|
|
/>
|
|
<script>
|
|
document
|
|
.getElementById('file-chooser-button')
|
|
.addEventListener('click', () => {
|
|
document.getElementById('file-input').click();
|
|
});
|
|
</script>`,
|
|
);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await uploadFile.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
filePath: testFilePath,
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.ok(response.includeSnapshot);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
`File uploaded from ${testFilePath}.`,
|
|
);
|
|
const uploadedFileName = await page.$eval('#file-input', el => {
|
|
const input = el as HTMLInputElement;
|
|
return input.files?.[0]?.name;
|
|
});
|
|
assert.strictEqual(uploadedFileName, 'test.txt');
|
|
|
|
await fs.unlink(testFilePath);
|
|
});
|
|
});
|
|
|
|
it('throws an error if the element is not a file input and does not open a file chooser', async () => {
|
|
const testFilePath = path.join(process.cwd(), 'test.txt');
|
|
await fs.writeFile(testFilePath, 'test file content');
|
|
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(html`<div>Not a file input</div>`);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
|
|
await assert.rejects(
|
|
uploadFile.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
filePath: testFilePath,
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
),
|
|
{
|
|
message:
|
|
'Failed to upload file. The element could not accept the file directly, and clicking it did not trigger a file chooser.',
|
|
},
|
|
);
|
|
|
|
assert.strictEqual(response.responseLines.length, 0);
|
|
assert.strictEqual(response.snapshotParams, undefined);
|
|
|
|
await fs.unlink(testFilePath);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('press_key', () => {
|
|
it('parses keys', () => {
|
|
assert.deepStrictEqual(parseKey('Shift+A'), ['A', 'Shift']);
|
|
assert.deepStrictEqual(parseKey('Shift++'), ['+', 'Shift']);
|
|
assert.deepStrictEqual(parseKey('Control+Shift++'), [
|
|
'+',
|
|
'Control',
|
|
'Shift',
|
|
]);
|
|
assert.deepStrictEqual(parseKey('Shift'), ['Shift']);
|
|
assert.deepStrictEqual(parseKey('KeyA'), ['KeyA']);
|
|
});
|
|
it('throws on empty key', () => {
|
|
assert.throws(() => {
|
|
parseKey('');
|
|
});
|
|
});
|
|
it('throws on invalid key', () => {
|
|
assert.throws(() => {
|
|
parseKey('aaaaa');
|
|
});
|
|
});
|
|
it('throws on multiple keys', () => {
|
|
assert.throws(() => {
|
|
parseKey('Shift+Shift');
|
|
});
|
|
});
|
|
|
|
it('processes press_key', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.setContent(
|
|
html`<script>
|
|
logs = [];
|
|
document.addEventListener('keydown', e => logs.push('d' + e.key));
|
|
document.addEventListener('keyup', e => logs.push('u' + e.key));
|
|
</script>`,
|
|
);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
|
|
await pressKey.handler(
|
|
{
|
|
params: {
|
|
key: 'Control+Shift+C',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.deepStrictEqual(await page.evaluate('logs'), [
|
|
'dControl',
|
|
'dShift',
|
|
'dC',
|
|
'uC',
|
|
'uShift',
|
|
'uControl',
|
|
]);
|
|
});
|
|
});
|
|
});
|
|
});
|