@tryghost/jest-snapshot 2.3.20 → 2.3.22
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 +44 -1
- package/lib/SnapshotManager.js +184 -36
- package/lib/jest-snapshot.js +15 -4
- package/package.json +8 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Jest Snapshot
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Jest-compatible snapshot testing for any test runner, powered by `@vitest/snapshot`
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -16,6 +16,49 @@ Snapshot assertion utilities and state management for Jest-style snapshots in no
|
|
|
16
16
|
|
|
17
17
|
## Usage
|
|
18
18
|
|
|
19
|
+
Snapshot files keep the Jest format (`// Jest Snapshot v1` header, `Object {` / `Array [` prefixes), so existing
|
|
20
|
+
`.snap` files continue to match without being rewritten. Set `SNAPSHOT_UPDATE=1` to update snapshots.
|
|
21
|
+
|
|
22
|
+
### Mocha
|
|
23
|
+
|
|
24
|
+
Register the root hooks, e.g. via `--require`:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
const { mochaHooks } = require('@tryghost/jest-snapshot');
|
|
28
|
+
exports.mochaHooks = mochaHooks;
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Other runners (e.g. Vitest)
|
|
32
|
+
|
|
33
|
+
Snapshot files are loaded and saved asynchronously, while matching stays synchronous. Wire the manager into your
|
|
34
|
+
runner's hooks and await the async calls:
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
const { snapshotManager } = require('@tryghost/jest-snapshot');
|
|
38
|
+
|
|
39
|
+
beforeAll(() => snapshotManager.resetRegistry());
|
|
40
|
+
|
|
41
|
+
beforeEach(async (context) => {
|
|
42
|
+
// Build a mocha-style full title: every describe name plus the test name
|
|
43
|
+
const titleParts = [context.task.name];
|
|
44
|
+
for (let suite = context.task.suite; suite && suite.name; suite = suite.suite) {
|
|
45
|
+
titleParts.unshift(suite.name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await snapshotManager.setCurrentTest({
|
|
49
|
+
testPath: context.task.file.filepath,
|
|
50
|
+
testTitle: titleParts.join(' '),
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterAll(() => snapshotManager.saveSnapshots());
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`snapshotManager.match(received, properties, hint)` and `snapshotManager.assertSnapshot(...)` require the current
|
|
58
|
+
test's snapshot file to be loaded (`await setCurrentTest(...)` or `await snapshotManager.ready()`).
|
|
59
|
+
|
|
60
|
+
Asymmetric matchers for property matching are exported as `any`, `anything` and `stringMatching`.
|
|
61
|
+
|
|
19
62
|
## Develop
|
|
20
63
|
|
|
21
64
|
This is a mono repository, managed with [Nx](https://nx.dev).
|
package/lib/SnapshotManager.js
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { SnapshotClient } = require('@vitest/snapshot');
|
|
2
|
+
const { NodeSnapshotEnvironment } = require('@vitest/snapshot/environment');
|
|
3
|
+
const { equals, iterableEquality, subsetEquality } = require('@vitest/expect');
|
|
4
|
+
const { diff } = require('@vitest/utils/diff');
|
|
2
5
|
const errors = require('@tryghost/errors');
|
|
3
|
-
const utils = require('@jest/expect-utils');
|
|
4
6
|
const assert = require('assert');
|
|
5
7
|
const path = require('path');
|
|
6
8
|
const { makeMessageFromMatchMessage } = require('./utils');
|
|
7
9
|
|
|
8
|
-
const DOT_EXTENSION =
|
|
10
|
+
const DOT_EXTENSION = '.snap';
|
|
11
|
+
|
|
12
|
+
// Keep the header jest-snapshot wrote so existing snapshot files are not
|
|
13
|
+
// rewritten just because the engine changed.
|
|
14
|
+
const SNAPSHOT_HEADER = '// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing';
|
|
15
|
+
|
|
16
|
+
// jest-snapshot used pretty-format's defaults (no snapshotFormat passed),
|
|
17
|
+
// which print `Object {` / `Array [` and escape quotes inside strings.
|
|
18
|
+
// @vitest/snapshot defaults both of these to false.
|
|
19
|
+
const SNAPSHOT_FORMAT = {
|
|
20
|
+
printBasicPrototype: true,
|
|
21
|
+
escapeString: true,
|
|
22
|
+
};
|
|
9
23
|
|
|
10
24
|
/**
|
|
11
25
|
* Determines whether to update all snapshots or only new ones
|
|
12
|
-
* @returns {
|
|
26
|
+
* @returns {'all' | 'new'}
|
|
13
27
|
*/
|
|
14
28
|
function willUpdate() {
|
|
15
29
|
const updateSnapshots =
|
|
@@ -21,6 +35,58 @@ function willUpdate() {
|
|
|
21
35
|
return updateSnapshots ? 'all' : 'new';
|
|
22
36
|
}
|
|
23
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Merges property matchers into the received value, as jest-snapshot does
|
|
40
|
+
* before serializing: matchers (and primitives) replace the received value,
|
|
41
|
+
* plain objects and arrays merge key-by-key / index-by-index, and anything in
|
|
42
|
+
* `received` that `properties` doesn't mention is kept.
|
|
43
|
+
*
|
|
44
|
+
* @param {unknown} received
|
|
45
|
+
* @param {unknown} properties
|
|
46
|
+
* @returns {unknown}
|
|
47
|
+
*/
|
|
48
|
+
function mergeProperties(received, properties) {
|
|
49
|
+
if (Array.isArray(received) && Array.isArray(properties)) {
|
|
50
|
+
const merged = [...received];
|
|
51
|
+
properties.forEach((value, index) => {
|
|
52
|
+
merged[index] = mergeProperties(received[index], value);
|
|
53
|
+
});
|
|
54
|
+
return merged;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const isMergeable = (value) =>
|
|
58
|
+
value !== null && typeof value === 'object' && !Array.isArray(value) && !value.$$typeof;
|
|
59
|
+
|
|
60
|
+
if (isMergeable(received) && isMergeable(properties)) {
|
|
61
|
+
const merged = { ...received };
|
|
62
|
+
for (const key of Object.keys(properties)) {
|
|
63
|
+
merged[key] =
|
|
64
|
+
key in received ? mergeProperties(received[key], properties[key]) : properties[key];
|
|
65
|
+
}
|
|
66
|
+
return merged;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return properties;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class JestCompatSnapshotEnvironment extends NodeSnapshotEnvironment {
|
|
73
|
+
/**
|
|
74
|
+
* @param {SnapshotManager} manager
|
|
75
|
+
*/
|
|
76
|
+
constructor(manager) {
|
|
77
|
+
super();
|
|
78
|
+
this.manager = manager;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
getHeader() {
|
|
82
|
+
return SNAPSHOT_HEADER;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async resolvePath(filepath) {
|
|
86
|
+
return this.manager._resolveSnapshotFilePath(filepath);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
24
90
|
class SnapshotManager {
|
|
25
91
|
constructor() {
|
|
26
92
|
this.registry = {};
|
|
@@ -29,6 +95,12 @@ class SnapshotManager {
|
|
|
29
95
|
|
|
30
96
|
// Set willUpdate once, as it can't change whilst we are running
|
|
31
97
|
this.willUpdate = willUpdate();
|
|
98
|
+
|
|
99
|
+
this.snapshotEnvironment = new JestCompatSnapshotEnvironment(this);
|
|
100
|
+
this.client = new SnapshotClient();
|
|
101
|
+
|
|
102
|
+
/** @type {Promise<void>} */
|
|
103
|
+
this._pendingSetup = Promise.resolve();
|
|
32
104
|
}
|
|
33
105
|
|
|
34
106
|
/**
|
|
@@ -67,7 +139,7 @@ class SnapshotManager {
|
|
|
67
139
|
/**
|
|
68
140
|
* Returns config for the current test
|
|
69
141
|
* Throws an error if the current test is not configured
|
|
70
|
-
* @returns {
|
|
142
|
+
* @returns {{testPath: string, testTitle: string, snapshotName: string, snapshotPath: string}}
|
|
71
143
|
*/
|
|
72
144
|
_getConfig() {
|
|
73
145
|
if (!this.currentTest.testPath || !this.currentTest.testTitle) {
|
|
@@ -84,7 +156,7 @@ class SnapshotManager {
|
|
|
84
156
|
const snapshotName = this._getNameForSnapshot(testPath, testTitle);
|
|
85
157
|
const snapshotPath = this._resolveSnapshotFilePath(testPath);
|
|
86
158
|
|
|
87
|
-
return {
|
|
159
|
+
return { testPath, testTitle, snapshotName, snapshotPath };
|
|
88
160
|
}
|
|
89
161
|
|
|
90
162
|
/**
|
|
@@ -96,34 +168,76 @@ class SnapshotManager {
|
|
|
96
168
|
|
|
97
169
|
/**
|
|
98
170
|
* Resets the registry for the current test only
|
|
171
|
+
* Also clears any snapshot keys the engine recorded for this test so a
|
|
172
|
+
* retry produces the same keys as the first attempt.
|
|
99
173
|
*/
|
|
100
174
|
resetRegistryForCurrentTest() {
|
|
101
175
|
const { testPath, testTitle } = this.currentTest;
|
|
102
176
|
if (testPath in this.registry && testTitle in this.registry[testPath]) {
|
|
103
177
|
delete this.registry[testPath][testTitle];
|
|
104
178
|
}
|
|
179
|
+
if (testPath && this.client.snapshotStateMap.has(testPath)) {
|
|
180
|
+
const state = this.client.getSnapshotState(testPath);
|
|
181
|
+
this.client.clearTest(testPath, testTitle);
|
|
182
|
+
|
|
183
|
+
// clearTest resets keys the cleared attempt *added* to `undefined`
|
|
184
|
+
// rather than removing them, so save() would crash if the retry
|
|
185
|
+
// never reaches those assertions. Drop them.
|
|
186
|
+
const data = state._snapshotData;
|
|
187
|
+
if (data) {
|
|
188
|
+
for (const key of Object.keys(data)) {
|
|
189
|
+
if (data[key] === undefined) {
|
|
190
|
+
delete data[key];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
105
195
|
}
|
|
106
196
|
|
|
107
197
|
/**
|
|
198
|
+
* Sets the current test and starts loading its snapshot file.
|
|
199
|
+
* Loading is async; callers that can wait should `await` the result (or
|
|
200
|
+
* `await snapshotManager.ready()`) before matching.
|
|
201
|
+
*
|
|
108
202
|
* @param {Object} testConfig
|
|
109
203
|
* @param {String} testConfig.testPath full path to the test file
|
|
110
204
|
* @param {String} testConfig.testTitle the full title of the test - all the describe and it names concatenated
|
|
205
|
+
* @returns {Promise<void>}
|
|
111
206
|
*/
|
|
112
207
|
setCurrentTest(testConfig) {
|
|
113
208
|
this.currentTest = testConfig;
|
|
209
|
+
|
|
210
|
+
if (testConfig && testConfig.testPath) {
|
|
211
|
+
this._pendingSetup = this.client.setup(testConfig.testPath, {
|
|
212
|
+
updateSnapshot: this.willUpdate,
|
|
213
|
+
snapshotEnvironment: this.snapshotEnvironment,
|
|
214
|
+
snapshotFormat: SNAPSHOT_FORMAT,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return this._pendingSetup;
|
|
114
219
|
}
|
|
115
220
|
|
|
116
221
|
/**
|
|
117
|
-
*
|
|
118
|
-
* @
|
|
119
|
-
* @returns {SnapshotState}
|
|
222
|
+
* Resolves once the snapshot file for the current test has been loaded
|
|
223
|
+
* @returns {Promise<void>}
|
|
120
224
|
*/
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
225
|
+
ready() {
|
|
226
|
+
return this._pendingSetup;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Writes every loaded snapshot file to disk (when changed), then forgets
|
|
231
|
+
* them. Obsolete snapshots are kept, matching the previous jest-snapshot
|
|
232
|
+
* behaviour.
|
|
233
|
+
* @returns {Promise<void>}
|
|
234
|
+
*/
|
|
235
|
+
async saveSnapshots() {
|
|
236
|
+
await this._pendingSetup;
|
|
237
|
+
const states = [...this.client.snapshotStateMap.values()];
|
|
238
|
+
await Promise.all(states.map((state) => state.save()));
|
|
239
|
+
// Only forget the states once every write succeeded, so a failed save can be retried
|
|
240
|
+
this.client.clear();
|
|
127
241
|
}
|
|
128
242
|
|
|
129
243
|
/**
|
|
@@ -170,40 +284,74 @@ class SnapshotManager {
|
|
|
170
284
|
}
|
|
171
285
|
|
|
172
286
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* Executes the matcher
|
|
176
|
-
* Saves the snapshot state
|
|
287
|
+
* Matches `received` against the stored snapshot for the current test.
|
|
288
|
+
* Synchronous: the snapshot file must already be loaded (see setCurrentTest/ready).
|
|
177
289
|
*
|
|
178
290
|
* @param {Object} received - our actual value
|
|
179
291
|
* @param {Object} [properties] - the properties we want to match against
|
|
180
292
|
* @param {string} [hint] - a hint to help with the match
|
|
181
|
-
* @returns {
|
|
293
|
+
* @returns {{pass: boolean, message: () => string, actual?: string, expected?: string}}
|
|
182
294
|
*/
|
|
183
295
|
match(received, properties = {}, hint) {
|
|
184
|
-
const {
|
|
296
|
+
const { testPath, testTitle, snapshotName } = this._getConfig();
|
|
185
297
|
|
|
186
|
-
|
|
298
|
+
if (!this.client.snapshotStateMap.has(testPath)) {
|
|
299
|
+
throw new errors.IncorrectUsageError({
|
|
300
|
+
message: 'Unable to run snapshot tests, snapshot file was not loaded',
|
|
301
|
+
context: `No snapshot state for ${testPath}`,
|
|
302
|
+
help: 'Await snapshotManager.setCurrentTest() (or snapshotManager.ready()) before matching',
|
|
303
|
+
});
|
|
304
|
+
}
|
|
187
305
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
306
|
+
// Mirror jest's key format: `${currentTestName}: ${hint} ${count}`
|
|
307
|
+
const name = hint ? `${snapshotName}: ${hint}` : snapshotName;
|
|
308
|
+
const failure = (snapshotKey, body, extra) => ({
|
|
309
|
+
pass: false,
|
|
310
|
+
...extra,
|
|
311
|
+
message: () =>
|
|
312
|
+
`expect(received).toMatchSnapshot()\n\nSnapshot name: \`${snapshotKey}\`\n\n${body}`,
|
|
193
313
|
});
|
|
194
314
|
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
315
|
+
// Property matchers are checked and merged here rather than by
|
|
316
|
+
// @vitest/snapshot, whose merge copies Any/Anything matchers inside
|
|
317
|
+
// arrays of objects (e.g. `roles: [any(Object)]`) onto the object.
|
|
318
|
+
let value = received;
|
|
319
|
+
if (properties && Object.keys(properties).length) {
|
|
320
|
+
const propertiesPass =
|
|
321
|
+
typeof received === 'object' &&
|
|
322
|
+
received !== null &&
|
|
323
|
+
equals(received, properties, [iterableEquality, subsetEquality]);
|
|
324
|
+
|
|
325
|
+
if (!propertiesPass) {
|
|
326
|
+
const body = diff(properties, received, {
|
|
327
|
+
aAnnotation: 'Expected properties',
|
|
328
|
+
bAnnotation: 'Received value',
|
|
329
|
+
includeChangeCounts: true,
|
|
330
|
+
});
|
|
331
|
+
return failure(`${name} 1`, body, { actual: received, expected: properties });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
value = mergeProperties(received, properties);
|
|
201
335
|
}
|
|
202
336
|
|
|
203
|
-
|
|
204
|
-
|
|
337
|
+
const result = this.client.match({
|
|
338
|
+
received: value,
|
|
339
|
+
filepath: testPath,
|
|
340
|
+
name,
|
|
341
|
+
testId: testTitle,
|
|
342
|
+
assertionName: 'toMatchSnapshot',
|
|
343
|
+
});
|
|
205
344
|
|
|
206
|
-
|
|
345
|
+
if (result.pass) {
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const body = diff(result.expected, result.actual, {
|
|
350
|
+
aAnnotation: 'Snapshot',
|
|
351
|
+
bAnnotation: 'Received',
|
|
352
|
+
includeChangeCounts: true,
|
|
353
|
+
});
|
|
354
|
+
return failure(`${name} 1`, body, { actual: result.actual, expected: result.expected });
|
|
207
355
|
}
|
|
208
356
|
}
|
|
209
357
|
|
package/lib/jest-snapshot.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { Any, Anything, StringMatching } = require('@vitest/expect');
|
|
2
2
|
const SnapshotManager = require('./SnapshotManager');
|
|
3
3
|
const snapshotManager = new SnapshotManager();
|
|
4
4
|
|
|
@@ -25,7 +25,7 @@ const mochaHooks = {
|
|
|
25
25
|
* Passes the current test config to the snapshot manager
|
|
26
26
|
* If we're running a retry, reset the registry for this test only
|
|
27
27
|
*/
|
|
28
|
-
beforeEach() {
|
|
28
|
+
async beforeEach() {
|
|
29
29
|
const { currentTest } = this;
|
|
30
30
|
|
|
31
31
|
if (currentTest.currentRetry() > 0) {
|
|
@@ -33,14 +33,25 @@ const mochaHooks = {
|
|
|
33
33
|
snapshotManager.resetRegistryForCurrentTest();
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
snapshotManager.setCurrentTest({
|
|
36
|
+
await snapshotManager.setCurrentTest({
|
|
37
37
|
testPath: currentTest.file,
|
|
38
38
|
testTitle: currentTest.fullTitle(),
|
|
39
39
|
});
|
|
40
40
|
},
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Runs after all tests
|
|
44
|
+
* Writes new/updated snapshots to disk
|
|
45
|
+
*/
|
|
46
|
+
async afterAll() {
|
|
47
|
+
await snapshotManager.saveSnapshots();
|
|
48
|
+
},
|
|
41
49
|
};
|
|
42
50
|
|
|
43
|
-
|
|
51
|
+
// Same shape as jest's expect.any / expect.anything / expect.stringMatching
|
|
52
|
+
const any = (sample) => new Any(sample);
|
|
53
|
+
const anything = () => new Anything();
|
|
54
|
+
const stringMatching = (expected) => new StringMatching(expected);
|
|
44
55
|
|
|
45
56
|
module.exports = {
|
|
46
57
|
mochaHooks,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tryghost/jest-snapshot",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.22",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Ghost Foundation",
|
|
6
6
|
"repository": {
|
|
@@ -17,14 +17,17 @@
|
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@
|
|
21
|
-
"@
|
|
22
|
-
"@
|
|
23
|
-
"
|
|
20
|
+
"@tryghost/errors": "^3.4.7",
|
|
21
|
+
"@vitest/expect": "^4.1.3 || ^5.0.0",
|
|
22
|
+
"@vitest/snapshot": "^4.1.3 || ^5.0.0",
|
|
23
|
+
"@vitest/utils": "^4.1.3 || ^5.0.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"sinon": "22.1.0"
|
|
27
27
|
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": "^22.12.0 || >=24.0.0"
|
|
30
|
+
},
|
|
28
31
|
"scripts": {
|
|
29
32
|
"dev": "echo \"Implement me!\"",
|
|
30
33
|
"test": "NODE_ENV=testing vitest run --config ../../vitest.config.ts --root . --coverage",
|