Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ pnpm-lock.yaml
.beads/
.claude/
.codex/

# Compiled output for the browser-free render harness (frontend/scripts/*.mjs).
/frontend/.harness-build/
25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
191 changes: 191 additions & 0 deletions frontend/scripts/fleet_render_check.mjs
Original file line number Diff line number Diff line change
@@ -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));
Comment on lines +77 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Intl.NumberFormat(undefined, options) use the host environment's default locale in ECMAScript?

💡 Result:

Yes, when the locales argument is undefined (or omitted) in the Intl.NumberFormat constructor, the ECMAScript Internationalization API (ECMA-402) uses the host environment's default locale [1][2]. In the ECMAScript specification, when Intl.NumberFormat is initialized without a specified locale, the runtime performs locale negotiation and defaults to the implementation's configured default locale [2]. This allows the formatting behavior to adapt automatically to the user's or system's language preferences if the application does not explicitly override them [3][2]. You can verify the locale being used by a specific Intl.NumberFormat instance by calling the resolvedOptions() method, which returns an object containing the locale property reflecting the actual locale used for formatting [4]. It is important to note that the specific "default locale" is determined by the host environment (such as a web browser or Node.js) [2][5]. For example, in environments with limited internationalization support or specific configurations (like some Node.js builds), the default might fall back to "en-US" if additional locale data is not available or configured [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
fd -a 'fleet_render_check\.mjs$' . || true

echo "== relevant snippets =="
if [ -f frontend/scripts/fleet_render_check.mjs ]; then
  wc -l frontend/scripts/fleet_render_check.mjs
  sed -n '1,240p' frontend/scripts/fleet_render_check.mjs | nl -ba
fi

echo "== search formatBalance usages =="
rg -n "function formatBalance|const formatBalance|formatBalance\\(" frontend/scripts frontend -g '*.mjs' -g '*.js' -g '*.ts' -g '*.tsx' || true

echo "== determine exact import and formatBalance definition locations =="
python3 - <<'PY'
from pathlib import Path
p=Path('frontend/scripts/fleet_render_check.mjs')
if p.exists():
    text=p.read_text()
    for i,line in enumerate(text.splitlines(),1):
        if 'formatBalance' in line:
            print(i,line)
PY

Repository: GeiserX/CashPilot-Desktop

Length of output: 349


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant snippets with line numbers =="
awk '{printf "%6d\t%s\n", NR, $0}' frontend/scripts/fleet_render_check.mjs | sed -n '1,240p'

echo "== search formatBalance usages/definition =="
rg -n "function formatBalance|const formatBalance|formatBalance\\(" frontend/scripts frontend -g '*.mjs' -g '*.js' -g '*.ts' -g '*.tsx' || true

echo "== exact formatBalance lines in check file =="
python3 - <<'PY'
from pathlib import Path
p=Path('frontend/scripts/fleet_render_check.mjs')
text=p.read_text().splitlines()
for i,line in enumerate(text,1):
    if 'formatBalance' in line:
        print(i, line)
PY

Repository: GeiserX/CashPilot-Desktop

Length of output: 12839


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== format.ts implementation =='
awk '{printf "%6d\t%s\n", NR, $0}' frontend/src/render/format.ts | sed -n '1,140p'

printf '%s\n' '== inspect harness format.js if present =='
if [ -f frontend/.harness-build/render/format.js ]; then
  awk '{printf "%6d\t%s\n", NR, $0}' frontend/.harness-build/render/format.js | sed -n '1,140p'
else
  echo "frontend/.harness-build/render/format.js not present"
fi

printf '%s\n' '== behavioral probe for Intl.NumberFormat locales if Node Intl is available =='
node - <<'JS'
console.log("process.version=", process.version);
try {
  const fmtUSD = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 2 });
  const fmtDE = new Intl.NumberFormat("de-DE", { style: "currency", currency: "USD", maximumFractionDigits: 2 });
  const fmtUndefinedUSD = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD", maximumFractionDigits: 2 });
  console.log("en-US USD 0:", fmtUSD.format(0));
  console.log("de-DE USD 0:", fmtDE.format(0));
  console.log("undefined USD expected 0:", fmtUndefinedUSD.format(0), fmtUndefinedUSD.resolvedOptions().locale);
} catch (e) {
  console.log("Intl probe failed:", e && e.message);
}
JS

Repository: GeiserX/CashPilot-Desktop

Length of output: 3509


Make currency-value assertions locale-independent.

formatBalance(undefined) allows Intl.NumberFormat to use the host default locale, which may render 0.00 as 0,00. Update the assertions at lines 77, 85, and 184 to compare against the generated formatBalance(amount, currency) output or the expected decimal separator for that amount.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/scripts/fleet_render_check.mjs` around lines 77 - 85, Update the
currency assertions in the relevant checks around the fleet rendering tests to
avoid hard-coded dot-decimal matching. Compare rendered values against the
generated formatBalance(amount, currency) result, or use the locale-appropriate
decimal separator, including the assertions at the measured-zero, known-amount,
and line-184 cases.

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</g) || []).length === 1,
"an unshared platform was marked shared",
);
}

{
const html = renderFleetSection(view({ platforms: [{ slug: "grass", usd: 4, shared: false }], totalUsd: 4 }));
check("no shared sentence when nothing is shared", !html.includes("more than one machine, so"));
}

// ---------------------------------------------------------------------------
// Escaping. serverUrl and the slugs come from a server the user typed the
// address of, and this string goes into innerHTML.
// ---------------------------------------------------------------------------

{
const html = renderFleetSection(
view({
serverUrl: '"><img src=x onerror=alert(1)>',
platforms: [{ slug: "<script>alert(2)</script>", usd: 1, shared: false }],
withoutReadings: ["<b>bold</b>"],
}),
);
check("the server URL is escaped", !html.includes("<img src=x"), html.slice(0, 400));
check("a platform slug is escaped", !html.includes("<script>alert(2)"));
check("the no-reading list is escaped", !html.includes("<b>bold</b>"));
check("the escaped forms are present", html.includes("&lt;script&gt;"));
}

// ---------------------------------------------------------------------------
// 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(`"'`) === "&quot;&#039;");
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, '"><b>').includes("<b>"));
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");
Loading
Loading