Skip to content

Testing Guide

Framework

Vitest across all packages β€” TypeScript-native, ESM-first, fast.

PackageEnvironmentKey Libraries
workers/apinodeVitest, Hono app.request()
apps/webjsdomVitest, React Testing Library, @testing-library/jest-dom
packages/sharednodeVitest

Directory Structure

workers/api/src/__tests__/
__mocks__/env.ts # KV, R2, Env factories
__fixtures__/html-samples.ts
middleware/auth.test.ts
routes/auth.test.ts
routes/files.test.ts
routes/convert.test.ts
routes/export.test.ts
routes/benchmark.test.ts
services/wcag-validator.test.ts
services/html-analyzer.test.ts
services/ux-optimizer.test.ts
services/math-detector.test.ts
services/claude-converter.test.ts
services/marker-converter.test.ts
services/unpdf-claude-converter.test.ts
services/mathpix-pdf.test.ts
services/image-enhancer.test.ts
services/benchmark-orchestrator.test.ts
services/axe-validator.test.ts
services/pdf-generator.test.ts
utils/crypto.test.ts
utils/response.test.ts
index.test.ts
apps/web/src/__tests__/
setup.ts # RTL + jsdom + matchMedia mock
lib/utils.test.ts
lib/mathpix-storage.test.ts
lib/api.test.ts
lib/auth-context.test.tsx
components/upload/DropZone.test.tsx
packages/shared/src/__tests__/
constants.test.ts
benchmark-types.test.ts

Running Tests

Terminal window
# All packages (via Turborepo)
npm run test:run
# With coverage
npm run test:coverage
# Watch mode
npm test
# Single package
cd workers/api && npx vitest run
cd apps/web && npx vitest run
cd packages/shared && npx vitest run
# Browser integration tests (real Puppeteer, requires internet for axe-core CDN)
cd workers/api && npm run test:browser

Browser Integration Tests

Browser tests live in workers/api/src/__tests__/integration/ and are excluded from the default vitest run. They use real Puppeteer (bundled Chromium, installed automatically with npm install) and axe-core loaded from CDN.

Run them explicitly before a deployment or major release:

Terminal window
cd workers/api && npm run test:browser

What they test:

  • 10 axe-core end-to-end scenarios (real violations detected in a real browser)
  • 2 resize text checks β€” 1.4.4 (responsive passes, fixed-width fails)
  • 2 text spacing checks β€” 1.4.12 (normal HTML passes, fixed-height clip fails)
  • 2 focus order checks β€” 2.4.3 (natural order passes, positive tabindex disruption warned)

Requirements:

  • Puppeteer Chromium binary (auto-installed with npm install)
  • Internet access (axe-core loaded from cdnjs.cloudflare.com)
  • ~30 seconds to run all 16 tests

Config: workers/api/vitest.browser.config.ts β€” 60 second test timeout.

Mock Strategy

DependencyApproach
Cloudflare KVIn-memory Map-backed mock (createMockKV())
Cloudflare R2In-memory Map-backed mock (createMockR2())
Cloudflare Puppeteervi.mock('@cloudflare/puppeteer') with mock page/browser
Anthropic SDKvi.mock('@anthropic-ai/sdk') with mock messages.create
unpdfvi.mock('unpdf')
External fetch (Mathpix, Marker)vi.stubGlobal('fetch', mockFetch)
Supabase clientvi.mock('@/lib/supabase')
localStoragejsdom’s built-in window.localStorage
Auth (JWTs)Real HMAC-SHA256 signed JWTs via createTestJwt() helper

Adding New Tests

  1. Create a .test.ts (or .test.tsx) file in the appropriate __tests__/ directory
  2. Import from vitest: { describe, it, expect, vi, beforeEach }
  3. Use existing mock factories from __mocks__/env.ts for API tests
  4. For route tests, use the createTestJwt pattern from existing route tests
  5. Run npx vitest run in the package to verify

Ratchet Tests (declining allowlists)

Some rules cannot be expressed as a unit test of one module β€” they are invariants across files, apps or runtimes. We encode those as ratchets: a test that statically parses the source, asserts the invariant, and carries an explicit allowlist of the places that do not satisfy it yet. Two rules make a list a ratchet rather than a rug:

  1. A subject not on the allowlist that breaks the invariant fails, naming what is missing.
  2. A subject on the allowlist that now satisfies the invariant also fails, telling the developer to delete its entry.

Rule 2 is what makes the list tighten-only: an allowlist entry can never quietly outlive the gap it documents.

RatchetGuards
workers/api/src/__tests__/route-parity.test.tsroute registration matches between the Worker and Lambda entry points (plus: every org-chart route is also on the Node composition). server.ts’s full set is deliberately not diffed β€” see that test’s header
workers/api/src/__tests__/env-parity.test.tsevery Env key is wired into buildSyntheticEnv() (Node) and c.env (Lambda)
packages/accessible-org-ui/src/__tests__/toast-standard-ratchet.test.tsevery released app mounts the shared toast with error auto-reporting (below)
workers/api/src/__tests__/zdr-google-endpoint-guard.test.tsno module outside an explicit allowlist constructs a direct Google AI client
workers/api/src/__tests__/config-ratchet-coverage.test.tsthe workflow’s unconditional cross-workspace step lists exactly the workers/api tests that read outside their workspace, in both directions

That table is an index, not a closed set β€” add a row when you add a ratchet.

Note that config-ratchet-coverage.test.ts polices only workers/api/src/__tests__: it walks its own __dirname, so a cross-workspace ratchet living in any other package β€” the toast one below included β€” is outside its reach and is not scheduled by it. See the scheduling caveat at the end of the next section.

Error-reporting standard (released apps)

Every released app β€” see RELEASED_APPS in that test for the current set β€” must meet three mechanical rules, which the ratchet enforces statically:

  1. mount the ToastProvider exported by @accessible-org/ui, directly or through a thin local wrapper (AppToastProvider, components/toast/…);
  2. pass it a recordError β€” normally createErrorRecorder({ apiBase, getAccessToken }) β€” so useNotifyError POSTs to /api/errors, which files or updates a support ticket and alerts admin over Telegram on each new error code (repeats throttled), plus an immediate email for severity: 'critical'. Without it users see a toast and nobody is told;
  3. actually raise toasts from its call sites (useToast / useNotify / useNotifyError from the shared package), rather than ad-hoc inline error text.

Plus one rule no parser can judge, left to code review: surface the server’s error.message for failed calls, falling back to a generic string only when the server returned none β€” and report even then.

Detection is by TypeScript AST, not grep: JSX tag names and the recordError expression are resolved through each module’s import bindings, so aliased imports, local wrappers and barrels are followed while dead imports and unrendered provider trees are not counted. The detector’s own limits are listed in its file header β€” when one bites, teach the detector rather than adding a KNOWN_GAPS entry.

To enforce a newly released app, add one line to RELEASED_APPS in that test. If it is not compliant yet, add a KNOWN_GAPS entry naming the issue that tracks the work and pinning the failure signature β€” never to silence a regression in an app that used to comply. Unreleased apps are deliberately out of scope (several carry forked local toast implementations; see #3445).

Scheduling caveat β€” #3451. This ratchet lives in packages/accessible-org-ui but inspects apps/*, and CI selects workspaces with turbo --filter='...[origin/main]' β€” changed packages plus their dependents. An apps-only PR does not schedule the shared package. The ratchet does run on every PR touching packages/accessible-org-ui, which includes every PR that edits KNOWN_GAPS. #3451 carries the one workflow step that closes the gap.

Coverage Targets

AreaTarget
workers/api/src/services/80%
workers/api/src/routes/70%
workers/api/src/middleware/85%
workers/api/src/utils/90%
packages/shared/95%
apps/web/src/lib/70%
apps/web/src/components/60%
Overall75%