@japa/snapshot
Version:
Snapshot testing plugin for Japa
342 lines (341 loc) • 11.9 kB
JavaScript
import { createRequire } from "node:module";
import { basename, dirname, join, sep } from "node:path";
import StackUtils from "stack-utils";
import { format } from "pretty-format";
import { existsSync, readFileSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import dedent from "dedent";
import { getStackTraceLines } from "jest-message-util";
import BaseMagicString from "magic-string";
const stackUtils = new StackUtils({ cwd: "something which does not exist" });
function prepareExpected(expected) {
function findStartIndent() {
const objectIndent = /^( +)}\s+$/m.exec(expected || "")?.[1]?.length;
if (objectIndent) return objectIndent;
return /^\n( +)"/.exec(expected || "")?.[1]?.length || 0;
}
const startIndent = findStartIndent();
let expectedTrimmed = expected?.trim();
if (startIndent) expectedTrimmed = expectedTrimmed?.replace(new RegExp(`^${" ".repeat(startIndent)}`, "gm"), "").replace(/ +}$/, "}");
return expectedTrimmed;
}
function escapeBackticks(str) {
return str.replace(/`|\\|\${/g, "\\$&");
}
function backticked(str) {
return `\`${escapeBackticks(str)}\``;
}
function serializeSnapshotValue(value, options = {}) {
return format(value, {
printBasicPrototype: false,
printFunctionName: false,
...options
});
}
const IGNORED_FRAMES = [
`${sep}node_modules${sep}`,
`${sep}snapshot${sep}src`,
`${sep}snapshot${sep}index.ts`,
`${sep}snapshot${sep}index.js`,
`${sep}snapshot${sep}build`
];
function getTopFrame(lines) {
for (const line of lines) {
if (IGNORED_FRAMES.some((frame) => line.includes(frame))) continue;
const parsedFrame = stackUtils.parseLine(line.trim());
if (parsedFrame && parsedFrame.file) return parsedFrame;
}
return null;
}
function isModuleInstalled(moduleName) {
const require = createRequire(import.meta.url);
try {
require.resolve(moduleName);
return true;
} catch (error) {
return false;
}
}
var SnapshotFile = class {
counters = /* @__PURE__ */ new Map();
#snapshotsToSave = [];
#snapshotPath;
#testPath;
#cachedContent = null;
#options;
constructor(testPath, options = {}) {
this.#testPath = testPath;
this.#options = options;
this.#snapshotPath = this.#resolveSnapshotPath();
this.#readSnapshotFile();
}
#resolveSnapshotPath() {
if (this.#options.resolveSnapshotPath) return this.#options.resolveSnapshotPath(this.#testPath);
return join(dirname(this.#testPath), "__snapshots__", `${basename(this.#testPath)}.cjs`);
}
async #prepareDirectory() {
if (!existsSync(dirname(this.#snapshotPath))) await mkdir(dirname(this.#snapshotPath), { recursive: true });
}
#readSnapshotFile() {
if (!existsSync(this.#snapshotPath)) return null;
const fileContent = readFileSync(this.#snapshotPath, "utf-8");
const data = Object.create(null);
let snapshotContents = "";
try {
snapshotContents = fileContent;
new Function("exports", snapshotContents)(data);
} catch {}
this.#cachedContent = data;
return data;
}
getTestPath() {
return this.#testPath;
}
getSnapshotPath() {
return this.#snapshotPath;
}
incrementTestCounter(test) {
const testCounterKey = `${this.#testPath}:${test.title}`;
this.counters.set(testCounterKey, (this.counters.get(testCounterKey) ?? 1) + 1);
}
getTestCounter(test) {
const testCounterKey = `${this.#testPath}:${test.title}`;
return this.counters.get(testCounterKey) ?? 1;
}
getSnapshotName(test) {
let exportName = "";
if (test.parent) exportName += test.parent?.title;
exportName += exportName ? ` > ${test.title}` : test.title;
exportName += ` ${this.getTestCounter(test)}`;
return exportName;
}
hasSnapshot(test) {
const exportName = this.getSnapshotName(test);
if (!this.#cachedContent) return true;
return this.#cachedContent[exportName] === void 0;
}
async updateSnapshot(test, value) {
const key = this.getSnapshotName(test);
const serializedValue = serializeSnapshotValue(value, this.#options.prettyFormatOptions);
this.incrementTestCounter(test);
this.#snapshotsToSave.push({
key,
value: serializedValue
});
}
compareSnapshot(test, value) {
return this.getSnapshotTestData(test, value).pass;
}
getSnapshotTestData(test, value) {
const snapshotName = this.getSnapshotName(test);
if (!this.#cachedContent) throw new Error(`Snapshot file ${this.#snapshotPath} not found`);
this.incrementTestCounter(test);
const expected = prepareExpected(this.#cachedContent[snapshotName]);
const received = serializeSnapshotValue(value, this.#options.prettyFormatOptions);
return {
snapshotName,
expected,
received,
pass: expected === received
};
}
async saveSnapshots() {
const content = this.#cachedContent || {};
for (const snapshot of this.#snapshotsToSave) content[snapshot.key] = snapshot.value;
let finalFile = "";
for (const values of Object.entries(content)) {
let serializedExport = `exports[${backticked(values[0])}] = ${backticked(values[1])}`;
finalFile += serializedExport + "\n\n";
}
await this.#prepareDirectory();
await writeFile(this.#snapshotPath, finalFile);
}
};
var FileSnapshotter = class {
#files = /* @__PURE__ */ new Set();
#options;
constructor(options = {}) {
this.#options = options;
}
#resolveTestPath(test) {
return join(dirname(test.options.meta.fileName), basename(test.options.meta.fileName));
}
getFiles() {
return this.#files;
}
getSnapshotFileForTest(test) {
const testPath = this.#resolveTestPath(test);
const existingFile = [...this.#files].find((file) => file.getTestPath() === testPath);
if (existingFile) return existingFile;
const newFile = new SnapshotFile(testPath, this.#options);
this.#files.add(newFile);
return newFile;
}
hasSnapshot(test) {
return this.getSnapshotFileForTest(test).hasSnapshot(test);
}
updateSnapshot(test, value) {
return this.getSnapshotFileForTest(test).updateSnapshot(test, value);
}
compareSnapshot(test, received) {
return this.getSnapshotFileForTest(test).compareSnapshot(test, received);
}
getSnapshotTestData(test, received) {
return this.getSnapshotFileForTest(test).getSnapshotTestData(test, received);
}
async saveSnapshots() {
await Promise.all([...this.#files].map((file) => file.saveSnapshots()));
}
};
var InlineSnapshotInserter = class {
static #getFrameIndex(frame, code) {
const lines = code.split("\n");
let start = 0;
if (frame.line > lines.length) return code.length;
for (let i = 0; i < frame.line - 1; i++) start += lines[i].length + 1;
return start + frame.column;
}
static #indexToLineNumber(source, index) {
const lines = source.split("\n");
let line = 0;
let count = 0;
while (line < lines.length && count + lines[line].length < index) {
count += lines[line].length + 1;
line++;
}
return line + 1;
}
static #getSnapshotStart(frame, code, matcher) {
const startExpectRegex = /(?:toMatchInlineSnapshot)\s*\(\s*(?:\/\*[\S\s]*\*\/\s*|\/\/.*\s+)*\s*[\w_$]*(['"`\)])/m;
const startAssertRegex = /(?:matchInline)\s*\(\s*(?:\/\*[\S\s]*\*\/\s*|\/\/.*\s+)*\s*[\w_$]*(['"`\)])/m;
const index = this.#getFrameIndex(frame, code);
const startMatch = code.slice(index).match(matcher === "expect" ? startExpectRegex : startAssertRegex);
if (!startMatch) throw new Error("Could not find start of snapshot");
return {
frameIndex: index,
startIndex: index + startMatch.index + startMatch[0].length,
isEmpty: startMatch[1] === ")",
quote: startMatch[1]
};
}
static #generateSnapString(snap, source, index) {
const lineNumber = this.#indexToLineNumber(source, index);
const indent = source.split("\n")[lineNumber - 1].match(/^\s*/)[0] || "";
const indentNext = indent.includes(" ") ? `${indent}\t` : `${indent} `;
const lines = snap.trim().replace(/\\/g, "\\\\").split(/\n/g);
const isOneline = lines.length <= 1;
const quote = isOneline ? "'" : "`";
if (isOneline) return `'${lines.join("\n").replace(/'/g, "\\'")}'`;
return `${quote}\n${lines.map((i) => i ? indentNext + i : "").join("\n").replace(/`/g, "\\`").replace(/\${/g, "\\${")}\n${indent}${quote}`;
}
static #overwriteSnapshot(quote, startIndex, magicString, snapString) {
const quoteEndRE = /* @__PURE__ */ new RegExp(`(?:^|[^\\\\])${quote}`);
const endMatch = magicString.original.slice(startIndex).match(quoteEndRE);
if (!endMatch) throw new Error("Could not find end of snapshot");
const endIndex = startIndex + endMatch.index + endMatch[0].length;
magicString.overwrite(startIndex - 1, endIndex, snapString);
}
static insert(code, snapshots) {
const magicString = new BaseMagicString(code);
for (const snapshot of snapshots) {
const { frame, value, matcher } = snapshot;
const { frameIndex, startIndex, isEmpty, quote } = this.#getSnapshotStart(frame, code, matcher);
const snapString = this.#generateSnapString(value, code, frameIndex);
if (isEmpty) magicString.appendRight(startIndex - 1, snapString);
else this.#overwriteSnapshot(quote, startIndex, magicString, snapString);
}
return {
hasChanged: magicString.hasChanged(),
newCode: magicString.toString()
};
}
};
var InlineSnaphotter = class {
#snapshotsToSave = [];
#getFilesToSave() {
return new Set(this.#snapshotsToSave.map(({ filePath }) => filePath));
}
updateSnapshot(test, value, matcher) {
const frame = getTopFrame(getStackTraceLines((/* @__PURE__ */ new Error()).stack ?? ""));
if (!frame || !frame.column || !frame.line) throw new Error("Could not find top frame");
frame.column -= 1;
this.#snapshotsToSave.push({
frame,
filePath: test.options.meta.fileName,
value: serializeSnapshotValue(value),
matcher
});
}
compareSnapshot(test, received, expected) {
return this.getSnapshotTestData(test, received, expected).pass;
}
getSnapshotTestData(test, received, expected) {
const serializedExpected = prepareExpected(dedent(expected));
const serializedReceived = serializeSnapshotValue(received);
return {
snapshotName: test.title,
expected: serializedExpected,
received: serializedReceived,
pass: serializedExpected === serializedReceived,
inline: true
};
}
async saveSnapshots() {
const files = this.#getFilesToSave();
for (const filePath of files) {
const snaps = this.#snapshotsToSave.filter((snapshot) => snapshot.filePath === filePath);
const code = await readFile(filePath, "utf-8");
const { newCode, hasChanged } = InlineSnapshotInserter.insert(code, snaps);
if (hasChanged) await writeFile(filePath, newCode);
}
}
};
var SnapshotManager = class {
#inlineSnapshotter;
#fileSnapshotter;
summary = {
passed: 0,
failed: 0,
updated: 0
};
constructor(options = {}) {
this.#inlineSnapshotter = new InlineSnaphotter();
this.#fileSnapshotter = new FileSnapshotter(options);
}
hasSnapshotInFile(test) {
return this.#fileSnapshotter.hasSnapshot(test);
}
updateInlineSnapshot(test, value, matcher) {
this.#inlineSnapshotter.updateSnapshot(test, value, matcher);
this.summary.updated++;
}
updateFileSnapshot(test, value) {
this.#fileSnapshotter.updateSnapshot(test, value);
this.summary.updated++;
}
getInlineSnapshotTestData(test, received, expected) {
return this.#inlineSnapshotter.getSnapshotTestData(test, received, expected);
}
getFileSnapshotTestData(test, received) {
return this.#fileSnapshotter.getSnapshotTestData(test, received);
}
saveSnapshots() {
return Promise.all([this.#fileSnapshotter.saveSnapshots(), this.#inlineSnapshotter.saveSnapshots()]);
}
};
var PluginContext = class {
static currentTestContext;
static snapshotManager;
static #cliArgs;
static init(options = {}, cliArgs) {
this.snapshotManager = new SnapshotManager(options);
this.#cliArgs = cliArgs;
}
static setCurrentTestContext(testContext) {
this.currentTestContext = testContext;
}
static shouldUpdateSnapshots() {
return this.#cliArgs.u || this.#cliArgs["update-snapshots"];
}
};
export { isModuleInstalled as n, PluginContext as t };