Files
arah2/docs/TESTING.md
T

548 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- generated-by: gsd-doc-writer -->
# 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 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, 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 | — |
---
## Contents
- [Test framework and setup](#test-framework-and-setup)
- [Running tests](#running-tests)
- [Writing new tests](#writing-new-tests)
- [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)
---
## Test framework and setup
### What "framework" means here
| Workspace | Runner | Harness | Registered via | Command |
|---|---|---|---|---|
| `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 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 (duplicated per workspace, two byte-identical pairs)
`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 = [];
export function test(name, fn) {
tests.push({ name, fn });
}
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–48 lines (byte-identical except header comments), behaviour-identical across all four suites)
`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.
3. Drain `registry` in registration order: `PASS <name>` on stdout, or `FAIL <name>` plus the error stack on stderr.
4. Print `[runner] <n> passed, <m> failed` and set `process.exitCode = 1` if anything failed.
Registration order is therefore: **suite files in alphabetical order, cases in file order**. There is no shuffling, no parallelism, no filtering, no watch mode.
### TypeScript is loaded directly
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/`, `web/`, `search/`, and `routing/` uses **`node:assert`'s strict mode**, imported as:
```js
import { strict as assert } from "node:assert";
```
In practice: `assert.ok(...)` for existence checks, `assert.equal` / `assert.strictEqual` for scalar equality, `assert.deepEqual` / `assert.deepStrictEqual` for structural equality, `assert.match` / `assert.doesNotMatch` for regex, and `assert.throws` / `assert.doesNotThrow` when a throw is the expected result. Every assertion carries a message string explaining the invariant being pinned.
`theme/scripts/audit.test.mjs` does not use `node:assert` at all — it defines a local `check(name, cond, detail = '')` that accumulates into `passes[]` / `failures[]`, reports them all at the end, and exits 1 if any failed.
---
## Running tests
### Everything, per workspace
```bash
# 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 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. 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:
```bash
cd theme && node scripts/audit.test.mjs
```
### Typecheck gates
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 search:typecheck # tsc --noEmit in search/
npm run routing:typecheck # tsc --noEmit in routing/
```
### The theme audit as a standalone tool
The audit is both the thing under test and a useful CLI in its own right. Run it on the real build:
```bash
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` for the full usage block and its scope adjudications.
```mermaid
graph TD
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["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 · 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"]
TSPAWN --> TCHECK["check() → passes[] / failures[]"]
TCHECK --> TREAL["also audits real dist/rams.css"]
classDef entry fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef runner fill:#1A1A1A,stroke:#EB8008,stroke-width:2px,color:#F7F0E3
classDef harness fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef theme fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14
class ROOT entry
class BR,WR,SR,RR,TR runner
class BDISC,BREG,WDISC,WREG,SDISC,SREG,RDISC,RREG harness
class TFIX,TSPAWN,TCHECK,TREAL theme
```
---
## Writing new tests
### Naming and location
| Rule | Value |
|---|---|
| File name | `<subject>.test.mjs` — the runner only discovers this suffix |
| 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}…"`, `"CR3 NO CRYPTO IN THE DEEP-LINK PATH…"`, `"RT4 null contract — …"`. The code is the identity; the prose is the explanation. |
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
```js
#!/usr/bin/env node
/*
* <subject>.test.mjs — what this suite pins, and under which requirement id.
* ------------------------------------------------------------------------
* <CODE> one line per case, so a reader can see the contract without
* reading the code.
*/
import { strict as assert } from "node:assert";
import { test } from "./harness.mjs";
const { someExport } = await import("../src/some/module.ts");
test("<CODE> <what is asserted> <and why>", () => {
assert.equal(someExport("input"), "expected", "why this matters");
});
test("<CODE> async path", async () => {
const res = await someAsync();
assert.ok(res.ok, `expected ok:true, got ${JSON.stringify(res)}`);
});
```
For `theme/`, add cases to `theme/scripts/audit.test.mjs` using the local helper instead:
```js
{
const r = runAudit([dirtyFixture]);
check('T2 dirty stylesheet → exit 1', r.status === 1, `status=${r.status} error=${r.error}`);
}
```
Note the theme suite uses a **bare block** (`{ … }`) rather than a named function, and relies on `passes[]`/`failures[]` instead of exceptions.
### 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. 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.
---
## Test patterns
The repo has converged on four recognisable patterns. Follow the closest precedent rather than inventing a new one.
### 1. Injected fakes over mocks and over the real platform
`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() {
const map = new Map();
const ttlLog = [];
return {
async get(key) { return map.has(key) ? map.get(key) : null; },
async put(key, value, opts) {
ttlLog.push({ key, expirationTtl: opts?.expirationTtl ?? null });
map.set(key, value);
},
async delete(key) { map.delete(key); },
map,
ttlLog,
};
}
```
Two variants exist and the difference matters:
- **Asserting fake** (`sessions.test.mjs`) — `put()` itself throws if `expirationTtl` is missing or non-finite, making the TTL law unbreakable from the inside.
- **Recording fake** (`webhook.test.mjs`, `location-pin.test.mjs`) — `put()` records everything into `ttlLog` and the case asserts on the log afterwards.
### 2. Global stubs: install, then restore in `finally`
Node has no DOM, no `navigator`, no `document`. Suites stub them per case and always restore:
```js
function stubNavigator(geolocationOrNull) {
const original = Object.getOwnPropertyDescriptor(globalThis, "navigator");
Object.defineProperty(globalThis, "navigator", { value: /* … */, configurable: true, writable: true });
return () => {
if (original) Object.defineProperty(globalThis, "navigator", original);
else delete globalThis.navigator;
};
}
// in the case
const restoreNav = stubNavigator({ geolocation: true });
try {
// …
} finally {
restoreNav();
}
```
`web/test/theme.test.mjs` does the same for `globalThis.document` (`installDocumentStub()` returns the `dataset` object, which *is* the assertion target). The headers are explicit about why: "a leaked stub would silently make a later pass/fail for the wrong reason."
`web/test/location-chain.test.mjs` also stubs `globalThis.fetch`. `bot/test/location-pin.test.mjs` patches a different layer entirely — grammY's node shim reads node-fetch's cached `default` export, so `createRequire` reaches `node-fetch` and swaps `.default` for the duration of the case.
### 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 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; 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, 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.
**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
.replace(/\/\*[\s\S]*?\*\//g, " ") // block comments
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (skips URLs)
```
### 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:
| Case | Assertion |
|---|---|
| BA1 | No emitted `.js`/`.css` references a third-party CDN host |
| BA2 | At least one self-hosted `.woff2` was copied through |
| BA3 | A hashed MapLibre worker chunk exists under `dist/assets` |
| 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:
```
run `npm run build -w theme && npm run build -w web` before this suite
```
The suite's header is emphatic about *why* BA5 scans HTML as well as JS: `web/index.html` is one entry shared by both builds, so a JS-only scan would miss an unguarded profile block entirely.
### Two more conventions worth copying
**Lazy module imports for RED phases.** `bot/test/initdata.test.mjs` wraps its import in `loadValidator()` so a missing module produces one `FAIL` per case rather than a lost suite:
```js
let validatorPromise = null;
function loadValidator() {
if (!validatorPromise) validatorPromise = import("../src/security/initdata.ts").then((m) => m.validateInitData);
return validatorPromise;
}
```
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 (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.
---
## Coverage requirements
**No coverage threshold is configured.** There is no `coverageThreshold`, no `nyc`/`c8` config, and no instrumentation anywhere — because there is no framework to instrument with.
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. `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.
---
## Visual capture workflow
Two Playwright scripts generate screenshots. They are **evidence generators, not tests** — they are not part of any `npm test` command and nothing in the suites depends on them. Both share the same shape: a docroot-guarded static server on `127.0.0.1:0`, a Playwright `chromium` launch, one `MIN_BYTES = 10240` blank bar per PNG, a `FAILED`/`MISSING`/`UNDERSIZED` report, and `exit(1)` if anything is unusable.
```bash
npm run capture # → theme/scripts/screenshot.mjs → theme/screenshots/
npm run web:capture # → web/scripts/capture.mjs → web/screenshots/
```
Both output directories are gitignored — they are regenerated artifacts, never committed.
### `theme/scripts/screenshot.mjs`
Serves `theme/` as docroot, then captures:
| Output | Notes |
|---|---|
| `sheet-full.png` | Component sheet at 390px, full page, after `document.fonts.ready` |
| `mockup-<screen>.png` ×4 | The four mockup screens over `file://` — CDN Tailwind, so **network required**. On failure, the committed `screen.png` is copied in as fallback and logged as `FALLBACK` |
| `basemap-kl.png` | 1280×800, waits on the page's own `RAMS-BASEMAP-READY` console signal + 2s tile settle |
The mockups are read-only reference (decision `D-06`) and are never edited.
### `web/scripts/capture.mjs` — the 22-leg harness
Rebuilds all three bundles first, then serves **`web/dist-harness/`**, not `web/dist`:
```bash
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.
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`.
The 22 legs (all under `web/screenshots/`):
| 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 |
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.
---
## 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.
---
## CI integration
**No CI/CD pipeline is detected.** There is no `.github/workflows/`, no `.gitea/workflows/`, and no other workflow definition in the repository.
<!-- VERIFY: automated CI — no workflow directory exists in the working tree as of this writing. The repository is hosted on a private Gitea instance (`http://gitmirah:3000/c-fu/arah2.git`); if a server-side runner was configured outside the repository, this section is out of date. -->
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 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–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.
---
## Quick reference
| I want to… | Command |
|---|---|
| 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` |
| 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:** [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.