ledgerlens / scripts /shot.mjs
Abdr007's picture
LedgerLens — deployed tree
6741fc6
Raw
History Blame Contribute Delete
5.55 kB
/**
* 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 <url> <out.png> [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 <url> <out.png> [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);