import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { once } from "node:events"; import { existsSync } from "node:fs"; import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { chromium } from "@playwright/test"; import { test } from "vitest"; import { Browser, executeOperations } from "../browser.ts"; import { binaryKey, dataRoot } from "../lib.ts"; const html = 'Chrome CDP fixture

Ready

'; test("debug trace classifies known events without logging payloads", () => { const b = new Browser(); const logs: { event: string; subtype?: string; count?: number }[] = []; b.diagnostic = (event, details) => logs.push({ event, ...details }); for (let i = 0; i < 200; i++) b.onMessage( JSON.stringify({ method: "Network.requestWillBeSent", params: { request: { url: "private.example" } }, }), ); assert.deepEqual( logs, Array.from({ length: 200 }, () => ({ event: "cdp.event", subtype: "network", })), ); }); test("debug trace aggregates unclassified events without logging payloads", () => { const b = new Browser(); const logs: { event: string; count?: number }[] = []; b.diagnostic = (event, details) => logs.push({ event, ...details }); for (let i = 0; i < 200; i++) b.onMessage( JSON.stringify({ method: "DOM.documentUpdated", params: { secret: "private" }, }), ); assert.deepEqual(logs, [{ event: "cdp.events", count: 200 }]); }); test("real Chrome: raw, events, captures, trace, persistent profile, headed and public smoke", { timeout: 180000, }, async () => { const server = createServer((_req, res) => { res.setHeader("Content-Type", "text/html"); res.end(html); }); const chromeExecutable = chromium.executablePath(); const profile = join(dataRoot(), binaryKey(chromeExecutable), "pi-cdp-test"); const root = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-")); let b: Browser | undefined; let ownsProfile = false; try { server.listen(0, "127.0.0.1"); await once(server, "listening"); const address = server.address(); if (!address || typeof address === "string") throw new Error("no port"); const url = `http://127.0.0.1:${address.port}`; if (existsSync(profile)) throw new Error( `test profile already exists; refusing to delete or reuse: ${profile}`, ); ownsProfile = true; b = await Browser.launch({ artifactDir: root, executablePath: chromeExecutable, }); const sid = b.initialSession; const first = await executeOperations(b, [ { kind: "cdp", method: "Page.navigate", sessionId: sid, params: { url }, label: "nav", }, { kind: "cdp", method: "Runtime.evaluate", sessionId: sid, params: { expression: "document.title", returnByValue: true }, label: "title", }, { kind: "cdp", method: "Runtime.evaluate", sessionId: sid, params: { expression: "40+2", returnByValue: true, contextId: "{{title.result.result.executionContextId}}", }, }, ]); // Missing reference stops the batch before the dependent command is sent. assert.match(first.failure || "", /missing reference/); const good = await executeOperations(b, [ { kind: "cdp", method: "Runtime.evaluate", sessionId: sid, params: { expression: "document.title", returnByValue: true }, label: "title2", }, { kind: "cdp", method: "Runtime.evaluate", sessionId: sid, params: { expression: "{{title2.result.result.value}}", returnByValue: true, }, }, { kind: "screenshot", sessionId: sid }, ]); assert.equal(good.failure, undefined); assert.equal(good.images.length, 1); assert.ok( [...first.events, ...good.events].some( (e) => e.method === "Runtime.consoleAPICalled", ), ); assert.equal( (good.output[2] as { file: string }).file.startsWith(root), true, ); assert.ok( (await stat((good.output[2] as { file: string }).file)).size > 100, ); const v = await executeOperations(b, [ { kind: "videoStart", sessionId: sid }, { kind: "cdp", method: "Page.reload", sessionId: sid }, ]); assert.equal(v.failure, undefined); const animated = await b.send( "Runtime.evaluate", { expression: "window.__cdpTestAnimation = setInterval(() => { if (document.body) document.body.style.backgroundColor = Date.now() % 2 ? '#fff' : '#eef'; }, 16)", }, sid, ); assert.equal(animated.error, undefined); const recordingStart = Date.now(); await new Promise((resolve) => setTimeout(resolve, 1500)); const recordingMs = Date.now() - recordingStart; const stopped = await executeOperations(b, [ { kind: "videoStop" }, { kind: "cdp", method: "Runtime.evaluate", sessionId: sid, params: { expression: "clearInterval(window.__cdpTestAnimation)" }, }, { kind: "traceStart" }, { kind: "cdp", method: "Page.reload", sessionId: sid }, { kind: "traceStop" }, ]); assert.equal(stopped.failure, undefined, JSON.stringify(stopped)); const videoFile = (stopped.output[0] as { file: string }).file; const traceFile = (stopped.output[2] as { file: string }).file; assert.ok((await stat(videoFile)).size > 50); const duration = Number( execFileSync( "ffprobe", [ "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", videoFile, ], { encoding: "utf8" }, ).trim(), ); assert.ok( Math.abs(duration - recordingMs / 1000) < 0.75, `WebM duration ${duration}s must track capture ${recordingMs}ms`, ); const traceData = JSON.parse(await readFile(traceFile, "utf8")); assert.ok(traceData && typeof traceData === "object"); assert.equal(b.status().uncertain, false); const cancelled = new AbortController(); const stuck = executeOperations( b, [ { kind: "cdp", method: "Runtime.evaluate", sessionId: sid, params: { expression: "new Promise(() => {})", awaitPromise: true }, }, ], cancelled.signal, ); setTimeout(() => cancelled.abort(), 50); const canceledResult = await stuck; assert.match(canceledResult.failure || "", /canceled/); const refused = await executeOperations(b, [ { kind: "cdp", method: "Browser.getVersion" }, ]); assert.match(refused.failure || "", /uncertain/); const resync = await executeOperations(b, [ { kind: "status" }, { kind: "cdp", method: "Browser.getVersion" }, ]); assert.equal(resync.failure, undefined, JSON.stringify(resync)); const savedPath = process.env.PATH; try { process.env.PATH = "/nonexistent"; const noEncoder = await executeOperations(b, [ { kind: "videoStart", sessionId: sid }, ]); assert.match(noEncoder.failure || "", /ENOENT|ffmpeg/); } finally { process.env.PATH = savedPath; } const chain = await executeOperations(b, [ { kind: "cdp", method: "Target.createTarget", params: { url: "about:blank" }, label: "newTab", }, { kind: "cdp", method: "Target.attachToTarget", params: { targetId: "{{newTab.result.targetId}}", flatten: true }, label: "attached", }, { kind: "cdp", method: "Runtime.evaluate", sessionId: "{{attached.result.sessionId}}", params: { expression: "6*7", returnByValue: true }, }, ]); assert.equal(chain.failure, undefined, JSON.stringify(chain)); assert.match(JSON.stringify(chain.output[2]), /42/); const smoke = await executeOperations(b, [ { kind: "cdp", method: "Page.navigate", sessionId: sid, params: { url: "https://example.com" }, label: "publicNav", }, { kind: "wait", after: "publicNav", event: "Page.loadEventFired", sessionId: sid, timeoutMs: 10000, }, { kind: "cdp", method: "Runtime.evaluate", sessionId: sid, params: { expression: "document.title", returnByValue: true }, }, ]); if ( smoke.failure || !JSON.stringify(smoke.output).includes("Example Domain") ) console.warn( `public site verification unavailable: ${smoke.failure || JSON.stringify(smoke.output).slice(0, 500)}`, ); else assert.match(JSON.stringify(smoke.output), /Example Domain/); await b.close(); b = undefined; const saved = await Browser.launch({ profile: "pi-cdp-test", artifactDir: root, executablePath: chromeExecutable, }); b = saved; await assert.rejects( Browser.launch({ profile: "pi-cdp-test", artifactDir: root, executablePath: chromeExecutable, }), /profile locked/, ); const writeCookie = await executeOperations(saved, [ { kind: "cdp", method: "Page.navigate", sessionId: saved.initialSession, params: { url }, label: "persistNav", }, { kind: "wait", after: "persistNav", event: "Page.loadEventFired", sessionId: saved.initialSession, }, { kind: "cdp", method: "Runtime.evaluate", sessionId: saved.initialSession, params: { expression: 'document.cookie="test=kept; Max-Age=3600; Path=/"', }, }, { kind: "cdp", method: "Runtime.evaluate", sessionId: saved.initialSession, params: { expression: 'location.href+" :: "+document.cookie', returnByValue: true, }, }, ]); assert.equal(writeCookie.failure, undefined, JSON.stringify(writeCookie)); assert.match( JSON.stringify(writeCookie.output), /test=kept/, JSON.stringify(writeCookie), ); await saved.close(); b = undefined; b = await Browser.launch({ profile: "pi-cdp-test", artifactDir: root, headed: true, executablePath: chromeExecutable, }); const persisted = await executeOperations(b, [ { kind: "cdp", method: "Page.navigate", sessionId: b.initialSession, params: { url }, label: "restoreNav", }, { kind: "wait", after: "restoreNav", event: "Page.loadEventFired", sessionId: b.initialSession, }, { kind: "cdp", method: "Runtime.evaluate", sessionId: b.initialSession, params: { expression: "document.cookie", returnByValue: true }, }, ]); assert.match(JSON.stringify(persisted.output), /test=kept/); await b.close(); b = undefined; await writeFile(join(profile, ".pi-chrome-cdp-version"), "999.0.0.0"); await assert.rejects( Browser.launch({ profile: "pi-cdp-test", artifactDir: root, executablePath: chromeExecutable, }), /downgrade refused/, ); } finally { if (b) await b.close(); server.close(); await rm(root, { recursive: true, force: true }); if (ownsProfile) await rm(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 100, }); } });