/** * Screenshot every generated HTML output with one headless Chrome, and record console * errors, page errors and failed requests per file. One Chrome for the whole batch, * navigating file by file, so 90 pages take minutes rather than a Chrome start each. * * Usage: node shoot.mjs [seconds-before-capture] [width] [height] * Writes //.png and /console.json */ import { spawn } from "node:child_process"; import { mkdtempSync, rmSync, readdirSync, mkdirSync, writeFileSync, statSync, existsSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; const CHROME = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"; const PORT = 9346; const [, , OUTDIR, PNGDIR, SECS = "5", W = "1600", H = "900"] = process.argv; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const files = []; for (const model of readdirSync(OUTDIR)) { const d = path.join(OUTDIR, model); if (!statSync(d).isDirectory()) continue; for (const fn of readdirSync(d)) { if (!fn.endsWith(".html")) continue; if (process.env.SKIP_EXISTING && existsSync(path.join(PNGDIR, model, fn.replace(/\.html$/, ".png")))) continue; files.push([model, fn]); } } files.sort(); const profileDir = mkdtempSync(path.join(tmpdir(), "shoot-")); const chrome = spawn(CHROME, [ "--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profileDir}`, `--window-size=${W},${H}`, "--hide-scrollbars", "--no-first-run", "--enable-webgl", "--ignore-gpu-blocklist", "--enable-unsafe-swiftshader", "--use-angle=default", "about:blank", ], { stdio: ["ignore", "ignore", "ignore"] }); let target = null; for (let i = 0; i < 60 && !target; i++) { try { const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json(); target = list.find((t) => t.type === "page" && t.webSocketDebuggerUrl); } catch {} if (!target) await sleep(400); } if (!target) { console.error("no devtools target"); process.exit(1); } const ws = new WebSocket(target.webSocketDebuggerUrl); await new Promise((r) => ws.addEventListener("open", r, { once: true })); let id = 0; const pending = new Map(); const call = (method, params = {}) => new Promise((resolve, reject) => { const n = ++id; pending.set(n, { resolve, reject }); ws.send(JSON.stringify({ id: n, method, params })); }); let log = []; ws.addEventListener("message", (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.reject(new Error(m.error.message)) : p.resolve(m.result); return; } if (m.method === "Runtime.consoleAPICalled" && ["error", "warning"].includes(m.params.type)) { log.push("console." + m.params.type + ": " + m.params.args.map((a) => a.value ?? a.description ?? a.type).join(" ").slice(0, 200)); } if (m.method === "Runtime.exceptionThrown") { const d = m.params.exceptionDetails; log.push("PAGE ERROR: " + (d.exception?.description || d.text).slice(0, 200)); } if (m.method === "Network.loadingFailed") { log.push("NET FAILED: " + m.params.errorText + " (" + m.params.type + ")"); } if (m.method === "Network.responseReceived" && m.params.response.status >= 400) { log.push("HTTP " + m.params.response.status + ": " + m.params.response.url.slice(0, 120)); } }); await call("Runtime.enable"); await call("Network.enable"); await call("Page.enable"); await call("Emulation.setDeviceMetricsOverride", { width: Number(W), height: Number(H), deviceScaleFactor: 1, mobile: false, }); const report = {}; for (const [model, fn] of files) { const abs = path.resolve(OUTDIR, model, fn); const url = "file:///" + abs.replace(/\\/g, "/"); log = []; // Reset to blank first so a hung page from the previous file cannot leak in. await call("Page.navigate", { url: "about:blank" }); await sleep(200); await call("Page.navigate", { url }); await sleep(Number(SECS) * 1000); const shot = await call("Page.captureScreenshot", { format: "png" }); mkdirSync(path.join(PNGDIR, model), { recursive: true }); writeFileSync(path.join(PNGDIR, model, fn.replace(/\.html$/, ".png")), Buffer.from(shot.data, "base64")); const uniq = [...new Set(log)].slice(0, 10); report[`${model}/${fn}`] = uniq; console.log(`${model}/${fn} ${uniq.length ? uniq.length + " issue(s): " + uniq[0] : "clean"}`); } mkdirSync(PNGDIR, { recursive: true }); let prev = {}; try { prev = JSON.parse(readFileSync(path.join(PNGDIR, "console.json"), "utf8")); } catch {} writeFileSync(path.join(PNGDIR, "console.json"), JSON.stringify({ ...prev, ...report }, null, 1)); ws.close(); chrome.kill(); await sleep(600); try { rmSync(profileDir, { recursive: true, force: true }); } catch {} console.log(`done: ${files.length} files`);