Files
chrome-devtools-mcp/tests/utils.ts
Thomas Bachem ca6f834980 perf(memory): drop async parents of request initiators in NetworkCollector (#3345)
This drops `initiator.stack.parent` from every request
`NetworkCollector` keeps. The async parents get there through the
debugger `createTargetUniverse()` starts (see #3344), and no tool reads
them.

With the repro from #3344, the heap now levels off at 52 MB instead of
290 MB on `main`. On a Google Calendar tab, an initiator averaged 416
KB, of which 11 KB remain without the async parents.

No tool output changes, and console messages and errors still show their
async fragments. I edit the object `initiator()` returns rather than a
copy, since a copy wouldn't free anything. That relies on Puppeteer
handing out its own object, which the mocked test can't check.

Fixes #3344
2026-10-27 13:11:41 +00:00

611 lines
16 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import './setupDevTools.js';
import assert from 'node:assert';
import {spawn, type ChildProcess} from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type {TestContext} from 'node:test';
import type {Browser} from 'puppeteer';
import puppeteer, {Locator} from 'puppeteer';
import type {
Frame,
HTTPRequest,
HTTPResponse,
LaunchOptions,
Page,
Protocol,
Target,
} from 'puppeteer-core';
import sinon from 'sinon';
import type {ParsedArguments} from '../src/config/mcp-options.js';
import {stopDaemon} from '../src/daemon/client.js';
import {isDaemonRunning} from '../src/daemon/utils.js';
import {McpContext, type McpContextOptions} from '../src/McpContext.js';
import {McpResponse} from '../src/McpResponse.js';
import {HeapSnapshotManager} from '../src/processors/HeapSnapshotManager.js';
import {TaskRunner} from '../src/utils/TaskRunner.js';
import {TextSnapshot} from '../src/TextSnapshot.js';
import {DevTools, type CallToolResult} from '../src/third_party/index.js';
import {stableIdSymbol} from '../src/utils/id.js';
import {
createMockParsedArguments,
createMockPuppeteerPage,
createMockServerConfig,
mockListener,
stripUndefined,
} from './mocks.js';
export function assertNoServiceWorkerReported(
targets: Target[],
id: string,
): void {
const target = targets.find(target => {
return target.url().includes(id) && target.type() === 'service_worker';
});
assert.strictEqual(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): string {
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[];
} = {},
): Promise<void> {
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('Navigation timeout') ||
error.message.includes("Couldn't fetch install info") ||
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));
}
}
}
/**
* Creates an McpContext that is already connected to `browser`.
*/
export async function createConnectedMcpContext(
browser: Browser,
options: Omit<McpContextOptions, 'getBrowser' | 'taskRunner'> & {
taskRunner?: TaskRunner;
},
locatorClass?: typeof Locator,
): Promise<McpContext> {
const context = McpContext.create(
undefined,
{
taskRunner: new TaskRunner(),
...options,
getBrowser: async () => browser,
},
locatorClass,
);
await context.ensureBrowser();
return context;
}
export async function withMcpContext(
cb: (
response: McpResponse,
context: McpContext,
args: ParsedArguments,
) => Promise<void>,
options: {
debug?: boolean;
autoOpenDevTools?: boolean;
performanceCrux?: boolean;
sourceMaps?: boolean;
executablePath?: string;
args?: string[];
blockedUrlPattern?: string[];
allowedUrlPattern?: string[];
allowUnrestrictedPaths?: boolean;
navigationTimeout?: number;
} = {},
args: Partial<ParsedArguments> = {},
): Promise<void> {
await withBrowser(async browser => {
TextSnapshot.resetCounter();
McpContext.resetPageIdsForTesting();
McpContext.resetWorkerIdsForTesting();
const parsedArgs = createMockParsedArguments(args);
const serverConfig = createMockServerConfig({
...args,
...stripUndefined({
performanceCrux: options.performanceCrux,
sourceMaps: options.sourceMaps,
allowedUrlPattern: options.allowedUrlPattern,
blockedUrlPattern: options.blockedUrlPattern,
allowUnrestrictedPaths: options.allowUnrestrictedPaths,
}),
});
const response = new McpResponse(serverConfig);
const heapSnapshotManager = new HeapSnapshotManager(new TaskRunner());
if (context) {
context.dispose();
}
context = await createConnectedMcpContext(
browser,
{
config: serverConfig,
navigationTimeout:
options.navigationTimeout ??
(process.platform === 'win32' ? 20000 : undefined),
heapSnapshotManager,
},
Locator,
);
response.setPage(context.getSelectedMcpPage());
try {
await cb(response, context, parsedArgs);
} finally {
context.dispose();
heapSnapshotManager.dispose();
context = undefined;
}
}, 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>;
initiator?: Protocol.Network.Initiator;
} = {},
): 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);
},
initiator() {
return options.initiator;
},
[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): string {
if (typeof content === 'string') {
return stabilizeResponseOutput(content);
}
const stabilize = (c: unknown): unknown => {
if (typeof c === 'string') {
return stabilizeResponseOutput(c);
}
if (Array.isArray(c)) {
return c.map(item => stabilize(item));
}
if (typeof c === 'object' && c !== null) {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(c)) {
if (key === 'snapshotFilePath' && typeof value === 'string') {
result[key] = '<file>';
} else {
result[key] = stabilize(value);
}
}
return result;
}
return c;
};
return JSON.stringify(stabilize(content), null, 2);
}
export function stabilizeResponseOutput(text: unknown): string {
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 loopbackAddress = /127.0.0.1:\d{5}/g;
output = output.replaceAll(loopbackAddress, '127.0.0.1:<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 getMockBrowser(options?: {
process?: ChildProcess | null;
wsEndpoint?: string;
}): Browser {
const page = createMockPuppeteerPage();
const listener = mockListener();
page.on.callsFake((eventName, handler) => {
listener.on(eventName, handler);
return page;
});
page.off.callsFake((eventName, handler) => {
if (handler) {
listener.off(eventName, handler);
}
return page;
});
(page as unknown as {emit: typeof listener.emit}).emit = listener.emit;
const pages = [page as unknown as Page];
return {
process() {
return options?.process ?? null;
},
wsEndpoint() {
return options?.wsEndpoint ?? '';
},
pages() {
return Promise.resolve(pages);
},
...mockListener(),
} as Browser;
}
export const CLI_PATH: string = path.resolve(
'build/src/bin/chrome-devtools.js',
);
export async function runCli(
args: string[],
sessionId?: string,
options?: {cwd?: string},
): Promise<{status: number | null; stdout: string; stderr: string}> {
return await new Promise((resolve, reject) => {
const finalArgs = [...args];
if (sessionId) {
// Prepend so that the flag is not swallowed by a trailing `--`.
finalArgs.unshift('--sessionId', sessionId);
}
const child = spawn('node', [CLI_PATH, ...finalArgs], {
env: process.env,
cwd: options?.cwd,
});
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,
): Promise<void> {
const result = await runCli(['status'], sessionId);
assert.strictEqual(
result.stdout,
'chrome-devtools-mcp daemon is not running.\n',
);
}
/**
* Stops the daemon in-process. Use it for test cleanup: it does not spawn
* a CLI process, which takes about a second per call.
*/
export async function stopDaemonForCleanup(sessionId: string): Promise<void> {
await stopDaemon(sessionId);
assert.ok(!isDaemonRunning(sessionId), 'daemon is still running');
}
/**
* Creates a new CLI session ID for one test and stops its daemon after the
* test. Each test gets its own session, so tests can run concurrently.
*/
export function createCliSession(t: TestContext): string {
const sessionId = crypto.randomUUID();
t.after(async () => {
await stopDaemonForCleanup(sessionId);
});
return sessionId;
}
export async function assertDaemonIsRunning(sessionId?: string): Promise<void> {
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,
): Promise<void> {
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.`);
}
export function createTempDir(
prefix = 'chrome-devtools-test-',
baseDir: string = os.tmpdir(),
): {
path: string;
[Symbol.dispose]: () => void;
} {
const dirPath = fs.mkdtempSync(path.join(baseDir, prefix));
return {
path: dirPath,
[Symbol.dispose](): void {
try {
fs.rmSync(dirPath, {recursive: true, force: true});
} catch {
// ignore
}
},
};
}
export function createTempFile(
content: string,
fileName: string,
baseDir: string = os.tmpdir(),
): {
path: string;
[Symbol.dispose]: () => void;
} {
const dirPath = fs.mkdtempSync(
path.join(baseDir, 'chrome-devtools-test-file-'),
);
const filePath = path.join(dirPath, fileName);
fs.writeFileSync(filePath, content);
return {
path: filePath,
[Symbol.dispose](): void {
try {
fs.rmSync(dirPath, {recursive: true, force: true});
} catch {
// ignore
}
},
};
}