import {Buffer} from 'node:buffer'; import crypto from 'node:crypto'; import fs from 'node:fs'; import {findSourceMap} from 'node:module'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; import zlib from 'node:zlib'; import cbor from 'cbor'; import concordance from 'concordance'; import indentString from 'indent-string'; import memoize from 'memoize'; import writeFileAtomic from 'write-file-atomic'; import {snapshotManager as concordanceOptions} from './concordance-options.js'; import slash from './slash.cjs'; // 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 = 3; 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 SHA_256_HASH_LENGTH = 32; export class SnapshotError extends Error { constructor(message, snapPath) { super(message); this.name = 'SnapshotError'; this.snapPath = snapPath; } } export class ChecksumError extends SnapshotError { constructor(snapPath) { super('Checksum mismatch', snapPath); this.name = 'ChecksumError'; } } export class VersionMismatchError extends SnapshotError { constructor(snapPath, version) { super('Unexpected snapshot version', snapPath); this.name = 'VersionMismatchError'; this.snapVersion = version; this.expectedVersion = VERSION; } } export class InvalidSnapshotError extends SnapshotError { constructor(snapPath) { super('Invalid snapshot file', snapPath); this.name = 'InvalidSnapshotError'; } } const LEGACY_SNAPSHOT_HEADER = Buffer.from('// Jest Snapshot v1'); function isLegacySnapshot(buffer) { return LEGACY_SNAPSHOT_HEADER.equals(buffer.slice(0, LEGACY_SNAPSHOT_HEADER.byteLength)); } export class LegacyError extends SnapshotError { constructor(snapPath) { super('Legacy snapshot file', snapPath); this.name = 'LegacyError'; } } function tryRead(file) { try { return fs.readFileSync(file); } catch (error) { if (error.code === 'ENOENT') { return null; } throw error; } } function formatEntry(snapshot, index) { const { data, label = `Snapshot ${index + 1}`, // Human-readable labels start counting at 1. } = snapshot; const description = data ? concordance.formatDescriptor(concordance.deserialize(data), concordanceOptions) : ''; const blockquote = label.split(/\n/).map(line => '> ' + line).join('\n'); return `${blockquote}\n\n${indentString(description, 4)}`; } function combineEntries({blocks}) { const combined = new BufferBuilder(); for (const {title, snapshots} of blocks) { const last = snapshots.at(-1); combined.write(`\n\n## ${title}\n\n`); for (const [index, snapshot] of snapshots.entries()) { combined.write(formatEntry(snapshot, index)); if (snapshot !== last) { combined.write(REPORT_SEPARATOR); } } } return combined; } function generateReport(relFile, snapFile, snapshots) { return new BufferBuilder() .write(`# Snapshot report for \`${slash(relFile)}\` The actual snapshot is saved in \`${snapFile}\`. Generated by [AVA](https://avajs.dev).`) .append(combineEntries(snapshots)) .write(REPORT_TRAILING_NEWLINE) .toBuffer(); } class BufferBuilder { constructor() { this.buffers = []; this.byteOffset = 0; } append(builder) { this.buffers.push(...builder.buffers); this.byteOffset += builder.byteOffset; return this; } write(data) { if (typeof data === 'string') { this.write(Buffer.from(data, 'utf8')); } else { this.buffers.push(data); this.byteOffset += data.byteLength; } return this; } toBuffer() { return Buffer.concat(this.buffers, this.byteOffset); } } function sortBlocks(blocksByTitle, blockIndices) { return [...blocksByTitle].sort( ([aTitle], [bTitle]) => { const a = blockIndices.get(aTitle); const b = blockIndices.get(bTitle); if (a === undefined) { if (b === undefined) { return 0; } return 1; } if (b === undefined) { return -1; } return a - b; }, ); } async function encodeSnapshots(snapshotData) { const encoded = await cbor.encodeAsync(snapshotData, { omitUndefinedProperties: true, canonical: true, }); const compressed = zlib.gzipSync(encoded); compressed[9] = 0x03; // Override the GZip header containing the OS to always be Linux const sha256sum = crypto.createHash('sha256').update(compressed).digest(); return Buffer.concat([ READABLE_PREFIX, VERSION_HEADER, sha256sum, compressed, ], READABLE_PREFIX.byteLength + VERSION_HEADER.byteLength + SHA_256_HASH_LENGTH + compressed.byteLength); } export function extractCompressedSnapshot(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 newline = buffer.indexOf(0x0A); if (newline === -1) { throw new InvalidSnapshotError(snapPath); } const versionOffset = newline + 1; const version = buffer.readUInt16LE(versionOffset); if (version !== VERSION) { throw new VersionMismatchError(snapPath, version); } const sha256sumOffset = versionOffset + 2; const compressedOffset = sha256sumOffset + SHA_256_HASH_LENGTH; const compressed = buffer.slice(compressedOffset); return { version, compressed, sha256sumOffset, compressedOffset, }; } function decodeSnapshots(buffer, snapPath) { const {compressed, sha256sumOffset, compressedOffset} = extractCompressedSnapshot(buffer, snapPath); const sha256sum = crypto.createHash('sha256').update(compressed).digest(); const expectedSum = buffer.slice(sha256sumOffset, compressedOffset); if (!sha256sum.equals(expectedSum)) { throw new ChecksumError(snapPath); } const decompressed = zlib.gunzipSync(compressed); return cbor.decode(decompressed); } class Manager { constructor(options) { this.dir = options.dir; this.recordNewSnapshots = options.recordNewSnapshots; this.updating = options.updating; this.relFile = options.relFile; this.reportFile = options.reportFile; this.reportPath = options.reportPath; this.snapFile = options.snapFile; this.snapPath = options.snapPath; this.oldBlocksByTitle = options.oldBlocksByTitle; this.newBlocksByTitle = options.newBlocksByTitle; this.blockIndices = new Map(); this.error = options.error; this.hasChanges = false; } touch(title, taskIndex) { this.blockIndices.set(title, taskIndex); } compare(options) { if (this.error) { throw this.error; } const block = this.newBlocksByTitle.get(options.belongsTo); const snapshot = block?.snapshots[options.index]; const data = snapshot?.data; if (!data) { if (!this.recordNewSnapshots) { return {pass: false}; } if (options.deferRecording) { const record = this.deferRecord(options); return {pass: true, record}; } this.record(options); return {pass: true}; } const actual = concordance.deserialize(data, concordanceOptions); const expected = concordance.describe(options.expected, concordanceOptions); const pass = concordance.compareDescriptors(actual, expected); return {actual, expected, pass}; } recordSerialized({data, label, belongsTo, index}) { const block = this.newBlocksByTitle.get(belongsTo) ?? {snapshots: []}; const {snapshots} = block; if (index > snapshots.length) { throw new RangeError(`Cannot record snapshot ${index} for ${JSON.stringify(belongsTo)}, exceeds expected index of ${snapshots.length}`); } else if (index < snapshots.length) { if (snapshots[index].data) { throw new RangeError(`Cannot record snapshot ${index} for ${JSON.stringify(belongsTo)}, already exists`); } snapshots[index] = {data, label}; } else { snapshots.push({data, label}); } this.newBlocksByTitle.set(belongsTo, block); } deferRecord(options) { const {expected, belongsTo, label, index} = options; const descriptor = concordance.describe(expected, concordanceOptions); const data = concordance.serialize(descriptor); return () => { // Must be called in order! this.hasChanges = true; this.recordSerialized({ data, label, belongsTo, index, }); }; } record(options) { const record = this.deferRecord(options); record(); } skipBlock(title) { const block = this.oldBlocksByTitle.get(title); if (block) { this.newBlocksByTitle.set(title, block); } } skipSnapshot({belongsTo, index, deferRecording}) { const oldBlock = this.oldBlocksByTitle.get(belongsTo); const snapshot = oldBlock?.snapshots[index] ?? {}; // Retain the label from the old snapshot, so as not to assume that the // snapshot.skip() arguments are well-formed. // Defer recording if called in a try(). if (deferRecording) { return () => { // Must be called in order! this.recordSerialized({belongsTo, index, ...snapshot}); }; } this.recordSerialized({belongsTo, index, ...snapshot}); } async save() { const {dir, relFile, snapFile, snapPath, reportPath} = this; if (this.updating && this.newBlocksByTitle.size === 0) { return { changedFiles: [cleanFile(snapPath), cleanFile(reportPath)].flat(), temporaryFiles: [], }; } if (!this.hasChanges) { return null; } const snapshots = { blocks: sortBlocks(this.newBlocksByTitle, this.blockIndices).map( ([title, block]) => ({title, ...block}), ), }; const buffer = await encodeSnapshots(snapshots); const reportBuffer = generateReport(relFile, snapFile, snapshots); await fs.promises.mkdir(dir, {recursive: true}); const temporaryFiles = []; const tmpfileCreated = file => temporaryFiles.push(file); await Promise.all([ writeFileAtomic(snapPath, buffer, {tmpfileCreated}), writeFileAtomic(reportPath, reportBuffer, {tmpfileCreated}), ]); return { changedFiles: [snapPath, reportPath], temporaryFiles, }; } } const resolveSourceFile = memoize(file => { const sourceMap = findSourceMap(file); // Prior to Node.js 18.8.0, the value when a source map could not be found was `undefined`. // This changed to `null` in . Check both. if (sourceMap === undefined || sourceMap === null) { return file; } const {payload} = sourceMap; if (payload.sources.length === 0) { // Hypothetical? return file; } return payload.sources[0].startsWith('file://') ? fileURLToPath(payload.sources[0]) : payload.sources[0]; }); export const determineSnapshotDir = memoize(({file, fixedLocation, projectDir}) => { const testDir = path.dirname(resolveSourceFile(file)); if (fixedLocation) { const relativeTestLocation = path.relative(projectDir, testDir); return path.join(fixedLocation, relativeTestLocation); } const parts = new Set(path.relative(projectDir, testDir).split(path.sep)); if (parts.has('__tests__')) { return path.join(testDir, '__snapshots__'); } 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; }, {cacheKey: ([{file}]) => file}); function determineSnapshotPaths({file, fixedLocation, projectDir}) { const dir = determineSnapshotDir({file, fixedLocation, projectDir}); const relFile = path.relative(projectDir, resolveSourceFile(file)); const name = path.basename(relFile); const reportFile = `${name}.md`; const snapFile = `${name}.snap`; return { dir, relFile, snapFile, reportFile, snapPath: path.join(dir, snapFile), reportPath: path.join(dir, reportFile), }; } function cleanFile(file) { try { fs.unlinkSync(file); return [file]; } catch (error) { if (error.code === 'ENOENT') { return []; } throw error; } } export function load({file, fixedLocation, projectDir, recordNewSnapshots, updating}) { // Keep runner unit tests that use `new Runner()` happy if (file === undefined || projectDir === undefined) { return new Manager({ recordNewSnapshots, updating, oldBlocksByTitle: new Map(), newBlocksByTitle: new Map(), }); } const paths = determineSnapshotPaths({file, fixedLocation, projectDir}); const buffer = tryRead(paths.snapPath); if (!buffer) { return new Manager({ recordNewSnapshots, updating, ...paths, oldBlocksByTitle: new Map(), newBlocksByTitle: new Map(), }); } let blocksByTitle; let snapshotError; try { const data = decodeSnapshots(buffer, paths.snapPath); blocksByTitle = new Map(data.blocks.map(({title, ...block}) => [title, block])); } catch (error) { blocksByTitle = new Map(); if (!updating) { // Discard all decoding errors when updating snapshots snapshotError = error instanceof SnapshotError ? error : new InvalidSnapshotError(paths.snapPath); } } return new Manager({ recordNewSnapshots, updating, ...paths, oldBlocksByTitle: blocksByTitle, newBlocksByTitle: updating ? new Map() : blocksByTitle, error: snapshotError, }); }