diff --git a/scripts/test.mjs b/scripts/test.mjs index 5e0457da4..d545f392b 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -70,7 +70,6 @@ const nodeArgs = [ '--test-reporter', (process.env['NODE_TEST_REPORTER'] ?? process.env['CI']) ? 'spec' : 'dot', '--test-force-exit', - '--test-concurrency=1', '--test', '--test-timeout=120000', ...flags, diff --git a/src/bin/chrome-devtools.ts b/src/bin/chrome-devtools.ts index 979533e84..84a96c086 100644 --- a/src/bin/chrome-devtools.ts +++ b/src/bin/chrome-devtools.ts @@ -31,9 +31,9 @@ await checkForUpdates( 'Run `npm install -g chrome-devtools-mcp@latest` and `chrome-devtools start` to update and restart the daemon.', ); -async function start(args: string[]) { +async function start(args: string[], sessionId: string) { const combinedArgs = [...args, ...defaultArgs]; - await startDaemon(combinedArgs); + await startDaemon(combinedArgs, sessionId); logDisclaimers(parseArguments(VERSION, combinedArgs)); } @@ -78,6 +78,12 @@ const y = yargs(hideBin(process.argv)) .usage( `Run 'chrome-devtools --help' for help on the specific command.`, ) + .option('sessionId', { + type: 'string', + description: 'Session ID for daemon scoping', + default: '', + hidden: true, + }) .demandCommand() .version(VERSION) .strict() @@ -96,8 +102,8 @@ y.command( ) .strict(), async argv => { - if (isDaemonRunning()) { - await stopDaemon(); + if (isDaemonRunning(argv.sessionId)) { + await stopDaemon(argv.sessionId); } // Defaults but we do not want to affect the yargs conflict resolution. if (argv.isolated === undefined && argv.userDataDir === undefined) { @@ -107,46 +113,60 @@ y.command( argv.headless = true; } const args = serializeArgs(cliOptions, argv); - await start(args); + await start(args, argv.sessionId); 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.'); - const response = await sendCommand({ - method: 'status', - }); - if (response.success) { - const data = JSON.parse(response.result) as { - pid: number | null; - socketPath: string; - startDate: string; - version: string; - args: string[]; - }; - console.log( - `pid=${data.pid} socket=${data.socketPath} start-date=${data.startDate} version=${data.version}`, +y.command( + 'status', + 'Checks if chrome-devtools-mcp is running', + y => y, + async argv => { + if (isDaemonRunning(argv.sessionId)) { + console.log('chrome-devtools-mcp daemon is running.'); + const response = await sendCommand( + { + method: 'status', + }, + argv.sessionId, ); - console.log(`args=${JSON.stringify(data.args)}`); + if (response.success) { + const data = JSON.parse(response.result) as { + pid: number | null; + socketPath: string; + startDate: string; + version: string; + args: string[]; + }; + console.log( + `pid=${data.pid} socket=${data.socketPath} start-date=${data.startDate} version=${data.version}`, + ); + console.log(`args=${JSON.stringify(data.args)}`); + } else { + console.error('Error:', response.error); + process.exit(1); + } } else { - console.error('Error:', response.error); - process.exit(1); + console.log('chrome-devtools-mcp daemon is not running.'); } - } else { - console.log('chrome-devtools-mcp daemon is not running.'); - } - process.exit(0); -}); + process.exit(0); + }, +); -y.command('stop', 'Stop chrome-devtools-mcp if any', async () => { - if (!isDaemonRunning()) { +y.command( + 'stop', + 'Stop chrome-devtools-mcp if any', + y => y, + async argv => { + const sessionId = argv.sessionId as string; + if (!isDaemonRunning(sessionId)) { + process.exit(0); + } + await stopDaemon(sessionId); process.exit(0); - } - await stopDaemon(); - process.exit(0); -}); + }, +); for (const [commandName, commandDef] of Object.entries(commands)) { const args = commandDef.args; @@ -213,9 +233,10 @@ for (const [commandName, commandDef] of Object.entries(commands)) { } }, async argv => { + const sessionId = argv.sessionId as string; try { - if (!isDaemonRunning()) { - await start([]); + if (!isDaemonRunning(sessionId)) { + await start([], sessionId); } const commandArgs: Record = {}; @@ -225,11 +246,14 @@ for (const [commandName, commandDef] of Object.entries(commands)) { } } - const response = await sendCommand({ - method: 'invoke_tool', - tool: commandName, - args: commandArgs, - }); + const response = await sendCommand( + { + method: 'invoke_tool', + tool: commandName, + args: commandArgs, + }, + sessionId, + ); if (response.success) { console.log( diff --git a/src/daemon/client.ts b/src/daemon/client.ts index 01167edee..47862f8e5 100644 --- a/src/daemon/client.ts +++ b/src/daemon/client.ts @@ -67,13 +67,13 @@ function waitForFile(filePath: string, removed = false) { }); } -export async function startDaemon(mcpArgs: string[] = []) { - if (isDaemonRunning()) { +export async function startDaemon(mcpArgs: string[] = [], sessionId: string) { + if (isDaemonRunning(sessionId)) { logger('Daemon is already running'); return; } - const pidFilePath = getPidFilePath(); + const pidFilePath = getPidFilePath(sessionId); if (fs.existsSync(pidFilePath)) { fs.unlinkSync(pidFilePath); @@ -83,7 +83,7 @@ export async function startDaemon(mcpArgs: string[] = []) { const child = spawn(process.execPath, [DAEMON_SCRIPT_PATH, ...mcpArgs], { detached: true, stdio: 'ignore', - env: process.env, + env: {...process.env, CHROME_DEVTOOLS_MCP_SESSION_ID: sessionId}, cwd: process.cwd(), windowsHide: true, }); @@ -99,8 +99,9 @@ const SEND_COMMAND_TIMEOUT = 60_000; // ms */ export async function sendCommand( command: DaemonMessage, + sessionId: string, ): Promise { - const socketPath = getSocketPath(); + const socketPath = getSocketPath(sessionId); const socket = net.createConnection({ path: socketPath, @@ -133,15 +134,15 @@ export async function sendCommand( }); } -export async function stopDaemon() { - if (!isDaemonRunning()) { +export async function stopDaemon(sessionId: string) { + if (!isDaemonRunning(sessionId)) { logger('Daemon is not running'); return; } - const pidFilePath = getPidFilePath(); + const pidFilePath = getPidFilePath(sessionId); - await sendCommand({method: 'stop'}); + await sendCommand({method: 'stop'}, sessionId); await waitForFile(pidFilePath, /*removed=*/ true); } diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts index 18f6ebad0..e6cb8bd08 100644 --- a/src/daemon/daemon.ts +++ b/src/daemon/daemon.ts @@ -22,7 +22,6 @@ import {VERSION} from '../version.js'; import type {DaemonMessage} from './types.js'; import { DAEMON_CLIENT_NAME, - getDaemonPid, getPidFilePath, getSocketPath, INDEX_SCRIPT_PATH, @@ -30,19 +29,20 @@ import { isDaemonRunning, } from './utils.js'; -const pid = getDaemonPid(); -if (isDaemonRunning(pid)) { +const sessionId = process.env.CHROME_DEVTOOLS_MCP_SESSION_ID || ''; +logger(`Daemon sessionId: ${sessionId}`); +if (isDaemonRunning(sessionId)) { logger('Another daemon process is running.'); process.exit(1); } -const pidFilePath = getPidFilePath(); +const pidFilePath = getPidFilePath(sessionId); fs.mkdirSync(path.dirname(pidFilePath), { recursive: true, }); fs.writeFileSync(pidFilePath, process.pid.toString()); logger(`Writing ${process.pid.toString()} to ${pidFilePath}`); -const socketPath = getSocketPath(); +const socketPath = getSocketPath(sessionId); const startDate = new Date(); const mcpServerArgs = process.argv.slice(2); diff --git a/src/daemon/utils.ts b/src/daemon/utils.ts index 7b73bc0ff..83f69d265 100644 --- a/src/daemon/utils.ts +++ b/src/daemon/utils.ts @@ -24,56 +24,60 @@ const APP_NAME = 'chrome-devtools-mcp'; export const DAEMON_CLIENT_NAME = 'chrome-devtools-cli-daemon'; // Using these paths due to strict limits on the POSIX socket path length. -export function getSocketPath(): string { +export function getSocketPath(sessionId: string): string { const uid = os.userInfo().uid; + const suffix = sessionId ? `-${sessionId}` : ''; + const appName = APP_NAME + suffix; if (IS_WINDOWS) { // Windows uses Named Pipes, not file paths. // This format is required for server.listen() - return path.join('\\\\.\\pipe', APP_NAME, 'server.sock'); + return path.join('\\\\.\\pipe', appName, 'server.sock'); } // 1. Try XDG_RUNTIME_DIR (Linux standard, sometimes macOS) if (process.env.XDG_RUNTIME_DIR) { - return path.join(process.env.XDG_RUNTIME_DIR, APP_NAME, 'server.sock'); + return path.join(process.env.XDG_RUNTIME_DIR, appName, 'server.sock'); } // 2. macOS/Unix Fallback: Use /tmp/ // We use /tmp/ because it is much shorter than ~/Library/Application Support/ // and keeps us well under the 104-character limit. - return path.join('/tmp', `${APP_NAME}-${uid}.sock`); + return path.join('/tmp', `${appName}-${uid}.sock`); } -export function getRuntimeHome(): string { +export function getRuntimeHome(sessionId: string): string { const platform = os.platform(); const uid = os.userInfo().uid; + const suffix = sessionId ? `-${sessionId}` : ''; + const appName = APP_NAME + suffix; // 1. Check for the modern Unix standard if (process.env.XDG_RUNTIME_DIR) { - return path.join(process.env.XDG_RUNTIME_DIR, APP_NAME); + return path.join(process.env.XDG_RUNTIME_DIR, appName); } // 2. Fallback for macOS and older Linux if (platform === 'darwin' || platform === 'linux') { // /tmp is cleared on boot, making it perfect for PIDs - return path.join('/tmp', `${APP_NAME}-${uid}`); + return path.join('/tmp', `${appName}-${uid}`); } // 3. Windows Fallback - return path.join(os.tmpdir(), APP_NAME); + return path.join(os.tmpdir(), appName); } export const IS_WINDOWS = os.platform() === 'win32'; -export function getPidFilePath() { - const runtimeDir = getRuntimeHome(); +export function getPidFilePath(sessionId: string) { + const runtimeDir = getRuntimeHome(sessionId); return path.join(runtimeDir, 'daemon.pid'); } -export function getDaemonPid() { +export function getDaemonPid(sessionId: string) { try { - const pidFile = getPidFilePath(); - logger(`Daemon pid file ${pidFile}`); + const pidFile = getPidFilePath(sessionId); + logger(`Daemon pid file ${pidFile} sessionId=${sessionId}`); if (!fs.existsSync(pidFile)) { return null; } @@ -89,7 +93,8 @@ export function getDaemonPid() { } } -export function isDaemonRunning(pid = getDaemonPid()): pid is number { +export function isDaemonRunning(sessionId: string): boolean { + const pid = getDaemonPid(sessionId); if (pid) { try { process.kill(pid, 0); // Throws if process doesn't exist diff --git a/tests/daemon/client.test.ts b/tests/daemon/client.test.ts index 8c59289cb..7db5cfd1b 100644 --- a/tests/daemon/client.test.ts +++ b/tests/daemon/client.test.ts @@ -5,6 +5,7 @@ */ import assert from 'node:assert'; +import crypto from 'node:crypto'; import {describe, it, afterEach, beforeEach} from 'node:test'; import { @@ -16,39 +17,57 @@ import {isDaemonRunning} from '../../src/daemon/utils.js'; describe('daemon client', () => { describe('start/stop', () => { + let sessionId: string; + beforeEach(async () => { - await stopDaemon(); + sessionId = crypto.randomUUID(); + await stopDaemon(sessionId); }); afterEach(async () => { - await stopDaemon(); + await stopDaemon(sessionId); }); it('should start and stop daemon', async () => { - assert.ok(!isDaemonRunning(), 'Daemon should not be running initially'); + assert.ok( + !isDaemonRunning(sessionId), + 'Daemon should not be running initially', + ); - await startDaemon(); - assert.ok(isDaemonRunning(), 'Daemon should be running after start'); + await startDaemon([], sessionId); + assert.ok( + isDaemonRunning(sessionId), + 'Daemon should be running after start', + ); - await stopDaemon(); - assert.ok(!isDaemonRunning(), 'Daemon should not be running after stop'); + await stopDaemon(sessionId); + assert.ok( + !isDaemonRunning(sessionId), + 'Daemon should not be running after stop', + ); }); it('should handle starting daemon when already running', async () => { - await startDaemon(); - assert.ok(isDaemonRunning(), 'Daemon should be running'); + await startDaemon([], sessionId); + assert.ok(isDaemonRunning(sessionId), 'Daemon should be running'); // Starting again should be a no-op - await startDaemon(); - assert.ok(isDaemonRunning(), 'Daemon should still be running'); + await startDaemon([], sessionId); + assert.ok(isDaemonRunning(sessionId), 'Daemon should still be running'); }); it('should handle stopping daemon when not running', async () => { - assert.ok(!isDaemonRunning(), 'Daemon should not be running initially'); + assert.ok( + !isDaemonRunning(sessionId), + 'Daemon should not be running initially', + ); // Stopping when not running should be a no-op - await stopDaemon(); - assert.ok(!isDaemonRunning(), 'Daemon should still not be running'); + await stopDaemon(sessionId); + assert.ok( + !isDaemonRunning(sessionId), + 'Daemon should still not be running', + ); }); }); diff --git a/tests/e2e/chrome-devtools-commands.test.ts b/tests/e2e/chrome-devtools-commands.test.ts index 7d1523f6e..7bca276d8 100644 --- a/tests/e2e/chrome-devtools-commands.test.ts +++ b/tests/e2e/chrome-devtools-commands.test.ts @@ -5,6 +5,7 @@ */ import assert from 'node:assert'; +import crypto from 'node:crypto'; import {describe, it, afterEach, beforeEach} from 'node:test'; import { @@ -14,27 +15,30 @@ import { } from '../utils.js'; describe('chrome-devtools', () => { + let sessionId: string; + beforeEach(async () => { - await runCli(['stop']); - await assertDaemonIsNotRunning(); + sessionId = crypto.randomUUID(); + await runCli(['stop'], sessionId); + await assertDaemonIsNotRunning(sessionId); }); afterEach(async () => { - await runCli(['stop']); - await assertDaemonIsNotRunning(); + await runCli(['stop'], sessionId); + await assertDaemonIsNotRunning(sessionId); }); it('can invoke list_pages', async () => { - await assertDaemonIsNotRunning(); + await assertDaemonIsNotRunning(sessionId); - const startResult = await runCli(['start']); + const startResult = await runCli(['start'], sessionId); assert.strictEqual( startResult.status, 0, `start command failed: ${startResult.stderr}`, ); - const listPagesResult = await runCli(['list_pages']); + const listPagesResult = await runCli(['list_pages'], sessionId); assert.strictEqual( listPagesResult.status, 0, @@ -45,18 +49,18 @@ describe('chrome-devtools', () => { 'list_pages output is unexpected', ); - await assertDaemonIsRunning(); + await assertDaemonIsRunning(sessionId); }); it('can take screenshot', async () => { - const startResult = await runCli(['start']); + const startResult = await runCli(['start'], sessionId); assert.strictEqual( startResult.status, 0, `start command failed: ${startResult.stderr}`, ); - const result = await runCli(['take_screenshot']); + const result = await runCli(['take_screenshot'], sessionId); assert.strictEqual( result.status, 0, diff --git a/tests/e2e/chrome-devtools-disclaimers.test.ts b/tests/e2e/chrome-devtools-disclaimers.test.ts index 47d3b4f09..7810b4f8e 100644 --- a/tests/e2e/chrome-devtools-disclaimers.test.ts +++ b/tests/e2e/chrome-devtools-disclaimers.test.ts @@ -5,23 +5,27 @@ */ import assert from 'node:assert'; +import crypto from 'node:crypto'; import {describe, it, afterEach, beforeEach} from 'node:test'; import {assertDaemonIsNotRunning, runCli} from '../utils.js'; describe('chrome-devtools', () => { + let sessionId: string; + beforeEach(async () => { - await runCli(['stop']); - await assertDaemonIsNotRunning(); + sessionId = crypto.randomUUID(); + await runCli(['stop'], sessionId); + await assertDaemonIsNotRunning(sessionId); }); afterEach(async () => { - await runCli(['stop']); - await assertDaemonIsNotRunning(); + await runCli(['stop'], sessionId); + await assertDaemonIsNotRunning(sessionId); }); it('forwards disclaimers to stderr on start', async () => { - const result = await runCli(['start']); + const result = await runCli(['start'], sessionId); assert.strictEqual( result.status, 0, diff --git a/tests/e2e/chrome-devtools-start-stop.test.ts b/tests/e2e/chrome-devtools-start-stop.test.ts index 72862dd3f..1db73a055 100644 --- a/tests/e2e/chrome-devtools-start-stop.test.ts +++ b/tests/e2e/chrome-devtools-start-stop.test.ts @@ -5,6 +5,7 @@ */ import assert from 'node:assert'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -17,36 +18,39 @@ import { } from '../utils.js'; describe('chrome-devtools', () => { + let sessionId: string; + beforeEach(async () => { - await runCli(['stop']); - await assertDaemonIsNotRunning(); + sessionId = crypto.randomUUID(); + await runCli(['stop'], sessionId); + await assertDaemonIsNotRunning(sessionId); }); afterEach(async () => { - await runCli(['stop']); - await assertDaemonIsNotRunning(); + await runCli(['stop'], sessionId); + await assertDaemonIsNotRunning(sessionId); }); it('can start and stop the daemon', async () => { - await assertDaemonIsNotRunning(); + await assertDaemonIsNotRunning(sessionId); - const startResult = await runCli(['start']); + const startResult = await runCli(['start'], sessionId); assert.strictEqual( startResult.status, 0, `start command failed: ${startResult.stderr}`, ); - await assertDaemonIsRunning(); + await assertDaemonIsRunning(sessionId); - const stopResult = await runCli(['stop']); + const stopResult = await runCli(['stop'], sessionId); assert.strictEqual( stopResult.status, 0, `stop command failed: ${stopResult.stderr}`, ); - await assertDaemonIsNotRunning(); + await assertDaemonIsNotRunning(sessionId); }); it('can start the daemon with userDataDir', async () => { @@ -56,7 +60,10 @@ describe('chrome-devtools', () => { ); fs.mkdirSync(userDataDir, {recursive: true}); - const startResult = await runCli(['start', '--userDataDir', userDataDir]); + const startResult = await runCli( + ['start', '--userDataDir', userDataDir], + sessionId, + ); assert.strictEqual( startResult.status, 0, @@ -69,6 +76,6 @@ describe('chrome-devtools', () => { `unexpected conflict error: ${startResult.stderr}`, ); - await assertDaemonIsRunning(); + await assertDaemonIsRunning(sessionId); }); }); diff --git a/tests/e2e/chrome-devtools-status.test.ts b/tests/e2e/chrome-devtools-status.test.ts index a07068793..13d129574 100644 --- a/tests/e2e/chrome-devtools-status.test.ts +++ b/tests/e2e/chrome-devtools-status.test.ts @@ -5,6 +5,7 @@ */ import assert from 'node:assert'; +import crypto from 'node:crypto'; import {describe, it, afterEach, beforeEach} from 'node:test'; import { @@ -14,26 +15,29 @@ import { } from '../utils.js'; describe('chrome-devtools', () => { + let sessionId: string; + beforeEach(async () => { - await runCli(['stop']); - await assertDaemonIsNotRunning(); + sessionId = crypto.randomUUID(); + await runCli(['stop'], sessionId); + await assertDaemonIsNotRunning(sessionId); }); afterEach(async () => { - await runCli(['stop']); - await assertDaemonIsNotRunning(); + await runCli(['stop'], sessionId); + await assertDaemonIsNotRunning(sessionId); }); it('reports daemon status correctly', async () => { - await assertDaemonIsNotRunning(); + await assertDaemonIsNotRunning(sessionId); - const startResult = await runCli(['start']); + const startResult = await runCli(['start'], sessionId); assert.strictEqual( startResult.status, 0, `start command failed: ${startResult.stderr}`, ); - await assertDaemonIsRunning(); + await assertDaemonIsRunning(sessionId); }); }); diff --git a/tests/utils.ts b/tests/utils.ts index c678d8087..811f164e1 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -352,9 +352,14 @@ 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 child = spawn('node', [CLI_PATH, ...args]); + const finalArgs = [...args]; + if (sessionId) { + finalArgs.push('--sessionId', sessionId); + } + const child = spawn('node', [CLI_PATH, ...finalArgs]); let stdout = ''; let stderr = ''; child.stdout.on('data', chunk => { @@ -370,16 +375,16 @@ export async function runCli( }); } -export async function assertDaemonIsNotRunning() { - const result = await runCli(['status']); +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() { - const result = await runCli(['status']); +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',