1// Drive headless Chrome over CDP from a JSON list of steps, then print console errors. Needs Node 22+.
2//
3// PORT=9401 node drive.mjs steps.json # one PORT per parallel agent
4// env: PORT, WIDTH=1512, HEIGHT=982, SCALE=1, CHROME=<binary>
5//
6// Steps (a selector may also be [x, y] page coordinates):
7// {"goto": url, "settle": ms} {"viewport": [w, h]}
8// {"waitFor": sel, "timeout": ms} {"wait": ms}
9// {"click": sel} {"rightclick": sel} {"dblclick": sel} {"hover": sel}
10// {"drag": [[x1, y1], [x2, y2]], "steps": 12}
11// {"type": "text"} {"key": ["Enter", "Enter", 13, modifiers]} (modifiers: 1 Alt, 2 Ctrl, 4 Meta, 8 Shift)
12// {"eval": "js expression", "print": true}
13// {"shot": "/tmp/run/01.png"}
14// Any step may carry "after": ms to wait once it's done. Every run starts from a fresh profile.
15// Exit status: 0 clean, 1 page errors were logged, 2 a selector was missing or invisible (the run stops there).
16import { spawn } from "node:child_process";
17import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18import { tmpdir } from "node:os";
19import { dirname, join } from "node:path";
20
21const steps = JSON.parse(readFileSync(process.argv[2], "utf8"));
22const port = Number(process.env.PORT || 9333);
23let width = Number(process.env.WIDTH || 1512);
24let height = Number(process.env.HEIGHT || 982);
25const scale = Number(process.env.SCALE || 1);
26
27const CANDIDATES = process.platform === "darwin"
28 ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium"]
29 : ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"];
30const binary = process.env.CHROME || CANDIDATES.find(existsSync);
31if (!binary) {
32 console.error(`No Chrome found (looked in ${CANDIDATES.join(", ")}). Set CHROME=<path to a Chrome or Chromium binary>.`);
33 process.exit(2);
34}
35
36const profile = mkdtempSync(join(tmpdir(), `drive-${port}-`));
37const chrome = spawn(binary, [
38 "--headless=new", `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`,
39 "--hide-scrollbars", `--window-size=${width},${height}`,
40 ...(process.getuid?.() === 0 ? ["--no-sandbox"] : []),
41 "about:blank",
42], { stdio: "ignore" });
43chrome.on("error", (error) => {
44 console.error(`Couldn't start ${binary}: ${error.message}. Set CHROME=<path>.`);
45 rmSync(profile, { recursive: true, force: true });
46 process.exit(2);
47});
48
49const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
50const logs = [];
51let ws;
52let status = 0;
53
54try {
55 let target;
56 for (let i = 0; i < 60 && !target; i++) {
57 await sleep(150);
58 try {
59 target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json();
60 } catch {}
61 }
62 if (!target) {
63 // A wedged macOS pasteboard service hangs every Chrome launch before the debug port opens.
64 throw new Error("Chrome never opened its debug port. Another run may hold this PORT; on macOS, check whether pboard is hung (copy/paste broken everywhere) — `killall pboard` fixes it.");
65 }
66
67 ws = new WebSocket(target.webSocketDebuggerUrl);
68 await new Promise((r) => (ws.onopen = r));
69 let id = 0;
70 const pending = new Map();
71 ws.onmessage = (event) => {
72 const msg = JSON.parse(event.data);
73 if (msg.id && pending.has(msg.id)) {
74 pending.get(msg.id)(msg);
75 pending.delete(msg.id);
76 }
77 if (msg.method === "Runtime.exceptionThrown") logs.push("EXCEPTION " + (msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text));
78 if (msg.method === "Runtime.consoleAPICalled" && ["error", "warning"].includes(msg.params.type)) logs.push(msg.params.type.toUpperCase() + " " + msg.params.args.map((a) => a.value ?? a.description).join(" "));
79 if (msg.method === "Log.entryAdded" && msg.params.entry.level === "error") logs.push("LOG " + msg.params.entry.text + " " + (msg.params.entry.url ?? ""));
80 };
81 const send = (method, params = {}) => new Promise((resolve) => {
82 const n = ++id;
83 pending.set(n, resolve);
84 ws.send(JSON.stringify({ id: n, method, params }));
85 });
86 const evaluate = async (expression) => {
87 const r = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
88 if (r.result?.exceptionDetails) logs.push("EVAL " + r.result.exceptionDetails.exception?.description);
89 return r.result?.result?.value;
90 };
91 // Centre of the first matching element with a visible box, or null.
92 const locate = (sel) => evaluate(`(() => {
93 const el = document.querySelector(${JSON.stringify(sel)});
94 if (!el) return null;
95 el.scrollIntoView({ block: "nearest" });
96 const r = el.getBoundingClientRect();
97 return r.width && r.height ? [r.left + r.width / 2, r.top + r.height / 2] : null;
98 })()`);
99 const at = async (sel, i) => {
100 const point = Array.isArray(sel) ? sel : await locate(sel);
101 if (!point) {
102 status = 2;
103 throw new Error(`step ${i + 1}: ${JSON.stringify(sel)} is missing or has no visible box`);
104 }
105 return point;
106 };
107 const mouse = (type, [x, y], extra = {}) => send("Input.dispatchMouseEvent", { type, x, y, ...extra });
108 const click = async (point, button = "left", clickCount = 1) => {
109 await mouse("mouseMoved", point);
110 await mouse("mousePressed", point, { button, clickCount });
111 await mouse("mouseReleased", point, { button, clickCount });
112 };
113 const metrics = () => send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: scale, mobile: false });
114
115 await send("Page.enable");
116 await send("Runtime.enable");
117 await send("Log.enable");
118 await send("Network.enable");
119 await send("Network.setCacheDisabled", { cacheDisabled: true });
120 await metrics();
121
122 for (const [i, step] of steps.entries()) {
123 if (step.goto) {
124 await send("Page.navigate", { url: step.goto });
125 await sleep(step.settle ?? 1500);
126 } else if (step.viewport) {
127 [width, height] = step.viewport;
128 await metrics();
129 } else if (step.waitFor) {
130 const deadline = Date.now() + (step.timeout ?? 5000);
131 while (!(await locate(step.waitFor)) && Date.now() < deadline) await sleep(100);
132 await at(step.waitFor, i);
133 } else if (step.eval) {
134 const value = await evaluate(step.eval);
135 if (step.print) console.log("eval:", JSON.stringify(value));
136 } else if (step.click || step.rightclick || step.dblclick || step.hover) {
137 const point = await at(step.click ?? step.rightclick ?? step.dblclick ?? step.hover, i);
138 if (step.hover) await mouse("mouseMoved", point);
139 else if (step.rightclick) await click(point, "right");
140 else if (step.dblclick) {
141 await click(point, "left", 1);
142 await click(point, "left", 2);
143 } else await click(point);
144 } else if (step.drag) {
145 const [[x1, y1], [x2, y2]] = step.drag;
146 const n = step.steps ?? 12;
147 await mouse("mouseMoved", [x1, y1]);
148 await mouse("mousePressed", [x1, y1], { button: "left", clickCount: 1 });
149 for (let k = 1; k <= n; k++) {
150 await mouse("mouseMoved", [x1 + ((x2 - x1) * k) / n, y1 + ((y2 - y1) * k) / n], { button: "left", buttons: 1 });
151 await sleep(16);
152 }
153 await mouse("mouseReleased", [x2, y2], { button: "left", clickCount: 1 });
154 } else if (step.type) {
155 await send("Input.insertText", { text: step.type });
156 } else if (step.key) {
157 const [key, code, keyCode, modifiers = 0] = step.key;
158 // Text makes the key act like a real keystroke (Enter submits, letters type); skip it under Ctrl/Meta chords.
159 const text = modifiers & 6 ? undefined : key === "Enter" ? "\r" : key.length === 1 ? key : undefined;
160 await send("Input.dispatchKeyEvent", { type: "keyDown", key, code, windowsVirtualKeyCode: keyCode, modifiers, text });
161 await send("Input.dispatchKeyEvent", { type: "keyUp", key, code, windowsVirtualKeyCode: keyCode, modifiers });
162 } else if (step.wait) {
163 await sleep(step.wait);
164 } else if (step.shot) {
165 const r = await send("Page.captureScreenshot", { format: "png" });
166 mkdirSync(dirname(step.shot), { recursive: true });
167 writeFileSync(step.shot, Buffer.from(r.result.data, "base64"));
168 console.log("shot", step.shot);
169 } else {
170 logs.push(`UNKNOWN step ${i + 1}: ${JSON.stringify(step)}`);
171 }
172 await sleep(step.after ?? (step.click || step.rightclick || step.dblclick ? 350 : step.type || step.key || step.hover ? 250 : 0));
173 }
174} catch (error) {
175 console.error(error.message);
176 status ||= 2;
177} finally {
178 console.log(logs.length ? logs.join("\n") : "no console errors");
179 ws?.close();
180 // Chrome keeps writing to its profile until it has really exited.
181 if (chrome.exitCode === null) {
182 chrome.kill();
183 await new Promise((r) => {
184 chrome.once("exit", r);
185 setTimeout(r, 3000);
186 });
187 }
188 rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
189}
190process.exit(status || (logs.length ? 1 : 0));