MSW handlers and fixtures
MSW (Mock Service Worker) is the mock-mode runtime — handlers + fixtures live under `apps/web/src/lib/msw/` and are also the source of truth for Storybook stories that need network responses.
MSW (Mock Service Worker, catalog msw: ^2.13.4) is how
apps/web runs without a real backend. It backs:
- Storybook stories that need API responses
- Unit tests that exercise components hitting
fetch mockMode=truein the running app — seeapps/web/src/lib/msw/msw-gate.tsx
The Playwright e2e suites (smoke / full) do not use MSW — they hit a real Vercel preview URL.
Layout
apps/web/src/lib/msw/
├── msw-gate.tsx Provider that conditionally enables MSW at runtime
├── mock-worker.ts Worker setup + lifecycle (`start`, `stop`)
├── handlers/
│ ├── index.ts Re-exports all handlers
│ ├── agent.ts POST /v1/messages, GET /events SSE, ...
│ ├── auth.ts better-auth endpoints
│ ├── automations.ts
│ ├── channels.ts
│ ├── connectors.ts
│ ├── devices.ts
│ ├── diaries.ts
│ ├── messages.ts
│ ├── onboarding.ts
│ ├── skills.ts
│ ├── tasks.ts
│ ├── timeline.ts
│ ├── today-pages.ts
│ ├── today-pages-v2.ts
│ ├── today-pages-v2-tckb.ts
│ └── user.ts
├── fixtures/
│ ├── index.ts Re-exports
│ ├── agent.ts Sample message threads, SSE event streams
│ ├── auth.ts Session shapes
│ ├── automations.ts
│ ├── ... (one fixture file per resource, mirroring handlers)
│ └── tckb/
│ └── README.md TCK widget fixture conventions
├── handlers.test.ts Unit tests against the handler set
├── fixtures-contract.test.ts Smoke-tests fixtures stay shaped like the real API
└── mock-worker.test.tsThe split is mirror-image: every resource has one handlers/<resource>.ts
and one fixtures/<resource>.ts. Handlers describe the request → response
flow; fixtures describe the data shape that flows through.
Why the split
A fixture is reusable across multiple handlers and across non-MSW code paths (Storybook decorators, unit tests that don't need a worker). Keeping fixtures data-only and handlers stateful/transport-only means:
- Storybook can
import { userFixture } from '@/lib/msw/fixtures/user'and pass it directly to a component prop without booting MSW - Unit tests can stub a single handler with a custom fixture variant
(
http.get('/api/user', () => HttpResponse.json({ ...userFixture, name: 'X' }))) without the whole MSW worker being involved - The contract test
(
fixtures-contract.test.ts) validates fixtures still match the generated OpenAPI types after the codegen step regenerates them
How handlers are activated
apps/web decides whether to enable MSW at runtime via
msw-gate.tsx:
// pseudo-code
function MswGate({ children }) {
if (!isMockModeEnabled()) return children
return <MswProvider handlers={allHandlers}>{children}</MswProvider>
}isMockModeEnabled() checks a ?mockMode=true query param plus a feature
flag, so individual pages and Storybook stories can flip it without affecting
the rest of the app.
In Storybook, the same gate is mounted in apps/web/.storybook/preview.ts
so any story renders with MSW enabled. Story-specific handler overrides go
through Storybook's parameters.msw.handlers convention.
Adding a handler
// apps/web/src/lib/msw/handlers/widgets.ts
import { http, HttpResponse } from 'msw'
import { widgetsFixture } from '../fixtures/widgets'
export const widgetsHandlers = [
http.get('/api/widgets', () => HttpResponse.json(widgetsFixture.list)),
http.post('/api/widgets', async ({ request }) => {
const body = await request.json()
return HttpResponse.json({ ...widgetsFixture.created, ...body }, { status: 201 })
}),
]// apps/web/src/lib/msw/fixtures/widgets.ts
export const widgetsFixture = {
list: [
/* ... */
],
created: {
/* ... */
},
}Then re-export from handlers/index.ts and fixtures/index.ts. The contract
test will pick the new fixture up automatically; if its shape diverges from the
generated OpenAPI types, the test fails and points at the field.
What MSW does not mock
- SSE streams that emit > tens of events. MSW supports SSE but the worker
thread has practical limits; the agent SSE handler in
handlers/agent.tsuses a bounded fixture stream - Token streaming beyond a small canned set (tier-2 improvement in TODO.md)
- WebSocket transports — none in the app currently
For everything else, MSW is the right layer. Avoid vi.mock('node-fetch') /
vi.mock('axios') style mocks; they are brittle and miss the request shape
that MSW exercises.
Worker lifecycle
mock-worker.ts exports setupMockWorker() which is what MswGate calls.
It registers all handlers, starts the worker, and returns a cleanup hook.
Tests that want per-test handler overrides use the worker.use(...) /
worker.resetHandlers() pattern — see
apps/web/src/lib/msw/handlers.test.ts
for the canonical setup.