/** * @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(); let context: McpContext | undefined; export async function withBrowser( cb: (browser: Browser, page: Page) => Promise, options: { debug?: boolean; autoOpenDevTools?: boolean; executablePath?: string; args?: string[]; blockedUrlPattern?: string[]; allowedUrlPattern?: string[]; } = {}, ) { 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); } 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); } export async function withMcpContext( cb: (response: McpResponse, context: McpContext) => Promise, options: { debug?: boolean; autoOpenDevTools?: boolean; performanceCrux?: boolean; executablePath?: string; args?: string[]; blockedUrlPattern?: string[]; allowedUrlPattern?: string[]; allowUnrestrictedPaths?: boolean; } = {}, args: Partial = {}, ) { await withBrowser(async browser => { TextSnapshot.resetCounter(); 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; stableId?: number; navigationRequest?: boolean; frame?: Frame; redirectChain?: HTTPRequest[]; headers?: Record; } = {}, ): 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 { 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; } = {}, ): HTTPResponse { return { status() { return options.status ?? 200; }, headers(): Record { 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 ` My test page ${bodyContent} `; } 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 = {}; for (const [key, value] of Object.entries(content)) { if (key === 'snapshotFilePath' && typeof value === 'string') { result[key] = ''; } 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, ''); const localhostRegEx = /localhost:\d{5}/g; output = output.replaceAll(localhostRegEx, 'localhost:'); const userAgentRegEx = /user-agent:.*\n/g; output = output.replaceAll(userAgentRegEx, 'user-agent:\n'); const chUaRegEx = /sec-ch-ua:"Chromium";v="\d{3}"/g; output = output.replaceAll(chUaRegEx, 'sec-ch-ua:"Chromium";v=""'); // sec-ch-ua-platform:"Linux" const chUaPlatformRegEx = /sec-ch-ua-platform:"[a-zA-Z]*"/g; output = output.replaceAll(chUaPlatformRegEx, 'sec-ch-ua-platform:""'); const savedSnapshot = /Saved snapshot to (.*)/g; output = output.replaceAll(savedSnapshot, 'Saved snapshot to '); const acceptLanguageRegEx = /accept-language:.*\n/g; output = output.replaceAll(acceptLanguageRegEx, 'accept-language:\n'); // Stabilize URL-encoded file paths const fileUriRegEx = /file%3A%2F%2F%2F[^)\n]+/g; output = output.replaceAll(fileUriRegEx, ''); return output; } export function getMockAggregatedIssue(): sinon.SinonStubbedInstance { const mockAggregatedIssue = sinon.createStubInstance( DevTools.AggregatedIssue, ); mockAggregatedIssue.getAllIssues.returns([]); return mockAggregatedIssue; } export function mockListener() { const listeners: Record 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: ''}), }; 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, 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.`); }