Files
budget2.0/scripts/screenshot.ts
T
Lucas WintherandClaude Opus 5 53707b14ca feat(tooling): drive headless Chrome, and see the app for the first time
CLAUDE.md, AGENTS.md, ARCHITECTURE.md and TODO.md all said no browser exists
in this environment, so appearance had never been verified by anyone. Chrome
is on PATH. Its own --screenshot flag is what fails: it fires on the load
event, before this app's async IndexedDB read resolves, and captures a blank
page — which is presumably how the claim took hold.

scripts/screenshot.ts drives CDP instead: waits past the load event, seeds
IndexedDB via --eval-file and reboots the app with --reload, clicks with
--click, applies shadcn's class-based dark palette with --dark, and reports
console errors and uncaught exceptions. A page that renders blank from a
thrown error looks identical to an empty one, so the console output is half
the value.

Two defects found by simply looking:

- The month table's numeric columns had no horizontal padding, so figures ran
  into each other: "+7,175.00 DKK4,200.00 DKK". Now padded.
- Dev logged "The script has an unsupported MIME type ('text/html')" on every
  boot, because registerServiceWorker asks for sw.js and the "/*" catch-all
  answered with the HTML shell. Dev now serves an inert worker.

Confirmed working, first time seen: the carry meter (including red where a post
is overspent), the Task 5 rule-history editor, the Task 6 "change from here"
control, and baked FX rates showing as "manual - 2026-09-01" in Settings.

Recorded in the docs, with the limits stated: this container's fontconfig maps
every generic family to a monospace face, so screenshots need a FONTCONFIG_FILE
override or you will chase a font bug that does not exist; Chrome starts from
an empty profile each run; and no screenshot can judge how a design feels.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 14:46:59 +02:00

202 lines
7.3 KiB
TypeScript

/**
* Screenshots a running page with headless Chrome, and reports whatever the
* page logged to the console while doing it.
*
* bun scripts/screenshot.ts <url> <out.png> [--w=1280] [--h=900]
* [--wait=1500] [--full] [--click=<selector>]
* [--eval=<js>|--eval-file=<path>] [--dark] [--reload]
*
* Why this exists: ARCHITECTURE.md and TODO.md both say nothing here can run a
* browser, so appearance has never been verified. Chrome IS available, but its
* plain `--screenshot` flag fires on the load event — before this app's async
* IndexedDB read resolves, which captures an empty page. Driving CDP directly
* lets us wait, act, and read console errors.
*
* This does NOT replace a human: it cannot judge feel, and it renders with one
* font stack on one platform. It does prove a layout exists and is not broken.
*/
const [url, out, ...flags] = process.argv.slice(2);
if (!url || !out) {
console.error("usage: bun scripts/screenshot.ts <url> <out.png> [--w=] [--h=] [--wait=] [--full] [--click=] [--eval=] [--dark]");
process.exit(2);
}
const flag = (name: string, fallback?: string) =>
flags.find((f) => f.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback;
const has = (name: string) => flags.includes(`--${name}`);
const width = Number(flag("w", "1280"));
const height = Number(flag("h", "900"));
const waitMs = Number(flag("wait", "1500"));
const chrome = Bun.spawn(
[
process.env.CHROME ?? "google-chrome",
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--no-first-run",
"--no-default-browser-check",
"--hide-scrollbars",
"--force-device-scale-factor=1",
"--remote-debugging-port=0",
`--window-size=${width},${height}`,
...(has("dark") ? ["--force-dark-mode", "--enable-features=WebContentsForceDark"] : []),
"about:blank",
],
{ stdout: "ignore", stderr: "pipe" },
);
/** Chrome announces its debugging endpoint on stderr, once, at startup. */
async function debuggerUrl(): Promise<string> {
const reader = chrome.stderr.getReader();
const decoder = new TextDecoder();
let buffer = "";
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const match = /ws:\/\/[^\s]+/.exec(buffer);
if (match) return match[0];
}
throw new Error("Chrome never printed a DevTools endpoint");
}
const browserWs = await debuggerUrl();
const host = new URL(browserWs).host;
// The freshly launched about:blank tab is the one to drive.
const targets = (await (await fetch(`http://${host}/json/list`)).json()) as Array<{
type: string;
webSocketDebuggerUrl: string;
}>;
const page = targets.find((t) => t.type === "page");
if (!page) throw new Error("No page target to attach to");
const socket = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
socket.onopen = resolve;
socket.onerror = reject;
});
let nextId = 0;
const pending = new Map<number, (result: unknown) => void>();
const events: Array<{ method: string; params: any }> = [];
socket.onmessage = (event) => {
const message = JSON.parse(String(event.data));
if (message.id !== undefined) {
const settle = pending.get(message.id);
pending.delete(message.id);
settle?.(message);
} else {
events.push({ method: message.method, params: message.params });
}
};
/** Rejects on a CDP error rather than resolving undefined, which is otherwise
* indistinguishable from a command that legitimately returns nothing. */
function send(method: string, params: Record<string, unknown> = {}): Promise<any> {
const id = ++nextId;
// Register BEFORE sending: a reply that arrives first would otherwise find
// no handler and the promise would never settle.
const reply = new Promise<any>((resolve, reject) => {
pending.set(id, (message: any) => {
if (message.error) reject(new Error(`${method}: ${message.error.message}`));
else resolve(message.result);
});
});
socket.send(JSON.stringify({ id, method, params }));
return reply;
}
await send("Page.enable");
await send("Runtime.enable");
await send("Log.enable");
await send("Emulation.setDeviceMetricsOverride", {
width,
height,
deviceScaleFactor: 1,
mobile: width < 700,
});
if (has("dark")) {
await send("Emulation.setEmulatedMedia", {
features: [{ name: "prefers-color-scheme", value: "dark" }],
});
// shadcn's dark palette is class-based (`.dark`), not media-query based, so
// emulating the media feature alone changes nothing. Add the class too.
await send("Page.addScriptToEvaluateOnNewDocument", {
source: `document.addEventListener("DOMContentLoaded", () =>
document.documentElement.classList.add("dark"));`,
});
}
await send("Page.navigate", { url });
// Wait for the load event, then for the app's own async work (the IndexedDB
// read, which plain --screenshot never waits for).
const loadDeadline = Date.now() + 20_000;
while (!events.some((e) => e.method === "Page.loadEventFired") && Date.now() < loadDeadline) {
await Bun.sleep(50);
}
await Bun.sleep(waitMs);
const evalFile = flag("eval-file");
const evalJs = evalFile ? await Bun.file(evalFile).text() : flag("eval");
if (evalJs) {
const result = await send("Runtime.evaluate", { expression: evalJs, awaitPromise: true });
console.log("eval:", JSON.stringify(result?.result?.value ?? result?.result?.description ?? null));
await Bun.sleep(400);
}
// --reload exists for --eval scripts that seed IndexedDB: the app reads its
// dataset once at boot, so it has to boot again to see what was written.
if (has("reload")) {
events.length = 0;
await send("Page.navigate", { url });
const again = Date.now() + 20_000;
while (!events.some((e) => e.method === "Page.loadEventFired") && Date.now() < again) {
await Bun.sleep(50);
}
await Bun.sleep(waitMs);
}
const clickSelector = flag("click");
if (clickSelector) {
const clicked = await send("Runtime.evaluate", {
expression: `(() => { const el = document.querySelector(${JSON.stringify(clickSelector)});
if (!el) return "not found"; el.click(); return "clicked"; })()`,
});
console.log(`click ${clickSelector}: ${clicked?.result?.value}`);
await Bun.sleep(700);
}
const shot = await send("Page.captureScreenshot", {
format: "png",
captureBeyondViewport: has("full"),
});
await Bun.write(out, Buffer.from(shot.data, "base64"));
// Console output is the point of doing this over a plain --screenshot: a page
// that renders blank because of a thrown error looks identical to an empty one.
const logs = events
.filter((e) => e.method === "Runtime.consoleAPICalled" || e.method === "Log.entryAdded")
.map((e) =>
e.method === "Log.entryAdded"
? `[${e.params.entry.level}] ${e.params.entry.text}`
: `[${e.params.type}] ${(e.params.args ?? [])
.map((a: any) => a.value ?? a.description ?? a.type)
.join(" ")}`,
);
const exceptions = events
.filter((e) => e.method === "Runtime.exceptionThrown")
.map((e) => `[uncaught] ${e.params.exceptionDetails?.exception?.description ?? e.params.exceptionDetails?.text}`);
console.log(`wrote ${out} (${width}x${height})`);
for (const line of [...exceptions, ...logs]) console.log(" " + line);
if (exceptions.length === 0 && logs.length === 0) console.log(" (console clean)");
socket.close();
chrome.kill();
await chrome.exited;