Skip to content

Commit 45a60e7

Browse files
committed
feat(snapshot-agent): add normalizeBody and normalizeQuery options
Adds two new options to SnapshotAgent and SnapshotRecorder for partial request matching: - `normalizeBody(body)` — normalizes the request body before hashing, e.g. to strip volatile fields like timestamps from JSON payloads - `normalizeQuery(params)` — normalizes query parameters (as URLSearchParams) before hashing, e.g. to strip cache-busting params Both options pair with the existing `matchBody`/`matchQuery` boolean toggles and run at both record and playback time so hashes stay consistent across both sides.
1 parent a07d945 commit 45a60e7

6 files changed

Lines changed: 203 additions & 4 deletions

File tree

docs/docs/api/SnapshotAgent.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ new SnapshotAgent([options])
2727
- **ignoreHeaders** `Array<String>` - Headers to ignore during request matching
2828
- **excludeHeaders** `Array<String>` - Headers to exclude from snapshots (for security)
2929
- **matchBody** `Boolean` - Whether to include request body in matching. Default: `true`
30+
- **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`.
3031
- **matchQuery** `Boolean` - Whether to include query parameters in matching. Default: `true`
32+
- **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`.
3133
- **caseSensitive** `Boolean` - Whether header matching is case-sensitive. Default: `false`
3234
- **shouldRecord** `Function` - Callback to determine if a request should be recorded
3335
- **shouldPlayback** `Function` - Callback to determine if a request should be played back
@@ -108,6 +110,27 @@ await agent.saveSnapshots('./custom-snapshots.json')
108110

109111
## Advanced Configuration
110112

113+
### Body Matching
114+
115+
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:
116+
117+
```javascript
118+
const agent = new SnapshotAgent({
119+
mode: 'playback',
120+
snapshotPath: './snapshots.json',
121+
122+
// Match on everything except the timestamp field
123+
normalizeBody: (body) => {
124+
if (!body) return ''
125+
const parsed = JSON.parse(String(body))
126+
delete parsed.timestamp
127+
return JSON.stringify(parsed)
128+
}
129+
})
130+
```
131+
132+
`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.
133+
111134
### Header Filtering
112135

113136
Control which headers are used for request matching and what gets stored in snapshots:

lib/mock/snapshot-agent.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ class SnapshotAgent extends MockAgent {
5555
ignoreHeaders: opts.ignoreHeaders,
5656
excludeHeaders: opts.excludeHeaders,
5757
matchBody: opts.matchBody,
58+
normalizeBody: opts.normalizeBody,
5859
matchQuery: opts.matchQuery,
60+
normalizeQuery: opts.normalizeQuery,
5961
caseSensitive: opts.caseSensitive,
6062
shouldRecord: opts.shouldRecord,
6163
shouldPlayback: opts.shouldPlayback,

lib/mock/snapshot-recorder.js

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ const { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } =
4646
* @property {Array<string>} [ignoreHeaders=[]] - Headers to ignore for matching
4747
* @property {Array<string>} [excludeHeaders=[]] - Headers to exclude from matching
4848
* @property {boolean} [matchBody=true] - Whether to match request body
49-
* @property {boolean} [matchQuery=true] - Whether to match query properties
49+
* @property {(body: string|Buffer|null|undefined) => string} [normalizeBody] - Function to normalize the body before matching (e.g. strip timestamps)
50+
* @property {boolean} [matchQuery=true] - Whether to match query parameters
51+
* @property {(query: URLSearchParams) => string} [normalizeQuery] - Function to normalize query parameters before matching (e.g. strip volatile params)
5052
* @property {boolean} [caseSensitive=false] - Whether header matching is case-sensitive
5153
*/
5254

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

84+
/**
85+
* Normalizes the URL string used for request matching.
86+
*
87+
* @param {URL} url - Parsed request URL
88+
* @param {boolean} matchQuery - Whether to include query parameters in matching
89+
* @param {((query: URLSearchParams) => string)|undefined} normalizeQuery - Optional normalization function
90+
* @returns {string} - URL string for hashing
91+
*/
92+
function normalizeUrlForMatching (url, matchQuery, normalizeQuery) {
93+
if (matchQuery === false) return `${url.origin}${url.pathname}`
94+
if (normalizeQuery) {
95+
const normalized = String(normalizeQuery(url.searchParams) ?? '')
96+
return normalized ? `${url.origin}${url.pathname}?${normalized}` : `${url.origin}${url.pathname}`
97+
}
98+
return url.toString()
99+
}
100+
101+
/**
102+
* Normalizes the body value used for request matching.
103+
*
104+
* @param {string|Buffer|null|undefined} body - Raw request body
105+
* @param {boolean} matchBody - Whether to include the body in matching
106+
* @param {((body: string|Buffer|null|undefined) => string)|undefined} normalizeBody - Optional normalization function
107+
* @returns {string} - Body string for hashing
108+
*/
109+
function normalizeBodyForMatching (body, matchBody, normalizeBody) {
110+
if (matchBody === false) return ''
111+
if (normalizeBody) return String(normalizeBody(body) ?? '')
112+
return body ? String(body) : ''
113+
}
114+
82115
/**
83116
* Formats a request for consistent snapshot storage
84117
* Caches normalized headers to avoid repeated processing
@@ -99,9 +132,9 @@ function formatRequestKey (opts, headerFilters, matchOptions = {}) {
99132

100133
return {
101134
method: opts.method || 'GET',
102-
url: matchOptions.matchQuery !== false ? url.toString() : `${url.origin}${url.pathname}`,
135+
url: normalizeUrlForMatching(url, matchOptions.matchQuery, matchOptions.normalizeQuery),
103136
headers: filterHeadersForMatching(normalized, headerFilters, matchOptions),
104-
body: matchOptions.matchBody !== false && opts.body ? String(opts.body) : ''
137+
body: normalizeBodyForMatching(opts.body, matchOptions.matchBody, matchOptions.normalizeBody)
105138
}
106139
}
107140

@@ -250,7 +283,9 @@ class SnapshotRecorder {
250283
ignoreHeaders: options.ignoreHeaders || [],
251284
excludeHeaders: options.excludeHeaders || [],
252285
matchBody: options.matchBody !== false, // default: true
286+
normalizeBody: options.normalizeBody || undefined,
253287
matchQuery: options.matchQuery !== false, // default: true
288+
normalizeQuery: options.normalizeQuery || undefined,
254289
caseSensitive: options.caseSensitive || false
255290
}
256291

test/snapshot-testing.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,48 @@ describe('SnapshotAgent - Request Matching', () => {
895895
'Should return original recorded response with original query params')
896896
})
897897

898+
it('normalizeQuery function for partial query matching', async (t) => {
899+
const server = createTestServer((req, res) => {
900+
res.writeHead(200, { 'content-type': 'application/json' })
901+
res.end(JSON.stringify({ url: req.url }))
902+
})
903+
904+
const { origin } = await setupServer(server)
905+
const snapshotPath = createSnapshotPath('normalize-query')
906+
907+
setupCleanup(t, { server, snapshotPath })
908+
909+
// Strip the volatile '_cb' cache-buster param before matching
910+
const normalizeQuery = (params) => {
911+
const copy = new URLSearchParams(params)
912+
copy.delete('_cb')
913+
return copy.toString()
914+
}
915+
916+
const agent = new SnapshotAgent({ mode: 'record', snapshotPath, normalizeQuery })
917+
const originalDispatcher = getGlobalDispatcher()
918+
setupCleanup(t, { agent, originalDispatcher })
919+
setGlobalDispatcher(agent)
920+
921+
await request(`${origin}/api/data?filter=active&_cb=111`)
922+
await agent.saveSnapshots()
923+
924+
// Playback with a different cache-buster — should still match
925+
const playbackAgent = new SnapshotAgent({ mode: 'playback', snapshotPath, normalizeQuery })
926+
setupCleanup(t, { agent: playbackAgent })
927+
setGlobalDispatcher(playbackAgent)
928+
929+
const response = await request(`${origin}/api/data?filter=active&_cb=999`)
930+
assert.strictEqual(response.statusCode, 200, 'Should match snapshot despite different cache-buster')
931+
932+
// Different filter value — should NOT match
933+
await assert.rejects(
934+
() => request(`${origin}/api/data?filter=inactive&_cb=111`),
935+
/No snapshot found/,
936+
'Should not match snapshot with different filter value'
937+
)
938+
})
939+
898940
it('body matching control', async (t) => {
899941
const server = createTestServer((req, res) => {
900942
let body = ''
@@ -952,6 +994,67 @@ describe('SnapshotAgent - Request Matching', () => {
952994
assert.strictEqual(responseBody.received, 'original-data',
953995
'Should return recorded response with original body')
954996
})
997+
998+
it('normalizeBody function for partial body matching', async (t) => {
999+
const server = createTestServer((req, res) => {
1000+
let body = ''
1001+
req.on('data', chunk => { body += chunk })
1002+
req.on('end', () => {
1003+
res.writeHead(200, { 'content-type': 'application/json' })
1004+
res.end(JSON.stringify({ received: body }))
1005+
})
1006+
})
1007+
1008+
const { origin } = await setupServer(server)
1009+
const snapshotPath = createSnapshotPath('body-matching-fn')
1010+
1011+
setupCleanup(t, { server, snapshotPath })
1012+
1013+
// Normalize body by stripping the timestamp before hashing
1014+
const normalizeBody = (body) => {
1015+
if (!body) return ''
1016+
const parsed = JSON.parse(String(body))
1017+
delete parsed.timestamp
1018+
return JSON.stringify(parsed)
1019+
}
1020+
1021+
const agent = new SnapshotAgent({ mode: 'record', snapshotPath, normalizeBody })
1022+
const originalDispatcher = getGlobalDispatcher()
1023+
setupCleanup(t, { agent, originalDispatcher })
1024+
setGlobalDispatcher(agent)
1025+
1026+
await request(`${origin}/api/submit`, {
1027+
method: 'POST',
1028+
body: JSON.stringify({ action: 'create', timestamp: '2024-01-01T00:00:00Z' }),
1029+
headers: { 'content-type': 'application/json' }
1030+
})
1031+
1032+
await agent.saveSnapshots()
1033+
1034+
// Playback with a different timestamp — should still match
1035+
const playbackAgent = new SnapshotAgent({ mode: 'playback', snapshotPath, normalizeBody })
1036+
setupCleanup(t, { agent: playbackAgent })
1037+
setGlobalDispatcher(playbackAgent)
1038+
1039+
const response = await request(`${origin}/api/submit`, {
1040+
method: 'POST',
1041+
body: JSON.stringify({ action: 'create', timestamp: '2025-06-15T12:00:00Z' }),
1042+
headers: { 'content-type': 'application/json' }
1043+
})
1044+
1045+
assert.strictEqual(response.statusCode, 200, 'Should match snapshot despite different timestamp')
1046+
1047+
// Different action — should NOT match
1048+
await assert.rejects(
1049+
() => request(`${origin}/api/submit`, {
1050+
method: 'POST',
1051+
body: JSON.stringify({ action: 'delete', timestamp: '2024-01-01T00:00:00Z' }),
1052+
headers: { 'content-type': 'application/json' }
1053+
}),
1054+
/No snapshot found/,
1055+
'Should not match snapshot with different action'
1056+
)
1057+
})
9551058
})
9561059

9571060
describe('SnapshotAgent - Management Features', () => {

test/types/snapshot-agent.test-d.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { expectAssignable, expectType } from 'tsd'
1+
import { Buffer } from 'node:buffer'
2+
import { expectAssignable, expectNotAssignable, expectType } from 'tsd'
23
import { Agent, Dispatcher, MockAgent, SnapshotAgent, setGlobalDispatcher } from '../..'
34
import { SnapshotRecorder } from '../../types/snapshot-agent'
45

@@ -27,7 +28,9 @@ expectAssignable<SnapshotAgent>(new SnapshotAgent({
2728
ignoreHeaders: ['authorization', 'x-api-key'],
2829
excludeHeaders: ['set-cookie', 'authorization'],
2930
matchBody: true,
31+
normalizeBody: (body: string | Buffer | null | undefined) => String(body ?? ''),
3032
matchQuery: false,
33+
normalizeQuery: (query: URLSearchParams) => query.toString(),
3134
caseSensitive: false
3235
}))
3336

@@ -42,7 +45,9 @@ expectAssignable<SnapshotAgent>(new SnapshotAgent({
4245
ignoreHeaders: ['user-agent'],
4346
excludeHeaders: ['cookie'],
4447
matchBody: false,
48+
normalizeBody: (body: string | Buffer | null | undefined) => String(body ?? ''),
4549
matchQuery: true,
50+
normalizeQuery: (query: URLSearchParams) => query.toString(),
4651
caseSensitive: true,
4752
connections: 5, // MockAgent option
4853
enableCallHistory: true // MockAgent option
@@ -124,7 +129,9 @@ expectAssignable<Dispatcher>(new SnapshotAgent())
124129
ignoreHeaders: ['user-agent'],
125130
excludeHeaders: ['authorization', 'cookie'],
126131
matchBody: true,
132+
normalizeBody: (body: string | Buffer | null | undefined) => String(body ?? ''),
127133
matchQuery: true,
134+
normalizeQuery: (query: URLSearchParams) => query.toString(),
128135
caseSensitive: false
129136
})
130137

@@ -151,7 +158,9 @@ expectAssignable<SnapshotRecorder.Options>({
151158
})
152159
expectAssignable<SnapshotRecorder.Options>({
153160
matchBody: false,
161+
normalizeBody: (body: string | Buffer | null | undefined) => String(body ?? ''),
154162
matchQuery: true,
163+
normalizeQuery: (query: URLSearchParams) => query.toString(),
155164
caseSensitive: true
156165
})
157166

@@ -388,14 +397,18 @@ expectAssignable<SnapshotAgent.Options>({
388397
// Test boolean configuration options
389398
expectAssignable<SnapshotAgent.Options>({
390399
matchBody: true,
400+
normalizeBody: (body: string | Buffer | null | undefined) => String(body ?? ''),
391401
matchQuery: false,
402+
normalizeQuery: (query: URLSearchParams) => query.toString(),
392403
caseSensitive: true,
393404
autoFlush: false
394405
})
395406

396407
expectAssignable<SnapshotRecorder.Options>({
397408
matchBody: false,
409+
normalizeBody: (body: string | Buffer | null | undefined) => String(body ?? ''),
398410
matchQuery: true,
411+
normalizeQuery: (query: URLSearchParams) => query.toString(),
399412
caseSensitive: false,
400413
autoFlush: true
401414
})
@@ -423,3 +436,22 @@ expectAssignable<SnapshotAgent.Options>({
423436
shouldPlayback: (requestOpts) => requestOpts.path !== '/forbidden',
424437
excludeUrls: ['secret', /admin/]
425438
})
439+
440+
// Test exact declared types for normalizeBody and normalizeQuery
441+
expectAssignable<SnapshotRecorder.Options>({
442+
normalizeBody: (body: string | Buffer | null | undefined) => String(body ?? ''),
443+
normalizeQuery: (query: URLSearchParams) => query.toString()
444+
})
445+
446+
expectAssignable<SnapshotAgent.Options>({
447+
normalizeBody: (body: string | Buffer | null | undefined) => String(body ?? ''),
448+
normalizeQuery: (query: URLSearchParams) => query.toString()
449+
})
450+
451+
// normalizeBody and normalizeQuery must return string
452+
expectNotAssignable<SnapshotRecorder.Options>({
453+
normalizeBody: (body: string | Buffer | null | undefined) => body
454+
})
455+
expectNotAssignable<SnapshotRecorder.Options>({
456+
normalizeQuery: (query: URLSearchParams) => query
457+
})

types/snapshot-agent.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ declare namespace SnapshotRecorder {
3030
ignoreHeaders?: string[]
3131
excludeHeaders?: string[]
3232
matchBody?: boolean
33+
normalizeBody?: (body: string | Buffer | null | undefined) => string
3334
matchQuery?: boolean
35+
normalizeQuery?: (query: URLSearchParams) => string
3436
caseSensitive?: boolean
3537
shouldRecord?: (requestOpts: any) => boolean
3638
shouldPlayback?: (requestOpts: any) => boolean
@@ -98,7 +100,9 @@ declare namespace SnapshotAgent {
98100
ignoreHeaders?: string[]
99101
excludeHeaders?: string[]
100102
matchBody?: boolean
103+
normalizeBody?: (body: string | Buffer | null | undefined) => string
101104
matchQuery?: boolean
105+
normalizeQuery?: (query: URLSearchParams) => string
102106
caseSensitive?: boolean
103107
shouldRecord?: (requestOpts: any) => boolean
104108
shouldPlayback?: (requestOpts: any) => boolean

0 commit comments

Comments
 (0)