1415 lines
41 KiB
TypeScript
1415 lines
41 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 sinon from 'sinon';
|
|
|
|
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, getTextContent} from '../utils.js';
|
|
|
|
describe('input', () => {
|
|
const server = serverHooks();
|
|
|
|
describe('click', () => {
|
|
it('clicks', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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('reports the new URL when click triggers a navigation', async () => {
|
|
server.addHtmlRoute(
|
|
'/start',
|
|
html`<a href="/after-click">Navigate page</a>`,
|
|
);
|
|
server.addHtmlRoute('/after-click', html`<main>arrived</main>`);
|
|
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
await page.goto(server.getRoute('/start'));
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await click.handler(
|
|
{
|
|
params: {
|
|
uid: '1_2',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
const result = await response.handle('click', context);
|
|
const textContent = getTextContent(result.content[0]);
|
|
const expectedUrl = server.getRoute('/after-click');
|
|
assert.ok(
|
|
textContent.includes(`Page navigated to ${expectedUrl}.`),
|
|
`Expected response to mention navigation to ${expectedUrl}, got: ${textContent}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('does not report navigation when click does not navigate', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
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,
|
|
);
|
|
const result = await response.handle('click', context);
|
|
const textContent = getTextContent(result.content[0]);
|
|
assert.ok(
|
|
!textContent.includes('Page navigated to '),
|
|
`Did not expect a navigation line, got: ${textContent}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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_2', // 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_3', // 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',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('toggles checkboxes', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
await page.setContent(
|
|
html`<input
|
|
type="checkbox"
|
|
id="cb"
|
|
/>`,
|
|
);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
|
|
// Check it
|
|
await fill.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
value: 'true',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
assert.strictEqual(
|
|
response.responseLines[0],
|
|
'Successfully filled out the element',
|
|
);
|
|
assert.ok(response.includeSnapshot);
|
|
let isChecked = await page.$eval(
|
|
'#cb',
|
|
el => (el as HTMLInputElement).checked,
|
|
);
|
|
assert.strictEqual(isChecked, true);
|
|
|
|
// Uncheck it
|
|
await fill.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
value: 'false',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
new McpResponse({} as ParsedArguments),
|
|
context,
|
|
);
|
|
|
|
isChecked = await page.$eval(
|
|
'#cb',
|
|
el => (el as HTMLInputElement).checked,
|
|
);
|
|
assert.strictEqual(isChecked, false);
|
|
});
|
|
});
|
|
|
|
it('toggles switches', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
await page.setContent(html`
|
|
<div
|
|
role="switch"
|
|
aria-checked="false"
|
|
id="sw"
|
|
style="width: 20px; height: 20px; background: blue;"
|
|
onclick="this.setAttribute('aria-checked', this.getAttribute('aria-checked') === 'true' ? 'false' : 'true')"
|
|
>
|
|
switch
|
|
</div>
|
|
`);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
|
|
// Turn it on
|
|
await fill.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
value: 'true',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
let swChecked = await page.$eval(
|
|
'#sw',
|
|
el => el.getAttribute('aria-checked') === 'true',
|
|
);
|
|
assert.strictEqual(swChecked, true);
|
|
|
|
// Turn it off
|
|
await fill.handler(
|
|
{
|
|
params: {
|
|
uid: '1_1',
|
|
value: 'false',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
new McpResponse({} as ParsedArguments),
|
|
context,
|
|
);
|
|
|
|
swChecked = await page.$eval(
|
|
'#sw',
|
|
el => el.getAttribute('aria-checked') === 'true',
|
|
);
|
|
assert.strictEqual(swChecked, false);
|
|
});
|
|
});
|
|
|
|
it('selects radio buttons', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
await page.setContent(html`
|
|
<input
|
|
type="radio"
|
|
name="group1"
|
|
id="r1"
|
|
checked
|
|
/>
|
|
<input
|
|
type="radio"
|
|
name="group1"
|
|
id="r2"
|
|
/>
|
|
`);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
|
|
// Initial state
|
|
let r1Checked = await page.$eval(
|
|
'#r1',
|
|
el => (el as HTMLInputElement).checked,
|
|
);
|
|
let r2Checked = await page.$eval(
|
|
'#r2',
|
|
el => (el as HTMLInputElement).checked,
|
|
);
|
|
assert.strictEqual(r1Checked, true);
|
|
assert.strictEqual(r2Checked, false);
|
|
|
|
// Fill second radio with true
|
|
await fill.handler(
|
|
{
|
|
params: {
|
|
uid: '1_2',
|
|
value: 'true',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
r1Checked = await page.$eval(
|
|
'#r1',
|
|
el => (el as HTMLInputElement).checked,
|
|
);
|
|
r2Checked = await page.$eval(
|
|
'#r2',
|
|
el => (el as HTMLInputElement).checked,
|
|
);
|
|
assert.strictEqual(r1Checked, false);
|
|
assert.strictEqual(r2Checked, true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('drags', () => {
|
|
it('drags one element onto another', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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_3',
|
|
value: 'test',
|
|
},
|
|
{
|
|
uid: '1_5',
|
|
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'],
|
|
);
|
|
});
|
|
});
|
|
|
|
it('fill_form handles checkboxes', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
await page.setContent(
|
|
html`<input
|
|
name="username"
|
|
type="text"
|
|
/><input
|
|
name="cb"
|
|
type="checkbox"
|
|
/>`,
|
|
);
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await fillForm.handler(
|
|
{
|
|
params: {
|
|
elements: [
|
|
{
|
|
uid: '1_1',
|
|
value: 'test',
|
|
},
|
|
{
|
|
uid: '1_2',
|
|
value: 'true',
|
|
},
|
|
],
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
assert.strictEqual(
|
|
await page.evaluate(() => {
|
|
// @ts-expect-error missing types
|
|
return document.querySelector('input[name=username]').value;
|
|
}),
|
|
'test',
|
|
);
|
|
assert.strictEqual(
|
|
await page.evaluate(() => {
|
|
// @ts-expect-error missing types
|
|
return document.querySelector('input[name=cb]').checked;
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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_2',
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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',
|
|
]);
|
|
});
|
|
});
|
|
|
|
it('releases held modifiers when the main key press fails', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
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(),
|
|
);
|
|
|
|
// Simulate the main key press failing mid-sequence (e.g. a CDP
|
|
// hiccup) after the modifiers have already been pressed down.
|
|
sinon
|
|
.stub(page.keyboard, 'press')
|
|
.throws(new Error('injected press failure'));
|
|
|
|
try {
|
|
await assert.rejects(
|
|
pressKey.handler(
|
|
{
|
|
params: {
|
|
key: 'Control+Shift+C',
|
|
},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
),
|
|
);
|
|
} finally {
|
|
sinon.restore();
|
|
}
|
|
|
|
// The modifiers were pressed down; both must be released even though
|
|
// the main key press threw, otherwise the browser is left with the
|
|
// modifiers logically stuck down.
|
|
assert.deepStrictEqual(await page.evaluate('logs'), [
|
|
'dControl',
|
|
'dShift',
|
|
'uShift',
|
|
'uControl',
|
|
]);
|
|
});
|
|
});
|
|
});
|
|
});
|