docs: full docs-update pass — 9 GSD docs regenerated + verified (434→454 claims; verifier corrections applied)

This commit is contained in:
araharah-executor
2026-10-25 12:51:01 +08:00
parent 809864f5b6
commit 44851c70fe
9 changed files with 2014 additions and 324 deletions
+165 -64
View File
@@ -2,9 +2,19 @@
# Testing
arah² has no third-party test framework. Every suite in this repo is a hand-rolled, zero-dependency Node script that registers cases into a small array and drains it. The runners were written first in `theme/`, then copied verbatim into `bot/` and `web/` — the three share one shape, deliberately.
arah² has no third-party test framework. Every suite in this repo is a hand-rolled, zero-dependency Node script that registers cases into a small array and drains it. The runner shape was written first in `theme/`, then copied into `bot/` and `web/`, and again into the newer `search/` and `routing/` workspaces — five workspaces, one idea, deliberately.
Beyond the suites, two additional gates stand in for CI (this repo has none — see [CI integration](#ci-integration)): `tsc --noEmit` typechecks, and the theme's off-token audit is itself a tested CLI.
Beyond the suites, additional gates stand in for CI (this repo has none — see [CI integration](#ci-integration)): `tsc --noEmit` typechecks, the theme's off-token audit is itself a tested CLI, and two repo-root gate CLIs (`privacy-audit`, `budget-audit`) each carry their own TDD suite.
Case totals (verified against the current tree):
| Workspace | Suites | Cases |
|---|---|---|
| `bot/` | 10 | 126 |
| `web/` | 14 | 165 |
| `search/` | 8 | 57 |
| `routing/` | 9 | 65 |
| `theme/` (single audit suite) | 1 | — |
---
@@ -16,6 +26,7 @@ Beyond the suites, two additional gates stand in for CI (this repo has none —
- [Test patterns](#test-patterns)
- [Coverage requirements](#coverage-requirements)
- [Visual capture workflow](#visual-capture-workflow)
- [Repo gate CLIs](#repo-gate-clis-privacy-audit--budget-audit)
- [CI integration](#ci-integration)
---
@@ -28,13 +39,15 @@ Beyond the suites, two additional gates stand in for CI (this repo has none —
|---|---|---|---|---|
| `bot/` | `bot/test/runner.mjs` | `bot/test/harness.mjs` | `import { test } from "./harness.mjs"` | `npm run bot:test` |
| `web/` | `web/test/runner.mjs` | `web/test/harness.mjs` | `import { test } from "./harness.mjs"` | `npm run web:test` |
| `search/` | `search/test/runner.mjs` | `search/test/harness.mjs` | `import { test } from "./harness.mjs"` | `npm run search:test` |
| `routing/` | `routing/test/runner.mjs` | `routing/test/harness.mjs` | `import { test } from "./harness.mjs"` | `npm run routing:test` |
| `theme/` | inline in `theme/scripts/audit.test.mjs` | local `check()` helper | `check("name", cond, detail)` | `npm test -w theme` |
There is **no jest, vitest, mocha, playwright-test, or any other runner** in any `package.json`. The only test-relevant dev dependency anywhere is `typescript` (`tsc --noEmit`, used as a compile-time gate) and `playwright` (used by the capture scripts, not by any suite).
There is **no jest, vitest, mocha, playwright-test, or any other runner** in any `package.json`. The test-relevant dev dependencies are `typescript` (`tsc --noEmit`, a compile-time gate) and `playwright` (used by the capture scripts, not by any suite). `routing/` additionally dev-depends on `sharp`, but only for its glyph/road-graph build tooling — never for tests.
### The harness (10 lines, duplicated per workspace)
### The harness (duplicated per workspace, two byte-identical pairs)
`bot/test/harness.mjs` and `web/test/harness.mjs` are byte-identical:
`bot/test/harness.mjs` and `web/test/harness.mjs` are byte-identical; `search/test/harness.mjs` and `routing/test/harness.mjs` are a second byte-identical pair with the same five-line body:
```js
const tests = [];
@@ -46,9 +59,9 @@ export const registry = tests;
`test(name, fn)` pushes a case. `fn` may be sync or `async` — the runner `await`s every one.
### The runner (47 lines, duplicated per workspace)
### The runner (47–48 lines (byte-identical except header comments), behaviour-identical across all four suites)
`bot/test/runner.mjs` and `web/test/runner.mjs` are also byte-identical in behaviour:
`bot/test/runner.mjs`, `web/test/runner.mjs`, `search/test/runner.mjs`, and `routing/test/runner.mjs` are behaviour-identical (only the header comments differ):
1. `readdirSync(import.meta.dirname)`, filter `*.test.mjs`, sort.
2. Dynamic-`import()` each suite inside a `try/catch`. A suite that throws at load time is **not** fatal — it is pushed into the registry as a single failing case named `suite load failed: <file>`. The run always completes.
@@ -59,11 +72,18 @@ Registration order is therefore: **suite files in alphabetical order, cases in f
### TypeScript is loaded directly
Both `bot/test/` and `web/test/` `await import("../src/....ts")` directly against the shipped source — no build step, no transpile step, no fixtures directory. This works because Node strips types natively (the code comments call this "Node 24 type stripping"). <!-- VERIFY: minimum Node.js version for native `.ts` import support — no `engines` field or `.nvmrc` is committed; the test suites themselves reference "Node 24 type stripping". -->
All four `test/` directories `await import("../src/....ts")` directly against the shipped source — no build step, no transpile step, no fixtures directory (except the committed fixture files described below). This works because Node strips types natively (the code comments call this "Node 24 type stripping"). <!-- VERIFY: minimum Node.js version for native `.ts` import support — no `engines` field or `.nvmrc` is committed; the test suites themselves reference "Node 24 type stripping". -->
### Committed fixtures
Unlike the rest of the suites, two test directories carry small committed fixtures (both gitignored-pattern-free, plain files):
- `search/test/fixtures/` — `golden-corpus.json` (the golden search corpus the CO cases measure), `overpass-response.json` (a recorded Overpass response the topup cases replay).
- `routing/test/fixtures/` — `mini-graph.bin` (a tiny built graph binary), `golden-pairs.json` (pinned origin/destination pairs), and `build-mini-graph.mjs` (the generator that produced the binary).
### Assertion style
Every suite in `bot/` and `web/` uses **`node:assert`'s strict mode**, imported as:
Every suite in `bot/`, `web/`, `search/`, and `routing/` uses **`node:assert`'s strict mode**, imported as:
```js
import { strict as assert } from "node:assert";
@@ -83,24 +103,22 @@ In practice: `assert.ok(...)` for existence checks, `assert.equal` / `assert.str
# from the repo root
npm run bot:test # → node test/runner.mjs in bot/
npm run web:test # → node test/runner.mjs in web/
npm run search:test # → node test/runner.mjs in search/
npm run routing:test # → node test/runner.mjs in routing/
npm test -w theme # → node ./scripts/audit.test.mjs in theme/
```
There is no root-level `npm test` aggregate — run all three, or add one to `package.json` when you need it.
There is no root-level `npm test` aggregate — run all five, or add one to `package.json` when you need it.
### Running one suite
The runners auto-discover by filename and offer no filter flag. To run a single file, invoke it directly from inside the workspace:
```bash
cd bot && node test/initdata.test.mjs
```
This works because a suite file's top-level `test(...)` calls register into `harness.mjs`, and `runner.mjs` is what drains the registry — so importing a suite alone reports nothing. **A single suite file run this way executes the module side effects (imports, fixture writes) but produces no pass/fail output.** Always go through the runner:
The runners auto-discover by filename and offer no filter flag. A single suite file invoked directly executes its module side effects but produces no pass/fail output — the runner is what drains the registry. Always go through the runner:
```bash
cd bot && node test/runner.mjs # all bot suites
cd web && node test/runner.mjs # all web suites
cd search && node test/runner.mjs # all search suites
cd routing && node test/runner.mjs # all routing suites
```
`theme` is the exception — its suite is a standalone script and *does* report standalone:
@@ -111,11 +129,13 @@ cd theme && node scripts/audit.test.mjs
### Typecheck gates
The `bot/` and `web/` suites do not compile. Run these alongside:
None of the suites compile. Run these alongside:
```bash
npm run bot:typecheck # tsc --noEmit in bot/
npm run web:typecheck # tsc --noEmit in web/
npm run bot:typecheck # tsc --noEmit in bot/
npm run web:typecheck # tsc --noEmit in web/
npm run search:typecheck # tsc --noEmit in search/
npm run routing:typecheck # tsc --noEmit in routing/
```
### The theme audit as a standalone tool
@@ -126,21 +146,31 @@ The audit is both the thing under test and a useful CLI in its own right. Run it
npm run audit # → node ./scripts/audit.mjs ./dist/rams.css
```
Its exit codes are a contract: `0` clean, `1` violations (each printed as `file:line: declaration`), `2` token-sheet drift or error. See [theme/scripts/audit.mjs](theme/scripts/audit.mjs) for the full usage block and its scope adjudications.
Its exit codes are a contract: `0` clean, `1` violations (each printed as `file:line: declaration`), `2` token-sheet drift or error. See `theme/scripts/audit.mjs` for the full usage block and its scope adjudications.
```mermaid
graph TD
ROOT["npm run bot:test / web:test / audit"] --> BR["bot/test/runner.mjs"]
ROOT["bot:test / web:test / search:test / routing:test / theme test"] --> BR["bot/test/runner.mjs"]
ROOT --> WR["web/test/runner.mjs"]
ROOT --> SR["search/test/runner.mjs"]
ROOT --> RR["routing/test/runner.mjs"]
ROOT --> TR["theme/scripts/audit.test.mjs"]
BR --> BDISC["readdir *.test.mjs → dynamic import"]
BDISC --> BREG["bot/test/harness.mjs registry"]
BREG --> BCASES["commands · initdata · location-pin · privacy · sessions · webhook"]
BREG --> BCASES["cari · commands · guidance-cron · guidance-envelope · initdata · location-pin · privacy · route-privacy · sessions · webhook"]
WR --> WDISC["readdir *.test.mjs → dynamic import"]
WDISC --> WREG["web/test/harness.mjs registry"]
WREG --> WCASES["build-artifact · copy-gate · location-chain · no-storage · session · theme"]
WREG --> WCASES["build-artifact · copy-gate · destination-preload · failure-state-matrix · fullscreen · guidance-eta · guidance-replay · guidance-surface · homescreen · location-chain · no-storage · route-surface · session · theme"]
SR --> SDISC["readdir *.test.mjs → dynamic import"]
SDISC --> SREG["search/test/harness.mjs registry"]
SREG --> SCASES["converter · corpus · distance · gazetteer · loader-env · normalizer · score · topup"]
RR --> RDISC["readdir *.test.mjs → dynamic import"]
RDISC --> RREG["routing/test/harness.mjs registry"]
RREG --> RCASES["astar · contract · corpus · fixture-source · graph · http-source · loader-env · polyline · snap"]
TR --> TFIX["fixtures under /tmp/opencode/audit-test/"]
TFIX --> TSPAWN["spawnSync scripts/audit.mjs"]
@@ -153,8 +183,8 @@ graph TD
classDef theme fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14
class ROOT entry
class BR,WR,TR runner
class BDISC,BREG,WDISC,WREG harness
class BR,WR,SR,RR,TR runner
class BDISC,BREG,WDISC,WREG,SDISC,SREG,RDISC,RREG harness
class TFIX,TSPAWN,TCHECK,TREAL theme
```
@@ -167,11 +197,18 @@ graph TD
| Rule | Value |
|---|---|
| File name | `<subject>.test.mjs` — the runner only discovers this suffix |
| Directory | `bot/test/` or `web/test/`, flat, no subdirectories |
| Directory | `bot/test/`, `web/test/`, `search/test/`, or `routing/test/` — flat, no subdirectories (fixtures live in a `fixtures/` dir, not alongside suites) |
| Import the harness | `import { test } from "./harness.mjs";` |
| Case name | Prefix with a stable case code: `"WK1 POST valid initData (under cap) → 200 {token: uuid}"`, `"GEO3 no banned storage token…"`, `"CS4 D-02: the command tree source has no reply-menu…"`. The code is the identity; the prose is the explanation. |
| Case name | Prefix with a stable case code: `"WK1 POST valid initData (under cap) → 200 {token: uuid}…"`, `"CR3 NO CRYPTO IN THE DEEP-LINK PATH…"`, `"RT4 null contract — …"`. The code is the identity; the prose is the explanation. |
A new file dropped into `bot/test/` or `web/test/` is picked up automatically on the next run — no manifest, no import list, no registration step anywhere.
Case-code families currently in use (sampled from the suite headers):
- `bot/` — `WK` (webhook), `CS` (commands), `CR` (cari venue card), `GC` (guidance cron, `GC-C` cron-cases), `GE` (guidance envelope), `T` (initData), `LP` (location pin), `PG` (privacy), `RP` (route privacy), `SM`/`SR`/`SG` (sessions)
- `web/` — `BA` (build artifact), `COPY` (copy gate), `DP` (destination preload), `FM` (failure-state matrix), `FS` (fullscreen), `E` (guidance ETA), `R` (guidance replay), `GS` (guidance surface), `HS` (homescreen), `GEO` (location chain / storage), `RS` (route surface), `SESS` (session), `PX02` (theme)
- `search/` — `CV` (converter), `CO` (corpus quality), `D` (damerau), `G` (gazetteer), `L` (loader env), `T` (normalizer), `S` (score), `TP` (topup)
- `routing/` — `A` (astar), `C` (contract), `RT` (route corpus), `F` (fixture source), `G` (graph binary), `H` (http source), `L` (loader env), `P` (polyline)
A new file dropped into any `test/` directory is picked up automatically on the next run — no manifest, no import list, no registration step anywhere.
### The minimum shape
@@ -211,7 +248,7 @@ Note the theme suite uses a **bare block** (`{ … }`) rather than a named funct
### Where the source of truth lives
Every suite header comment lists its cases up front. That header is the documentation — keep it in sync when adding a case. Several suites reference the decision ids (`D-02`, `PRIV-02`, `GEO-03`, `CS1`, `COPY5`, `WK3`) that the case is pinning; reuse that convention.
Every suite header comment lists its cases up front. That header is the documentation — keep it in sync when adding a case. Most cases reference decision ids or requirement tags (`D-02`, `PRIV-02`, `GEO-03`, `CS4`, `COPY5`, `WK3`, `RTNG-02`) that the case is pinning; reuse that convention.
---
@@ -221,7 +258,7 @@ The repo has converged on four recognisable patterns. Follow the closest precede
### 1. Injected fakes over mocks and over the real platform
`bot/test/sessions.test.mjs` and `bot/test/webhook.test.mjs` both hand-roll a ~20-line `KVNamespace` fake rather than depend on `miniflare` or `wrangler`:
`bot/test/sessions.test.mjs`, `bot/test/webhook.test.mjs`, and `bot/test/location-pin.test.mjs` all hand-roll a ~20-line `KVNamespace` fake rather than depend on `miniflare` or `wrangler`:
```js
function makeKV() {
@@ -274,16 +311,16 @@ try {
### 3. Source-introspection gates ("grep the shipped source")
Structural claims — "no menu surface exists", "copy lives only in `strings.ts`", "nothing calls `setStyle`" — are pinned by *reading the file*, not by mocking behaviour. This is the most distinctive pattern in the repo and it appears in both workspaces:
Structural claims — "no menu surface exists", "copy lives only in `strings.ts`", "nothing calls `setStyle`" — are pinned by *reading the file*, not by mocking behaviour. This is the most distinctive pattern in the repo and it appears in every workspace:
- `bot/test/commands.test.mjs` CS4 — asserts `/keyboard/i` does not appear in any command source.
- `bot/test/privacy.test.mjs` PG1 — every line containing `.put(` must also contain `expirationTtl` referencing a `SCREAMING_CASE` constant.
- `bot/test/privacy.test.mjs` PG2 — no persistence-shaped export names exist.
- `bot/test/privacy.test.mjs` PG1 — every line containing `.put(` must also contain `expirationTtl` referencing a `SCREAMING_CASE` constant; PG2 — no persistence-shaped export names exist.
- `bot/test/guidance-cron.test.mjs` GC6 — real `makeStreetAt` over the mini-graph; GC8 — structural: no `console` in the cron.
- `web/test/copy-gate.test.mjs` COPY5 — no shell module inlines user-facing copy.
- `web/test/no-storage.test.mjs` GEO3 — no banned storage token appears in `web/src` code.
- `web/test/session.test.mjs` SESS4, `web/test/theme.test.mjs` PX02 case 3 — same shape.
- `web/test/no-storage.test.mjs` GEO3 — no banned storage token appears in `web/src` code, plus no service-worker registration exists.
- `web/test/session.test.mjs` SESS4, `web/test/theme.test.mjs` PX02 case 3, `web/test/guidance-surface.test.mjs` GS, `web/test/route-surface.test.mjs` RS, `web/test/fullscreen.test.mjs` FS, `web/test/homescreen.test.mjs` HS — same shape at larger scale.
**The `web/` gates strip comments first; the three `bot/` gates do not.** `bot/test/commands.test.mjs` CS4 and `bot/test/privacy.test.mjs` PG1/PG2 read raw `readFileSync(file, "utf8")` and match lines directly, with no `replace(` anywhere in `bot/test`. In `web/`, the convention is two replaces, repeated verbatim across files:
**Comment-stripping is now the default in both workspaces.** The two-`replace` prelude — strip block comments, then line comments without breaking `https://` URLs — is repeated verbatim in `web/test/` gates and in the newer `bot/test/` gates (`cari.test.mjs`, `route-privacy.test.mjs`, `guidance-cron.test.mjs`). The reason is documented in `no-storage.test.mjs`: `web/src/mock/telegram-mock.ts` documents the storage ban *in prose*, and prose is not a call site. The original three bot gates (`commands.test.mjs` CS4, `privacy.test.mjs` PG1/PG2) still read raw `readFileSync(file, "utf8")` and match lines directly — their targets carry no self-describing prose.
```js
const code = src
@@ -291,8 +328,6 @@ const code = src
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (skips URLs)
```
The reason the `web/` gates strip is documented in `no-storage.test.mjs`: `web/src/mock/telegram-mock.ts` documents the storage ban *in prose*, and prose is not a call site.
### 4. Build-artifact gates (assert on the shipped bundle)
`web/test/build-artifact.test.mjs` reads `web/dist` — the **production** build only, never `web/dist-harness/` — and proves what actually ships:
@@ -305,6 +340,10 @@ The reason the `web/` gates strip is documented in `no-storage.test.mjs`: `web/s
| BA4 | The built JS carries the inlined Rams style JSON |
| BA5 | `installMockWebApp` / `__ARAHARAH_MOCK__` appear in **no** emitted `.js` **or** `.html` |
| BA6 | No emitted HTML carries the runner page's fetch stubs |
| BA7 | The shipped bundle carries EVERY gazetteer id (the bot/web id-set parity gate) |
| BA8 | The shipped bundle carries NO deep-link crypto |
| BA9 | The route payload fits the 200 KiB gz bundle budget — measured and printed |
| BA10 | The shipped bundle carries EVERY committed pairId (BA7's fixture-parity analog) |
These cases require `web/dist` to exist. Each one starts with `assert.ok(existsSync(DIST), claimBuildFirst().message)`, which yields:
@@ -326,13 +365,15 @@ function loadValidator() {
}
```
**Self-checks that pin the test's own primitives.** `initdata.test.mjs` T7 asserts `node:crypto`'s HMAC output equals the RFC-4231 test-case-1 digest, so a broken signing path cannot produce false-green signing tests. `theme/scripts/audit.test.mjs` T9 runs a real controlled Tailwind build to prove the assertion isn't tautological.
The `search/` and `routing/` suites use the same lazy-`import` shape (`score.test.mjs`, `astar.test.mjs`, both `corpus.test.mjs` files).
**Self-checks that pin the test's own primitives.** `initdata.test.mjs` T7 asserts `node:crypto`'s HMAC output equals the RFC-4231 test-case-1 digest, so a broken signing path cannot produce false-green signing tests.
### Anti-patterns to avoid
- **Letting a case reach the live Bot API.** `bot/test/webhook.test.mjs` documents the constraint at length: grammY's cloudflare-mod adapter calls `bot.init()` (a live `getMe`) *before* its own secret-token compare, so no committed case may let an update reach the framework with an armed secret. Armed-secret behaviour is verified by live smokes, not by the suite.
- **Writing fixtures into the repo.** `theme/scripts/audit.test.mjs` creates everything under `/tmp/opencode/audit-test/`; the bot/web suites keep fixtures in-process entirely.
- **Adding a test framework.** The zero-dependency property is load-bearing (decision `D-12`) and repeated across all three runners.
- **Writing fixtures into the repo.** `theme/scripts/audit.test.mjs` creates everything under `/tmp/opencode/audit-test/`; the bot/web suites keep fixtures in-process entirely (the two committed fixture files under `search/test/fixtures/` and `routing/test/fixtures/` are the deliberate exceptions — they hold pinned public data, never runtime state).
- **Adding a test framework.** The zero-dependency property is load-bearing (decision `D-12`) and repeated across all runners.
---
@@ -345,8 +386,9 @@ What substitutes for coverage measurement:
| Mechanism | Where |
|---|---|
| **Existence assertions on the scanned tree** | `no-storage.test.mjs` asserts `files.length >= 12` before scanning (`"scanned a real tree"`), so the gate cannot pass vacuously on an empty directory. `copy-gate.test.mjs` COPY5 asserts `files.length >= 1`. |
| **Fixture self-checks** | `theme/scripts/audit.test.mjs` aborts with `fixture setup: … not found` if `theme/src/tokens.css` loses a key the fixtures depend on. |
| **Typecheck as a structural gate** | `npm run bot:typecheck` / `web:typecheck` catch what the suites would not: a type change in a shape the runtime assertions never inspect. |
| **Fixture self-checks** | `theme/scripts/audit.test.mjs` aborts with `fixture setup: … not found` if `theme/src/tokens.css` loses a key the fixtures depend on. `routing/test/fixtures/build-mini-graph.mjs` regenerates `mini-graph.bin` so the graph cases test a real decode path. |
| **Golden-corpus rate gates** | `search/test/corpus.test.mjs` CO1–CO8 measures top-3 hit rate and refusal margin over the committed corpus; `routing/test/corpus.test.mjs` RT1–RT10 does the same for route fixtures. |
| **Typecheck as a structural gate** | `bot:typecheck` / `web:typecheck` / `search:typecheck` / `routing:typecheck` catch what the suites would not: a type change in a shape the runtime assertions never inspect. |
If you need coverage numbers, you would have to add tooling — which contradicts the current design. Prefer adding a case.
@@ -375,7 +417,7 @@ Serves `theme/` as docroot, then captures:
The mockups are read-only reference (decision `D-06`) and are never edited.
### `web/scripts/capture.mjs`
### `web/scripts/capture.mjs` — the 22-leg harness
Rebuilds all three bundles first, then serves **`web/dist-harness/`**, not `web/dist`:
@@ -385,24 +427,72 @@ npm run build -w theme && npm run build -w web && npm run build:harness -w web
It then asserts in both directions before launching the browser — the harness bundle *must* contain `installMockWebApp`, and `web/dist` *must not* — and fails the run if either is wrong, because a wrong bundle means the capture would race forever.
| Output | Leg | Wait mode |
|---|---|---|
| `shell-boot-1.png` | `?mock=android&sessiondelay=1000` | `settle400` |
| `shell-boot-2.png` | `?mock=android&fixdelay=1200` | `settle400` |
| `shell-live-light.png` | `?mock=android&scheme=light` | `live` — races `ARAHARAH-LIVE` on console, 2s tile settle |
| `shell-live-dark.png` | `?mock=android&scheme=dark` | `live` |
| `shell-denied.png` | `?mock=tdesktop` | `denied` — waits `.lamp[data-state="denied"]`, 90s budget (the 60s chat-pin poll must elapse first) |
| `shell-chat-pin.png` | `?mock=tdesktop&pin=1` | `live` |
| `gallery-compare.png` | Side-by-side against `stitch_minimalist_route_navigator/navigate_map_overview/screen.png` | — |
Every leg races **console state tokens** (`RAMS-BASEMAP-READY`, `ARAHARAH-LIVE`, `ARAHARAH-DEST`, `ARAHARAH-ROUTE`, `ARAHARAH-GUIDE`) or waits on a DOM state (`denied` lamp, expected body text). A timeout is always a hard failure with a diagnostic naming which signals fired — never a silent skip, never a blank-but-passing shot. Wait modes: `immediate`, `settle400`, `live`, `denied`, `dest`, `route`, `guide`.
Success prints `CAPTURE OK — 7/7 non-blank captures under web/screenshots/`.
The 22 legs (all under `web/screenshots/`):
Two capture legs double as runtime verification:
| Output | What it pins |
|---|---|
| `shell-boot-1.png` | boot stage 1 — validating-session card over live map |
| `shell-boot-2.png` | boot stage 2 — acquiring-location card |
| `shell-live-light.png` | LIVE state, Telegram light theme |
| `shell-live-dark.png` | LIVE state, Telegram dark theme (PX-02) |
| `shell-denied.png` | denied card + ASK VIA CHAT (GEO-02 entry point) |
| `shell-chat-pin.png` | LIVE via the chat-pin relay (`?pin=1`, stubbed `/api/location`) |
| `shell-dest-preload.png` | Mini App booted WITH a preloaded destination (`?dest=<id>`) |
| `shell-route-fixture.png` | route drawn from the demo pair's pinned origin (`?fixpair=`) |
| `shell-route-alt.png` | alternative selected via chip tap (ROUTE {n}/{N} re-render) |
| `shell-route-pending.png` | the honest ROUTE // ROUTER pending card (D-03) |
| `shell-route-dark.png` | route drawn, Telegram dark theme (PX-02) |
| `gallery-compare.png` | DSGN-02 side-by-side vs `navigate_map_overview/screen.png` |
| `route-gallery-compare.png` | DSGN-02 route fidelity artifact |
| `shell-guidance-active.png` | the LIVE cockpit over a replayed drive (`?drive=`) |
| `shell-guidance-list.png` | the banner tap's expanded list + NEXT row lamp |
| `shell-guidance-rerouting.png` | captured INSIDE the REROUTING window, no red |
| `shell-guidance-confirm.png` | the abandon confirm card, bottom register |
| `shell-guidance-tripend.png` | the trip-ended card, chrome cleared |
| `shell-guidance-dark.png` | the cockpit, Telegram dark theme (PX-02) |
| `guidance-gallery-compare.png` | DSGN-02 guidance fidelity artifact vs `active_route_guidance/screen.png` |
| `shell-guidance-landscape.png` | the cockpit in landscape (driving posture) |
| `shell-route-pending-landscape.png` | the designed pending card in landscape |
- **The theme honesty check** — the `live` legs read `document.documentElement.dataset.tgTheme` after the `ARAHARAH-LIVE` signal and throw if it does not equal the requested `colorScheme`. A themed screenshot cannot silently show the wrong theme.
Success prints `CAPTURE OK — 22/22 non-blank captures under web/screenshots/`.
Three capture legs double as runtime verification:
- **The theme honesty check** — the `live`/`route`/`guide` themed legs read `document.documentElement.dataset.tgTheme` after the console signal and throw if it does not equal the requested `colorScheme`. A themed screenshot cannot silently show the wrong theme.
- **The GEO-03 runtime probe** — after boot-to-live, `page.evaluate` reads `localStorage.length`, `sessionStorage` keys, `document.cookie`, and `indexedDB.databases()`, and fails the run on any non-empty app storage surface. One allowlisted exception: the official `telegram-web-app.js` SDK writes `__telegram__initParams`, which is the SDK's own launch bookkeeping and is not app data. Any *other* key fails.
- **The D-15 coordinate-hygiene scan** — the guidance legs scan the WHOLE leg's console output (an unbounded capture buffer) for leaked coordinates; a coordinate in a console line fails the run.
A timeout is always a hard failure with a diagnostic naming which console signals fired — never a silent skip, never a blank-but-passing shot.
---
## Repo gate CLIs (privacy-audit / budget-audit)
Two repo-root CLIs are maintainer-time audits, each with its own TDD suite:
| CLI | Suite | What it proves |
|---|---|---|
| `npm run privacy-audit` | `scripts/test/privacy-audit.test.mjs` | storage-api, kv-ttl, log-vocabulary, runtime-hosts, bundle-telemetry gates — plus two `--live` probes |
| `npm run budget-audit` | `scripts/test/budget-audit.test.mjs` | route-cpu, bundle-budget, kv-writes, subrequests, golden-corpus gates — plus one `--live` probe |
Both follow one exit-code contract — **no silent passes**:
| Exit | Meaning |
|---|---|
| `0` | clean — every required gate PASSed |
| `1` | violations — each printed as a per-gate evidence line |
| `2` | NOT RUN — environment/credential drift (a `--live` probe could not run because credentials are absent, or the build/graph is absent). **A NOT RUN is never a pass.** |
The `--live` protocol matters: without `--live`, the live probes print as `NOT RUN` with a `(pass --live)` hint (flag-skip, excluded from exit-2 — the operator's explicit choice). With `--live` but missing credentials, the run exits `2` with `PRIVACY AUDIT NOT RUN` — a skipped probe is never mistaken for a pass. Credentials come from `process.env` first, then `bot/.dev.vars` — see [CONFIGURATION](CONFIGURATION.md).
The gate suites themselves run directly (no npm script — they are maintainer tools):
```bash
node scripts/test/privacy-audit.test.mjs # SA/BG/CP/AU vectors → …TEST CLEAN
node scripts/test/budget-audit.test.mjs # CPU/BA/KV/SQ/CH/GC vectors → …vectors ok
```
Both are self-contained runners: they collect failures, print `FAIL` per vector, tally at the end, and `process.exit(1)` if any failed. They use the `PRIVACY_AUDIT_ROOT` env var as a repo-root override for fork checkouts.
---
@@ -417,14 +507,20 @@ The full green check a contributor must run by hand before pushing:
```bash
npm run bot:test
npm run web:test
npm run search:test
npm run routing:test
npm test -w theme
npm run bot:typecheck
npm run web:typecheck
npm run audit # token-drift audit on the real dist/rams.css
npm run build # required before web:test's BA cases can pass
npm run search:typecheck
npm run routing:typecheck
npm run audit # token-drift audit on the real dist/rams.css
npm run privacy-audit # the five static privacy gates
npm run budget-audit # the five measured budget gates
npm run build # required before web:test's BA cases can pass
```
Order matters in one place: `web/test/build-artifact.test.mjs` reads `web/dist`, so **`npm run build` must run before `npm run web:test`** or BA1–BA6 all fail with the build-first message.
Order matters in one place: `web/test/build-artifact.test.mjs` reads `web/dist`, so **`npm run build` must run before `npm run web:test`** or BA1–BA10 all fail with the build-first message. `budget-audit`'s route-cpu gate also needs the committed graph binary (`routing/data/kl-roadgraph.v1.bin`) and the corpus gates re-run `search:corpus` + `routing:corpus`, which read the committed data files.
---
@@ -434,13 +530,18 @@ Order matters in one place: `web/test/build-artifact.test.mjs` reads `web/dist`,
|---|---|
| Run every bot test | `npm run bot:test` |
| Run every Mini App test | `npm run web:test` |
| Run every search test | `npm run search:test` |
| Run every routing test | `npm run routing:test` |
| Run the theme audit tests | `npm test -w theme` |
| Typecheck the Worker | `npm run bot:typecheck` |
| Typecheck the Mini App | `npm run web:typecheck` |
| Typecheck search/routing | `npm run search:typecheck` / `npm run routing:typecheck` |
| Audit the real compiled CSS | `npm run audit` |
| Add a bot test | new `bot/test/<subject>.test.mjs` — pick up automatically |
| Add a Mini App test | new `web/test/<subject>.test.mjs` — pick up automatically |
| Run the privacy gate CLI | `npm run privacy-audit` (add `-- --live` for the live probes) |
| Run the budget gate CLI | `npm run budget-audit` (add `-- --live` for the live cache-header probe) |
| Re-run the golden corpora | `npm run search:corpus` / `npm run routing:corpus` |
| Add a test | new `<workspace>/test/<subject>.test.mjs` — picked up automatically |
| Regenerate design screenshots | `npm run capture` |
| Regenerate Mini App screenshots | `npm run web:capture` |
**Related docs:** [README](../README.md) for the command table and the HTTP surface these suites cover · [ARCHITECTURE](ARCHITECTURE.md) for why the Worker/Mini App split is what it is · [CONFIGURATION](CONFIGURATION.md) for the secrets the `bot:` tests substitute with `12345:TESTTOKEN`.
**Related docs:** [CONFIGURATION](CONFIGURATION.md) for the secrets the `bot:` tests substitute with `12345:TESTTOKEN` · [PRIVACY-AUDIT](PRIVACY-AUDIT.md) for what each privacy gate inspects · [BUDGETS](BUDGETS.md) for the measured budget record the budget CLI prints · [SEARCH-QUALITY](SEARCH-QUALITY.md) for the corpus metrics the CO cases pin · [ARCHITECTURE](ARCHITECTURE.md) for why the Worker/Mini App split is what it is.