// node preview/shot.mjs [width] [height] // // Headless Chrome over the DevTools protocol: a screenshot plus everything the // page said on the console, and a note if the page stopped answering — which // a plain --screenshot run would only report as a silent timeout. // DUMP=1 also print the page text // PROBE=js evaluate an expression at the end and print its value import { spawn } from "node:child_process"; import { writeFileSync } from "node:fs"; const [out, url, w = "1100", h = "720"] = process.argv.slice(2); if (!out || !url) { console.error("usage: node preview/shot.mjs [width] [height]"); process.exit(2); } const port = 9334; const chrome = process.env.CHROME ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; const proc = spawn( chrome, [ "--headless=new", "--disable-gpu", "--hide-scrollbars", `--remote-debugging-port=${port}`, `--window-size=${w},${h}`, "--user-data-dir=/tmp/bcal-chrome-profile", "about:blank", ], { stdio: "ignore" }, ); const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); let targets = []; for (let i = 0; i < 40; i += 1) { try { targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); if (targets.length > 0) break; } catch { /* not up yet */ } await wait(250); } const page = targets.find((target) => target.type === "page"); if (!page) { console.error("shot: chrome did not come up"); proc.kill(); process.exit(1); } const ws = new WebSocket(page.webSocketDebuggerUrl); await new Promise((resolve) => { ws.onopen = resolve; }); let id = 0; const pending = new Map(); const logs = []; ws.onmessage = (message) => { const data = JSON.parse(String(message.data)); if (data.id && pending.has(data.id)) { pending.get(data.id)(data); pending.delete(data.id); } if (data.method === "Runtime.consoleAPICalled") { logs.push( `${data.params.type}: ${data.params.args .map((arg) => arg.value ?? arg.description) .join(" ")}`, ); } if (data.method === "Runtime.exceptionThrown") { const details = data.params.exceptionDetails; logs.push(`EXC: ${details.exception?.description ?? details.text}`); } }; const send = (method, params = {}, timeout = 15_000) => new Promise((resolve, reject) => { const next = ++id; const timer = setTimeout(() => { pending.delete(next); reject(new Error(`${method} timed out after ${timeout} ms`)); }, timeout); pending.set(next, (value) => { clearTimeout(timer); resolve(value); }); ws.send(JSON.stringify({ id: next, method, params })); }); await send("Emulation.setDeviceMetricsOverride", { width: Number(w), height: Number(h), deviceScaleFactor: 2, mobile: false, }); await send("Page.enable"); await send("Runtime.enable"); await send("Page.navigate", { url }); await wait(Number(process.env.SETTLE ?? 2500)); let alive = true; try { const ping = await send("Runtime.evaluate", { expression: "1 + 1", returnByValue: true }, 5000); alive = ping.result?.result?.value === 2; } catch { alive = false; } if (!alive) { console.log("shot: PAGE UNRESPONSIVE (main thread busy — a hang)"); } if (alive && process.env.PROBE) { const probe = await send("Runtime.evaluate", { expression: process.env.PROBE, returnByValue: true, }); console.log("PROBE:", JSON.stringify(probe.result?.result?.value ?? probe.result)); } if (alive && process.env.DUMP) { const text = await send("Runtime.evaluate", { expression: "document.body.innerText.slice(0, 1500)", returnByValue: true, }); console.log("TEXT:", text.result?.result?.value); } try { const shot = await send("Page.captureScreenshot", { format: "png" }, 10_000); writeFileSync(out, Buffer.from(shot.result.data, "base64")); console.log("wrote", out); } catch (error) { console.log("shot: no screenshot —", error.message); } if (logs.length > 0) console.log("LOGS:\n" + logs.join("\n")); ws.close(); proc.kill(); process.exit(alive ? 0 : 3);