import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import net from "node:net"; import { createHash, randomBytes } from "node:crypto"; 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 { dirname, join, resolve } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SCRIPT = resolve(__dirname, "cdp.mjs"); const TARGET_ID = "ABCDEF1234567890ABCDEF1234567890"; const TARGET_PREFIX = TARGET_ID.slice(0, 8); const PNG_1X1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; function encodeFrame(text) { const payload = Buffer.from(text); if (payload.length < 126) return Buffer.concat([Buffer.from([0x81, payload.length]), payload]); if (payload.length <= 0xffff) { const head = Buffer.alloc(4); head[0] = 0x81; head[1] = 126; head.writeUInt16BE(payload.length, 2); return Buffer.concat([head, payload]); } const head = Buffer.alloc(10); head[0] = 0x81; head[1] = 127; head.writeBigUInt64BE(BigInt(payload.length), 2); return Buffer.concat([head, payload]); } function decodeFrames(buffer) { const messages = []; let offset = 0; while (offset + 2 <= buffer.length) { const b1 = buffer[offset]; const b2 = buffer[offset + 1]; let len = b2 & 0x7f; let header = 2; if (len === 126) { if (offset + 4 > buffer.length) break; len = buffer.readUInt16BE(offset + 2); header = 4; } else if (len === 127) { if (offset + 10 > buffer.length) break; len = Number(buffer.readBigUInt64BE(offset + 2)); header = 10; } const masked = (b2 & 0x80) !== 0; const maskOffset = offset + header; const payloadOffset = maskOffset + (masked ? 4 : 0); const end = payloadOffset + len; if (end > buffer.length) break; const payload = Buffer.from(buffer.subarray(payloadOffset, end)); if (masked) { const mask = buffer.subarray(maskOffset, payloadOffset); for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4]; } if ((b1 & 0x0f) === 0x1) messages.push(payload.toString("utf8")); offset = end; } return { messages, rest: buffer.subarray(offset) }; } async function startFakeCdpServer(options = {}) { const targetInfos = options.targetInfos ?? [ { targetId: TARGET_ID, type: "page", title: "Fake Page", url: "https://example.test/", }, { targetId: "CHROME0000000000000000000000000000", type: "page", title: "Chrome", url: "chrome://inspect", }, ]; const state = { targetInfos, methods: [], noLoadEvent: options.noLoadEvent ?? false, noRespondMethods: new Set(options.noRespondMethods ?? []), resourceEntries: options.resourceEntries ?? [ { name: "https://example.test/app.js", type: "script", duration: 12, size: 2048, }, ], mouseEvents: [], typed: [], loadMoreRemaining: 2, attached: 0, attachError: options.attachError ?? false, }; const sockets = new Set(); const server = createServer((req, res) => { if (req.url === "/json/version") { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ webSocketDebuggerUrl: `ws://127.0.0.1:${server.address().port}${path}` })); return; } res.writeHead(404); res.end(); }); server.on("upgrade", (req, socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); const accept = createHash("sha1") .update( req.headers["sec-websocket-key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", ) .digest("base64"); socket.write( [ "HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", "Connection: Upgrade", `Sec-WebSocket-Accept: ${accept}`, "", "", ].join("\r\n"), ); let buf = Buffer.alloc(0); socket.on("data", (chunk) => { buf = Buffer.concat([buf, chunk]); const decoded = decodeFrames(buf); buf = decoded.rest; for (const message of decoded.messages) { const req = JSON.parse(message); handleRequest(socket, req, state); } }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const { port } = server.address(); const path = `/devtools/browser/${randomBytes(4).toString("hex")}`; return { state, port, path, wsUrl: `ws://127.0.0.1:${port}${path}`, async close() { for (const socket of sockets) socket.destroy(); await new Promise((resolve) => server.close(resolve)); }, }; } function send(socket, payload) { socket.write(encodeFrame(JSON.stringify(payload))); } function runtimeValue(expression, state) { if (expression === "document.title") return "Fake Title"; if (expression === "document.readyState") return "complete"; if (expression === "window.devicePixelRatio") return 2; if (expression === "document.documentElement.outerHTML") return '
Card
'; if (expression.includes('document.querySelector(".card")?.outerHTML')) return '
Card
'; if (expression.includes('document.querySelector(".missing")?.outerHTML')) return "Element not found"; if (expression.includes("performance.getEntriesByType('resource')")) { return JSON.stringify(state.resourceEntries); } if ( expression.includes('document.querySelector("#go")') && expression.includes("return { ok: true, tag: el.tagName") ) { return { ok: true, tag: "BUTTON", text: "Go" }; } if ( expression.includes('document.querySelector("#missing")') && expression.includes("return { ok: true, tag: el.tagName") ) { return { ok: false, error: "Element not found: #missing" }; } if ( expression.includes('document.querySelector("#more")') && expression.includes("exists: true") ) { if (state.loadMoreRemaining > 0) { state.loadMoreRemaining--; return { exists: true, clicked: true }; } return { exists: false, clicked: false }; } return null; } function handleRequest(socket, req, state) { state.methods.push(req.method); if (state.noRespondMethods.has(req.method)) return; const id = req.id; const sid = req.sessionId; switch (req.method) { case "Target.getTargets": return send(socket, { id, result: { targetInfos: state.targetInfos } }); case "Target.attachToTarget": state.attached++; if (state.attachError) return send(socket, { id, error: { message: "attach denied" } }); return send(socket, { id, result: { sessionId: "session-1" } }); case "Accessibility.getFullAXTree": return send(socket, { id, sessionId: sid, result: { nodes: [ { nodeId: "1", role: { value: "RootWebArea" }, name: { value: "Fake Page" }, childIds: ["2", "3"], }, { nodeId: "2", parentId: "1", role: { value: "button" }, name: { value: "Go" }, }, { nodeId: "3", parentId: "1", role: { value: "InlineTextBox" }, name: { value: "hidden text" }, }, ], }, }); case "Runtime.enable": case "Page.enable": return send(socket, { id, sessionId: sid, result: {} }); case "Runtime.evaluate": if (req.params.expression === "throwError()") { return send(socket, { id, sessionId: sid, result: { exceptionDetails: { text: "Uncaught", exception: { description: "Error: boom" }, }, }, }); } return send(socket, { id, sessionId: sid, result: { result: { value: runtimeValue(req.params.expression, state) }, }, }); case "Page.getLayoutMetrics": return send(socket, { id, sessionId: sid, result: { visualViewport: { clientWidth: 1600 }, cssVisualViewport: { clientWidth: 800 }, }, }); case "Emulation.getDeviceMetricsOverride": return send(socket, { id, sessionId: sid, result: { deviceScaleFactor: 2 }, }); case "Page.captureScreenshot": return send(socket, { id, sessionId: sid, result: { data: PNG_1X1 } }); case "Page.navigate": if (req.params.url === "chrome-error://fail") return send(socket, { id, sessionId: sid, result: { errorText: "Navigation failed" } }); send(socket, { id, sessionId: sid, result: { loaderId: "loader-1" } }); if (!state.noLoadEvent) { setTimeout( () => send(socket, { method: "Page.loadEventFired", sessionId: sid, params: { timestamp: Date.now() / 1000 }, }), 5, ); } return; case "Input.dispatchMouseEvent": state.mouseEvents.push(req.params.type); return send(socket, { id, sessionId: sid, result: {} }); case "Input.insertText": state.typed.push(req.params.text); return send(socket, { id, sessionId: sid, result: {} }); case "DOM.getDocument": return send(socket, { id, sessionId: sid, result: { root: { nodeId: 1, nodeName: "#document" } }, }); default: return send(socket, { id, sessionId: sid, result: {} }); } } function runCdp(args, env, options = {}) { return new Promise((resolve, reject) => { const child = spawn(process.execPath, [SCRIPT, ...args], { env: { ...process.env, ...env }, cwd: __dirname, stdio: ["ignore", "pipe", "pipe"], ...options, }); let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); child.on("error", reject); child.on("close", (code) => resolve({ code, stdout, stderr })); }); } async function expectOk(args, env) { const result = await runCdp(args, env); assert.equal( result.code, 0, `${args.join(" ")}\nstdout=${result.stdout}\nstderr=${result.stderr}`, ); return result.stdout.trimEnd(); } async function expectFail(args, env) { const result = await runCdp(args, env); assert.notEqual( result.code, 0, `${args.join(" ")} unexpectedly passed\nstdout=${result.stdout}\nstderr=${result.stderr}`, ); return `${result.stdout}${result.stderr}`.trimEnd(); } function socketRoundTrip(socketPath, payload) { return new Promise((resolve, reject) => { const conn = net.connect(socketPath); let data = ""; conn.setEncoding("utf8"); conn.on("connect", () => conn.write(payload)); conn.on("data", (chunk) => { data += chunk; if (data.includes("\n")) conn.end(); }); conn.on("end", () => resolve(data.trimEnd())); conn.on("error", reject); }); } test("cdp CLI integration covers list + all page commands + daemon lifecycle", async () => { const server = await startFakeCdpServer(); const stateDir = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-test-")); const env = { PI_CHROME_CDP_WS_URL: server.wsUrl, PI_CHROME_CDP_STATE_DIR: stateDir, PI_CHROME_CDP_DAEMON_CONNECT_DELAY_MS: "5", PI_CHROME_CDP_DAEMON_CONNECT_RETRIES: "80", }; try { const list = await expectOk(["list"], env); assert.match( list, new RegExp(`^${TARGET_PREFIX}\\s+Fake Page\\s+https://example\\.test/`), ); assert.doesNotMatch(list, /chrome:\/\/inspect/); assert.equal((await stat(stateDir)).mode & 0o777, 0o700); assert.ok(existsSync(join(stateDir, "pages.json"))); const snap = await expectOk(["snap", TARGET_PREFIX], env); assert.match(snap, /\[RootWebArea\] Fake Page/); assert.match(snap, /\[button\] Go/); assert.doesNotMatch(snap, /InlineTextBox/); assert.equal( await expectOk(["eval", TARGET_PREFIX, "document.title"], env), "Fake Title", ); assert.equal( await expectOk(["html", TARGET_PREFIX, ".card"], env), '
Card
', ); assert.match( await expectOk(["nav", TARGET_PREFIX, "https://example.test/next"], env), /Navigated to https:\/\/example\.test\/next/, ); assert.match( await expectOk(["net", TARGET_PREFIX], env), /script\s+https:\/\/example\.test\/app\.js/, ); const defaultShot = await expectOk(["shot", TARGET_PREFIX], env); const defaultScreenshot = join(stateDir, "screenshot.png"); assert.match( defaultShot, new RegExp(defaultScreenshot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), ); assert.equal(await readFile(defaultScreenshot, "base64"), PNG_1X1); const screenshot = join(stateDir, "shot.png"); const shot = await expectOk(["shot", TARGET_PREFIX, screenshot], env); assert.match( shot, new RegExp(screenshot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), ); assert.match(shot, /DPR\): 2/); assert.equal(await readFile(screenshot, "base64"), PNG_1X1); assert.equal( await expectOk(["click", TARGET_PREFIX, "#go"], env), 'Clicked