| 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). |
| 16 | import { spawn } from "node:child_process"; |
| 17 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 18 | import { tmpdir } from "node:os"; |
| 19 | import { dirname, join } from "node:path"; |
| 20 | |
| 21 | const steps = JSON.parse(readFileSync(process.argv[2], "utf8")); |
| 22 | const port = Number(process.env.PORT || 9333); |
| 23 | let width = Number(process.env.WIDTH || 1512); |
| 24 | let height = Number(process.env.HEIGHT || 982); |
| 25 | const scale = Number(process.env.SCALE || 1); |
| 26 | |
| 27 | const 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"]; |
| 30 | const binary = process.env.CHROME || CANDIDATES.find(existsSync); |
| 31 | if (!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 | |
| 36 | const profile = mkdtempSync(join(tmpdir(), `drive-${port}-`)); |
| 37 | const 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" }); |
| 43 | chrome.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 | |
| 49 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 50 | const logs = []; |
| 51 | let ws; |
| 52 | let status = 0; |
| 53 | |
| 54 | try { |
| 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 | } |
| 190 | process.exit(status || (logs.length ? 1 : 0)); |