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:
co-authored by
Claude Opus 5
parent
b4e1933761
commit
53707b14ca
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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" && {
|
||||
|
||||
@@ -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" : ""
|
||||
}`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user