aboutsummaryrefslogtreecommitdiff
path: root/node_modules/ava/lib/snapshot-manager.js
diff options
context:
space:
mode:
authorFlorian Dold <florian.dold@gmail.com>2017-08-14 05:01:11 +0200
committerFlorian Dold <florian.dold@gmail.com>2017-08-14 05:02:09 +0200
commit363723fc84f7b8477592e0105aeb331ec9a017af (patch)
tree29f92724f34131bac64d6a318dd7e30612e631c7 /node_modules/ava/lib/snapshot-manager.js
parent5634e77ad96bfe1818f6b6ee70b7379652e5487f (diff)
node_modules
Diffstat (limited to 'node_modules/ava/lib/snapshot-manager.js')
-rw-r--r--node_modules/ava/lib/snapshot-manager.js396
1 files changed, 396 insertions, 0 deletions
diff --git a/node_modules/ava/lib/snapshot-manager.js b/node_modules/ava/lib/snapshot-manager.js
new file mode 100644
index 000000000..ea1246585
--- /dev/null
+++ b/node_modules/ava/lib/snapshot-manager.js
@@ -0,0 +1,396 @@
+'use strict';
+
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const zlib = require('zlib');
+
+const writeFileAtomic = require('@ava/write-file-atomic');
+const concordance = require('concordance');
+const indentString = require('indent-string');
+const makeDir = require('make-dir');
+const md5Hex = require('md5-hex');
+const Buffer = require('safe-buffer').Buffer;
+
+const concordanceOptions = require('./concordance-options').snapshotManager;
+
+// Increment if encoding layout or Concordance serialization versions change. Previous AVA versions will not be able to
+// decode buffers generated by a newer version, so changing this value will require a major version bump of AVA itself.
+// The version is encoded as an unsigned 16 bit integer.
+const VERSION = 1;
+
+const VERSION_HEADER = Buffer.alloc(2);
+VERSION_HEADER.writeUInt16LE(VERSION);
+
+// The decoder matches on the trailing newline byte (0x0A).
+const READABLE_PREFIX = Buffer.from(`AVA Snapshot v${VERSION}\n`, 'ascii');
+const REPORT_SEPARATOR = Buffer.from('\n\n', 'ascii');
+const REPORT_TRAILING_NEWLINE = Buffer.from('\n', 'ascii');
+
+const MD5_HASH_LENGTH = 16;
+
+class SnapshotError extends Error {
+ constructor(message, snapPath) {
+ super(message);
+ this.name = 'SnapshotError';
+ this.snapPath = snapPath;
+ }
+}
+exports.SnapshotError = SnapshotError;
+
+class ChecksumError extends SnapshotError {
+ constructor(snapPath) {
+ super('Checksum mismatch', snapPath);
+ this.name = 'ChecksumError';
+ }
+}
+exports.ChecksumError = ChecksumError;
+
+class VersionMismatchError extends SnapshotError {
+ constructor(snapPath, version) {
+ super('Unexpected snapshot version', snapPath);
+ this.name = 'VersionMismatchError';
+ this.snapVersion = version;
+ this.expectedVersion = VERSION;
+ }
+}
+exports.VersionMismatchError = VersionMismatchError;
+
+const LEGACY_SNAPSHOT_HEADER = Buffer.from('// Jest Snapshot v1');
+function isLegacySnapshot(buffer) {
+ return LEGACY_SNAPSHOT_HEADER.equals(buffer.slice(0, LEGACY_SNAPSHOT_HEADER.byteLength));
+}
+
+class LegacyError extends SnapshotError {
+ constructor(snapPath) {
+ super('Legacy snapshot file', snapPath);
+ this.name = 'LegacyError';
+ }
+}
+exports.LegacyError = LegacyError;
+
+function tryRead(file) {
+ try {
+ return fs.readFileSync(file);
+ } catch (err) {
+ if (err.code === 'ENOENT') {
+ return null;
+ }
+
+ throw err;
+ }
+}
+
+function withoutLineEndings(buffer) {
+ let newLength = buffer.byteLength - 1;
+ while (buffer[newLength] === 0x0A || buffer[newLength] === 0x0D) {
+ newLength--;
+ }
+ return buffer.slice(0, newLength);
+}
+
+function formatEntry(label, descriptor) {
+ if (label) {
+ label = `> ${label}\n\n`;
+ }
+ const codeBlock = indentString(concordance.formatDescriptor(descriptor, concordanceOptions), 4);
+ return Buffer.from(label + codeBlock, 'utf8');
+}
+
+function combineEntries(entries) {
+ const buffers = [];
+ let byteLength = 0;
+
+ const sortedKeys = Array.from(entries.keys()).sort();
+ for (const key of sortedKeys) {
+ const keyBuffer = Buffer.from(`\n\n## ${key}\n\n`, 'utf8');
+ buffers.push(keyBuffer);
+ byteLength += keyBuffer.byteLength;
+
+ const formattedEntries = entries.get(key);
+ const last = formattedEntries[formattedEntries.length - 1];
+ for (const entry of formattedEntries) {
+ buffers.push(entry);
+ byteLength += entry.byteLength;
+
+ if (entry !== last) {
+ buffers.push(REPORT_SEPARATOR);
+ byteLength += REPORT_SEPARATOR.byteLength;
+ }
+ }
+ }
+
+ return {buffers, byteLength};
+}
+
+function generateReport(relFile, snapFile, entries) {
+ const combined = combineEntries(entries);
+ const buffers = combined.buffers;
+ let byteLength = combined.byteLength;
+
+ const header = Buffer.from(`# Snapshot report for \`${relFile}\`
+
+The actual snapshot is saved in \`${snapFile}\`.
+
+Generated by [AVA](https://ava.li).`, 'utf8');
+ buffers.unshift(header);
+ byteLength += header.byteLength;
+
+ buffers.push(REPORT_TRAILING_NEWLINE);
+ byteLength += REPORT_TRAILING_NEWLINE.byteLength;
+ return Buffer.concat(buffers, byteLength);
+}
+
+function appendReportEntries(existingReport, entries) {
+ const combined = combineEntries(entries);
+ const buffers = combined.buffers;
+ let byteLength = combined.byteLength;
+
+ const prepend = withoutLineEndings(existingReport);
+ buffers.unshift(prepend);
+ byteLength += prepend.byteLength;
+
+ return Buffer.concat(buffers, byteLength);
+}
+
+function encodeSnapshots(buffersByHash) {
+ const buffers = [];
+ let byteOffset = 0;
+
+ // Entry start and end pointers are relative to the header length. This means
+ // it's possible to append new entries to an existing snapshot file, without
+ // having to rewrite pointers for existing entries.
+ const headerLength = Buffer.alloc(4);
+ buffers.push(headerLength);
+ byteOffset += 4;
+
+ // Allows 65535 hashes (tests or identified snapshots) per file.
+ const numHashes = Buffer.alloc(2);
+ numHashes.writeUInt16LE(buffersByHash.size);
+ buffers.push(numHashes);
+ byteOffset += 2;
+
+ const entries = [];
+ for (const pair of buffersByHash) {
+ const hash = pair[0];
+ const snapshotBuffers = pair[1];
+
+ buffers.push(Buffer.from(hash, 'hex'));
+ byteOffset += MD5_HASH_LENGTH;
+
+ // Allows 65535 snapshots per hash.
+ const numSnapshots = Buffer.alloc(2);
+ numSnapshots.writeUInt16LE(snapshotBuffers.length, 0);
+ buffers.push(numSnapshots);
+ byteOffset += 2;
+
+ for (const value of snapshotBuffers) {
+ // Each pointer is 32 bits, restricting the total, uncompressed buffer to
+ // 4 GiB.
+ const start = Buffer.alloc(4);
+ const end = Buffer.alloc(4);
+ entries.push({start, end, value});
+
+ buffers.push(start, end);
+ byteOffset += 8;
+ }
+ }
+
+ headerLength.writeUInt32LE(byteOffset, 0);
+
+ let bodyOffset = 0;
+ for (const entry of entries) {
+ const start = bodyOffset;
+ const end = bodyOffset + entry.value.byteLength;
+ entry.start.writeUInt32LE(start, 0);
+ entry.end.writeUInt32LE(end, 0);
+ buffers.push(entry.value);
+ bodyOffset = end;
+ }
+ byteOffset += bodyOffset;
+
+ const compressed = zlib.gzipSync(Buffer.concat(buffers, byteOffset));
+ const md5sum = crypto.createHash('md5').update(compressed).digest();
+ return Buffer.concat([
+ READABLE_PREFIX,
+ VERSION_HEADER,
+ md5sum,
+ compressed
+ ], READABLE_PREFIX.byteLength + VERSION_HEADER.byteLength + MD5_HASH_LENGTH + compressed.byteLength);
+}
+
+function decodeSnapshots(buffer, snapPath) {
+ if (isLegacySnapshot(buffer)) {
+ throw new LegacyError(snapPath);
+ }
+
+ // The version starts after the readable prefix, which is ended by a newline
+ // byte (0x0A).
+ const versionOffset = buffer.indexOf(0x0A) + 1;
+ const version = buffer.readUInt16LE(versionOffset);
+ if (version !== VERSION) {
+ throw new VersionMismatchError(snapPath, version);
+ }
+
+ const md5sumOffset = versionOffset + 2;
+ const compressedOffset = md5sumOffset + MD5_HASH_LENGTH;
+ const compressed = buffer.slice(compressedOffset);
+
+ const md5sum = crypto.createHash('md5').update(compressed).digest();
+ const expectedSum = buffer.slice(md5sumOffset, compressedOffset);
+ if (!md5sum.equals(expectedSum)) {
+ throw new ChecksumError(snapPath);
+ }
+
+ const decompressed = zlib.gunzipSync(compressed);
+ let byteOffset = 0;
+
+ const headerLength = decompressed.readUInt32LE(byteOffset);
+ byteOffset += 4;
+
+ const snapshotsByHash = new Map();
+ const numHashes = decompressed.readUInt16LE(byteOffset);
+ byteOffset += 2;
+
+ for (let count = 0; count < numHashes; count++) {
+ const hash = decompressed.toString('hex', byteOffset, byteOffset + MD5_HASH_LENGTH);
+ byteOffset += MD5_HASH_LENGTH;
+
+ const numSnapshots = decompressed.readUInt16LE(byteOffset);
+ byteOffset += 2;
+
+ const snapshotsBuffers = new Array(numSnapshots);
+ for (let index = 0; index < numSnapshots; index++) {
+ const start = decompressed.readUInt32LE(byteOffset) + headerLength;
+ byteOffset += 4;
+ const end = decompressed.readUInt32LE(byteOffset) + headerLength;
+ byteOffset += 4;
+ snapshotsBuffers[index] = decompressed.slice(start, end);
+ }
+
+ // Allow for new entries to be appended to an existing header, which could
+ // lead to the same hash being present multiple times.
+ if (snapshotsByHash.has(hash)) {
+ snapshotsByHash.set(hash, snapshotsByHash.get(hash).concat(snapshotsBuffers));
+ } else {
+ snapshotsByHash.set(hash, snapshotsBuffers);
+ }
+ }
+
+ return snapshotsByHash;
+}
+
+class Manager {
+ constructor(options) {
+ this.appendOnly = options.appendOnly;
+ this.dir = options.dir;
+ this.relFile = options.relFile;
+ this.reportFile = options.reportFile;
+ this.snapFile = options.snapFile;
+ this.snapPath = options.snapPath;
+ this.snapshotsByHash = options.snapshotsByHash;
+
+ this.hasChanges = false;
+ this.reportEntries = new Map();
+ }
+
+ compare(options) {
+ const hash = md5Hex(options.belongsTo);
+ const entries = this.snapshotsByHash.get(hash) || [];
+ if (options.index > entries.length) {
+ throw new RangeError(`Cannot record snapshot ${options.index} for ${JSON.stringify(options.belongsTo)}, exceeds expected index of ${entries.length}`);
+ }
+ if (options.index === entries.length) {
+ this.record(hash, options);
+ return {pass: true};
+ }
+
+ const snapshotBuffer = entries[options.index];
+ const actual = concordance.deserialize(snapshotBuffer, concordanceOptions);
+
+ const expected = concordance.describe(options.expected, concordanceOptions);
+ const pass = concordance.compareDescriptors(actual, expected);
+
+ return {actual, expected, pass};
+ }
+
+ record(hash, options) {
+ const descriptor = concordance.describe(options.expected, concordanceOptions);
+
+ this.hasChanges = true;
+ const snapshot = concordance.serialize(descriptor);
+ if (this.snapshotsByHash.has(hash)) {
+ this.snapshotsByHash.get(hash).push(snapshot);
+ } else {
+ this.snapshotsByHash.set(hash, [snapshot]);
+ }
+
+ const entry = formatEntry(options.label, descriptor);
+ if (this.reportEntries.has(options.belongsTo)) {
+ this.reportEntries.get(options.belongsTo).push(entry);
+ } else {
+ this.reportEntries.set(options.belongsTo, [entry]);
+ }
+ }
+
+ save() {
+ if (!this.hasChanges) {
+ return null;
+ }
+
+ const snapPath = this.snapPath;
+ const buffer = encodeSnapshots(this.snapshotsByHash);
+
+ const reportPath = path.join(this.dir, this.reportFile);
+ const existingReport = this.appendOnly ? tryRead(reportPath) : null;
+ const reportBuffer = existingReport ?
+ appendReportEntries(existingReport, this.reportEntries) :
+ generateReport(this.relFile, this.snapFile, this.reportEntries);
+
+ makeDir.sync(this.dir);
+ const tmpSnapPath = writeFileAtomic.sync(snapPath, buffer);
+ const tmpReportPath = writeFileAtomic.sync(reportPath, reportBuffer);
+
+ return [tmpSnapPath, tmpReportPath, snapPath, reportPath];
+ }
+}
+
+function determineSnapshotDir(projectDir, testDir) {
+ const parts = new Set(path.relative(projectDir, testDir).split(path.sep));
+ if (parts.has('__tests__')) {
+ return path.join(testDir, '__snapshots__');
+ } else if (parts.has('test') || parts.has('tests')) { // Accept tests, even though it's not in the default test patterns
+ return path.join(testDir, 'snapshots');
+ }
+ return testDir;
+}
+
+function load(options) {
+ const dir = determineSnapshotDir(options.projectDir, options.testDir);
+ const reportFile = `${options.name}.md`;
+ const snapFile = `${options.name}.snap`;
+ const snapPath = path.join(dir, snapFile);
+
+ let appendOnly = !options.updating;
+ let snapshotsByHash;
+
+ if (!options.updating) {
+ const buffer = tryRead(snapPath);
+ if (buffer) {
+ snapshotsByHash = decodeSnapshots(buffer, snapPath);
+ } else {
+ appendOnly = false;
+ }
+ }
+
+ return new Manager({
+ appendOnly,
+ dir,
+ relFile: options.relFile,
+ reportFile,
+ snapFile,
+ snapPath,
+ snapshotsByHash: snapshotsByHash || new Map()
+ });
+}
+exports.load = load;