// Derived from Node.js v24.16.0 (MIT; Copyright Node.js contributors, and Joyent, Inc. and // other Node contributors). See LICENSE-node. // node:internal/test_runner/snapshot.js: SnapshotFile (one per entry file, backed by a // `.snapshot` sibling written as plain JS — `exports[\`key\`] = \`value\`;` lines), // SnapshotManager (resolves the file, serializes, compares or records), and the two // `test.snapshot.*` setters. `t.assert.snapshot`/`t.assert.fileSnapshot` are wired in // test.ts's TestContext#assert getter via createAssert()/createFileAssert(), one // SnapshotManager per harness (test_runner/test.ts's lazy assert-map bootstrap), // matching Node's own lazyAssertObject(harness). // // Node evaluates the snapshot file with vm.createContext + runInContext, handing it a // bare `exports` object; a Function constructor given only `exports` as a parameter is // the same sandboxing this runtime-privileged code needs (it is not guest code // reached through the CSP'd virtual origin — see DESIGN.md's refusals section for what // *is* refused there). import { assert } from "../assert"; import { invalidArgType, invalidState } from "../errors"; const kMissingSnapshotTip = "Missing snapshots can be generated by rerunning the command with the --test-update-snapshots flag."; const defaultSerializers: Array<(value: unknown) => unknown> = [(value) => JSON.stringify(value, null, 2)]; function defaultResolveSnapshotPath(testPath: unknown): unknown { if (typeof testPath !== "string") return testPath; return `${testPath}.snapshot`; } let resolveSnapshotPathFn: (testPath: unknown) => unknown = defaultResolveSnapshotPath; let serializerFns: Array<(value: unknown) => unknown> = defaultSerializers; // No shared validateFunction/validateObject/validateString/validateArray copy exists // in src/runtime; this module's small local copies mirror internal/validators.js. function validateFunction(value: unknown, name: string): asserts value is (...args: unknown[]) => unknown { if (typeof value !== "function") throw invalidArgType(name, "of type function", value); } function validateObject(value: unknown, name: string): void { if (value === null || Array.isArray(value) || typeof value !== "object") throw invalidArgType(name, "of type object", value); } function validateString(value: unknown, name: string): void { if (typeof value !== "string") throw invalidArgType(name, "of type string", value); } function validateArray(value: unknown, name: string): void { if (!Array.isArray(value)) throw invalidArgType(name, "an instance of Array", value); } function validateFunctionArray(fns: unknown, name: string): void { validateArray(fns, name); for (let i = 0; i < (fns as unknown[]).length; i++) validateFunction((fns as unknown[])[i], `${name}[${i}]`); } export function setResolveSnapshotPath(fn: unknown): void { validateFunction(fn, "fn"); resolveSnapshotPathFn = fn as (testPath: unknown) => unknown; } export function setDefaultSnapshotSerializers(serializers: unknown): void { validateFunctionArray(serializers, "serializers"); serializerFns = (serializers as Array<(value: unknown) => unknown>).slice(0); } /** fs/path sync hooks SnapshotFile needs, bound once per worker from test.ts's * makeTest the way harness.ts's bindProcess binds the guest process. Snapshot files * are read and written through the kernel's own fs, like Node's require('fs'). */ export interface SnapshotFsHooks { readFileSync(path: string, encoding: "utf8"): string; writeFileSync(path: string, data: string, encoding: "utf8"): void; mkdirSync(path: string, options: { recursive: boolean }): void; dirname(path: string): string; } let fsHooks: SnapshotFsHooks | null = null; export function bindSnapshotFs(hooks: SnapshotFsHooks): void { fsHooks = hooks; } function throwReadError(err: unknown, filename: string): never { let msg = `Cannot read snapshot file '${filename}.'`; if ((err as { code?: string } | null)?.code === "ENOENT") msg += ` ${kMissingSnapshotTip}`; throw Object.assign(invalidState(msg), { cause: err, filename }); } function throwWriteError(err: unknown, filename: string): never { throw Object.assign(invalidState(`Cannot write snapshot file '${filename}.'`), { cause: err, filename }); } function throwSerializationError(input: unknown, err: unknown): never { throw Object.assign(invalidState("The provided serializers did not generate a string."), { input, cause: err }); } function serializeValue(value: unknown, serializers: Array<(value: unknown) => unknown>): unknown { let v = value; for (const fn of serializers) v = fn(v); return v; } /** Node's escapeSnapshotKey/templateEscape: both escape backslashes, backticks and * `${` the same way (a snapshot id and a snapshot value are both embedded in a * template literal on write) — escapeSnapshotKey additionally runs the id through * JSON.stringify first (so a key with newlines or quotes stays on one line) and * strips the surrounding quotes JSON.stringify added. */ function escapeSnapshotKey(str: string): string { let result = JSON.stringify(str).slice(1, -1); result = result.replaceAll("`", "\\`").replaceAll("${", "\\${"); return result; } function templateEscape(str: unknown): string { let result = String(str); result = result.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${"); return result; } class SnapshotFile { snapshotFile: string; snapshots: Record = Object.create(null); #nameCounts = new Map(); loaded = false; constructor(snapshotFile: string) { this.snapshotFile = snapshotFile; } getSnapshot(id: string): string { if (!(id in this.snapshots)) { throw Object.assign(invalidState(`Snapshot '${id}' not found in '${this.snapshotFile}.' ${kMissingSnapshotTip}`), { snapshot: id, filename: this.snapshotFile, }); } return this.snapshots[id]!; } setSnapshot(id: string, value: string): void { this.snapshots[escapeSnapshotKey(id)] = value; } nextId(name: string): string { const count = this.#nameCounts.get(name) ?? 1; this.#nameCounts.set(name, count + 1); return `${name} ${count}`; } readFile(): void { if (this.loaded) return; try { const source = fsHooks!.readFileSync(this.snapshotFile, "utf8"); const exportsObj: Record = Object.create(null); new Function("exports", source)(exportsObj); if (exportsObj === null || typeof exportsObj !== "object") throw invalidState(`Malformed snapshot file '${this.snapshotFile}'.`); for (const key in exportsObj) this.snapshots[key] = templateEscape(exportsObj[key]); this.loaded = true; } catch (err) { throwReadError(err, this.snapshotFile); } } writeFile(): void { try { const keys = Object.keys(this.snapshots).sort(); const output = keys.map((key) => `exports[\`${key}\`] = \`${this.snapshots[key]}\`;\n`).join("\n"); fsHooks!.mkdirSync(fsHooks!.dirname(this.snapshotFile), { recursive: true }); fsHooks!.writeFileSync(this.snapshotFile, output, "utf8"); } catch (err) { throwWriteError(err, this.snapshotFile); } } } export interface SnapshotAssertContext { filePath?: string; fullName: string; } export class SnapshotManager { updateSnapshots: boolean; #cache = new Map(); constructor(updateSnapshots: boolean) { this.updateSnapshots = updateSnapshots; } resolveSnapshotFile(entryFile: string | undefined): SnapshotFile { let snapshotFile = this.#cache.get(entryFile ?? ""); if (snapshotFile === undefined) { const resolved = resolveSnapshotPathFn(entryFile); if (typeof resolved !== "string") throw Object.assign(invalidState("Invalid snapshot filename."), { filename: resolved }); snapshotFile = new SnapshotFile(resolved); snapshotFile.loaded = this.updateSnapshots; this.#cache.set(entryFile ?? "", snapshotFile); } return snapshotFile; } serialize(input: unknown, serializers: Array<(value: unknown) => unknown> = serializerFns): string { try { const value = serializeValue(input, serializers); return `\n${templateEscape(value)}\n`; } catch (err) { throwSerializationError(input, err); } } serializeWithoutEscape(input: unknown, serializers: Array<(value: unknown) => unknown> = serializerFns): string { try { return serializeValue(input, serializers) as string; } catch (err) { throwSerializationError(input, err); } } writeSnapshotFiles(): void { if (!this.updateSnapshots) return; for (const snapshotFile of this.#cache.values()) snapshotFile.writeFile(); } createAssert(): (this: SnapshotAssertContext, actual: unknown, options?: unknown) => void { const manager = this; return function snapshotAssertion(this: SnapshotAssertContext, actual: unknown, options: unknown = {}): void { validateObject(options, "options"); const { serializers = serializerFns } = options as { serializers?: Array<(value: unknown) => unknown> }; validateFunctionArray(serializers, "options.serializers"); const { filePath, fullName } = this; const snapshotFile = manager.resolveSnapshotFile(filePath); const value = manager.serialize(actual, serializers); const id = snapshotFile.nextId(fullName); if (manager.updateSnapshots) { snapshotFile.setSnapshot(id, value); } else { snapshotFile.readFile(); assert.strictEqual(value, snapshotFile.getSnapshot(id)); } }; } createFileAssert(): (this: SnapshotAssertContext, actual: unknown, path: unknown, options?: unknown) => void { const manager = this; return function fileSnapshotAssertion(this: SnapshotAssertContext, actual: unknown, path: unknown, options: unknown = {}): void { validateString(path, "path"); validateObject(options, "options"); const { serializers = serializerFns } = options as { serializers?: Array<(value: unknown) => unknown> }; validateFunctionArray(serializers, "options.serializers"); const value = manager.serializeWithoutEscape(actual, serializers); if (manager.updateSnapshots) { try { fsHooks!.mkdirSync(fsHooks!.dirname(path as string), { recursive: true }); fsHooks!.writeFileSync(path as string, value, "utf8"); } catch (err) { throwWriteError(err, path as string); } } else { let expected: string; try { expected = fsHooks!.readFileSync(path as string, "utf8"); } catch (err) { throwReadError(err, path as string); } assert.strictEqual(value, expected); } }; } }