http-snapshotter 0.3.0 → 0.4.0-beta.2
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 +19 -12
- package/index.d.ts +9 -0
- package/index.js +84 -36
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -11,10 +11,10 @@ To have predictable inputs to external requests there are 2 popular approaches:
|
|
|
11
11
|
However stubs / fakes take quite a while to write. And a mock service is an additional piece to deploy and maintain.
|
|
12
12
|
|
|
13
13
|
Presenting you another solution:
|
|
14
|
+
|
|
14
15
|
3. Create snapshots of the requests automatically the first time you run your test and then replay the snapshot responses on future runs of the test.
|
|
15
|
-
Additionally with the approach, with predictability and speed in mind, one wouldn't want any real network request from being made; and if it does happen, then the test should fail.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Additionally with the approach, with predictability and speed in mind, one wouldn't want any real network request from being made; and if it does happen, then the test should fail.
|
|
18
18
|
|
|
19
19
|
Example (test.js):
|
|
20
20
|
|
|
@@ -22,19 +22,18 @@ Example (test.js):
|
|
|
22
22
|
import test from "tape";
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
24
|
import { resolve, dirname } from "node:path";
|
|
25
|
-
import { start } from "http-snapshotter";
|
|
26
|
-
|
|
27
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
28
|
-
const __dirname = dirname(__filename);
|
|
29
|
-
const snapshotDirectory = resolve(__dirname, "http-snapshots");
|
|
25
|
+
import { start, startTestCase, endTestCase } from "http-snapshotter";
|
|
30
26
|
|
|
31
|
-
|
|
27
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
start({ snapshotDirectory: resolve(__dirname, "http-snapshots") });
|
|
32
29
|
|
|
33
30
|
test("Latest XKCD comic (ESM)", async (t) => {
|
|
31
|
+
startTestCase('test-case-1');
|
|
34
32
|
const res = await fetch("https://xkcd.com/info.0.json");
|
|
35
33
|
const json = await res.json();
|
|
36
34
|
|
|
37
35
|
t.deepEquals(json.title, "Iceberg Efficiency", "must be equal");
|
|
36
|
+
endTestCase();
|
|
38
37
|
});
|
|
39
38
|
```
|
|
40
39
|
|
|
@@ -49,11 +48,11 @@ You will see a file named `get-xkcd-com-info-0-arAlFb5gfcr9aCN.json` created in
|
|
|
49
48
|
Then onwards running: `node test.js` or `SNAPSHOT=read node test.js` will ensure HTTP network calls are all read from a snapshot file.
|
|
50
49
|
In this mode, http-snapshotter will prevent any real HTTP calls from happening by failing the test (if it didn't have a snapshot file) and print out the request details and the snapshot file name it should have had.
|
|
51
50
|
|
|
52
|
-
There is also a `SNAPSHOT=ignore` option to neither read nor write from snapshot files and do real network requests instead.
|
|
51
|
+
For adding new snapshots without touching existing snapshots use `SNAPSHOT=append`. There is also a `SNAPSHOT=ignore` option to neither read nor write from snapshot files and do real network requests instead. These could be useful while writing a new test.
|
|
53
52
|
|
|
54
|
-
Tip: When you do `SNAPSHOT=update` to create snapshots, run it against a single test, so you know what exact snapshots that one test created/updated.
|
|
53
|
+
Tip: When you do `SNAPSHOT=update` or `SNAPHOT=append` to create snapshots, run it against a single test, so you know what exact snapshots that one test created/updated.
|
|
55
54
|
|
|
56
|
-
Log read/saved snapshots by setting LOG_SNAPSHOT=1
|
|
55
|
+
Log read/saved snapshots by setting LOG_SNAPSHOT=1 or LOG_SNAPSHOT=summary env variable. It prints the HTTP method, url and snapshot file that it would use. If you want even more details in the logs use LOG_REQ=detailed.
|
|
57
56
|
|
|
58
57
|
Once you are done writing your tests, run your test runner on all your tests and then take a look at `<snapshots directory>/unused-snapshots.log` file to see which snapshot files haven't been used by your final test suite. You can delete unused snapshot files.
|
|
59
58
|
|
|
@@ -62,7 +61,7 @@ The tests of this library uses this library itself, check the `tests/` directory
|
|
|
62
61
|
## About snapshot files and its names
|
|
63
62
|
|
|
64
63
|
A snapshot file name uniquely identifies a request. By default it is a combination of HTTP method + URL + body that makes a request unique (headers are ignored).
|
|
65
|
-
For example, take the filename `get-xkcd-com-info-0-arAlFb5gfcr9aCN.json` - The prefix `get-xkcd-com-info-0` is added just for readability, and the suffix `arAlFb5gfcr9aCN` is a SHA256 hash of concatenated HTTP method + URL + body
|
|
64
|
+
For example, take the filename `get-xkcd-com-info-0-arAlFb5gfcr9aCN.json` - The prefix `get-xkcd-com-info-0` is added just for readability, and the suffix `arAlFb5gfcr9aCN` is a SHA256 hash of concatenated HTTP method + URL + body of request that makes the file name unique.
|
|
66
65
|
|
|
67
66
|
However you may want to specially handle some requests. e.g. DynamoDB calls also need the `x-amz-target` header to uniquely identify the request,
|
|
68
67
|
because the header affects the response data. You can add logic to create better snapshot files for this case:
|
|
@@ -189,3 +188,11 @@ test('Test behavior on a free account', async (t) => {
|
|
|
189
188
|
```
|
|
190
189
|
|
|
191
190
|
Now when you run `SNAPHOT=update node test2.js` you will get a snapshot file with `free-account-test-` as prefix. You can now edit the JSON response for this test.
|
|
191
|
+
|
|
192
|
+
## Concurrency
|
|
193
|
+
|
|
194
|
+
WARNING: This module isn't concurrent or thread safe. Make sure that:
|
|
195
|
+
|
|
196
|
+
1. within one worker only one test is being executed at a time. e.g. If you use `ava`, and you have multiple `test()` blocks in one file, you need to change it to run serially with `test.serial()`.
|
|
197
|
+
|
|
198
|
+
2. parallel tests don't update the same snapshot file at the same time (i.e. while you run with SNAPSHOT=update). Regardless, updating snapshots of multiple tests at the same time is not a great idea in my opinion, because reviewing the snapshots files are a pain, escpecially if you have a shared snapshot files.
|
package/index.d.ts
CHANGED
|
@@ -40,6 +40,15 @@ export type ReadSnapshotReturnType = Promise<{
|
|
|
40
40
|
}>;
|
|
41
41
|
export type ClientRequestInterceptorType = import('@mswjs/interceptors/ClientRequest').ClientRequestInterceptor;
|
|
42
42
|
export type FetchInterceptorType = import('@mswjs/interceptors/fetch').FetchInterceptor;
|
|
43
|
+
/**
|
|
44
|
+
* Write/read snapshots to/from a sub directory. This isolates snapshots for a test.
|
|
45
|
+
* @param {string} directoryName Directory name relative to snapshot directory. It will be created if it doesn't exist.
|
|
46
|
+
*/
|
|
47
|
+
export function startTestCase(directoryName: string): void;
|
|
48
|
+
/**
|
|
49
|
+
* Reset the directory to the root directory
|
|
50
|
+
*/
|
|
51
|
+
export function endTestCase(): void;
|
|
43
52
|
/**
|
|
44
53
|
* @param {Request} request
|
|
45
54
|
*/
|
package/index.js
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* SNAPSHOT=update <test runner command>
|
|
11
11
|
* e.g. SNAPSHOT=update tape tests/**\/*.js | tap-diff
|
|
12
12
|
*
|
|
13
|
-
* Here onwards run test runner without SNAPSHOT env variable or SNAPSHOT=read
|
|
13
|
+
* Here onwards run test runner without SNAPSHOT env variable or SNAPSHOT=read.
|
|
14
|
+
* For adding new snapshots without touching existing snapshots use SNAPSHOT=append.
|
|
15
|
+
*
|
|
14
16
|
* You can use SNAPSHOT=ignore to neither read not write snapshots, for testing on real
|
|
15
17
|
* network operations.
|
|
16
18
|
*
|
|
@@ -19,8 +21,8 @@
|
|
|
19
21
|
* Unused snapshot files will be written into a log file named 'unused-snapshots.log'.
|
|
20
22
|
* You can delete those files manually.
|
|
21
23
|
*
|
|
22
|
-
* Log requests with LOG_REQ=1 or LOG_REQ=summary (to just print summary)
|
|
23
|
-
* or node.js built-in NODE_DEBUG=http,http2
|
|
24
|
+
* Log requests with LOG_REQ=1 or LOG_REQ=summary (to just print summary) or LOG_REQ=detailed
|
|
25
|
+
* (to print request details) env variable or node.js built-in NODE_DEBUG=http,http2
|
|
24
26
|
*
|
|
25
27
|
* More docs at the end of this file, find the exported methods.
|
|
26
28
|
*/
|
|
@@ -31,9 +33,9 @@ const { FetchInterceptor } = require('@mswjs/interceptors/fetch');
|
|
|
31
33
|
const slugify = require('@sindresorhus/slugify');
|
|
32
34
|
const { createHash } = require('node:crypto');
|
|
33
35
|
const { promises: fs } = require('node:fs');
|
|
34
|
-
const { resolve } = require('node:path');
|
|
36
|
+
const { resolve, dirname, relative } = require('node:path');
|
|
35
37
|
|
|
36
|
-
// Environment variable SNAPSHOT = update / ignore / read (default)
|
|
38
|
+
// Environment variable SNAPSHOT = update / append / ignore / read (default)
|
|
37
39
|
const SNAPSHOT = process.env.SNAPSHOT || 'read';
|
|
38
40
|
const { LOG_REQ, LOG_SNAPSHOT } = process.env;
|
|
39
41
|
const unusedSnapshotsLogFile = 'unused-snapshots.log';
|
|
@@ -79,18 +81,30 @@ let snapshotDirectory = null;
|
|
|
79
81
|
* @typedef {SnapshotText | SnapshotJson} Snapshot
|
|
80
82
|
*/
|
|
81
83
|
|
|
84
|
+
const dynamodbHostNameRegex = /^dynamodb\..+\.amazonaws\.com$/;
|
|
82
85
|
|
|
83
86
|
const defaultKeyDerivationProps = ['method', 'url', 'body'];
|
|
84
87
|
/**
|
|
85
88
|
* @param {Request} request
|
|
86
89
|
*/
|
|
87
90
|
async function defaultSnapshotFileNameGenerator(request) {
|
|
91
|
+
let filePrefix;
|
|
92
|
+
|
|
88
93
|
const url = new URL(request.url);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
if (dynamodbHostNameRegex.test(url.hostname)) {
|
|
95
|
+
filePrefix = [
|
|
96
|
+
'dynamodb',
|
|
97
|
+
// slugify(url.hostname), // FIXME: uncomment this next release
|
|
98
|
+
slugify(request.headers?.get?.('x-amz-target')?.split?.('.')?.pop?.() || ''),
|
|
99
|
+
slugify(JSON.parse(await request.clone().text())?.TableName),
|
|
100
|
+
].filter(Boolean).join('-');
|
|
101
|
+
} else {
|
|
102
|
+
filePrefix = [
|
|
103
|
+
request.method.toLowerCase(),
|
|
104
|
+
slugify(url.hostname),
|
|
105
|
+
slugify(url.pathname.replace('.json', '')),
|
|
106
|
+
].filter(Boolean).join('-');
|
|
107
|
+
}
|
|
94
108
|
|
|
95
109
|
// Input data
|
|
96
110
|
const dataList = await Promise.all(
|
|
@@ -114,6 +128,7 @@ async function defaultSnapshotFileNameGenerator(request) {
|
|
|
114
128
|
* @type {(req: Request) => Promise<{ filePrefix: string, fileSuffixKey: string }>}
|
|
115
129
|
*/
|
|
116
130
|
let snapshotFileNameGenerator = defaultSnapshotFileNameGenerator;
|
|
131
|
+
let snapshotSubDirectory = '';
|
|
117
132
|
|
|
118
133
|
/**
|
|
119
134
|
* @param {Request} request
|
|
@@ -127,7 +142,7 @@ async function getSnapshotFileName(request) {
|
|
|
127
142
|
.digest('base64url')
|
|
128
143
|
.slice(0, 15);
|
|
129
144
|
|
|
130
|
-
const fileName = `${filePrefix}-${hash}.json`;
|
|
145
|
+
const fileName = `${snapshotSubDirectory ? `${snapshotSubDirectory}/` : ''}${filePrefix}-${hash}.json`;
|
|
131
146
|
|
|
132
147
|
return {
|
|
133
148
|
absoluteFilePath: resolve(/** @type {string} */ (snapshotDirectory), fileName),
|
|
@@ -148,14 +163,23 @@ async function getSnapshotFileName(request) {
|
|
|
148
163
|
/** @type {Map<string, ReadSnapshotReturnType>} */
|
|
149
164
|
const alreadyWrittenFiles = new Map();
|
|
150
165
|
const readFiles = new Set();
|
|
166
|
+
const existingSubDirectories = new Set();
|
|
151
167
|
|
|
152
168
|
/**
|
|
153
|
-
* @param {
|
|
154
|
-
* @param {
|
|
169
|
+
* @param {object} param
|
|
170
|
+
* @param {Request} param.request
|
|
171
|
+
* @param {Response} param.response
|
|
172
|
+
* @param {string} param.absoluteFilePath
|
|
173
|
+
* @param {string} param.fileName
|
|
174
|
+
* @param {string} param.fileSuffixKey
|
|
155
175
|
*/
|
|
156
|
-
async function saveSnapshot(
|
|
157
|
-
|
|
158
|
-
|
|
176
|
+
async function saveSnapshot({
|
|
177
|
+
request,
|
|
178
|
+
response,
|
|
179
|
+
absoluteFilePath,
|
|
180
|
+
fileName,
|
|
181
|
+
fileSuffixKey,
|
|
182
|
+
}) {
|
|
159
183
|
// Prevent multiple tests from having same snapshot
|
|
160
184
|
if (alreadyWrittenFiles.has(absoluteFilePath)) {
|
|
161
185
|
return /** @type {ReadSnapshotReturnType} */ (alreadyWrittenFiles.get(absoluteFilePath));
|
|
@@ -220,6 +244,11 @@ async function saveSnapshot(request, response) {
|
|
|
220
244
|
fileSuffixKey,
|
|
221
245
|
};
|
|
222
246
|
const json = JSON.stringify(snapshot, null, 2);
|
|
247
|
+
const dir = dirname(absoluteFilePath);
|
|
248
|
+
if (!existingSubDirectories.has(dir)) {
|
|
249
|
+
existingSubDirectories.add(dir);
|
|
250
|
+
await fs.mkdir(dir, { recursive: true });
|
|
251
|
+
}
|
|
223
252
|
await fs.writeFile(absoluteFilePath, json, 'utf-8');
|
|
224
253
|
return { snapshot, absoluteFilePath, fileName };
|
|
225
254
|
};
|
|
@@ -249,6 +278,7 @@ async function readSnapshot(request) {
|
|
|
249
278
|
// Fail any test that fires a real network request (without snapshot)
|
|
250
279
|
// @ts-ignore
|
|
251
280
|
if (err.code === 'ENOENT') {
|
|
281
|
+
if (SNAPSHOT === 'append') return {};
|
|
252
282
|
const reqBody = await request.clone().text();
|
|
253
283
|
console.error('No network snapshot found for request with cache keys:', {
|
|
254
284
|
request: {
|
|
@@ -312,16 +342,10 @@ async function sendResponse(request, snapshot) {
|
|
|
312
342
|
*/
|
|
313
343
|
async function readSnapshotAndSendResponse(request) {
|
|
314
344
|
const { snapshot } = await readSnapshot(request);
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
* @param {Request} request
|
|
320
|
-
* @param {Response} response
|
|
321
|
-
*/
|
|
322
|
-
async function saveSnapshotAndSendResponse(request, response) {
|
|
323
|
-
const { snapshot } = await saveSnapshot(request, response);
|
|
324
|
-
return sendResponse(request, snapshot);
|
|
345
|
+
if (snapshot) {
|
|
346
|
+
return sendResponse(request, snapshot);
|
|
347
|
+
}
|
|
348
|
+
return undefined;
|
|
325
349
|
}
|
|
326
350
|
|
|
327
351
|
/** @typedef {import('@mswjs/interceptors/ClientRequest').ClientRequestInterceptor} ClientRequestInterceptorType */
|
|
@@ -336,15 +360,18 @@ let unusedFiles;
|
|
|
336
360
|
process.on('beforeExit', async () => {
|
|
337
361
|
if (SNAPSHOT === 'read' && !beforeExitEventSeen) {
|
|
338
362
|
beforeExitEventSeen = true;
|
|
363
|
+
const dir = /** @type {string} */(snapshotDirectory);
|
|
364
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
339
365
|
let files;
|
|
340
366
|
try {
|
|
341
|
-
|
|
342
|
-
files = await fs.readdir(snapshotDirectory);
|
|
367
|
+
files = await fs.readdir(dir, { recursive: true, withFileTypes: true });
|
|
343
368
|
} catch (err) {
|
|
344
369
|
return;
|
|
345
370
|
}
|
|
346
|
-
|
|
347
|
-
|
|
371
|
+
unusedFiles = files
|
|
372
|
+
.filter((file) => file.isFile())
|
|
373
|
+
.map((file) => relative(dir, resolve(file.path, file.name)))
|
|
374
|
+
.filter((file) => (!readFiles.has(file) && file !== unusedSnapshotsLogFile));
|
|
348
375
|
if (unusedFiles.length) {
|
|
349
376
|
await fs
|
|
350
377
|
.writeFile(
|
|
@@ -365,6 +392,23 @@ process.on('beforeExit', async () => {
|
|
|
365
392
|
}
|
|
366
393
|
});
|
|
367
394
|
|
|
395
|
+
/**
|
|
396
|
+
* Write/read snapshots to/from a sub directory. This isolates snapshots for a test.
|
|
397
|
+
* @param {string} directoryName Directory name relative to snapshot directory. It will be created if it doesn't exist.
|
|
398
|
+
*/
|
|
399
|
+
function startTestCase(directoryName) {
|
|
400
|
+
if (snapshotSubDirectory) {
|
|
401
|
+
throw new Error(`Cannot start test case '${directoryName}' as test case '${snapshotSubDirectory}' is already running.`);
|
|
402
|
+
}
|
|
403
|
+
snapshotSubDirectory = directoryName;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Reset the directory to the root directory
|
|
407
|
+
*/
|
|
408
|
+
function endTestCase() {
|
|
409
|
+
snapshotSubDirectory = '';
|
|
410
|
+
}
|
|
411
|
+
|
|
368
412
|
/**
|
|
369
413
|
* Attach snapshot filename generator function
|
|
370
414
|
*
|
|
@@ -421,7 +465,7 @@ function start({
|
|
|
421
465
|
|
|
422
466
|
// @ts-ignore
|
|
423
467
|
interceptor.on('request', async ({ request }) => {
|
|
424
|
-
if (
|
|
468
|
+
if (['read', 'append'].includes(SNAPSHOT)) {
|
|
425
469
|
await readSnapshotAndSendResponse(request);
|
|
426
470
|
}
|
|
427
471
|
});
|
|
@@ -430,12 +474,12 @@ function start({
|
|
|
430
474
|
'response',
|
|
431
475
|
/** @type {(params: { request: Request, response: Response }) => Promise<void>} */
|
|
432
476
|
async ({ request, response }) => {
|
|
477
|
+
const { absoluteFilePath, fileName, fileSuffixKey } = await getSnapshotFileName(request);
|
|
433
478
|
if (LOG_REQ) {
|
|
434
|
-
const { fileName, fileSuffixKey } = await getSnapshotFileName(request);
|
|
435
479
|
const summary = `----------\n${request.method} ${request.url}\nWould use file name: ${fileName}`;
|
|
436
|
-
if (LOG_REQ === 'summary') {
|
|
480
|
+
if (LOG_REQ === '1' || LOG_REQ === 'summary') {
|
|
437
481
|
console.debug(summary);
|
|
438
|
-
} else {
|
|
482
|
+
} else if (LOG_REQ === 'detailed') {
|
|
439
483
|
console.debug(`${summary}\n----------\n`, {
|
|
440
484
|
request: {
|
|
441
485
|
url: request.url,
|
|
@@ -453,12 +497,14 @@ function start({
|
|
|
453
497
|
});
|
|
454
498
|
}
|
|
455
499
|
}
|
|
456
|
-
if (SNAPSHOT === 'update') {
|
|
500
|
+
if (SNAPSHOT === 'update' || (SNAPSHOT === 'append' && !readFiles.has(fileName))) {
|
|
457
501
|
if (!dirCreatePromise) {
|
|
458
502
|
dirCreatePromise = fs.mkdir( /** @type {string} */(snapshotDirectory), { recursive: true });
|
|
459
503
|
}
|
|
460
504
|
await dirCreatePromise;
|
|
461
|
-
await
|
|
505
|
+
await saveSnapshot({
|
|
506
|
+
request, response, absoluteFilePath, fileName, fileSuffixKey,
|
|
507
|
+
});
|
|
462
508
|
}
|
|
463
509
|
},
|
|
464
510
|
);
|
|
@@ -475,6 +521,8 @@ function stop() {
|
|
|
475
521
|
|
|
476
522
|
// Singleton - as it makes sense only one interceptor be active at any given moment.
|
|
477
523
|
module.exports = {
|
|
524
|
+
startTestCase,
|
|
525
|
+
endTestCase,
|
|
478
526
|
defaultSnapshotFileNameGenerator,
|
|
479
527
|
attachSnapshotFilenameGenerator,
|
|
480
528
|
resetSnapshotFilenameGenerator,
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "http-snapshotter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-beta.2",
|
|
4
4
|
"description": "Snapshot HTTP requests for tests (node.js)",
|
|
5
|
-
"main": "index.
|
|
5
|
+
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
7
7
|
"exports": {
|
|
8
8
|
"import": "./index.mjs",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"types": "./index.d.ts"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
|
-
"test": "tape tests/**/*.test.* | tap-
|
|
13
|
+
"test": "tape tests/**/*.test.* | tap-arc",
|
|
14
14
|
"tsc": "tsc"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/node": "^20.6.2",
|
|
32
|
-
"tap-
|
|
32
|
+
"tap-arc": "^1.3.2",
|
|
33
33
|
"tape": "^5.6.6",
|
|
34
34
|
"typescript": "^5.2.2"
|
|
35
35
|
}
|