Skip to content

Testing & CI

Chapter 19. The last chapter covered the admin UI. This final chapter is how the whole system is proven correct and shipped — the test pyramid, the end-to-end proofs, and the CI gate. This is the end of the book.

How Ratio-3.0 is proven correct and how it ships. Source of truth: the __tests__/ dirs, tests/, bench/, and .github/workflows/ci.yml.

Two test runners (read this first)

There are two distinct stacks, and conflating them is the number-one gotcha:

  • Backend + shared packages → node:test (Node's built-in runner), run as node --import tsx --import ./tests/bootstrap.ts --test. ~144 *.test.ts files import { test } from 'node:test'.
  • apps/admin-web → Vitest (vitest run). ~19 *.test.ts(x) files. Its Vitest environment is 'node', not a DOM — even .test.tsx component tests run in Node, so they exercise render/logic seams, not a real browser DOM.

Bun is only the package manager / task runner — it doesn't run the tests; node --import tsx --test does.

The pyramid

Unit tests sit beside their source in __tests__/. The shape, and where the risk is:

AreaFiles
builder-core (theme / render / compose)46
admin-api (routes / middleware / authz)35
origin (storefront server)20
storefront-integrations7
data-db5
edge-core / seo4 each
edge app3
builder-render (the sandbox/isolate)2
admin-web (Vitest)19

The thinnest file count sits on the highest-risk surface

builder-render — the untrusted-Liquid sandbox + worker isolate, the sharpest security edge in the system — has only 2 test files. They're dense and good, but treat any change there with extra care.

The end-to-end proofs — tests/

  • tests/bootstrap.ts is --import'd before every test: it sets EDGE_SECRET (many modules resolve the edge secret at import and fail closed without it) and configures the DB — but deliberately does not set RATIO_LOCAL (that would flip admin-api into dev-insecure mode and break the prod-mode assertions).
  • The e2e tests use the real Postgres and real MinIO; they mock only the external HTTP boundary, by injection. They import the actual Hono apps and drive them via app.fetch(new Request(...)) (no sockets), talk to the real s2poc_test DB, and stub Cloudflare by passing a fakeCf: typeof fetch.
  • The prove harnesses (tests/prove.ts S2, tests/prove-s1.ts S1) are standalone scripts that need the live stack running (edge :8080, origin :9090). prove.ts asserts the tenancy invariants against the running system (two tenants resolve by host; a client-supplied X-Ratio-Tenant is ignored; the private origin 403s without edge auth; cross-tenant → 404; deny-by-default forTenant(undefined) throws). prove-s1.ts hits a page 5× and asserts 1 origin render + 4 edge hits, /cart always bypasses.

The engine parity test

packages/builder-render/src/__tests__/liquid-render.test.ts renders a battery of filters through both the in-process engine and the worker isolate and asserts byte-equality — the anti-drift net for the two hand-maintained engine copies. The regression it exists to catch:

// the worker's `money` filter once didn't divide paise→rupees → every price rendered 100× too high
assert.equal(await renderUntrusted('{{ 49900 | money }}', ...), '₹499.00');

isolate-pool.test.ts proves the pool contract: warm workers are reused, a wall-clock-killed runaway is terminated and replaced (co-tenants keep serving), and a worker that fails to construct rejects rather than crashing.

CI — .github/workflows/ci.yml

PR runs are disabled (push-to-main + workflow_dispatch only) to conserve Actions minutes — you are the pre-merge gate: bun run typecheck && lint && format:check && test locally.

The build job (a postgres:17 service container) runs the four gates — typecheck, lint, format:check, test — where test first migrates a fresh s2poc_test then runs the node:test suite over the packages, the three backend apps, and tests/e2e. Plus the star guard:

The "edge stays Node-free" guard

The Cloudflare Worker runs on workerd, not Node — no node:fs, no threads, no sonic-boom (pino's Node-only writer). But @ratio/observability (a root dep) pulls in pino. CI builds the Worker with wrangler deploy --dry-run and then greps the bundle for sonic-boom|node:(fs|stream|worker_threads) — if a Node-only dependency ever leaks into the edge's import graph, the build fails. (It greps sonic-boom, the real bundle marker, not the word "pino", which appears legitimately in comments.) This is why observability is split into observability / observability-core / observability-edge — the edge gets a Node-free variant. Importing plain @ratio/observability into edge code will trip this.

The deploy/origin-image/purge/admin-ui jobs are gated behind repo variables (DEPLOY_AWS, ECS_DEPLOY, PATH_B, PURGE_CACHE, DEPLOY_ADMIN_UI) — see the deploy runbook.

Running it locally

bun run dev (dev/all.ts) brings up the full stack: docker compose up (Postgres :5433 + MinIO :9000) → migrate → ensure bucket → three services (storefront edge :8080 + origin :9090, admin-api :8787, admin-web :5173), all RATIO_LOCAL=true. Then bun run test (backend), bun run --cwd apps/admin-web test (SPA), or bun run prove / prove:s1 (need the stack up).

Green CI does not mean the object-store tests ran

CI has no MinIO. ~8 tests that need a published bundle (theme-lifecycle, the origin bundle/asset/ownership tests, the e2e lifecycle + a round-5 case) self-skip unless BUNDLE_S3_ENDPOINT is set — they pass green in CI without running. Also: CI Postgres is :5432, local is :5433 (read DATABASE_URL, never hardcode). To actually exercise the bundle tests, run locally with MinIO up and the env set.

Test philosophy

  • Real DB, never mocked — every backend test hits the real s2poc_test Postgres, inserting/deleting its own rows; test:setup even runs the migrations. Real MinIO stands in for S3 via the same S3ObjectStore code path. Only the third-party HTTP boundary is stubbed (by DI), never the database.
  • Isolation is a blocking regression suiteapps/admin-api/src/__tests__/cross-tenant-authz.test.ts encodes "tenant A can never touch tenant B": a merchant can't claim another's host (409), can't overwrite another's store (409), and a store-scoped agent token can't escalate via /stores or /assistant (403). Its unit-level twin is prove.ts's deny-by-default check — the repo refuses to build a tenant handle without a tenant id, so cross-tenant reads are structurally impossible, not merely filtered.

Gotchas

  1. Two runners — never mix node:test and vitest imports across the boundary.
  2. admin-web tests run in a Node env, not a DOM.
  3. bootstrap.ts must be --import'd before any backend test, or it fails closed on DATABASE_URL/EDGE_SECRET.
  4. Set EDGE_SECRET, not RATIO_LOCAL, for tests.
  5. No MinIO in CI → object-store tests skip silently; a green build didn't run them.
  6. PRs get zero CI — validate locally.
  7. The render engine is duplicated — a filter change must land in engine.ts and worker.mjs, plus a new parity case.
  8. prove/prove:s1 aren't part of bun run test — they need the live stack and are run by hand.