2
Testing
araharah-executor edited this page 2026-10-25 12:36:12 +08:00
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.

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): 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

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:

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 awaits every one.

The runner (47 lines, 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").

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:

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

# 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:

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:

cd theme && node scripts/audit.test.mjs

Typecheck gates

None of the suites compile. Run these alongside:

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:

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.

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

#!/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:

{
  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:

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:

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.

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:

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.

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:

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.

The gate suites themselves run directly (no npm script — they are maintainer tools):

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.

The full green check a contributor must run by hand before pushing:

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 for the secrets the bot: tests substitute with 12345:TESTTOKEN · PRIVACY-AUDIT for what each privacy gate inspects · BUDGETS for the measured budget record the budget CLI prints · SEARCH-QUALITY for the corpus metrics the CO cases pin · ARCHITECTURE for why the Worker/Mini App split is what it is.