@tryghost/jest-snapshot 2.3.21 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Jest Snapshot
2
2
 
3
- Tools for using jest snapshot without jest
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).
@@ -1,15 +1,29 @@
1
- const { SnapshotState, toMatchSnapshot, EXTENSION } = require('jest-snapshot');
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 = `.${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 {string} e.g. 'all' or 'new'
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 {Object} e.g. {snapshotPath: 'test/__snapshots__/my-fake.test.js.snap', snapshotName: 'My fake test title 1', willUpdate: 'new'}
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 { snapshotPath, snapshotName };
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
- * Gets a SnapshotState instance for the current test
118
- * @param {string} snapshotPath e.g. 'test/__snapshots__/my-fake.test.js.snap'
119
- * @returns {SnapshotState}
222
+ * Resolves once the snapshot file for the current test has been loaded
223
+ * @returns {Promise<void>}
120
224
  */
121
- getSnapshotState(snapshotPath) {
122
- // Initialize the SnapshotState, it’s responsible for actually matching
123
- // actual snapshot with expected one and storing results
124
- return new SnapshotState(snapshotPath, {
125
- updateSnapshot: this.willUpdate,
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
- * Calls Jest's toMatchSnapshot function
174
- * Binds the snapshotState, currentTestName, utils and equals function to the matcher
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 {Object} result of the match
293
+ * @returns {{pass: boolean, message: () => string, actual?: string, expected?: string}}
182
294
  */
183
295
  match(received, properties = {}, hint) {
184
- const { snapshotPath, snapshotName } = this._getConfig();
296
+ const { testPath, testTitle, snapshotName } = this._getConfig();
185
297
 
186
- const snapshotState = this.getSnapshotState(snapshotPath);
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
- const matcher = toMatchSnapshot.bind({
189
- snapshotState,
190
- currentTestName: snapshotName,
191
- utils,
192
- equals: utils.equals,
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
- // Execute the matcher
196
- let result;
197
- if (properties) {
198
- result = matcher(received, properties, hint);
199
- } else {
200
- result = matcher(received, hint);
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
- // Store the state of snapshot, depending on updateSnapshot value
204
- snapshotState.save();
337
+ const result = this.client.match({
338
+ received: value,
339
+ filepath: testPath,
340
+ name,
341
+ testId: testTitle,
342
+ assertionName: 'toMatchSnapshot',
343
+ });
205
344
 
206
- return result;
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
 
@@ -1,4 +1,4 @@
1
- const { jestExpect } = require('@jest/expect');
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
- const { any, anything, stringMatching } = jestExpect;
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.21",
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
- "@jest/expect": "^30.4.1",
21
- "@jest/expect-utils": "^30.4.1",
22
- "@tryghost/errors": "^3.4.6",
23
- "jest-snapshot": "^30.4.1"
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",