Spaces:
Sleeping
Sleeping
File size: 5,549 Bytes
6741fc6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | /**
* 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);
|