/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
cli/js/40_test_snapshot.js
452 строки
13 KB
Bartek Iwańczuk
feat(test): built-in snapshot testing via t.assertSnapshot (#35139)
20 июн 2026, 22:31
Не верифицирован
20 июн 2026, 22:31
2ac9a90
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. import { core, primordials } from "ext:core/mod.js"; // TODO(mmastrac): We cannot import these from "ext:core/ops" yet const { op_test_event_snapshot_summary, op_test_snapshot_in_update_mode, op_test_snapshot_read, op_test_snapshot_write, } = core.ops; const { ArrayPrototypeIncludes, ArrayPrototypeJoin, ArrayPrototypePush, Error, Map, MapPrototypeGet, MapPrototypeHas, MapPrototypeSet, SafeArrayIterator, StringPrototypeIncludes, StringPrototypeIndexOf, StringPrototypeReplaceAll, StringPrototypeSlice, StringPrototypeSplit, TypeError, } = primordials; // Capture `Deno.inspect` and `Deno.noColor` early so that user code mangling // the `Deno` global doesn't affect snapshot serialization. const DenoNs = globalThis.Deno; const inspect = DenoNs.inspect; const noColor = DenoNs.noColor; class AssertionError extends Error { name = "AssertionError"; } /** * Run state shared with `40_test.js`. Stale snapshots are only removed from * snapshot files when every test in the module had a chance to run all of * its snapshot assertions; an ignored test, an `only` run or a failed test * means some assertions may not have executed, so removal is skipped. */ export const snapshotRunState = { sawIgnored: false, sawOnly: false, sawFailure: false, }; /** * Default snapshot serializer, mirroring `@std/testing/snapshot` so that * snapshot files generated by the std library keep working unchanged. */ function serialize(actual) { return StringPrototypeReplaceAll( inspect(actual, { depth: Infinity, sorted: true, trailingComma: true, compact: false, iterableLimit: Infinity, strAbbreviateSize: Infinity, breakLength: Infinity, escapeSequences: false, }), "\r", "\\r", ); } /** * Escapes a string so it can be embedded in a template literal in the * generated snapshot file. Mirrors `@std/testing/snapshot`. */ function escapeStringForJs(str) { str = StringPrototypeReplaceAll(str, "\\", "\\\\"); str = StringPrototypeReplaceAll(str, "`", "\\`"); str = StringPrototypeReplaceAll(str, "$", "\\$"); return str; } /** * Reads a backtick-delimited string starting at `pos` (which must point right * after the opening backtick). Only the escape sequences produced by * `escapeStringForJs` are understood. Returns `{ value, end }` or `null`. */ function readBacktickString(content, pos) { let value = ""; while (pos < content.length) { const c = content[pos]; if (c === "\\") { if (pos + 1 >= content.length) { return null; } value += content[pos + 1]; pos += 2; continue; } if (c === "`") { return { value, end: pos + 1 }; } value += c; pos++; } return null; } /** * Parses a snapshot file. Only the exact format produced by the snapshot * writer (and by `@std/testing/snapshot`) is supported: * * ```js * export const snapshot = {}; * * snapshot[`name`] = `value`; * ``` * * Returns `{ names, values }` where `names` preserves file order. */ function parseSnapshotFileContent(content, filePath) { const names = []; const values = new Map(); const corrupt = () => { return new AssertionError( `Corrupt snapshot file (only snapshot files generated by the test runner or @std/testing/snapshot are supported):\n\t${filePath}`, ); }; let pos = 0; while (true) { const start = StringPrototypeIndexOf(content, "snapshot[`", pos); if (start === -1) { break; } const name = readBacktickString(content, start + 10); if (name === null) { throw corrupt(); } pos = name.end; if (StringPrototypeSlice(content, pos, pos + 4) !== "] = ") { throw corrupt(); } if (content[pos + 4] !== "`") { throw corrupt(); } const value = readBacktickString(content, pos + 5); if (value === null) { throw corrupt(); } pos = value.end; // Multi-line snapshot values are written with a leading and trailing // newline for readability; strip them back off when reading. const text = StringPrototypeIncludes(value.value, "\n") ? StringPrototypeSlice(value.value, 1, -1) : value.value; if (!MapPrototypeHas(values, name.value)) { ArrayPrototypePush(names, name.value); } MapPrototypeSet(values, name.value, text); } return { names, values }; } function red(str) { return noColor ? str : `\x1b[31m${str}\x1b[39m`; } function green(str) { return noColor ? str : `\x1b[32m${str}\x1b[39m`; } /** * Builds a simple line diff (longest common subsequence based) between the * actual and expected snapshot texts. Lines only present in `actual` are * prefixed with `+`, lines only present in `expected` with `-`. */ function buildDiffLines(actual, expected) { const a = StringPrototypeSplit(actual, "\n"); const b = StringPrototypeSplit(expected, "\n"); const out = []; if (a.length * b.length > 4_000_000) { // Too large for an LCS table; fall back to showing both versions. for (const line of new SafeArrayIterator(a)) { ArrayPrototypePush(out, green(`+ ${line}`)); } for (const line of new SafeArrayIterator(b)) { ArrayPrototypePush(out, red(`- ${line}`)); } return out; } // dp[i][j] = length of the LCS of a[i..] and b[j..] const width = b.length + 1; const dp = new Uint32Array((a.length + 1) * width); for (let i = a.length - 1; i >= 0; i--) { for (let j = b.length - 1; j >= 0; j--) { dp[i * width + j] = a[i] === b[j] ? dp[(i + 1) * width + j + 1] + 1 : (dp[(i + 1) * width + j] >= dp[i * width + j + 1] ? dp[(i + 1) * width + j] : dp[i * width + j + 1]); } } let i = 0; let j = 0; while (i < a.length && j < b.length) { if (a[i] === b[j]) { ArrayPrototypePush(out, ` ${a[i]}`); i++; j++; } else if (dp[(i + 1) * width + j] >= dp[i * width + j + 1]) { ArrayPrototypePush(out, green(`+ ${a[i]}`)); i++; } else { ArrayPrototypePush(out, red(`- ${b[j]}`)); j++; } } for (; i < a.length; i++) { ArrayPrototypePush(out, green(`+ ${a[i]}`)); } for (; j < b.length; j++) { ArrayPrototypePush(out, red(`- ${b[j]}`)); } return out; } function getSnapshotNotMatchMessage(actual, expected) { const diff = ArrayPrototypeJoin(buildDiffLines(actual, expected), "\n"); return `Snapshot does not match:\n\n ${green("[Diff]")} ${ green("Actual") } / ${ red("Expected") }\n\n${diff}\n\nTo update snapshots, run\n deno test --update-snapshots [files]...\n`; } let isUpdateMode = undefined; function getIsUpdateMode() { if (isUpdateMode === undefined) { isUpdateMode = op_test_snapshot_in_update_mode(); } return isUpdateMode; } /** * Per snapshot file state. Contexts are keyed by the resolved snapshot file * path so that different option combinations pointing at the same file share * one context (mirroring `@std/testing/snapshot`). * * @typedef {{ * locationArgs: { dir: string | undefined, path: string | undefined }, * filePath: string, * fileExists: boolean, * currentNames: string[], * currentValues: Map<string, string>, * counts: Map<string, number>, * updateQueue: string[], * updatedValues: Map<string, string>, * updatedNames: string[], * }} SnapshotContext */ /** @type {Map<string, SnapshotContext>} */ const snapshotContexts = new Map(); /** @type {SnapshotContext[]} */ const snapshotContextsList = []; function getSnapshotContext(options) { const locationArgs = { dir: options.dir, path: options.path }; const { path: filePath, content } = op_test_snapshot_read(locationArgs); let context = MapPrototypeGet(snapshotContexts, filePath); if (context !== undefined) { return context; } let parsed = { names: [], values: new Map() }; if (content !== null) { parsed = parseSnapshotFileContent(content, filePath); } context = { locationArgs, filePath, fileExists: content !== null, currentNames: parsed.names, currentValues: parsed.values, counts: new Map(), updateQueue: [], updatedValues: new Map(), updatedNames: [], }; MapPrototypeSet(snapshotContexts, filePath, context); ArrayPrototypePush(snapshotContextsList, context); return context; } function getFullTestName(tContext) { if (tContext.parent !== undefined) { return `${getFullTestName(tContext.parent)} > ${tContext.name}`; } return tContext.name; } function getErrorMessage(message, options) { return typeof options.msg === "string" ? options.msg : message; } /** * Implementation of `Deno.TestContext.assertSnapshot()`. `tContext` is the * test context object the method was called on. */ export function assertSnapshot( tContext, actual, options = { __proto__: null }, ) { if (typeof options === "string") { options = { __proto__: null, msg: options }; } else if (typeof options !== "object" || options === null) { throw new TypeError( "Expected the second argument to assertSnapshot() to be an options object or a message string", ); } const context = getSnapshotContext(options); const testName = options.name ?? getFullTestName(tContext); const count = (MapPrototypeGet(context.counts, testName) ?? 0) + 1; MapPrototypeSet(context.counts, testName, count); const name = `${testName} ${count}`; if (!ArrayPrototypeIncludes(context.updateQueue, name)) { ArrayPrototypePush(context.updateQueue, name); } const serializer = options.serializer ?? serialize; const actualSnapshot = serializer(actual); if (typeof actualSnapshot !== "string") { throw new TypeError("Snapshot serializer must return a string"); } const expectedSnapshot = MapPrototypeGet(context.currentValues, name); if (getIsUpdateMode()) { if (actualSnapshot !== expectedSnapshot) { MapPrototypeSet(context.updatedValues, name, actualSnapshot); if (!ArrayPrototypeIncludes(context.updatedNames, name)) { ArrayPrototypePush(context.updatedNames, name); } } return; } if (!context.fileExists) { throw new AssertionError( getErrorMessage("Missing snapshot file.", options), ); } if (expectedSnapshot === undefined) { throw new AssertionError( getErrorMessage(`Missing snapshot: ${name}`, options), ); } if (actualSnapshot === expectedSnapshot) { return; } throw new AssertionError( getErrorMessage( getSnapshotNotMatchMessage(actualSnapshot, expectedSnapshot), options, ), ); } function buildSnapshotFileContent(names, getValue) { const buf = ["export const snapshot = {};"]; for (const name of new SafeArrayIterator(names)) { const value = getValue(name); if (value === undefined) { continue; } let formatted = escapeStringForJs(value); formatted = StringPrototypeIncludes(formatted, "\n") ? `\n${formatted}\n` : formatted; ArrayPrototypePush( buf, `\nsnapshot[\`${escapeStringForJs(name)}\`] = \`${formatted}\`;`, ); } return ArrayPrototypeJoin(buf, "\n") + "\n"; } /** * Called by the test runner (from Rust) after all tests in the module have * finished. In update mode, writes pending snapshot updates to disk, removes * stale snapshots when safe to do so, and reports a summary to the test * reporter. No-op when not running with `--update-snapshots`. */ function flushTestSnapshots(allowStaleRemoval) { if (!getIsUpdateMode()) { return; } allowStaleRemoval = allowStaleRemoval && !snapshotRunState.sawIgnored && !snapshotRunState.sawOnly && !snapshotRunState.sawFailure; let updated = 0; const removed = []; for (const context of new SafeArrayIterator(snapshotContextsList)) { let names; const removedNames = []; if (allowStaleRemoval) { // Snapshots are written in assertion order; everything in the file // that was not asserted this run is stale and gets dropped. names = context.updateQueue; for (const name of new SafeArrayIterator(context.currentNames)) { if (!ArrayPrototypeIncludes(context.updateQueue, name)) { ArrayPrototypePush(removedNames, name); } } } else { // Keep existing entries (in file order) and append new ones. names = [...new SafeArrayIterator(context.currentNames)]; for (const name of new SafeArrayIterator(context.updateQueue)) { if (!ArrayPrototypeIncludes(names, name)) { ArrayPrototypePush(names, name); } } } if (context.updatedNames.length === 0 && removedNames.length === 0) { continue; } const content = buildSnapshotFileContent( names, (name) => MapPrototypeGet(context.updatedValues, name) ?? MapPrototypeGet(context.currentValues, name), ); op_test_snapshot_write(context.locationArgs, content); updated += context.updatedNames.length; for (const name of new SafeArrayIterator(removedNames)) { ArrayPrototypePush(removed, name); } } if (updated > 0 || removed.length > 0) { op_test_event_snapshot_summary(updated, removed); } } globalThis.Deno[globalThis.Deno.internal].flushTestSnapshots = flushTestSnapshots;