agentleFS
anivaranivar/msw-skill/AGENTS.md

msw-skill

AGENTS.md2 starsChanged 9 days agoMITView on GitHub

This document is for AI agents and LLMs to follow when writing, reviewing, or debugging MSW (Mock Service Worker) handlers, server setup, and test patterns. It compiles all rules and references into a single executable guide. Baseline: msw ^3.0.0 (audited against 3.0.3; ESM-only, Node.js >=22, TypeScript >=5.9) MSW (Mock Service Worker) is a network-level API mocking library for JavaScript/TypeScript. It intercepts HTTP and GraphQL requests using the http and graphql namespaces, returning mock responses via HttpResponse; it also mocks Server-Sent…

  • Reads credentials
  • In this repositoryOne other file it gives its agents.

The file

Files
AGENTS.md

MSW — Complete Guide

This document is for AI agents and LLMs to follow when writing, reviewing, or debugging MSW (Mock Service Worker) handlers, server setup, and test patterns. It compiles all rules and references into a single executable guide.

Baseline: msw ^3.0.0 (audited against 3.0.3; ESM-only, Node.js >=22, TypeScript >=5.9)


Abstract

MSW (Mock Service Worker) is a network-level API mocking library for JavaScript/TypeScript. It intercepts HTTP and GraphQL requests using the http and graphql namespaces, returning mock responses via HttpResponse; it also mocks Server-Sent Events with sse and WebSocket connections with ws. In tests, setupServer (from msw/node) intercepts at the request-client level; in the browser, setupWorker (from msw/browser) uses a Service Worker; React Native uses @msw/react-native. MSW v2 completely removed the v1 rest namespace, res(ctx.*) response composition, and (req, res, ctx) resolver signature. MSW v3 is ESM-only and requires Node.js >=22 and TypeScript >=5.9; handlers and utilities moved to subpath entrypoints (msw/http, msw/graphql, msw/ws, msw/sse, msw/utils/*), GraphQL mocking is link-first, onUnhandledRequest is now onUnhandledFrame, and the server.events life-cycle connection event is now websocket:connection (the ws link's own addEventListener('connection') is unchanged). This guide covers v2 and v3 patterns, testing best practices, and migration from v1 and v2.


Table of Contents

  1. Handler Design — CRITICAL
  2. Setup & Lifecycle — CRITICAL
  3. Request Reading — HIGH
  4. Response Construction — HIGH
  5. Test Patterns — HIGH
  6. GraphQL — MEDIUM
  7. Utilities — MEDIUM

1. Handler Design

Impact: CRITICAL

Rule: Use http Namespace Instead of rest

The rest namespace was removed in MSW 2.0. All HTTP handlers use http.

// INCORRECT — rest does not exist in v2
// @ts-expect-error msw has no 'rest' export since v2
import { rest } from 'msw'
// @ts-expect-error v1 resolver signature (req, res, ctx)
rest.get('/api/user', (req, res, ctx) => res(ctx.json({ name: 'John' })))

// CORRECT
import { http, HttpResponse } from 'msw/http'
http.get('/api/user', () => HttpResponse.json({ name: 'John' }))

Rule: Never Put Query Parameters in Handler URL Predicates

MSW matches by pathname only. Query parameters are stripped from the predicate, so including them widens the handler to every request on that path rather than narrowing it. MSW logs a warning when it finds them.

// INCORRECT — query string stripped; this matches ALL '/post' requests
http.get('/post?id=1', resolver)

// CORRECT — read query params inside the resolver
http.get('/post', ({ request }) => {
  const url = new URL(request.url)
  const id = url.searchParams.get('id')
  return HttpResponse.json({ id })
})

Rule: Use v2 Destructured Resolver Signature

v2 resolvers receive a single info object: { request, params, cookies, requestId }.

// INCORRECT — v1 triple signature
// @ts-expect-error v2 resolvers take one info object, not (req, res, ctx)
http.get('/api/user/:id', (req, res, ctx) => {
  return res(ctx.json({ id: req.params.id }))
})

// CORRECT — v2 destructured info object
http.get('/api/user/:id', ({ request, params, cookies }) => {
  return HttpResponse.json({ id: params.id })
})

Resolver info properties:

PropertyTypeDescription
requestRequestStandard Fetch API Request object
paramsRecord<string, string | string[]>Path parameters. In msw 3.0.3 a repeating param (:segments+, :path*) arrives as one string with its slashes ('a/b'); the docs and the type also allow an array
cookiesRecord<string, string>Parsed request cookies
requestIdstringUnique request identifier
finalizeFunctionSchedule a cleanup callback to run after the resolver completes

Rule: Use HttpResponse Static Methods Instead of res(ctx.*)

v2 uses standard Response objects constructed via HttpResponse.

// INCORRECT — res() and ctx are removed
// @ts-expect-error v2 resolvers take one info object, not (req, res, ctx)
http.get('/api/user', (req, res, ctx) => {
  return res(ctx.status(200), ctx.json({ name: 'John' }))
})

// CORRECT
http.get('/api/user', () => {
  return HttpResponse.json({ name: 'John' }, { status: 200 })
})

Complete v1 to v2 mapping:

v1 Patternv2 Equivalent
res(ctx.json(data))HttpResponse.json(data)
res(ctx.text(str))HttpResponse.text(str)
res(ctx.xml(str))HttpResponse.xml(str)
res(ctx.body(str))new HttpResponse(str)
res(ctx.status(code))new HttpResponse(null, { status: code })
res(ctx.set(name, val))Include in headers init
res(ctx.delay(ms), ...)await delay(ms); return HttpResponse.json(...)
res(ctx.cookie(name, val))headers: { 'Set-Cookie': 'name=val' }
res.networkError(msg)HttpResponse.error()
res.once(...)http.get(url, resolver, { once: true })

WebSocket Handlers (ws)

MSW mocks WebSocket connections too, through the ws namespace:

import { ws } from 'msw/ws'

const chat = ws.link('wss://chat.example.com')

export const handlers = [
  chat.addEventListener('connection', ({ client }) => {
    client.addEventListener('message', (event) => {
      client.send(`echo: ${event.data}`)
    })
  }),
]

ws.link(url: string | URL | RegExp) matches an endpoint; the connection event fires whenever a client opens a matching connection. client.send() accepts string, Blob and ArrayBuffer; link.broadcast() / link.broadcastExcept() reach every connected client; server.connect() forwards to the real server — without it, the handler is the server. See references/handler-api.md for the full member list.


2. Setup & Lifecycle

Impact: CRITICAL

Rule: Import Server/Worker from Correct Subpaths

// INCORRECT
// @ts-expect-error setupServer is exported from 'msw/node', not 'msw'
import { setupServer } from 'msw'
// CORRECT
import { setupServer } from 'msw/node'    // Node.js (tests, SSR)
import { setupWorker } from 'msw/browser'  // Browser (Storybook, dev)
import { http, HttpResponse } from 'msw/http'   // Handlers and utilities
ExportImport from
http, HttpResponse'msw/http'
graphql'msw/graphql' (install the graphql package)
ws, sse'msw/ws', 'msw/sse'
delay, bypass, passthrough'msw/utils/delay', 'msw/utils/bypass', 'msw/utils/passthrough'
setupServer'msw/node'
setupWorker'msw/browser'
setupServer (React Native)'@msw/react-native'

msw/node pulls in Node.js modules such as http that do not exist in React Native — replace any msw/node import in React Native code with @msw/react-native.

Rule: Always Use beforeAll/afterEach/afterAll Lifecycle Pattern

Missing afterEach(() => server.resetHandlers()) causes handlers added via server.use() to leak between tests.

// INCORRECT — handlers leak
beforeAll(() => server.listen())
afterAll(() => server.close())

// CORRECT
beforeAll(() => server.listen({ onUnhandledFrame: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
HookMethodPurpose
beforeAllserver.listen()Start intercepting requests
afterEachserver.resetHandlers()Remove runtime overrides
afterAllserver.close()Stop intercepting, clean up

Rule: Organize Mocks in src/mocks/

src/mocks/
├── handlers.ts    # Shared happy-path handlers
├── node.ts        # setupServer(...handlers)
└── browser.ts     # setupWorker(...handlers)
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw/http'

export const handlers = [
  http.get('/api/user', () => HttpResponse.json({ name: 'John' })),
  http.get('/api/posts', () => HttpResponse.json([{ id: 1, title: 'Post' }])),
]
// src/mocks/node.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)

Browser setup also needs the worker script on disk before worker.start() will work:

npx msw init <PUBLIC_DIR> --save

This copies mockServiceWorker.js into your public directory — commit it, and verify it is served at /mockServiceWorker.js. The --save flag records msw.workerDirectory in package.json, so the script is copied again automatically whenever you install msw.

On Vite projects, prefer the msw/vite plugin (v3): it serves the worker script for you — no msw init copy step — and exposes a preconfigured network via the virtual:msw module.


3. Request Reading

Impact: HIGH

Rule: Clone Request Before Reading Body in Lifecycle Events

Reading request.json() consumes the body stream. In lifecycle events, clone first.

// INCORRECT — body consumed: the handler lookup fails and the client gets a 500
server.events.on('request:start', async ({ request }) => {
  const body = await request.json()
})

// CORRECT
server.events.on('request:start', async ({ request }) => {
  const body = await request.clone().json()
})

Rule: Always Await Body Reading Methods

v2 uses the standard Fetch API Request — body reading is always async.

// INCORRECT — request.body is a ReadableStream
http.post('/api/user', ({ request }) => {
  const body = request.body
})

// CORRECT
http.post('/api/user', async ({ request }) => {
  const body = await request.json()
})
MethodReturnsUse for
await request.json()Parsed objectJSON payloads
await request.text()StringPlain text, HTML
await request.formData()FormDataForm submissions
await request.arrayBuffer()ArrayBufferBinary data
await request.blob()BlobBinary with MIME type

4. Response Construction

Impact: HIGH

Rule: Use HttpResponse for Cookie Mocking

In the browser, a native Response drops Set-Cookie (a forbidden response header). HttpResponse keeps it. Node.js keeps the header on either class, so use HttpResponse for handlers that run in both.

// INCORRECT in the browser — Set-Cookie silently dropped
new Response(null, { headers: { 'Set-Cookie': 'token=abc' } })

// CORRECT
new HttpResponse(null, { headers: { 'Set-Cookie': 'token=abc' } })

Rule: Use HttpResponse.error() for Network Failures

// INCORRECT — MSW handles this as an unhandled resolver exception, not a network error
http.get('/api/data', () => { throw new Error('fail') })

// CORRECT — fetch rejects with a TypeError (Node.js: 'fetch failed'; each browser has its own text)
http.get('/api/data', () => HttpResponse.error())

Throwing a Response/HttpResponse is different, and is supported: a thrown Response is used as the mocked response, which short-circuits a shared guard.

function withAuthorization(request: Request) {
  if (!request.headers.get('authorization')) {
    throw HttpResponse.text('Unauthorized', { status: 401 })
  }
}

http.get('/resource', ({ request }) => {
  withAuthorization(request)
  return HttpResponse.json({ id: 'abc-123' })
})

Rule: Use sse() for Server-Sent Events, ReadableStream for Chunked

import { sse } from 'msw/sse'

// Server-Sent Events — the sse handler owns the wire format and headers
export const handlers = [
  sse<{ greeting: string }>('/api/events', ({ client }) => {
    client.send({ id: 'abc-123', event: 'greeting', data: 'Hello, John!' })
    client.close()
  }),
]
import { http, HttpResponse } from 'msw/http'
import { delay } from 'msw/utils/delay'

// Non-SSE chunked transfer — a ReadableStream body is still the right tool
http.get('/api/stream', () => {
  const stream = new ReadableStream({
    async start(controller) {
      controller.enqueue(new TextEncoder().encode('chunk1'))
      await delay(100)
      controller.enqueue(new TextEncoder().encode('chunk2'))
      controller.close()
    },
  })

  return new HttpResponse(stream, {
    headers: { 'Content-Type': 'text/plain' },
  })
})

5. Test Patterns

Impact: HIGH

Rule: Test Application Behavior, Not Request Mechanics

// INCORRECT — tests implementation
expect(fetch).toHaveBeenCalledWith('/api/login', expect.any(Object))

// CORRECT — tests observable behavior
await waitFor(() => {
  expect(screen.getByText('Welcome, John!')).toBeInTheDocument()
})

Where the request's validity is the point, validate it inside the handler and return an error response. Exception: one-way requests that leave no trace in the application (analytics, monitoring) may be asserted directly via the life-cycle events API (server.events.on('request:start', ...)).

Rule: Use server.use() for Per-Test Overrides

test('shows error state', async () => {
  server.use(
    http.get('/api/user', () => new HttpResponse(null, { status: 500 }))
  )
  render(<UserProfile />)
  await waitFor(() => {
    expect(screen.getByText('Something went wrong')).toBeInTheDocument()
  })
})

Rule: Use server.boundary() for Concurrent Test Isolation

it.concurrent('admin view', server.boundary(async () => {
  server.use(
    http.get('/api/user', () => HttpResponse.json({ role: 'admin' }))
  )
  // Override scoped to this boundary
}))

it.concurrent('member view', server.boundary(async () => {
  server.use(
    http.get('/api/user', () => HttpResponse.json({ role: 'member' }))
  )
  // Isolated from admin test
}))

Rule: Set onUnhandledFrame: 'error'

// Default 'warn' prints a warning and passes the request through — missing handlers don't fail tests
server.listen({ onUnhandledFrame: 'error' })
StrategyBehavior
'warn' (default)Console warning, request passes through
'error'Console error; the request fails with a network error (TypeError)
'bypass'Silent, passes through
({ frame, defaults }) => {}Conditional handling. Opts you out of the built-in common-asset exemption — re-apply it with isCommonAssetRequest(frame.data.request) after frame instanceof HttpNetworkFrame

6. GraphQL

Impact: MEDIUM

Rule: Return { data } / { errors } Directly

// INCORRECT — ctx.data() removed in v2
// @ts-expect-error v1 resolver signature (req, res, ctx), and msw 3 has no graphql.query()
graphql.query('GetUser', (req, res, ctx) => res(ctx.data({ user: {} })))

// INCORRECT in v3 — query/mutation/operation are no longer on the namespace
// @ts-expect-error msw 3 has no graphql.query(); define operations on graphql.link()
graphql.query('GetUser', ({ variables }) => { /* ... */ })

// CORRECT — v3 is link-first: define operations on a graphql.link()
import { graphql } from 'msw/graphql'
import { HttpResponse } from 'msw/http'

const api = graphql.link('https://api.example.com/graphql')

api.query('GetUser', ({ variables }) => {
  return HttpResponse.json({
    data: { user: { id: variables.id, name: 'John' } },
  })
})

Rule: Use graphql.link() for Every Endpoint (required in v3)

v2 allowed graphql.query() directly on the namespace; v3 is link-first — operations exist only on a link. Give each API its own link: a wildcard or RegExp link ('*', /graphql$/) matches the operation on every endpoint. msw/graphql needs the graphql package (>=16), an optional peer dependency of msw; without it the import fails with Cannot find package 'graphql'.

const github = graphql.link('https://api.github.com/graphql')
const internal = graphql.link('/graphql')

export const handlers = [
  github.query('GetUser', ({ variables }) => {
    return HttpResponse.json({ data: { user: { login: variables.login } } })
  }),
  internal.query('GetUser', ({ variables }) => {
    return HttpResponse.json({ data: { user: { id: variables.id } } })
  }),
]

Link URLs accept relative or absolute strings, wildcards, and regular expressions. v3 also adds api.subscription() for mocking GraphQL subscriptions, publishing data through the subscription object exposed on the resolver:

api.subscription('OnCreateMessage', ({ subscription }) => {
  subscription.publish({ data: { messageCreated: { content: 'Welcome!' } } })
})

7. Utilities

Impact: MEDIUM

Rule: bypass() vs passthrough() — Not Interchangeable

import { bypass } from 'msw/utils/bypass'
import { passthrough } from 'msw/utils/passthrough'

// passthrough() — let intercepted request through to real server
http.get('/api/flags', () => {
  if (process.env.USE_REAL_FLAGS) return passthrough()
  return HttpResponse.json({ enabled: true })
})

// bypass() — make additional request that won't be intercepted
http.get('/api/user', async ({ request }) => {
  const real = await fetch(bypass(request))
  const data = await real.json()
  return HttpResponse.json({ ...data, extra: 'mocked' })
})
FunctionCreates new request?Original continues?Use case
passthrough()NoYesConditional mocking
bypass(request)YesNoResponse patching

Rule: Use Explicit Milliseconds with delay()

// INCORRECT — in Node.js the realistic delay is cut down ("negated to prevent
// it affecting the test performance"), so neither is observable in a test
await delay()
await delay('real')       // Sets the same realistic response time — same result

// CORRECT
await delay(200)          // Always waits 200ms
await delay('infinite')   // Never resolves — test timeout handling
UsageBrowserNode.js
delay()Random ~100-400msNegated
delay(ms)Waits msWaits ms
delay('real')Random ~100-400msNegated — identical to delay()
delay('infinite')Never resolvesNever resolves

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.