MSW mocks
MSW (Mock Service Worker) is the architectural mock layer — Storybook stories, unit tests, and the runtime `mockMode=true` path all share the same handlers and fixtures.
MSW is not just a testing tool in this repo — it's a first-class architectural layer that mock-runs the entire app without a backend. Three consumers share the same handlers + fixtures:
| Consumer | Why |
|---|---|
| Storybook stories | Components that hit the network need responses to render |
Unit tests in apps/web/src/ | Component behaviors-under-test that exercise fetch |
The running app with mockMode=true | A live demo path: full apps/web UX, zero cloud dependencies |
The implementation lives under
apps/web/src/lib/msw/
— see Toolchain → Testing → MSW for the file
layout. This page covers why MSW is structured this way, not how to add
a handler.
Mock mode is a runtime decision
The web app decides whether to enable MSW at runtime via
MswGate:
function MswGate({ children }) {
if (!isMockModeEnabled()) return children
return <MswProvider handlers={allHandlers}>{children}</MswProvider>
}isMockModeEnabled() checks a ?mockMode=true query parameter plus a
feature flag. So:
- Production users: query param not set → MSW never installs → real network
- A demo session:
?mockMode=true→ MSW intercepts every request → app runs entirely on fixtures - A specific page in Storybook: per-story
parameters.msw.handlersoverride
The gate's separation between "user-runtime mock mode" and "build-time test mode" lets product demos run on the same code path as unit tests. They share the same fixtures and the same handlers.
Handlers + fixtures, side by side
The directory split:
apps/web/src/lib/msw/
├── handlers/<resource>.ts transport — request → response
└── fixtures/<resource>.ts data — the JSON shapeThe handlers describe what to do when a request comes in. The fixtures
describe what the data looks like in a shape-only sense. A test that
needs a custom variant overrides just the fixture; a test that needs custom
status codes overrides the handler. Both can live without booting the full
MSW worker — import { userFixture } from '@/lib/msw/fixtures/user' is
data-only and free of MSW dependencies.
The contract test that keeps fixtures honest
apps/web/src/lib/msw/fixtures-contract.test.ts
runs the generated OpenAPI Zod schemas (from pnpm api:generate)
against every fixture in the codebase. When a backend schema changes:
- Cloud team updates the OpenAPI spec
- Someone runs
pnpm api:generateon web - New Zod schemas land in
src/lib/api/generated/zod.gen.ts fixtures-contract.test.tsfails on every fixture that no longer matches- Fix the fixtures (or — rarely — push back on the schema change)
This catches the most common drift between mock data and real responses: fixtures going stale silently. The test failure tells you exactly which field shape diverged.
Why MSW and not vi.mock('axios') style mocks
Tooling alternatives we considered and rejected:
| Approach | Why it's worse |
|---|---|
vi.mock('fetch') per test | Each test repaints the mock setup; no shared baseline |
vi.mock('@hey-api/client-fetch') | Tied to the codegen client; brittle when codegen changes |
Custom mockFetch() injection | Requires every fetch caller to accept a dependency injection seam |
| MSW | Intercepts at the network layer; same setup for tests and runtime mock |
MSW intercepts at the fetch/XHR boundary, so:
- The app's code paths run unchanged in mock mode — same
useApiClient, sameagent-client, same headers, same retry logic - Storybook stories don't have to know they're in mock mode (the gate above the story's tree handles it)
- Unit tests that exercise a specific failure mode can override one handler
via
worker.use(http.get('/api/X', () => HttpResponse.error()))without re-mocking the whole stack
What MSW does not do
- No SSE-stream replay with token-level fidelity. The agent handler in
handlers/agent.tsemits a small canned event stream. Token-level streaming with realistic timing is a tier-2 improvement (tracked in TODO.md). - No WebSocket support. The app doesn't currently use WebSockets; if it ever does, MSW will need configuration to handle them.
- No production-shaped error responses for every endpoint. The handlers
emit success responses by default; tests opt into error responses
per-test via
worker.use(...).
When to add a new handler
You're hitting a new backend endpoint from apps/web for the first time:
- Run
pnpm api:generateif the cloud team has added the endpoint to the OpenAPI spec - Add a fixture in
lib/msw/fixtures/<resource>.tsmatching the generated Zod schema - Add a handler in
lib/msw/handlers/<resource>.tsreturning the fixture - Re-export both from the respective
index.ts fixtures-contract.test.tswill pick up the new fixture automatically
Skipping these steps means Storybook breaks (no response → component shows loading forever) and unit tests have to import handlers individually. The mirror pattern keeps it cheap.
Related
- API codegen — generates the Zod schemas the contract test uses
- Chat runtime — chat handlers under
lib/msw/handlers/agent.tsmock the SSE stream that the real runtime consumes - Toolchain → Testing → MSW — file layout and adding-a-handler walkthrough
Chat runtime
A high-level overview of the chat-v2 runtime — Postbox-style store, SSE-driven live source, paginated history, holes — with pointers to the full local docs under `apps/web/docs/chat/`.
Cloud dev (Claude Code on the web)
Running this repo end-to-end inside Claude Code on the web — credential flows, env panel, network allowlist, MCP servers.