项目文件夹

文件
Alex Rudenko 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
test: fix potential flakiness in tests (#2396)
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.
2026-07-21 13:47:47 +00:00

474 行
13 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {spawn} from 'node:child_process';
import path from 'node:path';
import type {CallToolResult} from '@modelcontextprotocol/sdk/types.js';
import logger from 'debug';
import type {Browser} from 'puppeteer';
import puppeteer, {Locator} from 'puppeteer';
import type {
Frame,
HTTPRequest,
HTTPResponse,
LaunchOptions,
Page,
Target,
} from 'puppeteer-core';
import sinon from 'sinon';
import type {ParsedArguments} from '../src/bin/chrome-devtools-mcp-cli-options.js';
import {McpContext} from '../src/McpContext.js';
import {McpResponse} from '../src/McpResponse.js';
import {TextSnapshot} from '../src/TextSnapshot.js';
import {DevTools} from '../src/third_party/index.js';
import {stableIdSymbol} from '../src/utils/id.js';
export function assertNoServiceWorkerReported(targets: Target[], id: string) {
const target = targets.find(target => {
return target.url().includes(id) && target.type() === 'service_worker';
});
assert(target === undefined);
}
export function getTextContent(
content: CallToolResult['content'][number],
): string {
if (content.type === 'text') {
return content.text;
}
throw new Error(`Expected text content but got ${content.type}`);
}
export function getImageContent(content: CallToolResult['content'][number]): {
data: string;
mimeType: string;
} {
if (content.type === 'image') {
return {data: content.data, mimeType: content.mimeType};
}
throw new Error(`Expected image content but got ${content.type}`);
}
export function extractExtensionId(response: McpResponse) {
const responseLine = response.responseLines[0];
assert.ok(responseLine, 'Response should not be empty');
const match = responseLine.match(/Extension installed\. Id: (.+)/);
const extensionId = match ? match[1] : null;
assert.ok(extensionId, 'Response should contain a valid key');
return extensionId;
}
const browsers = new Map<string, Browser>();
let context: McpContext | undefined;
export async function withBrowser(
cb: (browser: Browser, page: Page) => Promise<void>,
options: {
debug?: boolean;
autoOpenDevTools?: boolean;
executablePath?: string;
args?: string[];
blockedUrlPattern?: string[];
allowedUrlPattern?: string[];
} = {},
) {
let attempt = 1;
while (attempt <= 3) {
const launchOptions: LaunchOptions = {
executablePath:
options.executablePath ?? process.env.PUPPETEER_EXECUTABLE_PATH,
headless: !options.debug,
defaultViewport: null,
devtools: options.autoOpenDevTools ?? false,
pipe: true,
handleDevToolsAsPage: true,
args: [...(options.args || []), '--screen-info={3840x2160}'],
enableExtensions: true,
blocklist: options.blockedUrlPattern,
allowlist: options.allowedUrlPattern,
};
const key = JSON.stringify(launchOptions);
let browser = browsers.get(key);
if (!browser) {
browser = await puppeteer.launch(launchOptions);
browsers.set(key, browser);
}
try {
await Promise.race([
(async () => {
const newPage = await browser.newPage();
// Close other pages.
await Promise.all(
(await browser.pages()).map(async page => {
if (page !== newPage) {
await page.close();
}
}),
);
await cb(browser, newPage);
})(),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('withBrowser timeout exceeded')),
60000,
),
),
]);
return;
} catch (error) {
browsers.delete(key);
try {
await Promise.race([
browser.close(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('browser.close timeout')), 2000),
),
]);
} catch {
browser.process()?.kill('SIGKILL');
}
const isRetryable =
error instanceof Error &&
(error.message === 'withBrowser timeout exceeded' ||
error.message.includes('closed') ||
error.message.includes('crash') ||
error.message.includes('hang'));
if (attempt === 3 || !isRetryable) {
throw error;
}
attempt++;
await new Promise(r => setTimeout(r, 1000));
}
}
}
export async function withMcpContext(
cb: (response: McpResponse, context: McpContext) => Promise<void>,
options: {
debug?: boolean;
autoOpenDevTools?: boolean;
performanceCrux?: boolean;
executablePath?: string;
args?: string[];
blockedUrlPattern?: string[];
allowedUrlPattern?: string[];
allowUnrestrictedPaths?: boolean;
} = {},
args: Partial<ParsedArguments> = {},
) {
await withBrowser(async browser => {
TextSnapshot.resetCounter();
McpContext.resetPageIdsForTesting();
const response = new McpResponse(args as ParsedArguments);
if (context) {
context.dispose();
}
context = await McpContext.from(
browser,
logger('test'),
{
experimentalDevToolsDebugging: false,
performanceCrux: options.performanceCrux ?? true,
allowList: options.allowedUrlPattern,
blocklist: options.blockedUrlPattern,
allowUnrestrictedPaths: options.allowUnrestrictedPaths ?? false,
},
Locator,
);
response.setPage(context.getSelectedMcpPage());
await cb(response, context);
}, options);
}
export function getMockRequest(
options: {
url?: string;
method?: string;
response?: HTTPResponse;
failure?: HTTPRequest['failure'];
resourceType?: string;
hasPostData?: boolean;
postData?: string;
fetchPostData?: Promise<string>;
stableId?: number;
navigationRequest?: boolean;
frame?: Frame;
redirectChain?: HTTPRequest[];
headers?: Record<string, string>;
} = {},
): HTTPRequest {
return {
url() {
return options.url ?? 'http://example.com';
},
method() {
return options.method ?? 'GET';
},
fetchPostData() {
return options.fetchPostData ?? Promise.reject();
},
hasPostData() {
return options.hasPostData ?? false;
},
postData() {
return options.postData;
},
response() {
return options.response ?? null;
},
failure() {
return options.failure?.() ?? null;
},
resourceType() {
return options.resourceType ?? 'document';
},
headers(): Record<string, string> {
return (
options.headers ?? {
'content-size': '10',
}
);
},
redirectChain(): HTTPRequest[] {
// Puppeteer returns a fresh copy on every call (HTTPRequest returns
// `this._redirectChain.slice()`); mirror that so formatters can't share
// and accidentally mutate the same array across calls.
return [...(options.redirectChain ?? [])];
},
isNavigationRequest() {
return options.navigationRequest ?? false;
},
frame() {
return options.frame ?? ({} as Frame);
},
[stableIdSymbol]: options.stableId ?? 1,
} as unknown as HTTPRequest;
}
export function getMockResponse(
options: {
status?: number;
headers?: Record<string, string>;
} = {},
): HTTPResponse {
return {
status() {
return options.status ?? 200;
},
headers(): Record<string, string> {
return options.headers ?? {};
},
} as unknown as HTTPResponse;
}
export function html(
strings: TemplateStringsArray,
...values: unknown[]
): string {
const bodyContent = strings.reduce((acc, str, i) => {
return acc + str + (values[i] || '');
}, '');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My test page</title>
</head>
<body>
${bodyContent}
</body>
</html>`;
}
export function stabilizeStructuredContent(content: unknown): unknown {
if (typeof content === 'string') {
return stabilizeResponseOutput(content);
}
if (Array.isArray(content)) {
return content.map(item => stabilizeStructuredContent(item));
}
if (typeof content === 'object' && content !== null) {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(content)) {
if (key === 'snapshotFilePath' && typeof value === 'string') {
result[key] = '<file>';
} else {
result[key] = stabilizeStructuredContent(value);
}
}
return result;
}
return content;
}
export function stabilizeResponseOutput(text: unknown) {
if (typeof text !== 'string') {
throw new Error('Input must be string');
}
let output = text;
const dateRegEx = /.{3}, \d{2} .{3} \d{4} \d{2}:\d{2}:\d{2} [A-Z]{3}/g;
output = output.replaceAll(dateRegEx, '<long date>');
const localhostRegEx = /localhost:\d{5}/g;
output = output.replaceAll(localhostRegEx, 'localhost:<port>');
const userAgentRegEx = /user-agent:.*\n/g;
output = output.replaceAll(userAgentRegEx, 'user-agent:<user-agent>\n');
const chUaRegEx = /sec-ch-ua:"Chromium";v="\d{3}"/g;
output = output.replaceAll(chUaRegEx, 'sec-ch-ua:"Chromium";v="<version>"');
// sec-ch-ua-platform:"Linux"
const chUaPlatformRegEx = /sec-ch-ua-platform:"[a-zA-Z]*"/g;
output = output.replaceAll(chUaPlatformRegEx, 'sec-ch-ua-platform:"<os>"');
const savedSnapshot = /Saved snapshot to (.*)/g;
output = output.replaceAll(savedSnapshot, 'Saved snapshot to <file>');
const acceptLanguageRegEx = /accept-language:.*\n/g;
output = output.replaceAll(acceptLanguageRegEx, 'accept-language:<lang>\n');
// Stabilize URL-encoded file paths
const fileUriRegEx = /file%3A%2F%2F%2F[^)\n]+/g;
output = output.replaceAll(fileUriRegEx, '<file-path>');
return output;
}
export function getMockAggregatedIssue(): sinon.SinonStubbedInstance<DevTools.AggregatedIssue> {
const mockAggregatedIssue = sinon.createStubInstance(
DevTools.AggregatedIssue,
);
mockAggregatedIssue.getAllIssues.returns([]);
return mockAggregatedIssue;
}
export function mockListener() {
const listeners: Record<string, Array<(data: unknown) => void>> = {};
return {
on(eventName: string, listener: (data: unknown) => void) {
if (listeners[eventName]) {
listeners[eventName].push(listener);
} else {
listeners[eventName] = [listener];
}
},
off(_eventName: string, _listener: (data: unknown) => void) {
// no-op
},
emit(eventName: string, data: unknown) {
for (const listener of listeners[eventName] ?? []) {
listener(data);
}
},
};
}
export function getMockPage(): Page {
const mainFrame = {} as Frame;
const cdpSession = {
...mockListener(),
send: () => {
// no-op
},
target: () => ({_targetId: '<mock target ID>'}),
};
return {
mainFrame() {
return mainFrame;
},
...mockListener(),
// @ts-expect-error internal API.
_client() {
return cdpSession;
},
} satisfies Page;
}
export function getMockBrowser(): Browser {
const pages = [getMockPage()];
return {
pages() {
return Promise.resolve(pages);
},
...mockListener(),
} as Browser;
}
export const CLI_PATH = path.resolve('build/src/bin/chrome-devtools.js');
export async function runCli(
args: string[],
sessionId?: string,
): Promise<{status: number | null; stdout: string; stderr: string}> {
return new Promise((resolve, reject) => {
const finalArgs = [...args];
if (sessionId) {
finalArgs.push('--sessionId', sessionId);
}
const child = spawn('node', [CLI_PATH, ...finalArgs], {
env: process.env,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', chunk => {
stdout += chunk;
process.stdout.write(chunk);
});
child.stderr.on('data', chunk => {
stderr += chunk;
process.stderr.write(chunk);
});
child.on('close', status => resolve({status, stdout, stderr}));
child.on('error', reject);
});
}
export async function assertDaemonIsNotRunning(sessionId?: string) {
const result = await runCli(['status'], sessionId);
assert.strictEqual(
result.stdout,
'chrome-devtools-mcp daemon is not running.\n',
);
}
export async function assertDaemonIsRunning(sessionId?: string) {
const result = await runCli(['status'], sessionId);
assert.ok(
result.stdout.startsWith('chrome-devtools-mcp daemon is running.\n'),
'chrome-devtools-mcp daemon is not running',
);
}
export async function waitExecutionFor(
func: () => Promise<void>,
timeout: number,
) {
const start = Date.now();
while (Date.now() - start < 10000) {
try {
await func();
return;
} catch {
await new Promise(resolve => setTimeout(resolve, 100)); // wait and retry
}
}
throw new Error(`Timeout of ${timeout} reached.`);
}