chromedevtools--chrome-devtools-mcp
8e90a92780
I split this PR off from my "create one DevTools universe per page" PR in preparation. This allows tests to re-use browser instances without creating an `McpContext`. Drive-by: Move mocked browser/page into utils.ts.
538 行
15 KiB
TypeScript
538 行
15 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 {
|
|
click,
|
|
hover,
|
|
fill,
|
|
drag,
|
|
fillForm,
|
|
uploadFile,
|
|
pressKey,
|
|
} 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.getSelectedPage();
|
|
await page.setContent(
|
|
html`<button onclick="this.innerText = 'clicked';">test</button>`,
|
|
);
|
|
await context.createTextSnapshot();
|
|
await click.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
},
|
|
},
|
|
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.getSelectedPage();
|
|
await page.setContent(
|
|
html`<button ondblclick="this.innerText = 'dblclicked';"
|
|
>test</button
|
|
>`,
|
|
);
|
|
await context.createTextSnapshot();
|
|
await click.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
dblClick: true,
|
|
},
|
|
},
|
|
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.getSelectedPage();
|
|
await page.goto(server.getRoute('/link'));
|
|
await context.createTextSnapshot();
|
|
const clickPromise = click.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
},
|
|
},
|
|
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.getSelectedPage();
|
|
await page.goto(server.getRoute('/unstable'));
|
|
await context.createTextSnapshot();
|
|
const handlerResolveTime = await click
|
|
.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
},
|
|
},
|
|
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');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('hover', () => {
|
|
it('hovers', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPage();
|
|
await page.setContent(
|
|
html`<button onmouseover="this.innerText = 'hovered';">test</button>`,
|
|
);
|
|
await context.createTextSnapshot();
|
|
await hover.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
},
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully hovered over the element',
|
|
);
|
|
assert.ok(response.includeSnapshot);
|
|
assert.ok(await page.$('text/hovered'));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('fill', () => {
|
|
it('fills out an input', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPage();
|
|
await page.setContent(html`<input />`);
|
|
await context.createTextSnapshot();
|
|
await fill.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
value: 'test',
|
|
},
|
|
},
|
|
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.getSelectedPage();
|
|
await page.setContent(
|
|
html`<select
|
|
><option value="v1">one</option
|
|
><option value="v2">two</option></select
|
|
>`,
|
|
);
|
|
await context.createTextSnapshot();
|
|
await fill.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
value: 'two',
|
|
},
|
|
},
|
|
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');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('drags', () => {
|
|
it('drags one element onto another', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPage();
|
|
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>`,
|
|
);
|
|
await context.createTextSnapshot();
|
|
await drag.handler(
|
|
{
|
|
params: {
|
|
from_uid: '1_1',
|
|
to_uid: '1_2',
|
|
},
|
|
},
|
|
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.getSelectedPage();
|
|
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>`,
|
|
);
|
|
await context.createTextSnapshot();
|
|
await fillForm.handler(
|
|
{
|
|
params: {
|
|
elements: [
|
|
{
|
|
uid: '1_2',
|
|
value: 'test',
|
|
},
|
|
{
|
|
uid: '1_4',
|
|
value: 'test2',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
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.getSelectedPage();
|
|
await page.setContent(
|
|
html`<form>
|
|
<input
|
|
type="file"
|
|
id="file-input"
|
|
/>
|
|
</form>`,
|
|
);
|
|
await context.createTextSnapshot();
|
|
await uploadFile.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
filePath: testFilePath,
|
|
},
|
|
},
|
|
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.getSelectedPage();
|
|
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>`,
|
|
);
|
|
await context.createTextSnapshot();
|
|
await uploadFile.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
filePath: testFilePath,
|
|
},
|
|
},
|
|
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.getSelectedPage();
|
|
await page.setContent(html`<div>Not a file input</div>`);
|
|
await context.createTextSnapshot();
|
|
|
|
await assert.rejects(
|
|
uploadFile.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
filePath: testFilePath,
|
|
},
|
|
},
|
|
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.getSelectedPage();
|
|
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>`,
|
|
);
|
|
await context.createTextSnapshot();
|
|
|
|
await pressKey.handler(
|
|
{
|
|
params: {
|
|
key: 'Control+Shift+C',
|
|
},
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.deepStrictEqual(await page.evaluate('logs'), [
|
|
'dControl',
|
|
'dShift',
|
|
'dC',
|
|
'uC',
|
|
'uShift',
|
|
'uControl',
|
|
]);
|
|
});
|
|
});
|
|
});
|
|
});
|