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.
Files changed (4) hide show
  1. package/README.md +17 -10
  2. package/index.d.ts +9 -0
  3. package/index.js +73 -31
  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
 
@@ -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 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
@@ -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) env variable
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
- const filePrefix = [
92
- request.method.toLowerCase(),
93
- slugify(url.hostname),
94
- slugify(url.pathname.replace('.json', '')),
95
- ].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
+ }
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 {Request} request
156
- * @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
157
175
  */
158
- async function saveSnapshot(request, response) {
159
- const { absoluteFilePath, fileName, fileSuffixKey } = await getSnapshotFileName(request);
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
- // @ts-ignore
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
- let dir = /** @type {string} */(snapshotDirectory);
353
- 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));
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 saveSnapshotAndSendResponse(request, response);
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.1",
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
  }