import { fromFileUrl, parse, resolve, toFileUrl } from "../path/mod.ts";import { ensureFile, ensureFileSync } from "../fs/mod.ts";import { bold, green, red } from "../fmt/colors.ts";import { assert, AssertionError, equal } from "./asserts.ts";import { buildMessage, diff, diffstr } from "./_diff.ts";
const CAN_NOT_DISPLAY = "[Cannot display]";const SNAPSHOT_DIR = "__snapshots__";const SNAPSHOT_EXT = "snap";
export type SnapshotMode = "assert" | "update";
export type SnapshotOptions<T = unknown> = { dir?: string; mode?: SnapshotMode; msg?: string; name?: string; path?: string; serializer?: (actual: T) => string;};
function getErrorMessage(message: string, options: SnapshotOptions) { return typeof options.msg === "string" ? options.msg : message;}
export function serialize(actual: unknown): string;export function serialize<T>(actual: T): string;export function serialize(actual: unknown): string { return Deno.inspect(actual, { depth: Infinity, sorted: true, trailingComma: true, compact: false, iterableLimit: Infinity, strAbbreviateSize: Infinity, }).replace(/\\n/g, "\n");}
function escapeStringForJs(str: string) { return str .replace(/\\/g, "\\\\") .replace(/`/g, "\\`") .replace(/\$/g, "\\$");}
let _mode: SnapshotMode;function getMode(options: SnapshotOptions) { if (options.mode) { return options.mode; } else if (_mode) { return _mode; } else { _mode = Deno.args.some((arg) => arg === "--update" || arg === "-u") ? "update" : "assert"; return _mode; }}
function getIsUpdate(options: SnapshotOptions) { return getMode(options) === "update";}
class AssertSnapshotContext { static contexts = new Map<string, AssertSnapshotContext>();
static fromOptions( testContext: Deno.TestContext, options: SnapshotOptions, ): AssertSnapshotContext { let path: string; const testFilePath = fromFileUrl(testContext.origin); const { dir, base } = parse(testFilePath); if (options.path) { path = resolve(dir, options.path); } else if (options.dir) { path = resolve(dir, options.dir, `${base}.${SNAPSHOT_EXT}`); } else { path = resolve(dir, SNAPSHOT_DIR, `${base}.${SNAPSHOT_EXT}`); }
let context = this.contexts.get(path); if (context) { return context; }
context = new this(toFileUrl(path)); this.contexts.set(path, context); return context; }
#teardownRegistered = false; #currentSnapshots: Map<string, string | undefined> | undefined; #updatedSnapshots = new Map<string, string>(); #snapshotCounts = new Map<string, number>(); #snapshotsUpdated = new Array<string>(); #snapshotFileUrl: URL; snapshotUpdateQueue = new Array<string>();
constructor(snapshotFileUrl: URL) { this.#snapshotFileUrl = snapshotFileUrl; }
#getCurrentSnapshotsInitialized() { assert( this.#currentSnapshots, "Snapshot was not initialized. This is a bug in `assertSnapshot`.", ); return this.#currentSnapshots; }
#teardown = () => { const buf = ["export const snapshot = {};"]; const currentSnapshots = this.#getCurrentSnapshotsInitialized(); this.snapshotUpdateQueue.forEach((name) => { const updatedSnapshot = this.#updatedSnapshots.get(name); const currentSnapshot = currentSnapshots.get(name); let formattedSnapshot: string; if (typeof updatedSnapshot === "string") { formattedSnapshot = updatedSnapshot; } else if (typeof currentSnapshot === "string") { formattedSnapshot = currentSnapshot; } else { return; } formattedSnapshot = escapeStringForJs(formattedSnapshot); formattedSnapshot = formattedSnapshot.includes("\n") ? `\n${formattedSnapshot}\n` : formattedSnapshot; const formattedName = escapeStringForJs(name); buf.push(`\nsnapshot[\`${formattedName}\`] = \`${formattedSnapshot}\`;`); }); const snapshotFilePath = fromFileUrl(this.#snapshotFileUrl); ensureFileSync(snapshotFilePath); Deno.writeTextFileSync(snapshotFilePath, buf.join("\n") + "\n");
const contexts = Array.from(AssertSnapshotContext.contexts.values()); if (contexts[contexts.length - 1] === this) { let updated = 0; for (const context of contexts) { updated += context.getUpdatedCount(); } if (updated > 0) { console.log( green(bold(`\n > ${updated} snapshots updated.`)), ); } } };
async #readSnapshotFile(options: SnapshotOptions) { if (this.#currentSnapshots) { return this.#currentSnapshots; }
if (getIsUpdate(options)) { await ensureFile(fromFileUrl(this.#snapshotFileUrl)); }
try { const snapshotFileUrl = this.#snapshotFileUrl.toString(); const { snapshot } = await import(snapshotFileUrl); this.#currentSnapshots = typeof snapshot === "undefined" ? new Map() : new Map( Object.entries(snapshot).map(([name, snapshot]) => { if (typeof snapshot !== "string") { throw new AssertionError( getErrorMessage( `Corrupt snapshot:\n\t(${name})\n\t${snapshotFileUrl}`, options, ), ); } return [ name, snapshot.includes("\n") ? snapshot.slice(1, -1) : snapshot, ]; }), ); return this.#currentSnapshots; } catch (error) { if ( error instanceof TypeError && error.message.startsWith("Module not found") ) { throw new AssertionError( getErrorMessage( "Missing snapshot file.", options, ), ); } throw error; } }
public registerTeardown() { if (!this.#teardownRegistered) { globalThis.addEventListener("unload", this.#teardown); this.#teardownRegistered = true; } }
public getCount(snapshotName: string) { let count = this.#snapshotCounts.get(snapshotName) || 0; this.#snapshotCounts.set(snapshotName, ++count); return count; }
public async getSnapshot(snapshotName: string, options: SnapshotOptions) { const snapshots = await this.#readSnapshotFile(options); return snapshots.get(snapshotName); }
public updateSnapshot(snapshotName: string, snapshot: string) { if (!this.#snapshotsUpdated.includes(snapshotName)) { this.#snapshotsUpdated.push(snapshotName); } const currentSnapshots = this.#getCurrentSnapshotsInitialized(); if (!currentSnapshots.has(snapshotName)) { currentSnapshots.set(snapshotName, undefined); } this.#updatedSnapshots.set(snapshotName, snapshot); }
public getUpdatedCount() { return this.#snapshotsUpdated.length; }
public pushSnapshotToUpdateQueue(snapshotName: string) { this.snapshotUpdateQueue.push(snapshotName); }
public hasSnapshot(snapshotName: string): boolean { return this.#currentSnapshots ? this.#currentSnapshots.has(snapshotName) : false; }}
export async function assertSnapshot<T>( context: Deno.TestContext, actual: T, options: SnapshotOptions<T>,): Promise<void>;export async function assertSnapshot<T>( context: Deno.TestContext, actual: T, message?: string,): Promise<void>;export async function assertSnapshot( context: Deno.TestContext, actual: unknown, msgOrOpts?: string | SnapshotOptions<unknown>,): Promise<void> { const options = getOptions(); const assertSnapshotContext = AssertSnapshotContext.fromOptions( context, options, ); const testName = getTestName(context, options); const count = assertSnapshotContext.getCount(testName); const name = `${testName} ${count}`; const snapshot = await assertSnapshotContext.getSnapshot( name, options, );
assertSnapshotContext.pushSnapshotToUpdateQueue(name); const _serialize = options.serializer || serialize; const _actual = _serialize(actual); if (getIsUpdate(options)) { assertSnapshotContext.registerTeardown(); if (!equal(_actual, snapshot)) { assertSnapshotContext.updateSnapshot(name, _actual); } } else { if ( !assertSnapshotContext.hasSnapshot(name) || typeof snapshot === "undefined" ) { throw new AssertionError( getErrorMessage(`Missing snapshot: ${name}`, options), ); } if (equal(_actual, snapshot)) { return; } let message = ""; try { const stringDiff = !_actual.includes("\n"); const diffResult = stringDiff ? diffstr(_actual, snapshot) : diff(_actual.split("\n"), snapshot.split("\n")); const diffMsg = buildMessage(diffResult, { stringDiff }).join("\n"); message = `Snapshot does not match:\n${diffMsg}`; } catch { message = `Snapshot does not match:\n${red(CAN_NOT_DISPLAY)} \n\n`; } throw new AssertionError( getErrorMessage(message, options), ); }
function getOptions(): SnapshotOptions { if (typeof msgOrOpts === "object" && msgOrOpts !== null) { return msgOrOpts; }
return { msg: msgOrOpts, }; } function getTestName( context: Deno.TestContext, options?: SnapshotOptions, ): string { if (options && options.name) { return options.name; } else if (context.parent) { return `${getTestName(context.parent)} > ${context.name}`; } return context.name; }}
export function createAssertSnapshot<T>( options: SnapshotOptions<T>, baseAssertSnapshot: typeof assertSnapshot = assertSnapshot,): typeof assertSnapshot { return async function _assertSnapshot( context: Deno.TestContext, actual: T, messageOrOptions?: string | SnapshotOptions<T>, ): Promise<void> { const mergedOptions: SnapshotOptions<T> = { ...options, ...(typeof messageOrOptions === "string" ? { msg: messageOrOptions, } : messageOrOptions), };
await baseAssertSnapshot(context, actual, mergedOptions); };}