UNPKG

@japa/snapshot

Version:

Snapshot testing plugin for Japa

342 lines (341 loc) 11.9 kB
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 };