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 makeDir = require('make-dir');
|
11 | const md5Hex = require('md5-hex');
|
12 | const convertSourceMap = require('convert-source-map');
|
13 | const slash = require('slash');
|
14 | const writeFileAtomic = require('write-file-atomic');
|
15 |
|
16 | const concordanceOptions = require('./concordance-options').snapshotManager;
|
17 |
|
18 |
|
19 |
|
20 |
|
21 | const VERSION = 2;
|
22 |
|
23 | const VERSION_HEADER = Buffer.alloc(2);
|
24 | VERSION_HEADER.writeUInt16LE(VERSION);
|
25 |
|
26 |
|
27 | const READABLE_PREFIX = Buffer.from(`AVA Snapshot v${VERSION}\n`, 'ascii');
|
28 | const REPORT_SEPARATOR = Buffer.from('\n\n', 'ascii');
|
29 | const REPORT_TRAILING_NEWLINE = Buffer.from('\n', 'ascii');
|
30 |
|
31 | const MD5_HASH_LENGTH = 16;
|
32 |
|
33 | class SnapshotError extends Error {
|
34 | constructor(message, snapPath) {
|
35 | super(message);
|
36 | this.name = 'SnapshotError';
|
37 | this.snapPath = snapPath;
|
38 | }
|
39 | }
|
40 | exports.SnapshotError = SnapshotError;
|
41 |
|
42 | class ChecksumError extends SnapshotError {
|
43 | constructor(snapPath) {
|
44 | super('Checksum mismatch', snapPath);
|
45 | this.name = 'ChecksumError';
|
46 | }
|
47 | }
|
48 | exports.ChecksumError = ChecksumError;
|
49 |
|
50 | class VersionMismatchError extends SnapshotError {
|
51 | constructor(snapPath, version) {
|
52 | super('Unexpected snapshot version', snapPath);
|
53 | this.name = 'VersionMismatchError';
|
54 | this.snapVersion = version;
|
55 | this.expectedVersion = VERSION;
|
56 | }
|
57 | }
|
58 | exports.VersionMismatchError = VersionMismatchError;
|
59 |
|
60 | const LEGACY_SNAPSHOT_HEADER = Buffer.from('// Jest Snapshot v1');
|
61 | function isLegacySnapshot(buffer) {
|
62 | return LEGACY_SNAPSHOT_HEADER.equals(buffer.slice(0, LEGACY_SNAPSHOT_HEADER.byteLength));
|
63 | }
|
64 |
|
65 | class LegacyError extends SnapshotError {
|
66 | constructor(snapPath) {
|
67 | super('Legacy snapshot file', snapPath);
|
68 | this.name = 'LegacyError';
|
69 | }
|
70 | }
|
71 | exports.LegacyError = LegacyError;
|
72 |
|
73 | function tryRead(file) {
|
74 | try {
|
75 | return fs.readFileSync(file);
|
76 | } catch (error) {
|
77 | if (error.code === 'ENOENT') {
|
78 | return null;
|
79 | }
|
80 |
|
81 | throw error;
|
82 | }
|
83 | }
|
84 |
|
85 | function withoutLineEndings(buffer) {
|
86 | let checkPosition = buffer.byteLength - 1;
|
87 | while (buffer[checkPosition] === 0x0A || buffer[checkPosition] === 0x0D) {
|
88 | checkPosition--;
|
89 | }
|
90 |
|
91 | return buffer.slice(0, checkPosition + 1);
|
92 | }
|
93 |
|
94 | function formatEntry(label, descriptor) {
|
95 | if (label) {
|
96 | label = `> ${label}\n\n`;
|
97 | }
|
98 |
|
99 | const codeBlock = indentString(concordance.formatDescriptor(descriptor, concordanceOptions), 4);
|
100 | return Buffer.from(label + codeBlock, 'utf8');
|
101 | }
|
102 |
|
103 | function combineEntries(entries) {
|
104 | const buffers = [];
|
105 | let byteLength = 0;
|
106 |
|
107 | const sortedKeys = [...entries.keys()].sort();
|
108 | for (const key of sortedKeys) {
|
109 | const keyBuffer = Buffer.from(`\n\n## ${key}\n\n`, 'utf8');
|
110 | buffers.push(keyBuffer);
|
111 | byteLength += keyBuffer.byteLength;
|
112 |
|
113 | const formattedEntries = entries.get(key);
|
114 | const last = formattedEntries[formattedEntries.length - 1];
|
115 | for (const entry of formattedEntries) {
|
116 | buffers.push(entry);
|
117 | byteLength += entry.byteLength;
|
118 |
|
119 | if (entry !== last) {
|
120 | buffers.push(REPORT_SEPARATOR);
|
121 | byteLength += REPORT_SEPARATOR.byteLength;
|
122 | }
|
123 | }
|
124 | }
|
125 |
|
126 | return {buffers, byteLength};
|
127 | }
|
128 |
|
129 | function generateReport(relFile, snapFile, entries) {
|
130 | const combined = combineEntries(entries);
|
131 | const {buffers} = combined;
|
132 | let {byteLength} = combined;
|
133 |
|
134 | const header = Buffer.from(`# Snapshot report for \`${slash(relFile)}\`
|
135 |
|
136 | The actual snapshot is saved in \`${snapFile}\`.
|
137 |
|
138 | Generated by [AVA](https://ava.li).`, 'utf8');
|
139 | buffers.unshift(header);
|
140 | byteLength += header.byteLength;
|
141 |
|
142 | buffers.push(REPORT_TRAILING_NEWLINE);
|
143 | byteLength += REPORT_TRAILING_NEWLINE.byteLength;
|
144 | return Buffer.concat(buffers, byteLength);
|
145 | }
|
146 |
|
147 | function appendReportEntries(existingReport, entries) {
|
148 | const combined = combineEntries(entries);
|
149 | const {buffers} = combined;
|
150 | let {byteLength} = combined;
|
151 |
|
152 | const prepend = withoutLineEndings(existingReport);
|
153 | buffers.unshift(prepend);
|
154 | byteLength += prepend.byteLength;
|
155 |
|
156 | buffers.push(REPORT_TRAILING_NEWLINE);
|
157 | byteLength += REPORT_TRAILING_NEWLINE.byteLength;
|
158 | return Buffer.concat(buffers, byteLength);
|
159 | }
|
160 |
|
161 | function encodeSnapshots(buffersByHash) {
|
162 | const buffers = [];
|
163 | let byteOffset = 0;
|
164 |
|
165 |
|
166 |
|
167 |
|
168 | const headerLength = Buffer.alloc(4);
|
169 | buffers.push(headerLength);
|
170 | byteOffset += 4;
|
171 |
|
172 |
|
173 | const numHashes = Buffer.alloc(2);
|
174 | numHashes.writeUInt16LE(buffersByHash.size);
|
175 | buffers.push(numHashes);
|
176 | byteOffset += 2;
|
177 |
|
178 | const entries = [];
|
179 | for (const pair of buffersByHash) {
|
180 | const hash = pair[0];
|
181 | const snapshotBuffers = pair[1];
|
182 |
|
183 | buffers.push(Buffer.from(hash, 'hex'));
|
184 | byteOffset += MD5_HASH_LENGTH;
|
185 |
|
186 |
|
187 | const numSnapshots = Buffer.alloc(2);
|
188 | numSnapshots.writeUInt16LE(snapshotBuffers.length, 0);
|
189 | buffers.push(numSnapshots);
|
190 | byteOffset += 2;
|
191 |
|
192 | for (const value of snapshotBuffers) {
|
193 |
|
194 |
|
195 | const start = Buffer.alloc(4);
|
196 | const end = Buffer.alloc(4);
|
197 | entries.push({start, end, value});
|
198 |
|
199 | buffers.push(start, end);
|
200 | byteOffset += 8;
|
201 | }
|
202 | }
|
203 |
|
204 | headerLength.writeUInt32LE(byteOffset, 0);
|
205 |
|
206 | let bodyOffset = 0;
|
207 | for (const entry of entries) {
|
208 | const start = bodyOffset;
|
209 | const end = bodyOffset + entry.value.byteLength;
|
210 | entry.start.writeUInt32LE(start, 0);
|
211 | entry.end.writeUInt32LE(end, 0);
|
212 | buffers.push(entry.value);
|
213 | bodyOffset = end;
|
214 | }
|
215 |
|
216 | byteOffset += bodyOffset;
|
217 |
|
218 | const compressed = zlib.gzipSync(Buffer.concat(buffers, byteOffset));
|
219 | compressed[9] = 0x03;
|
220 | const md5sum = crypto.createHash('md5').update(compressed).digest();
|
221 | return Buffer.concat([
|
222 | READABLE_PREFIX,
|
223 | VERSION_HEADER,
|
224 | md5sum,
|
225 | compressed
|
226 | ], READABLE_PREFIX.byteLength + VERSION_HEADER.byteLength + MD5_HASH_LENGTH + compressed.byteLength);
|
227 | }
|
228 |
|
229 | function decodeSnapshots(buffer, snapPath) {
|
230 | if (isLegacySnapshot(buffer)) {
|
231 | throw new LegacyError(snapPath);
|
232 | }
|
233 |
|
234 |
|
235 |
|
236 | const versionOffset = buffer.indexOf(0x0A) + 1;
|
237 | const version = buffer.readUInt16LE(versionOffset);
|
238 | if (version !== VERSION) {
|
239 | throw new VersionMismatchError(snapPath, version);
|
240 | }
|
241 |
|
242 | const md5sumOffset = versionOffset + 2;
|
243 | const compressedOffset = md5sumOffset + MD5_HASH_LENGTH;
|
244 | const compressed = buffer.slice(compressedOffset);
|
245 |
|
246 | const md5sum = crypto.createHash('md5').update(compressed).digest();
|
247 | const expectedSum = buffer.slice(md5sumOffset, compressedOffset);
|
248 | if (!md5sum.equals(expectedSum)) {
|
249 | throw new ChecksumError(snapPath);
|
250 | }
|
251 |
|
252 | const decompressed = zlib.gunzipSync(compressed);
|
253 | let byteOffset = 0;
|
254 |
|
255 | const headerLength = decompressed.readUInt32LE(byteOffset);
|
256 | byteOffset += 4;
|
257 |
|
258 | const snapshotsByHash = new Map();
|
259 | const numHashes = decompressed.readUInt16LE(byteOffset);
|
260 | byteOffset += 2;
|
261 |
|
262 | for (let count = 0; count < numHashes; count++) {
|
263 | const hash = decompressed.toString('hex', byteOffset, byteOffset + MD5_HASH_LENGTH);
|
264 | byteOffset += MD5_HASH_LENGTH;
|
265 |
|
266 | const numSnapshots = decompressed.readUInt16LE(byteOffset);
|
267 | byteOffset += 2;
|
268 |
|
269 | const snapshotsBuffers = new Array(numSnapshots);
|
270 | for (let index = 0; index < numSnapshots; index++) {
|
271 | const start = decompressed.readUInt32LE(byteOffset) + headerLength;
|
272 | byteOffset += 4;
|
273 | const end = decompressed.readUInt32LE(byteOffset) + headerLength;
|
274 | byteOffset += 4;
|
275 | snapshotsBuffers[index] = decompressed.slice(start, end);
|
276 | }
|
277 |
|
278 |
|
279 |
|
280 | if (snapshotsByHash.has(hash)) {
|
281 | snapshotsByHash.set(hash, snapshotsByHash.get(hash).concat(snapshotsBuffers));
|
282 | } else {
|
283 | snapshotsByHash.set(hash, snapshotsBuffers);
|
284 | }
|
285 | }
|
286 |
|
287 | return snapshotsByHash;
|
288 | }
|
289 |
|
290 | class Manager {
|
291 | constructor(options) {
|
292 | this.appendOnly = options.appendOnly;
|
293 | this.dir = options.dir;
|
294 | this.recordNewSnapshots = options.recordNewSnapshots;
|
295 | this.relFile = options.relFile;
|
296 | this.reportFile = options.reportFile;
|
297 | this.snapFile = options.snapFile;
|
298 | this.snapPath = options.snapPath;
|
299 | this.snapshotsByHash = options.snapshotsByHash;
|
300 |
|
301 | this.hasChanges = false;
|
302 | this.reportEntries = new Map();
|
303 | }
|
304 |
|
305 | compare(options) {
|
306 | const hash = md5Hex(options.belongsTo);
|
307 | const entries = this.snapshotsByHash.get(hash) || [];
|
308 | if (options.index > entries.length) {
|
309 | throw new RangeError(`Cannot record snapshot ${options.index} for ${JSON.stringify(options.belongsTo)}, exceeds expected index of ${entries.length}`);
|
310 | }
|
311 |
|
312 | if (options.index === entries.length) {
|
313 | if (!this.recordNewSnapshots) {
|
314 | return {pass: false};
|
315 | }
|
316 |
|
317 | this.record(hash, options);
|
318 | return {pass: true};
|
319 | }
|
320 |
|
321 | const snapshotBuffer = entries[options.index];
|
322 | const actual = concordance.deserialize(snapshotBuffer, concordanceOptions);
|
323 |
|
324 | const expected = concordance.describe(options.expected, concordanceOptions);
|
325 | const pass = concordance.compareDescriptors(actual, expected);
|
326 |
|
327 | return {actual, expected, pass};
|
328 | }
|
329 |
|
330 | record(hash, options) {
|
331 | const descriptor = concordance.describe(options.expected, concordanceOptions);
|
332 |
|
333 | this.hasChanges = true;
|
334 | const snapshot = concordance.serialize(descriptor);
|
335 | if (this.snapshotsByHash.has(hash)) {
|
336 | this.snapshotsByHash.get(hash).push(snapshot);
|
337 | } else {
|
338 | this.snapshotsByHash.set(hash, [snapshot]);
|
339 | }
|
340 |
|
341 | const entry = formatEntry(options.label, descriptor);
|
342 | if (this.reportEntries.has(options.belongsTo)) {
|
343 | this.reportEntries.get(options.belongsTo).push(entry);
|
344 | } else {
|
345 | this.reportEntries.set(options.belongsTo, [entry]);
|
346 | }
|
347 | }
|
348 |
|
349 | save() {
|
350 | if (!this.hasChanges) {
|
351 | return null;
|
352 | }
|
353 |
|
354 | const {snapPath} = this;
|
355 | const buffer = encodeSnapshots(this.snapshotsByHash);
|
356 |
|
357 | const reportPath = path.join(this.dir, this.reportFile);
|
358 | const existingReport = this.appendOnly ? tryRead(reportPath) : null;
|
359 | const reportBuffer = existingReport ?
|
360 | appendReportEntries(existingReport, this.reportEntries) :
|
361 | generateReport(this.relFile, this.snapFile, this.reportEntries);
|
362 |
|
363 | makeDir.sync(this.dir);
|
364 |
|
365 | const paths = [snapPath, reportPath];
|
366 | const tmpfileCreated = tmpfile => paths.push(tmpfile);
|
367 | writeFileAtomic.sync(snapPath, buffer, {tmpfileCreated});
|
368 | writeFileAtomic.sync(reportPath, reportBuffer, {tmpfileCreated});
|
369 | return paths;
|
370 | }
|
371 | }
|
372 |
|
373 | function determineSnapshotDir({file, fixedLocation, projectDir}) {
|
374 | const testDir = path.dirname(file);
|
375 | if (fixedLocation) {
|
376 | const relativeTestLocation = path.relative(projectDir, testDir);
|
377 | return path.join(fixedLocation, relativeTestLocation);
|
378 | }
|
379 |
|
380 | const parts = new Set(path.relative(projectDir, testDir).split(path.sep));
|
381 | if (parts.has('__tests__')) {
|
382 | return path.join(testDir, '__snapshots__');
|
383 | }
|
384 |
|
385 | if (parts.has('test') || parts.has('tests')) {
|
386 | return path.join(testDir, 'snapshots');
|
387 | }
|
388 |
|
389 | return testDir;
|
390 | }
|
391 |
|
392 | function resolveSourceFile(file) {
|
393 | const testDir = path.dirname(file);
|
394 | const buffer = tryRead(file);
|
395 | if (!buffer) {
|
396 | return file;
|
397 | }
|
398 |
|
399 | const source = buffer.toString();
|
400 | const converter = convertSourceMap.fromSource(source) || convertSourceMap.fromMapFileSource(source, testDir);
|
401 | if (converter) {
|
402 | const map = converter.toObject();
|
403 | const firstSource = `${map.sourceRoot || ''}${map.sources[0]}`;
|
404 | return path.resolve(testDir, firstSource);
|
405 | }
|
406 |
|
407 | return file;
|
408 | }
|
409 |
|
410 | function load({file, fixedLocation, projectDir, recordNewSnapshots, updating}) {
|
411 | const sourceFile = resolveSourceFile(file);
|
412 | const dir = determineSnapshotDir({file: sourceFile, fixedLocation, projectDir});
|
413 | const relFile = path.relative(projectDir, sourceFile);
|
414 | const name = path.basename(relFile);
|
415 | const reportFile = `${name}.md`;
|
416 | const snapFile = `${name}.snap`;
|
417 | const snapPath = path.join(dir, snapFile);
|
418 |
|
419 | let appendOnly = !updating;
|
420 | let snapshotsByHash;
|
421 |
|
422 | if (!updating) {
|
423 | const buffer = tryRead(snapPath);
|
424 | if (buffer) {
|
425 | snapshotsByHash = decodeSnapshots(buffer, snapPath);
|
426 | } else {
|
427 | appendOnly = false;
|
428 | }
|
429 | }
|
430 |
|
431 | return new Manager({
|
432 | appendOnly,
|
433 | dir,
|
434 | recordNewSnapshots,
|
435 | relFile,
|
436 | reportFile,
|
437 | snapFile,
|
438 | snapPath,
|
439 | snapshotsByHash: snapshotsByHash || new Map()
|
440 | });
|
441 | }
|
442 |
|
443 | exports.load = load;
|