chromedevtools--chrome-devtools-mcp
b4546ef86b
release-please / release-please (push) Has been cancelled
Compile and run tests / Tests on macos-latest with node 22 (push) Has been cancelled
Compile and run tests / Tests on ubuntu-latest with node 22 (push) Has been cancelled
Compile and run tests / Tests on windows-latest with node 22 (push) Has been cancelled
Compile and run tests / Tests on macos-latest with node 24 (push) Has been cancelled
Compile and run tests / Tests on ubuntu-latest with node 24 (push) Has been cancelled
Compile and run tests / Tests on windows-latest with node 24 (push) Has been cancelled
Compile and run tests / Tests on macos-latest with node 26 (push) Has been cancelled
Compile and run tests / Tests on ubuntu-latest with node 26 (push) Has been cancelled
Compile and run tests / Tests on windows-latest with node 26 (push) Has been cancelled
Check code before submitting / [Required] Check correct format (push) Has been cancelled
Check code before submitting / [Required] Check docs updated (push) Has been cancelled
Compile and run tests / [Required] Tests passed (push) Has been cancelled
Assortment of various flakiness conditions found running tests in a loop locally: This PR introduces a comprehensive set of hermetic retry layers and aggressive timeout handlers across the test suite to insulate it from random Chromium startup hangs, CDP deadlocks, and Puppeteer lifecycle flakes. It guarantees that temporary browser infrastructure failures are automatically retried without failing the CI, while actual code assertion failures still fail fast. ### Test Harness & Retry Improvements • tests/utils.ts: Rewrote withBrowser to include a 30-second internal timeout and a 3-attempt retry loop. If Chromium locks up or disconnects (Target closed / socket hang up), the browser is forcibly evicted (via SIGKILL if browser.close() hangs) and the test setup is cleanly retried. • tests/index.test.ts: Wrapped withClient (used by E2E tests) in a 3-attempt retry loop to handle the daemon/Chromium hanging during launch and triggering the 60-second MCP client timeout. • tests/browser.test.ts: Added a safeClose helper that imposes a 2-second timeout before SIGKILLing browsers, and wrapped raw Puppeteer tests in runWithRetry to handle startup hangs. • tests/shutdown.test.ts: Added a setupServerWithRetry helper to prevent random 60s RPC timeouts when the server's Chrome instance hangs during boot. ### Flaky Operations & Navigation Fixes • src/tools/performance.ts & tests/tools/performance.test.ts: Replaced the notoriously flaky waitUntil: ['networkidle0'] with 'load' when navigating to about:blank in performance_start_trace. This prevents random 10-second Navigation timeout exceeded errors. Also stubbed goto in the associated unit tests for better hermeticity. • src/McpContext.ts: Wrapped browser.installExtension() with a 15-second timeout to prevent deadlocks when an extension fails to load. • tests/tools/extensions.test.ts: Removed flaky headless UI navigations to chrome://extensions in favor of using the context.listExtensions() API. • tests/tools/pages.test.js.snapshot: Synced test snapshots to reflect updated environment baselines.
411 行
11 KiB
TypeScript
411 行
11 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import assert from 'node:assert';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import {describe, it} from 'node:test';
|
|
|
|
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
|
|
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
import {
|
|
ListRootsRequestSchema,
|
|
RootsListChangedNotificationSchema,
|
|
type ClientCapabilities,
|
|
type TextContent,
|
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
import {executablePath} from 'puppeteer';
|
|
|
|
import type {ToolCategory} from '../src/tools/categories.js';
|
|
import {OFF_BY_DEFAULT_CATEGORIES} from '../src/tools/categories.js';
|
|
import type {ToolDefinition} from '../src/tools/ToolDefinition.js';
|
|
|
|
describe('e2e', () => {
|
|
async function withClient(
|
|
cb: (client: Client) => Promise<void>,
|
|
extraArgs: string[] = [],
|
|
options: {capabilities?: ClientCapabilities} = {},
|
|
) {
|
|
let attempt = 1;
|
|
while (attempt <= 3) {
|
|
const transport = new StdioClientTransport({
|
|
command: 'node',
|
|
args: [
|
|
'build/src/bin/chrome-devtools-mcp.js',
|
|
'--headless',
|
|
'--isolated',
|
|
'--executable-path',
|
|
await executablePath(),
|
|
...extraArgs,
|
|
],
|
|
env: {...process.env, CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'},
|
|
});
|
|
const client = new Client(
|
|
{
|
|
name: 'e2e-test',
|
|
version: '1.0.0',
|
|
},
|
|
{
|
|
capabilities: options.capabilities ?? {},
|
|
},
|
|
);
|
|
|
|
try {
|
|
await client.connect(transport);
|
|
await cb(client);
|
|
return;
|
|
} catch (error) {
|
|
if (
|
|
attempt === 3 ||
|
|
!(error instanceof Error) ||
|
|
(!error.message.includes('timed out') &&
|
|
!error.message.includes('timeout'))
|
|
) {
|
|
throw error;
|
|
}
|
|
attempt++;
|
|
await new Promise(r => setTimeout(r, 1000));
|
|
} finally {
|
|
try {
|
|
await client.close();
|
|
} catch {
|
|
// Ignore close errors
|
|
}
|
|
}
|
|
}
|
|
}
|
|
it('calls a tool', async t => {
|
|
await withClient(async client => {
|
|
const result = await client.callTool({
|
|
name: 'list_pages',
|
|
arguments: {},
|
|
});
|
|
t.assert.snapshot(JSON.stringify(result.content));
|
|
});
|
|
});
|
|
|
|
it('calls a tool multiple times', async t => {
|
|
await withClient(async client => {
|
|
let result = await client.callTool({
|
|
name: 'list_pages',
|
|
arguments: {},
|
|
});
|
|
result = await client.callTool({
|
|
name: 'list_pages',
|
|
arguments: {},
|
|
});
|
|
t.assert.snapshot(JSON.stringify(result.content));
|
|
});
|
|
});
|
|
|
|
it('has all tools with off by default categories', async () => {
|
|
await withClient(
|
|
async client => {
|
|
const {tools} = await client.listTools();
|
|
const exposedNames = tools.map(t => t.name).sort();
|
|
const definedNames = await getToolsWithFilteredCategories();
|
|
definedNames.sort();
|
|
assert.deepStrictEqual(exposedNames, definedNames);
|
|
},
|
|
OFF_BY_DEFAULT_CATEGORIES.map(category => `--category-${category}`),
|
|
);
|
|
});
|
|
|
|
it('has all tools', async () => {
|
|
await withClient(async client => {
|
|
const {tools} = await client.listTools();
|
|
const exposedNames = tools.map(t => t.name).sort();
|
|
const definedNames = await getToolsWithFilteredCategories(
|
|
OFF_BY_DEFAULT_CATEGORIES,
|
|
);
|
|
definedNames.sort();
|
|
assert.deepStrictEqual(exposedNames, definedNames);
|
|
});
|
|
});
|
|
|
|
it('has experimental third-party developer tools', async () => {
|
|
await withClient(
|
|
async client => {
|
|
const {tools} = await client.listTools();
|
|
const listThirdPartyDeveloperTools = tools.find(
|
|
t => t.name === 'list_3p_developer_tools',
|
|
);
|
|
assert.ok(listThirdPartyDeveloperTools);
|
|
},
|
|
['--category-experimental-third-party'],
|
|
);
|
|
});
|
|
|
|
it('has experimental extensions tools', async () => {
|
|
await withClient(
|
|
async client => {
|
|
const {tools} = await client.listTools();
|
|
const installExtension = tools.find(
|
|
t => t.name === 'install_extension',
|
|
);
|
|
assert.ok(installExtension);
|
|
},
|
|
['--category-extensions'],
|
|
);
|
|
});
|
|
|
|
it('has experimental vision tools', async () => {
|
|
await withClient(
|
|
async client => {
|
|
const {tools} = await client.listTools();
|
|
const clickAt = tools.find(t => t.name === 'click_at');
|
|
assert.ok(clickAt);
|
|
},
|
|
['--experimental-vision'],
|
|
);
|
|
});
|
|
|
|
it('has experimental interop tools', async () => {
|
|
await withClient(
|
|
async client => {
|
|
const {tools} = await client.listTools();
|
|
const getTabId = tools.find(t => t.name === 'get_tab_id');
|
|
assert.ok(getTabId);
|
|
},
|
|
['--experimental-interop-tools'],
|
|
);
|
|
});
|
|
|
|
it('has experimental webmcp', async () => {
|
|
await withClient(
|
|
async client => {
|
|
const {tools} = await client.listTools();
|
|
const listWebMcpTools = tools.find(t => t.name === 'list_webmcp_tools');
|
|
const executeWebMcpTool = tools.find(
|
|
t => t.name === 'execute_webmcp_tool',
|
|
);
|
|
assert.ok(listWebMcpTools);
|
|
assert.ok(executeWebMcpTool);
|
|
},
|
|
['--categoryExperimentalWebmcp'],
|
|
);
|
|
});
|
|
|
|
it('has memory debugging tools', async () => {
|
|
await withClient(
|
|
async client => {
|
|
const {tools} = await client.listTools();
|
|
const getHeapSnapshotSummary = tools.find(
|
|
t => t.name === 'get_heapsnapshot_summary',
|
|
);
|
|
assert.ok(getHeapSnapshotSummary);
|
|
},
|
|
['--memoryDebugging'],
|
|
);
|
|
});
|
|
|
|
it('updates roots when client notifies', async () => {
|
|
const roots = [{uri: 'file:///test-root', name: 'test-root'}];
|
|
let resolvePromise: () => void;
|
|
const promise = new Promise<void>(resolve => {
|
|
resolvePromise = resolve;
|
|
});
|
|
|
|
await withClient(
|
|
async client => {
|
|
client.setRequestHandler(ListRootsRequestSchema, () => {
|
|
resolvePromise();
|
|
return {roots};
|
|
});
|
|
|
|
await client.notification({
|
|
method: RootsListChangedNotificationSchema.shape.method.value,
|
|
});
|
|
|
|
// Wait for the server to process the notification and request roots
|
|
await promise;
|
|
},
|
|
[],
|
|
{
|
|
capabilities: {
|
|
roots: {listChanged: true},
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
it('denies file access if roots list is empty', async () => {
|
|
await withClient(
|
|
async client => {
|
|
client.setRequestHandler(ListRootsRequestSchema, () => {
|
|
return {roots: []};
|
|
});
|
|
|
|
const result = await client.callTool({
|
|
name: 'take_screenshot',
|
|
arguments: {
|
|
filePath: path.resolve(os.homedir(), 'test.png'),
|
|
},
|
|
});
|
|
|
|
assert.strictEqual(result.isError, true);
|
|
const content = result.content as TextContent[];
|
|
assert.match(content[0].text, /Access denied/);
|
|
},
|
|
[],
|
|
{
|
|
capabilities: {
|
|
roots: {listChanged: true},
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
it('allows file access if roots capability is missing', async () => {
|
|
await withClient(
|
|
async client => {
|
|
// Use os.tmpdir() rather than a hardcoded /tmp path.
|
|
// On macOS, os.tmpdir() returns /var/folders/... (not /tmp), so a
|
|
// hardcoded /tmp path is outside the allowed root after the
|
|
// validatePath fix and would be rejected with Access denied.
|
|
const result = await client.callTool({
|
|
name: 'take_screenshot',
|
|
arguments: {
|
|
filePath: path.join(os.tmpdir(), 'test.png'),
|
|
},
|
|
});
|
|
|
|
assert.strictEqual(result.isError, undefined);
|
|
const content = result.content as TextContent[];
|
|
assert.match(content[0].text, /Saved screenshot to/);
|
|
},
|
|
[],
|
|
{
|
|
capabilities: {},
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('Dialogs', () => {
|
|
async function createNewPageAndTriggerDialog(client: Client) {
|
|
// Navigate to a page with a button that triggers a dialog on click
|
|
await client.callTool({
|
|
name: 'new_page',
|
|
arguments: {
|
|
url: `data:text/html,<button id="test" onclick="alert('test dialog')">Click me</button>`,
|
|
},
|
|
});
|
|
|
|
const snapshotResult = await client.callTool({
|
|
name: 'take_snapshot',
|
|
arguments: {},
|
|
});
|
|
|
|
const snapshotText = (snapshotResult.content as TextContent[])[0].text;
|
|
const match = snapshotText.match(/uid=(\d+_\d+)\s+button "Click me"/);
|
|
const uid = match ? match[1] : '1_1';
|
|
|
|
// Trigger the dialog
|
|
const result = await client.callTool({
|
|
name: 'click',
|
|
arguments: {
|
|
uid,
|
|
},
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
it('returns blocked message when dialog is opened during tool execution', async t => {
|
|
await withClient(async client => {
|
|
const result = await createNewPageAndTriggerDialog(client);
|
|
t.assert.snapshot(JSON.stringify(result));
|
|
});
|
|
});
|
|
|
|
it('when dialog is open and tool is blocked, returns an error', async t => {
|
|
await withClient(async client => {
|
|
await createNewPageAndTriggerDialog(client);
|
|
const result = await client.callTool({
|
|
name: 'take_screenshot',
|
|
arguments: {
|
|
// Use os.tmpdir() so validatePath passes on macOS/Windows before
|
|
// reaching the dialog-blocked check.
|
|
filePath: path.join(os.tmpdir(), 'test.png'),
|
|
},
|
|
});
|
|
|
|
t.assert.snapshot(JSON.stringify(result));
|
|
});
|
|
});
|
|
|
|
it('when dialog is open and tool is not blocked, executes tool', async t => {
|
|
await withClient(async client => {
|
|
await createNewPageAndTriggerDialog(client);
|
|
const result = await client.callTool({
|
|
name: 'new_page',
|
|
arguments: {
|
|
url: `data:text/html,<h1>New</h1>`,
|
|
},
|
|
});
|
|
|
|
t.assert.snapshot(JSON.stringify(result));
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
async function getToolsWithFilteredCategories(
|
|
filterOutCategories: ToolCategory[] = [],
|
|
): Promise<string[]> {
|
|
const files = fs.readdirSync('build/src/tools');
|
|
const definedNames = [];
|
|
for (const file of files) {
|
|
if (
|
|
!file.endsWith('.js') ||
|
|
file === 'ToolDefinition.js' ||
|
|
file === 'tools.js' ||
|
|
file === 'slim'
|
|
) {
|
|
continue;
|
|
}
|
|
const fileTools = await import(`../src/tools/${file}`);
|
|
|
|
for (const maybeTool of Object.values<unknown>(fileTools)) {
|
|
let tool;
|
|
if (typeof maybeTool === 'function') {
|
|
tool = (maybeTool as (val: boolean) => ToolDefinition)(false);
|
|
} else {
|
|
tool = maybeTool as ToolDefinition;
|
|
}
|
|
|
|
// Skipping all files that are not tool files
|
|
if (tool === null || typeof tool !== 'object' || !('name' in tool)) {
|
|
continue;
|
|
}
|
|
|
|
if (toolShouldBeSkipped(tool, filterOutCategories)) {
|
|
continue;
|
|
}
|
|
definedNames.push(tool.name);
|
|
}
|
|
}
|
|
return definedNames;
|
|
}
|
|
|
|
function toolShouldBeSkipped(
|
|
tool: ToolDefinition,
|
|
filteredOutCategories: ToolCategory[],
|
|
) {
|
|
if (tool.annotations?.conditions) {
|
|
return true;
|
|
}
|
|
if (
|
|
tool.annotations?.category &&
|
|
filteredOutCategories.includes(tool.annotations?.category)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|