2024-05-18 16:32:41 +00:00
|
|
|
'use strict';
|
|
|
|
const {
|
|
|
|
ArrayPrototypeJoin,
|
|
|
|
ArrayPrototypeMap,
|
|
|
|
ArrayPrototypeSlice,
|
|
|
|
ArrayPrototypeSort,
|
|
|
|
JSONStringify,
|
|
|
|
ObjectKeys,
|
|
|
|
SafeMap,
|
|
|
|
String,
|
|
|
|
StringPrototypeReplaceAll,
|
|
|
|
} = primordials;
|
|
|
|
const {
|
|
|
|
codes: {
|
|
|
|
ERR_INVALID_STATE,
|
|
|
|
},
|
|
|
|
} = require('internal/errors');
|
|
|
|
const { emitExperimentalWarning, kEmptyObject } = require('internal/util');
|
|
|
|
let debug = require('internal/util/debuglog').debuglog('test_runner', (fn) => {
|
|
|
|
debug = fn;
|
|
|
|
});
|
|
|
|
const {
|
|
|
|
validateArray,
|
|
|
|
validateFunction,
|
|
|
|
validateObject,
|
|
|
|
} = require('internal/validators');
|
|
|
|
const { strictEqual } = require('assert');
|
|
|
|
const { mkdirSync, readFileSync, writeFileSync } = require('fs');
|
|
|
|
const { dirname } = require('path');
|
|
|
|
const { createContext, runInContext } = require('vm');
|
|
|
|
const kExperimentalWarning = 'Snapshot testing';
|
|
|
|
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) {
|
|
|
|
emitExperimentalWarning(kExperimentalWarning);
|
|
|
|
validateFunction(fn, 'fn');
|
|
|
|
resolveSnapshotPathFn = fn;
|
|
|
|
}
|
|
|
|
|
|
|
|
function setDefaultSnapshotSerializers(serializers) {
|
|
|
|
emitExperimentalWarning(kExperimentalWarning);
|
|
|
|
validateFunctionArray(serializers, 'serializers');
|
|
|
|
serializerFns = ArrayPrototypeSlice(serializers);
|
|
|
|
}
|
|
|
|
|
2024-07-15 00:33:13 +00:00
|
|
|
class SnapshotFile {
|
|
|
|
constructor(snapshotFile) {
|
|
|
|
this.snapshotFile = snapshotFile;
|
2024-05-18 16:32:41 +00:00
|
|
|
this.snapshots = { __proto__: null };
|
|
|
|
this.nameCounts = new SafeMap();
|
2024-07-15 00:33:13 +00:00
|
|
|
this.loaded = false;
|
2024-05-18 16:32:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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}`;
|
|
|
|
}
|
|
|
|
|
2024-07-15 00:33:13 +00:00
|
|
|
readFile() {
|
2024-05-18 16:32:41 +00:00
|
|
|
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}'.`,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2024-07-15 14:55:43 +00:00
|
|
|
for (const key in context.exports) {
|
|
|
|
this.snapshots[key] = templateEscape(context.exports[key]);
|
|
|
|
}
|
2024-05-18 16:32:41 +00:00
|
|
|
this.loaded = true;
|
|
|
|
} catch (err) {
|
|
|
|
let msg = `Cannot read snapshot file '${this.snapshotFile}.'`;
|
|
|
|
|
|
|
|
if (err?.code === 'ENOENT') {
|
|
|
|
msg += ` ${kMissingSnapshotTip}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
const error = new ERR_INVALID_STATE(msg);
|
|
|
|
error.cause = err;
|
|
|
|
error.filename = this.snapshotFile;
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-07-15 00:33:13 +00:00
|
|
|
writeFile() {
|
2024-05-18 16:32:41 +00:00
|
|
|
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) {
|
|
|
|
const msg = `Cannot write snapshot file '${this.snapshotFile}.'`;
|
|
|
|
const error = new ERR_INVALID_STATE(msg);
|
|
|
|
error.cause = err;
|
|
|
|
error.filename = this.snapshotFile;
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
}
|
2024-07-15 00:33:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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 {
|
|
|
|
let value = input;
|
|
|
|
|
|
|
|
for (let i = 0; i < serializers.length; ++i) {
|
|
|
|
const fn = serializers[i];
|
|
|
|
value = fn(value);
|
|
|
|
}
|
|
|
|
|
|
|
|
return `\n${templateEscape(value)}\n`;
|
|
|
|
} catch (err) {
|
|
|
|
const error = new ERR_INVALID_STATE(
|
|
|
|
'The provided serializers did not generate a string.',
|
|
|
|
);
|
|
|
|
error.input = input;
|
|
|
|
error.cause = err;
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
writeSnapshotFiles() {
|
|
|
|
if (!this.updateSnapshots) {
|
|
|
|
debug('skipping write of snapshot files');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.cache.forEach((snapshotFile) => {
|
|
|
|
snapshotFile.writeFile();
|
|
|
|
});
|
|
|
|
}
|
2024-05-18 16:32:41 +00:00
|
|
|
|
|
|
|
createAssert() {
|
|
|
|
const manager = this;
|
|
|
|
|
|
|
|
return function snapshotAssertion(actual, options = kEmptyObject) {
|
|
|
|
emitExperimentalWarning(kExperimentalWarning);
|
|
|
|
validateObject(options, 'options');
|
|
|
|
const {
|
|
|
|
serializers = serializerFns,
|
|
|
|
} = options;
|
|
|
|
validateFunctionArray(serializers, 'options.serializers');
|
2024-07-15 00:33:13 +00:00
|
|
|
const { filePath, fullName } = this;
|
|
|
|
const snapshotFile = manager.resolveSnapshotFile(filePath);
|
2024-05-18 16:32:41 +00:00
|
|
|
const value = manager.serialize(actual, serializers);
|
2024-07-15 00:33:13 +00:00
|
|
|
const id = snapshotFile.nextId(fullName);
|
2024-05-18 16:32:41 +00:00
|
|
|
|
|
|
|
if (manager.updateSnapshots) {
|
2024-07-15 00:33:13 +00:00
|
|
|
snapshotFile.setSnapshot(id, value);
|
2024-05-18 16:32:41 +00:00
|
|
|
} else {
|
2024-07-15 00:33:13 +00:00
|
|
|
snapshotFile.readFile();
|
|
|
|
strictEqual(value, snapshotFile.getSnapshot(id));
|
2024-05-18 16:32:41 +00:00
|
|
|
}
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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,
|
|
|
|
};
|