From 9b4b9d862fc08e4eac3869d4ecac1d9a13f8d1fb Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Thu, 14 May 2026 21:30:42 +0200 Subject: [PATCH 01/11] Add overload to SnapshotAssertion --- types/assertions.d.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/types/assertions.d.ts b/types/assertions.d.ts index 4dc0bbc3b..b8e6cf3d0 100644 --- a/types/assertions.d.ts +++ b/types/assertions.d.ts @@ -327,6 +327,19 @@ export type SnapshotAssertion = { */ (expected: any, message?: string): true; + /** + * Assert that the `expected` string is equal to a previously recorded + * [snapshot](https://github.com/concordancejs/concordance#serialization-details), or if necessary record a new + * snapshot. + * + * The snapshot will be formatted as a code block with the given language identifier, or a generic code block if + * `formatAsCodeBlock` is `true`. The language identifier must be non-empty. This is useful for snapshots that are + * primarily strings, such as code snippets, which are more readable as a code block. + * + * Returns `true` if the assertion passed and throws otherwise. + */ + (expected: string, options: {formatAsCodeBlock: true | string}, message?: string): true; + /** Skip this assertion. */ skip(expected: any, message?: string): void; }; From 7146c9137b4ef598a3ced663c7b2caf0bfb22df0 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Thu, 14 May 2026 21:31:10 +0200 Subject: [PATCH 02/11] Implement assertion --- lib/assert.js | 51 +++++++++++++++++++++++++++++++++++++++++++++++++-- lib/test.js | 3 ++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/lib/assert.js b/lib/assert.js index 627040d2d..2ea61a394 100644 --- a/lib/assert.js +++ b/lib/assert.js @@ -1,6 +1,7 @@ import {isNativeError} from 'node:util/types'; import concordance from 'concordance'; +import {isPlainObject} from 'is-plain-object'; import isPromise from 'is-promise'; import concordanceOptions from './concordance-options.js'; @@ -631,13 +632,59 @@ export class Assertions { return handlePromise(retval, true); }); - this.snapshot = withSkip((expected, message) => { + this.snapshot = withSkip((expected, ...options) => { if (disableSnapshots) { throw fail(new AssertionError('`t.snapshot()` can only be used in tests', { assertion: 't.snapshot()', })); } + let message; + let formatAsCodeBlock; + if (options.length > 0) { + if (typeof options[0] === 'object') { + if (!isPlainObject(options[0])) { + throw fail(new AssertionError('The options argument must be a plain object', { + assertion: 't.snapshot()', + formattedDetails: [formatWithLabel('Called with:', options[0])], + })); + } + + if (typeof expected !== 'string') { + throw fail(new AssertionError('The first argument must be a string when options are provided', { + assertion: 't.snapshot()', + formattedDetails: [formatWithLabel('Called with:', expected)], + })); + } + + [{formatAsCodeBlock}, message] = options; + if (formatAsCodeBlock === undefined) { + throw fail(new AssertionError('The options object must contain the `formatAsCodeBlock` property', { + assertion: 't.snapshot()', + formattedDetails: [formatWithLabel('Called with:', options[0])], + })); + } + + if (formatAsCodeBlock !== true && typeof formatAsCodeBlock !== 'string') { + throw fail(new AssertionError('The `formatAsCodeBlock` option must be `true` or a string', { + assertion: 't.snapshot()', + formattedDetails: [formatWithLabel('Called with:', formatAsCodeBlock)], + })); + } + + if (formatAsCodeBlock === '') { + throw fail(new AssertionError('The `formatAsCodeBlock` option must be a non-empty string when it is not `true`', { + assertion: 't.snapshot()', + formattedDetails: [formatWithLabel('Called with:', formatAsCodeBlock)], + })); + } + } else { + [message] = options; + } + } + + formatAsCodeBlock ??= false; + assertMessage(message, 't.snapshot()'); if (message === '') { @@ -649,7 +696,7 @@ export class Assertions { let result; try { - result = compareWithSnapshot({expected, message}); + result = compareWithSnapshot({expected, formatAsCodeBlock, message}); } catch (error) { if (!(error instanceof SnapshotError)) { throw error; diff --git a/lib/test.js b/lib/test.js index 5a598bbd1..56b40502b 100644 --- a/lib/test.js +++ b/lib/test.js @@ -218,7 +218,7 @@ export default class Test { const deferRecording = this.metadata.inline; this.deferredSnapshotRecordings = []; - this.compareWithSnapshot = ({expected, message}) => { + this.compareWithSnapshot = ({expected, formatAsCodeBlock, message}) => { this.snapshotCount++; const belongsTo = snapshotBelongsTo; @@ -229,6 +229,7 @@ export default class Test { belongsTo, deferRecording, expected, + formatAsCodeBlock, index, label, taskIndex: this.metadata.taskIndex, From 0c90f1b4c039ffc917ee8df7086a0c8d4f12e983 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Thu, 14 May 2026 21:54:46 +0200 Subject: [PATCH 03/11] Implement in snapshot manager --- lib/snapshot-manager.js | 86 ++++++++++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 18 deletions(-) diff --git a/lib/snapshot-manager.js b/lib/snapshot-manager.js index 647f7fcfd..8f197ae76 100644 --- a/lib/snapshot-manager.js +++ b/lib/snapshot-manager.js @@ -23,8 +23,17 @@ const VERSION = 3; const VERSION_HEADER = Buffer.alloc(2); VERSION_HEADER.writeUInt16LE(VERSION); +// Version 4 adds support for rawValue snapshots (the formatAsCodeBlock option). Version 3 snapshots can still be read +// by AVA 8.1+. This version is only used when a snapshot file contains at least one rawValue entry. +const RAW_VALUE_VERSION = 4; + +const RAW_VALUE_VERSION_HEADER = Buffer.alloc(2); +RAW_VALUE_VERSION_HEADER.writeUInt16LE(RAW_VALUE_VERSION); + // The decoder matches on the trailing newline byte (0x0A). const READABLE_PREFIX = Buffer.from(`AVA Snapshot v${VERSION}\n`, 'ascii'); +const RAW_VALUE_VERSION_READABLE_PREFIX = Buffer.from(`AVA Snapshot v${RAW_VALUE_VERSION}\n`, 'ascii'); + const REPORT_SEPARATOR = Buffer.from('\n\n', 'ascii'); const REPORT_TRAILING_NEWLINE = Buffer.from('\n', 'ascii'); @@ -88,12 +97,26 @@ function tryRead(file) { function formatEntry(snapshot, index) { const { data, + rawValue, + formatAsCodeBlock, label = `Snapshot ${index + 1}`, // Human-readable labels start counting at 1. } = snapshot; - const description = data - ? concordance.formatDescriptor(concordance.deserialize(data), concordanceOptions) - : ''; + let description; + if (rawValue !== undefined) { + const language = formatAsCodeBlock === true ? '' : formatAsCodeBlock; + let longestBacktickRun = 0; + for (const run of rawValue.match(/`+/g) ?? []) { + longestBacktickRun = Math.max(longestBacktickRun, run.length); + } + + const fence = '`'.repeat(Math.max(3, longestBacktickRun + 1)); + description = `${fence}${language}\n${rawValue}\n${fence}`; + } else if (data) { + description = concordance.formatDescriptor(concordance.deserialize(data), concordanceOptions); + } else { + description = ''; + } const blockquote = label.split(/\n/).map(line => '> ' + line).join('\n'); @@ -180,7 +203,10 @@ function sortBlocks(blocksByTitle, blockIndices) { }); } -async function encodeSnapshots(snapshotData) { +async function encodeSnapshots(snapshotData, version) { + const readablePrefix = version === RAW_VALUE_VERSION ? RAW_VALUE_VERSION_READABLE_PREFIX : READABLE_PREFIX; + const versionHeader = version === RAW_VALUE_VERSION ? RAW_VALUE_VERSION_HEADER : VERSION_HEADER; + const encoded = await cbor.encodeAsync(snapshotData, { omitUndefinedProperties: true, canonical: true, @@ -189,11 +215,11 @@ async function encodeSnapshots(snapshotData) { compressed[9] = 0x03; // Override the GZip header containing the OS to always be Linux const sha256sum = crypto.createHash('sha256').update(compressed).digest(); return Buffer.concat([ - READABLE_PREFIX, - VERSION_HEADER, + readablePrefix, + versionHeader, sha256sum, compressed, - ], READABLE_PREFIX.byteLength + VERSION_HEADER.byteLength + SHA_256_HASH_LENGTH + compressed.byteLength); + ], readablePrefix.byteLength + versionHeader.byteLength + SHA_256_HASH_LENGTH + compressed.byteLength); } export function extractCompressedSnapshot(buffer, snapPath) { @@ -210,7 +236,7 @@ export function extractCompressedSnapshot(buffer, snapPath) { const versionOffset = newline + 1; const version = buffer.readUInt16LE(versionOffset); - if (version !== VERSION) { + if (version !== VERSION && version !== RAW_VALUE_VERSION) { throw new VersionMismatchError(snapPath, version); } @@ -267,8 +293,9 @@ class Manager { const snapshot = block?.snapshots[options.index]; const data = snapshot?.data; + const rawValue = snapshot?.rawValue; - if (!data) { + if (data === undefined && rawValue === undefined) { if (!this.recordNewSnapshots) { return {pass: false}; } @@ -282,6 +309,17 @@ class Manager { return {pass: true}; } + if (rawValue !== undefined) { + const pass = rawValue === options.expected; + if (pass) { + return {pass}; + } + + const actual = concordance.describe(rawValue, concordanceOptions); + const expected = concordance.describe(options.expected, concordanceOptions); + return {actual, expected, pass}; + } + const actual = concordance.deserialize(data, concordanceOptions); const expected = concordance.describe(options.expected, concordanceOptions); const pass = concordance.compareDescriptors(actual, expected); @@ -289,34 +327,45 @@ class Manager { return {actual, expected, pass}; } - recordSerialized({data, label, belongsTo, index}) { + recordSerialized({data, rawValue, formatAsCodeBlock, label, belongsTo, index}) { const block = this.newBlocksByTitle.get(belongsTo) ?? {snapshots: []}; const {snapshots} = block; if (index > snapshots.length) { throw new RangeError(`Cannot record snapshot ${index} for ${JSON.stringify(belongsTo)}, exceeds expected index of ${snapshots.length}`); } else if (index < snapshots.length) { - if (snapshots[index].data) { + if (snapshots[index].data || snapshots[index].rawValue !== undefined) { throw new RangeError(`Cannot record snapshot ${index} for ${JSON.stringify(belongsTo)}, already exists`); } - snapshots[index] = {data, label}; + snapshots[index] = { + data, rawValue, formatAsCodeBlock, label, + }; } else { - snapshots.push({data, label}); + snapshots.push({ + data, rawValue, formatAsCodeBlock, label, + }); } this.newBlocksByTitle.set(belongsTo, block); } deferRecord(options) { - const {expected, belongsTo, label, index} = options; - const descriptor = concordance.describe(expected, concordanceOptions); - const data = concordance.serialize(descriptor); + const {expected, formatAsCodeBlock, belongsTo, label, index} = options; + + let data; + let rawValue; + if (formatAsCodeBlock) { + rawValue = expected; + } else { + const descriptor = concordance.describe(expected, concordanceOptions); + data = concordance.serialize(descriptor); + } return () => { // Must be called in order! this.hasChanges = true; this.recordSerialized({ - data, label, belongsTo, index, + data, rawValue, formatAsCodeBlock: formatAsCodeBlock || undefined, label, belongsTo, index, }); }; } @@ -369,7 +418,8 @@ class Manager { blocks: sortBlocks(this.newBlocksByTitle, this.blockIndices).map(([title, block]) => ({title, ...block})), }; - const buffer = await encodeSnapshots(snapshots); + const hasRawValues = snapshots.blocks.some(({snapshots: blockSnapshots}) => blockSnapshots.some(snapshot => snapshot.rawValue !== undefined)); + const buffer = await encodeSnapshots(snapshots, hasRawValues ? RAW_VALUE_VERSION : VERSION); const reportBuffer = generateReport(relFile, snapFile, snapshots); await fs.promises.mkdir(dir, {recursive: true}); From e3de91df1fa871cfc8b68e941ea5bd992a176c34 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Thu, 14 May 2026 22:07:11 +0200 Subject: [PATCH 04/11] Add assertion tests --- test-tap/assert.js | 69 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/test-tap/assert.js b/test-tap/assert.js index 06986bac0..0e01cb137 100644 --- a/test-tap/assert.js +++ b/test-tap/assert.js @@ -1541,7 +1541,7 @@ test('.snapshot()', async t => { const assertions = setup('bad message'); failsWith(t, () => assertions.snapshot(null, null), { assertion: 't.snapshot()', - message: 'The assertion message must be a string', + message: 'The options argument must be a plain object', formattedDetails: [{ label: 'Called with:', formatted: /null/, @@ -1562,6 +1562,73 @@ test('.snapshot()', async t => { t.end(); }); +test('.snapshot() with formatAsCodeBlock option', t => { + // Validation: non-string expected value when options are provided + { + const a = new AssertionsBase(); + failsWith(t, () => a.snapshot(42, {formatAsCodeBlock: true}), { + assertion: 't.snapshot()', + message: 'The first argument must be a string when options are provided', + formattedDetails: [{label: 'Called with:', formatted: /42/}], + }); + } + + // Validation: options object missing formatAsCodeBlock + { + const a = new AssertionsBase(); + failsWith(t, () => a.snapshot('str', {}), { + assertion: 't.snapshot()', + message: 'The options object must contain the `formatAsCodeBlock` property', + formattedDetails: [{label: 'Called with:', formatted: /{}/}], + }); + } + + // Validation: formatAsCodeBlock is not true or string + { + const a = new AssertionsBase(); + failsWith(t, () => a.snapshot('str', {formatAsCodeBlock: false}), { + assertion: 't.snapshot()', + message: 'The `formatAsCodeBlock` option must be `true` or a string', + formattedDetails: [{label: 'Called with:', formatted: /false/}], + }); + } + + // Validation: formatAsCodeBlock is an empty string + { + const a = new AssertionsBase(); + failsWith(t, () => a.snapshot('str', {formatAsCodeBlock: ''}), { + assertion: 't.snapshot()', + message: 'The `formatAsCodeBlock` option must be a non-empty string when it is not `true`', + formattedDetails: [{label: 'Called with:', formatted: '\'\''}], + }); + } + + // CompareWithSnapshot is called with the correct formatAsCodeBlock value + { + let capturedOptions; + const a = new AssertionsBase({ + compareWithSnapshot(options) { + capturedOptions = options; + return {pass: true}; + }, + }); + + passes(t, () => a.snapshot('hello', {formatAsCodeBlock: true})); + t.equal(capturedOptions.expected, 'hello'); + t.equal(capturedOptions.formatAsCodeBlock, true); + + passes(t, () => a.snapshot('hello', {formatAsCodeBlock: 'javascript'})); + t.equal(capturedOptions.expected, 'hello'); + t.equal(capturedOptions.formatAsCodeBlock, 'javascript'); + + // Without options, formatAsCodeBlock defaults to false + passes(t, () => a.snapshot('hello')); + t.equal(capturedOptions.formatAsCodeBlock, false); + } + + t.end(); +}); + test('.truthy()', t => { failsWith(t, () => assertions.truthy(0), { assertion: 't.truthy()', From 7d8383b20b78edb5e67cb82ecd153aa7a96b1f7b Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Thu, 14 May 2026 22:12:12 +0200 Subject: [PATCH 05/11] Add integration tests --- test/snapshot-tests/code-block.js | 22 +++++++++++ .../fixtures/code-block-snapshot/package.json | 1 + .../fixtures/code-block-snapshot/test.js | 7 ++++ .../snapshot-tests/snapshots/code-block.js.md | 36 ++++++++++++++++++ .../snapshots/code-block.js.snap | Bin 0 -> 356 bytes 5 files changed, 66 insertions(+) create mode 100644 test/snapshot-tests/code-block.js create mode 100644 test/snapshot-tests/fixtures/code-block-snapshot/package.json create mode 100644 test/snapshot-tests/fixtures/code-block-snapshot/test.js create mode 100644 test/snapshot-tests/snapshots/code-block.js.md create mode 100644 test/snapshot-tests/snapshots/code-block.js.snap diff --git a/test/snapshot-tests/code-block.js b/test/snapshot-tests/code-block.js new file mode 100644 index 000000000..197f18a0a --- /dev/null +++ b/test/snapshot-tests/code-block.js @@ -0,0 +1,22 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import test from '@ava/test'; + +import {cwd, fixture} from '../helpers/exec.js'; +import {withTemporaryFixture} from '../helpers/with-temporary-fixture.js'; + +test('formatAsCodeBlock option renders fenced code blocks in the snapshot report', async t => { + await withTemporaryFixture(cwd('code-block-snapshot'), async cwd => { + await fixture(['--update-snapshots'], { + cwd, + env: { + AVA_FORCE_CI: 'not-ci', + }, + }); + + const reportPath = path.join(cwd, 'test.js.md'); + const report = fs.readFileSync(reportPath, {encoding: 'utf8'}); + t.snapshot(report, 'resulting snapshot report'); + }); +}); diff --git a/test/snapshot-tests/fixtures/code-block-snapshot/package.json b/test/snapshot-tests/fixtures/code-block-snapshot/package.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/test/snapshot-tests/fixtures/code-block-snapshot/package.json @@ -0,0 +1 @@ +{} diff --git a/test/snapshot-tests/fixtures/code-block-snapshot/test.js b/test/snapshot-tests/fixtures/code-block-snapshot/test.js new file mode 100644 index 000000000..75251b0f4 --- /dev/null +++ b/test/snapshot-tests/fixtures/code-block-snapshot/test.js @@ -0,0 +1,7 @@ +const {default: test} = await import(process.env.TEST_AVA_IMPORT_FROM); + +test('code block snapshots', t => { + t.snapshot('hello world', {formatAsCodeBlock: true}); + t.snapshot('console.log("hello")', {formatAsCodeBlock: 'javascript'}); + t.snapshot('text with ``` backticks ```', {formatAsCodeBlock: true}); +}); diff --git a/test/snapshot-tests/snapshots/code-block.js.md b/test/snapshot-tests/snapshots/code-block.js.md new file mode 100644 index 000000000..8fd2f84db --- /dev/null +++ b/test/snapshot-tests/snapshots/code-block.js.md @@ -0,0 +1,36 @@ +# Snapshot report for `test/snapshot-tests/code-block.js` + +The actual snapshot is saved in `code-block.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## formatAsCodeBlock option renders fenced code blocks in the snapshot report + +> resulting snapshot report + + `# Snapshot report for \`test.js\`␊ + ␊ + The actual snapshot is saved in \`test.js.snap\`.␊ + ␊ + Generated by [AVA](https://avajs.dev).␊ + ␊ + ## code block snapshots␊ + ␊ + > Snapshot 1␊ + ␊ + \`\`\`␊ + hello world␊ + \`\`\`␊ + ␊ + > Snapshot 2␊ + ␊ + \`\`\`javascript␊ + console.log("hello")␊ + \`\`\`␊ + ␊ + > Snapshot 3␊ + ␊ + \`\`\`\`␊ + text with \`\`\` backticks \`\`\`␊ + \`\`\`\`␊ + ` diff --git a/test/snapshot-tests/snapshots/code-block.js.snap b/test/snapshot-tests/snapshots/code-block.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..c4149b464b7e4887de1730c0a010735940e96437 GIT binary patch literal 356 zcmV-q0h|6oRzVFs5sqB_+tC&m3dvCs(B;Q5uf?n`uOJc>*`nd@46J^2=!O(rFi5q2Gg)&eUlWg|h3dLg!X@4ep!$ze`zeHn?DPOP8({f8gk5X=DN0RR9R CX{GuA literal 0 HcmV?d00001 From a1f033a53d835432bda3ee72862943769b5b5edb Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Thu, 14 May 2026 22:20:49 +0200 Subject: [PATCH 06/11] Sanitize line numbers for lib/ files in reporter logs --- test-tap/helper/report.js | 3 ++- test-tap/reporters/default.js | 1 + test-tap/reporters/tap.failfast.v22.log | 2 +- test-tap/reporters/tap.failfast.v24.log | 2 +- test-tap/reporters/tap.failfast.v25.log | 2 +- test-tap/reporters/tap.failfast2.v22.log | 2 +- test-tap/reporters/tap.failfast2.v24.log | 2 +- test-tap/reporters/tap.failfast2.v25.log | 2 +- test-tap/reporters/tap.js | 1 + test-tap/reporters/tap.regular.v22.log | 16 ++++++++-------- test-tap/reporters/tap.regular.v24.log | 16 ++++++++-------- test-tap/reporters/tap.regular.v25.log | 16 ++++++++-------- 12 files changed, 34 insertions(+), 31 deletions(-) diff --git a/test-tap/helper/report.js b/test-tap/helper/report.js index 8370c31e3..ac99df170 100644 --- a/test-tap/helper/report.js +++ b/test-tap/helper/report.js @@ -45,7 +45,8 @@ exports.sanitizers = { esmLoader: string => string.split('\n').filter(line => !line.includes('› async node:internal/modules/esm/loader:')).join('\n'), experimentalWarning: string => string.replaceAll(/^\(node:\d+\) ExperimentalWarning.+\n/g, ''), lineEndings: string => string.replaceAll('\r\n', '\n'), - // The following are invjected by tap@18. + libLineNumbers: string => string.replaceAll(/\((\/lib\/.+\.js):\d+:\d+\)/g, '($1)'), + // The following are injected by tap@18. posix: string => string.replaceAll('\\', '/'), tapLoaders: string => string.replaceAll(/.+(Module\._compile|node_modules.pirates|require\.extensions).+\r?\n/g, ''), timers: string => string.replaceAll(/timers\.js:\d+:\d+/g, 'timers.js'), diff --git a/test-tap/reporters/default.js b/test-tap/reporters/default.js index f04b5945e..151caeb3a 100644 --- a/test-tap/reporters/default.js +++ b/test-tap/reporters/default.js @@ -23,6 +23,7 @@ test(async t => { report.sanitizers.cwd, report.sanitizers.esmLoader, report.sanitizers.experimentalWarning, + report.sanitizers.libLineNumbers, report.sanitizers.posix, report.sanitizers.tapLoaders, report.sanitizers.timers, diff --git a/test-tap/reporters/tap.failfast.v22.log b/test-tap/reporters/tap.failfast.v22.log index 91999f942..67e77c8a0 100644 --- a/test-tap/reporters/tap.failfast.v22.log +++ b/test-tap/reporters/tap.failfast.v22.log @@ -5,7 +5,7 @@ not ok 1 - a › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:288:15)' + at: 'ExecutionContext.fail (/lib/assert.js)' ... ---tty-stream-chunk-separator diff --git a/test-tap/reporters/tap.failfast.v24.log b/test-tap/reporters/tap.failfast.v24.log index 91999f942..67e77c8a0 100644 --- a/test-tap/reporters/tap.failfast.v24.log +++ b/test-tap/reporters/tap.failfast.v24.log @@ -5,7 +5,7 @@ not ok 1 - a › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:288:15)' + at: 'ExecutionContext.fail (/lib/assert.js)' ... ---tty-stream-chunk-separator diff --git a/test-tap/reporters/tap.failfast.v25.log b/test-tap/reporters/tap.failfast.v25.log index 91999f942..67e77c8a0 100644 --- a/test-tap/reporters/tap.failfast.v25.log +++ b/test-tap/reporters/tap.failfast.v25.log @@ -5,7 +5,7 @@ not ok 1 - a › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:288:15)' + at: 'ExecutionContext.fail (/lib/assert.js)' ... ---tty-stream-chunk-separator diff --git a/test-tap/reporters/tap.failfast2.v22.log b/test-tap/reporters/tap.failfast2.v22.log index 5f93b2d15..37835de4c 100644 --- a/test-tap/reporters/tap.failfast2.v22.log +++ b/test-tap/reporters/tap.failfast2.v22.log @@ -5,7 +5,7 @@ not ok 1 - a › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:288:15)' + at: 'ExecutionContext.fail (/lib/assert.js)' ... ---tty-stream-chunk-separator # 1 test remaining in a.js diff --git a/test-tap/reporters/tap.failfast2.v24.log b/test-tap/reporters/tap.failfast2.v24.log index 5f93b2d15..37835de4c 100644 --- a/test-tap/reporters/tap.failfast2.v24.log +++ b/test-tap/reporters/tap.failfast2.v24.log @@ -5,7 +5,7 @@ not ok 1 - a › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:288:15)' + at: 'ExecutionContext.fail (/lib/assert.js)' ... ---tty-stream-chunk-separator # 1 test remaining in a.js diff --git a/test-tap/reporters/tap.failfast2.v25.log b/test-tap/reporters/tap.failfast2.v25.log index 5f93b2d15..37835de4c 100644 --- a/test-tap/reporters/tap.failfast2.v25.log +++ b/test-tap/reporters/tap.failfast2.v25.log @@ -5,7 +5,7 @@ not ok 1 - a › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:288:15)' + at: 'ExecutionContext.fail (/lib/assert.js)' ... ---tty-stream-chunk-separator # 1 test remaining in a.js diff --git a/test-tap/reporters/tap.js b/test-tap/reporters/tap.js index d2aad9d1d..bac73e913 100644 --- a/test-tap/reporters/tap.js +++ b/test-tap/reporters/tap.js @@ -23,6 +23,7 @@ test(async t => { report.sanitizers.cwd, report.sanitizers.esmLoader, report.sanitizers.experimentalWarning, + report.sanitizers.libLineNumbers, report.sanitizers.posix, report.sanitizers.tapLoaders, report.sanitizers.timers, diff --git a/test-tap/reporters/tap.regular.v22.log b/test-tap/reporters/tap.regular.v22.log index 6d9689e3f..6d54396d3 100644 --- a/test-tap/reporters/tap.regular.v22.log +++ b/test-tap/reporters/tap.regular.v22.log @@ -30,7 +30,7 @@ not ok 3 - nested-objects › format with max depth 4 + }, } message: '' - at: 'ExecutionContext.deepEqual (/lib/assert.js:340:15)' + at: 'ExecutionContext.deepEqual (/lib/assert.js)' ... ---tty-stream-chunk-separator not ok 4 - nested-objects › format like with max depth 4 @@ -48,7 +48,7 @@ not ok 4 - nested-objects › format like with max depth 4 }, } message: '' - at: 'ExecutionContext.like (/lib/assert.js:392:15)' + at: 'ExecutionContext.like (/lib/assert.js)' ... ---tty-stream-chunk-separator # output-in-hook › before hook @@ -72,7 +72,7 @@ not ok 6 - output-in-hook › failing test name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:288:15)' + at: 'ExecutionContext.fail (/lib/assert.js)' ... ---tty-stream-chunk-separator # output-in-hook › afterEach hook for passing test @@ -102,7 +102,7 @@ not ok 10 - test › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:288:15)' + at: 'ExecutionContext.fail (/lib/assert.js)' ... ---tty-stream-chunk-separator ok 11 - test › known failure @@ -113,7 +113,7 @@ not ok 12 - test › no longer failing message: >- Test was expected to fail, but succeeded, you should stop marking the test as failing - at: 'Test.finish (/lib/test.js:642:7)' + at: 'Test.finish (/lib/test.js)' ... ---tty-stream-chunk-separator not ok 13 - test › logs @@ -123,7 +123,7 @@ not ok 13 - test › logs name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:288:15)' + at: 'ExecutionContext.fail (/lib/assert.js)' ... ---tty-stream-chunk-separator not ok 14 - test › formatted @@ -135,7 +135,7 @@ not ok 14 - test › formatted - 'foo' + 'bar' message: '' - at: 'ExecutionContext.deepEqual (/lib/assert.js:340:15)' + at: 'ExecutionContext.deepEqual (/lib/assert.js)' ... ---tty-stream-chunk-separator not ok 15 - test › implementation throws non-error @@ -144,7 +144,7 @@ not ok 15 - test › implementation throws non-error details: 'Error thrown in test:': 'null' message: Error thrown in test - at: 'Test.run (/lib/test.js:553:25)' + at: 'Test.run (/lib/test.js)' ... ---tty-stream-chunk-separator not ok 16 - traces-in-t-throws › throws diff --git a/test-tap/reporters/tap.regular.v24.log b/test-tap/reporters/tap.regular.v24.log index 6d9689e3f..6d54396d3 100644 --- a/test-tap/reporters/tap.regular.v24.log +++ b/test-tap/reporters/tap.regular.v24.log @@ -30,7 +30,7 @@ not ok 3 - nested-objects › format with max depth 4 + }, } message: '' - at: 'ExecutionContext.deepEqual (/lib/assert.js:340:15)' + at: 'ExecutionContext.deepEqual (/lib/assert.js)' ... ---tty-stream-chunk-separator not ok 4 - nested-objects › format like with max depth 4 @@ -48,7 +48,7 @@ not ok 4 - nested-objects › format like with max depth 4 }, } message: '' - at: 'ExecutionContext.like (/lib/assert.js:392:15)' + at: 'ExecutionContext.like (/lib/assert.js)' ... ---tty-stream-chunk-separator # output-in-hook › before hook @@ -72,7 +72,7 @@ not ok 6 - output-in-hook › failing test name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:288:15)' + at: 'ExecutionContext.fail (/lib/assert.js)' ... ---tty-stream-chunk-separator # output-in-hook › afterEach hook for passing test @@ -102,7 +102,7 @@ not ok 10 - test › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:288:15)' + at: 'ExecutionContext.fail (/lib/assert.js)' ... ---tty-stream-chunk-separator ok 11 - test › known failure @@ -113,7 +113,7 @@ not ok 12 - test › no longer failing message: >- Test was expected to fail, but succeeded, you should stop marking the test as failing - at: 'Test.finish (/lib/test.js:642:7)' + at: 'Test.finish (/lib/test.js)' ... ---tty-stream-chunk-separator not ok 13 - test › logs @@ -123,7 +123,7 @@ not ok 13 - test › logs name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:288:15)' + at: 'ExecutionContext.fail (/lib/assert.js)' ... ---tty-stream-chunk-separator not ok 14 - test › formatted @@ -135,7 +135,7 @@ not ok 14 - test › formatted - 'foo' + 'bar' message: '' - at: 'ExecutionContext.deepEqual (/lib/assert.js:340:15)' + at: 'ExecutionContext.deepEqual (/lib/assert.js)' ... ---tty-stream-chunk-separator not ok 15 - test › implementation throws non-error @@ -144,7 +144,7 @@ not ok 15 - test › implementation throws non-error details: 'Error thrown in test:': 'null' message: Error thrown in test - at: 'Test.run (/lib/test.js:553:25)' + at: 'Test.run (/lib/test.js)' ... ---tty-stream-chunk-separator not ok 16 - traces-in-t-throws › throws diff --git a/test-tap/reporters/tap.regular.v25.log b/test-tap/reporters/tap.regular.v25.log index 6d9689e3f..6d54396d3 100644 --- a/test-tap/reporters/tap.regular.v25.log +++ b/test-tap/reporters/tap.regular.v25.log @@ -30,7 +30,7 @@ not ok 3 - nested-objects › format with max depth 4 + }, } message: '' - at: 'ExecutionContext.deepEqual (/lib/assert.js:340:15)' + at: 'ExecutionContext.deepEqual (/lib/assert.js)' ... ---tty-stream-chunk-separator not ok 4 - nested-objects › format like with max depth 4 @@ -48,7 +48,7 @@ not ok 4 - nested-objects › format like with max depth 4 }, } message: '' - at: 'ExecutionContext.like (/lib/assert.js:392:15)' + at: 'ExecutionContext.like (/lib/assert.js)' ... ---tty-stream-chunk-separator # output-in-hook › before hook @@ -72,7 +72,7 @@ not ok 6 - output-in-hook › failing test name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:288:15)' + at: 'ExecutionContext.fail (/lib/assert.js)' ... ---tty-stream-chunk-separator # output-in-hook › afterEach hook for passing test @@ -102,7 +102,7 @@ not ok 10 - test › fails name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:288:15)' + at: 'ExecutionContext.fail (/lib/assert.js)' ... ---tty-stream-chunk-separator ok 11 - test › known failure @@ -113,7 +113,7 @@ not ok 12 - test › no longer failing message: >- Test was expected to fail, but succeeded, you should stop marking the test as failing - at: 'Test.finish (/lib/test.js:642:7)' + at: 'Test.finish (/lib/test.js)' ... ---tty-stream-chunk-separator not ok 13 - test › logs @@ -123,7 +123,7 @@ not ok 13 - test › logs name: AssertionError assertion: t.fail() message: Test failed via `t.fail()` - at: 'ExecutionContext.fail (/lib/assert.js:288:15)' + at: 'ExecutionContext.fail (/lib/assert.js)' ... ---tty-stream-chunk-separator not ok 14 - test › formatted @@ -135,7 +135,7 @@ not ok 14 - test › formatted - 'foo' + 'bar' message: '' - at: 'ExecutionContext.deepEqual (/lib/assert.js:340:15)' + at: 'ExecutionContext.deepEqual (/lib/assert.js)' ... ---tty-stream-chunk-separator not ok 15 - test › implementation throws non-error @@ -144,7 +144,7 @@ not ok 15 - test › implementation throws non-error details: 'Error thrown in test:': 'null' message: Error thrown in test - at: 'Test.run (/lib/test.js:553:25)' + at: 'Test.run (/lib/test.js)' ... ---tty-stream-chunk-separator not ok 16 - traces-in-t-throws › throws From 5467676b40ffaf018499186ae3b1bb2faeaf559f Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Thu, 14 May 2026 22:26:04 +0200 Subject: [PATCH 07/11] Add to docs --- docs/03-assertions.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/03-assertions.md b/docs/03-assertions.md index f5800b063..430ee188a 100644 --- a/docs/03-assertions.md +++ b/docs/03-assertions.md @@ -271,6 +271,17 @@ Assert that `contents` does not match `regex`. Compares the `expected` value with a previously recorded snapshot. Snapshots are stored for each test, so ensure you give your tests unique titles. +### `.snapshot(expected, {formatAsCodeBlock}, message?)` + +Only accepted when `expected` is a string. The string is stored verbatim and rendered as a fenced code block in the resulting `.snap.md` file. Set `formatAsCodeBlock` to `true` for a generic code block, or a non-empty language identifier string for syntax highlighting: + +```js +test('generates code', t => { + const code = generate(); + t.snapshot(code, {formatAsCodeBlock: 'js'}); +}); +``` + ### `.try(title?, implementation | macro, ...args?)` `.try()` allows you to *try* assertions without causing the test to fail. From c46228bf3cc479e1663978308c8858686795dd2f Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Fri, 15 May 2026 20:23:03 +0200 Subject: [PATCH 08/11] fixup! Add assertion tests --- test-tap/assert.js | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test-tap/assert.js b/test-tap/assert.js index 0e01cb137..d55b8b8cd 100644 --- a/test-tap/assert.js +++ b/test-tap/assert.js @@ -1626,6 +1626,40 @@ test('.snapshot() with formatAsCodeBlock option', t => { t.equal(capturedOptions.formatAsCodeBlock, false); } + // Mismatch detection: rawValue comparison returns concordance descriptors for the diff + { + const manager = snapshotManager.load({ + recordNewSnapshots: true, + updating: true, + }); + let callIndex = 0; + const a = new AssertionsBase({ + compareWithSnapshot(assertionOptions) { + const {record, ...result} = manager.compare({ + belongsTo: 'test', + expected: assertionOptions.expected, + formatAsCodeBlock: assertionOptions.formatAsCodeBlock, + index: callIndex++, + label: `Snapshot ${callIndex}`, + }); + if (record) { + record(); + } + + return result; + }, + }); + + passes(t, () => a.snapshot('hello', {formatAsCodeBlock: true})); + + callIndex = 0; + failsWith(t, () => a.snapshot('world', {formatAsCodeBlock: true}), { + assertion: 't.snapshot()', + message: 'Did not match snapshot', + formattedDetails: [{label: 'Difference (- actual, + expected):', formatted: /.+/}], + }); + } + t.end(); }); From 6c2851a48487d23c53a8ef25f39b0e3c90a2595e Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Fri, 15 May 2026 20:36:19 +0200 Subject: [PATCH 09/11] fixup! fixup! Add assertion tests --- test-tap/assert.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test-tap/assert.js b/test-tap/assert.js index d55b8b8cd..3dd539570 100644 --- a/test-tap/assert.js +++ b/test-tap/assert.js @@ -1652,6 +1652,9 @@ test('.snapshot() with formatAsCodeBlock option', t => { passes(t, () => a.snapshot('hello', {formatAsCodeBlock: true})); + callIndex = 0; + passes(t, () => a.snapshot('hello', {formatAsCodeBlock: true})); + callIndex = 0; failsWith(t, () => a.snapshot('world', {formatAsCodeBlock: true}), { assertion: 't.snapshot()', From 323078e10bf61656369a4023f2df8b9df0034d35 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Fri, 15 May 2026 20:37:03 +0200 Subject: [PATCH 10/11] fixup! Implement in snapshot manager --- lib/snapshot-manager.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/snapshot-manager.js b/lib/snapshot-manager.js index 8f197ae76..a785e2597 100644 --- a/lib/snapshot-manager.js +++ b/lib/snapshot-manager.js @@ -113,14 +113,14 @@ function formatEntry(snapshot, index) { const fence = '`'.repeat(Math.max(3, longestBacktickRun + 1)); description = `${fence}${language}\n${rawValue}\n${fence}`; } else if (data) { - description = concordance.formatDescriptor(concordance.deserialize(data), concordanceOptions); + description = indentString(concordance.formatDescriptor(concordance.deserialize(data), concordanceOptions), 4); } else { - description = ''; + description = indentString('', 4); } const blockquote = label.split(/\n/).map(line => '> ' + line).join('\n'); - return `${blockquote}\n\n${indentString(description, 4)}`; + return `${blockquote}\n\n${description}`; } function combineEntries({blocks}) { From ba42e729e5e0de54a4478e6f8a31e899115b45b3 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Fri, 15 May 2026 20:37:19 +0200 Subject: [PATCH 11/11] fixup! Add integration tests --- .../snapshot-tests/snapshots/code-block.js.md | 18 +++++++++--------- .../snapshots/code-block.js.snap | Bin 356 -> 347 bytes 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/test/snapshot-tests/snapshots/code-block.js.md b/test/snapshot-tests/snapshots/code-block.js.md index 8fd2f84db..6c62085e6 100644 --- a/test/snapshot-tests/snapshots/code-block.js.md +++ b/test/snapshot-tests/snapshots/code-block.js.md @@ -18,19 +18,19 @@ Generated by [AVA](https://avajs.dev). ␊ > Snapshot 1␊ ␊ - \`\`\`␊ - hello world␊ - \`\`\`␊ + \`\`\`␊ + hello world␊ + \`\`\`␊ ␊ > Snapshot 2␊ ␊ - \`\`\`javascript␊ - console.log("hello")␊ - \`\`\`␊ + \`\`\`javascript␊ + console.log("hello")␊ + \`\`\`␊ ␊ > Snapshot 3␊ ␊ - \`\`\`\`␊ - text with \`\`\` backticks \`\`\`␊ - \`\`\`\`␊ + \`\`\`\`␊ + text with \`\`\` backticks \`\`\`␊ + \`\`\`\`␊ ` diff --git a/test/snapshot-tests/snapshots/code-block.js.snap b/test/snapshot-tests/snapshots/code-block.js.snap index c4149b464b7e4887de1730c0a010735940e96437..8da27b30b1508ea834ef1b81e7df3a3a00d83755 100644 GIT binary patch literal 347 zcmV-h0i^yxRzV46XO~j0J}%%T z&%3)}%pZ#g00000000AJkUvWUK@i0gMTG4v?S`aDnhR=YAw&zoRuM#mox7dOZL;nh zJCjQ+^HIzvl{<-vI^E;F-~9V5^T5SIHd|7!fz}Ttr+3t;+_9tE)&s89bA&|EQ4%OA z7UZB{NAL}UkDwKSM69J^g+w(c^*b$_t#hdO(w`jnPJ3zC%Zw5+EV3kC( zxmaVKDgrEYiDBQ}3kWvk+OfN6*19$`=AI%YRC17i!0YsR`ZlUmYq_3Gu)(=xj+$|1 z%y4*c!B1~7=4QXrg)!DzQ&9*ED^9_6`Y>|YMdsL`6v@|W3XZ~ovcR*^pd$z4f7De+ tS);U8Sb43$S_?TA3-#?kTdxaHz?{PRJdrGe`Z)Xh<1Z&{kQu-M002Wuq!9oB literal 356 zcmV-q0h|6oRzVFs5sqB_+tC&m3dvCs(B;Q5uf?n`uOJc>*`nd@46J^2=!O(rFi5q2Gg)&eUlWg|h3dLg!X@4ep!$ze`zeHn?DPOP8({f8gk5X=DN0RR9R CX{GuA