http-snapshotter 0.1.0
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 +170 -0
- package/index.js +371 -0
- package/index.mjs +1 -0
- package/package.json +28 -0
- package/tests/get-latest-xkcd-comic.test.js +31 -0
- package/tests/get-latest-xkcd-comic.test.mjs +33 -0
- package/tests/http-snapshots/get-xkcd-com-info-0-arAlFb5gfcr9aCN.json +101 -0
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# HTTP Snapshotter
|
|
2
|
+
|
|
3
|
+
Take snapshots of HTTP requests for purpose of tests.
|
|
4
|
+
|
|
5
|
+
WARNING: This module isn't concurrent or thread safe yet. You can only use it on serial test runners like `tape`.
|
|
6
|
+
|
|
7
|
+
Example (test.js):
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import test from "tape";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { resolve, dirname } from "node:path";
|
|
13
|
+
import { start } from "http-snapshotter";
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = dirname(__filename);
|
|
17
|
+
const snapshotDirectory = resolve(__dirname, "http-snapshots");
|
|
18
|
+
|
|
19
|
+
await start({ snapshotDirectory });
|
|
20
|
+
|
|
21
|
+
test("Latest XKCD comic (ESM)", async (t) => {
|
|
22
|
+
const res = await fetch("https://xkcd.com/info.0.json");
|
|
23
|
+
const json = await res.json();
|
|
24
|
+
|
|
25
|
+
t.deepEquals(json.title, "Iceberg Efficiency", "must be equal");
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
To create snapshots the first time run:
|
|
31
|
+
```sh
|
|
32
|
+
SNAPSHOT=update node test.js
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
You will see a file named `get-xkcd-com-info-0-arAlFb5gfcr9aCN.json` created in the `http-snapshots` directory.
|
|
36
|
+
|
|
37
|
+
e.g.
|
|
38
|
+
|
|
39
|
+
Then onwards running: `node test.js` or `SNAPSHOT=read node test.js` will ensure HTTP network calls are all read from a snapshot file.
|
|
40
|
+
It will prevent any real HTTP calls from happening by failing the test (if it didn't have a snapshot file).
|
|
41
|
+
|
|
42
|
+
## About snapshot files and its name
|
|
43
|
+
|
|
44
|
+
A snapshot file name unique identifies a request. By default it is a combination of HTTP method + URL + body that makes a request unique.
|
|
45
|
+
The hash of concatenated HTTP method + URL + body makes the file name suffix.
|
|
46
|
+
|
|
47
|
+
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,
|
|
48
|
+
because every call is a POST call with DynamoDB. You can add logic to create better snapshot files for this case:
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
import {
|
|
52
|
+
start,
|
|
53
|
+
defaultSnapshotFileNameGenerator,
|
|
54
|
+
attachSnapshotFilenameGenerator
|
|
55
|
+
} from "http-snapshotter";
|
|
56
|
+
const slugify = require('@sindresorhus/slugify');
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {Request} request https://developer.mozilla.org/en-US/docs/Web/API/Request
|
|
60
|
+
*/
|
|
61
|
+
async function mySnapshotFilenameGenerator(request) {
|
|
62
|
+
const url = new URL(request.url);
|
|
63
|
+
if (!url.hostname.startsWith('dynamodb.') || !url.hostname.endsWith('.amazonaws.com')) {
|
|
64
|
+
return defaultSnapshotFileNameGenerator(request);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Make a more readable file name suffix
|
|
68
|
+
let filePrefix;
|
|
69
|
+
const xAmzHeader = request.headers?.get?.('x-amz-target')?.split?.('.')?.pop?.() || '';
|
|
70
|
+
filePrefix = [
|
|
71
|
+
'dynamodb',
|
|
72
|
+
slugify(xAmzHeader),
|
|
73
|
+
slugify(JSON.parse(await request.clone().text())?.TableName),
|
|
74
|
+
].filter(Boolean).join('-');
|
|
75
|
+
|
|
76
|
+
// Input data
|
|
77
|
+
const dataList = await Promise.all(
|
|
78
|
+
['url', 'body'].map((key) => {
|
|
79
|
+
if (key === 'body') {
|
|
80
|
+
return request.clone().text();
|
|
81
|
+
}
|
|
82
|
+
return request[key];
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
filePrefix,
|
|
88
|
+
fileSuffixKey: `${xAmzHeader}#${dataList.join('#')}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
attachSnapshotFilenameGenerator(mySnapshotFilenameGenerator);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Same request, varied response
|
|
96
|
+
|
|
97
|
+
There are scenarios where one needs to test varied response for the same call (.e.g GET /account).
|
|
98
|
+
|
|
99
|
+
There are 2 ways to go about this.
|
|
100
|
+
|
|
101
|
+
Method 1: The easy way it to not touch the existing snapshot file, and use `attachResponseTransformer` to
|
|
102
|
+
change the response on runtime for the specific test:
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
import {
|
|
106
|
+
// ...
|
|
107
|
+
attachResponseTransformer,
|
|
108
|
+
resetResponseTransformer,
|
|
109
|
+
} from "http-snapshotter";
|
|
110
|
+
|
|
111
|
+
test('Test behavior on a free account', async (t) => {
|
|
112
|
+
/**
|
|
113
|
+
* @param {Response} response https://developer.mozilla.org/en-US/docs/Web/API/Response
|
|
114
|
+
* @param {Request} request https://developer.mozilla.org/en-US/docs/Web/API/Request
|
|
115
|
+
*/
|
|
116
|
+
const interceptResponse = async (response, request) => {
|
|
117
|
+
const url = new URL(request.url);
|
|
118
|
+
if (request.method === 'GET' && url.pathname === '/account') {
|
|
119
|
+
return new Response(
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
...(await response.clone().json()),
|
|
122
|
+
free_user: true,
|
|
123
|
+
}),
|
|
124
|
+
{
|
|
125
|
+
headers: response.headers
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
//
|
|
130
|
+
return response;
|
|
131
|
+
};
|
|
132
|
+
attachResponseTransformer(interceptResponse);
|
|
133
|
+
|
|
134
|
+
// make fetch() call here
|
|
135
|
+
// assert the test
|
|
136
|
+
|
|
137
|
+
// cleanup before moving to next test
|
|
138
|
+
resetResponseTransformer();
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Method 2: Add a filename suffix for the specific test you are running and manually edit the new snapshot file (it is a regular JSON file)
|
|
143
|
+
|
|
144
|
+
(building on the last attachSnapshotFilenameGenerator snippet)
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
test('Test behavior on a free account', async (t) => {
|
|
148
|
+
attachSnapshotFilenameGenerator(async (request) => {
|
|
149
|
+
const defaultReturn = mySnapshotFilenameGenerator();
|
|
150
|
+
|
|
151
|
+
const url = new URL(request.url);
|
|
152
|
+
if (request.method === 'GET' && url.pathname === '/account') {
|
|
153
|
+
return {
|
|
154
|
+
filePrefix: `free-account-test-${defaultReturn.filePrefix}`,
|
|
155
|
+
fileSuffixKey: defaultReturn.fileSuffixKey,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return defaultReturn;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// make fetch() call here
|
|
163
|
+
// assert the test
|
|
164
|
+
|
|
165
|
+
// cleanup before moving to next test
|
|
166
|
+
attachSnapshotFilenameGenerator(mySnapshotFilenameGenerator);
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Now when you run `SNAPHOT=update node specific-test.js` you will get a snapshot file with `free-account-test-` as prefix. You can now edit the JSON response for this test.
|
package/index.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/* eslint-disable import/no-extraneous-dependencies, no-console */
|
|
2
|
+
/**
|
|
3
|
+
* How to use:
|
|
4
|
+
* Place file in directory as tests and add `require('./snapshotter').start()` before tests begin
|
|
5
|
+
*
|
|
6
|
+
* WARNING: This snapshotter is not thread-safe. Only will work with test runners like tape where
|
|
7
|
+
* tests run on single threads.
|
|
8
|
+
*
|
|
9
|
+
* Run tests with environment variable SNAPSHOT=update first time to create snapshots
|
|
10
|
+
* SNAPSHOT=update <test runner command>
|
|
11
|
+
* e.g. SNAPSHOT=update tape tests/**\/*.js | tap-diff
|
|
12
|
+
*
|
|
13
|
+
* Here onwards run test runner without SNAPSHOT env variable or SNAPSHOT=read
|
|
14
|
+
* You can use SNAPSHOT=ignore to neither read not write snapshots, for testing on real
|
|
15
|
+
* network operations.
|
|
16
|
+
*
|
|
17
|
+
* Unused snapshot files will be written into a log file named 'unused-snapshots.log'.
|
|
18
|
+
* You can delete those files manually.
|
|
19
|
+
*
|
|
20
|
+
* Log requests with LOG_REQ=1 env variable
|
|
21
|
+
* or node.js built-in NODE_DEBUG=http,http2
|
|
22
|
+
*
|
|
23
|
+
* More docs at the end of this file, find the exported methods.
|
|
24
|
+
*/
|
|
25
|
+
// Tested with @mswjs/interceptors v0.24.1
|
|
26
|
+
const { BatchInterceptor } = require('@mswjs/interceptors');
|
|
27
|
+
const { ClientRequestInterceptor } = require('@mswjs/interceptors/ClientRequest');
|
|
28
|
+
const { FetchInterceptor } = require('@mswjs/interceptors/fetch');
|
|
29
|
+
const slugify = require('@sindresorhus/slugify');
|
|
30
|
+
const { createHash } = require('node:crypto');
|
|
31
|
+
const { promises: fs } = require('node:fs');
|
|
32
|
+
const { resolve } = require('node:path');
|
|
33
|
+
|
|
34
|
+
// Environment variable SNAPSHOT = update / ignore / read (default)
|
|
35
|
+
const SNAPSHOT = process.env.SNAPSHOT || 'read';
|
|
36
|
+
const LOG_REQ = process.env.LOG_REQ === '1' || process.env.LOG_REQ === 'true';
|
|
37
|
+
const unusedSnapshotsLogFile = 'unused-snapshots.log';
|
|
38
|
+
let snapshotDirectory = null;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef SnapshotText
|
|
42
|
+
* @property {'text'} responseType
|
|
43
|
+
* @property {string} fileSuffixKey
|
|
44
|
+
* @property {object} request
|
|
45
|
+
* @property {string} request.method
|
|
46
|
+
* @property {string} request.url
|
|
47
|
+
* @property {string[][]} request.headers
|
|
48
|
+
* @property {string|undefined} request.body
|
|
49
|
+
* @property {object} response
|
|
50
|
+
* @property {number} response.status
|
|
51
|
+
* @property {string} response.statusText
|
|
52
|
+
* @property {string[][]} response.headers
|
|
53
|
+
* @property {string|undefined} response.body
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* @typedef SnapshotJson
|
|
57
|
+
* @property {'json'} responseType
|
|
58
|
+
* @property {string} fileSuffixKey
|
|
59
|
+
* @property {object} request
|
|
60
|
+
* @property {string} request.method
|
|
61
|
+
* @property {string} request.url
|
|
62
|
+
* @property {string[][]} request.headers
|
|
63
|
+
* @property {string|undefined} request.body
|
|
64
|
+
* @property {object} response
|
|
65
|
+
* @property {number} response.status
|
|
66
|
+
* @property {string} response.statusText
|
|
67
|
+
* @property {string[][]} response.headers
|
|
68
|
+
* @property {object|undefined} response.body
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @typedef {SnapshotText | SnapshotJson} Snapshot
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
const identity = (response) => response;
|
|
76
|
+
|
|
77
|
+
const defaultKeyDerivationProps = ['method', 'url', 'body'];
|
|
78
|
+
async function defaultSnapshotFileNameGenerator(request) {
|
|
79
|
+
const url = new URL(request.url);
|
|
80
|
+
const filePrefix = [
|
|
81
|
+
request.method.toLowerCase(),
|
|
82
|
+
slugify(url.hostname),
|
|
83
|
+
slugify(url.pathname.replace('.json', '')),
|
|
84
|
+
].filter(Boolean).join('-');
|
|
85
|
+
|
|
86
|
+
// Input data
|
|
87
|
+
const dataList = await Promise.all(
|
|
88
|
+
defaultKeyDerivationProps.map((key) => {
|
|
89
|
+
if (key === 'body') {
|
|
90
|
+
return request.clone().text();
|
|
91
|
+
}
|
|
92
|
+
return request[key];
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
filePrefix,
|
|
98
|
+
fileSuffixKey: dataList.join('#'),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Dynamically changeable props
|
|
103
|
+
/**
|
|
104
|
+
* @type {(response: Response, request: Request) => Promise<Response>}
|
|
105
|
+
*/
|
|
106
|
+
let responseTransformer = identity;
|
|
107
|
+
/**
|
|
108
|
+
* @type {(req: Request) => Promise<{ filePrefix: string, fileSuffixKey: string }>}
|
|
109
|
+
*/
|
|
110
|
+
let snapshotFileNameGenerator = defaultSnapshotFileNameGenerator;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {Request} request
|
|
114
|
+
*/
|
|
115
|
+
async function getSnapshotFileName(request) {
|
|
116
|
+
const { fileSuffixKey, filePrefix } = await snapshotFileNameGenerator(request.clone());
|
|
117
|
+
|
|
118
|
+
// 15 characters are enough for uniqueness
|
|
119
|
+
const hash = createHash('sha256')
|
|
120
|
+
.update(fileSuffixKey)
|
|
121
|
+
.digest('base64url')
|
|
122
|
+
.slice(0, 15);
|
|
123
|
+
|
|
124
|
+
const fileName = `${filePrefix}-${hash}.json`;
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
absoluteFilePath: resolve(snapshotDirectory, fileName),
|
|
128
|
+
fileName,
|
|
129
|
+
filePrefix,
|
|
130
|
+
fileSuffixKey,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// NOTE: This isn't going to work on a test runner that uses multiple processes / workers
|
|
135
|
+
const alreadyWrittenFiles = new Set();
|
|
136
|
+
const readFiles = new Set();
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {Request} request
|
|
140
|
+
* @param {Response} response
|
|
141
|
+
*/
|
|
142
|
+
async function saveSnapshot(request, response) {
|
|
143
|
+
const { absoluteFilePath, fileName, fileSuffixKey } = await getSnapshotFileName(request);
|
|
144
|
+
// console.log(fileName);
|
|
145
|
+
|
|
146
|
+
// Prevent multiple tests from having same snapshot
|
|
147
|
+
if (alreadyWrittenFiles.has(absoluteFilePath)) return fileName;
|
|
148
|
+
alreadyWrittenFiles.add(absoluteFilePath);
|
|
149
|
+
|
|
150
|
+
let body;
|
|
151
|
+
/** @type {'text' | 'json'} */
|
|
152
|
+
let responseType;
|
|
153
|
+
const contentType = response.headers.get('content-type') || '';
|
|
154
|
+
if (contentType.includes('application/json') || contentType.includes('application/x-amz-json-1.0')) {
|
|
155
|
+
responseType = 'json';
|
|
156
|
+
body = await response.clone().json();
|
|
157
|
+
} else {
|
|
158
|
+
responseType = 'text';
|
|
159
|
+
body = await response.clone().text();
|
|
160
|
+
}
|
|
161
|
+
/** @type {Snapshot} */
|
|
162
|
+
const snapshot = {
|
|
163
|
+
request: {
|
|
164
|
+
method: request.method,
|
|
165
|
+
url: request.url,
|
|
166
|
+
headers: [...request.headers.entries()],
|
|
167
|
+
body: await request.clone().text(),
|
|
168
|
+
},
|
|
169
|
+
responseType,
|
|
170
|
+
response: {
|
|
171
|
+
status: response.status,
|
|
172
|
+
statusText: response.statusText,
|
|
173
|
+
headers: [...response.headers.entries()],
|
|
174
|
+
body,
|
|
175
|
+
},
|
|
176
|
+
fileSuffixKey,
|
|
177
|
+
};
|
|
178
|
+
const json = JSON.stringify(snapshot, null, 2);
|
|
179
|
+
await fs.writeFile(absoluteFilePath, json, 'utf-8');
|
|
180
|
+
return fileName;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const snapshotCache = {};
|
|
184
|
+
/**
|
|
185
|
+
* @param {Request} request
|
|
186
|
+
*/
|
|
187
|
+
async function enforceSnapshotResponse(request) {
|
|
188
|
+
const { absoluteFilePath, fileName, fileSuffixKey } = await getSnapshotFileName(request);
|
|
189
|
+
// console.log(fileName);
|
|
190
|
+
|
|
191
|
+
if (!snapshotCache[absoluteFilePath]) {
|
|
192
|
+
let json;
|
|
193
|
+
try {
|
|
194
|
+
json = await fs.readFile(absoluteFilePath, 'utf-8');
|
|
195
|
+
} catch (err) {
|
|
196
|
+
// Fail any test that fires a real network request (without snapshot)
|
|
197
|
+
// @ts-ignore
|
|
198
|
+
if (err.code === 'ENOENT') {
|
|
199
|
+
const reqBody = await request.clone().text();
|
|
200
|
+
console.error('No network snapshot found for request with cache keys:', {
|
|
201
|
+
request: {
|
|
202
|
+
url: request.url,
|
|
203
|
+
method: request.method,
|
|
204
|
+
headers: Object.fromEntries([...request.headers.entries()]),
|
|
205
|
+
body: reqBody,
|
|
206
|
+
},
|
|
207
|
+
wouldBeFileSuffixKey: fileSuffixKey,
|
|
208
|
+
wouldBeFileName: fileName,
|
|
209
|
+
});
|
|
210
|
+
throw new Error('Network request not mocked');
|
|
211
|
+
} else {
|
|
212
|
+
// @ts-ignore
|
|
213
|
+
console.error('Error reading network snapshot file:', err.message);
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
snapshotCache[absoluteFilePath] = JSON.parse(json);
|
|
218
|
+
readFiles.add(fileName);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const snapshot = snapshotCache[absoluteFilePath];
|
|
222
|
+
const {
|
|
223
|
+
responseType,
|
|
224
|
+
response: {
|
|
225
|
+
status,
|
|
226
|
+
statusText,
|
|
227
|
+
headers,
|
|
228
|
+
body,
|
|
229
|
+
},
|
|
230
|
+
} = snapshot;
|
|
231
|
+
|
|
232
|
+
let newResponse = new Response(
|
|
233
|
+
responseType === 'json' ? JSON.stringify(body) : body,
|
|
234
|
+
{
|
|
235
|
+
status,
|
|
236
|
+
statusText,
|
|
237
|
+
headers: new Headers(/** @type HeadersInit */ (headers)),
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
newResponse = await responseTransformer(newResponse, request);
|
|
242
|
+
|
|
243
|
+
// respondWith is a method added by @mswjs/interceptors
|
|
244
|
+
// @ts-ignore
|
|
245
|
+
request.respondWith(newResponse);
|
|
246
|
+
return newResponse;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* @type {import('@mswjs/interceptors').BatchInterceptor|null}
|
|
251
|
+
*/
|
|
252
|
+
let interceptor = null;
|
|
253
|
+
|
|
254
|
+
let beforeExitEventSeen = false;
|
|
255
|
+
let unusedFiles;
|
|
256
|
+
process.on('beforeExit', async () => {
|
|
257
|
+
if (SNAPSHOT === 'read' && !beforeExitEventSeen) {
|
|
258
|
+
beforeExitEventSeen = true;
|
|
259
|
+
let files;
|
|
260
|
+
try {
|
|
261
|
+
files = await fs.readdir(snapshotDirectory);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
unusedFiles = files.filter((file) => !readFiles.has(file) && file !== unusedSnapshotsLogFile);
|
|
266
|
+
if (unusedFiles.length) {
|
|
267
|
+
await fs
|
|
268
|
+
.writeFile(
|
|
269
|
+
resolve(snapshotDirectory, unusedSnapshotsLogFile),
|
|
270
|
+
unusedFiles.join('\n'),
|
|
271
|
+
'utf-8',
|
|
272
|
+
)
|
|
273
|
+
.catch((err) => console.error(err));
|
|
274
|
+
} else {
|
|
275
|
+
await fs
|
|
276
|
+
.unlink(resolve(snapshotDirectory, unusedSnapshotsLogFile))
|
|
277
|
+
.catch((err) => {
|
|
278
|
+
if (err.code !== 'ENOENT') {
|
|
279
|
+
console.error(err);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Attach snapshot filename generator function
|
|
287
|
+
function attachSnapshotFilenameGenerator(func) {
|
|
288
|
+
snapshotFileNameGenerator = func;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Reset snapshot filename generator to default
|
|
292
|
+
function resetSnapshotFilenameGenerator() {
|
|
293
|
+
snapshotFileNameGenerator = defaultSnapshotFileNameGenerator;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Attach response transformer function
|
|
297
|
+
function attachResponseTransformer(func) {
|
|
298
|
+
responseTransformer = func;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Reset response transformer
|
|
302
|
+
function resetResponseTransformer() {
|
|
303
|
+
responseTransformer = identity;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Start the interceptor
|
|
307
|
+
function start({
|
|
308
|
+
snapshotDirectory: _snapshotDirectory = null,
|
|
309
|
+
} = { snapshotDirectory: null }) {
|
|
310
|
+
if (!_snapshotDirectory) {
|
|
311
|
+
throw new Error('Please specify full path to a directory for storing/reading snapshots');
|
|
312
|
+
}
|
|
313
|
+
snapshotDirectory = _snapshotDirectory;
|
|
314
|
+
let dirCreatePromise;
|
|
315
|
+
|
|
316
|
+
interceptor = new BatchInterceptor({
|
|
317
|
+
name: 'http-snapshotter-interceptor',
|
|
318
|
+
interceptors: [
|
|
319
|
+
new ClientRequestInterceptor(),
|
|
320
|
+
new FetchInterceptor(),
|
|
321
|
+
],
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
interceptor.on('request', async ({ request }) => {
|
|
325
|
+
if (SNAPSHOT === 'read') {
|
|
326
|
+
await enforceSnapshotResponse(request);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
interceptor.on('response', async ({ request, response }) => {
|
|
330
|
+
if (SNAPSHOT === 'update') {
|
|
331
|
+
if (!dirCreatePromise) {
|
|
332
|
+
dirCreatePromise = fs.mkdir(snapshotDirectory, { recursive: true });
|
|
333
|
+
}
|
|
334
|
+
await dirCreatePromise;
|
|
335
|
+
saveSnapshot(request, response);
|
|
336
|
+
}
|
|
337
|
+
if (LOG_REQ) {
|
|
338
|
+
const { fileName, fileSuffixKey } = await getSnapshotFileName(request);
|
|
339
|
+
console.debug('Request', {
|
|
340
|
+
request: {
|
|
341
|
+
url: request.url,
|
|
342
|
+
method: request.method,
|
|
343
|
+
headers: Object.fromEntries([...request.headers.entries()]),
|
|
344
|
+
body: await request.clone().text(),
|
|
345
|
+
},
|
|
346
|
+
wouldBeFileName: fileName,
|
|
347
|
+
wouldBeFileSuffixKey: fileSuffixKey,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
interceptor.apply();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Stop the interceptor
|
|
355
|
+
function stop() {
|
|
356
|
+
if (interceptor) {
|
|
357
|
+
interceptor.dispose();
|
|
358
|
+
interceptor = null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Singleton - as it makes sense only one interceptor be active at any given moment.
|
|
363
|
+
module.exports = {
|
|
364
|
+
defaultSnapshotFileNameGenerator,
|
|
365
|
+
attachSnapshotFilenameGenerator,
|
|
366
|
+
resetSnapshotFilenameGenerator,
|
|
367
|
+
attachResponseTransformer,
|
|
368
|
+
resetResponseTransformer,
|
|
369
|
+
start,
|
|
370
|
+
stop,
|
|
371
|
+
};
|
package/index.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './index.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "http-snapshotter",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Snapshot HTTP requests for tests (node.js)",
|
|
5
|
+
"main": "index.cjs",
|
|
6
|
+
"exports": {
|
|
7
|
+
"import": "./index.mjs",
|
|
8
|
+
"require": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "tape tests/**/*.test.* | tap-diff"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"snapshot",
|
|
15
|
+
"testing"
|
|
16
|
+
],
|
|
17
|
+
"author": "Munawwar",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@mswjs/interceptors": "^0.24.1",
|
|
21
|
+
"@sindresorhus/slugify": "^1.1.2"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"tap-diff": "^0.1.1",
|
|
25
|
+
"tape": "^5.6.6",
|
|
26
|
+
"typescript": "^5.2.2"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const test = require("tape");
|
|
2
|
+
const { resolve } = require("node:path");
|
|
3
|
+
|
|
4
|
+
const { start } = require("../index.js");
|
|
5
|
+
|
|
6
|
+
const snapshotDirectory = resolve(__dirname, "http-snapshots");
|
|
7
|
+
|
|
8
|
+
start({ snapshotDirectory });
|
|
9
|
+
|
|
10
|
+
test("Latest XKCD comic (CJS)", async (t) => {
|
|
11
|
+
const res = await fetch("https://xkcd.com/info.0.json");
|
|
12
|
+
const json = await res.json();
|
|
13
|
+
|
|
14
|
+
t.deepEquals(
|
|
15
|
+
json,
|
|
16
|
+
{
|
|
17
|
+
month: "9",
|
|
18
|
+
num: 2829,
|
|
19
|
+
link: "",
|
|
20
|
+
year: "2023",
|
|
21
|
+
news: "",
|
|
22
|
+
safe_title: "Iceberg Efficiency",
|
|
23
|
+
transcript: "",
|
|
24
|
+
alt: "Our experimental aerogel iceberg with helium pockets manages true 100% efficiency, barely touching the water, and it can even lift off of the surface and fly to more efficiently pursue fleeing hubristic liners.",
|
|
25
|
+
img: "https://imgs.xkcd.com/comics/iceberg_efficiency.png",
|
|
26
|
+
title: "Iceberg Efficiency",
|
|
27
|
+
day: "15",
|
|
28
|
+
},
|
|
29
|
+
"must be deeply equal"
|
|
30
|
+
);
|
|
31
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import test from "tape";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { resolve, dirname } from "node:path";
|
|
4
|
+
import { start } from "../index.mjs";
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = dirname(__filename);
|
|
8
|
+
const snapshotDirectory = resolve(__dirname, "http-snapshots");
|
|
9
|
+
|
|
10
|
+
await start({ snapshotDirectory });
|
|
11
|
+
|
|
12
|
+
test("Latest XKCD comic (ESM)", async (t) => {
|
|
13
|
+
const res = await fetch("https://xkcd.com/info.0.json");
|
|
14
|
+
const json = await res.json();
|
|
15
|
+
|
|
16
|
+
t.deepEquals(
|
|
17
|
+
json,
|
|
18
|
+
{
|
|
19
|
+
month: "9",
|
|
20
|
+
num: 2829,
|
|
21
|
+
link: "",
|
|
22
|
+
year: "2023",
|
|
23
|
+
news: "",
|
|
24
|
+
safe_title: "Iceberg Efficiency",
|
|
25
|
+
transcript: "",
|
|
26
|
+
alt: "Our experimental aerogel iceberg with helium pockets manages true 100% efficiency, barely touching the water, and it can even lift off of the surface and fly to more efficiently pursue fleeing hubristic liners.",
|
|
27
|
+
img: "https://imgs.xkcd.com/comics/iceberg_efficiency.png",
|
|
28
|
+
title: "Iceberg Efficiency",
|
|
29
|
+
day: "15",
|
|
30
|
+
},
|
|
31
|
+
"must be deeply equal"
|
|
32
|
+
);
|
|
33
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
{
|
|
2
|
+
"request": {
|
|
3
|
+
"method": "GET",
|
|
4
|
+
"url": "https://xkcd.com/info.0.json",
|
|
5
|
+
"headers": [],
|
|
6
|
+
"body": ""
|
|
7
|
+
},
|
|
8
|
+
"responseType": "json",
|
|
9
|
+
"response": {
|
|
10
|
+
"status": 200,
|
|
11
|
+
"statusText": "OK",
|
|
12
|
+
"headers": [
|
|
13
|
+
[
|
|
14
|
+
"accept-ranges",
|
|
15
|
+
"bytes"
|
|
16
|
+
],
|
|
17
|
+
[
|
|
18
|
+
"age",
|
|
19
|
+
"228"
|
|
20
|
+
],
|
|
21
|
+
[
|
|
22
|
+
"cache-control",
|
|
23
|
+
"max-age=300"
|
|
24
|
+
],
|
|
25
|
+
[
|
|
26
|
+
"connection",
|
|
27
|
+
"keep-alive"
|
|
28
|
+
],
|
|
29
|
+
[
|
|
30
|
+
"content-encoding",
|
|
31
|
+
"gzip"
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
"content-length",
|
|
35
|
+
"287"
|
|
36
|
+
],
|
|
37
|
+
[
|
|
38
|
+
"content-type",
|
|
39
|
+
"application/json"
|
|
40
|
+
],
|
|
41
|
+
[
|
|
42
|
+
"date",
|
|
43
|
+
"Fri, 15 Sep 2023 18:26:59 GMT"
|
|
44
|
+
],
|
|
45
|
+
[
|
|
46
|
+
"etag",
|
|
47
|
+
"W/\"65044c59-1c0\""
|
|
48
|
+
],
|
|
49
|
+
[
|
|
50
|
+
"expires",
|
|
51
|
+
"Fri, 15 Sep 2023 12:28:00 GMT"
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
"last-modified",
|
|
55
|
+
"Fri, 15 Sep 2023 12:21:45 GMT"
|
|
56
|
+
],
|
|
57
|
+
[
|
|
58
|
+
"server",
|
|
59
|
+
"nginx"
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
"vary",
|
|
63
|
+
"Accept-Encoding"
|
|
64
|
+
],
|
|
65
|
+
[
|
|
66
|
+
"via",
|
|
67
|
+
"1.1 varnish, 1.1 varnish"
|
|
68
|
+
],
|
|
69
|
+
[
|
|
70
|
+
"x-cache",
|
|
71
|
+
"HIT, HIT"
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
"x-cache-hits",
|
|
75
|
+
"4250, 1"
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
"x-served-by",
|
|
79
|
+
"cache-dfw-kdal2120106-DFW, cache-dxb1470033-DXB"
|
|
80
|
+
],
|
|
81
|
+
[
|
|
82
|
+
"x-timer",
|
|
83
|
+
"S1694802419.108277,VS0,VE218"
|
|
84
|
+
]
|
|
85
|
+
],
|
|
86
|
+
"body": {
|
|
87
|
+
"month": "9",
|
|
88
|
+
"num": 2829,
|
|
89
|
+
"link": "",
|
|
90
|
+
"year": "2023",
|
|
91
|
+
"news": "",
|
|
92
|
+
"safe_title": "Iceberg Efficiency",
|
|
93
|
+
"transcript": "",
|
|
94
|
+
"alt": "Our experimental aerogel iceberg with helium pockets manages true 100% efficiency, barely touching the water, and it can even lift off of the surface and fly to more efficiently pursue fleeing hubristic liners.",
|
|
95
|
+
"img": "https://imgs.xkcd.com/comics/iceberg_efficiency.png",
|
|
96
|
+
"title": "Iceberg Efficiency",
|
|
97
|
+
"day": "15"
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"fileSuffixKey": "GET#https://xkcd.com/info.0.json#"
|
|
101
|
+
}
|