ava 3.15.0 → 4.0.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.
Files changed (72) hide show
  1. package/entrypoints/cli.mjs +4 -0
  2. package/entrypoints/eslint-plugin-helper.cjs +109 -0
  3. package/entrypoints/main.cjs +2 -0
  4. package/entrypoints/main.mjs +1 -0
  5. package/entrypoints/plugin.cjs +2 -0
  6. package/entrypoints/plugin.mjs +4 -0
  7. package/index.d.ts +6 -816
  8. package/lib/api.js +108 -49
  9. package/lib/assert.js +255 -270
  10. package/lib/chalk.js +9 -14
  11. package/lib/cli.js +118 -112
  12. package/lib/code-excerpt.js +12 -17
  13. package/lib/concordance-options.js +29 -65
  14. package/lib/context-ref.js +3 -6
  15. package/lib/create-chain.js +32 -20
  16. package/lib/environment-variables.js +1 -4
  17. package/lib/eslint-plugin-helper-worker.js +73 -0
  18. package/lib/extensions.js +2 -2
  19. package/lib/fork.js +81 -84
  20. package/lib/glob-helpers.cjs +140 -0
  21. package/lib/globs.js +136 -163
  22. package/lib/{ipc-flow-control.js → ipc-flow-control.cjs} +1 -0
  23. package/lib/is-ci.js +4 -2
  24. package/lib/like-selector.js +7 -13
  25. package/lib/line-numbers.js +11 -18
  26. package/lib/load-config.js +56 -180
  27. package/lib/module-types.js +3 -7
  28. package/lib/node-arguments.js +4 -5
  29. package/lib/{now-and-timers.js → now-and-timers.cjs} +0 -0
  30. package/lib/parse-test-args.js +22 -11
  31. package/lib/pkg.cjs +2 -0
  32. package/lib/plugin-support/shared-worker-loader.js +45 -48
  33. package/lib/plugin-support/shared-workers.js +24 -46
  34. package/lib/provider-manager.js +20 -14
  35. package/lib/reporters/beautify-stack.js +6 -12
  36. package/lib/reporters/colors.js +40 -15
  37. package/lib/reporters/default.js +114 -364
  38. package/lib/reporters/format-serialized-error.js +7 -18
  39. package/lib/reporters/improper-usage-messages.js +8 -9
  40. package/lib/reporters/prefix-title.js +17 -15
  41. package/lib/reporters/tap.js +18 -25
  42. package/lib/run-status.js +29 -23
  43. package/lib/runner.js +157 -172
  44. package/lib/scheduler.js +53 -0
  45. package/lib/serialize-error.js +61 -64
  46. package/lib/snapshot-manager.js +271 -289
  47. package/lib/test.js +135 -291
  48. package/lib/watcher.js +69 -44
  49. package/lib/worker/base.js +208 -0
  50. package/lib/worker/channel.cjs +290 -0
  51. package/lib/worker/dependency-tracker.js +24 -23
  52. package/lib/worker/{ensure-forked.js → guard-environment.cjs} +5 -4
  53. package/lib/worker/line-numbers.js +58 -20
  54. package/lib/worker/main.cjs +12 -0
  55. package/lib/worker/{options.js → options.cjs} +0 -0
  56. package/lib/worker/{plugin.js → plugin.cjs} +30 -21
  57. package/lib/worker/state.cjs +5 -0
  58. package/lib/worker/utils.cjs +6 -0
  59. package/package.json +71 -68
  60. package/plugin.d.ts +51 -53
  61. package/readme.md +5 -13
  62. package/types/assertions.d.ts +327 -0
  63. package/types/subscribable.ts +6 -0
  64. package/types/test-fn.d.ts +231 -0
  65. package/types/try-fn.d.ts +58 -0
  66. package/cli.js +0 -11
  67. package/eslint-plugin-helper.js +0 -201
  68. package/index.js +0 -8
  69. package/lib/worker/ipc.js +0 -201
  70. package/lib/worker/main.js +0 -21
  71. package/lib/worker/subprocess.js +0 -266
  72. package/plugin.js +0 -9
@@ -1,24 +1,24 @@
1
- 'use strict';
2
-
3
- const crypto = require('crypto');
4
- const fs = require('fs');
5
- const path = require('path');
6
- const zlib = require('zlib');
7
-
8
- const concordance = require('concordance');
9
- const indentString = require('indent-string');
10
- const md5Hex = require('md5-hex');
11
- const convertSourceMap = require('convert-source-map');
12
- const slash = require('slash');
13
- const writeFileAtomic = require('write-file-atomic');
14
- const mem = require('mem');
15
-
16
- const concordanceOptions = require('./concordance-options').snapshotManager;
1
+ import {Buffer} from 'node:buffer';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import {findSourceMap} from 'node:module';
5
+ import path from 'node:path';
6
+ import {fileURLToPath} from 'node:url';
7
+ import zlib from 'node:zlib';
8
+
9
+ import cbor from 'cbor';
10
+ import concordance from 'concordance';
11
+ import indentString from 'indent-string';
12
+ import mem from 'mem';
13
+ import slash from 'slash';
14
+ import writeFileAtomic from 'write-file-atomic';
15
+
16
+ import {snapshotManager as concordanceOptions} from './concordance-options.js';
17
17
 
18
18
  // Increment if encoding layout or Concordance serialization versions change. Previous AVA versions will not be able to
19
19
  // decode buffers generated by a newer version, so changing this value will require a major version bump of AVA itself.
20
20
  // The version is encoded as an unsigned 16 bit integer.
21
- const VERSION = 2;
21
+ const VERSION = 3;
22
22
 
23
23
  const VERSION_HEADER = Buffer.alloc(2);
24
24
  VERSION_HEADER.writeUInt16LE(VERSION);
@@ -28,26 +28,24 @@ const READABLE_PREFIX = Buffer.from(`AVA Snapshot v${VERSION}\n`, 'ascii');
28
28
  const REPORT_SEPARATOR = Buffer.from('\n\n', 'ascii');
29
29
  const REPORT_TRAILING_NEWLINE = Buffer.from('\n', 'ascii');
30
30
 
31
- const MD5_HASH_LENGTH = 16;
31
+ const SHA_256_HASH_LENGTH = 32;
32
32
 
33
- class SnapshotError extends Error {
33
+ export class SnapshotError extends Error {
34
34
  constructor(message, snapPath) {
35
35
  super(message);
36
36
  this.name = 'SnapshotError';
37
37
  this.snapPath = snapPath;
38
38
  }
39
39
  }
40
- exports.SnapshotError = SnapshotError;
41
40
 
42
- class ChecksumError extends SnapshotError {
41
+ export class ChecksumError extends SnapshotError {
43
42
  constructor(snapPath) {
44
43
  super('Checksum mismatch', snapPath);
45
44
  this.name = 'ChecksumError';
46
45
  }
47
46
  }
48
- exports.ChecksumError = ChecksumError;
49
47
 
50
- class VersionMismatchError extends SnapshotError {
48
+ export class VersionMismatchError extends SnapshotError {
51
49
  constructor(snapPath, version) {
52
50
  super('Unexpected snapshot version', snapPath);
53
51
  this.name = 'VersionMismatchError';
@@ -55,20 +53,25 @@ class VersionMismatchError extends SnapshotError {
55
53
  this.expectedVersion = VERSION;
56
54
  }
57
55
  }
58
- exports.VersionMismatchError = VersionMismatchError;
56
+
57
+ export class InvalidSnapshotError extends SnapshotError {
58
+ constructor(snapPath) {
59
+ super('Invalid snapshot file', snapPath);
60
+ this.name = 'InvalidSnapshotError';
61
+ }
62
+ }
59
63
 
60
64
  const LEGACY_SNAPSHOT_HEADER = Buffer.from('// Jest Snapshot v1');
61
65
  function isLegacySnapshot(buffer) {
62
66
  return LEGACY_SNAPSHOT_HEADER.equals(buffer.slice(0, LEGACY_SNAPSHOT_HEADER.byteLength));
63
67
  }
64
68
 
65
- class LegacyError extends SnapshotError {
69
+ export class LegacyError extends SnapshotError {
66
70
  constructor(snapPath) {
67
71
  super('Legacy snapshot file', snapPath);
68
72
  this.name = 'LegacyError';
69
73
  }
70
74
  }
71
- exports.LegacyError = LegacyError;
72
75
 
73
76
  function tryRead(file) {
74
77
  try {
@@ -82,168 +85,117 @@ function tryRead(file) {
82
85
  }
83
86
  }
84
87
 
85
- function withoutLineEndings(buffer) {
86
- let checkPosition = buffer.byteLength - 1;
87
- while (buffer[checkPosition] === 0x0A || buffer[checkPosition] === 0x0D) {
88
- checkPosition--;
89
- }
88
+ function formatEntry(snapshot, index) {
89
+ const {
90
+ data,
91
+ label = `Snapshot ${index + 1}`, // Human-readable labels start counting at 1.
92
+ } = snapshot;
90
93
 
91
- return buffer.slice(0, checkPosition + 1);
92
- }
94
+ const description = data
95
+ ? concordance.formatDescriptor(concordance.deserialize(data), concordanceOptions)
96
+ : '<No Data>';
93
97
 
94
- function formatEntry(label, descriptor) {
95
- if (label) {
96
- label = `> ${label}\n\n`;
97
- }
98
+ const blockquote = label.split(/\n/).map(line => '> ' + line).join('\n');
98
99
 
99
- const codeBlock = indentString(concordance.formatDescriptor(descriptor, concordanceOptions), 4);
100
- return Buffer.from(label + codeBlock, 'utf8');
100
+ return `${blockquote}\n\n${indentString(description, 4)}`;
101
101
  }
102
102
 
103
- function combineEntries(entries) {
104
- const buffers = [];
105
- let byteLength = 0;
103
+ function combineEntries({blocks}) {
104
+ const combined = new BufferBuilder();
106
105
 
107
- const sortedKeys = [...entries.keys()].sort((keyA, keyB) => {
108
- const [a, b] = [entries.get(keyA), entries.get(keyB)];
109
- const taskDifference = a.taskIndex - b.taskIndex;
106
+ for (const {title, snapshots} of blocks) {
107
+ const last = snapshots[snapshots.length - 1];
108
+ combined.write(`\n\n## ${title}\n\n`);
110
109
 
111
- if (taskDifference !== 0) {
112
- return taskDifference;
113
- }
114
-
115
- const [assocA, assocB] = [a.associatedTaskIndex, b.associatedTaskIndex];
116
- if (assocA !== undefined && assocB !== undefined) {
117
- const assocDifference = assocA - assocB;
110
+ for (const [index, snapshot] of snapshots.entries()) {
111
+ combined.write(formatEntry(snapshot, index));
118
112
 
119
- if (assocDifference !== 0) {
120
- return assocDifference;
121
- }
122
- }
123
-
124
- return a.snapIndex - b.snapIndex;
125
- });
126
-
127
- for (const key of sortedKeys) {
128
- const keyBuffer = Buffer.from(`\n\n## ${key}\n\n`, 'utf8');
129
- buffers.push(keyBuffer);
130
- byteLength += keyBuffer.byteLength;
131
-
132
- const formattedEntries = entries.get(key).buffers;
133
- const last = formattedEntries[formattedEntries.length - 1];
134
- for (const entry of formattedEntries) {
135
- buffers.push(entry);
136
- byteLength += entry.byteLength;
137
-
138
- if (entry !== last) {
139
- buffers.push(REPORT_SEPARATOR);
140
- byteLength += REPORT_SEPARATOR.byteLength;
113
+ if (snapshot !== last) {
114
+ combined.write(REPORT_SEPARATOR);
141
115
  }
142
116
  }
143
117
  }
144
118
 
145
- return {buffers, byteLength};
119
+ return combined;
146
120
  }
147
121
 
148
- function generateReport(relFile, snapFile, entries) {
149
- const combined = combineEntries(entries);
150
- const {buffers} = combined;
151
- let {byteLength} = combined;
152
-
153
- const header = Buffer.from(`# Snapshot report for \`${slash(relFile)}\`
122
+ function generateReport(relFile, snapFile, snapshots) {
123
+ return new BufferBuilder()
124
+ .write(`# Snapshot report for \`${slash(relFile)}\`
154
125
 
155
126
  The actual snapshot is saved in \`${snapFile}\`.
156
127
 
157
- Generated by [AVA](https://avajs.dev).`, 'utf8');
158
- buffers.unshift(header);
159
- byteLength += header.byteLength;
160
-
161
- buffers.push(REPORT_TRAILING_NEWLINE);
162
- byteLength += REPORT_TRAILING_NEWLINE.byteLength;
163
- return Buffer.concat(buffers, byteLength);
128
+ Generated by [AVA](https://avajs.dev).`)
129
+ .append(combineEntries(snapshots))
130
+ .write(REPORT_TRAILING_NEWLINE)
131
+ .toBuffer();
164
132
  }
165
133
 
166
- function appendReportEntries(existingReport, entries) {
167
- const combined = combineEntries(entries);
168
- const {buffers} = combined;
169
- let {byteLength} = combined;
170
-
171
- const prepend = withoutLineEndings(existingReport);
172
- buffers.unshift(prepend);
173
- byteLength += prepend.byteLength;
134
+ class BufferBuilder {
135
+ constructor() {
136
+ this.buffers = [];
137
+ this.byteOffset = 0;
138
+ }
174
139
 
175
- buffers.push(REPORT_TRAILING_NEWLINE);
176
- byteLength += REPORT_TRAILING_NEWLINE.byteLength;
177
- return Buffer.concat(buffers, byteLength);
178
- }
140
+ append(builder) {
141
+ this.buffers.push(...builder.buffers);
142
+ this.byteOffset += builder.byteOffset;
143
+ return this;
144
+ }
179
145
 
180
- function encodeSnapshots(buffersByHash) {
181
- const buffers = [];
182
- let byteOffset = 0;
183
-
184
- // Entry start and end pointers are relative to the header length. This means
185
- // it's possible to append new entries to an existing snapshot file, without
186
- // having to rewrite pointers for existing entries.
187
- const headerLength = Buffer.alloc(4);
188
- buffers.push(headerLength);
189
- byteOffset += 4;
190
-
191
- // Allows 65535 hashes (tests or identified snapshots) per file.
192
- const numberHashes = Buffer.alloc(2);
193
- numberHashes.writeUInt16LE(buffersByHash.size);
194
- buffers.push(numberHashes);
195
- byteOffset += 2;
196
-
197
- const entries = [];
198
- // Maps can't have duplicate keys, so all items in [...buffersByHash.keys()]
199
- // are unique, so sortedHashes should be deterministic.
200
- const sortedHashes = [...buffersByHash.keys()].sort();
201
- const sortedBuffersByHash = [...sortedHashes.map(hash => [hash, buffersByHash.get(hash)])];
202
- for (const [hash, snapshotBuffers] of sortedBuffersByHash) {
203
- buffers.push(Buffer.from(hash, 'hex'));
204
- byteOffset += MD5_HASH_LENGTH;
205
-
206
- // Allows 65535 snapshots per hash.
207
- const numberSnapshots = Buffer.alloc(2);
208
- numberSnapshots.writeUInt16LE(snapshotBuffers.length, 0);
209
- buffers.push(numberSnapshots);
210
- byteOffset += 2;
211
-
212
- for (const value of snapshotBuffers) {
213
- // Each pointer is 32 bits, restricting the total, uncompressed buffer to
214
- // 4 GiB.
215
- const start = Buffer.alloc(4);
216
- const end = Buffer.alloc(4);
217
- entries.push({start, end, value});
218
-
219
- buffers.push(start, end);
220
- byteOffset += 8;
146
+ write(data) {
147
+ if (typeof data === 'string') {
148
+ this.write(Buffer.from(data, 'utf8'));
149
+ } else {
150
+ this.buffers.push(data);
151
+ this.byteOffset += data.byteLength;
221
152
  }
222
- }
223
153
 
224
- headerLength.writeUInt32LE(byteOffset, 0);
154
+ return this;
155
+ }
225
156
 
226
- let bodyOffset = 0;
227
- for (const entry of entries) {
228
- const start = bodyOffset;
229
- const end = bodyOffset + entry.value.byteLength;
230
- entry.start.writeUInt32LE(start, 0);
231
- entry.end.writeUInt32LE(end, 0);
232
- buffers.push(entry.value);
233
- bodyOffset = end;
157
+ toBuffer() {
158
+ return Buffer.concat(this.buffers, this.byteOffset);
234
159
  }
160
+ }
161
+
162
+ function sortBlocks(blocksByTitle, blockIndices) {
163
+ return [...blocksByTitle].sort(
164
+ ([aTitle], [bTitle]) => {
165
+ const a = blockIndices.get(aTitle);
166
+ const b = blockIndices.get(bTitle);
167
+
168
+ if (a === undefined) {
169
+ if (b === undefined) {
170
+ return 0;
171
+ }
235
172
 
236
- byteOffset += bodyOffset;
173
+ return 1;
174
+ }
175
+
176
+ if (b === undefined) {
177
+ return -1;
178
+ }
237
179
 
238
- const compressed = zlib.gzipSync(Buffer.concat(buffers, byteOffset));
180
+ return a - b;
181
+ },
182
+ );
183
+ }
184
+
185
+ function encodeSnapshots(snapshotData) {
186
+ const encoded = cbor.encodeOne(snapshotData, {
187
+ omitUndefinedProperties: true,
188
+ canonical: true,
189
+ });
190
+ const compressed = zlib.gzipSync(encoded);
239
191
  compressed[9] = 0x03; // Override the GZip header containing the OS to always be Linux
240
- const md5sum = crypto.createHash('md5').update(compressed).digest();
192
+ const sha256sum = crypto.createHash('sha256').update(compressed).digest();
241
193
  return Buffer.concat([
242
194
  READABLE_PREFIX,
243
195
  VERSION_HEADER,
244
- md5sum,
245
- compressed
246
- ], READABLE_PREFIX.byteLength + VERSION_HEADER.byteLength + MD5_HASH_LENGTH + compressed.byteLength);
196
+ sha256sum,
197
+ compressed,
198
+ ], READABLE_PREFIX.byteLength + VERSION_HEADER.byteLength + SHA_256_HASH_LENGTH + compressed.byteLength);
247
199
  }
248
200
 
249
201
  function decodeSnapshots(buffer, snapPath) {
@@ -253,182 +205,205 @@ function decodeSnapshots(buffer, snapPath) {
253
205
 
254
206
  // The version starts after the readable prefix, which is ended by a newline
255
207
  // byte (0x0A).
256
- const versionOffset = buffer.indexOf(0x0A) + 1;
208
+ const newline = buffer.indexOf(0x0A);
209
+ if (newline === -1) {
210
+ throw new InvalidSnapshotError(snapPath);
211
+ }
212
+
213
+ const versionOffset = newline + 1;
257
214
  const version = buffer.readUInt16LE(versionOffset);
258
215
  if (version !== VERSION) {
259
216
  throw new VersionMismatchError(snapPath, version);
260
217
  }
261
218
 
262
- const md5sumOffset = versionOffset + 2;
263
- const compressedOffset = md5sumOffset + MD5_HASH_LENGTH;
219
+ const sha256sumOffset = versionOffset + 2;
220
+ const compressedOffset = sha256sumOffset + SHA_256_HASH_LENGTH;
264
221
  const compressed = buffer.slice(compressedOffset);
265
222
 
266
- const md5sum = crypto.createHash('md5').update(compressed).digest();
267
- const expectedSum = buffer.slice(md5sumOffset, compressedOffset);
268
- if (!md5sum.equals(expectedSum)) {
223
+ const sha256sum = crypto.createHash('sha256').update(compressed).digest();
224
+ const expectedSum = buffer.slice(sha256sumOffset, compressedOffset);
225
+ if (!sha256sum.equals(expectedSum)) {
269
226
  throw new ChecksumError(snapPath);
270
227
  }
271
228
 
272
229
  const decompressed = zlib.gunzipSync(compressed);
273
- let byteOffset = 0;
274
-
275
- const headerLength = decompressed.readUInt32LE(byteOffset);
276
- byteOffset += 4;
277
-
278
- const snapshotsByHash = new Map();
279
- const numberHashes = decompressed.readUInt16LE(byteOffset);
280
- byteOffset += 2;
281
-
282
- for (let count = 0; count < numberHashes; count++) {
283
- const hash = decompressed.toString('hex', byteOffset, byteOffset + MD5_HASH_LENGTH);
284
- byteOffset += MD5_HASH_LENGTH;
285
-
286
- const numberSnapshots = decompressed.readUInt16LE(byteOffset);
287
- byteOffset += 2;
288
-
289
- const snapshotsBuffers = new Array(numberSnapshots);
290
- for (let index = 0; index < numberSnapshots; index++) {
291
- const start = decompressed.readUInt32LE(byteOffset) + headerLength;
292
- byteOffset += 4;
293
- const end = decompressed.readUInt32LE(byteOffset) + headerLength;
294
- byteOffset += 4;
295
- snapshotsBuffers[index] = decompressed.slice(start, end);
296
- }
297
-
298
- // Allow for new entries to be appended to an existing header, which could
299
- // lead to the same hash being present multiple times.
300
- if (snapshotsByHash.has(hash)) {
301
- snapshotsByHash.set(hash, snapshotsByHash.get(hash).concat(snapshotsBuffers));
302
- } else {
303
- snapshotsByHash.set(hash, snapshotsBuffers);
304
- }
305
- }
306
-
307
- return snapshotsByHash;
230
+ return cbor.decode(decompressed);
308
231
  }
309
232
 
310
233
  class Manager {
311
234
  constructor(options) {
312
- this.appendOnly = options.appendOnly;
313
235
  this.dir = options.dir;
314
236
  this.recordNewSnapshots = options.recordNewSnapshots;
237
+ this.updating = options.updating;
315
238
  this.relFile = options.relFile;
316
239
  this.reportFile = options.reportFile;
240
+ this.reportPath = options.reportPath;
317
241
  this.snapFile = options.snapFile;
318
242
  this.snapPath = options.snapPath;
319
- this.snapshotsByHash = options.snapshotsByHash;
243
+ this.oldBlocksByTitle = options.oldBlocksByTitle;
244
+ this.newBlocksByTitle = options.newBlocksByTitle;
245
+ this.blockIndices = new Map();
246
+ this.error = options.error;
320
247
 
321
248
  this.hasChanges = false;
322
- this.reportEntries = new Map();
249
+ }
250
+
251
+ touch(title, taskIndex) {
252
+ this.blockIndices.set(title, taskIndex);
323
253
  }
324
254
 
325
255
  compare(options) {
326
- const hash = md5Hex(options.belongsTo);
327
- const entries = this.snapshotsByHash.get(hash) || [];
328
- const snapshotBuffer = entries[options.index];
256
+ if (this.error) {
257
+ throw this.error;
258
+ }
329
259
 
330
- if (!snapshotBuffer) {
260
+ const block = this.newBlocksByTitle.get(options.belongsTo);
261
+
262
+ const snapshot = block && block.snapshots[options.index];
263
+ const data = snapshot && snapshot.data;
264
+
265
+ if (!data) {
331
266
  if (!this.recordNewSnapshots) {
332
267
  return {pass: false};
333
268
  }
334
269
 
335
270
  if (options.deferRecording) {
336
- const record = this.deferRecord(hash, options);
271
+ const record = this.deferRecord(options);
337
272
  return {pass: true, record};
338
273
  }
339
274
 
340
- this.record(hash, options);
275
+ this.record(options);
341
276
  return {pass: true};
342
277
  }
343
278
 
344
- const actual = concordance.deserialize(snapshotBuffer, concordanceOptions);
279
+ const actual = concordance.deserialize(data, concordanceOptions);
345
280
  const expected = concordance.describe(options.expected, concordanceOptions);
346
281
  const pass = concordance.compareDescriptors(actual, expected);
347
282
 
348
283
  return {actual, expected, pass};
349
284
  }
350
285
 
351
- deferRecord(hash, options) {
352
- const descriptor = concordance.describe(options.expected, concordanceOptions);
353
- const snapshot = concordance.serialize(descriptor);
354
- const entry = formatEntry(options.label, descriptor);
355
- const {taskIndex, snapIndex, associatedTaskIndex} = options;
286
+ recordSerialized({data, label, belongsTo, index}) {
287
+ let block = this.newBlocksByTitle.get(belongsTo);
288
+ if (!block) {
289
+ block = {snapshots: []};
290
+ }
356
291
 
357
- return () => { // Must be called in order!
358
- this.hasChanges = true;
292
+ const {snapshots} = block;
359
293
 
360
- let snapshots = this.snapshotsByHash.get(hash);
361
- if (!snapshots) {
362
- snapshots = [];
363
- this.snapshotsByHash.set(hash, snapshots);
294
+ if (index > snapshots.length) {
295
+ throw new RangeError(`Cannot record snapshot ${index} for ${JSON.stringify(belongsTo)}, exceeds expected index of ${snapshots.length}`);
296
+ } else if (index < snapshots.length) {
297
+ if (snapshots[index].data) {
298
+ throw new RangeError(`Cannot record snapshot ${index} for ${JSON.stringify(belongsTo)}, already exists`);
364
299
  }
365
300
 
366
- if (options.index > snapshots.length) {
367
- throw new RangeError(`Cannot record snapshot ${options.index} for ${JSON.stringify(options.belongsTo)}, exceeds expected index of ${snapshots.length}`);
368
- }
301
+ snapshots[index] = {data, label};
302
+ } else {
303
+ snapshots.push({data, label});
304
+ }
369
305
 
370
- if (options.index < snapshots.length) {
371
- throw new RangeError(`Cannot record snapshot ${options.index} for ${JSON.stringify(options.belongsTo)}, already exists`);
372
- }
306
+ this.newBlocksByTitle.set(belongsTo, block);
307
+ }
373
308
 
374
- snapshots.push(snapshot);
309
+ deferRecord(options) {
310
+ const {expected, belongsTo, label, index} = options;
311
+ const descriptor = concordance.describe(expected, concordanceOptions);
312
+ const data = concordance.serialize(descriptor);
375
313
 
376
- if (this.reportEntries.has(options.belongsTo)) {
377
- this.reportEntries.get(options.belongsTo).buffers.push(entry);
378
- } else {
379
- this.reportEntries.set(options.belongsTo, {buffers: [entry], taskIndex, snapIndex, associatedTaskIndex});
380
- }
314
+ return () => { // Must be called in order!
315
+ this.hasChanges = true;
316
+ this.recordSerialized({data, label, belongsTo, index});
381
317
  };
382
318
  }
383
319
 
384
- record(hash, options) {
385
- const record = this.deferRecord(hash, options);
320
+ record(options) {
321
+ const record = this.deferRecord(options);
386
322
  record();
387
323
  }
388
324
 
325
+ skipBlock(title) {
326
+ const block = this.oldBlocksByTitle.get(title);
327
+
328
+ if (block) {
329
+ this.newBlocksByTitle.set(title, block);
330
+ }
331
+ }
332
+
333
+ skipSnapshot({belongsTo, index, deferRecording}) {
334
+ const oldBlock = this.oldBlocksByTitle.get(belongsTo);
335
+ let snapshot = oldBlock && oldBlock.snapshots[index];
336
+
337
+ if (!snapshot) {
338
+ snapshot = {};
339
+ }
340
+
341
+ // Retain the label from the old snapshot, so as not to assume that the
342
+ // snapshot.skip() arguments are well-formed.
343
+
344
+ // Defer recording if called in a try().
345
+ if (deferRecording) {
346
+ return () => { // Must be called in order!
347
+ this.recordSerialized({belongsTo, index, ...snapshot});
348
+ };
349
+ }
350
+
351
+ this.recordSerialized({belongsTo, index, ...snapshot});
352
+ }
353
+
389
354
  save() {
355
+ const {dir, relFile, snapFile, snapPath, reportPath} = this;
356
+
357
+ if (this.updating && this.newBlocksByTitle.size === 0) {
358
+ return {
359
+ changedFiles: [cleanFile(snapPath), cleanFile(reportPath)].flat(),
360
+ temporaryFiles: [],
361
+ };
362
+ }
363
+
390
364
  if (!this.hasChanges) {
391
365
  return null;
392
366
  }
393
367
 
394
- const {snapPath} = this;
395
- const buffer = encodeSnapshots(this.snapshotsByHash);
368
+ const snapshots = {
369
+ blocks: sortBlocks(this.newBlocksByTitle, this.blockIndices).map(
370
+ ([title, block]) => ({title, ...block}),
371
+ ),
372
+ };
396
373
 
397
- const reportPath = path.join(this.dir, this.reportFile);
398
- const existingReport = this.appendOnly ? tryRead(reportPath) : null;
399
- const reportBuffer = existingReport ?
400
- appendReportEntries(existingReport, this.reportEntries) :
401
- generateReport(this.relFile, this.snapFile, this.reportEntries);
374
+ const buffer = encodeSnapshots(snapshots);
375
+ const reportBuffer = generateReport(relFile, snapFile, snapshots);
402
376
 
403
- fs.mkdirSync(this.dir, {recursive: true});
377
+ fs.mkdirSync(dir, {recursive: true});
404
378
 
405
- const paths = [snapPath, reportPath];
406
- const tmpfileCreated = tmpfile => paths.push(tmpfile);
379
+ const temporaryFiles = [];
380
+ const tmpfileCreated = file => temporaryFiles.push(file);
407
381
  writeFileAtomic.sync(snapPath, buffer, {tmpfileCreated});
408
382
  writeFileAtomic.sync(reportPath, reportBuffer, {tmpfileCreated});
409
- return paths;
383
+ return {
384
+ changedFiles: [snapPath, reportPath],
385
+ temporaryFiles,
386
+ };
410
387
  }
411
388
  }
412
389
 
413
390
  const resolveSourceFile = mem(file => {
414
- const testDir = path.dirname(file);
415
- const buffer = tryRead(file);
416
- if (!buffer) {
417
- return file; // Assume the file is stubbed in our test suite.
391
+ const sourceMap = findSourceMap(file);
392
+ if (sourceMap === undefined) {
393
+ return file;
418
394
  }
419
395
 
420
- const source = buffer.toString();
421
- const converter = convertSourceMap.fromSource(source) || convertSourceMap.fromMapFileSource(source, testDir);
422
- if (converter) {
423
- const map = converter.toObject();
424
- const firstSource = `${map.sourceRoot || ''}${map.sources[0]}`;
425
- return path.resolve(testDir, firstSource);
396
+ const {payload} = sourceMap;
397
+ if (payload.sources.length === 0) { // Hypothetical?
398
+ return file;
426
399
  }
427
400
 
428
- return file;
401
+ return payload.sources[0].startsWith('file://')
402
+ ? fileURLToPath(payload.sources[0])
403
+ : payload.sources[0];
429
404
  });
430
405
 
431
- const determineSnapshotDir = mem(({file, fixedLocation, projectDir}) => {
406
+ export const determineSnapshotDir = mem(({file, fixedLocation, projectDir}) => {
432
407
  const testDir = path.dirname(resolveSourceFile(file));
433
408
  if (fixedLocation) {
434
409
  const relativeTestLocation = path.relative(projectDir, testDir);
@@ -447,8 +422,6 @@ const determineSnapshotDir = mem(({file, fixedLocation, projectDir}) => {
447
422
  return testDir;
448
423
  }, {cacheKey: ([{file}]) => file});
449
424
 
450
- exports.determineSnapshotDir = determineSnapshotDir;
451
-
452
425
  function determineSnapshotPaths({file, fixedLocation, projectDir}) {
453
426
  const dir = determineSnapshotDir({file, fixedLocation, projectDir});
454
427
  const relFile = path.relative(projectDir, resolveSourceFile(file));
@@ -460,7 +433,9 @@ function determineSnapshotPaths({file, fixedLocation, projectDir}) {
460
433
  dir,
461
434
  relFile,
462
435
  snapFile,
463
- reportFile
436
+ reportFile,
437
+ snapPath: path.join(dir, snapFile),
438
+ reportPath: path.join(dir, reportFile),
464
439
  };
465
440
  }
466
441
 
@@ -477,45 +452,52 @@ function cleanFile(file) {
477
452
  }
478
453
  }
479
454
 
480
- // Remove snapshot and report if they exist. Returns an array containing the
481
- // paths of the touched files.
482
- function cleanSnapshots({file, fixedLocation, projectDir}) {
483
- const {dir, snapFile, reportFile} = determineSnapshotPaths({file, fixedLocation, projectDir});
455
+ export function load({file, fixedLocation, projectDir, recordNewSnapshots, updating}) {
456
+ // Keep runner unit tests that use `new Runner()` happy
457
+ if (file === undefined || projectDir === undefined) {
458
+ return new Manager({
459
+ recordNewSnapshots,
460
+ updating,
461
+ oldBlocksByTitle: new Map(),
462
+ newBlocksByTitle: new Map(),
463
+ });
464
+ }
484
465
 
485
- return [
486
- ...cleanFile(path.join(dir, snapFile)),
487
- ...cleanFile(path.join(dir, reportFile))
488
- ];
489
- }
466
+ const paths = determineSnapshotPaths({file, fixedLocation, projectDir});
467
+ const buffer = tryRead(paths.snapPath);
490
468
 
491
- exports.cleanSnapshots = cleanSnapshots;
469
+ if (!buffer) {
470
+ return new Manager({
471
+ recordNewSnapshots,
472
+ updating,
473
+ ...paths,
474
+ oldBlocksByTitle: new Map(),
475
+ newBlocksByTitle: new Map(),
476
+ });
477
+ }
492
478
 
493
- function load({file, fixedLocation, projectDir, recordNewSnapshots, updating}) {
494
- const {dir, relFile, snapFile, reportFile} = determineSnapshotPaths({file, fixedLocation, projectDir});
495
- const snapPath = path.join(dir, snapFile);
479
+ let blocksByTitle;
480
+ let snapshotError;
496
481
 
497
- let appendOnly = !updating;
498
- let snapshotsByHash;
482
+ try {
483
+ const data = decodeSnapshots(buffer, paths.snapPath);
484
+ blocksByTitle = new Map(data.blocks.map(({title, ...block}) => [title, block]));
485
+ } catch (error) {
486
+ blocksByTitle = new Map();
499
487
 
500
- if (!updating) {
501
- const buffer = tryRead(snapPath);
502
- if (buffer) {
503
- snapshotsByHash = decodeSnapshots(buffer, snapPath);
504
- } else {
505
- appendOnly = false;
488
+ if (!updating) { // Discard all decoding errors when updating snapshots
489
+ snapshotError = error instanceof SnapshotError
490
+ ? error
491
+ : new InvalidSnapshotError(paths.snapPath);
506
492
  }
507
493
  }
508
494
 
509
495
  return new Manager({
510
- appendOnly,
511
- dir,
512
496
  recordNewSnapshots,
513
- relFile,
514
- reportFile,
515
- snapFile,
516
- snapPath,
517
- snapshotsByHash: snapshotsByHash || new Map()
497
+ updating,
498
+ ...paths,
499
+ oldBlocksByTitle: blocksByTitle,
500
+ newBlocksByTitle: updating ? new Map() : blocksByTitle,
501
+ error: snapshotError,
518
502
  });
519
503
  }
520
-
521
- exports.load = load;