http-snapshotter 0.3.1 → 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 +17 -10
- package/index.d.ts +9 -0
- package/index.js +73 -31
- 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
|
|
|
@@ -53,7 +52,7 @@ For adding new snapshots without touching existing snapshots use `SNAPSHOT=appen
|
|
|
53
52
|
|
|
54
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
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
* Unused snapshot files will be written into a log file named 'unused-snapshots.log'.
|
|
22
22
|
* You can delete those files manually.
|
|
23
23
|
*
|
|
24
|
-
* Log requests with LOG_REQ=1 or LOG_REQ=summary (to just print summary)
|
|
25
|
-
* 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
|
|
26
26
|
*
|
|
27
27
|
* More docs at the end of this file, find the exported methods.
|
|
28
28
|
*/
|
|
@@ -33,7 +33,7 @@ const { FetchInterceptor } = require('@mswjs/interceptors/fetch');
|
|
|
33
33
|
const slugify = require('@sindresorhus/slugify');
|
|
34
34
|
const { createHash } = require('node:crypto');
|
|
35
35
|
const { promises: fs } = require('node:fs');
|
|
36
|
-
const { resolve } = require('node:path');
|
|
36
|
+
const { resolve, dirname, relative } = require('node:path');
|
|
37
37
|
|
|
38
38
|
// Environment variable SNAPSHOT = update / append / ignore / read (default)
|
|
39
39
|
const SNAPSHOT = process.env.SNAPSHOT || 'read';
|
|
@@ -81,18 +81,30 @@ let snapshotDirectory = null;
|
|
|
81
81
|
* @typedef {SnapshotText | SnapshotJson} Snapshot
|
|
82
82
|
*/
|
|
83
83
|
|
|
84
|
+
const dynamodbHostNameRegex = /^dynamodb\..+\.amazonaws\.com$/;
|
|
84
85
|
|
|
85
86
|
const defaultKeyDerivationProps = ['method', 'url', 'body'];
|
|
86
87
|
/**
|
|
87
88
|
* @param {Request} request
|
|
88
89
|
*/
|
|
89
90
|
async function defaultSnapshotFileNameGenerator(request) {
|
|
91
|
+
let filePrefix;
|
|
92
|
+
|
|
90
93
|
const url = new URL(request.url);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
}
|
|
96
108
|
|
|
97
109
|
// Input data
|
|
98
110
|
const dataList = await Promise.all(
|
|
@@ -116,6 +128,7 @@ async function defaultSnapshotFileNameGenerator(request) {
|
|
|
116
128
|
* @type {(req: Request) => Promise<{ filePrefix: string, fileSuffixKey: string }>}
|
|
117
129
|
*/
|
|
118
130
|
let snapshotFileNameGenerator = defaultSnapshotFileNameGenerator;
|
|
131
|
+
let snapshotSubDirectory = '';
|
|
119
132
|
|
|
120
133
|
/**
|
|
121
134
|
* @param {Request} request
|
|
@@ -129,7 +142,7 @@ async function getSnapshotFileName(request) {
|
|
|
129
142
|
.digest('base64url')
|
|
130
143
|
.slice(0, 15);
|
|
131
144
|
|
|
132
|
-
const fileName = `${filePrefix}-${hash}.json`;
|
|
145
|
+
const fileName = `${snapshotSubDirectory ? `${snapshotSubDirectory}/` : ''}${filePrefix}-${hash}.json`;
|
|
133
146
|
|
|
134
147
|
return {
|
|
135
148
|
absoluteFilePath: resolve(/** @type {string} */ (snapshotDirectory), fileName),
|
|
@@ -150,14 +163,23 @@ async function getSnapshotFileName(request) {
|
|
|
150
163
|
/** @type {Map<string, ReadSnapshotReturnType>} */
|
|
151
164
|
const alreadyWrittenFiles = new Map();
|
|
152
165
|
const readFiles = new Set();
|
|
166
|
+
const existingSubDirectories = new Set();
|
|
153
167
|
|
|
154
168
|
/**
|
|
155
|
-
* @param {
|
|
156
|
-
* @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
|
|
157
175
|
*/
|
|
158
|
-
async function saveSnapshot(
|
|
159
|
-
|
|
160
|
-
|
|
176
|
+
async function saveSnapshot({
|
|
177
|
+
request,
|
|
178
|
+
response,
|
|
179
|
+
absoluteFilePath,
|
|
180
|
+
fileName,
|
|
181
|
+
fileSuffixKey,
|
|
182
|
+
}) {
|
|
161
183
|
// Prevent multiple tests from having same snapshot
|
|
162
184
|
if (alreadyWrittenFiles.has(absoluteFilePath)) {
|
|
163
185
|
return /** @type {ReadSnapshotReturnType} */ (alreadyWrittenFiles.get(absoluteFilePath));
|
|
@@ -222,6 +244,11 @@ async function saveSnapshot(request, response) {
|
|
|
222
244
|
fileSuffixKey,
|
|
223
245
|
};
|
|
224
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
|
+
}
|
|
225
252
|
await fs.writeFile(absoluteFilePath, json, 'utf-8');
|
|
226
253
|
return { snapshot, absoluteFilePath, fileName };
|
|
227
254
|
};
|
|
@@ -321,15 +348,6 @@ async function readSnapshotAndSendResponse(request) {
|
|
|
321
348
|
return undefined;
|
|
322
349
|
}
|
|
323
350
|
|
|
324
|
-
/**
|
|
325
|
-
* @param {Request} request
|
|
326
|
-
* @param {Response} response
|
|
327
|
-
*/
|
|
328
|
-
async function saveSnapshotAndSendResponse(request, response) {
|
|
329
|
-
const { snapshot } = await saveSnapshot(request, response);
|
|
330
|
-
return sendResponse(request, snapshot);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
351
|
/** @typedef {import('@mswjs/interceptors/ClientRequest').ClientRequestInterceptor} ClientRequestInterceptorType */
|
|
334
352
|
/** @typedef {import('@mswjs/interceptors/fetch').FetchInterceptor} FetchInterceptorType */
|
|
335
353
|
/**
|
|
@@ -342,15 +360,18 @@ let unusedFiles;
|
|
|
342
360
|
process.on('beforeExit', async () => {
|
|
343
361
|
if (SNAPSHOT === 'read' && !beforeExitEventSeen) {
|
|
344
362
|
beforeExitEventSeen = true;
|
|
363
|
+
const dir = /** @type {string} */(snapshotDirectory);
|
|
364
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
345
365
|
let files;
|
|
346
366
|
try {
|
|
347
|
-
|
|
348
|
-
files = await fs.readdir(snapshotDirectory);
|
|
367
|
+
files = await fs.readdir(dir, { recursive: true, withFileTypes: true });
|
|
349
368
|
} catch (err) {
|
|
350
369
|
return;
|
|
351
370
|
}
|
|
352
|
-
|
|
353
|
-
|
|
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));
|
|
354
375
|
if (unusedFiles.length) {
|
|
355
376
|
await fs
|
|
356
377
|
.writeFile(
|
|
@@ -371,6 +392,23 @@ process.on('beforeExit', async () => {
|
|
|
371
392
|
}
|
|
372
393
|
});
|
|
373
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
|
+
|
|
374
412
|
/**
|
|
375
413
|
* Attach snapshot filename generator function
|
|
376
414
|
*
|
|
@@ -436,12 +474,12 @@ function start({
|
|
|
436
474
|
'response',
|
|
437
475
|
/** @type {(params: { request: Request, response: Response }) => Promise<void>} */
|
|
438
476
|
async ({ request, response }) => {
|
|
439
|
-
const { fileName, fileSuffixKey } = await getSnapshotFileName(request);
|
|
477
|
+
const { absoluteFilePath, fileName, fileSuffixKey } = await getSnapshotFileName(request);
|
|
440
478
|
if (LOG_REQ) {
|
|
441
479
|
const summary = `----------\n${request.method} ${request.url}\nWould use file name: ${fileName}`;
|
|
442
|
-
if (LOG_REQ === 'summary') {
|
|
480
|
+
if (LOG_REQ === '1' || LOG_REQ === 'summary') {
|
|
443
481
|
console.debug(summary);
|
|
444
|
-
} else {
|
|
482
|
+
} else if (LOG_REQ === 'detailed') {
|
|
445
483
|
console.debug(`${summary}\n----------\n`, {
|
|
446
484
|
request: {
|
|
447
485
|
url: request.url,
|
|
@@ -464,7 +502,9 @@ function start({
|
|
|
464
502
|
dirCreatePromise = fs.mkdir( /** @type {string} */(snapshotDirectory), { recursive: true });
|
|
465
503
|
}
|
|
466
504
|
await dirCreatePromise;
|
|
467
|
-
await
|
|
505
|
+
await saveSnapshot({
|
|
506
|
+
request, response, absoluteFilePath, fileName, fileSuffixKey,
|
|
507
|
+
});
|
|
468
508
|
}
|
|
469
509
|
},
|
|
470
510
|
);
|
|
@@ -481,6 +521,8 @@ function stop() {
|
|
|
481
521
|
|
|
482
522
|
// Singleton - as it makes sense only one interceptor be active at any given moment.
|
|
483
523
|
module.exports = {
|
|
524
|
+
startTestCase,
|
|
525
|
+
endTestCase,
|
|
484
526
|
defaultSnapshotFileNameGenerator,
|
|
485
527
|
attachSnapshotFilenameGenerator,
|
|
486
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
|
}
|