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