From 53707b14cac4f085988daade2092e7712872cd9a Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 14:46:59 +0200 Subject: [PATCH] feat(tooling): drive headless Chrome, and see the app for the first time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- AGENTS.md | 7 +- ARCHITECTURE.md | 22 +++- CLAUDE.md | 28 ++++- TODO.md | 29 +++-- scripts/screenshot.ts | 201 ++++++++++++++++++++++++++++++++ src/index.ts | 10 ++ src/ui/components/PostTable.tsx | 16 +-- 7 files changed, 279 insertions(+), 34 deletions(-) create mode 100644 scripts/screenshot.ts diff --git a/AGENTS.md b/AGENTS.md index b5183a2..d935c21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,5 +18,8 @@ Then, depending on what you are doing: Two rules worth knowing before your first edit: 1. **Bun only.** `bun test`, `bun run`, `bunx`. Never npm, jest, vitest or vite. -2. **There is no browser here.** Anything visual, offline, or focus-related - cannot be verified in this environment. Say so rather than claiming it works. +2. **There IS a browser here** — `bun scripts/screenshot.ts ` + drives headless Chrome and hands you a PNG to look at. Read CLAUDE.md's + "Verification limits" first: the container's fonts lie, and offline, the + install prompt, the `.ods` in a spreadsheet, and how anything *feels* still + need a human. Say so rather than claiming it works. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e5b2d55..6a1a11e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -136,12 +136,22 @@ Adding a "missing" guard here is a regression, not a fix: Most logic is pure and tested directly with `bun test`. The domain carries the bulk of the coverage; the UI is thin by design. -**There is no browser in the agent environment.** Nothing has ever verified -visual appearance, real offline behaviour, the install or update prompts, -keyboard focus traversal, or the `.ods` opening in a spreadsheet. Where a check -could not be run, the honest substitute was used and recorded — for example the -`.ods` is validated structurally by unzipping it and asserting `mimetype` is -first and stored, every XML part parses, and numeric cells carry `office:value`. +**A browser IS available**, via `scripts/screenshot.ts` — headless Chrome +driven over CDP, because Chrome's own `--screenshot` fires on the load event +and captures an empty page before this app's async IndexedDB read resolves. +That script is how the month view, the carry meter and the rule editors were +first seen by anyone. It reports console errors too, which is the point: a page +that renders blank because of a thrown error looks identical to an empty one. + +Its limits are real. The container's fontconfig maps every generic family to a +monospace face, so screenshots need a `FONTCONFIG_FILE` override to be +representative; Chrome starts with an empty profile each run; and no screenshot +can judge how a design feels. Real offline behaviour, the install and update +prompts, and the `.ods` opening in a spreadsheet remain unverifiable here. +Where a check could not be run, the honest substitute was used and recorded — +the `.ods` is validated structurally by unzipping it and asserting `mimetype` +is first and stored, every XML part parses, and numeric cells carry +`office:value`. `src/ui/eventCapture.test.ts` is a *source-level* guard rather than a behavioural one, because the bug it prevents depends on real browser render diff --git a/CLAUDE.md b/CLAUDE.md index 1981b78..ff40d3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,9 +167,25 @@ Adding a "missing" guard here is a regression: ## Verification limits -There is no browser in this environment. Visual appearance, offline behaviour, -install and update prompts, keyboard focus traversal, and opening the generated -`.ods` in a spreadsheet **cannot be verified here**. Do not claim you checked -them. Substitute a real check where one exists — structural validation of the -`.ods`, curl against the dev server, pure-function tests — and state plainly -what still needs a human. +**There IS a browser here.** `google-chrome` is on PATH, and +`bun scripts/screenshot.ts ` drives it over CDP: it waits past +the async IndexedDB read (which plain `--screenshot` does not), can seed data +with `--eval-file`, click with `--click`, and reports console errors and +uncaught exceptions. Read the PNG it writes — you can see the app. Use it +before claiming anything about layout. + +Two things to know before trusting a screenshot: + +- **This container's fontconfig resolves every generic family to Fira Code, a + monospace font**, so the whole UI renders monospaced in a way no real user + sees. Point `FONTCONFIG_FILE` at an override that maps the generics to + installed sans/mono faces, or you will "fix" a font bug that does not exist. +- Chrome launches with a fresh profile each run, so IndexedDB starts empty and + the app re-seeds. Pass `--eval-file= --reload` for populated data. + +Still **not** verifiable here, so do not claim it: real offline behaviour, the +install and update prompts, opening the generated `.ods` in a spreadsheet, and +anything about how the design *feels*. A screenshot proves a layout exists and +is not broken; it does not prove it is good. Substitute a real check where one +exists — structural validation of the `.ods`, curl against the dev server, +pure-function tests — and state plainly what still needs a human. diff --git a/TODO.md b/TODO.md index b017dfa..9ab2ee4 100644 --- a/TODO.md +++ b/TODO.md @@ -82,20 +82,25 @@ evidence and anti-reference, not as something to preserve. - **The dev-only `src/manifest.webmanifest` placeholder hardcodes `scope: "/"`.** Harmless in production, misleading if you dev-serve under a subpath. -## 5. Needs a human with a browser +## 5. Still needs a human -Nothing in the agent environment can run a browser, so these have never been -seen by anyone: +`scripts/screenshot.ts` drives headless Chrome, so the list here is much +shorter than it was. **Seen and confirmed working:** the carry meter (it +renders, and shows red where a post is overspent), the rule-history editor in +Settings, the "change from here" control, the baked FX rates arriving in a +fresh dataset, and every route at desktop and mobile widths. -- the **carry meter** under each post row in the month view — the signature - design element, verified only as a CSS gradient expression; -- the **rule-history editor** in Settings and the **"change from here"** control - in the month view. Their arithmetic is verified through the real fold, and - both badge predicates were driven as pure functions, but nobody has seen the - expand/collapse, the layout, the disabled states, or the focus order; -- **offline behaviour**, the **install prompt**, and the **update prompt**; +Genuinely outstanding: + +- **How it looks and feels.** A screenshot proves a layout is not broken. It + cannot tell you the design is good, and the container's fonts are not the + owner's fonts. +- **Offline behaviour**, the **install prompt**, and the **update prompt**. The + dev server now serves a deliberately inert `sw.js`, so dev exercises + registration but nothing else. - the generated **`.ods` opening in a real spreadsheet** (validated - structurally: mimetype first and stored, well-formed XML, numeric cells); + structurally: mimetype first and stored, well-formed XML, numeric cells). - **keyboard flow in fast entry** — Tab traversal, Backspace-to-remove, and - focus placement after a row auto-appends; + focus placement after a row auto-appends. Drivable over CDP in principle; + not yet done. - **GitHub Actions** has never been executed. diff --git a/scripts/screenshot.ts b/scripts/screenshot.ts new file mode 100644 index 0000000..5fe5b36 --- /dev/null +++ b/scripts/screenshot.ts @@ -0,0 +1,201 @@ +/** + * Screenshots a running page with headless Chrome, and reports whatever the + * page logged to the console while doing it. + * + * bun scripts/screenshot.ts [--w=1280] [--h=900] + * [--wait=1500] [--full] [--click=] + * [--eval=|--eval-file=] [--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 [--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 { + 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 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 = {}): Promise { + 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((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; diff --git a/src/index.ts b/src/index.ts index 0f6d41e..f4f12c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,16 @@ const server = serve({ new Response(Bun.file(new URL("./icon-512.png", import.meta.url)), { headers: { "content-type": "image/png" }, }), + // registerServiceWorker() asks for sw.js on every boot. Without this the + // catch-all answers with the HTML shell and the browser logs + // "The script has an unsupported MIME type ('text/html')" — caught and + // harmless, but noise that hides real errors. A worker that claims no + // clients and handles no fetches keeps registration honest in dev without + // caching anything, which would fight HMR. + [`${basePath}sw.js`]: () => + new Response("/* dev: intentionally does nothing */\n", { + headers: { "content-type": "text/javascript", "cache-control": "no-cache" }, + }), "/*": index, }, development: process.env.NODE_ENV !== "production" && { diff --git a/src/ui/components/PostTable.tsx b/src/ui/components/PostTable.tsx index f541b08..5f979a1 100644 --- a/src/ui/components/PostTable.tsx +++ b/src/ui/components/PostTable.tsx @@ -67,10 +67,10 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props) Post - Carried in - Allocated - Spent - Remaining + Carried in + Allocated + Spent + Remaining @@ -119,7 +119,7 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props) )} 0 ? "text-surplus" : figures.carriedIn < 0 @@ -129,14 +129,14 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props) > {formatSignedMoney(figures.carriedIn, baseCurrency)} - + {formatMoney(figures.allocation, baseCurrency)} - + {formatMoney(figures.charges, baseCurrency)}