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" + ] +}