Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

pi/agent/skills/optional/chrome-cdp/scripts/cdp.integration.test.mjs

Raw
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 '<html><body><button id="go">Go</button><div class="card">Card</div></body></html>';
	if (expression.includes('document.querySelector(".card")?.outerHTML'))
		return '<div class="card">Card</div>';
	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),
			'<div class="card">Card</div>',
		);
		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 <BUTTON> "Go"',
		);
		assert.equal(
			await expectOk(["clickxy", TARGET_PREFIX, "10", "20"], env),
			"Clicked at CSS (10, 20)",
		);
		assert.equal(
			await expectOk(["type", TARGET_PREFIX, "hello world"], env),
			"Typed 11 characters",
		);
		assert.equal(
			await expectOk(["loadall", TARGET_PREFIX, "#more", "1"], env),
			'Clicked "#more" 2 time(s) until it disappeared',
		);
		assert.match(
			await expectOk(["evalraw", TARGET_PREFIX, "DOM.getDocument", "{}"], env),
			/"nodeName": "#document"/,
		);

		const batchJson = JSON.stringify([
			{ cmd: "eval", args: ["document.title"] },
			{ cmd: "html", args: [".card"] },
		]);
		const batch = JSON.parse(
			await expectOk(["batch", TARGET_PREFIX, batchJson], env),
		);
		assert.deepEqual(
			batch.map((item) => item.ok),
			[true, true],
		);
		assert.equal(batch[0].result, "Fake Title");
		assert.equal(batch[1].result, '<div class="card">Card</div>');

		const daemonList = await expectOk(["list"], env);
		assert.match(daemonList, /Fake Page/);
		assert.ok(server.state.attached >= 1);
		assert.deepEqual(server.state.mouseEvents, [
			"mouseMoved",
			"mousePressed",
			"mouseReleased",
		]);
		assert.deepEqual(server.state.typed, ["hello world"]);

		await expectOk(["stop", TARGET_PREFIX], env);
	} finally {
		await runCdp(["stop"], env).catch(() => {});
		await rm(stateDir, { recursive: true, force: true });
		await server.close();
	}
});

test("cdp CLI safety caps are tunable and explicit", async () => {
	const server = await startFakeCdpServer({
		resourceEntries: [
			{ name: "https://example.test/a.js", type: "script", duration: 1, size: 1 },
			{ name: "https://example.test/b.js", type: "script", duration: 2, size: 2 },
		],
	});
	const stateDir = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-caps-test-"));
	const env = {
		PI_CHROME_CDP_WS_URL: server.wsUrl,
		PI_CHROME_CDP_STATE_DIR: stateDir,
		PI_CHROME_CDP_MAX_HTML_CHARS: "20",
		PI_CHROME_CDP_MAX_NET_ENTRIES: "1",
	};

	try {
		await expectOk(["list"], env);
		const html = await expectOk(["html", TARGET_PREFIX], env);
		assert.match(html, /page HTML truncated/);
		const net = await expectOk(["net", TARGET_PREFIX], env);
		assert.match(net, /network entries truncated: 2 entries > 1/);
	} finally {
		await runCdp(["stop"], env).catch(() => {});
		await rm(stateDir, { recursive: true, force: true });
		await server.close();
	}
});

test("cdp CLI handles zero inspectable page targets", async () => {
	const server = await startFakeCdpServer({
		targetInfos: [
			{
				targetId: "CHROME0000000000000000000000000000",
				type: "page",
				title: "Chrome",
				url: "chrome://inspect",
			},
			{
				targetId: "WORKER0000000000000000000000000000",
				type: "service_worker",
				title: "Worker",
				url: "https://example.test/sw.js",
			},
		],
	});
	const stateDir = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-empty-test-"));
	const env = {
		PI_CHROME_CDP_WS_URL: server.wsUrl,
		PI_CHROME_CDP_STATE_DIR: stateDir,
	};

	try {
		assert.equal(await expectOk(["list"], env), "");
		assert.deepEqual(JSON.parse(await readFile(join(stateDir, "pages.json"), "utf8")), []);
	} finally {
		await rm(stateDir, { recursive: true, force: true });
		await server.close();
	}
});

test("cdp CLI reports invalid DevToolsActivePort files", async () => {
	const stateDir = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-invalid-portfile-test-"));
	const portFile = join(stateDir, "DevToolsActivePort");
	await writeFile(portFile, "12345\n");
	const env = {
		PI_CHROME_CDP_DEVTOOLS_ACTIVE_PORT: portFile,
		PI_CHROME_CDP_STATE_DIR: stateDir,
	};

	try {
		assert.match(await expectFail(["list"], env), /DevToolsActivePort \(invalid\)/);
	} finally {
		await rm(stateDir, { recursive: true, force: true });
	}
});

test("cdp CLI uses explicit websocket env before DevToolsActivePort files", async () => {
	const server = await startFakeCdpServer();
	const stateDir = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-explicit-ws-test-"));
	const badPortFile = join(stateDir, "DevToolsActivePort");
	await writeFile(badPortFile, "9\n/devtools/browser/dead\n");
	const env = {
		PI_CHROME_CDP_WS_URL: server.wsUrl,
		PI_CHROME_CDP_DEVTOOLS_ACTIVE_PORT: badPortFile,
		PI_CHROME_CDP_STATE_DIR: stateDir,
	};

	try {
		const list = await expectOk(["list"], env);
		assert.match(list, /Fake Page/);
	} finally {
		await rm(stateDir, { recursive: true, force: true });
		await server.close();
	}
});

test("cdp CLI uses unique long prefixes and rejects ambiguous short prefixes", async () => {
	const server = await startFakeCdpServer({
		targetInfos: [
			{
				targetId: "ABCDEF12000000000000000000000000",
				type: "page",
				title: "First Page",
				url: "https://one.example/",
			},
			{
				targetId: "ABCDEF12FFFFFFFFFFFFFFFFFFFFFFFF",
				type: "page",
				title: "Second Page",
				url: "https://two.example/",
			},
		],
	});
	const stateDir = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-prefix-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, /^ABCDEF120\s+First Page/m);
		assert.match(list, /^ABCDEF12F\s+Second Page/m);
		assert.match(await expectFail(["snap", "ABCDEF12"], env), /Ambiguous prefix/);
		assert.match(await expectOk(["snap", "ABCDEF120"], env), /\[RootWebArea\] Fake Page/);
	} finally {
		await runCdp(["stop"], env).catch(() => {});
		await rm(stateDir, { recursive: true, force: true });
		await server.close();
	}
});

test("cdp CLI rejects ambiguous daemon stop prefixes", async () => {
	const stateDir = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-stop-prefix-test-"));
	const env = { PI_CHROME_CDP_STATE_DIR: stateDir };
	await writeFile(join(stateDir, "cdp-ABCDEF12000000000000000000000000.sock"), "stale");
	await writeFile(join(stateDir, "cdp-ABCDEF12FFFFFFFFFFFFFFFFFFFFFFFF.sock"), "stale");

	try {
		assert.match(await expectFail(["stop", "ABCDEF12"], env), /Ambiguous prefix/);
		await expectOk(["stop"], env);
		assert.equal(existsSync(join(stateDir, "cdp-ABCDEF12000000000000000000000000.sock")), false);
		assert.equal(existsSync(join(stateDir, "cdp-ABCDEF12FFFFFFFFFFFFFFFFFFFFFFFF.sock")), false);
	} finally {
		await rm(stateDir, { recursive: true, force: true });
	}
});

test("cdp CLI discovers websocket via DevToolsActivePort override", async () => {
	const server = await startFakeCdpServer();
	const stateDir = await mkdtemp(
		join(tmpdir(), "pi-chrome-cdp-portfile-test-"),
	);
	const portFile = join(stateDir, "DevToolsActivePort");
	await writeFile(portFile, `${server.port}\n${server.path}\n`);
	const env = {
		PI_CHROME_CDP_DEVTOOLS_ACTIVE_PORT: portFile,
		PI_CHROME_CDP_STATE_DIR: stateDir,
	};

	try {
		const list = await expectOk(["list"], env);
		assert.match(list, /Fake Page/);
	} finally {
		await rm(stateDir, { recursive: true, force: true });
		await server.close();
	}
});

test("cdp CLI stop removes stale daemon sockets", async () => {
	const stateDir = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-stale-test-"));
	const env = { PI_CHROME_CDP_STATE_DIR: stateDir };
	const staleSocket = join(stateDir, `cdp-${TARGET_ID}.sock`);
	await writeFile(staleSocket, "stale");

	try {
		await expectOk(["stop", TARGET_PREFIX], env);
		assert.equal(existsSync(staleSocket), false);
	} finally {
		await rm(stateDir, { recursive: true, force: true });
	}
});

test("cdp CLI validates command shape before browser work", async () => {
	const stateDir = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-args-test-"));
	const env = { PI_CHROME_CDP_STATE_DIR: stateDir };

	try {
		assert.match(await expectOk(["--help"], env), /Usage: cdp <command>/);
		assert.match(await expectFail(["bogus"], env), /Unknown command: bogus/);
		assert.match(await expectFail(["snap"], env), /target ID required/);
		assert.match(
			await expectFail(["snap", TARGET_PREFIX], env),
			/No page list cached/,
		);
	} finally {
		await rm(stateDir, { recursive: true, force: true });
	}
});

test("cdp CLI validates required page-command args before starting daemons", async () => {
	const server = await startFakeCdpServer();
	const stateDir = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-preflight-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: "20",
	};
	await writeFile(
		join(stateDir, "pages.json"),
		JSON.stringify([{ targetId: TARGET_ID, type: "page", title: "Fake Page", url: "https://example.test/" }]),
	);

	try {
		assert.match(await expectFail(["eval", TARGET_PREFIX], env), /expression required/);
		assert.match(await expectFail(["type", TARGET_PREFIX], env), /text required/);
		assert.match(await expectFail(["nav", TARGET_PREFIX], env), /URL required/);
		assert.match(await expectFail(["batch", TARGET_PREFIX], env), /batch JSON required/);
		assert.match(await expectFail(["evalraw", TARGET_PREFIX], env), /CDP method required/);
		assert.equal(server.state.attached, 0);
	} finally {
		await runCdp(["stop"], env).catch(() => {});
		await rm(stateDir, { recursive: true, force: true });
		await server.close();
	}
});

test("cdp CLI reports CDP command and load-event timeouts", async () => {
	const server = await startFakeCdpServer({ noRespondMethods: ["Never.respond"] });
	const stateDir = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-timeout-test-"));
	const env = {
		PI_CHROME_CDP_WS_URL: server.wsUrl,
		PI_CHROME_CDP_STATE_DIR: stateDir,
		PI_CHROME_CDP_CDP_TIMEOUT_MS: "5",
		PI_CHROME_CDP_DAEMON_CONNECT_DELAY_MS: "5",
		PI_CHROME_CDP_DAEMON_CONNECT_RETRIES: "80",
	};
	await writeFile(
		join(stateDir, "pages.json"),
		JSON.stringify([{ targetId: TARGET_ID, type: "page", title: "Fake Page", url: "https://example.test/" }]),
	);

	try {
		assert.match(await expectFail(["evalraw", TARGET_PREFIX, "Never.respond", "{}"], env), /Timeout: Never\.respond/);
	} finally {
		await runCdp(["stop"], env).catch(() => {});
		await rm(stateDir, { recursive: true, force: true });
		await server.close();
	}

	const navServer = await startFakeCdpServer({ noLoadEvent: true });
	const navStateDir = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-load-timeout-test-"));
	const navEnv = {
		PI_CHROME_CDP_WS_URL: navServer.wsUrl,
		PI_CHROME_CDP_STATE_DIR: navStateDir,
		PI_CHROME_CDP_NAVIGATION_TIMEOUT_MS: "5",
		PI_CHROME_CDP_DAEMON_CONNECT_DELAY_MS: "5",
		PI_CHROME_CDP_DAEMON_CONNECT_RETRIES: "80",
	};
	await writeFile(
		join(navStateDir, "pages.json"),
		JSON.stringify([{ targetId: TARGET_ID, type: "page", title: "Fake Page", url: "https://example.test/" }]),
	);

	try {
		assert.match(await expectFail(["nav", TARGET_PREFIX, "https://example.test/slow"], navEnv), /Timeout waiting for event: Page\.loadEventFired/);
	} finally {
		await runCdp(["stop"], navEnv).catch(() => {});
		await rm(navStateDir, { recursive: true, force: true });
		await navServer.close();
	}
});

test("cdp CLI reports daemon startup connection and attach failures", async () => {
	const stateDir = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-daemon-fail-test-"));
	const env = {
		PI_CHROME_CDP_WS_URL: "ws://127.0.0.1:9/devtools/browser/dead",
		PI_CHROME_CDP_STATE_DIR: stateDir,
		PI_CHROME_CDP_DAEMON_CONNECT_DELAY_MS: "1",
		PI_CHROME_CDP_DAEMON_CONNECT_RETRIES: "0",
	};
	await writeFile(
		join(stateDir, "pages.json"),
		JSON.stringify([{ targetId: TARGET_ID, type: "page", title: "Fake Page", url: "https://example.test/" }]),
	);

	try {
		assert.match(await expectFail(["snap", TARGET_PREFIX], env), /Daemon failed to start/);
	} finally {
		await rm(stateDir, { recursive: true, force: true });
	}

	const server = await startFakeCdpServer({ attachError: true });
	const attachStateDir = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-attach-fail-test-"));
	const attachEnv = {
		PI_CHROME_CDP_WS_URL: server.wsUrl,
		PI_CHROME_CDP_STATE_DIR: attachStateDir,
		PI_CHROME_CDP_DAEMON_CONNECT_DELAY_MS: "1",
		PI_CHROME_CDP_DAEMON_CONNECT_RETRIES: "0",
	};
	await writeFile(
		join(attachStateDir, "pages.json"),
		JSON.stringify([{ targetId: TARGET_ID, type: "page", title: "Fake Page", url: "https://example.test/" }]),
	);

	try {
		assert.match(await expectFail(["snap", TARGET_PREFIX], attachEnv), /Daemon failed to start/);
		assert.equal(server.state.attached, 1);
	} finally {
		await rm(attachStateDir, { recursive: true, force: true });
		await server.close();
	}
});

test("cdp daemon rejects invalid NDJSON and continues", async () => {
	const server = await startFakeCdpServer();
	const stateDir = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-invalid-json-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",
	};

	await writeFile(
		join(stateDir, "pages.json"),
		JSON.stringify([{ targetId: TARGET_ID, type: "page", title: "Fake Page", url: "https://example.test/" }]),
	);

	try {
		await expectOk(["snap", TARGET_PREFIX], env);
		const socketPath = join(stateDir, `cdp-${TARGET_ID}.sock`);
		const raw = await socketRoundTrip(socketPath, "{bad\n");
		assert.deepEqual(JSON.parse(raw), { ok: false, error: "Invalid JSON request", id: null });
		const list = JSON.parse(await socketRoundTrip(socketPath, JSON.stringify({ id: 7, cmd: "list" }) + "\n"));
		assert.equal(list.id, 7);
		assert.equal(list.ok, true);
		assert.match(list.result, /Fake Page/);
		assert.equal(await expectOk(["eval", TARGET_PREFIX, "document.title"], env), "Fake Title");
	} finally {
		await runCdp(["stop"], env).catch(() => {});
		await rm(stateDir, { recursive: true, force: true });
		await server.close();
	}
});

test("cdp CLI reports daemon command validation errors and keeps daemon usable", async () => {
	const server = await startFakeCdpServer();
	const stateDir = await mkdtemp(join(tmpdir(), "pi-chrome-cdp-errors-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",
		PI_CHROME_CDP_MAX_BATCH_COMMANDS: "1",
	};

	try {
		await expectOk(["list"], env);
		assert.match(await expectFail(["click", TARGET_PREFIX], env), /CSS selector required/);
		assert.match(
			await expectFail(["clickxy", TARGET_PREFIX, "x", "20"], env),
			/x and y must be finite numbers/,
		);
		assert.match(await expectFail(["eval", TARGET_PREFIX, "throwError()"], env), /Uncaught/);
		assert.match(await expectFail(["click", TARGET_PREFIX, "#missing"], env), /Element not found: #missing/);
		assert.equal(await expectOk(["html", TARGET_PREFIX, ".missing"], env), "Element not found");
		assert.match(await expectFail(["nav", TARGET_PREFIX, "chrome-error://fail"], env), /Navigation failed/);
		assert.match(await expectFail(["clickxy", TARGET_PREFIX, "Infinity", "20"], env), /x and y must be finite numbers/);
		assert.match(await expectFail(["loadall", TARGET_PREFIX, "#more", "NaN"], env), /interval must be a finite number/);
		assert.match(
			await expectFail(["evalraw", TARGET_PREFIX, "DOM.getDocument", "{bad"], env),
			/Invalid JSON params/,
		);
		assert.match(
			await expectFail(["batch", TARGET_PREFIX, JSON.stringify({ cmd: "eval" })], env),
			/Batch JSON must be an array/,
		);
		assert.match(
			await expectFail(["batch", TARGET_PREFIX, JSON.stringify([{ args: [] }])], env),
			/Batch item missing cmd/,
		);
		assert.match(
			await expectFail(["batch", TARGET_PREFIX, JSON.stringify([{ cmd: "nope" }])], env),
			/Unknown command: nope/,
		);
		assert.match(
			await expectFail(["batch", TARGET_PREFIX, JSON.stringify([{ cmd: "list" }])], env),
			/Command not allowed in batch: list/,
		);
		assert.match(
			await expectFail(["batch", TARGET_PREFIX, JSON.stringify([{ cmd: "eval" }, { cmd: "html" }])], env),
			/Batch too large: 2 > 1/,
		);
		assert.equal(
			await expectOk(["eval", TARGET_PREFIX, "document.title"], env),
			"Fake Title",
		);
	} finally {
		await runCdp(["stop"], env).catch(() => {});
		await rm(stateDir, { recursive: true, force: true });
		await server.close();
	}
});