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.
212 行
6.2 KiB
TypeScript
212 行
6.2 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import assert from 'node:assert';
|
|
import type {ChildProcessByStdio} from 'node:child_process';
|
|
import {spawn} from 'node:child_process';
|
|
import type {Readable, Writable} from 'node:stream';
|
|
import {describe, it} from 'node:test';
|
|
|
|
import {executablePath} from 'puppeteer';
|
|
|
|
type Server = ChildProcessByStdio<Writable, Readable, Readable>;
|
|
|
|
// Once shutdown is signalled, the server should be fully gone within this
|
|
// budget. The actual fast path is well under 500ms; the budget is set to be
|
|
// generous against CI noise without being so loose that it would hide a hang.
|
|
const SHUTDOWN_BUDGET_MS = 10000;
|
|
// Outer test timeout. If exit doesn't happen within this, treat as a hang
|
|
// (the bug we're guarding against) and SIGKILL the subprocess.
|
|
const EXIT_TIMEOUT_MS = 15000;
|
|
|
|
async function spawnServer(): Promise<Server> {
|
|
const child = spawn(
|
|
'node',
|
|
[
|
|
'build/src/bin/chrome-devtools-mcp.js',
|
|
'--headless',
|
|
'--isolated',
|
|
'--executable-path',
|
|
await executablePath(),
|
|
],
|
|
{
|
|
env: {
|
|
...process.env,
|
|
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
|
|
},
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
},
|
|
) as Server;
|
|
// Drain stderr to avoid pipe-buffer backpressure stalling the server.
|
|
child.stderr.on('data', () => {
|
|
// discard
|
|
});
|
|
// Drain stdout to avoid pipe-buffer backpressure stalling the server during shutdown.
|
|
child.stdout.on('data', () => {
|
|
// discard
|
|
});
|
|
return child;
|
|
}
|
|
|
|
async function waitForExit(
|
|
child: Server,
|
|
timeoutMs: number,
|
|
): Promise<{
|
|
code: number | null;
|
|
signal: NodeJS.Signals | null;
|
|
elapsedMs: number;
|
|
}> {
|
|
const start = Date.now();
|
|
return await new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
child.kill('SIGKILL');
|
|
reject(new Error(`server did not exit within ${timeoutMs}ms`));
|
|
}, timeoutMs);
|
|
child.once('exit', (code, signal) => {
|
|
clearTimeout(timer);
|
|
resolve({code, signal, elapsedMs: Date.now() - start});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function rpc(
|
|
child: Server,
|
|
msg: {method: string; params?: unknown},
|
|
): Promise<unknown> {
|
|
const id = Math.floor(Math.random() * 1e9);
|
|
const payload = JSON.stringify({jsonrpc: '2.0', id, ...msg}) + '\n';
|
|
return await new Promise((resolve, reject) => {
|
|
let buf = '';
|
|
const onData = (chunk: Buffer) => {
|
|
buf += chunk.toString();
|
|
const lines = buf.split('\n');
|
|
buf = lines.pop() ?? '';
|
|
for (const line of lines) {
|
|
if (!line.trim()) {
|
|
continue;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(line) as {id?: number};
|
|
if (parsed.id === id) {
|
|
clearTimeout(timer);
|
|
child.stdout.off('data', onData);
|
|
child.off('exit', onExit);
|
|
resolve(parsed);
|
|
return;
|
|
}
|
|
} catch {
|
|
// Not a JSON message; ignore.
|
|
}
|
|
}
|
|
};
|
|
const timer = setTimeout(() => {
|
|
child.stdout.off('data', onData);
|
|
child.off('exit', onExit);
|
|
reject(
|
|
new Error(
|
|
`RPC timeout: no response for method ${msg.method} within 60000ms`,
|
|
),
|
|
);
|
|
}, 60000);
|
|
|
|
child.stdout.on('data', onData);
|
|
const onExit = () => {
|
|
clearTimeout(timer);
|
|
child.stdout.off('data', onData);
|
|
reject(new Error('server exited before RPC response'));
|
|
};
|
|
child.once('exit', onExit);
|
|
child.stdin.write(payload);
|
|
});
|
|
}
|
|
|
|
function notify(child: Server, msg: {method: string; params?: unknown}): void {
|
|
child.stdin.write(JSON.stringify({jsonrpc: '2.0', ...msg}) + '\n');
|
|
}
|
|
|
|
async function initializeAndLaunchBrowser(child: Server): Promise<void> {
|
|
await rpc(child, {
|
|
method: 'initialize',
|
|
params: {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {},
|
|
clientInfo: {name: 'shutdown-test', version: '0.0.1'},
|
|
},
|
|
});
|
|
notify(child, {method: 'notifications/initialized'});
|
|
// list_pages forces a real Chrome launch — this is what reproduces
|
|
// the hang in #2116. Without an active Chrome subprocess, stdin EOF
|
|
// would close the event loop on its own and shutdown would look fine
|
|
// even with broken handlers.
|
|
await rpc(child, {
|
|
method: 'tools/call',
|
|
params: {
|
|
name: 'list_pages',
|
|
arguments: {},
|
|
},
|
|
});
|
|
}
|
|
|
|
async function setupServerWithRetry(): Promise<Server> {
|
|
let lastError: Error | undefined;
|
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
const child = await spawnServer();
|
|
try {
|
|
await initializeAndLaunchBrowser(child);
|
|
return child;
|
|
} catch (e) {
|
|
lastError = e as Error;
|
|
// If setup failed (e.g., Chrome hung on launch), kill the child and try again.
|
|
child.kill('SIGKILL');
|
|
// Wait briefly for OS cleanup.
|
|
await new Promise(r => setTimeout(r, 500));
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
describe('shutdown', () => {
|
|
it('exits within budget on stdin EOF after Chrome launch', async () => {
|
|
const child = await setupServerWithRetry();
|
|
child.stdin.end();
|
|
const {elapsedMs} = await waitForExit(child, EXIT_TIMEOUT_MS);
|
|
assert.ok(
|
|
elapsedMs < SHUTDOWN_BUDGET_MS,
|
|
`stdin-EOF shutdown took ${elapsedMs}ms (budget ${SHUTDOWN_BUDGET_MS}ms)`,
|
|
);
|
|
});
|
|
|
|
it('exits within budget on SIGTERM after Chrome launch', async () => {
|
|
const child = await setupServerWithRetry();
|
|
child.kill('SIGTERM');
|
|
const {elapsedMs} = await waitForExit(child, EXIT_TIMEOUT_MS);
|
|
assert.ok(
|
|
elapsedMs < SHUTDOWN_BUDGET_MS,
|
|
`SIGTERM shutdown took ${elapsedMs}ms (budget ${SHUTDOWN_BUDGET_MS}ms)`,
|
|
);
|
|
});
|
|
|
|
it('exits within budget on SIGINT after Chrome launch', async () => {
|
|
const child = await setupServerWithRetry();
|
|
child.kill('SIGINT');
|
|
const {elapsedMs} = await waitForExit(child, EXIT_TIMEOUT_MS);
|
|
assert.ok(
|
|
elapsedMs < SHUTDOWN_BUDGET_MS,
|
|
`SIGINT shutdown took ${elapsedMs}ms (budget ${SHUTDOWN_BUDGET_MS}ms)`,
|
|
);
|
|
});
|
|
|
|
it('exits within budget on SIGHUP after Chrome launch', async () => {
|
|
const child = await setupServerWithRetry();
|
|
child.kill('SIGHUP');
|
|
const {elapsedMs} = await waitForExit(child, EXIT_TIMEOUT_MS);
|
|
assert.ok(
|
|
elapsedMs < SHUTDOWN_BUDGET_MS,
|
|
`SIGHUP shutdown took ${elapsedMs}ms (budget ${SHUTDOWN_BUDGET_MS}ms)`,
|
|
);
|
|
});
|
|
});
|