repo_description: Node.js test runner that lets you develop with confidence 🚀
repo_topics: assert async async-functions ava cli cli-app concurrency javascript node nodejs performance tap tdd test-framework test-runner testing unicorns unit-testing
code: 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)
  		: '<No Data>';
  
  	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].toSorted(([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 <https://github.com/nodejs/node/pull/43875>. 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,
  	});
  }
  