UNPKG

@japa/snapshot

Version:

Snapshot testing plugin for Japa

570 lines (560 loc) 16.9 kB
// 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 };