258 lines
6.7 KiB
TypeScript
258 lines
6.7 KiB
TypeScript
/**
|
|
* @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<ChromeDevToolsMcpExtension>;
|
|
}
|
|
|
|
async function startMockServer(): Promise<MockServerContext> {
|
|
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<void>(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<void> {
|
|
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<void> {
|
|
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},
|
|
),
|
|
);
|
|
});
|