diff --git a/scripts/test.mjs b/scripts/test.mjs index 7a00c1151..9fc0ef438 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -52,6 +52,7 @@ const nodeArgs = [ '--test-reporter', (process.env['NODE_TEST_REPORTER'] ?? process.env['CI']) ? 'spec' : 'dot', '--test-force-exit', + '--test-concurrency=1', '--test', '--test-timeout=60000', ...flags, diff --git a/src/bin/chrome-devtools.ts b/src/bin/chrome-devtools.ts index 2b9160b06..a34e2f8cd 100644 --- a/src/bin/chrome-devtools.ts +++ b/src/bin/chrome-devtools.ts @@ -11,6 +11,7 @@ import process from 'node:process'; import yargs, {type Options, type PositionalOptions} from 'yargs'; import {hideBin} from 'yargs/helpers'; +import {parseArguments} from '../cli.js'; import { startDaemon, stopDaemon, @@ -18,6 +19,7 @@ import { handleResponse, } from '../daemon/client.js'; import {isDaemonRunning} from '../daemon/utils.js'; +import {logDisclaimers} from '../server.js'; import type {CallToolResult} from '../third_party/index.js'; import {VERSION} from '../version.js'; @@ -31,6 +33,12 @@ if (argv.length === 0 || argv[0] === '--custom-help') { process.exit(0); } +async function start(args: string[]) { + const combinedArgs = [...args, ...defaultArgs]; + await startDaemon([...args, ...defaultArgs]); + logDisclaimers(parseArguments(VERSION, combinedArgs)); +} + const defaultArgs = ['--viaCli', '--experimentalStructuredContent']; const y = yargs(argv) @@ -44,7 +52,14 @@ const y = yargs(argv) y.command( 'start', 'Start or restart chrome-devtools-mcp', - y => y.help(false), // Disable help for start command to avoid parsing issues with passed args + y => + y + .help(false) // Disable help for start command to avoid parsing issues with passed args. + .example( + '$0 start --port 8080 --url http://localhost:8080', + 'Start the server on port 8080 with a specific URL', + ) + .strict(false), // Don't validate arguments for start, as they are passed through to the daemon. async () => { if (isDaemonRunning()) { await stopDaemon(); @@ -52,20 +67,27 @@ y.command( // Extract args after 'start' const startIndex = process.argv.indexOf('start'); const args = startIndex !== -1 ? process.argv.slice(startIndex + 1) : []; - await startDaemon([...args, ...defaultArgs]); + await start(args); + process.exit(0); }, -); +).strict(); // Re-enable strict validation for other commands; this is applied to the yargs instance itself y.command('status', 'Checks if chrome-devtools-mcp is running', async () => { if (isDaemonRunning()) { console.log('chrome-devtools-mcp daemon is running.'); } else { - console.log('chrome-devtools-mcp daemon is not running'); + console.log('chrome-devtools-mcp daemon is not running.'); } + process.exit(0); }); y.command('stop', 'Stop chrome-devtools-mcp if any', async () => { + if (!isDaemonRunning()) { + process.exit(0); + return; + } await stopDaemon(); + process.exit(0); }); for (const [commandName, commandDef] of Object.entries(commands)) { @@ -127,7 +149,7 @@ for (const [commandName, commandDef] of Object.entries(commands)) { async argv => { try { if (!isDaemonRunning()) { - await startDaemon(defaultArgs); + await start([]); } const commandArgs: Record = {}; diff --git a/src/daemon/client.ts b/src/daemon/client.ts index de18d26d9..3400268c2 100644 --- a/src/daemon/client.ts +++ b/src/daemon/client.ts @@ -9,7 +9,6 @@ import fs from 'node:fs'; import net from 'node:net'; import {logger} from '../logger.js'; -import {START_INDICATOR} from '../server.js'; import type {CallToolResult} from '../third_party/index.js'; import {PipeTransport} from '../third_party/index.js'; @@ -21,12 +20,29 @@ import { isDaemonRunning, } from './utils.js'; +const FILE_TIMEOUT = 10_000; + /** - * Waits for a file to be created and populated. + * Waits for a file to be created and populated (removed = false) or removed (removed = true). */ -function waitForFile(filePath: string, timeout = 5000) { +function waitForFile(filePath: string, removed = false) { return new Promise((resolve, reject) => { - if (fs.existsSync(filePath) && fs.statSync(filePath).size > 0) { + const check = () => { + const exists = fs.existsSync(filePath); + if (removed) { + return !exists; + } + if (!exists) { + return false; + } + try { + return fs.statSync(filePath).size > 0; + } catch { + return false; + } + }; + + if (check()) { resolve(); return; } @@ -34,14 +50,16 @@ function waitForFile(filePath: string, timeout = 5000) { const timer = setTimeout(() => { fs.unwatchFile(filePath); reject( - new Error(`Timeout: file ${filePath} not found within ${timeout}ms`), + new Error( + `Timeout: file ${filePath} ${removed ? 'not removed' : 'not found'} within ${FILE_TIMEOUT}ms`, + ), ); - }, timeout); + }, FILE_TIMEOUT); - fs.watchFile(filePath, {interval: 500}, curr => { - if (curr.size > 0) { + fs.watchFile(filePath, {interval: 500}, () => { + if (check()) { clearTimeout(timer); - fs.unwatchFile(filePath); // Always clean up your listeners! + fs.unwatchFile(filePath); resolve(); } }); @@ -54,38 +72,22 @@ export async function startDaemon(mcpArgs: string[] = []) { return; } + const pidFilePath = getPidFilePath(); + + if (fs.existsSync(pidFilePath)) { + fs.unlinkSync(pidFilePath); + } + logger('Starting daemon...'); const child = spawn(process.execPath, [DAEMON_SCRIPT_PATH, ...mcpArgs], { detached: true, - stdio: ['ignore', 'ignore', 'pipe'], + stdio: 'ignore', + env: process.env, cwd: process.cwd(), }); + child.unref(); - await new Promise((resolve, reject) => { - child.on('error', err => { - reject(err); - }); - child.on('exit', code => { - logger(`Child exited with code ${code}`); - reject(new Error(`Daemon process exited prematurely with code ${code}`)); - }); - - waitForFile(getPidFilePath()).then(resolve).catch(reject); - }); - - logger(`Pid file found ${getPidFilePath()}`); - - child.stderr.pipe(process.stderr); - await new Promise(resolve => { - child.stderr.on('data', data => { - if (data.toString().includes(START_INDICATOR)) { - child.stderr.unpipe(process.stderr); - child.stderr.destroy(); - child.unref(); - resolve(); - } - }); - }); + await waitForFile(pidFilePath); } const SEND_COMMAND_TIMEOUT = 60_000; // ms @@ -135,7 +137,11 @@ export async function stopDaemon() { return; } + const pidFilePath = getPidFilePath(); + await sendCommand({method: 'stop'}); + + await waitForFile(pidFilePath, /*removed=*/ true); } export function handleResponse( diff --git a/src/main.ts b/src/main.ts index 4ae3f7586..4f97418d2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,7 +10,7 @@ import process from 'node:process'; import {cliOptions, parseArguments} from './cli.js'; import {logger, saveLogsToFile} from './logger.js'; -import {createMcpServer, START_INDICATOR} from './server.js'; +import {createMcpServer, logDisclaimers} from './server.js'; import {computeFlagUsage} from './telemetry/flagUtils.js'; import {StdioServerTransport} from './third_party/index.js'; import {VERSION} from './version.js'; @@ -35,37 +35,12 @@ if (process.env['CHROME_DEVTOOLS_MCP_CRASH_ON_UNCAUGHT'] !== 'true') { } logger(`Starting Chrome DevTools MCP Server v${VERSION}`); - -const logDisclaimers = () => { - console.error( - `chrome-devtools-mcp exposes content of the browser instance to the MCP clients allowing them to inspect, -debug, and modify any data in the browser or DevTools. -Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`, - ); - - if (!args.slim && args.performanceCrux) { - console.error( - `Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data. To disable, run with --no-performance-crux.`, - ); - } - - if (!args.slim && args.usageStatistics) { - console.error( - ` -Google collects usage statistics to improve Chrome DevTools MCP. To opt-out, run with --no-usage-statistics. -For more details, visit: https://github.com/ChromeDevTools/chrome-devtools-mcp#usage-statistics`, - ); - } - - console.error(START_INDICATOR); -}; - const {server, clearcutLogger} = await createMcpServer(args, { logFile, }); const transport = new StdioServerTransport(); await server.connect(transport); logger('Chrome DevTools MCP Server connected'); -logDisclaimers(); +logDisclaimers(args); void clearcutLogger?.logDailyActiveIfNeeded(); void clearcutLogger?.logServerStart(computeFlagUsage(args, cliOptions)); diff --git a/src/server.ts b/src/server.ts index def4a83dd..91690a57a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -257,4 +257,24 @@ export async function createMcpServer( return {server, clearcutLogger}; } -export const START_INDICATOR = 'Server started.'; +export const logDisclaimers = (args: ReturnType) => { + console.error( + `chrome-devtools-mcp exposes content of the browser instance to the MCP clients allowing them to inspect, +debug, and modify any data in the browser or DevTools. +Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`, + ); + + if (!args.slim && args.performanceCrux) { + console.error( + `Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data. To disable, run with --no-performance-crux.`, + ); + } + + if (!args.slim && args.usageStatistics) { + console.error( + ` +Google collects usage statistics to improve Chrome DevTools MCP. To opt-out, run with --no-usage-statistics. +For more details, visit: https://github.com/ChromeDevTools/chrome-devtools-mcp#usage-statistics`, + ); + } +}; diff --git a/tests/daemon/client.test.ts b/tests/daemon/client.test.ts index 1ec9a7adb..1a11fcff7 100644 --- a/tests/daemon/client.test.ts +++ b/tests/daemon/client.test.ts @@ -5,7 +5,7 @@ */ import assert from 'node:assert'; -import {describe, it, afterEach} from 'node:test'; +import {describe, it, afterEach, beforeEach} from 'node:test'; import { handleResponse, @@ -16,12 +16,12 @@ import {isDaemonRunning} from '../../src/daemon/utils.js'; describe('daemon client', () => { describe('start/stop', () => { + beforeEach(async () => { + await stopDaemon(); + }); + afterEach(async () => { - if (isDaemonRunning()) { - await stopDaemon(); - // Wait a bit for the daemon to fully terminate and clean up its files. - await new Promise(resolve => setTimeout(resolve, 1000)); - } + await stopDaemon(); }); it('should start and stop daemon', async () => { @@ -31,7 +31,6 @@ describe('daemon client', () => { assert.ok(isDaemonRunning(), 'Daemon should be running after start'); await stopDaemon(); - await new Promise(resolve => setTimeout(resolve, 1000)); assert.ok(!isDaemonRunning(), 'Daemon should not be running after stop'); }); @@ -54,12 +53,12 @@ describe('daemon client', () => { }); describe('parsing', () => { - it('handles MCP response with text format', () => { + it('handles MCP response with text format', async () => { const textResponse = {content: [{type: 'text' as const, text: 'test'}]}; assert.strictEqual(handleResponse(textResponse, 'text'), 'test'); }); - it('handles JSON response', () => { + it('handles JSON response', async () => { const jsonResponse = { content: [], structuredContent: { @@ -73,7 +72,7 @@ describe('daemon client', () => { ); }); - it('handles error response when isError is true', () => { + it('handles error response when isError is true', async () => { const errorResponse = { isError: true, content: [{type: 'text' as const, text: 'Something went wrong'}], @@ -84,7 +83,7 @@ describe('daemon client', () => { ); }); - it('handles text response when json format is requested but no structured content', () => { + it('handles text response when json format is requested but no structured content', async () => { const textResponse = { content: [{type: 'text' as const, text: 'Fall through text'}], }; @@ -94,7 +93,7 @@ describe('daemon client', () => { ); }); - it('throws error for unsupported content type', () => { + it('throws error for unsupported content type', async () => { const unsupportedContentResponse = { content: [ { diff --git a/tests/e2e/chrome-devtools.test.ts b/tests/e2e/chrome-devtools.test.ts new file mode 100644 index 000000000..70f7874fd --- /dev/null +++ b/tests/e2e/chrome-devtools.test.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {spawnSync} from 'node:child_process'; +import path from 'node:path'; +import {describe, it, afterEach, beforeEach} from 'node:test'; + +const CLI_PATH = path.resolve('build/src/bin/chrome-devtools.js'); + +describe('chrome-devtools', () => { + const START_ARGS = ['--headless', '--isolated']; + + function assertDaemonIsNotRunning() { + const result = spawnSync('node', [CLI_PATH, 'status']); + assert.strictEqual( + result.stdout.toString(), + 'chrome-devtools-mcp daemon is not running.\n', + ); + } + + beforeEach(() => { + spawnSync('node', [CLI_PATH, 'stop']); + assertDaemonIsNotRunning(); + }); + + afterEach(() => { + spawnSync('node', [CLI_PATH, 'stop']); + assertDaemonIsNotRunning(); + }); + + it('reports daemon status correctly', () => { + assertDaemonIsNotRunning(); + + const startResult = spawnSync('node', [CLI_PATH, 'start', ...START_ARGS]); + assert.strictEqual( + startResult.status, + 0, + `start command failed: ${startResult.stderr.toString()}`, + ); + + const result = spawnSync('node', [CLI_PATH, 'status']); + assert.strictEqual( + result.stdout.toString(), + 'chrome-devtools-mcp daemon is running.\n', + ); + }); + + it('can start and stop the daemon', () => { + assertDaemonIsNotRunning(); + + const startResult = spawnSync('node', [CLI_PATH, 'start', ...START_ARGS]); + assert.strictEqual( + startResult.status, + 0, + `start command failed: ${startResult.stderr.toString()}`, + ); + + let result = spawnSync('node', [CLI_PATH, 'status']); + assert.strictEqual( + result.stdout.toString(), + 'chrome-devtools-mcp daemon is running.\n', + ); + + const stopResult = spawnSync('node', [CLI_PATH, 'stop']); + assert.strictEqual( + stopResult.status, + 0, + `stop command failed: ${stopResult.stderr.toString()}`, + ); + + result = spawnSync('node', [CLI_PATH, 'status']); + assert.strictEqual( + result.stdout.toString(), + 'chrome-devtools-mcp daemon is not running.\n', + ); + }); + + it('can invoke list_pages', async () => { + assertDaemonIsNotRunning(); + + const startResult = spawnSync('node', [CLI_PATH, 'start', ...START_ARGS]); + assert.strictEqual( + startResult.status, + 0, + `start command failed: ${startResult.stderr.toString()}`, + ); + + const listPagesResult = spawnSync('node', [CLI_PATH, 'list_pages']); + assert.strictEqual( + listPagesResult.status, + 0, + `list_pages command failed: ${listPagesResult.stderr.toString()}`, + ); + assert( + listPagesResult.stdout.toString().includes('about:blank'), + 'list_pages output is unexpected', + ); + + // Daemon should now be running. + const result = spawnSync('node', [CLI_PATH, 'status']); + assert.strictEqual( + result.stdout.toString(), + 'chrome-devtools-mcp daemon is running.\n', + ); + }); + + it('forwards disclaimers to stderr on start', () => { + const result = spawnSync('node', [CLI_PATH, 'start', ...START_ARGS]); + assert.strictEqual( + result.status, + 0, + `start command failed: ${result.stderr.toString()}`, + ); + assert( + result.stderr.toString().includes('chrome-devtools-mcp exposes content'), + 'Disclaimer not found in stderr on start', + ); + }); +});