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]>
This commit is contained in:
Lucas Winther
2026-09-02 14:46:59 +02:00
co-authored by Claude Opus 5
parent b4e1933761
commit 53707b14ca
7 changed files with 279 additions and 34 deletions
+5 -2
View File
@@ -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 <url> <out.png>`
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.
+16 -6
View File
@@ -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
+22 -6
View File
@@ -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 <url> <out.png>` 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=<seed.js> --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.
+17 -12
View File
@@ -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.
+201
View File
@@ -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 <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;
+10
View File
@@ -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" && {
+8 -8
View File
@@ -67,10 +67,10 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
<thead className="border-b text-left text-muted-foreground">
<tr>
<th className="py-2">Post</th>
<th className="py-2 text-right">Carried in</th>
<th className="py-2 text-right">Allocated</th>
<th className="py-2 text-right">Spent</th>
<th className="py-2 text-right">Remaining</th>
<th className="py-2 pl-6 text-right">Carried in</th>
<th className="py-2 pl-6 text-right">Allocated</th>
<th className="py-2 pl-6 text-right">Spent</th>
<th className="py-2 pl-6 text-right">Remaining</th>
</tr>
</thead>
<tbody>
@@ -119,7 +119,7 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
)}
</td>
<td
className={`font-money py-2 text-right ${
className={`font-money py-2 pl-6 text-right ${
figures.carriedIn > 0
? "text-surplus"
: figures.carriedIn < 0
@@ -129,14 +129,14 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
>
{formatSignedMoney(figures.carriedIn, baseCurrency)}
</td>
<td className="font-money py-2 text-right">
<td className="font-money py-2 pl-6 text-right">
{formatMoney(figures.allocation, baseCurrency)}
</td>
<td className="font-money py-2 text-right">
<td className="font-money py-2 pl-6 text-right">
{formatMoney(figures.charges, baseCurrency)}
</td>
<td
className={`font-money py-2 text-right font-medium ${
className={`font-money py-2 pl-6 text-right font-medium ${
figures.remaining < 0 ? "text-overspend" : ""
}`}
>