chromedevtools--chrome-devtools-mcp
e046c23563
Improvements for handling in-page tool responses. In order to successfully pass an in-page tool response from the page context to the MCP server, the response needs to be serializable. The code walks the response object an performs the following changes: - DOM elements are stashed onto the window object and replaced with an ID. On the MCP server side this ID is used to map back to the corresponding UID in the page snapshot generated from the accessibility tree. - Circular references are replaced with a string. - Class instances (which can be complex or non-serializable) are replaced with a string. - Functions are replaced with a string. If the in-page tool response contains DOM elements which are not part of the page snapshot, a new snapshot is created add the missing elements are added explicitly.
389 行
12 KiB
TypeScript
389 行
12 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import assert from 'node:assert';
|
|
import path from 'node:path';
|
|
import {describe, it} from 'node:test';
|
|
|
|
import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js';
|
|
import {TextSnapshot} from '../../src/TextSnapshot.js';
|
|
import {installExtension} from '../../src/tools/extensions.js';
|
|
import {evaluateScript} from '../../src/tools/script.js';
|
|
import {serverHooks} from '../server.js';
|
|
import {
|
|
assertNoServiceWorkerReported,
|
|
extractExtensionId,
|
|
html,
|
|
withMcpContext,
|
|
} from '../utils.js';
|
|
|
|
const EXTENSION_PATH = path.join(
|
|
import.meta.dirname,
|
|
'../../../tests/tools/fixtures/extension-sw',
|
|
);
|
|
|
|
describe('script', () => {
|
|
const server = serverHooks();
|
|
|
|
describe('browser_evaluate_script', () => {
|
|
it('evaluates', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await evaluateScript().handler(
|
|
{
|
|
params: {function: String(() => 2 * 5)},
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
const lineEvaluation = response.responseLines.at(2)!;
|
|
assert.strictEqual(JSON.parse(lineEvaluation), 10);
|
|
});
|
|
});
|
|
it('runs in selected page', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
await evaluateScript().handler(
|
|
{
|
|
params: {function: String(() => document.title)},
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
let lineEvaluation = response.responseLines.at(2)!;
|
|
assert.strictEqual(JSON.parse(lineEvaluation), '');
|
|
|
|
const page = await context.newPage();
|
|
await page.pptrPage.setContent(`
|
|
<head>
|
|
<title>New Page</title>
|
|
</head>
|
|
`);
|
|
|
|
response.resetResponseLineForTesting();
|
|
await evaluateScript().handler(
|
|
{
|
|
params: {function: String(() => document.title)},
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
lineEvaluation = response.responseLines.at(2)!;
|
|
assert.strictEqual(JSON.parse(lineEvaluation), 'New Page');
|
|
});
|
|
});
|
|
|
|
it('work for complex objects', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
|
|
await page.setContent(html`<script src="./scripts.js"></script> `);
|
|
|
|
await evaluateScript().handler(
|
|
{
|
|
params: {
|
|
function: String(() => {
|
|
const scripts = Array.from(
|
|
document.head.querySelectorAll('script'),
|
|
).map(s => ({src: s.src, async: s.async, defer: s.defer}));
|
|
|
|
return {scripts};
|
|
}),
|
|
},
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
const lineEvaluation = response.responseLines.at(2)!;
|
|
assert.deepEqual(JSON.parse(lineEvaluation), {
|
|
scripts: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
it('work for scripts that trigger dialogs', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
|
|
await page.setContent(html`<button id="test">test</button>`);
|
|
|
|
await evaluateScript().handler(
|
|
{
|
|
params: {
|
|
function: String(() => {
|
|
alert('hello');
|
|
return 'Works';
|
|
}),
|
|
},
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
const lineEvaluation = response.responseLines.at(2)!;
|
|
assert.strictEqual(JSON.parse(lineEvaluation), 'Works');
|
|
});
|
|
});
|
|
|
|
it('work for scripts that trigger dialogs and dismiss them', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
|
|
await page.setContent(html`<button id="test">test</button>`);
|
|
|
|
await evaluateScript().handler(
|
|
{
|
|
params: {
|
|
function: String(() => {
|
|
return confirm('hello');
|
|
}),
|
|
dialogAction: 'dismiss',
|
|
},
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
const lineEvaluation = response.responseLines.at(2)!;
|
|
assert.strictEqual(JSON.parse(lineEvaluation), false);
|
|
});
|
|
});
|
|
|
|
it('work for scripts that trigger prompts and fill them', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
|
|
await page.setContent(html`<button id="test">test</button>`);
|
|
|
|
await evaluateScript().handler(
|
|
{
|
|
params: {
|
|
function: String(() => {
|
|
return prompt('Enter your name:');
|
|
}),
|
|
dialogAction: 'John Doe',
|
|
},
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
const lineEvaluation = response.responseLines.at(2)!;
|
|
assert.strictEqual(JSON.parse(lineEvaluation), 'John Doe');
|
|
});
|
|
});
|
|
|
|
it('work for async functions', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
|
|
await page.setContent(html`<script src="./scripts.js"></script> `);
|
|
|
|
await evaluateScript().handler(
|
|
{
|
|
params: {
|
|
function: String(async () => {
|
|
await new Promise(res => setTimeout(res, 0));
|
|
return 'Works';
|
|
}),
|
|
},
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
const lineEvaluation = response.responseLines.at(2)!;
|
|
assert.strictEqual(JSON.parse(lineEvaluation), 'Works');
|
|
});
|
|
});
|
|
|
|
it('work with one argument', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
|
|
await page.setContent(html`<button id="test">test</button>`);
|
|
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
|
|
await evaluateScript().handler(
|
|
{
|
|
params: {
|
|
function: String(async (el: Element) => {
|
|
return el.id;
|
|
}),
|
|
args: ['1_1'],
|
|
},
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
const lineEvaluation = response.responseLines.at(2)!;
|
|
assert.strictEqual(JSON.parse(lineEvaluation), 'test');
|
|
});
|
|
});
|
|
|
|
it('work with multiple args', async () => {
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
|
|
await page.setContent(html`<button id="test">test</button>`);
|
|
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
|
|
await evaluateScript().handler(
|
|
{
|
|
params: {
|
|
function: String((container: Element, child: Element) => {
|
|
return container.contains(child);
|
|
}),
|
|
args: ['1_0', '1_1'],
|
|
},
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
const lineEvaluation = response.responseLines.at(2)!;
|
|
assert.strictEqual(JSON.parse(lineEvaluation), true);
|
|
});
|
|
});
|
|
|
|
it('work for elements inside iframes', async () => {
|
|
server.addHtmlRoute(
|
|
'/iframe',
|
|
html`<main><button>I am iframe button</button></main>`,
|
|
);
|
|
server.addHtmlRoute('/main', html`<iframe src="/iframe"></iframe>`);
|
|
|
|
await withMcpContext(async (response, context) => {
|
|
const page = context.getSelectedPptrPage();
|
|
await page.goto(server.getRoute('/main'));
|
|
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
|
|
context.getSelectedMcpPage(),
|
|
);
|
|
await evaluateScript().handler(
|
|
{
|
|
params: {
|
|
function: String((element: Element) => {
|
|
return element.textContent;
|
|
}),
|
|
args: ['1_3'],
|
|
},
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
const lineEvaluation = response.responseLines.at(2)!;
|
|
assert.strictEqual(JSON.parse(lineEvaluation), 'I am iframe button');
|
|
});
|
|
});
|
|
it('evaluates inside extension service worker', async () => {
|
|
await withMcpContext(
|
|
async (response, context) => {
|
|
await installExtension.handler(
|
|
{params: {path: EXTENSION_PATH}},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const extensionId = extractExtensionId(response);
|
|
const swTarget = await context.browser.waitForTarget(
|
|
t => t.type() === 'service_worker' && t.url().includes(extensionId),
|
|
);
|
|
|
|
await context.createExtensionServiceWorkersSnapshot();
|
|
const swList = context.getExtensionServiceWorkers();
|
|
const sw = swList.find(s => s.target === swTarget);
|
|
|
|
if (!sw) {
|
|
assert.fail('Service worker not found in context list');
|
|
}
|
|
|
|
const swId = context.getExtensionServiceWorkerId(sw);
|
|
|
|
response.resetResponseLineForTesting();
|
|
await evaluateScript({
|
|
categoryExtensions: true,
|
|
} as ParsedArguments).handler(
|
|
{
|
|
params: {
|
|
function: String(() => {
|
|
return 'chrome' in globalThis ? 'has-chrome' : 'no-chrome';
|
|
}),
|
|
serviceWorkerId: swId,
|
|
},
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
|
|
const lineEvaluation = response.responseLines.at(2)!;
|
|
assert.strictEqual(JSON.parse(lineEvaluation), 'has-chrome');
|
|
await context.uninstallExtension(extensionId);
|
|
const targets = context.browser.targets();
|
|
assertNoServiceWorkerReported(targets, extensionId);
|
|
},
|
|
{},
|
|
{categoryExtensions: true} as ParsedArguments,
|
|
);
|
|
});
|
|
|
|
it('throws error when both pageId and serviceWorkerId are provided', async () => {
|
|
await withMcpContext(
|
|
async (response, context) => {
|
|
await assert.rejects(
|
|
evaluateScript({
|
|
categoryExtensions: true,
|
|
} as ParsedArguments).handler(
|
|
{
|
|
params: {
|
|
function: String(() => 'test'),
|
|
serviceWorkerId: 'example_service_worker',
|
|
pageId: '1',
|
|
},
|
|
},
|
|
response,
|
|
context,
|
|
),
|
|
{
|
|
message: 'specify either a pageId or a serviceWorkerId.',
|
|
},
|
|
);
|
|
},
|
|
{},
|
|
{categoryExtensions: true} as ParsedArguments,
|
|
);
|
|
});
|
|
|
|
it('throws error when args are provided with serviceWorkerId', async () => {
|
|
await withMcpContext(
|
|
async (response, context) => {
|
|
await assert.rejects(
|
|
evaluateScript({
|
|
categoryExtensions: true,
|
|
} as ParsedArguments).handler(
|
|
{
|
|
params: {
|
|
function: String(() => 'test'),
|
|
serviceWorkerId: 'example_service_worker',
|
|
args: ['1_1'],
|
|
},
|
|
},
|
|
response,
|
|
context,
|
|
),
|
|
{
|
|
message:
|
|
'args (element uids) cannot be used when evaluating in a service worker.',
|
|
},
|
|
);
|
|
},
|
|
{},
|
|
{categoryExtensions: true} as ParsedArguments,
|
|
);
|
|
});
|
|
});
|
|
});
|