undici 7.12.0 → 7.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/docs/docs/api/ProxyAgent.md +1 -1
- package/docs/docs/api/SnapshotAgent.md +616 -0
- package/index.js +2 -0
- package/lib/api/readable.js +48 -26
- package/lib/core/util.js +0 -1
- package/lib/dispatcher/proxy-agent.js +67 -71
- package/lib/handler/redirect-handler.js +10 -0
- package/lib/interceptor/dump.js +2 -1
- package/lib/mock/mock-agent.js +10 -4
- package/lib/mock/snapshot-agent.js +333 -0
- package/lib/mock/snapshot-recorder.js +517 -0
- package/lib/web/fetch/body.js +0 -1
- package/lib/web/fetch/formdata-parser.js +0 -3
- package/lib/web/fetch/formdata.js +0 -4
- package/lib/web/webidl/index.js +1 -1
- package/package.json +1 -1
- package/types/agent.d.ts +0 -4
- package/types/client.d.ts +0 -2
- package/types/dispatcher.d.ts +0 -6
- package/types/h2c-client.d.ts +0 -2
- package/types/index.d.ts +3 -1
- package/types/mock-interceptor.d.ts +0 -1
- package/types/snapshot-agent.d.ts +107 -0
package/README.md
CHANGED
|
@@ -440,13 +440,14 @@ This behavior is intentional for server-side environments where CORS restriction
|
|
|
440
440
|
* https://fetch.spec.whatwg.org/#garbage-collection
|
|
441
441
|
|
|
442
442
|
The [Fetch Standard](https://fetch.spec.whatwg.org) allows users to skip consuming the response body by relying on
|
|
443
|
-
[garbage collection](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management#garbage_collection) to release connection resources.
|
|
443
|
+
[garbage collection](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management#garbage_collection) to release connection resources.
|
|
444
444
|
|
|
445
445
|
Garbage collection in Node is less aggressive and deterministic
|
|
446
446
|
(due to the lack of clear idle periods that browsers have through the rendering refresh rate)
|
|
447
447
|
which means that leaving the release of connection resources to the garbage collector can lead
|
|
448
448
|
to excessive connection usage, reduced performance (due to less connection re-use), and even
|
|
449
449
|
stalls or deadlocks when running out of connections.
|
|
450
|
+
Therefore, __it is important to always either consume or cancel the response body anyway__.
|
|
450
451
|
|
|
451
452
|
```js
|
|
452
453
|
// Do
|
|
@@ -459,7 +460,15 @@ for await (const chunk of body) {
|
|
|
459
460
|
const { headers } = await fetch(url);
|
|
460
461
|
```
|
|
461
462
|
|
|
462
|
-
|
|
463
|
+
However, if you want to get only headers, it might be better to use `HEAD` request method. Usage of this method will obviate the need for consumption or cancelling of the response body. See [MDN - HTTP - HTTP request methods - HEAD](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD) for more details.
|
|
464
|
+
|
|
465
|
+
```js
|
|
466
|
+
const headers = await fetch(url, { method: 'HEAD' })
|
|
467
|
+
.then(res => res.headers)
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
Note that consuming the response body is _mandatory_ for `request`:
|
|
471
|
+
|
|
463
472
|
```js
|
|
464
473
|
// Do
|
|
465
474
|
const { body, headers } = await request(url);
|
|
@@ -469,13 +478,6 @@ await res.body.dump(); // force consumption of body
|
|
|
469
478
|
const { headers } = await request(url);
|
|
470
479
|
```
|
|
471
480
|
|
|
472
|
-
However, if you want to get only headers, it might be better to use `HEAD` request method. Usage of this method will obviate the need for consumption or cancelling of the response body. See [MDN - HTTP - HTTP request methods - HEAD](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD) for more details.
|
|
473
|
-
|
|
474
|
-
```js
|
|
475
|
-
const headers = await fetch(url, { method: 'HEAD' })
|
|
476
|
-
.then(res => res.headers)
|
|
477
|
-
```
|
|
478
|
-
|
|
479
481
|
#### Forbidden and Safelisted Header Names
|
|
480
482
|
|
|
481
483
|
* https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name
|
|
@@ -27,7 +27,7 @@ For detailed information on the parsing process and potential validation errors,
|
|
|
27
27
|
* **clientFactory** `(origin: URL, opts: Object) => Dispatcher` (optional) - Default: `(origin, opts) => new Pool(origin, opts)`
|
|
28
28
|
* **requestTls** `BuildOptions` (optional) - Options object passed when creating the underlying socket via the connector builder for the request. It extends from [`Client#ConnectOptions`](/docs/docs/api/Client.md#parameter-connectoptions).
|
|
29
29
|
* **proxyTls** `BuildOptions` (optional) - Options object passed when creating the underlying socket via the connector builder for the proxy server. It extends from [`Client#ConnectOptions`](/docs/docs/api/Client.md#parameter-connectoptions).
|
|
30
|
-
* **proxyTunnel** `boolean` (optional) -
|
|
30
|
+
* **proxyTunnel** `boolean` (optional) - For connections involving secure protocols, Undici will always establish a tunnel via the HTTP2 CONNECT extension. If proxyTunnel is set to true, this will occur for unsecured proxy/endpoint connections as well. Currently, there is no way to facilitate HTTP1 IP tunneling as described in https://www.rfc-editor.org/rfc/rfc9484.html#name-http-11-request. If proxyTunnel is set to false (the default), ProxyAgent connections where both the Proxy and Endpoint are unsecured will issue all requests to the Proxy, and prefix the endpoint request path with the endpoint origin address.
|
|
31
31
|
|
|
32
32
|
Examples:
|
|
33
33
|
|
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
# SnapshotAgent
|
|
2
|
+
|
|
3
|
+
The `SnapshotAgent` provides a powerful way to record and replay HTTP requests for testing purposes. It extends `MockAgent` to enable automatic snapshot testing, eliminating the need to manually define mock responses.
|
|
4
|
+
|
|
5
|
+
## Use Cases
|
|
6
|
+
|
|
7
|
+
- **Integration Testing**: Record real API interactions and replay them in tests
|
|
8
|
+
- **Offline Development**: Work with APIs without network connectivity
|
|
9
|
+
- **Consistent Test Data**: Ensure tests use the same responses across runs
|
|
10
|
+
- **API Contract Testing**: Capture and validate API behavior over time
|
|
11
|
+
|
|
12
|
+
## Constructor
|
|
13
|
+
|
|
14
|
+
```javascript
|
|
15
|
+
new SnapshotAgent([options])
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### Parameters
|
|
19
|
+
|
|
20
|
+
- **options** `Object` (optional)
|
|
21
|
+
- **mode** `String` - The snapshot mode: `'record'`, `'playback'`, or `'update'`. Default: `'record'`
|
|
22
|
+
- **snapshotPath** `String` - Path to the snapshot file for loading/saving
|
|
23
|
+
- **maxSnapshots** `Number` - Maximum number of snapshots to keep in memory. Default: `Infinity`
|
|
24
|
+
- **autoFlush** `Boolean` - Whether to automatically save snapshots to disk. Default: `false`
|
|
25
|
+
- **flushInterval** `Number` - Interval in milliseconds for auto-flush. Default: `30000`
|
|
26
|
+
- **matchHeaders** `Array<String>` - Specific headers to include in request matching. Default: all headers
|
|
27
|
+
- **ignoreHeaders** `Array<String>` - Headers to ignore during request matching
|
|
28
|
+
- **excludeHeaders** `Array<String>` - Headers to exclude from snapshots (for security)
|
|
29
|
+
- **matchBody** `Boolean` - Whether to include request body in matching. Default: `true`
|
|
30
|
+
- **matchQuery** `Boolean` - Whether to include query parameters in matching. Default: `true`
|
|
31
|
+
- **caseSensitive** `Boolean` - Whether header matching is case-sensitive. Default: `false`
|
|
32
|
+
- **shouldRecord** `Function` - Callback to determine if a request should be recorded
|
|
33
|
+
- **shouldPlayback** `Function` - Callback to determine if a request should be played back
|
|
34
|
+
- **excludeUrls** `Array` - URL patterns (strings or RegExp) to exclude from recording/playback
|
|
35
|
+
- All other options from `MockAgent` are supported
|
|
36
|
+
|
|
37
|
+
### Modes
|
|
38
|
+
|
|
39
|
+
#### Record Mode (`'record'`)
|
|
40
|
+
Makes real HTTP requests and saves the responses to snapshots.
|
|
41
|
+
|
|
42
|
+
```javascript
|
|
43
|
+
import { SnapshotAgent, setGlobalDispatcher } from 'undici'
|
|
44
|
+
|
|
45
|
+
const agent = new SnapshotAgent({
|
|
46
|
+
mode: 'record',
|
|
47
|
+
snapshotPath: './test/snapshots/api-calls.json'
|
|
48
|
+
})
|
|
49
|
+
setGlobalDispatcher(agent)
|
|
50
|
+
|
|
51
|
+
// Makes real requests and records them
|
|
52
|
+
const response = await fetch('https://api.example.com/users')
|
|
53
|
+
const users = await response.json()
|
|
54
|
+
|
|
55
|
+
// Save recorded snapshots
|
|
56
|
+
await agent.saveSnapshots()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
#### Playback Mode (`'playback'`)
|
|
60
|
+
Replays recorded responses without making real HTTP requests.
|
|
61
|
+
|
|
62
|
+
```javascript
|
|
63
|
+
import { SnapshotAgent, setGlobalDispatcher } from 'undici'
|
|
64
|
+
|
|
65
|
+
const agent = new SnapshotAgent({
|
|
66
|
+
mode: 'playback',
|
|
67
|
+
snapshotPath: './test/snapshots/api-calls.json'
|
|
68
|
+
})
|
|
69
|
+
setGlobalDispatcher(agent)
|
|
70
|
+
|
|
71
|
+
// Uses recorded response instead of real request
|
|
72
|
+
const response = await fetch('https://api.example.com/users')
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
#### Update Mode (`'update'`)
|
|
76
|
+
Uses existing snapshots when available, but records new ones for missing requests.
|
|
77
|
+
|
|
78
|
+
```javascript
|
|
79
|
+
import { SnapshotAgent, setGlobalDispatcher } from 'undici'
|
|
80
|
+
|
|
81
|
+
const agent = new SnapshotAgent({
|
|
82
|
+
mode: 'update',
|
|
83
|
+
snapshotPath: './test/snapshots/api-calls.json'
|
|
84
|
+
})
|
|
85
|
+
setGlobalDispatcher(agent)
|
|
86
|
+
|
|
87
|
+
// Uses snapshot if exists, otherwise makes real request and records it
|
|
88
|
+
const response = await fetch('https://api.example.com/new-endpoint')
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Instance Methods
|
|
92
|
+
|
|
93
|
+
### `agent.saveSnapshots([filePath])`
|
|
94
|
+
|
|
95
|
+
Saves all recorded snapshots to a file.
|
|
96
|
+
|
|
97
|
+
#### Parameters
|
|
98
|
+
|
|
99
|
+
- **filePath** `String` (optional) - Path to save snapshots. Uses constructor `snapshotPath` if not provided.
|
|
100
|
+
|
|
101
|
+
#### Returns
|
|
102
|
+
|
|
103
|
+
`Promise<void>`
|
|
104
|
+
|
|
105
|
+
```javascript
|
|
106
|
+
await agent.saveSnapshots('./custom-snapshots.json')
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Advanced Configuration
|
|
110
|
+
|
|
111
|
+
### Header Filtering
|
|
112
|
+
|
|
113
|
+
Control which headers are used for request matching and what gets stored in snapshots:
|
|
114
|
+
|
|
115
|
+
```javascript
|
|
116
|
+
const agent = new SnapshotAgent({
|
|
117
|
+
mode: 'record',
|
|
118
|
+
snapshotPath: './snapshots.json',
|
|
119
|
+
|
|
120
|
+
// Only match these specific headers
|
|
121
|
+
matchHeaders: ['content-type', 'accept'],
|
|
122
|
+
|
|
123
|
+
// Ignore these headers during matching (but still store them)
|
|
124
|
+
ignoreHeaders: ['user-agent', 'date'],
|
|
125
|
+
|
|
126
|
+
// Exclude sensitive headers from snapshots entirely
|
|
127
|
+
excludeHeaders: ['authorization', 'x-api-key', 'cookie']
|
|
128
|
+
})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Custom Request/Response Filtering
|
|
132
|
+
|
|
133
|
+
Use callback functions to determine what gets recorded or played back:
|
|
134
|
+
|
|
135
|
+
```javascript
|
|
136
|
+
const agent = new SnapshotAgent({
|
|
137
|
+
mode: 'record',
|
|
138
|
+
snapshotPath: './snapshots.json',
|
|
139
|
+
|
|
140
|
+
// Only record GET requests to specific endpoints
|
|
141
|
+
shouldRecord: (requestOpts) => {
|
|
142
|
+
const url = new URL(requestOpts.path, requestOpts.origin)
|
|
143
|
+
return requestOpts.method === 'GET' && url.pathname.startsWith('/api/v1/')
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
// Skip authentication endpoints during playback
|
|
147
|
+
shouldPlayback: (requestOpts) => {
|
|
148
|
+
const url = new URL(requestOpts.path, requestOpts.origin)
|
|
149
|
+
return !url.pathname.includes('/auth/')
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### URL Pattern Exclusion
|
|
155
|
+
|
|
156
|
+
Exclude specific URLs from recording/playback using patterns:
|
|
157
|
+
|
|
158
|
+
```javascript
|
|
159
|
+
const agent = new SnapshotAgent({
|
|
160
|
+
mode: 'record',
|
|
161
|
+
snapshotPath: './snapshots.json',
|
|
162
|
+
|
|
163
|
+
excludeUrls: [
|
|
164
|
+
'https://analytics.example.com', // String match
|
|
165
|
+
/\/api\/v\d+\/health/, // Regex pattern
|
|
166
|
+
'telemetry' // Substring match
|
|
167
|
+
]
|
|
168
|
+
})
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Memory Management
|
|
172
|
+
|
|
173
|
+
Configure automatic memory and disk management:
|
|
174
|
+
|
|
175
|
+
```javascript
|
|
176
|
+
const agent = new SnapshotAgent({
|
|
177
|
+
mode: 'record',
|
|
178
|
+
snapshotPath: './snapshots.json',
|
|
179
|
+
|
|
180
|
+
// Keep only 1000 snapshots in memory
|
|
181
|
+
maxSnapshots: 1000,
|
|
182
|
+
|
|
183
|
+
// Automatically save to disk every 30 seconds
|
|
184
|
+
autoFlush: true,
|
|
185
|
+
flushInterval: 30000
|
|
186
|
+
})
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Sequential Response Handling
|
|
190
|
+
|
|
191
|
+
Handle multiple responses for the same request (similar to nock):
|
|
192
|
+
|
|
193
|
+
```javascript
|
|
194
|
+
// In record mode, multiple identical requests get recorded as separate responses
|
|
195
|
+
const agent = new SnapshotAgent({ mode: 'record', snapshotPath: './sequential.json' })
|
|
196
|
+
|
|
197
|
+
// First call returns response A
|
|
198
|
+
await fetch('https://api.example.com/random')
|
|
199
|
+
|
|
200
|
+
// Second call returns response B
|
|
201
|
+
await fetch('https://api.example.com/random')
|
|
202
|
+
|
|
203
|
+
await agent.saveSnapshots()
|
|
204
|
+
|
|
205
|
+
// In playback mode, calls return responses in sequence
|
|
206
|
+
const playbackAgent = new SnapshotAgent({ mode: 'playback', snapshotPath: './sequential.json' })
|
|
207
|
+
|
|
208
|
+
// Returns response A
|
|
209
|
+
const first = await fetch('https://api.example.com/random')
|
|
210
|
+
|
|
211
|
+
// Returns response B
|
|
212
|
+
const second = await fetch('https://api.example.com/random')
|
|
213
|
+
|
|
214
|
+
// Third call repeats the last response (B)
|
|
215
|
+
const third = await fetch('https://api.example.com/random')
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Managing Snapshots
|
|
219
|
+
|
|
220
|
+
### Replacing Existing Snapshots
|
|
221
|
+
|
|
222
|
+
```javascript
|
|
223
|
+
// Load existing snapshots
|
|
224
|
+
await agent.loadSnapshots('./old-snapshots.json')
|
|
225
|
+
|
|
226
|
+
// Get snapshot data
|
|
227
|
+
const recorder = agent.getRecorder()
|
|
228
|
+
const snapshots = recorder.getSnapshots()
|
|
229
|
+
|
|
230
|
+
// Modify or filter snapshots
|
|
231
|
+
const filteredSnapshots = snapshots.filter(s =>
|
|
232
|
+
!s.request.url.includes('deprecated')
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
// Replace all snapshots
|
|
236
|
+
agent.replaceSnapshots(filteredSnapshots.map((snapshot, index) => ({
|
|
237
|
+
hash: `new-hash-${index}`,
|
|
238
|
+
snapshot
|
|
239
|
+
})))
|
|
240
|
+
|
|
241
|
+
// Save updated snapshots
|
|
242
|
+
await agent.saveSnapshots('./updated-snapshots.json')
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### `agent.loadSnapshots([filePath])`
|
|
246
|
+
|
|
247
|
+
Loads snapshots from a file.
|
|
248
|
+
|
|
249
|
+
#### Parameters
|
|
250
|
+
|
|
251
|
+
- **filePath** `String` (optional) - Path to load snapshots from. Uses constructor `snapshotPath` if not provided.
|
|
252
|
+
|
|
253
|
+
#### Returns
|
|
254
|
+
|
|
255
|
+
`Promise<void>`
|
|
256
|
+
|
|
257
|
+
```javascript
|
|
258
|
+
await agent.loadSnapshots('./existing-snapshots.json')
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### `agent.getRecorder()`
|
|
262
|
+
|
|
263
|
+
Gets the underlying `SnapshotRecorder` instance.
|
|
264
|
+
|
|
265
|
+
#### Returns
|
|
266
|
+
|
|
267
|
+
`SnapshotRecorder`
|
|
268
|
+
|
|
269
|
+
```javascript
|
|
270
|
+
const recorder = agent.getRecorder()
|
|
271
|
+
console.log(`Recorded ${recorder.size()} interactions`)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### `agent.getMode()`
|
|
275
|
+
|
|
276
|
+
Gets the current snapshot mode.
|
|
277
|
+
|
|
278
|
+
#### Returns
|
|
279
|
+
|
|
280
|
+
`String` - The current mode (`'record'`, `'playback'`, or `'update'`)
|
|
281
|
+
|
|
282
|
+
### `agent.clearSnapshots()`
|
|
283
|
+
|
|
284
|
+
Clears all recorded snapshots from memory.
|
|
285
|
+
|
|
286
|
+
```javascript
|
|
287
|
+
agent.clearSnapshots()
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## Working with Different Request Types
|
|
291
|
+
|
|
292
|
+
### GET Requests
|
|
293
|
+
|
|
294
|
+
```javascript
|
|
295
|
+
// Record mode
|
|
296
|
+
const agent = new SnapshotAgent({ mode: 'record', snapshotPath: './get-snapshots.json' })
|
|
297
|
+
setGlobalDispatcher(agent)
|
|
298
|
+
|
|
299
|
+
const response = await fetch('https://jsonplaceholder.typicode.com/posts/1')
|
|
300
|
+
const post = await response.json()
|
|
301
|
+
|
|
302
|
+
await agent.saveSnapshots()
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### POST Requests with Body
|
|
306
|
+
|
|
307
|
+
```javascript
|
|
308
|
+
// Record mode
|
|
309
|
+
const agent = new SnapshotAgent({ mode: 'record', snapshotPath: './post-snapshots.json' })
|
|
310
|
+
setGlobalDispatcher(agent)
|
|
311
|
+
|
|
312
|
+
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
|
|
313
|
+
method: 'POST',
|
|
314
|
+
headers: { 'Content-Type': 'application/json' },
|
|
315
|
+
body: JSON.stringify({ title: 'Test Post', body: 'Content' })
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
await agent.saveSnapshots()
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Using with `undici.request`
|
|
322
|
+
|
|
323
|
+
SnapshotAgent works with all undici APIs, not just fetch:
|
|
324
|
+
|
|
325
|
+
```javascript
|
|
326
|
+
import { SnapshotAgent, request, setGlobalDispatcher } from 'undici'
|
|
327
|
+
|
|
328
|
+
const agent = new SnapshotAgent({ mode: 'record', snapshotPath: './request-snapshots.json' })
|
|
329
|
+
setGlobalDispatcher(agent)
|
|
330
|
+
|
|
331
|
+
const { statusCode, headers, body } = await request('https://api.example.com/data')
|
|
332
|
+
const data = await body.json()
|
|
333
|
+
|
|
334
|
+
await agent.saveSnapshots()
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## Test Integration
|
|
338
|
+
|
|
339
|
+
### Basic Test Setup
|
|
340
|
+
|
|
341
|
+
```javascript
|
|
342
|
+
import { test } from 'node:test'
|
|
343
|
+
import { SnapshotAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici'
|
|
344
|
+
|
|
345
|
+
test('API integration test', async (t) => {
|
|
346
|
+
const originalDispatcher = getGlobalDispatcher()
|
|
347
|
+
|
|
348
|
+
const agent = new SnapshotAgent({
|
|
349
|
+
mode: 'playback',
|
|
350
|
+
snapshotPath: './test/snapshots/api-test.json'
|
|
351
|
+
})
|
|
352
|
+
setGlobalDispatcher(agent)
|
|
353
|
+
|
|
354
|
+
t.after(() => setGlobalDispatcher(originalDispatcher))
|
|
355
|
+
|
|
356
|
+
// This will use recorded data
|
|
357
|
+
const response = await fetch('https://api.example.com/users')
|
|
358
|
+
const users = await response.json()
|
|
359
|
+
|
|
360
|
+
assert(Array.isArray(users))
|
|
361
|
+
assert(users.length > 0)
|
|
362
|
+
})
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### Environment-Based Mode Selection
|
|
366
|
+
|
|
367
|
+
```javascript
|
|
368
|
+
const mode = process.env.SNAPSHOT_MODE || 'playback'
|
|
369
|
+
|
|
370
|
+
const agent = new SnapshotAgent({
|
|
371
|
+
mode,
|
|
372
|
+
snapshotPath: './test/snapshots/integration.json'
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
// Run with: SNAPSHOT_MODE=record npm test (to record)
|
|
376
|
+
// Run with: npm test (to playback)
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### Test Helper Function
|
|
380
|
+
|
|
381
|
+
```javascript
|
|
382
|
+
function createSnapshotAgent(testName, mode = 'playback') {
|
|
383
|
+
return new SnapshotAgent({
|
|
384
|
+
mode,
|
|
385
|
+
snapshotPath: `./test/snapshots/${testName}.json`
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
test('user API test', async (t) => {
|
|
390
|
+
const agent = createSnapshotAgent('user-api')
|
|
391
|
+
setGlobalDispatcher(agent)
|
|
392
|
+
|
|
393
|
+
// Test implementation...
|
|
394
|
+
})
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
## Snapshot File Format
|
|
398
|
+
|
|
399
|
+
Snapshots are stored as JSON with the following structure:
|
|
400
|
+
|
|
401
|
+
```json
|
|
402
|
+
[
|
|
403
|
+
{
|
|
404
|
+
"hash": "dGVzdC1oYXNo...",
|
|
405
|
+
"snapshot": {
|
|
406
|
+
"request": {
|
|
407
|
+
"method": "GET",
|
|
408
|
+
"url": "https://api.example.com/users",
|
|
409
|
+
"headers": {
|
|
410
|
+
"authorization": "Bearer token"
|
|
411
|
+
},
|
|
412
|
+
"body": undefined
|
|
413
|
+
},
|
|
414
|
+
"response": {
|
|
415
|
+
"statusCode": 200,
|
|
416
|
+
"headers": {
|
|
417
|
+
"content-type": "application/json"
|
|
418
|
+
},
|
|
419
|
+
"body": "eyJkYXRhIjoidGVzdCJ9", // base64 encoded
|
|
420
|
+
"trailers": {}
|
|
421
|
+
},
|
|
422
|
+
"timestamp": "2024-01-01T00:00:00.000Z"
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
]
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
## Security Considerations
|
|
429
|
+
|
|
430
|
+
### Sensitive Data in Snapshots
|
|
431
|
+
|
|
432
|
+
By default, SnapshotAgent records all headers and request/response data. For production use, always exclude sensitive information:
|
|
433
|
+
|
|
434
|
+
```javascript
|
|
435
|
+
const agent = new SnapshotAgent({
|
|
436
|
+
mode: 'record',
|
|
437
|
+
snapshotPath: './snapshots.json',
|
|
438
|
+
|
|
439
|
+
// Exclude sensitive headers from snapshots
|
|
440
|
+
excludeHeaders: [
|
|
441
|
+
'authorization',
|
|
442
|
+
'x-api-key',
|
|
443
|
+
'cookie',
|
|
444
|
+
'set-cookie',
|
|
445
|
+
'x-auth-token',
|
|
446
|
+
'x-csrf-token'
|
|
447
|
+
],
|
|
448
|
+
|
|
449
|
+
// Filter out requests with sensitive data
|
|
450
|
+
shouldRecord: (requestOpts) => {
|
|
451
|
+
const url = new URL(requestOpts.path, requestOpts.origin)
|
|
452
|
+
|
|
453
|
+
// Don't record authentication endpoints
|
|
454
|
+
if (url.pathname.includes('/auth/') || url.pathname.includes('/login')) {
|
|
455
|
+
return false
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Don't record if request contains sensitive body data
|
|
459
|
+
if (requestOpts.body && typeof requestOpts.body === 'string') {
|
|
460
|
+
const body = requestOpts.body.toLowerCase()
|
|
461
|
+
if (body.includes('password') || body.includes('secret')) {
|
|
462
|
+
return false
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return true
|
|
467
|
+
}
|
|
468
|
+
})
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### Snapshot File Security
|
|
472
|
+
|
|
473
|
+
**Important**: Snapshot files may contain sensitive data. Handle them securely:
|
|
474
|
+
|
|
475
|
+
- ✅ Add snapshot files to `.gitignore` if they contain real API data
|
|
476
|
+
- ✅ Use environment-specific snapshots (dev/staging/prod)
|
|
477
|
+
- ✅ Regularly review snapshot contents for sensitive information
|
|
478
|
+
- ✅ Use the `excludeHeaders` option for production snapshots
|
|
479
|
+
- ❌ Never commit snapshots with real authentication tokens
|
|
480
|
+
- ❌ Don't share snapshot files containing personal data
|
|
481
|
+
|
|
482
|
+
```gitignore
|
|
483
|
+
# Exclude snapshots with real data
|
|
484
|
+
/test/snapshots/production-*.json
|
|
485
|
+
/test/snapshots/*-real-data.json
|
|
486
|
+
|
|
487
|
+
# Include sanitized test snapshots
|
|
488
|
+
!/test/snapshots/mock-*.json
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
## Error Handling
|
|
492
|
+
|
|
493
|
+
### Missing Snapshots in Playback Mode
|
|
494
|
+
|
|
495
|
+
```javascript
|
|
496
|
+
try {
|
|
497
|
+
const response = await fetch('https://api.example.com/nonexistent')
|
|
498
|
+
} catch (error) {
|
|
499
|
+
if (error.message.includes('No snapshot found')) {
|
|
500
|
+
// Handle missing snapshot
|
|
501
|
+
console.log('Snapshot not found for this request')
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
### Handling Network Errors in Record Mode
|
|
507
|
+
|
|
508
|
+
```javascript
|
|
509
|
+
const agent = new SnapshotAgent({ mode: 'record', snapshotPath: './snapshots.json' })
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
const response = await fetch('https://nonexistent-api.example.com/data')
|
|
513
|
+
} catch (error) {
|
|
514
|
+
// Network errors are not recorded as snapshots
|
|
515
|
+
console.log('Network error:', error.message)
|
|
516
|
+
}
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
## Best Practices
|
|
520
|
+
|
|
521
|
+
### 1. Organize Snapshots by Test Suite
|
|
522
|
+
|
|
523
|
+
```javascript
|
|
524
|
+
// Use descriptive snapshot file names
|
|
525
|
+
const agent = new SnapshotAgent({
|
|
526
|
+
mode: 'playback',
|
|
527
|
+
snapshotPath: `./test/snapshots/${testSuiteName}-${testName}.json`
|
|
528
|
+
})
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
### 2. Version Control Snapshots
|
|
532
|
+
|
|
533
|
+
Add snapshot files to version control to ensure consistent test behavior across environments:
|
|
534
|
+
|
|
535
|
+
```gitignore
|
|
536
|
+
# Include snapshots in version control
|
|
537
|
+
!/test/snapshots/*.json
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
### 3. Clean Up Test Data
|
|
541
|
+
|
|
542
|
+
```javascript
|
|
543
|
+
test('API test', async (t) => {
|
|
544
|
+
const agent = new SnapshotAgent({
|
|
545
|
+
mode: 'playback',
|
|
546
|
+
snapshotPath: './test/snapshots/temp-test.json'
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
// Clean up after test
|
|
550
|
+
t.after(() => {
|
|
551
|
+
agent.clearSnapshots()
|
|
552
|
+
})
|
|
553
|
+
})
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
### 4. Snapshot Validation
|
|
557
|
+
|
|
558
|
+
```javascript
|
|
559
|
+
test('validate snapshot contents', async (t) => {
|
|
560
|
+
const agent = new SnapshotAgent({
|
|
561
|
+
mode: 'playback',
|
|
562
|
+
snapshotPath: './test/snapshots/validation.json'
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
const recorder = agent.getRecorder()
|
|
566
|
+
const snapshots = recorder.getSnapshots()
|
|
567
|
+
|
|
568
|
+
// Validate snapshot structure
|
|
569
|
+
assert(snapshots.length > 0, 'Should have recorded snapshots')
|
|
570
|
+
assert(snapshots[0].request.url.startsWith('https://'), 'Should use HTTPS')
|
|
571
|
+
})
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
## Comparison with Other Tools
|
|
575
|
+
|
|
576
|
+
### vs Manual MockAgent Setup
|
|
577
|
+
|
|
578
|
+
**Manual MockAgent:**
|
|
579
|
+
```javascript
|
|
580
|
+
const mockAgent = new MockAgent()
|
|
581
|
+
const mockPool = mockAgent.get('https://api.example.com')
|
|
582
|
+
|
|
583
|
+
mockPool.intercept({
|
|
584
|
+
path: '/users',
|
|
585
|
+
method: 'GET'
|
|
586
|
+
}).reply(200, [
|
|
587
|
+
{ id: 1, name: 'User 1' },
|
|
588
|
+
{ id: 2, name: 'User 2' }
|
|
589
|
+
])
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
**SnapshotAgent:**
|
|
593
|
+
```javascript
|
|
594
|
+
// Record once
|
|
595
|
+
const agent = new SnapshotAgent({ mode: 'record', snapshotPath: './snapshots.json' })
|
|
596
|
+
// Real API call gets recorded automatically
|
|
597
|
+
|
|
598
|
+
// Use in tests
|
|
599
|
+
const agent = new SnapshotAgent({ mode: 'playback', snapshotPath: './snapshots.json' })
|
|
600
|
+
// Automatically replays recorded response
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
### vs nock
|
|
604
|
+
|
|
605
|
+
SnapshotAgent provides similar functionality to nock but is specifically designed for undici:
|
|
606
|
+
|
|
607
|
+
- ✅ Works with all undici APIs (`request`, `stream`, `pipeline`, etc.)
|
|
608
|
+
- ✅ Supports undici-specific features (RetryAgent, connection pooling)
|
|
609
|
+
- ✅ Better TypeScript integration
|
|
610
|
+
- ✅ More efficient for high-performance scenarios
|
|
611
|
+
|
|
612
|
+
## See Also
|
|
613
|
+
|
|
614
|
+
- [MockAgent](./MockAgent.md) - Manual mocking for more control
|
|
615
|
+
- [MockCallHistory](./MockCallHistory.md) - Inspecting request history
|
|
616
|
+
- [Testing Best Practices](../best-practices/writing-tests.md) - General testing guidance
|
package/index.js
CHANGED
|
@@ -18,6 +18,7 @@ const MockClient = require('./lib/mock/mock-client')
|
|
|
18
18
|
const { MockCallHistory, MockCallHistoryLog } = require('./lib/mock/mock-call-history')
|
|
19
19
|
const MockAgent = require('./lib/mock/mock-agent')
|
|
20
20
|
const MockPool = require('./lib/mock/mock-pool')
|
|
21
|
+
const SnapshotAgent = require('./lib/mock/snapshot-agent')
|
|
21
22
|
const mockErrors = require('./lib/mock/mock-errors')
|
|
22
23
|
const RetryHandler = require('./lib/handler/retry-handler')
|
|
23
24
|
const { getGlobalDispatcher, setGlobalDispatcher } = require('./lib/global')
|
|
@@ -178,6 +179,7 @@ module.exports.MockCallHistory = MockCallHistory
|
|
|
178
179
|
module.exports.MockCallHistoryLog = MockCallHistoryLog
|
|
179
180
|
module.exports.MockPool = MockPool
|
|
180
181
|
module.exports.MockAgent = MockAgent
|
|
182
|
+
module.exports.SnapshotAgent = SnapshotAgent
|
|
181
183
|
module.exports.mockErrors = mockErrors
|
|
182
184
|
|
|
183
185
|
const { EventSource } = require('./lib/web/eventsource/eventsource')
|