diff options
author | Florian Dold <florian.dold@gmail.com> | 2017-08-14 05:01:11 +0200 |
---|---|---|
committer | Florian Dold <florian.dold@gmail.com> | 2017-08-14 05:02:09 +0200 |
commit | 363723fc84f7b8477592e0105aeb331ec9a017af (patch) | |
tree | 29f92724f34131bac64d6a318dd7e30612e631c7 /node_modules/ava/lib/snapshot-manager.js | |
parent | 5634e77ad96bfe1818f6b6ee70b7379652e5487f (diff) |
node_modules
Diffstat (limited to 'node_modules/ava/lib/snapshot-manager.js')
-rw-r--r-- | node_modules/ava/lib/snapshot-manager.js | 396 |
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; |