Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/docs/api/SnapshotAgent.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ new SnapshotAgent([options])
- **ignoreHeaders** `Array<String>` - Headers to ignore during request matching
- **excludeHeaders** `Array<String>` - Headers to exclude from snapshots (for security)
- **matchBody** `Boolean` - Whether to include request body in matching. Default: `true`
- **normalizeBody** `Function` - Optional function `(body) => string` to normalize the request body before matching (e.g. strip volatile fields like timestamps). Only used when `matchBody` is `true`.
- **matchQuery** `Boolean` - Whether to include query parameters in matching. Default: `true`
- **normalizeQuery** `Function` - Optional function `(query: URLSearchParams) => string` to normalize query parameters before matching (e.g. strip volatile params like cache-busters). Only used when `matchQuery` is `true`.
- **caseSensitive** `Boolean` - Whether header matching is case-sensitive. Default: `false`
- **shouldRecord** `Function` - Callback to determine if a request should be recorded
- **shouldPlayback** `Function` - Callback to determine if a request should be played back
Expand Down Expand Up @@ -108,6 +110,27 @@ await agent.saveSnapshots('./custom-snapshots.json')

## Advanced Configuration

### Body Matching

By default (`matchBody: true`) the full request body string is included in the snapshot key. Set it to `false` to ignore the body entirely, or use `normalizeBody` to strip volatile fields (like timestamps) before matching:

```javascript
const agent = new SnapshotAgent({
mode: 'playback',
snapshotPath: './snapshots.json',

// Match on everything except the timestamp field
normalizeBody: (body) => {
if (!body) return ''
const parsed = JSON.parse(String(body))
delete parsed.timestamp
return JSON.stringify(parsed)
}
})
```

`normalizeBody` receives the raw body (`string | Buffer | null | undefined`) and must return a `string`. It runs at both record and playback time so the hash is consistent. Two requests match the same snapshot whenever their normalized strings are identical.

### Header Filtering

Control which headers are used for request matching and what gets stored in snapshots:
Expand Down
2 changes: 2 additions & 0 deletions lib/mock/snapshot-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ class SnapshotAgent extends MockAgent {
ignoreHeaders: opts.ignoreHeaders,
excludeHeaders: opts.excludeHeaders,
matchBody: opts.matchBody,
normalizeBody: opts.normalizeBody,
matchQuery: opts.matchQuery,
normalizeQuery: opts.normalizeQuery,
caseSensitive: opts.caseSensitive,
shouldRecord: opts.shouldRecord,
shouldPlayback: opts.shouldPlayback,
Expand Down
41 changes: 38 additions & 3 deletions lib/mock/snapshot-recorder.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ const { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } =
* @property {Array<string>} [ignoreHeaders=[]] - Headers to ignore for matching
* @property {Array<string>} [excludeHeaders=[]] - Headers to exclude from matching
* @property {boolean} [matchBody=true] - Whether to match request body
* @property {boolean} [matchQuery=true] - Whether to match query properties
* @property {(body: string|Buffer|null|undefined) => string} [normalizeBody] - Function to normalize the body before matching (e.g. strip timestamps)
* @property {boolean} [matchQuery=true] - Whether to match query parameters
* @property {(query: URLSearchParams) => string} [normalizeQuery] - Function to normalize query parameters before matching (e.g. strip volatile params)
* @property {boolean} [caseSensitive=false] - Whether header matching is case-sensitive
*/

Expand Down Expand Up @@ -79,6 +81,37 @@ const { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } =
* @property {string} timestamp - ISO timestamp of when the snapshot was created
*/

/**
* Normalizes the URL string used for request matching.
*
* @param {URL} url - Parsed request URL
* @param {boolean} matchQuery - Whether to include query parameters in matching
* @param {((query: URLSearchParams) => string)|undefined} normalizeQuery - Optional normalization function
* @returns {string} - URL string for hashing
*/
function normalizeUrlForMatching (url, matchQuery, normalizeQuery) {
if (matchQuery === false) return `${url.origin}${url.pathname}`
if (normalizeQuery) {
const normalized = String(normalizeQuery(url.searchParams) ?? '')
return normalized ? `${url.origin}${url.pathname}?${normalized}` : `${url.origin}${url.pathname}`
}
return url.toString()
}

/**
* Normalizes the body value used for request matching.
*
* @param {string|Buffer|null|undefined} body - Raw request body
* @param {boolean} matchBody - Whether to include the body in matching
* @param {((body: string|Buffer|null|undefined) => string)|undefined} normalizeBody - Optional normalization function
* @returns {string} - Body string for hashing
*/
function normalizeBodyForMatching (body, matchBody, normalizeBody) {
if (matchBody === false) return ''
if (normalizeBody) return String(normalizeBody(body) ?? '')
return body ? String(body) : ''
}

/**
* Formats a request for consistent snapshot storage
* Caches normalized headers to avoid repeated processing
Expand All @@ -99,9 +132,9 @@ function formatRequestKey (opts, headerFilters, matchOptions = {}) {

return {
method: opts.method || 'GET',
url: matchOptions.matchQuery !== false ? url.toString() : `${url.origin}${url.pathname}`,
url: normalizeUrlForMatching(url, matchOptions.matchQuery, matchOptions.normalizeQuery),
headers: filterHeadersForMatching(normalized, headerFilters, matchOptions),
body: matchOptions.matchBody !== false && opts.body ? String(opts.body) : ''
body: normalizeBodyForMatching(opts.body, matchOptions.matchBody, matchOptions.normalizeBody)
}
}

Expand Down Expand Up @@ -250,7 +283,9 @@ class SnapshotRecorder {
ignoreHeaders: options.ignoreHeaders || [],
excludeHeaders: options.excludeHeaders || [],
matchBody: options.matchBody !== false, // default: true
normalizeBody: options.normalizeBody || undefined,
matchQuery: options.matchQuery !== false, // default: true
normalizeQuery: options.normalizeQuery || undefined,
caseSensitive: options.caseSensitive || false
}

Expand Down
103 changes: 103 additions & 0 deletions test/snapshot-testing.js
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,48 @@ describe('SnapshotAgent - Request Matching', () => {
'Should return original recorded response with original query params')
})

it('normalizeQuery function for partial query matching', async (t) => {
const server = createTestServer((req, res) => {
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({ url: req.url }))
})

const { origin } = await setupServer(server)
const snapshotPath = createSnapshotPath('normalize-query')

setupCleanup(t, { server, snapshotPath })

// Strip the volatile '_cb' cache-buster param before matching
const normalizeQuery = (params) => {
const copy = new URLSearchParams(params)
copy.delete('_cb')
return copy.toString()
}

const agent = new SnapshotAgent({ mode: 'record', snapshotPath, normalizeQuery })
const originalDispatcher = getGlobalDispatcher()
setupCleanup(t, { agent, originalDispatcher })
setGlobalDispatcher(agent)

await request(`${origin}/api/data?filter=active&_cb=111`)
await agent.saveSnapshots()

// Playback with a different cache-buster — should still match
const playbackAgent = new SnapshotAgent({ mode: 'playback', snapshotPath, normalizeQuery })
setupCleanup(t, { agent: playbackAgent })
setGlobalDispatcher(playbackAgent)

const response = await request(`${origin}/api/data?filter=active&_cb=999`)
assert.strictEqual(response.statusCode, 200, 'Should match snapshot despite different cache-buster')

// Different filter value — should NOT match
await assert.rejects(
() => request(`${origin}/api/data?filter=inactive&_cb=111`),
/No snapshot found/,
'Should not match snapshot with different filter value'
)
})

it('body matching control', async (t) => {
const server = createTestServer((req, res) => {
let body = ''
Expand Down Expand Up @@ -952,6 +994,67 @@ describe('SnapshotAgent - Request Matching', () => {
assert.strictEqual(responseBody.received, 'original-data',
'Should return recorded response with original body')
})

it('matchBody function for partial body matching', async (t) => {
const server = createTestServer((req, res) => {
let body = ''
req.on('data', chunk => { body += chunk })
req.on('end', () => {
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({ received: body }))
})
})

const { origin } = await setupServer(server)
const snapshotPath = createSnapshotPath('body-matching-fn')

setupCleanup(t, { server, snapshotPath })

// Normalize body by stripping the timestamp before hashing
const normalizeBody = (body) => {
if (!body) return ''
const parsed = JSON.parse(String(body))
delete parsed.timestamp
return JSON.stringify(parsed)
}

const agent = new SnapshotAgent({ mode: 'record', snapshotPath, normalizeBody })
const originalDispatcher = getGlobalDispatcher()
setupCleanup(t, { agent, originalDispatcher })
setGlobalDispatcher(agent)

await request(`${origin}/api/submit`, {
method: 'POST',
body: JSON.stringify({ action: 'create', timestamp: '2024-01-01T00:00:00Z' }),
headers: { 'content-type': 'application/json' }
})

await agent.saveSnapshots()

// Playback with a different timestamp — should still match
const playbackAgent = new SnapshotAgent({ mode: 'playback', snapshotPath, normalizeBody })
setupCleanup(t, { agent: playbackAgent })
setGlobalDispatcher(playbackAgent)

const response = await request(`${origin}/api/submit`, {
method: 'POST',
body: JSON.stringify({ action: 'create', timestamp: '2025-06-15T12:00:00Z' }),
headers: { 'content-type': 'application/json' }
})

assert.strictEqual(response.statusCode, 200, 'Should match snapshot despite different timestamp')

// Different action — should NOT match
await assert.rejects(
() => request(`${origin}/api/submit`, {
method: 'POST',
body: JSON.stringify({ action: 'delete', timestamp: '2024-01-01T00:00:00Z' }),
headers: { 'content-type': 'application/json' }
}),
/No snapshot found/,
'Should not match snapshot with different action'
)
})
})

describe('SnapshotAgent - Management Features', () => {
Expand Down
4 changes: 4 additions & 0 deletions types/snapshot-agent.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ declare namespace SnapshotRecorder {
ignoreHeaders?: string[]
excludeHeaders?: string[]
matchBody?: boolean
normalizeBody?: (body: string | Buffer | null | undefined) => string
matchQuery?: boolean
normalizeQuery?: (query: URLSearchParams) => string
caseSensitive?: boolean
shouldRecord?: (requestOpts: any) => boolean
shouldPlayback?: (requestOpts: any) => boolean
Expand Down Expand Up @@ -98,7 +100,9 @@ declare namespace SnapshotAgent {
ignoreHeaders?: string[]
excludeHeaders?: string[]
matchBody?: boolean
normalizeBody?: (body: string | Buffer | null | undefined) => string
matchQuery?: boolean
normalizeQuery?: (query: URLSearchParams) => string
caseSensitive?: boolean
shouldRecord?: (requestOpts: any) => boolean
shouldPlayback?: (requestOpts: any) => boolean
Expand Down
Loading