Files

138 lines
4.0 KiB
JavaScript

// node preview/shot.mjs <out.png> <url> [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 <out.png> <url> [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);