/** * Screenshot a LedgerLens page once it has finished rendering real data. * * Drives headless Chrome over the DevTools Protocol using Node's built-in WebSocket, * so capturing the dashboard needs no Puppeteer or Playwright dependency. It waits on * a DOM condition rather than a fixed sleep: the dashboard polls the API and animates * its counters, so a timed capture reliably catches a page mid-count with zeros in * the KPI cards — a screenshot that misrepresents the product in the direction of * looking broken. * * node scripts/shot.mjs [width] [height] [waitForText] [afterJs] * * `afterJs` runs once the wait condition holds and before the capture, for the shots * that are behind an interaction — opening the audit drawer, scrolling to the ledger. * Anything it needs to wait for it can await itself; its result is awaited here. */ import { spawn } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const [url, out, width = "1512", height = "1100", waitFor = "", afterJs = ""] = process.argv.slice(2); if (!url || !out) { console.error("usage: node scripts/shot.mjs [w] [h] [waitForText]"); process.exit(2); } const CHROME = process.env.CHROME_PATH ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; const profile = mkdtempSync(join(tmpdir(), "ledgerlens-shot-")); const port = 9200 + Math.floor(Math.random() * 400); const chrome = spawn( CHROME, [ "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", "--no-first-run", "--force-color-profile=srgb", `--user-data-dir=${profile}`, `--remote-debugging-port=${port}`, `--window-size=${width},${height}`, "about:blank", ], { stdio: "ignore" }, ); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function target() { for (let i = 0; i < 60; i += 1) { try { const res = await fetch(`http://127.0.0.1:${port}/json/list`); const pages = (await res.json()).filter((t) => t.type === "page"); if (pages.length) return pages[0].webSocketDebuggerUrl; } catch { /* chrome not up yet */ } await sleep(250); } throw new Error("chrome did not expose a debugging target"); } function connect(wsUrl) { return new Promise((resolve, reject) => { const ws = new WebSocket(wsUrl); let id = 0; const pending = new Map(); ws.addEventListener("message", (event) => { const msg = JSON.parse(event.data); const entry = pending.get(msg.id); if (!entry) return; pending.delete(msg.id); if (msg.error) entry.reject(new Error(JSON.stringify(msg.error))); else entry.resolve(msg.result); }); ws.addEventListener("error", reject); ws.addEventListener("open", () => resolve({ send(method, params = {}) { id += 1; const messageId = id; return new Promise((res, rej) => { pending.set(messageId, { resolve: res, reject: rej }); ws.send(JSON.stringify({ id: messageId, method, params })); }); }, close: () => ws.close(), }), ); }); } let timedOut = false; try { const client = await connect(await target()); await client.send("Page.enable"); await client.send("Runtime.enable"); await client.send("Page.navigate", { url }); const deadline = Date.now() + 60_000; for (;;) { await sleep(400); // Case-insensitive: CSS text-transform is reflected in innerText, so a heading // styled uppercase would never match its source-cased string. const expression = waitFor ? `!!document.body && document.body.innerText.toLowerCase().includes(${JSON.stringify( waitFor.toLowerCase(), )})` : `document.readyState === "complete"`; const { result } = await client.send("Runtime.evaluate", { expression, returnByValue: true, }); if (result.value === true) break; if (Date.now() > deadline) { timedOut = true; console.error(`timed out waiting for: ${waitFor || "load"}`); break; } } if (afterJs) { const { result, exceptionDetails } = await client.send("Runtime.evaluate", { expression: afterJs, awaitPromise: true, returnByValue: true, }); if (exceptionDetails) { throw new Error(`afterJs threw: ${exceptionDetails.text} ${result?.description ?? ""}`); } if (result?.value !== undefined) console.error(`afterJs -> ${result.value}`); } // Let the counter animations and the entrance transition settle, so the capture // is the resting state rather than a frame partway through it. await sleep(1_800); const { data } = await client.send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false, }); writeFileSync(out, Buffer.from(data, "base64")); console.log(`wrote ${out}`); client.close(); } finally { chrome.kill("SIGKILL"); // SIGKILL returns before the kernel has finished reaping Chrome, which is still // flushing its profile directory. Removing it in that window raises ENOTEMPTY and // fails the command after the screenshot was already written successfully. await sleep(400); rmSync(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); } // A screenshot taken after a timeout is a screenshot of the wrong thing, and it // would be committed as if it were the right thing. if (timedOut) process.exit(1);