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.
Files changed (4) hide show
  1. package/README.md +19 -12
  2. package/index.d.ts +9 -0
  3. package/index.js +84 -36
  4. 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
- WARNING: This module isn't concurrent or thread safe yet. You can only use it on serial test runners like `tape`. If you use `ava`, you need to convert tests to run serially with `test.serial()`.
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
- start({ snapshotDirectory });
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. This could be useful while writing a new test.
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 env variable. Log requests with LOG_REQ=1 or LOG_REQ=summary (to just print request HTTP method, url and snapshot file that it would use).
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 string that makes the file name unique.
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) env variable
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
- const filePrefix = [
90
- request.method.toLowerCase(),
91
- slugify(url.hostname),
92
- slugify(url.pathname.replace('.json', '')),
93
- ].filter(Boolean).join('-');
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 {Request} request
154
- * @param {Response} response
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(request, response) {
157
- const { absoluteFilePath, fileName, fileSuffixKey } = await getSnapshotFileName(request);
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
- return sendResponse(request, snapshot);
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
- // @ts-ignore
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
- let dir = /** @type {string} */(snapshotDirectory);
347
- unusedFiles = files.filter((file) => !readFiles.has(file) && file !== unusedSnapshotsLogFile);
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 (SNAPSHOT === 'read') {
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 saveSnapshotAndSendResponse(request, response);
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.0",
3
+ "version": "0.4.0-beta.2",
4
4
  "description": "Snapshot HTTP requests for tests (node.js)",
5
- "main": "index.cjs",
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-diff",
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-diff": "^0.1.1",
32
+ "tap-arc": "^1.3.2",
33
33
  "tape": "^5.6.6",
34
34
  "typescript": "^5.2.2"
35
35
  }