@japa/snapshot
Version:
Snapshot testing plugin for Japa
570 lines (560 loc) • 16.9 kB
JavaScript
// src/utils.ts
import { sep } from "path";
import StackUtils from "stack-utils";
import { createRequire } from "module";
import { format as prettyFormat } from "pretty-format";
var stackUtils = new StackUtils({ cwd: "something which does not exist" });
function prepareExpected(expected) {
function findStartIndent() {
const matchObject = /^( +)}\s+$/m.exec(expected || "");
const objectIndent = matchObject?.[1]?.length;
if (objectIndent) return objectIndent;
const matchText = /^\n( +)"/.exec(expected || "");
return matchText?.[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 prettyFormat(value, {
printBasicPrototype: false,
printFunctionName: false,
...options
});
}
var 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 require2 = createRequire(import.meta.url);
try {
require2.resolve(moduleName);
return true;
} catch (error) {
return false;
}
}
// src/file_snapshotter.ts
import { basename as basename2, dirname as dirname2, join as join2 } from "path";
// src/snapshot_file.ts
import { existsSync, readFileSync } from "fs";
import { mkdir, writeFile } from "fs/promises";
import { basename, dirname, join } from "path";
var SnapshotFile = class {
/**
* Counters for each test.
* Help to keep track of the number of snapshots in a test.
*/
counters = /* @__PURE__ */ new Map();
/**
* List of snapshots that will be saved/updated at the end of the tests
*/
#snapshotsToSave = [];
/**
* Path to the snapshot file
*/
#snapshotPath;
/**
* Path to the test file linked to this snapshot file
*/
#testPath;
/**
* Cached content of the snapshot file.
*/
#cachedContent = null;
/**
* Snapshot plugin options
*/
#options;
constructor(testPath, options = {}) {
this.#testPath = testPath;
this.#options = options;
this.#snapshotPath = this.#resolveSnapshotPath();
this.#readSnapshotFile();
}
/**
* Resolve the snapshot path for the given test
*/
#resolveSnapshotPath() {
if (this.#options.resolveSnapshotPath) {
return this.#options.resolveSnapshotPath(this.#testPath);
}
const testDir = dirname(this.#testPath);
const snapshotFileName = `${basename(this.#testPath)}.cjs`;
return join(testDir, "__snapshots__", snapshotFileName);
}
/**
* Create the directory where the snapshot file will be saved
*/
async #prepareDirectory() {
if (!existsSync(dirname(this.#snapshotPath))) {
await mkdir(dirname(this.#snapshotPath), { recursive: true });
}
}
/**
* Read the snapshot file from disk
*/
#readSnapshotFile() {
const isFileExist = existsSync(this.#snapshotPath);
if (!isFileExist) {
return null;
}
const fileContent = readFileSync(this.#snapshotPath, "utf-8");
const data = /* @__PURE__ */ Object.create(null);
let snapshotContents = "";
try {
snapshotContents = fileContent;
const populate = new Function("exports", snapshotContents);
populate(data);
} catch {
}
this.#cachedContent = data;
return data;
}
/**
* Path to the test file linked to this snapshot file
*/
getTestPath() {
return this.#testPath;
}
/**
* Path to the snapshot file
*/
getSnapshotPath() {
return this.#snapshotPath;
}
/**
* Increment the counter for the given test
*/
incrementTestCounter(test) {
const testCounterKey = `${this.#testPath}:${test.title}`;
this.counters.set(testCounterKey, (this.counters.get(testCounterKey) ?? 1) + 1);
}
/**
* Get the counter for the given test
*/
getTestCounter(test) {
const testCounterKey = `${this.#testPath}:${test.title}`;
return this.counters.get(testCounterKey) ?? 1;
}
/**
* Generate a name for the named export for the given test
*/
getSnapshotName(test) {
let exportName = "";
if (test.parent) {
exportName += test.parent?.title;
}
exportName += exportName ? ` > ${test.title}` : test.title;
exportName += ` ${this.getTestCounter(test)}`;
return exportName;
}
/**
* Check if the given test hasn't a snapshot saved yet
*/
hasSnapshot(test) {
const exportName = this.getSnapshotName(test);
if (!this.#cachedContent) {
return true;
}
return this.#cachedContent[exportName] === void 0;
}
/**
* Update a snapshot by saving the new serialized value
* in memory.
*
* Will be persisted to disk when the tests are done.
*/
async updateSnapshot(test, value) {
const exportName = this.getSnapshotName(test);
const key = exportName;
const serializedValue = serializeSnapshotValue(value, this.#options.prettyFormatOptions);
this.incrementTestCounter(test);
this.#snapshotsToSave.push({
key,
value: serializedValue
});
}
/**
* Compare the value with the snapshot of the given test
*/
compareSnapshot(test, value) {
return this.getSnapshotTestData(test, value).pass;
}
/**
* Returns the data needed for a future assertion
*/
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
};
}
/**
* Save the snapshot file to disk
*/
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)) {
const key = backticked(values[0]);
const value = backticked(values[1]);
let serializedExport = `exports[${key}] = ${value}`;
finalFile += serializedExport + "\n\n";
}
await this.#prepareDirectory();
await writeFile(this.#snapshotPath, finalFile);
}
};
// src/file_snapshotter.ts
var FileSnapshotter = class {
#files = /* @__PURE__ */ new Set();
#options;
constructor(options = {}) {
this.#options = options;
}
/**
* Resolve the test path from a given test
*/
#resolveTestPath(test) {
const testDir = dirname2(test.options.meta.fileName);
const testFileName = basename2(test.options.meta.fileName);
return join2(testDir, testFileName);
}
/**
* Return all SnapshotFile instances
*/
getFiles() {
return this.#files;
}
/**
* Check if a snapshot file exists for a given test path
* Otherwise, create a new one and return it
*/
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;
}
/**
* Check if the test has already a saved snapshot
*/
hasSnapshot(test) {
return this.getSnapshotFileForTest(test).hasSnapshot(test);
}
/**
* Update the snapshot for a given test
*/
updateSnapshot(test, value) {
return this.getSnapshotFileForTest(test).updateSnapshot(test, value);
}
/**
* Compare a snapshot for a given test
*/
compareSnapshot(test, received) {
return this.getSnapshotFileForTest(test).compareSnapshot(test, received);
}
/**
* Returns the data needed for a future assertion
*/
getSnapshotTestData(test, received) {
return this.getSnapshotFileForTest(test).getSnapshotTestData(test, received);
}
/**
* Save all snapshot files to disk
*/
async saveSnapshots() {
await Promise.all([...this.#files].map((file) => file.saveSnapshots()));
}
};
// src/inline/inline_snapshotter.ts
import dedent from "dedent";
import { readFile, writeFile as writeFile2 } from "fs/promises";
import { getStackTraceLines } from "jest-message-util";
// src/inline/inline_snapshot_inserter.ts
import BaseMagicString from "magic-string";
var InlineSnapshotInserter = class {
/**
* Given the frame column and line, returns the index in the `code` string
* to access the character at that position.
*/
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;
}
/**
* Given a string and an index, returns the line number in
* the string where the index is located.
*/
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]
};
}
/**
* Generate a snapshot string before inserting them. Try to keep the
* indentation of the code clean
*/
static #generateSnapString(snap, source, index) {
const lineNumber = this.#indexToLineNumber(source, index);
const line = source.split("\n")[lineNumber - 1];
const indent = line.match(/^\s*/)[0] || "";
const indentNext = indent.includes(" ") ? `${indent} ` : `${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}
${lines.map((i) => i ? indentNext + i : "").join("\n").replace(/`/g, "\\`").replace(/\${/g, "\\${")}
${indent}${quote}`;
}
/**
* Overwrites the snapshot in the code with the new snapshot
*/
static #overwriteSnapshot(quote, startIndex, magicString, snapString) {
const quoteEndRE = 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);
}
/**
* Insert the given snapshot in the code. Returns the modified code
*/
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()
};
}
};
// src/inline/inline_snapshotter.ts
var InlineSnaphotter = class {
#snapshotsToSave = [];
/**
* Get all the files that have snapshots to save
*/
#getFilesToSave() {
return new Set(this.#snapshotsToSave.map(({ filePath }) => filePath));
}
/**
* Update a snapshot by saving the new serialized value
* in memory.
*
* Will be persisted to disk when the tests are done.
*/
updateSnapshot(test, value, matcher) {
const error = new Error();
const lines = getStackTraceLines(error.stack ?? "");
const frame = getTopFrame(lines);
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
});
}
/**
* Check if the received value matches the expected snapshot
*/
compareSnapshot(test, received, expected) {
return this.getSnapshotTestData(test, received, expected).pass;
}
/**
* Returns the data needed for a future assertion
*/
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
};
}
/**
* Saves all the inline snapshots that were updated during the run.
* Reads the file contents, updates inline snapshots, then writes the
* changes back to the file
*
* This method should be called after all tests have finished running.
*/
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 writeFile2(filePath, newCode);
}
}
};
// src/snapshot_manager.ts
var SnapshotManager = class {
#inlineSnapshotter;
#fileSnapshotter;
/**
* Keep track of the different snapshot results/updates
*/
summary = {
passed: 0,
failed: 0,
updated: 0
};
constructor(options = {}) {
this.#inlineSnapshotter = new InlineSnaphotter();
this.#fileSnapshotter = new FileSnapshotter(options);
}
/**
* Check if the test has an inline snapshot
*/
hasSnapshotInFile(test) {
return this.#fileSnapshotter.hasSnapshot(test);
}
/**
* Update an inline snapshot
*/
updateInlineSnapshot(test, value, matcher) {
this.#inlineSnapshotter.updateSnapshot(test, value, matcher);
this.summary.updated++;
}
/**
* Update a snapshot in the file
*/
updateFileSnapshot(test, value) {
this.#fileSnapshotter.updateSnapshot(test, value);
this.summary.updated++;
}
/**
* Returns the data needed for a future assertion
*/
getInlineSnapshotTestData(test, received, expected) {
return this.#inlineSnapshotter.getSnapshotTestData(test, received, expected);
}
/**
* Returns the data needed for a future assertion
*/
getFileSnapshotTestData(test, received) {
return this.#fileSnapshotter.getSnapshotTestData(test, received);
}
/**
* Save both inline and file snapshots to disk
*/
saveSnapshots() {
return Promise.all([
this.#fileSnapshotter.saveSnapshots(),
this.#inlineSnapshotter.saveSnapshots()
]);
}
};
// src/plugin_context.ts
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,
PluginContext
};