From ded1f11acb8315d91c9b16e283679735d182f7bd Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:16:22 +0200 Subject: [PATCH] test(frontend): a render harness, because the null-is-unknown rule lived untested frontend/ had no test runner at all -- no harness, no CI check beyond the release build. The rule that matters most in this app now lives there: a platform with NO reading must render as an em dash, never 0.00. Showing zero reports a loss that did not happen, and it does so most convincingly to the user whose collector is broken, who is precisely the person who must not be told everything is fine. The Go side proves null survives to JSON, with negative controls. Nothing proved the render. THE BARRIER, and the first step the bead named: main.ts does `const root = document.querySelector("#app")!` at module scope and imports the Wails runtime, so importing it from Node needs a DOM, a CSS loader and Wails stubs. Nothing in it could be tested. So the pure render functions move to frontend/src/render/ -- no DOM, no globals, no import-time side effects. renderFleetSection, relativeTime, escapeHtml and formatBalance are moved VERBATIM; main.ts imports them back. The built bundle goes 51.21 kB -> 51.26 kB, which is the extraction being behaviour-neutral. scripts/fleet_render_check.mjs then drives the REAL functions against constructed state and asserts on the HTML they return -- 31 checks covering the null rule and its mirror (a MEASURED zero must still print as money), shared-platform marking, escaping of the server URL and slugs into an innerHTML sink, freshness phrasing, and null vs empty lists. It deliberately does NOT assert on source text. This repo and its sibling have both been bitten by tests that matched their own prose, where a check that a file CONTAINS a guard passed against a build where the guard was unreachable. Proven by control: turning the null guard into `?? 0` fails "an unknown platform does NOT render a zero amount", and dropping escapeHtml from the server URL fails "the server URL is escaped". CI gains a frontend job -- typecheck, render checks, vite build. Before this the frontend was built ONLY during a release, so a broken one reached main and was found at tag time: the same shape as the docs site building only on deploy, where the gate ran after the decision it was meant to inform. Refs: CashPilot-Desktop-tft --- .github/workflows/ci.yml | 36 +++++ .gitignore | 3 + CLAUDE.md | 25 ++++ frontend/package.json | 4 +- frontend/scripts/fleet_render_check.mjs | 191 ++++++++++++++++++++++++ frontend/src/main.ts | 102 +------------ frontend/src/render/fleet.ts | 79 ++++++++++ frontend/src/render/format.ts | 67 +++++++++ frontend/tsconfig.harness.json | 17 +++ 9 files changed, 424 insertions(+), 100 deletions(-) create mode 100644 frontend/scripts/fleet_render_check.mjs create mode 100644 frontend/src/render/fleet.ts create mode 100644 frontend/src/render/format.ts create mode 100644 frontend/tsconfig.harness.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4790729..bdca298 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,42 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + frontend: + name: frontend (typecheck + render checks) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: '26' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install + working-directory: frontend + run: npm ci + + # The frontend was built ONLY during a release, so a broken one reached + # main and was found at tag time. Same shape as the docs site building only + # on deploy: the gate ran after the decision it was meant to inform. + - name: Typecheck + working-directory: frontend + run: npx tsc --noEmit -p tsconfig.json + + # Browser-free checks on the real render functions. They exist for one + # rule above all: a platform with NO reading must render as an em dash, + # never 0.00 -- showing zero reports a loss that did not happen, most + # convincingly to the user whose collector is broken. + - name: Render checks + working-directory: frontend + run: npm test + + - name: Build + working-directory: frontend + run: npx vite build + # Advisory dependency vulnerability scan (govulncheck). Non-blocking on purpose: # the only current findings are upstream-unfixed github.com/docker/docker daemon # CVEs (Fixed in: N/A) that a Docker *client* app cannot resolve by bumping the diff --git a/.gitignore b/.gitignore index a8f5ff2..90f6e2f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ pnpm-lock.yaml .beads/ .claude/ .codex/ + +# Compiled output for the browser-free render harness (frontend/scripts/*.mjs). +/frontend/.harness-build/ diff --git a/CLAUDE.md b/CLAUDE.md index bf8cc7d..7fa87b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,31 @@ go test -race -coverprofile=coverage.out ./... go tool cover -html=coverage.out ``` +### Frontend + +`frontend/src/render/` holds the PURE render functions — no DOM, no globals, no +import-time side effects — so a Node harness can import them. Everything else +still lives in `main.ts`, which grabs `#app` at module scope and therefore cannot +be imported by anything. + +```bash +cd frontend && npm test # compiles src/render/ and runs scripts/*_check.mjs +``` + +The harness drives the real functions against constructed state and asserts on +the HTML they return. **Never assert on the source text** — this repo and its +sibling have both been bitten by tests that matched their own prose, where a +check that a file *contains* a guard passed against a build where the guard was +unreachable. + +The rule these exist for: a platform with **no reading** renders as an em dash, +never `0.00`. Showing zero reports a loss that did not happen, most convincingly +to the user whose collector is broken. + +CI runs typecheck → render checks → `vite build` on every PR. Before this, the +frontend was only built during a release, so a broken one reached main and was +found at tag time. + ## Build & Release Tag releases (`v*`) build Linux/macOS/Windows on free GitHub-hosted runners diff --git a/frontend/package.json b/frontend/package.json index acc623e..cd1da0b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite --host 127.0.0.1", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "harness:build": "tsc -p tsconfig.harness.json", + "test": "npm run harness:build && node scripts/fleet_render_check.mjs" }, "dependencies": {}, "devDependencies": { diff --git a/frontend/scripts/fleet_render_check.mjs b/frontend/scripts/fleet_render_check.mjs new file mode 100644 index 0000000..d53e238 --- /dev/null +++ b/frontend/scripts/fleet_render_check.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node +// Browser-free checks on the fleet panel's rendering. CashPilot-Desktop-tft. +// +// WHY THIS SHAPE +// -------------- +// frontend/ had no test runner at all, and the rule that matters most in this +// app now lives here: a platform with NO reading must render as an em dash, +// never as 0.00. The Go side proves null survives to JSON, with negative +// controls. Nothing proved the render. +// +// It drives the REAL function against constructed state and asserts on the HTML +// it returns. It deliberately does NOT assert on the source text: the repository +// has been bitten repeatedly by tests that matched their own prose — a check +// that main.ts CONTAINS a null guard passes against a build where the guard is +// unreachable. +// +// Modelled on CashPilot's own browser-free harnesses (currency_check.mjs, +// fleet_staleness_check.mjs, ...), which are wired into CI the same way. +// +// node scripts/fleet_render_check.mjs # against ./.harness-build + +import { renderFleetSection } from "../.harness-build/render/fleet.js"; +import { escapeHtml, formatBalance, relativeTime } from "../.harness-build/render/format.js"; + +let failures = 0; +let checks = 0; + +function check(name, condition, detail = "") { + checks++; + if (condition) return; + failures++; + console.error(`FAIL ${name}${detail ? `\n ${detail}` : ""}`); +} + +/** A FleetView with sane defaults, overridable per case. */ +function view(overrides = {}) { + return { + serverUrl: "http://cashpilot.local:8080", + reportedAt: new Date(Date.now() - 12 * 60_000).toISOString(), + windowDays: 30, + currency: "USD", + platforms: [], + totalUsd: null, + withoutReadings: [], + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// The rule this whole harness exists for. +// --------------------------------------------------------------------------- + +{ + const html = renderFleetSection( + view({ + platforms: [{ slug: "storj", usd: null, shared: false }], + totalUsd: null, + withoutReadings: ["storj"], + }), + ); + check("an unknown platform renders an em dash", html.includes("—"), html.slice(0, 400)); + check( + "an unknown platform does NOT render a zero amount", + !/\$\s*0\.00/.test(html), + "a missing reading was shown as money", + ); + check("an unknown TOTAL says so in words", html.includes("nothing collected yet")); + check("the platforms with no reading are named", html.includes("storj")); +} + +{ + // The mirror. A guard that renders everything as a dash is as useless as none: + // a real, measured 0.00 must still print as money. + const html = renderFleetSection( + view({ platforms: [{ slug: "grass", usd: 0, shared: false }], totalUsd: 0 }), + ); + check("a MEASURED zero renders as money, not a dash", /0\.00/.test(html), html.slice(0, 400)); + check("a measured zero is not called unknown", !html.includes("nothing collected yet")); +} + +{ + const html = renderFleetSection( + view({ platforms: [{ slug: "grass", usd: 4.25, shared: false }], totalUsd: 4.25 }), + ); + check("a known amount is rendered", /4\.25/.test(html), html.slice(0, 400)); + check("a known total is labelled as partial", html.includes("known platforms only")); +} + +// --------------------------------------------------------------------------- +// Per-platform, not per-device. +// --------------------------------------------------------------------------- + +{ + const html = renderFleetSection( + view({ + platforms: [ + { slug: "grass", usd: 4, shared: true }, + { slug: "honeygain", usd: 1, shared: false }, + ], + totalUsd: 5, + }), + ); + check("a shared platform is marked", html.includes(">shared<"), html.slice(0, 600)); + check("a shared platform gets the explanatory class", html.includes("earning-chip shared")); + check( + "the header explains what shared means for the number", + html.includes("not this machine's"), + "the panel lets the figure imply this machine earned it", + ); + check( + "a platform on ONE worker is not marked shared", + (html.match(/>shared', + platforms: [{ slug: "", usd: 1, shared: false }], + withoutReadings: ["bold"], + }), + ); + check("the server URL is escaped", !html.includes("alert(2)")); + check("the no-reading list is escaped", !html.includes("bold")); + check("the escaped forms are present", html.includes("<script>")); +} + +// --------------------------------------------------------------------------- +// Freshness. The heartbeat is on a timer, so a figure with no age reads as +// current when it may be an hour old. +// --------------------------------------------------------------------------- + +{ + const now = Date.UTC(2026, 0, 2, 12, 0, 0); + check("minutes", relativeTime(new Date(now - 12 * 60_000).toISOString(), now) === "12 minutes ago"); + check("singular minute", relativeTime(new Date(now - 60_000).toISOString(), now) === "1 minute ago"); + check("just now", relativeTime(new Date(now - 5_000).toISOString(), now) === "just now"); + check("hours", relativeTime(new Date(now - 3 * 3600_000).toISOString(), now) === "3 hours ago"); + check("days", relativeTime(new Date(now - 50 * 3600_000).toISOString(), now) === "2 days ago"); + check( + "an unparseable stamp yields nothing rather than 'Invalid Date'", + relativeTime("not a date", now) === "", + ); + + const html = renderFleetSection(view({ reportedAt: "not a date" })); + check("an unparseable stamp is simply omitted", !html.includes("Invalid"), html.slice(0, 400)); +} + +// --------------------------------------------------------------------------- +// Empty and absent shapes. The Go side sends null, not [], for "none". +// --------------------------------------------------------------------------- + +{ + const html = renderFleetSection(view({ platforms: null, withoutReadings: null })); + check("null lists do not throw", typeof html === "string"); + check("an empty panel says why", html.includes("no figures for the platforms on this machine yet")); +} + +// --------------------------------------------------------------------------- +// The helpers, since they are now shared and their edge cases are load-bearing. +// --------------------------------------------------------------------------- + +check("escapeHtml handles null", escapeHtml(null) === ""); +check("escapeHtml escapes quotes", escapeHtml(`"'`) === ""'"); +check( + "formatBalance falls back for a reward token", + formatBalance(12.5, "GRASS") === "12.50 GRASS", + formatBalance(12.5, "GRASS"), +); +check("formatBalance strips a hostile code", !formatBalance(1, '">').includes("")); +check("formatBalance survives a non-finite amount", formatBalance(NaN, "USD").includes("0.00")); + +console.log(`\n${checks - failures}/${checks} checks passed`); +if (failures) { + console.error(`\n${failures} FAILED`); + process.exit(1); +} +console.log("fleet render check passed"); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 8495f63..dc5d930 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -29,7 +29,9 @@ import { StartService, StopService, } from "../wailsjs/go/main/App"; -import type { AppState, BackgroundStatus, DailyPoint, Deployment, FleetState, FleetView, HealthScore, InstallGuide, MystNode, PointsBalance, Service, ServiceEarning, SettingsState } from "./wails"; +import { renderFleetSection } from "./render/fleet"; +import { escapeHtml, formatBalance } from "./render/format"; +import type { AppState, BackgroundStatus, DailyPoint, Deployment, FleetState, HealthScore, InstallGuide, MystNode, PointsBalance, Service, ServiceEarning, SettingsState } from "./wails"; let state: AppState | null = null; let selectedService: Service | null = null; @@ -879,75 +881,6 @@ function changeCaption(pct: number, suffix: string) { return `${arrow} ${Math.abs(pct).toFixed(1)}% ${suffix}`; } -// The account-level picture, shown only while paired with a CashPilot server. -// -// It sits BELOW the local numbers rather than replacing them, and it is labelled -// as the account's rather than this machine's, because those are different -// claims. Earnings are collected per PLATFORM from the provider; if two machines -// run the same service the provider reports one balance and nothing can split -// it. Saying "this machine earned X" would be false in exactly the case a fleet -// user is in. -function renderFleetSection(fleet: FleetView) { - const platforms = fleet.platforms || []; - const withoutReadings = fleet.withoutReadings || []; - const shared = platforms.filter((p) => p.shared).length; - // null is UNKNOWN, and it renders as a dash. `?? 0` here would report a loss - // that did not happen -- most convincingly to the user whose collector is - // broken, who is precisely the person who must not be told everything is fine. - const money = (usd: number | null) => - usd === null || usd === undefined ? "—" : escapeHtml(formatBalance(usd, fleet.currency || "USD")); - - return ` -
-
-
- Across your CashPilot account -

- What the platforms this machine runs earned on your account over the last - ${escapeHtml(String(fleet.windowDays || 30))} days, reported by - ${escapeHtml(fleet.serverUrl)}${fleet.reportedAt ? ` · ${escapeHtml(relativeTime(fleet.reportedAt))}` : ""}. - ${shared ? `${shared} of these run on more than one machine, so the figure is the account's, not this machine's.` : ""} -

-
-
- ${money(fleet.totalUsd)} - ${fleet.totalUsd === null ? "nothing collected yet" : "known platforms only"} -
-
-
- ${platforms.length - ? platforms.map((p) => ` -
- ${escapeHtml(p.slug)} - ${money(p.usd)} - ${p.shared ? `shared` : ""} -
- `).join("") - : `

This server has no figures for the platforms on this machine yet.

`} -
- ${withoutReadings.length - ? `

No reading at all for ${escapeHtml(withoutReadings.join(", "))} — usually a collector that does not exist yet, or credentials never entered. They are missing from the total rather than counted as zero.

` - : ""} -
- `; -} - -// relativeTime turns an RFC3339 stamp into "just now" / "12 minutes ago". An -// unparseable value yields "" so the caller simply omits the phrase rather than -// rendering "Invalid Date". -function relativeTime(iso: string): string { - const then = Date.parse(iso); - if (Number.isNaN(then)) return ""; - const seconds = Math.max(0, Math.round((Date.now() - then) / 1000)); - if (seconds < 60) return "just now"; - const minutes = Math.round(seconds / 60); - if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; - const hours = Math.round(minutes / 60); - if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; - const days = Math.round(hours / 24); - return `${days} day${days === 1 ? "" : "s"} ago`; -} - function renderPointsSection(points: PointsBalance[]) { return `
@@ -1601,39 +1534,10 @@ function wireChrome() { }); } -function escapeHtml(value: string | undefined | null) { - return String(value || "").replace(/[&<>"']/g, (ch) => ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - }[ch] || ch)); -} - function capitalize(value: string) { return value ? value.charAt(0).toUpperCase() + value.slice(1) : ""; } -function formatBalance(value: number, currency: string) { - // Sanitize to A-Z0-9 only: `code` is interpolated unescaped into a couple of - // innerHTML sinks (the topbar and services-table balance cells), so stripping - // everything else closes those injection points and keeps Intl happy. - const code = (currency || "USD").toUpperCase().replace(/[^A-Z0-9]/g, ""); - const amount = Number.isFinite(value) ? value : 0; - // Intl.NumberFormat throws RangeError on non-ISO codes (e.g. reward "points" - // like MYST or GRASS); those fall back to a plain "1234.00 CODE" string. - try { - return new Intl.NumberFormat(undefined, { - style: "currency", - currency: code, - maximumFractionDigits: 2, - }).format(amount); - } catch { - return `${amount.toFixed(2)} ${code}`; - } -} - // formatMyst renders a MYST amount to a few decimals. MYST is a reward token, // not an ISO currency, so Intl currency formatting can't be used; non-finite // values degrade to 0 rather than showing NaN. diff --git a/frontend/src/render/fleet.ts b/frontend/src/render/fleet.ts new file mode 100644 index 0000000..c663761 --- /dev/null +++ b/frontend/src/render/fleet.ts @@ -0,0 +1,79 @@ +// The account-wide earnings panel, extracted from main.ts so it can be TESTED. +// +// It carries the rule this whole feature turns on — a platform with NO reading +// renders as an em dash, never 0.00 — and until now nothing verified it past the +// JSON boundary. See CashPilot-Desktop-tft. +// +// Pure: takes a FleetView, returns an HTML string, touches no global. + +import type { FleetView } from "../wails"; +import { escapeHtml, formatBalance, relativeTime } from "./format.js"; // .js so the emitted ESM resolves in Node; Vite maps it back to .ts + +/** + * Render the paired server's account-level figures. + * + * It sits BELOW the local numbers rather than replacing them, and it is labelled + * as the ACCOUNT's rather than this machine's, because those are different + * claims. Earnings are collected per PLATFORM from the provider; if two machines + * run the same service the provider reports one balance and nothing can split + * it. Saying "this machine earned X" would be false in exactly the case a fleet + * user is in. + * + * `now` is injectable so the "12 minutes ago" phrasing is testable without + * freezing the clock. + */ +export function renderFleetSection(fleet: FleetView, now: number = Date.now()): string { + const platforms = fleet.platforms || []; + const withoutReadings = fleet.withoutReadings || []; + const shared = platforms.filter((p) => p.shared).length; + + // null is UNKNOWN, and it renders as a dash. `?? 0` here would report a loss + // that did not happen — most convincingly to the user whose collector is + // broken, who is precisely the person who must not be told everything is fine. + const money = (usd: number | null | undefined) => + usd === null || usd === undefined ? "—" : escapeHtml(formatBalance(usd, fleet.currency || "USD")); + + const age = fleet.reportedAt ? relativeTime(fleet.reportedAt, now) : ""; + + return ` +
+
+
+ Across your CashPilot account +

+ What the platforms this machine runs earned on your account over the last + ${escapeHtml(String(fleet.windowDays || 30))} days, reported by + ${escapeHtml(fleet.serverUrl)}${age ? ` · ${escapeHtml(age)}` : ""}. + ${shared ? `${shared} of these run on more than one machine, so the figure is the account's, not this machine's.` : ""} +

+
+
+ ${money(fleet.totalUsd)} + ${fleet.totalUsd === null || fleet.totalUsd === undefined ? "nothing collected yet" : "known platforms only"} +
+
+
+ ${ + platforms.length + ? platforms + .map( + (p) => ` +
+ ${escapeHtml(p.slug)} + ${money(p.usd)} + ${p.shared ? `shared` : ""} +
+ `, + ) + .join("") + : `

This server has no figures for the platforms on this machine yet.

` + } +
+ ${ + withoutReadings.length + ? `

No reading at all for ${escapeHtml(withoutReadings.join(", "))} — usually a collector that does not exist yet, or credentials never entered. They are missing from the total rather than counted as zero.

` + : "" + } +
+ `; +} diff --git a/frontend/src/render/format.ts b/frontend/src/render/format.ts new file mode 100644 index 0000000..6fc9da6 --- /dev/null +++ b/frontend/src/render/format.ts @@ -0,0 +1,67 @@ +// Pure formatting helpers, extracted from main.ts so they can be TESTED. +// +// main.ts does `const root = document.querySelector("#app")!` at module scope +// and imports the Wails runtime, so importing it from a Node harness needs a +// DOM, a CSS loader and Wails stubs. Nothing in this file touches the document, +// the network or any global — importing it is free, which is the whole point. +// +// This is the first step CashPilot-Desktop-tft asks for. Behaviour is unchanged: +// both functions are moved verbatim, and main.ts imports them from here. + +/** Escape the five characters that matter in an innerHTML sink. */ +export function escapeHtml(value: string | undefined | null): string { + return String(value || "").replace( + /[&<>"']/g, + (ch) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[ch] || ch, + ); +} + +/** + * Format an amount in a currency, degrading gracefully for reward tokens. + * + * `code` is sanitised to A-Z0-9 because it is interpolated UNESCAPED into a + * couple of innerHTML sinks (the topbar and the services-table balance cells); + * stripping everything else closes those injection points and keeps Intl happy. + * + * Intl.NumberFormat throws RangeError on non-ISO codes — reward tokens like + * MYST or GRASS — so those fall back to a plain "1234.00 CODE" string. + */ +export function formatBalance(value: number, currency: string): string { + const code = (currency || "USD").toUpperCase().replace(/[^A-Z0-9]/g, ""); + const amount = Number.isFinite(value) ? value : 0; + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: code, + maximumFractionDigits: 2, + }).format(amount); + } catch { + return `${amount.toFixed(2)} ${code}`; + } +} + +/** + * Turn an RFC3339 stamp into "just now" / "12 minutes ago". + * + * An unparseable value yields "" so the caller can omit the phrase entirely + * rather than render "Invalid Date". + */ +export function relativeTime(iso: string, now: number = Date.now()): string { + const then = Date.parse(iso); + if (Number.isNaN(then)) return ""; + const seconds = Math.max(0, Math.round((now - then) / 1000)); + if (seconds < 60) return "just now"; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; + const days = Math.round(hours / 24); + return `${days} day${days === 1 ? "" : "s"} ago`; +} diff --git a/frontend/tsconfig.harness.json b/frontend/tsconfig.harness.json new file mode 100644 index 0000000..104b781 --- /dev/null +++ b/frontend/tsconfig.harness.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": ".harness-build", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": false, + "sourceMap": false, + "rootDir": "src", + "allowImportingTsExtensions": false + }, + "include": [ + "src/render/**/*.ts", + "src/wails.d.ts" + ] +}