'use strict'; const { ArrayPrototypeJoin, ArrayPrototypeMap, ArrayPrototypeSlice, ArrayPrototypeSort, JSONStringify, ObjectKeys, SafeMap, String, StringPrototypeReplaceAll, } = primordials; const { codes: { ERR_INVALID_STATE, }, } = require('internal/errors'); const { kEmptyObject } = require('internal/util'); let debug = require('internal/util/debuglog').debuglog('test_runner', (fn) => { debug = fn; }); const { validateArray, validateFunction, validateObject, validateString, } = require('internal/validators'); const { strictEqual } = require('assert'); const { mkdirSync, readFileSync, writeFileSync } = require('fs'); const { dirname } = require('path'); const { createContext, runInContext } = require('vm'); const kMissingSnapshotTip = 'Missing snapshots can be generated by rerunning ' + 'the command with the --test-update-snapshots flag.'; const defaultSerializers = [ (value) => { return JSONStringify(value, null, 2); }, ]; function defaultResolveSnapshotPath(testPath) { if (typeof testPath !== 'string') { return testPath; } return `${testPath}.snapshot`; } let resolveSnapshotPathFn = defaultResolveSnapshotPath; let serializerFns = defaultSerializers; function setResolveSnapshotPath(fn) { validateFunction(fn, 'fn'); resolveSnapshotPathFn = fn; } function setDefaultSnapshotSerializers(serializers) { validateFunctionArray(serializers, 'serializers'); serializerFns = ArrayPrototypeSlice(serializers); } class SnapshotFile { constructor(snapshotFile) { this.snapshotFile = snapshotFile; this.snapshots = { __proto__: null }; this.nameCounts = new SafeMap(); this.loaded = false; } getSnapshot(id) { if (!(id in this.snapshots)) { const err = new ERR_INVALID_STATE(`Snapshot '${id}' not found in ` + `'${this.snapshotFile}.' ${kMissingSnapshotTip}`); err.snapshot = id; err.filename = this.snapshotFile; throw err; } return this.snapshots[id]; } setSnapshot(id, value) { this.snapshots[templateEscape(id)] = value; } nextId(name) { const count = this.nameCounts.get(name) ?? 1; this.nameCounts.set(name, count + 1); return `${name} ${count}`; } readFile() { if (this.loaded) { debug('skipping read of snapshot file'); return; } try { const source = readFileSync(this.snapshotFile, 'utf8'); const context = { __proto__: null, exports: { __proto__: null } }; createContext(context); runInContext(source, context); if (context.exports === null || typeof context.exports !== 'object') { throw new ERR_INVALID_STATE( `Malformed snapshot file '${this.snapshotFile}'.`, ); } for (const key in context.exports) { this.snapshots[key] = templateEscape(context.exports[key]); } this.loaded = true; } catch (err) { throwReadError(err, this.snapshotFile); } } writeFile() { try { const keys = ArrayPrototypeSort(ObjectKeys(this.snapshots)); const snapshotStrings = ArrayPrototypeMap(keys, (key) => { return `exports[\`${key}\`] = \`${this.snapshots[key]}\`;\n`; }); const output = ArrayPrototypeJoin(snapshotStrings, '\n'); mkdirSync(dirname(this.snapshotFile), { __proto__: null, recursive: true }); writeFileSync(this.snapshotFile, output, 'utf8'); } catch (err) { throwWriteError(err, this.snapshotFile); } } } class SnapshotManager { constructor(updateSnapshots) { // A manager instance will only read or write snapshot files based on the // updateSnapshots argument. this.updateSnapshots = updateSnapshots; this.cache = new SafeMap(); } resolveSnapshotFile(entryFile) { let snapshotFile = this.cache.get(entryFile); if (snapshotFile === undefined) { const resolved = resolveSnapshotPathFn(entryFile); if (typeof resolved !== 'string') { const err = new ERR_INVALID_STATE('Invalid snapshot filename.'); err.filename = resolved; throw err; } snapshotFile = new SnapshotFile(resolved); snapshotFile.loaded = this.updateSnapshots; this.cache.set(entryFile, snapshotFile); } return snapshotFile; } serialize(input, serializers = serializerFns) { try { const value = serializeValue(input, serializers); return `\n${templateEscape(value)}\n`; } catch (err) { throwSerializationError(input, err); } } serializeWithoutEscape(input, serializers = serializerFns) { try { return serializeValue(input, serializers); } catch (err) { throwSerializationError(input, err); } } writeSnapshotFiles() { if (!this.updateSnapshots) { debug('skipping write of snapshot files'); return; } this.cache.forEach((snapshotFile) => { snapshotFile.writeFile(); }); } createAssert() { const manager = this; return function snapshotAssertion(actual, options = kEmptyObject) { validateObject(options, 'options'); const { serializers = serializerFns, } = options; validateFunctionArray(serializers, 'options.serializers'); const { filePath, fullName } = this; const snapshotFile = manager.resolveSnapshotFile(filePath); const value = manager.serialize(actual, serializers); const id = snapshotFile.nextId(fullName); if (manager.updateSnapshots) { snapshotFile.setSnapshot(id, value); } else { snapshotFile.readFile(); strictEqual(value, snapshotFile.getSnapshot(id)); } }; } createFileAssert() { const manager = this; return function fileSnapshotAssertion(actual, path, options = kEmptyObject) { validateString(path, 'path'); validateObject(options, 'options'); const { serializers = serializerFns, } = options; validateFunctionArray(serializers, 'options.serializers'); const value = manager.serializeWithoutEscape(actual, serializers); if (manager.updateSnapshots) { try { mkdirSync(dirname(path), { __proto__: null, recursive: true }); writeFileSync(path, value, 'utf8'); } catch (err) { throwWriteError(err, path); } } else { let expected; try { expected = readFileSync(path, 'utf8'); } catch (err) { throwReadError(err, path); } strictEqual(value, expected); } }; } } function throwReadError(err, filename) { let msg = `Cannot read snapshot file '${filename}.'`; if (err?.code === 'ENOENT') { msg += ` ${kMissingSnapshotTip}`; } const error = new ERR_INVALID_STATE(msg); error.cause = err; error.filename = filename; throw error; } function throwWriteError(err, filename) { const msg = `Cannot write snapshot file '${filename}.'`; const error = new ERR_INVALID_STATE(msg); error.cause = err; error.filename = filename; throw error; } function throwSerializationError(input, err) { const error = new ERR_INVALID_STATE( 'The provided serializers did not generate a string.', ); error.input = input; error.cause = err; throw error; } function serializeValue(value, serializers) { let v = value; for (let i = 0; i < serializers.length; ++i) { const fn = serializers[i]; v = fn(v); } return v; } function validateFunctionArray(fns, name) { validateArray(fns, name); for (let i = 0; i < fns.length; ++i) { validateFunction(fns[i], `${name}[${i}]`); } } function templateEscape(str) { let result = String(str); result = StringPrototypeReplaceAll(result, '\\', '\\\\'); result = StringPrototypeReplaceAll(result, '`', '\\`'); result = StringPrototypeReplaceAll(result, '${', '\\${'); return result; } module.exports = { SnapshotManager, defaultResolveSnapshotPath, // Exported for testing only. defaultSerializers, // Exported for testing only. setDefaultSnapshotSerializers, setResolveSnapshotPath, };