refactor: add support for CLI sessionIds in tests (#1919)

Closes https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/1119
This commit is contained in:
Alex Rudenko
2026-04-22 10:15:36 +02:00
committed by GitHub
parent a1612be8e0
commit 82b67b07d3
11 changed files with 196 additions and 124 deletions
-1
View File
@@ -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,
+67 -43
View File
@@ -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 <command> --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);
});
y.command('stop', 'Stop chrome-devtools-mcp if any', async () => {
if (!isDaemonRunning()) {
process.exit(0);
}
await stopDaemon();
process.exit(0);
});
},
);
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);
},
);
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<string, unknown> = {};
@@ -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(
+10 -9
View File
@@ -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<DaemonResponse> {
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);
}
+5 -5
View File
@@ -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);
+19 -14
View File
@@ -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
+33 -14
View File
@@ -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',
);
});
});
+14 -10
View File
@@ -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,
@@ -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,
+18 -11
View File
@@ -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);
});
});
+11 -7
View File
@@ -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);
});
});
+10 -5
View File
@@ -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',