/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import assert from 'node:assert'; import {spawn, type ChildProcess, type SpawnOptions} from 'node:child_process'; import http from 'node:http'; import type {AddressInfo} from 'node:net'; import path from 'node:path'; import {describe, it} from 'node:test'; import type {ChromeDevToolsMcpExtension} from '../../src/telemetry/types'; const SERVER_PATH = path.resolve('build/src/bin/chrome-devtools-mcp.js'); interface MockServerContext { server: http.Server; port: number; events: ChromeDevToolsMcpExtension[]; watchdogPid?: number; waitForEvent: ( predicate: (event: ChromeDevToolsMcpExtension) => boolean, ) => Promise; } async function startMockServer(): Promise { const events: ChromeDevToolsMcpExtension[] = []; let waitingResolvers: Array<{ predicate: (event: ChromeDevToolsMcpExtension) => boolean; resolve: (event: ChromeDevToolsMcpExtension) => void; }> = []; let watchdogPid: number | undefined; const server = http.createServer((req, res) => { if (req.method === 'POST') { const pidHeader = req.headers['x-watchdog-pid']; if (pidHeader && !Array.isArray(pidHeader)) { watchdogPid = parseInt(pidHeader, 10); } let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', () => { try { const parsed = JSON.parse(body); // Extract internal log events if (parsed.log_event) { for (const logEvent of parsed.log_event) { if (logEvent.source_extension_json) { const ext = JSON.parse( logEvent.source_extension_json, ) as ChromeDevToolsMcpExtension; events.push(ext); // Check if any waiters are satisfied waitingResolvers = waitingResolvers.filter( ({predicate, resolve}) => { if (predicate(ext)) { resolve(ext); return false; } return true; }, ); } } } } catch (err) { console.error('Failed to parse mock server request', err); } res.writeHead(200, {'Content-Type': 'application/json'}); res.end(JSON.stringify({next_request_wait_millis: 100})); }); } else { res.writeHead(404); res.end(); } }); await new Promise(resolve => { server.listen(0, '127.0.0.1', () => resolve()); }); const address = server.address() as AddressInfo; return { server, port: address.port, events, get watchdogPid() { return watchdogPid; }, waitForEvent: predicate => { const existing = events.find(predicate); if (existing) { return Promise.resolve(existing); } return new Promise(resolve => { waitingResolvers.push({predicate, resolve}); }); }, }; } interface TestContext { process?: ChildProcess; mockServer?: MockServerContext; } function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } } async function waitForProcessExit( pid: number, timeoutMs = 5000, ): Promise { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { if (!isProcessAlive(pid)) { return; } await new Promise(resolve => setTimeout(resolve, 50)); } throw new Error(`Timeout waiting for process ${pid} to exit`); } function cleanupTest(ctx: TestContext): void { // Kill Main Process if (ctx.process && ctx.process.exitCode === null) { try { ctx.process.kill('SIGKILL'); } catch { // ignore } } // Kill Watchdog Process if (ctx.mockServer?.watchdogPid) { try { process.kill(ctx.mockServer.watchdogPid, 'SIGKILL'); } catch { // ignore } } // Stop Mock Server if (ctx.mockServer) { ctx.mockServer.server.close(); } } describe('Telemetry E2E', () => { async function runTelemetryTest( killFn: (ctx: TestContext) => void, spawnOptions?: SpawnOptions, ): Promise { const mockContext = await startMockServer(); const ctx: TestContext = { mockServer: mockContext, }; try { ctx.process = spawn( process.execPath, [ SERVER_PATH, '--usage-statistics', '--headless', `--clearcutEndpoint=http://127.0.0.1:${mockContext.port}`, '--clearcutForceFlushIntervalMs=10', '--clearcutIncludePidHeader', ], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, CI: undefined, CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: undefined, }, ...spawnOptions, }, ); const startEvent = await Promise.race([ mockContext.waitForEvent(e => e.server_start !== undefined), new Promise((_, reject) => setTimeout( () => reject(new Error('Timeout waiting for server_start')), 10000, ), ), ]); assert.ok(startEvent, 'server_start event not received'); // Now that we received an event, we should have the Watchdog PID const watchdogPid = mockContext.watchdogPid; assert.ok(watchdogPid, 'Watchdog PID not captured from headers'); // Assert Watchdog is actually running assert.strictEqual( isProcessAlive(watchdogPid), true, 'Watchdog process should be running', ); // Trigger shutdown killFn(ctx); // Verify shutdown event const shutdownEvent = await Promise.race([ mockContext.waitForEvent(e => e.server_shutdown !== undefined), new Promise((_, reject) => setTimeout( () => reject(new Error('Timeout waiting for server_shutdown')), 10000, ), ), ]); assert.ok(shutdownEvent, 'server_shutdown event not received'); // Wait for Watchdog to exit naturally await waitForProcessExit(watchdogPid); } finally { cleanupTest(ctx); } } it('handles SIGKILL', () => runTelemetryTest(ctx => { ctx.process!.kill('SIGKILL'); })); it('handles SIGTERM', () => runTelemetryTest(ctx => { ctx.process!.kill('SIGTERM'); })); it( 'handles POSIX process group SIGTERM', {skip: process.platform === 'win32'}, () => runTelemetryTest( ctx => { process.kill(-ctx.process!.pid!, 'SIGTERM'); }, {detached: true}, ), ); });