http-snapshotter 0.5.2 → 0.5.3

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 CHANGED
@@ -62,6 +62,12 @@ Once you are done writing your tests, run your test runner on all your tests and
62
62
 
63
63
  The tests of this library uses this library itself, check the `tests/` directory and try the tests `npm ci; npm test`.
64
64
 
65
+ ## Limitations
66
+
67
+ 1. MSW interceptor cannot mock `GET` calls with body. So trying to mock elasticsearch / opensearch `GET /<index>/_search` calls with body breaks. I ended up using `POST` only when unit testing, as `GET` is the right way to use with AWS `AmazonOpenSearchServiceReadOnlyAccess` IAM permission 🤷‍♂️.
68
+
69
+ 2. Stripe SDK uses node http module in a way MSW interceptor cannot mock - [reference](https://github.com/mswjs/msw/issues/2259#issuecomment-2379379566). Solution mentioned in a comment there is to use Stripe SDK with `fetch` client - [reference](https://github.com/mswjs/msw/issues/2259#issuecomment-2422672039). Another workaround is to mock Stripe SDK's methods with your testing library of choice.
70
+
65
71
  ## About snapshot files and its names
66
72
 
67
73
  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).
@@ -200,3 +206,24 @@ WARNING: This module isn't concurrent or thread safe. Make sure that:
200
206
  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()`.
201
207
 
202
208
  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.
209
+
210
+ ## Ignoring requests from snapshots
211
+
212
+ Sometimes you may want certain requests to not be snapshotted (e.g., file uploads to S3 / object storage) so that you can layer function / result snapshotting on top. Use `attachSnapshotIgnoreRules()` to provide a function that determines which requests should be ignored:
213
+
214
+ ```js
215
+ import { attachSnapshotIgnoreRules, resetSnapshotIgnoreRules } from "http-snapshotter";
216
+
217
+ // Ignore analytics and health check requests
218
+ attachSnapshotIgnoreRules((request) => {
219
+ const url = new URL(request.url);
220
+ return url.hostname.includes('/upload');
221
+ });
222
+
223
+ // Reset to default (no requests ignored)
224
+ resetSnapshotIgnoreRules();
225
+ ```
226
+
227
+ **Behavior varies by mode:**
228
+ - `SNAPSHOT=update/append`: Ignored requests make real network calls but don't create snapshots
229
+ - `SNAPSHOT=read`: Ignored requests throw an error (tests shouldn't make real network calls)
package/index.js CHANGED
@@ -16,6 +16,10 @@
16
16
  * You can use SNAPSHOT=ignore to neither read not write snapshots, for testing on real
17
17
  * network operations.
18
18
  *
19
+ * To ignore specific requests from being snapshotted in SNAPSHOT=update or SNAPSHOT=append mode,
20
+ * use attachSnapshotIgnoreRules() to provide a function that receives the request object and
21
+ * returns true if the request should be ignored from snapshotting.
22
+ *
19
23
  * Log read/saved snapshots by setting LOG_SNAPSHOT=1 env variable.
20
24
  *
21
25
  * Unused snapshot files will be written into a log file named 'unused-snapshots.log'.
@@ -135,6 +139,15 @@ async function defaultSnapshotFileNameGenerator(request) {
135
139
  };
136
140
  }
137
141
 
142
+ /**
143
+ * Default snapshot ignore rules - by default no requests are ignored
144
+ * @param {Request} request
145
+ * @returns {boolean}
146
+ */
147
+ function defaultSnapshotIgnoreRules(request) {
148
+ return false;
149
+ }
150
+
138
151
  // Dynamically changeable props
139
152
  /**
140
153
  * @type {(req: Request) => Promise<{ filePrefix: string, fileSuffixKey: string }>}
@@ -142,6 +155,11 @@ async function defaultSnapshotFileNameGenerator(request) {
142
155
  let snapshotFileNameGenerator = defaultSnapshotFileNameGenerator;
143
156
  let snapshotSubDirectory = '';
144
157
 
158
+ /**
159
+ * @type {(req: Request) => boolean}
160
+ */
161
+ let snapshotIgnoreRules = defaultSnapshotIgnoreRules;
162
+
145
163
  /**
146
164
  * @typedef SnapshotFileInfo
147
165
  * @property {string} absoluteFilePath
@@ -595,6 +613,36 @@ function resetSnapshotFilenameGenerator() {
595
613
  snapshotFileNameGenerator = defaultSnapshotFileNameGenerator;
596
614
  }
597
615
 
616
+ /**
617
+ * Attach snapshot ignore rules function
618
+ *
619
+ * Here's your opportunity to define custom rules for ignoring requests from being snapshotted.
620
+ * The function receives the Request object and should return true if the request should be ignored.
621
+ *
622
+ * IMPORTANT: Behavior varies by SNAPSHOT mode:
623
+ * - SNAPSHOT=update/append: Ignored requests make real network calls but don't create snapshots
624
+ * - SNAPSHOT=read: Ignored requests throw an error (tests shouldn't make real network calls)
625
+ *
626
+ * Use cases (not limited to):
627
+ * 1. Ignore requests with specific headers (e.g., x-debug-mode: no-snapshot)
628
+ * 2. Ignore requests to specific URLs or domains
629
+ * 3. Ignore requests with specific HTTP methods
630
+ * 4. Ignore requests based on request body content
631
+ *
632
+ * WARNING: Attaching a function on a per-test basis may not be concurrent safe. i.e. If your tests
633
+ * run sequentially, then it is safe. But if your test runner runs test suites concurrently,
634
+ * then it is better to attach a function only once ever.
635
+ * @param {(req: Request) => boolean} func
636
+ */
637
+ function attachSnapshotIgnoreRules(func) {
638
+ snapshotIgnoreRules = func;
639
+ }
640
+
641
+ /** Reset snapshot ignore rules to default (no requests ignored) */
642
+ function resetSnapshotIgnoreRules() {
643
+ snapshotIgnoreRules = defaultSnapshotIgnoreRules;
644
+ }
645
+
598
646
  /**
599
647
  * Start the interceptor
600
648
  * @param {object} opts
@@ -621,10 +669,35 @@ function start({
621
669
  });
622
670
 
623
671
  const cache = /** @type {WeakMap<Request, SnapshotFileInfo>} */ (new WeakMap());
672
+ const ignoredRequests = /** @type {WeakSet<Request>} */ (new WeakSet());
624
673
 
625
674
  //@ts-ignore
626
675
  interceptor.on('request', async ({ request, controller }) => {
627
- if (['read', 'append'].includes(SNAPSHOT)) {
676
+ // Check if request should be ignored from snapshotting using ignore rules
677
+ const shouldIgnoreSnapshot = snapshotIgnoreRules(request);
678
+
679
+ // Track ignored requests
680
+ if (shouldIgnoreSnapshot) {
681
+ ignoredRequests.add(request);
682
+
683
+ // In read mode, we should not allow ignored requests to make real network calls
684
+ if (SNAPSHOT === 'read') {
685
+ console.error(
686
+ `${colors.red}Request ignored by snapshot ignore rules but SNAPSHOT=read mode doesn't allow real network requests:${colors.reset}`,
687
+ {
688
+ request: {
689
+ url: request.url,
690
+ method: request.method,
691
+ headers: Object.fromEntries([...request.headers.entries()]),
692
+ body: await request.clone().text(),
693
+ }
694
+ }
695
+ );
696
+ throw new Error('Request ignored by snapshot ignore rules but SNAPSHOT=read mode doesn\'t allow real network requests');
697
+ }
698
+ }
699
+
700
+ if (['read', 'append'].includes(SNAPSHOT) && !shouldIgnoreSnapshot) {
628
701
  const snapshotFileInfo = await getSnapshotFileInfo(request);
629
702
  cache.set(request, snapshotFileInfo);
630
703
  await readSnapshotAndSendResponse(request, controller, snapshotFileInfo);
@@ -635,15 +708,23 @@ function start({
635
708
  'response',
636
709
  /** @type {(params: { request: Request, response: Response }) => Promise<void>} */
637
710
  async ({ request, response }) => {
711
+ // Check if this request was marked to ignore snapshots
712
+ const shouldIgnoreSnapshot = ignoredRequests.has(request);
713
+
638
714
  const snapshotFileInfo = cache.get(request) || (await getSnapshotFileInfo(request));
639
715
  cache.delete(request);
716
+
640
717
  const {
641
718
  // absoluteFilePath,
642
719
  fileName,
643
720
  fileSuffixKey,
644
721
  } = snapshotFileInfo;
645
722
  if (LOG_REQ) {
646
- const summary = `----------\n${request.method} ${request.url}\nWould use file name: ${fileName}`;
723
+ const summary = `----------\n${request.method} ${request.url}\n${
724
+ shouldIgnoreSnapshot
725
+ ? 'ignored from snapshotting by ignore rules'
726
+ : `Would use file name: ${fileName}`
727
+ }`;
647
728
  if (LOG_REQ === '1' || LOG_REQ === 'summary') {
648
729
  console.debug(summary);
649
730
  } else if (LOG_REQ === 'detailed') {
@@ -664,7 +745,7 @@ function start({
664
745
  });
665
746
  }
666
747
  }
667
- if (SNAPSHOT === 'update' || (SNAPSHOT === 'append' && !readFiles.has(fileName))) {
748
+ if (!shouldIgnoreSnapshot && (SNAPSHOT === 'update' || (SNAPSHOT === 'append' && !readFiles.has(fileName)))) {
668
749
  if (!dirCreatePromise) {
669
750
  dirCreatePromise = fs.mkdir( /** @type {string} */(snapshotDirectory), { recursive: true });
670
751
  }
@@ -691,6 +772,9 @@ module.exports = {
691
772
  defaultSnapshotFileNameGenerator,
692
773
  attachSnapshotFilenameGenerator,
693
774
  resetSnapshotFilenameGenerator,
775
+ defaultSnapshotIgnoreRules,
776
+ attachSnapshotIgnoreRules,
777
+ resetSnapshotIgnoreRules,
694
778
  start,
695
779
  stop,
696
780
  };
package/intercept.js ADDED
@@ -0,0 +1,78 @@
1
+ /*
2
+ * This function was created with a realization that once you override a method for mocking
3
+ * and run a test, you can't undo the override, because shared code (test runner without
4
+ * test isolation) will hold on to closures to the overridden function.
5
+ *
6
+ * So a solution it to intercept once before all tests, mock for a test and "undoMock()" at
7
+ * end of a test will cause the intercept to "proxy" future calls to the original method.
8
+ */
9
+
10
+ const mocked = new Set();
11
+
12
+ /**
13
+ * Extracts the keys of an object where the values of the keys are functions.
14
+ * @template T The object type.
15
+ * @typedef {Extract<
16
+ * keyof T,
17
+ * { [K in keyof T]: T[K] extends Function ? K : never }[keyof T]
18
+ * >} FunctionKeys
19
+ */
20
+
21
+ /**
22
+ * @template T
23
+ * @template {FunctionKeys<T>} K
24
+ * @param {T} object
25
+ * @param {K} methodName
26
+ */
27
+ function intercept(object, methodName) {
28
+ let mockHandler = null;
29
+ const originalMethod = object[methodName];
30
+ // @ts-ignore
31
+ const boundedMethod = object[methodName].bind(object);
32
+
33
+ // @ts-ignore
34
+ // eslint-disable-next-line no-param-reassign
35
+ object[methodName] = function interceptedFunction(...args) {
36
+ return mockHandler
37
+ ? mockHandler(boundedMethod, ...args)
38
+ // @ts-ignore
39
+ : originalMethod.apply(object, args);
40
+ };
41
+
42
+ return {
43
+ /**
44
+ * @param {(originalMethod: T[K], ...args: Parameters<T[K]>) => any} mockFunction
45
+ */
46
+ mock(mockFunction) {
47
+ mockHandler = mockFunction;
48
+ mocked.add(this);
49
+ },
50
+ undoMock() {
51
+ mockHandler = null;
52
+ mocked.delete(this);
53
+ },
54
+ /**
55
+ * Un-intercepting will not get rid of closures to interceptedFunction
56
+ */
57
+ destroy() {
58
+ mockHandler = null;
59
+ // eslint-disable-next-line no-param-reassign
60
+ object[methodName] = originalMethod;
61
+ },
62
+ };
63
+ }
64
+
65
+ function undoAllMocks() {
66
+ [...mocked].forEach((methods) => methods.undoMock());
67
+ }
68
+
69
+ const interceptAllMethods = (object) => Object.fromEntries(
70
+ Object
71
+ .keys(object)
72
+ .filter((key) => typeof object[key] === 'function')
73
+ .map(
74
+ (key) => [key, intercept(object, key)],
75
+ ),
76
+ );
77
+
78
+ module.exports = { intercept, undoAllMocks, interceptAllMethods };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "http-snapshotter",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Snapshot HTTP requests for tests (node.js)",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,6 +9,9 @@
9
9
  "require": "./index.js",
10
10
  "types": "./index.d.ts"
11
11
  },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
12
15
  "scripts": {
13
16
  "test": "tape tests/**/*.test.* | tap-arc",
14
17
  "tsc": "tsc"
File without changes