From 8180d4405312eb12496101d958c275aff8662a5f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 25 Mar 2026 19:26:20 +0900 Subject: [PATCH 01/57] feat: support custom snapshot matcher --- .../__snapshots__/basic.test.ts.snap | 8 ++ .../fixtures/custom-matcher/basic.test.ts | 75 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 test/snapshots/test/fixtures/custom-matcher/__snapshots__/basic.test.ts.snap create mode 100644 test/snapshots/test/fixtures/custom-matcher/basic.test.ts diff --git a/test/snapshots/test/fixtures/custom-matcher/__snapshots__/basic.test.ts.snap b/test/snapshots/test/fixtures/custom-matcher/__snapshots__/basic.test.ts.snap new file mode 100644 index 000000000000..ef9905eb6122 --- /dev/null +++ b/test/snapshots/test/fixtures/custom-matcher/__snapshots__/basic.test.ts.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`custom file snapshot matcher 1`] = ` +Object { + "length": 5, + "reversed": "olleh", +} +`; diff --git a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts new file mode 100644 index 000000000000..3a59432401d7 --- /dev/null +++ b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from 'vitest' + +// custom snapshot matcher to wraper input code string +interface CustomMatchers { + toMatchCustomSnapshot: () => R + toMatchCustomInlineSnapshot: (snapshot?: string) => R +} + +declare module 'vitest' { + interface Assertion extends CustomMatchers {} + interface AsymmetricMatchersContaining extends CustomMatchers {} +} + +function toCustomSnapshot(received: string) { + return { + reversed: received.split('').reverse().join(''), + length: received.length, + } +} + +expect.extend({ + toMatchCustomSnapshot(received: string) { + const receviedCustom = toCustomSnapshot(received) + const result = this.snapshotState.match({ + // TODO: + // it sort of works, but these `testId/testName` aren't exactly what we expect. + // @ts-expect-error todo + testId: this.task.id, + // @ts-expect-error todo + testName: this.currentTestName, + received: receviedCustom, + }) + + return { + pass: result.pass, + message: () => `Snapshot \`${result.key || 'unknown'}\` mismatched`, + actual: result.actual?.trim(), + expected: result.expected?.trim(), + } + }, + // toMatchCustomInlineSnapshot: function __INLINE_SNAPSHOT_OFFSET_3__( + // received: string, + // inlineSnapshot?: string, + // ) { + // const result = this.snapshotState.match({ + // testId: this.task.id, + // testName: this.currentTestName, + // received: formatCodeframe(received), + // isInline: true, + // inlineSnapshot, + // error: new Error('snapshot'), + // }) + + // return { + // pass: result.pass, + // message: () => `Snapshot \`${result.key || 'unknown'}\` mismatched`, + // actual: result.actual?.trim(), + // expected: result.expected?.trim(), + // } + // }, +}) + +test('custom file snapshot matcher', () => { + expect(`hello`).toMatchCustomSnapshot() +}) + +// test('custom inline snapshot matcher', () => { +// expect(` +// const answer = 42 +// throw new Error(String(answer)) +// `).toMatchInlineCodeframeSnapshot(` +// "1 | const answer = 42 +// 2 | throw new Error(String(answer))" +// `) +// }) From 101cbe1b550c3f1532f9f7ee2257ecf98ca023f5 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 26 Mar 2026 16:40:23 +0900 Subject: [PATCH 02/57] wip: API shape --- packages/expect/src/jest-extend.ts | 8 +- packages/expect/src/types.ts | 6 + .../vitest/src/integrations/snapshot/chai.ts | 109 +++++++++++++++++- 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/packages/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index cc5d313177e6..727d5a515d0f 100644 --- a/packages/expect/src/jest-extend.ts +++ b/packages/expect/src/jest-extend.ts @@ -22,6 +22,7 @@ import { wrapAssertion } from './utils' function getMatcherState( assertion: Chai.AssertionStatic & Chai.Assertion, expect: ExpectStatic, + assertionName: string, ) { const obj = assertion._obj const isNot = util.flag(assertion, 'negate') as boolean @@ -43,6 +44,11 @@ function getMatcherState( const matcherState: MatcherState = { ...getState(expect), + __vitest_context: { + chaiAssertion: assertion, + chaiUtils: util, + assertionName, + }, task, currentTestName, customTesters: getCustomEqualityTesters(), @@ -87,7 +93,7 @@ function JestExtendPlugin( this: Chai.AssertionStatic & Chai.Assertion, ...args: any[] ) { - const { state, isNot, obj, customMessage } = getMatcherState(this, expect) + const { state, isNot, obj, customMessage } = getMatcherState(this, expect, expectAssertionName) const result = expectAssertion.call(state, obj, ...args) diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index dc12f71a4fd3..895625968b92 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -84,6 +84,12 @@ export interface MatcherState { soft?: boolean poll?: boolean task?: Readonly + /** @internal */ + __vitest_context: { + chaiAssertion: Chai.AssertionStatic & Chai.Assertion + chaiUtils: Chai.ChaiUtils + assertionName: string + } } export interface SyncExpectationResult { diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 908f1061d616..17740e203d9b 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -1,4 +1,4 @@ -import type { Assertion, ChaiPlugin } from '@vitest/expect' +import type { Assertion, ChaiPlugin, MatcherState } from '@vitest/expect' import type { Test } from '@vitest/runner' import { createAssertionMessage, equals, iterableEquality, recordAsyncExpect, subsetEquality, wrapAssertion } from '@vitest/expect' import { getNames } from '@vitest/runner/utils' @@ -227,3 +227,110 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { ) utils.addMethod(chai.expect, 'addSnapshotSerializer', addSerializer) } + +// TODO: use impl for above builtin snapshot API too. +function toMatchSnapshotImpl( + assertion: Chai.AssertionStatic & Chai.Assertion, + utils: Chai.ChaiUtils, + assertionName: string, + received: unknown, + propertiesOrHint?: object, + hint?: string, +): void { + utils.flag(assertion, '_name', assertionName) + const isNot = utils.flag(assertion, 'negate') + if (isNot) { + throw new Error(`${assertionName} cannot be used with "not"`) + } + const test = utils.flag(assertion, 'vitest-test') as Test | undefined + if (!test) { + throw new Error(`'${assertionName}' cannot be used without test context`) + } + if (typeof propertiesOrHint === 'string' && typeof hint === 'undefined') { + hint = propertiesOrHint + propertiesOrHint = undefined + } + // TODO: implement non-throwing variant for jest matcher convention (likely SnapshotClient.match) + getSnapshotClient().assert({ + received, + message: hint, + isInline: false, + properties: propertiesOrHint, + errorMessage: utils.flag(assertion, 'message'), + ...getTestNames(test), + }) +} + +function toMatchInlineSnapshotImpl( + assertion: Chai.AssertionStatic & Chai.Assertion, + utils: Chai.ChaiUtils, + assertionName: string, + received: unknown, + propertiesOrHint?: object, + inlineSnapshot?: string, + hint?: string, +) { + utils.flag(assertion, '_name', assertionName) + const isNot = utils.flag(assertion, 'negate') + if (isNot) { + throw new Error(`${assertionName} cannot be used with "not"`) + } + const test = utils.flag(assertion, 'vitest-test') as Test | undefined + if (!test) { + throw new Error(`'${assertionName}' cannot be used without test context`) + } + if (typeof propertiesOrHint === 'string') { + hint = inlineSnapshot + inlineSnapshot = propertiesOrHint + propertiesOrHint = undefined + } + if (inlineSnapshot) { + inlineSnapshot = stripSnapshotIndentation(inlineSnapshot) + } + // TODO: non-throwing + // TODO: pass `assertionName` to help stack probing + getSnapshotClient().assert({ + received, + message: hint, + isInline: true, + properties: propertiesOrHint, + inlineSnapshot, + error: utils.flag(assertion, 'error'), // set by `.resolves/rejects` wrapper + errorMessage: utils.flag(assertion, 'message'), + ...getTestNames(test), + }) +} + +export function toMatchSnapshot( + this: MatcherState, + received: unknown, + propertiesOrHint?: object, + hint?: string, +): void { + return toMatchSnapshotImpl( + this.__vitest_context.chaiAssertion, + this.__vitest_context.chaiUtils, + this.__vitest_context.assertionName, + received, + propertiesOrHint, + hint, + ) +} + +export function toMatchInlineSnapshot( + this: MatcherState, + received: unknown, + propertiesOrHint?: object, + inlineSnapshot?: string, + hint?: string, +): void { + return toMatchInlineSnapshotImpl( + this.__vitest_context.chaiAssertion, + this.__vitest_context.chaiUtils, + this.__vitest_context.assertionName, + received, + propertiesOrHint, + inlineSnapshot, + hint, + ) +} From 394cd3ded753a8dd1b9f4811eb7c901665e2209e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 26 Mar 2026 16:42:42 +0900 Subject: [PATCH 03/57] chore: todo --- packages/vitest/src/integrations/snapshot/chai.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 17740e203d9b..1bad80ac2f93 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -295,8 +295,9 @@ function toMatchInlineSnapshotImpl( isInline: true, properties: propertiesOrHint, inlineSnapshot, - error: utils.flag(assertion, 'error'), // set by `.resolves/rejects` wrapper errorMessage: utils.flag(assertion, 'message'), + // set by async assertion (e.g. resolves/rejects) for stack probing + error: utils.flag(assertion, 'error'), ...getTestNames(test), }) } From 4198ff81a46547e8841585a796030f55fe13b51f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 26 Mar 2026 16:58:06 +0900 Subject: [PATCH 04/57] wip: test --- .../vitest/src/integrations/snapshot/chai.ts | 4 +- packages/vitest/src/public/runtime.ts | 1 + test/core/test/exports.test.ts | 2 + .../__snapshots__/basic.test.ts.snap | 4 +- .../fixtures/custom-matcher/basic.test.ts | 66 +++++-------------- 5 files changed, 25 insertions(+), 52 deletions(-) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 1bad80ac2f93..69f882ccd332 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -266,7 +266,7 @@ function toMatchInlineSnapshotImpl( utils: Chai.ChaiUtils, assertionName: string, received: unknown, - propertiesOrHint?: object, + propertiesOrHint?: object | string, inlineSnapshot?: string, hint?: string, ) { @@ -321,7 +321,7 @@ export function toMatchSnapshot( export function toMatchInlineSnapshot( this: MatcherState, received: unknown, - propertiesOrHint?: object, + propertiesOrHint?: object | string, inlineSnapshot?: string, hint?: string, ): void { diff --git a/packages/vitest/src/public/runtime.ts b/packages/vitest/src/public/runtime.ts index f8d048111bf7..080ff790f97a 100644 --- a/packages/vitest/src/public/runtime.ts +++ b/packages/vitest/src/public/runtime.ts @@ -11,6 +11,7 @@ import { getWorkerState } from '../runtime/utils' export { environments as builtinEnvironments } from '../integrations/env/index' export { populateGlobal } from '../integrations/env/utils' +export { toMatchInlineSnapshot, toMatchSnapshot } from '../integrations/snapshot/chai' export { VitestNodeSnapshotEnvironment as VitestSnapshotEnvironment } from '../integrations/snapshot/environments/node' export type { Environment, diff --git a/test/core/test/exports.test.ts b/test/core/test/exports.test.ts index 1829c30a757c..d8b9e7557f97 100644 --- a/test/core/test/exports.test.ts +++ b/test/core/test/exports.test.ts @@ -165,6 +165,8 @@ it('exports snapshot', async ({ skip, task }) => { "__INTERNAL": "object", "builtinEnvironments": "object", "populateGlobal": "function", + "toMatchInlineSnapshot": "function", + "toMatchSnapshot": "function", }, "./snapshot": { "VitestSnapshotEnvironment": "function", diff --git a/test/snapshots/test/fixtures/custom-matcher/__snapshots__/basic.test.ts.snap b/test/snapshots/test/fixtures/custom-matcher/__snapshots__/basic.test.ts.snap index ef9905eb6122..59a8b089611b 100644 --- a/test/snapshots/test/fixtures/custom-matcher/__snapshots__/basic.test.ts.snap +++ b/test/snapshots/test/fixtures/custom-matcher/__snapshots__/basic.test.ts.snap @@ -2,7 +2,7 @@ exports[`custom file snapshot matcher 1`] = ` Object { - "length": 5, - "reversed": "olleh", + "length": 6, + "reversed": "ahahah", } `; diff --git a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts index 3a59432401d7..917419eeae90 100644 --- a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts +++ b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts @@ -1,4 +1,5 @@ import { expect, test } from 'vitest' +import { toMatchInlineSnapshot, toMatchSnapshot } from "vitest/runtime" // custom snapshot matcher to wraper input code string interface CustomMatchers { @@ -11,65 +12,34 @@ declare module 'vitest' { interface AsymmetricMatchersContaining extends CustomMatchers {} } -function toCustomSnapshot(received: string) { +function formatCustom(received: string) { return { reversed: received.split('').reverse().join(''), length: received.length, } } +// TODO: make snapshot helper non-throwing and return { pass, ... } expect.extend({ toMatchCustomSnapshot(received: string) { - const receviedCustom = toCustomSnapshot(received) - const result = this.snapshotState.match({ - // TODO: - // it sort of works, but these `testId/testName` aren't exactly what we expect. - // @ts-expect-error todo - testId: this.task.id, - // @ts-expect-error todo - testName: this.currentTestName, - received: receviedCustom, - }) - - return { - pass: result.pass, - message: () => `Snapshot \`${result.key || 'unknown'}\` mismatched`, - actual: result.actual?.trim(), - expected: result.expected?.trim(), - } + const receivedCustom = formatCustom(received) + toMatchSnapshot.call(this, receivedCustom) + return { pass: true, message: () => '' } + }, + toMatchCustomInlineSnapshot( + received: string, + inlineSnapshot?: string, + ) { + const receivedCustom = formatCustom(received) + toMatchInlineSnapshot.call(this, receivedCustom, inlineSnapshot) + return { pass: true, message: () => '' } }, - // toMatchCustomInlineSnapshot: function __INLINE_SNAPSHOT_OFFSET_3__( - // received: string, - // inlineSnapshot?: string, - // ) { - // const result = this.snapshotState.match({ - // testId: this.task.id, - // testName: this.currentTestName, - // received: formatCodeframe(received), - // isInline: true, - // inlineSnapshot, - // error: new Error('snapshot'), - // }) - - // return { - // pass: result.pass, - // message: () => `Snapshot \`${result.key || 'unknown'}\` mismatched`, - // actual: result.actual?.trim(), - // expected: result.expected?.trim(), - // } - // }, }) test('custom file snapshot matcher', () => { - expect(`hello`).toMatchCustomSnapshot() + expect(`hahaha`).toMatchCustomSnapshot() }) -// test('custom inline snapshot matcher', () => { -// expect(` -// const answer = 42 -// throw new Error(String(answer)) -// `).toMatchInlineCodeframeSnapshot(` -// "1 | const answer = 42 -// 2 | throw new Error(String(answer))" -// `) -// }) +test.skip('custom inline snapshot matcher', () => { + expect(`hehehe`).toMatchCustomInlineSnapshot() +}) From 66700de0510e595aa178cd89d14e01be4ef418f0 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 26 Mar 2026 17:45:14 +0900 Subject: [PATCH 05/57] wip: generalize inline snapshot stack probing --- packages/snapshot/src/client.ts | 3 + packages/snapshot/src/port/inlineSnapshot.ts | 64 ++++++++++++------- packages/snapshot/src/port/state.ts | 21 +++++- packages/snapshot/src/types/index.ts | 1 + .../vitest/src/integrations/snapshot/chai.ts | 3 +- .../fixtures/custom-matcher/basic.test.ts | 9 ++- 6 files changed, 71 insertions(+), 30 deletions(-) diff --git a/packages/snapshot/src/client.ts b/packages/snapshot/src/client.ts index cd7f7d229ac7..1bfdc90e980d 100644 --- a/packages/snapshot/src/client.ts +++ b/packages/snapshot/src/client.ts @@ -48,6 +48,7 @@ interface AssertOptions { error?: Error errorMessage?: string rawSnapshot?: RawSnapshotInfo + method?: string } export interface SnapshotClientOptions { @@ -111,6 +112,7 @@ export class SnapshotClient { error, errorMessage, rawSnapshot, + method, } = options let { received } = options @@ -158,6 +160,7 @@ export class SnapshotClient { error, inlineSnapshot, rawSnapshot, + method, }) if (!pass) { diff --git a/packages/snapshot/src/port/inlineSnapshot.ts b/packages/snapshot/src/port/inlineSnapshot.ts index 185080f54ba6..379a2a598818 100644 --- a/packages/snapshot/src/port/inlineSnapshot.ts +++ b/packages/snapshot/src/port/inlineSnapshot.ts @@ -13,6 +13,7 @@ export interface InlineSnapshot { file: string line: number column: number + method?: string } export async function saveInlineSnapshots( @@ -33,7 +34,7 @@ export async function saveInlineSnapshots( for (const snap of snaps) { const index = positionToOffset(code, snap.line, snap.column) - replaceInlineSnap(code, s, index, snap.snapshot) + replaceInlineSnap(code, s, index, snap.snapshot, snap.method) } const transformed = s.toString() @@ -44,17 +45,29 @@ export async function saveInlineSnapshots( ) } -const startObjectRegex +const defaultStartObjectRegex = /(?:toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot)\s*\(\s*(?:\/\*[\s\S]*\*\/\s*|\/\/.*(?:[\n\r\u2028\u2029]\s*|[\t\v\f \xA0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF]))*\{/ +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function buildStartObjectRegex(method: string): RegExp { + return new RegExp( + `${escapeRegExp(method)}\\s*\\(\\s*(?:\\/\\*[\\s\\S]*\\*\\/\\s*|\\/\\/.*(?:[\\n\\r\\u2028\\u2029]\\s*|[\\t\\v\\f \\xA0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000\\uFEFF]))*\\{`, + ) +} + function replaceObjectSnap( code: string, s: MagicString, index: number, newSnap: string, + method?: string, ) { let _code = code.slice(index) - const startMatch = startObjectRegex.exec(_code) + const regex = method ? buildStartObjectRegex(method) : defaultStartObjectRegex + const startMatch = regex.exec(_code) if (!startMatch) { return false } @@ -121,23 +134,17 @@ function prepareSnapString(snap: string, source: string, index: number) { .replace(/\$\{/g, '\\${')}\n${indent}${quote}` } -const toMatchInlineName = 'toMatchInlineSnapshot' -const toThrowErrorMatchingInlineName = 'toThrowErrorMatchingInlineSnapshot' +const defaultMethodNames = ['toMatchInlineSnapshot', 'toThrowErrorMatchingInlineSnapshot'] // on webkit, the line number is at the end of the method, not at the start -function getCodeStartingAtIndex(code: string, index: number) { - const indexInline = index - toMatchInlineName.length - if (code.slice(indexInline, index) === toMatchInlineName) { - return { - code: code.slice(indexInline), - index: indexInline, - } - } - const indexThrowInline = index - toThrowErrorMatchingInlineName.length - if (code.slice(index - indexThrowInline, index) === toThrowErrorMatchingInlineName) { - return { - code: code.slice(index - indexThrowInline), - index: index - indexThrowInline, +function getCodeStartingAtIndex(code: string, index: number, methodNames: string[]) { + for (const name of methodNames) { + const adjusted = index - name.length + if (adjusted >= 0 && code.slice(adjusted, index) === name) { + return { + code: code.slice(adjusted), + index: adjusted, + } } } return { @@ -146,24 +153,33 @@ function getCodeStartingAtIndex(code: string, index: number) { } } -const startRegex +const defaultStartRegex = /(?:toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot)\s*\(\s*(?:\/\*[\s\S]*\*\/\s*|\/\/.*(?:[\n\r\u2028\u2029]\s*|[\t\v\f \xA0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF]))*[\w$]*(['"`)])/ + +function buildStartRegex(method: string): RegExp { + const escaped = escapeRegExp(method) + const wsAndComments = '\\s*\\(\\s*(?:\\/\\*[\\s\\S]*\\*\\/\\s*|\\/\\/.*(?:[\\n\\r\\u2028\\u2029]\\s*|[\\t\\v\\f \\xA0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000\\uFEFF]))*[\\w$]*' + return new RegExp(`${escaped + wsAndComments}(['"\`)])`) +} + export function replaceInlineSnap( code: string, s: MagicString, currentIndex: number, newSnap: string, + method?: string, ): boolean { - const { code: codeStartingAtIndex, index } = getCodeStartingAtIndex(code, currentIndex) + const methodNames = method ? [method] : defaultMethodNames + const { code: codeStartingAtIndex, index } = getCodeStartingAtIndex(code, currentIndex, methodNames) + const startRegex = method ? buildStartRegex(method) : defaultStartRegex const startMatch = startRegex.exec(codeStartingAtIndex) - const firstKeywordMatch = /toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot/.exec( - codeStartingAtIndex, - ) + const keywordRegex = method ? new RegExp(escapeRegExp(method)) : /toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot/ + const firstKeywordMatch = keywordRegex.exec(codeStartingAtIndex) if (!startMatch || startMatch.index !== firstKeywordMatch?.index) { - return replaceObjectSnap(code, s, index, newSnap) + return replaceObjectSnap(code, s, index, newSnap, method) } const quote = startMatch[1] diff --git a/packages/snapshot/src/port/state.ts b/packages/snapshot/src/port/state.ts index e3249f8af992..3443579394fb 100644 --- a/packages/snapshot/src/port/state.ts +++ b/packages/snapshot/src/port/state.ts @@ -159,7 +159,7 @@ export default class SnapshotState { this.unmatched.delete(testId) } - protected _inferInlineSnapshotStack(stacks: ParsedStack[]): ParsedStack | null { + protected _inferInlineSnapshotStack(stacks: ParsedStack[], method?: string): ParsedStack | null { // if called inside resolves/rejects, stacktrace is different const promiseIndex = stacks.findIndex(i => i.method.match(/__VITEST_(RESOLVES|REJECTS)__/), @@ -177,6 +177,17 @@ export default class SnapshotState { } } + // find custom matcher name in stack and resolve to call site + // the call site is 4 frames above the custom matcher: + // method frame → expectWrapper → Proxy → methodWrapper → call site + if (method) { + for (let i = 0; i < stacks.length; i++) { + if (stacks[i].method.includes(method)) { + return stacks[i + 4] ?? null + } + } + } + // inline snapshot function is called __INLINE_SNAPSHOT__ // in integrations/snapshot/chai.ts const stackIndex = stacks.findIndex(i => @@ -188,7 +199,7 @@ export default class SnapshotState { private _addSnapshot( key: string, receivedSerialized: string, - options: { rawSnapshot?: RawSnapshotInfo; stack?: ParsedStack; testId: string }, + options: { rawSnapshot?: RawSnapshotInfo; stack?: ParsedStack; testId: string; method?: string }, ): void { this._dirty = true if (options.stack) { @@ -196,6 +207,7 @@ export default class SnapshotState { snapshot: receivedSerialized, testId: options.testId, ...options.stack, + method: options.method, }) } else if (options.rawSnapshot) { @@ -276,6 +288,7 @@ export default class SnapshotState { isInline, error, rawSnapshot, + method, }: SnapshotMatchOptions): SnapshotReturnOptions { // this also increments counter for inline snapshots. maybe we shouldn't? this._counters.increment(testName) @@ -343,7 +356,7 @@ export default class SnapshotState { error || new Error('snapshot'), { ignoreStackEntries: [] }, ) - const _stack = this._inferInlineSnapshotStack(stacks) + const _stack = this._inferInlineSnapshotStack(stacks, method) if (!_stack) { throw new Error( `@vitest/snapshot: Couldn't infer stack frame for inline snapshot.\n${JSON.stringify( @@ -404,6 +417,7 @@ export default class SnapshotState { stack, testId, rawSnapshot, + method, }) } else { @@ -415,6 +429,7 @@ export default class SnapshotState { stack, testId, rawSnapshot, + method, }) this.added.increment(testId) } diff --git a/packages/snapshot/src/types/index.ts b/packages/snapshot/src/types/index.ts index f314d3befa15..47eb866897c4 100644 --- a/packages/snapshot/src/types/index.ts +++ b/packages/snapshot/src/types/index.ts @@ -32,6 +32,7 @@ export interface SnapshotMatchOptions { isInline: boolean error?: Error rawSnapshot?: RawSnapshotInfo + method?: string } export interface SnapshotResult { diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 69f882ccd332..28ef87cbc57b 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -288,7 +288,6 @@ function toMatchInlineSnapshotImpl( inlineSnapshot = stripSnapshotIndentation(inlineSnapshot) } // TODO: non-throwing - // TODO: pass `assertionName` to help stack probing getSnapshotClient().assert({ received, message: hint, @@ -296,6 +295,8 @@ function toMatchInlineSnapshotImpl( properties: propertiesOrHint, inlineSnapshot, errorMessage: utils.flag(assertion, 'message'), + // pass `assertionName` to help stack probing + method: assertionName, // set by async assertion (e.g. resolves/rejects) for stack probing error: utils.flag(assertion, 'error'), ...getTestNames(test), diff --git a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts index 917419eeae90..080ec572b52f 100644 --- a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts +++ b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts @@ -40,6 +40,11 @@ test('custom file snapshot matcher', () => { expect(`hahaha`).toMatchCustomSnapshot() }) -test.skip('custom inline snapshot matcher', () => { - expect(`hehehe`).toMatchCustomInlineSnapshot() +test('custom inline snapshot matcher', () => { + expect(`hehehe`).toMatchCustomInlineSnapshot(` + Object { + "length": 6, + "reversed": "eheheh", + } + `) }) From 95dcf04d7f7772982280f55ee5767777ad69d3d8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 26 Mar 2026 18:08:03 +0900 Subject: [PATCH 06/57] refactor: bake-in __VITEST_EXTEND_ASSERTION__ for stack probing --- packages/expect/src/jest-extend.ts | 4 ++-- packages/snapshot/src/port/state.ts | 20 +++++++++----------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index 727d5a515d0f..a797382e64ef 100644 --- a/packages/expect/src/jest-extend.ts +++ b/packages/expect/src/jest-extend.ts @@ -89,7 +89,7 @@ function JestExtendPlugin( return (_, utils) => { Object.entries(matchers).forEach( ([expectAssertionName, expectAssertion]) => { - function expectWrapper( + function __VITEST_EXTEND_ASSERTION__( this: Chai.AssertionStatic & Chai.Assertion, ...args: any[] ) { @@ -133,7 +133,7 @@ function JestExtendPlugin( } } - const softWrapper = wrapAssertion(utils, expectAssertionName, expectWrapper) + const softWrapper = wrapAssertion(utils, expectAssertionName, __VITEST_EXTEND_ASSERTION__) utils.addMethod( (globalThis as any)[JEST_MATCHERS_OBJECT].matchers, expectAssertionName, diff --git a/packages/snapshot/src/port/state.ts b/packages/snapshot/src/port/state.ts index 3443579394fb..378dc7eb8e57 100644 --- a/packages/snapshot/src/port/state.ts +++ b/packages/snapshot/src/port/state.ts @@ -159,7 +159,7 @@ export default class SnapshotState { this.unmatched.delete(testId) } - protected _inferInlineSnapshotStack(stacks: ParsedStack[], method?: string): ParsedStack | null { + protected _inferInlineSnapshotStack(stacks: ParsedStack[]): ParsedStack | null { // if called inside resolves/rejects, stacktrace is different const promiseIndex = stacks.findIndex(i => i.method.match(/__VITEST_(RESOLVES|REJECTS)__/), @@ -177,15 +177,13 @@ export default class SnapshotState { } } - // find custom matcher name in stack and resolve to call site - // the call site is 4 frames above the custom matcher: - // method frame → expectWrapper → Proxy → methodWrapper → call site - if (method) { - for (let i = 0; i < stacks.length; i++) { - if (stacks[i].method.includes(method)) { - return stacks[i + 4] ?? null - } - } + // custom matcher registered via expect.extend() — the wrapper function + // in jest-extend.ts is named __VITEST_EXTEND_ASSERTION__ + const customMatcherIndex = stacks.findIndex(i => + i.method.includes('__VITEST_EXTEND_ASSERTION__'), + ) + if (customMatcherIndex !== -1) { + return stacks[customMatcherIndex + 3] ?? null } // inline snapshot function is called __INLINE_SNAPSHOT__ @@ -356,7 +354,7 @@ export default class SnapshotState { error || new Error('snapshot'), { ignoreStackEntries: [] }, ) - const _stack = this._inferInlineSnapshotStack(stacks, method) + const _stack = this._inferInlineSnapshotStack(stacks) if (!_stack) { throw new Error( `@vitest/snapshot: Couldn't infer stack frame for inline snapshot.\n${JSON.stringify( From ceb0ad2270be8c9e451ebddfdf289793fd431602 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 26 Mar 2026 18:16:47 +0900 Subject: [PATCH 07/57] test: cleanup --- .../custom-matcher/__snapshots__/basic.test.ts.snap | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 test/snapshots/test/fixtures/custom-matcher/__snapshots__/basic.test.ts.snap diff --git a/test/snapshots/test/fixtures/custom-matcher/__snapshots__/basic.test.ts.snap b/test/snapshots/test/fixtures/custom-matcher/__snapshots__/basic.test.ts.snap deleted file mode 100644 index 59a8b089611b..000000000000 --- a/test/snapshots/test/fixtures/custom-matcher/__snapshots__/basic.test.ts.snap +++ /dev/null @@ -1,8 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`custom file snapshot matcher 1`] = ` -Object { - "length": 6, - "reversed": "ahahah", -} -`; From d081be571b0f80cc8a28f086e47181f72e81a233 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 26 Mar 2026 18:24:22 +0900 Subject: [PATCH 08/57] test: integration --- test/snapshots/test/custom-matcher.test.ts | 163 ++++++++++++++++++ .../test/fixtures/custom-matcher/.gitignore | 1 + .../fixtures/custom-matcher/basic.test.ts | 2 + 3 files changed, 166 insertions(+) create mode 100644 test/snapshots/test/custom-matcher.test.ts create mode 100644 test/snapshots/test/fixtures/custom-matcher/.gitignore diff --git a/test/snapshots/test/custom-matcher.test.ts b/test/snapshots/test/custom-matcher.test.ts new file mode 100644 index 000000000000..c47cdc400684 --- /dev/null +++ b/test/snapshots/test/custom-matcher.test.ts @@ -0,0 +1,163 @@ +import fs, { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect, test } from 'vitest' +import { editFile, runVitest } from '../../test-utils' + +function extractInlineBlocks(content: string): string { + const blocks: string[] = [] + const regex = /\/\/ -- TEST INLINE START --\n([\s\S]*?)\/\/ -- TEST INLINE END --/g + let match + while ((match = regex.exec(content)) !== null) { + blocks.push(match[1].trim()) + } + return blocks.join('\n\n') +} + +test('custom snapshot matcher', async () => { + const root = join(import.meta.dirname, 'fixtures/custom-matcher') + const testFile = join(root, 'basic.test.ts') + const snapshotFile = join(root, '__snapshots__/basic.test.ts.snap') + + // remove snapshots + fs.rmSync(join(root, '__snapshots__'), { recursive: true, force: true }) + editFile(testFile, s => s.replace(/toMatchCustomInlineSnapshot\(`[^`]*`\)/gs, 'toMatchCustomInlineSnapshot()')) + + // create snapshots from scratch + let result = await runVitest({ root, update: 'new' }) + expect(result.stderr).toMatchInlineSnapshot(`""`) + expect(readFileSync(snapshotFile, 'utf-8')).toMatchInlineSnapshot(` + "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + + exports[\`custom file snapshot matcher 1\`] = \` + Object { + "length": 6, + "reversed": "ahahah", + } + \`; + " + `) + expect(extractInlineBlocks(readFileSync(testFile, 'utf-8'))).toMatchInlineSnapshot(` + "test('custom inline snapshot matcher', () => { + expect(\`hehehe\`).toMatchCustomInlineSnapshot(\` + Object { + "length": 6, + "reversed": "eheheh", + } + \`) + })" + `) + expect(result.errorTree()).toMatchInlineSnapshot(` + Object { + "basic.test.ts": Object { + "custom file snapshot matcher": "passed", + "custom inline snapshot matcher": "passed", + }, + } + `) + + // edit tests to introduce snapshot errors + editFile(testFile, s => s + .replace('`hahaha`', '`hahaha-edit`') + .replace('`hehehe`', '`hehehe-edit`')) + + result = await runVitest({ root, update: 'none' }) + expect(result.stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL basic.test.ts > custom file snapshot matcher + Error: Snapshot \`custom file snapshot matcher 1\` mismatched + + - Expected + + Received + + Object { + - "length": 6, + + "length": 11, + - "reversed": "ahahah", + + "reversed": "tide-ahahah", + } + + ❯ Object.toMatchCustomSnapshot basic.test.ts:26:21 + 24| toMatchCustomSnapshot(received: string) { + 25| const receivedCustom = formatCustom(received) + 26| toMatchSnapshot.call(this, receivedCustom) + | ^ + 27| return { pass: true, message: () => '' } + 28| }, + ❯ basic.test.ts:40:25 + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ + + FAIL basic.test.ts > custom inline snapshot matcher + Error: Snapshot \`custom inline snapshot matcher 1\` mismatched + + - Expected + + Received + + Object { + - "length": 6, + + "length": 11, + - "reversed": "eheheh", + + "reversed": "tide-eheheh", + } + + ❯ Object.toMatchCustomInlineSnapshot basic.test.ts:34:27 + 32| ) { + 33| const receivedCustom = formatCustom(received) + 34| toMatchInlineSnapshot.call(this, receivedCustom, inlineSnapshot) + | ^ + 35| return { pass: true, message: () => '' } + 36| }, + ❯ basic.test.ts:45:25 + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ + + " + `) + expect(result.errorTree()).toMatchInlineSnapshot(` + Object { + "basic.test.ts": Object { + "custom file snapshot matcher": Array [ + "Snapshot \`custom file snapshot matcher 1\` mismatched", + ], + "custom inline snapshot matcher": Array [ + "Snapshot \`custom inline snapshot matcher 1\` mismatched", + ], + }, + } + `) + + // run with update + result = await runVitest({ root, update: 'all' }) + expect(result.stderr).toMatchInlineSnapshot(`""`) + expect(readFileSync(snapshotFile, 'utf-8')).toMatchInlineSnapshot(` + "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + + exports[\`custom file snapshot matcher 1\`] = \` + Object { + "length": 11, + "reversed": "tide-ahahah", + } + \`; + " + `) + expect(extractInlineBlocks(readFileSync(testFile, 'utf-8'))).toMatchInlineSnapshot(` + "test('custom inline snapshot matcher', () => { + expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` + Object { + "length": 11, + "reversed": "tide-eheheh", + } + \`) + })" + `) + expect(result.errorTree()).toMatchInlineSnapshot(` + Object { + "basic.test.ts": Object { + "custom file snapshot matcher": "passed", + "custom inline snapshot matcher": "passed", + }, + } + `) +}) diff --git a/test/snapshots/test/fixtures/custom-matcher/.gitignore b/test/snapshots/test/fixtures/custom-matcher/.gitignore new file mode 100644 index 000000000000..b05c2dfa7007 --- /dev/null +++ b/test/snapshots/test/fixtures/custom-matcher/.gitignore @@ -0,0 +1 @@ +__snapshots__ diff --git a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts index 080ec572b52f..204b7b71df91 100644 --- a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts +++ b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts @@ -40,6 +40,7 @@ test('custom file snapshot matcher', () => { expect(`hahaha`).toMatchCustomSnapshot() }) +// -- TEST INLINE START -- test('custom inline snapshot matcher', () => { expect(`hehehe`).toMatchCustomInlineSnapshot(` Object { @@ -48,3 +49,4 @@ test('custom inline snapshot matcher', () => { } `) }) +// -- TEST INLINE END -- From 78af6a30e2275f29711a9308e6e0659c41d45e7b Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 26 Mar 2026 18:37:20 +0900 Subject: [PATCH 09/57] refactor: rename method -> assertionName --- packages/snapshot/src/client.ts | 6 ++-- packages/snapshot/src/port/inlineSnapshot.ts | 28 +++++++++---------- packages/snapshot/src/port/state.ts | 12 ++++---- packages/snapshot/src/types/index.ts | 2 +- .../vitest/src/integrations/snapshot/chai.ts | 2 +- test/snapshots/test/custom-matcher.test.ts | 14 ++++------ 6 files changed, 31 insertions(+), 33 deletions(-) diff --git a/packages/snapshot/src/client.ts b/packages/snapshot/src/client.ts index 1bfdc90e980d..78c2592c8dff 100644 --- a/packages/snapshot/src/client.ts +++ b/packages/snapshot/src/client.ts @@ -48,7 +48,7 @@ interface AssertOptions { error?: Error errorMessage?: string rawSnapshot?: RawSnapshotInfo - method?: string + assertionName?: string } export interface SnapshotClientOptions { @@ -112,7 +112,7 @@ export class SnapshotClient { error, errorMessage, rawSnapshot, - method, + assertionName, } = options let { received } = options @@ -160,7 +160,7 @@ export class SnapshotClient { error, inlineSnapshot, rawSnapshot, - method, + assertionName, }) if (!pass) { diff --git a/packages/snapshot/src/port/inlineSnapshot.ts b/packages/snapshot/src/port/inlineSnapshot.ts index 379a2a598818..8f7957706330 100644 --- a/packages/snapshot/src/port/inlineSnapshot.ts +++ b/packages/snapshot/src/port/inlineSnapshot.ts @@ -13,7 +13,7 @@ export interface InlineSnapshot { file: string line: number column: number - method?: string + assertionName?: string } export async function saveInlineSnapshots( @@ -34,7 +34,7 @@ export async function saveInlineSnapshots( for (const snap of snaps) { const index = positionToOffset(code, snap.line, snap.column) - replaceInlineSnap(code, s, index, snap.snapshot, snap.method) + replaceInlineSnap(code, s, index, snap.snapshot, snap.assertionName) } const transformed = s.toString() @@ -52,9 +52,9 @@ function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function buildStartObjectRegex(method: string): RegExp { +function buildStartObjectRegex(assertionName: string): RegExp { return new RegExp( - `${escapeRegExp(method)}\\s*\\(\\s*(?:\\/\\*[\\s\\S]*\\*\\/\\s*|\\/\\/.*(?:[\\n\\r\\u2028\\u2029]\\s*|[\\t\\v\\f \\xA0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000\\uFEFF]))*\\{`, + `${escapeRegExp(assertionName)}\\s*\\(\\s*(?:\\/\\*[\\s\\S]*\\*\\/\\s*|\\/\\/.*(?:[\\n\\r\\u2028\\u2029]\\s*|[\\t\\v\\f \\xA0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000\\uFEFF]))*\\{`, ) } @@ -63,10 +63,10 @@ function replaceObjectSnap( s: MagicString, index: number, newSnap: string, - method?: string, + assertionName?: string, ) { let _code = code.slice(index) - const regex = method ? buildStartObjectRegex(method) : defaultStartObjectRegex + const regex = assertionName ? buildStartObjectRegex(assertionName) : defaultStartObjectRegex const startMatch = regex.exec(_code) if (!startMatch) { return false @@ -156,8 +156,8 @@ function getCodeStartingAtIndex(code: string, index: number, methodNames: string const defaultStartRegex = /(?:toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot)\s*\(\s*(?:\/\*[\s\S]*\*\/\s*|\/\/.*(?:[\n\r\u2028\u2029]\s*|[\t\v\f \xA0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF]))*[\w$]*(['"`)])/ -function buildStartRegex(method: string): RegExp { - const escaped = escapeRegExp(method) +function buildStartRegex(assertionName: string): RegExp { + const escaped = escapeRegExp(assertionName) const wsAndComments = '\\s*\\(\\s*(?:\\/\\*[\\s\\S]*\\*\\/\\s*|\\/\\/.*(?:[\\n\\r\\u2028\\u2029]\\s*|[\\t\\v\\f \\xA0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000\\uFEFF]))*[\\w$]*' return new RegExp(`${escaped + wsAndComments}(['"\`)])`) } @@ -167,19 +167,19 @@ export function replaceInlineSnap( s: MagicString, currentIndex: number, newSnap: string, - method?: string, + assertionName?: string, ): boolean { - const methodNames = method ? [method] : defaultMethodNames - const { code: codeStartingAtIndex, index } = getCodeStartingAtIndex(code, currentIndex, methodNames) + const names = assertionName ? [assertionName] : defaultMethodNames + const { code: codeStartingAtIndex, index } = getCodeStartingAtIndex(code, currentIndex, names) - const startRegex = method ? buildStartRegex(method) : defaultStartRegex + const startRegex = assertionName ? buildStartRegex(assertionName) : defaultStartRegex const startMatch = startRegex.exec(codeStartingAtIndex) - const keywordRegex = method ? new RegExp(escapeRegExp(method)) : /toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot/ + const keywordRegex = assertionName ? new RegExp(escapeRegExp(assertionName)) : /toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot/ const firstKeywordMatch = keywordRegex.exec(codeStartingAtIndex) if (!startMatch || startMatch.index !== firstKeywordMatch?.index) { - return replaceObjectSnap(code, s, index, newSnap, method) + return replaceObjectSnap(code, s, index, newSnap, assertionName) } const quote = startMatch[1] diff --git a/packages/snapshot/src/port/state.ts b/packages/snapshot/src/port/state.ts index 378dc7eb8e57..f4417f6c2360 100644 --- a/packages/snapshot/src/port/state.ts +++ b/packages/snapshot/src/port/state.ts @@ -197,15 +197,15 @@ export default class SnapshotState { private _addSnapshot( key: string, receivedSerialized: string, - options: { rawSnapshot?: RawSnapshotInfo; stack?: ParsedStack; testId: string; method?: string }, + options: { rawSnapshot?: RawSnapshotInfo; stack?: ParsedStack; testId: string; assertionName?: string }, ): void { this._dirty = true if (options.stack) { this._inlineSnapshots.push({ + ...options.stack, snapshot: receivedSerialized, testId: options.testId, - ...options.stack, - method: options.method, + assertionName: options.assertionName, }) } else if (options.rawSnapshot) { @@ -286,7 +286,7 @@ export default class SnapshotState { isInline, error, rawSnapshot, - method, + assertionName, }: SnapshotMatchOptions): SnapshotReturnOptions { // this also increments counter for inline snapshots. maybe we shouldn't? this._counters.increment(testName) @@ -415,7 +415,7 @@ export default class SnapshotState { stack, testId, rawSnapshot, - method, + assertionName, }) } else { @@ -427,7 +427,7 @@ export default class SnapshotState { stack, testId, rawSnapshot, - method, + assertionName, }) this.added.increment(testId) } diff --git a/packages/snapshot/src/types/index.ts b/packages/snapshot/src/types/index.ts index 47eb866897c4..e58d99428fa7 100644 --- a/packages/snapshot/src/types/index.ts +++ b/packages/snapshot/src/types/index.ts @@ -32,7 +32,7 @@ export interface SnapshotMatchOptions { isInline: boolean error?: Error rawSnapshot?: RawSnapshotInfo - method?: string + assertionName?: string } export interface SnapshotResult { diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 28ef87cbc57b..941503f3145e 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -296,7 +296,7 @@ function toMatchInlineSnapshotImpl( inlineSnapshot, errorMessage: utils.flag(assertion, 'message'), // pass `assertionName` to help stack probing - method: assertionName, + assertionName, // set by async assertion (e.g. resolves/rejects) for stack probing error: utils.flag(assertion, 'error'), ...getTestNames(test), diff --git a/test/snapshots/test/custom-matcher.test.ts b/test/snapshots/test/custom-matcher.test.ts index c47cdc400684..39f1776e9b70 100644 --- a/test/snapshots/test/custom-matcher.test.ts +++ b/test/snapshots/test/custom-matcher.test.ts @@ -3,14 +3,12 @@ import { join } from 'node:path' import { expect, test } from 'vitest' import { editFile, runVitest } from '../../test-utils' +const INLINE_BLOCK_RE = /\/\/ -- TEST INLINE START --\n([\s\S]*?)\/\/ -- TEST INLINE END --/g + function extractInlineBlocks(content: string): string { - const blocks: string[] = [] - const regex = /\/\/ -- TEST INLINE START --\n([\s\S]*?)\/\/ -- TEST INLINE END --/g - let match - while ((match = regex.exec(content)) !== null) { - blocks.push(match[1].trim()) - } - return blocks.join('\n\n') + return [...content.matchAll(INLINE_BLOCK_RE)] + .map(m => m[1].trim()) + .join('\n\n') } test('custom snapshot matcher', async () => { @@ -20,7 +18,7 @@ test('custom snapshot matcher', async () => { // remove snapshots fs.rmSync(join(root, '__snapshots__'), { recursive: true, force: true }) - editFile(testFile, s => s.replace(/toMatchCustomInlineSnapshot\(`[^`]*`\)/gs, 'toMatchCustomInlineSnapshot()')) + editFile(testFile, s => s.replace(/toMatchCustomInlineSnapshot\(`[^`]*`\)/g, 'toMatchCustomInlineSnapshot()')) // create snapshots from scratch let result = await runVitest({ root, update: 'new' }) From 9891c0645885f9246996f37f14cf4817b5ca0ef5 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 26 Mar 2026 18:53:13 +0900 Subject: [PATCH 10/57] refactor: memoize regex --- packages/snapshot/src/port/inlineSnapshot.ts | 23 ++++++++++++-------- packages/snapshot/src/port/utils.ts | 11 ++++++++++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/snapshot/src/port/inlineSnapshot.ts b/packages/snapshot/src/port/inlineSnapshot.ts index 8f7957706330..6b5bf4e4bd32 100644 --- a/packages/snapshot/src/port/inlineSnapshot.ts +++ b/packages/snapshot/src/port/inlineSnapshot.ts @@ -6,6 +6,7 @@ import { offsetToLineNumber, positionToOffset, } from '@vitest/utils/offset' +import { memo } from './utils' export interface InlineSnapshot { snapshot: string @@ -52,11 +53,13 @@ function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function buildStartObjectRegex(assertionName: string): RegExp { - return new RegExp( - `${escapeRegExp(assertionName)}\\s*\\(\\s*(?:\\/\\*[\\s\\S]*\\*\\/\\s*|\\/\\/.*(?:[\\n\\r\\u2028\\u2029]\\s*|[\\t\\v\\f \\xA0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000\\uFEFF]))*\\{`, +const buildStartObjectRegex = memo((assertionName: string) => { + const replaced = defaultStartObjectRegex.source.replace( + 'toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot', + escapeRegExp(assertionName), ) -} + return new RegExp(replaced) +}) function replaceObjectSnap( code: string, @@ -156,11 +159,13 @@ function getCodeStartingAtIndex(code: string, index: number, methodNames: string const defaultStartRegex = /(?:toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot)\s*\(\s*(?:\/\*[\s\S]*\*\/\s*|\/\/.*(?:[\n\r\u2028\u2029]\s*|[\t\v\f \xA0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF]))*[\w$]*(['"`)])/ -function buildStartRegex(assertionName: string): RegExp { - const escaped = escapeRegExp(assertionName) - const wsAndComments = '\\s*\\(\\s*(?:\\/\\*[\\s\\S]*\\*\\/\\s*|\\/\\/.*(?:[\\n\\r\\u2028\\u2029]\\s*|[\\t\\v\\f \\xA0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000\\uFEFF]))*[\\w$]*' - return new RegExp(`${escaped + wsAndComments}(['"\`)])`) -} +const buildStartRegex = memo((assertionName: string) => { + const replaced = defaultStartRegex.source.replace( + 'toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot', + escapeRegExp(assertionName), + ) + return new RegExp(replaced) +}) export function replaceInlineSnap( code: string, diff --git a/packages/snapshot/src/port/utils.ts b/packages/snapshot/src/port/utils.ts index b096aed842f5..9534251f9958 100644 --- a/packages/snapshot/src/port/utils.ts +++ b/packages/snapshot/src/port/utils.ts @@ -286,3 +286,14 @@ export class CounterMap extends DefaultMap { return total } } + +/* @__NO_SIDE_EFFECTS__ */ +export function memo(fn: (arg: T) => U): (arg: T) => U { + const cache = new Map() + return (arg: T) => { + if (!cache.has(arg)) { + cache.set(arg, fn(arg)) + } + return cache.get(arg)! + } +} From 715996fb7b7022f1c2c903c69144304c0f0b2658 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 26 Mar 2026 18:54:37 +0900 Subject: [PATCH 11/57] refactor: nit --- packages/snapshot/src/port/inlineSnapshot.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/snapshot/src/port/inlineSnapshot.ts b/packages/snapshot/src/port/inlineSnapshot.ts index 6b5bf4e4bd32..e035c4dd9eea 100644 --- a/packages/snapshot/src/port/inlineSnapshot.ts +++ b/packages/snapshot/src/port/inlineSnapshot.ts @@ -174,8 +174,8 @@ export function replaceInlineSnap( newSnap: string, assertionName?: string, ): boolean { - const names = assertionName ? [assertionName] : defaultMethodNames - const { code: codeStartingAtIndex, index } = getCodeStartingAtIndex(code, currentIndex, names) + const methodNames = assertionName ? [assertionName] : defaultMethodNames + const { code: codeStartingAtIndex, index } = getCodeStartingAtIndex(code, currentIndex, methodNames) const startRegex = assertionName ? buildStartRegex(assertionName) : defaultStartRegex const startMatch = startRegex.exec(codeStartingAtIndex) From 7ea3891003969c0ee1b0619a297ca3d90627dc6d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 26 Mar 2026 19:39:37 +0900 Subject: [PATCH 12/57] wip: add SnapshotClient.match for non-throwing helper --- packages/browser/src/client/tester/runner.ts | 2 +- packages/expect/src/jest-extend.ts | 2 +- packages/snapshot/src/client.ts | 28 +++++++++++++---- packages/snapshot/src/index.ts | 1 + .../vitest/src/integrations/snapshot/chai.ts | 15 +++++----- packages/vitest/src/node/printError.ts | 1 + test/snapshots/test/custom-matcher.test.ts | 30 +++++++++---------- .../fixtures/custom-matcher/basic.test.ts | 7 ++--- 8 files changed, 50 insertions(+), 36 deletions(-) diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index faddbcb9f090..6c911fb75159 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -156,7 +156,7 @@ export function createBrowserRunner( } onTaskFinished = async (task: Task) => { - const lastErrorContext = task.result?.errors?.at(-1)?.context + const lastErrorContext = task.result?.errors?.at(-1)?.__vitest_error_context__ if ( this.config.browser.screenshotFailures && document.body.clientHeight > 0 diff --git a/packages/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index a797382e64ef..61d57af1019b 100644 --- a/packages/expect/src/jest-extend.ts +++ b/packages/expect/src/jest-extend.ts @@ -75,7 +75,7 @@ class JestExtendError extends Error { message: string, public actual?: any, public expected?: any, - public context?: { assertionName: string; meta?: object }, + public __vitest_error_context__?: { assertionName: string; meta?: object }, ) { super(message) } diff --git a/packages/snapshot/src/client.ts b/packages/snapshot/src/client.ts index 78c2592c8dff..544195c037cd 100644 --- a/packages/snapshot/src/client.ts +++ b/packages/snapshot/src/client.ts @@ -51,6 +51,13 @@ interface AssertOptions { assertionName?: string } +export interface MatchResult { + pass: boolean + message: () => string + actual?: string + expected?: string +} + export interface SnapshotClientOptions { isEqual?: (received: unknown, expected: unknown) => boolean } @@ -100,7 +107,7 @@ export class SnapshotClient { return state } - assert(options: AssertOptions): void { + match(options: AssertOptions): MatchResult { const { filepath, name, @@ -163,12 +170,23 @@ export class SnapshotClient { assertionName, }) - if (!pass) { + return { + pass, + message: () => `Snapshot \`${key || 'unknown'}\` mismatched`, + actual: rawSnapshot ? actual : actual?.trim(), + expected: rawSnapshot ? expected : expected?.trim(), + } + } + + assert(options: AssertOptions): void { + const result = this.match(options) + if (!result.pass) { + const snapshotState = this.getSnapshotState(options.filepath) throw createMismatchError( - `Snapshot \`${key || 'unknown'}\` mismatched`, + result.message(), snapshotState.expand, - rawSnapshot ? actual : actual?.trim(), - rawSnapshot ? expected : expected?.trim(), + result.actual, + result.expected, ) } } diff --git a/packages/snapshot/src/index.ts b/packages/snapshot/src/index.ts index 4fa23f225b9f..b8d06d110363 100644 --- a/packages/snapshot/src/index.ts +++ b/packages/snapshot/src/index.ts @@ -1,4 +1,5 @@ export { SnapshotClient } from './client' +export type { MatchResult } from './client' export { stripSnapshotIndentation } from './port/inlineSnapshot' export { addSerializer, getSerializers } from './port/plugins' diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 941503f3145e..9540b9de273f 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -2,6 +2,7 @@ import type { Assertion, ChaiPlugin, MatcherState } from '@vitest/expect' import type { Test } from '@vitest/runner' import { createAssertionMessage, equals, iterableEquality, recordAsyncExpect, subsetEquality, wrapAssertion } from '@vitest/expect' import { getNames } from '@vitest/runner/utils' +import type { MatchResult } from '@vitest/snapshot' import { addSerializer, SnapshotClient, @@ -236,7 +237,7 @@ function toMatchSnapshotImpl( received: unknown, propertiesOrHint?: object, hint?: string, -): void { +): MatchResult { utils.flag(assertion, '_name', assertionName) const isNot = utils.flag(assertion, 'negate') if (isNot) { @@ -250,8 +251,7 @@ function toMatchSnapshotImpl( hint = propertiesOrHint propertiesOrHint = undefined } - // TODO: implement non-throwing variant for jest matcher convention (likely SnapshotClient.match) - getSnapshotClient().assert({ + return getSnapshotClient().match({ received, message: hint, isInline: false, @@ -269,7 +269,7 @@ function toMatchInlineSnapshotImpl( propertiesOrHint?: object | string, inlineSnapshot?: string, hint?: string, -) { +): MatchResult { utils.flag(assertion, '_name', assertionName) const isNot = utils.flag(assertion, 'negate') if (isNot) { @@ -287,8 +287,7 @@ function toMatchInlineSnapshotImpl( if (inlineSnapshot) { inlineSnapshot = stripSnapshotIndentation(inlineSnapshot) } - // TODO: non-throwing - getSnapshotClient().assert({ + return getSnapshotClient().match({ received, message: hint, isInline: true, @@ -308,7 +307,7 @@ export function toMatchSnapshot( received: unknown, propertiesOrHint?: object, hint?: string, -): void { +): MatchResult { return toMatchSnapshotImpl( this.__vitest_context.chaiAssertion, this.__vitest_context.chaiUtils, @@ -325,7 +324,7 @@ export function toMatchInlineSnapshot( propertiesOrHint?: object | string, inlineSnapshot?: string, hint?: string, -): void { +): MatchResult { return toMatchInlineSnapshotImpl( this.__vitest_context.chaiAssertion, this.__vitest_context.chaiUtils, diff --git a/packages/vitest/src/node/printError.ts b/packages/vitest/src/node/printError.ts index 300d132acb09..bea106916d85 100644 --- a/packages/vitest/src/node/printError.ts +++ b/packages/vitest/src/node/printError.ts @@ -283,6 +283,7 @@ const skipErrorProperties = new Set([ 'VITEST_TEST_NAME', 'VITEST_TEST_PATH', '__vitest_rollup_error__', + '__vitest_error_context__', ...Object.getOwnPropertyNames(Error.prototype), ...Object.getOwnPropertyNames(Object.prototype), ]) diff --git a/test/snapshots/test/custom-matcher.test.ts b/test/snapshots/test/custom-matcher.test.ts index 39f1776e9b70..56eb10e9548f 100644 --- a/test/snapshots/test/custom-matcher.test.ts +++ b/test/snapshots/test/custom-matcher.test.ts @@ -76,14 +76,13 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-ahahah", } - ❯ Object.toMatchCustomSnapshot basic.test.ts:26:21 - 24| toMatchCustomSnapshot(received: string) { - 25| const receivedCustom = formatCustom(received) - 26| toMatchSnapshot.call(this, receivedCustom) - | ^ - 27| return { pass: true, message: () => '' } - 28| }, - ❯ basic.test.ts:40:25 + ❯ basic.test.ts:37:25 + 35| + 36| test('custom file snapshot matcher', () => { + 37| expect(\`hahaha-edit\`).toMatchCustomSnapshot() + | ^ + 38| }) + 39| ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ @@ -100,14 +99,13 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-eheheh", } - ❯ Object.toMatchCustomInlineSnapshot basic.test.ts:34:27 - 32| ) { - 33| const receivedCustom = formatCustom(received) - 34| toMatchInlineSnapshot.call(this, receivedCustom, inlineSnapshot) - | ^ - 35| return { pass: true, message: () => '' } - 36| }, - ❯ basic.test.ts:45:25 + ❯ basic.test.ts:42:25 + 40| // -- TEST INLINE START -- + 41| test('custom inline snapshot matcher', () => { + 42| expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` + | ^ + 43| Object { + 44| "length": 6, ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ diff --git a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts index 204b7b71df91..f56afebbb806 100644 --- a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts +++ b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts @@ -19,20 +19,17 @@ function formatCustom(received: string) { } } -// TODO: make snapshot helper non-throwing and return { pass, ... } expect.extend({ toMatchCustomSnapshot(received: string) { const receivedCustom = formatCustom(received) - toMatchSnapshot.call(this, receivedCustom) - return { pass: true, message: () => '' } + return toMatchSnapshot.call(this, receivedCustom) }, toMatchCustomInlineSnapshot( received: string, inlineSnapshot?: string, ) { const receivedCustom = formatCustom(received) - toMatchInlineSnapshot.call(this, receivedCustom, inlineSnapshot) - return { pass: true, message: () => '' } + return toMatchInlineSnapshot.call(this, receivedCustom, inlineSnapshot) }, }) From a234b542da2e1387700ddd9299bb7cbc912e3c27 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 26 Mar 2026 19:42:02 +0900 Subject: [PATCH 13/57] chore: todo --- packages/snapshot/src/client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/snapshot/src/client.ts b/packages/snapshot/src/client.ts index 544195c037cd..94424c316d0d 100644 --- a/packages/snapshot/src/client.ts +++ b/packages/snapshot/src/client.ts @@ -130,6 +130,7 @@ export class SnapshotClient { const snapshotState = this.getSnapshotState(filepath) if (typeof properties === 'object') { + // TODO: this should be also non-throwing? if (typeof received !== 'object' || !received) { throw new Error( 'Received value must be an object when the matcher has properties', From dd4e34481f7628a4edb8269d18b9d9b9c8eb6832 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 26 Mar 2026 19:43:13 +0900 Subject: [PATCH 14/57] chore: lint --- packages/vitest/src/integrations/snapshot/chai.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 9540b9de273f..b11a4c94ade9 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -1,8 +1,8 @@ import type { Assertion, ChaiPlugin, MatcherState } from '@vitest/expect' import type { Test } from '@vitest/runner' +import type { MatchResult } from '@vitest/snapshot' import { createAssertionMessage, equals, iterableEquality, recordAsyncExpect, subsetEquality, wrapAssertion } from '@vitest/expect' import { getNames } from '@vitest/runner/utils' -import type { MatchResult } from '@vitest/snapshot' import { addSerializer, SnapshotClient, From eb02748dbaf57444a8fec8c2fbc0e929474aec7b Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Thu, 26 Mar 2026 19:45:32 +0900 Subject: [PATCH 15/57] chore: tweak types --- packages/vitest/src/integrations/snapshot/chai.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index b11a4c94ade9..e75842fcebd1 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -1,6 +1,5 @@ -import type { Assertion, ChaiPlugin, MatcherState } from '@vitest/expect' +import type { Assertion, ChaiPlugin, MatcherState, SyncExpectationResult } from '@vitest/expect' import type { Test } from '@vitest/runner' -import type { MatchResult } from '@vitest/snapshot' import { createAssertionMessage, equals, iterableEquality, recordAsyncExpect, subsetEquality, wrapAssertion } from '@vitest/expect' import { getNames } from '@vitest/runner/utils' import { @@ -237,7 +236,7 @@ function toMatchSnapshotImpl( received: unknown, propertiesOrHint?: object, hint?: string, -): MatchResult { +): SyncExpectationResult { utils.flag(assertion, '_name', assertionName) const isNot = utils.flag(assertion, 'negate') if (isNot) { @@ -269,7 +268,7 @@ function toMatchInlineSnapshotImpl( propertiesOrHint?: object | string, inlineSnapshot?: string, hint?: string, -): MatchResult { +): SyncExpectationResult { utils.flag(assertion, '_name', assertionName) const isNot = utils.flag(assertion, 'negate') if (isNot) { @@ -307,7 +306,7 @@ export function toMatchSnapshot( received: unknown, propertiesOrHint?: object, hint?: string, -): MatchResult { +): SyncExpectationResult { return toMatchSnapshotImpl( this.__vitest_context.chaiAssertion, this.__vitest_context.chaiUtils, @@ -324,7 +323,7 @@ export function toMatchInlineSnapshot( propertiesOrHint?: object | string, inlineSnapshot?: string, hint?: string, -): MatchResult { +): SyncExpectationResult { return toMatchInlineSnapshotImpl( this.__vitest_context.chaiAssertion, this.__vitest_context.chaiUtils, From 777c182d9b5b53f0df77bdaf7497f318a4512ebc Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 08:38:33 +0900 Subject: [PATCH 16/57] chore: todo --- packages/snapshot/src/port/inlineSnapshot.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/snapshot/src/port/inlineSnapshot.ts b/packages/snapshot/src/port/inlineSnapshot.ts index e035c4dd9eea..e02700cbe433 100644 --- a/packages/snapshot/src/port/inlineSnapshot.ts +++ b/packages/snapshot/src/port/inlineSnapshot.ts @@ -14,6 +14,7 @@ export interface InlineSnapshot { file: string line: number column: number + // TODO: can we rely on `method` extracted from stack? assertionName?: string } From 38e3a6a7525c83638d0521d2405fc6dc84032716 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 08:52:28 +0900 Subject: [PATCH 17/57] chore: todo --- test/snapshots/test/fixtures/custom-matcher/basic.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts index f56afebbb806..9ae3c05007b7 100644 --- a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts +++ b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts @@ -19,6 +19,12 @@ function formatCustom(received: string) { } } +// TODO: +// can we support inlien snapshot with arbitrary options and argument position? +// ideally users should be able to define custom matcher such as: +// expect(thing).toMatchCustomInlineSnapthot(myCustomOption1, myCustomOption2, `...snaphsot goes here...`) +// does jest supports this pattern? + expect.extend({ toMatchCustomSnapshot(received: string) { const receivedCustom = formatCustom(received) From 74f071eb0553ca884528499fb1b7f23b7e437d72 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 10:04:40 +0900 Subject: [PATCH 18/57] chore: cleanup --- packages/browser/src/client/tester/runner.ts | 2 +- packages/expect/src/jest-extend.ts | 2 +- packages/vitest/src/node/printError.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index 6c911fb75159..faddbcb9f090 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -156,7 +156,7 @@ export function createBrowserRunner( } onTaskFinished = async (task: Task) => { - const lastErrorContext = task.result?.errors?.at(-1)?.__vitest_error_context__ + const lastErrorContext = task.result?.errors?.at(-1)?.context if ( this.config.browser.screenshotFailures && document.body.clientHeight > 0 diff --git a/packages/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index 61d57af1019b..a797382e64ef 100644 --- a/packages/expect/src/jest-extend.ts +++ b/packages/expect/src/jest-extend.ts @@ -75,7 +75,7 @@ class JestExtendError extends Error { message: string, public actual?: any, public expected?: any, - public __vitest_error_context__?: { assertionName: string; meta?: object }, + public context?: { assertionName: string; meta?: object }, ) { super(message) } diff --git a/packages/vitest/src/node/printError.ts b/packages/vitest/src/node/printError.ts index bea106916d85..300d132acb09 100644 --- a/packages/vitest/src/node/printError.ts +++ b/packages/vitest/src/node/printError.ts @@ -283,7 +283,6 @@ const skipErrorProperties = new Set([ 'VITEST_TEST_NAME', 'VITEST_TEST_PATH', '__vitest_rollup_error__', - '__vitest_error_context__', ...Object.getOwnPropertyNames(Error.prototype), ...Object.getOwnPropertyNames(Object.prototype), ]) From 2fe20fd830ebff7b1557be39c83c0518d1ec048d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 10:05:07 +0900 Subject: [PATCH 19/57] test: update --- test/snapshots/test/custom-matcher.test.ts | 28 ++++++++++++---------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/test/snapshots/test/custom-matcher.test.ts b/test/snapshots/test/custom-matcher.test.ts index 56eb10e9548f..350c765e10e9 100644 --- a/test/snapshots/test/custom-matcher.test.ts +++ b/test/snapshots/test/custom-matcher.test.ts @@ -76,14 +76,16 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-ahahah", } - ❯ basic.test.ts:37:25 - 35| - 36| test('custom file snapshot matcher', () => { - 37| expect(\`hahaha-edit\`).toMatchCustomSnapshot() + ❯ basic.test.ts:43:25 + 41| + 42| test('custom file snapshot matcher', () => { + 43| expect(\`hahaha-edit\`).toMatchCustomSnapshot() | ^ - 38| }) - 39| + 44| }) + 45| + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + Serialized Error: { context: { assertionName: 'toMatchCustomSnapshot', meta: undefined } } ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ FAIL basic.test.ts > custom inline snapshot matcher @@ -99,14 +101,16 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-eheheh", } - ❯ basic.test.ts:42:25 - 40| // -- TEST INLINE START -- - 41| test('custom inline snapshot matcher', () => { - 42| expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` + ❯ basic.test.ts:48:25 + 46| // -- TEST INLINE START -- + 47| test('custom inline snapshot matcher', () => { + 48| expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` | ^ - 43| Object { - 44| "length": 6, + 49| Object { + 50| "length": 6, + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + Serialized Error: { context: { assertionName: 'toMatchCustomInlineSnapshot', meta: undefined } } ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ " From 91a5a8363484d75bf5890c93ba201818c9afbdbf Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 10:06:51 +0900 Subject: [PATCH 20/57] chore: rename --- packages/expect/src/jest-extend.ts | 2 +- packages/expect/src/types.ts | 2 +- packages/vitest/src/integrations/snapshot/chai.ts | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index a797382e64ef..85623cc3eda6 100644 --- a/packages/expect/src/jest-extend.ts +++ b/packages/expect/src/jest-extend.ts @@ -44,7 +44,7 @@ function getMatcherState( const matcherState: MatcherState = { ...getState(expect), - __vitest_context: { + __vitest_context__: { chaiAssertion: assertion, chaiUtils: util, assertionName, diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 895625968b92..872c32d68b25 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -85,7 +85,7 @@ export interface MatcherState { poll?: boolean task?: Readonly /** @internal */ - __vitest_context: { + __vitest_context__: { chaiAssertion: Chai.AssertionStatic & Chai.Assertion chaiUtils: Chai.ChaiUtils assertionName: string diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index e75842fcebd1..4fcbe3590f3d 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -308,9 +308,9 @@ export function toMatchSnapshot( hint?: string, ): SyncExpectationResult { return toMatchSnapshotImpl( - this.__vitest_context.chaiAssertion, - this.__vitest_context.chaiUtils, - this.__vitest_context.assertionName, + this.__vitest_context__.chaiAssertion, + this.__vitest_context__.chaiUtils, + this.__vitest_context__.assertionName, received, propertiesOrHint, hint, @@ -325,9 +325,9 @@ export function toMatchInlineSnapshot( hint?: string, ): SyncExpectationResult { return toMatchInlineSnapshotImpl( - this.__vitest_context.chaiAssertion, - this.__vitest_context.chaiUtils, - this.__vitest_context.assertionName, + this.__vitest_context__.chaiAssertion, + this.__vitest_context__.chaiUtils, + this.__vitest_context__.assertionName, received, propertiesOrHint, inlineSnapshot, From a5ce4760b66147dbbb9c6eef23bda7b7dc1e55cf Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 10:12:18 +0900 Subject: [PATCH 21/57] chore: comment --- packages/snapshot/src/client.ts | 1 + packages/vitest/src/integrations/snapshot/chai.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/snapshot/src/client.ts b/packages/snapshot/src/client.ts index 94424c316d0d..1c818c0da794 100644 --- a/packages/snapshot/src/client.ts +++ b/packages/snapshot/src/client.ts @@ -51,6 +51,7 @@ interface AssertOptions { assertionName?: string } +/** same shape as expect.extend custom matcher result */ export interface MatchResult { pass: boolean message: () => string diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 4fcbe3590f3d..a04ae9dc52d8 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -301,6 +301,7 @@ function toMatchInlineSnapshotImpl( }) } +// TODO: docs export function toMatchSnapshot( this: MatcherState, received: unknown, From 96bbe07bf18795cca00cfba0067e04377ad081d4 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 10:22:26 +0900 Subject: [PATCH 22/57] test: matcher result composability --- test/snapshots/test/custom-matcher.test.ts | 56 +++++++++---------- .../fixtures/custom-matcher/basic.test.ts | 30 ++++++---- 2 files changed, 47 insertions(+), 39 deletions(-) diff --git a/test/snapshots/test/custom-matcher.test.ts b/test/snapshots/test/custom-matcher.test.ts index 350c765e10e9..42f222cbbaec 100644 --- a/test/snapshots/test/custom-matcher.test.ts +++ b/test/snapshots/test/custom-matcher.test.ts @@ -26,7 +26,7 @@ test('custom snapshot matcher', async () => { expect(readFileSync(snapshotFile, 'utf-8')).toMatchInlineSnapshot(` "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - exports[\`custom file snapshot matcher 1\`] = \` + exports[\`file 1\`] = \` Object { "length": 6, "reversed": "ahahah", @@ -35,7 +35,7 @@ test('custom snapshot matcher', async () => { " `) expect(extractInlineBlocks(readFileSync(testFile, 'utf-8'))).toMatchInlineSnapshot(` - "test('custom inline snapshot matcher', () => { + "test('inline', () => { expect(\`hehehe\`).toMatchCustomInlineSnapshot(\` Object { "length": 6, @@ -47,8 +47,8 @@ test('custom snapshot matcher', async () => { expect(result.errorTree()).toMatchInlineSnapshot(` Object { "basic.test.ts": Object { - "custom file snapshot matcher": "passed", - "custom inline snapshot matcher": "passed", + "file": "passed", + "inline": "passed", }, } `) @@ -63,8 +63,8 @@ test('custom snapshot matcher', async () => { " ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ - FAIL basic.test.ts > custom file snapshot matcher - Error: Snapshot \`custom file snapshot matcher 1\` mismatched + FAIL basic.test.ts > file + Error: [custom error] Snapshot \`file 1\` mismatched - Expected + Received @@ -76,20 +76,20 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-ahahah", } - ❯ basic.test.ts:43:25 - 41| - 42| test('custom file snapshot matcher', () => { - 43| expect(\`hahaha-edit\`).toMatchCustomSnapshot() + ❯ basic.test.ts:46:25 + 44| + 45| test('file', () => { + 46| expect(\`hahaha-edit\`).toMatchCustomSnapshot() | ^ - 44| }) - 45| + 47| }) + 48| ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { context: { assertionName: 'toMatchCustomSnapshot', meta: undefined } } ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ - FAIL basic.test.ts > custom inline snapshot matcher - Error: Snapshot \`custom inline snapshot matcher 1\` mismatched + FAIL basic.test.ts > inline + Error: [custom error] Snapshot \`inline 1\` mismatched - Expected + Received @@ -101,13 +101,13 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-eheheh", } - ❯ basic.test.ts:48:25 - 46| // -- TEST INLINE START -- - 47| test('custom inline snapshot matcher', () => { - 48| expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` + ❯ basic.test.ts:56:25 + 54| // -- TEST INLINE START -- + 55| test('inline', () => { + 56| expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` | ^ - 49| Object { - 50| "length": 6, + 57| Object { + 58| "length": 6, ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { context: { assertionName: 'toMatchCustomInlineSnapshot', meta: undefined } } @@ -118,11 +118,11 @@ test('custom snapshot matcher', async () => { expect(result.errorTree()).toMatchInlineSnapshot(` Object { "basic.test.ts": Object { - "custom file snapshot matcher": Array [ - "Snapshot \`custom file snapshot matcher 1\` mismatched", + "file": Array [ + "[custom error] Snapshot \`file 1\` mismatched", ], - "custom inline snapshot matcher": Array [ - "Snapshot \`custom inline snapshot matcher 1\` mismatched", + "inline": Array [ + "[custom error] Snapshot \`inline 1\` mismatched", ], }, } @@ -134,7 +134,7 @@ test('custom snapshot matcher', async () => { expect(readFileSync(snapshotFile, 'utf-8')).toMatchInlineSnapshot(` "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - exports[\`custom file snapshot matcher 1\`] = \` + exports[\`file 1\`] = \` Object { "length": 11, "reversed": "tide-ahahah", @@ -143,7 +143,7 @@ test('custom snapshot matcher', async () => { " `) expect(extractInlineBlocks(readFileSync(testFile, 'utf-8'))).toMatchInlineSnapshot(` - "test('custom inline snapshot matcher', () => { + "test('inline', () => { expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` Object { "length": 11, @@ -155,8 +155,8 @@ test('custom snapshot matcher', async () => { expect(result.errorTree()).toMatchInlineSnapshot(` Object { "basic.test.ts": Object { - "custom file snapshot matcher": "passed", - "custom inline snapshot matcher": "passed", + "file": "passed", + "inline": "passed", }, } `) diff --git a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts index 9ae3c05007b7..c5b129a0f6a0 100644 --- a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts +++ b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts @@ -12,10 +12,10 @@ declare module 'vitest' { interface AsymmetricMatchersContaining extends CustomMatchers {} } -function formatCustom(received: string) { +function formatCustom(input: string) { return { - reversed: received.split('').reverse().join(''), - length: received.length, + reversed: input.split('').reverse().join(''), + length: input.length, } } @@ -26,25 +26,33 @@ function formatCustom(received: string) { // does jest supports this pattern? expect.extend({ - toMatchCustomSnapshot(received: string) { - const receivedCustom = formatCustom(received) - return toMatchSnapshot.call(this, receivedCustom) + toMatchCustomSnapshot(actual: string) { + const actualCustom = formatCustom(actual) + const result = toMatchSnapshot.call(this, actualCustom) + // result can be further enhanced + return { ...result, message: () => `[custom error] ${result.message()}` } }, toMatchCustomInlineSnapshot( - received: string, + actual: string, inlineSnapshot?: string, ) { - const receivedCustom = formatCustom(received) - return toMatchInlineSnapshot.call(this, receivedCustom, inlineSnapshot) + const actualCustom = formatCustom(actual) + const result = toMatchInlineSnapshot.call(this, actualCustom, inlineSnapshot) + return { ...result, message: () => `[custom error] ${result.message()}` } }, }) -test('custom file snapshot matcher', () => { +test('file', () => { expect(`hahaha`).toMatchCustomSnapshot() }) +// TODO +// test('with properties', () => { +// expect(`hahaha`).toMatchCustomSnapshot() +// }) + // -- TEST INLINE START -- -test('custom inline snapshot matcher', () => { +test('inline', () => { expect(`hehehe`).toMatchCustomInlineSnapshot(` Object { "length": 6, From cf05a48d1d8d89a17e54c376239fc5cc8b292ee1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 10:26:24 +0900 Subject: [PATCH 23/57] test: test snapshot with properties --- test/snapshots/test/custom-matcher.test.ts | 62 ++++++++++++++++--- .../fixtures/custom-matcher/basic.test.ts | 13 ++-- 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/test/snapshots/test/custom-matcher.test.ts b/test/snapshots/test/custom-matcher.test.ts index 42f222cbbaec..1445a5a96397 100644 --- a/test/snapshots/test/custom-matcher.test.ts +++ b/test/snapshots/test/custom-matcher.test.ts @@ -32,6 +32,13 @@ test('custom snapshot matcher', async () => { "reversed": "ahahah", } \`; + + exports[\`properties 1\`] = \` + Object { + "length": Any, + "reversed": "opopop", + } + \`; " `) expect(extractInlineBlocks(readFileSync(testFile, 'utf-8'))).toMatchInlineSnapshot(` @@ -49,6 +56,7 @@ test('custom snapshot matcher', async () => { "basic.test.ts": Object { "file": "passed", "inline": "passed", + "properties": "passed", }, } `) @@ -56,12 +64,13 @@ test('custom snapshot matcher', async () => { // edit tests to introduce snapshot errors editFile(testFile, s => s .replace('`hahaha`', '`hahaha-edit`') + .replace('`popopo`', '`popopo-edit`') .replace('`hehehe`', '`hehehe-edit`')) result = await runVitest({ root, update: 'none' }) expect(result.stderr).toMatchInlineSnapshot(` " - ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ + ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯ FAIL basic.test.ts > file Error: [custom error] Snapshot \`file 1\` mismatched @@ -86,7 +95,31 @@ test('custom snapshot matcher', async () => { ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { context: { assertionName: 'toMatchCustomSnapshot', meta: undefined } } - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯ + + FAIL basic.test.ts > properties + Error: [custom error] Snapshot \`properties 1\` mismatched + + - Expected + + Received + + Object { + "length": Any, + - "reversed": "opopop", + + "reversed": "tide-opopop", + } + + ❯ basic.test.ts:50:25 + 48| + 49| test('properties', () => { + 50| expect(\`popopo-edit\`).toMatchCustomSnapshot({ length: expect.any(Num… + | ^ + 51| }) + 52| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + Serialized Error: { context: { assertionName: 'toMatchCustomSnapshot', meta: undefined } } + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯ FAIL basic.test.ts > inline Error: [custom error] Snapshot \`inline 1\` mismatched @@ -101,17 +134,17 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-eheheh", } - ❯ basic.test.ts:56:25 - 54| // -- TEST INLINE START -- - 55| test('inline', () => { - 56| expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` + ❯ basic.test.ts:55:25 + 53| // -- TEST INLINE START -- + 54| test('inline', () => { + 55| expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` | ^ - 57| Object { - 58| "length": 6, + 56| Object { + 57| "length": 6, ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { context: { assertionName: 'toMatchCustomInlineSnapshot', meta: undefined } } - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/3]⎯ " `) @@ -124,6 +157,9 @@ test('custom snapshot matcher', async () => { "inline": Array [ "[custom error] Snapshot \`inline 1\` mismatched", ], + "properties": Array [ + "[custom error] Snapshot \`properties 1\` mismatched", + ], }, } `) @@ -140,6 +176,13 @@ test('custom snapshot matcher', async () => { "reversed": "tide-ahahah", } \`; + + exports[\`properties 1\`] = \` + Object { + "length": Any, + "reversed": "tide-opopop", + } + \`; " `) expect(extractInlineBlocks(readFileSync(testFile, 'utf-8'))).toMatchInlineSnapshot(` @@ -157,6 +200,7 @@ test('custom snapshot matcher', async () => { "basic.test.ts": Object { "file": "passed", "inline": "passed", + "properties": "passed", }, } `) diff --git a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts index c5b129a0f6a0..ceb6e081276a 100644 --- a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts +++ b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts @@ -3,7 +3,7 @@ import { toMatchInlineSnapshot, toMatchSnapshot } from "vitest/runtime" // custom snapshot matcher to wraper input code string interface CustomMatchers { - toMatchCustomSnapshot: () => R + toMatchCustomSnapshot: (properties?: object) => R toMatchCustomInlineSnapshot: (snapshot?: string) => R } @@ -26,9 +26,9 @@ function formatCustom(input: string) { // does jest supports this pattern? expect.extend({ - toMatchCustomSnapshot(actual: string) { + toMatchCustomSnapshot(actual: string, properties?: object) { const actualCustom = formatCustom(actual) - const result = toMatchSnapshot.call(this, actualCustom) + const result = toMatchSnapshot.call(this, actualCustom, properties) // result can be further enhanced return { ...result, message: () => `[custom error] ${result.message()}` } }, @@ -46,10 +46,9 @@ test('file', () => { expect(`hahaha`).toMatchCustomSnapshot() }) -// TODO -// test('with properties', () => { -// expect(`hahaha`).toMatchCustomSnapshot() -// }) +test('properties', () => { + expect(`popopo`).toMatchCustomSnapshot({ length: expect.any(Number) }) +}) // -- TEST INLINE START -- test('inline', () => { From d0715f74063cfdaadb2de62b15f5e09bd7e34a4f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 10:36:35 +0900 Subject: [PATCH 24/57] test: more test --- test/snapshots/test/custom-matcher.test.ts | 173 ++++++++++++++---- .../fixtures/custom-matcher/basic.test.ts | 8 +- 2 files changed, 142 insertions(+), 39 deletions(-) diff --git a/test/snapshots/test/custom-matcher.test.ts b/test/snapshots/test/custom-matcher.test.ts index 1445a5a96397..5c32dd716283 100644 --- a/test/snapshots/test/custom-matcher.test.ts +++ b/test/snapshots/test/custom-matcher.test.ts @@ -33,12 +33,19 @@ test('custom snapshot matcher', async () => { } \`; - exports[\`properties 1\`] = \` + exports[\`properties 1 1\`] = \` Object { - "length": Any, + "length": 6, "reversed": "opopop", } \`; + + exports[\`properties 2 1\`] = \` + Object { + "length": toSatisfy<[Function lessThan10]>, + "reversed": "epepep", + } + \`; " `) expect(extractInlineBlocks(readFileSync(testFile, 'utf-8'))).toMatchInlineSnapshot(` @@ -56,7 +63,8 @@ test('custom snapshot matcher', async () => { "basic.test.ts": Object { "file": "passed", "inline": "passed", - "properties": "passed", + "properties 1": "passed", + "properties 2": "passed", }, } `) @@ -65,12 +73,23 @@ test('custom snapshot matcher', async () => { editFile(testFile, s => s .replace('`hahaha`', '`hahaha-edit`') .replace('`popopo`', '`popopo-edit`') + .replace('`pepepe`', '`pepepe-edit`') .replace('`hehehe`', '`hehehe-edit`')) result = await runVitest({ root, update: 'none' }) expect(result.stderr).toMatchInlineSnapshot(` " - ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯ + ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL basic.test.ts [ basic.test.ts ] + Error: Obsolete snapshots found when no snapshot update is expected. + · properties 1 1 + · properties 2 1 + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/5]⎯ + + + ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 4 ⎯⎯⎯⎯⎯⎯⎯ FAIL basic.test.ts > file Error: [custom error] Snapshot \`file 1\` mismatched @@ -95,31 +114,53 @@ test('custom snapshot matcher', async () => { ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { context: { assertionName: 'toMatchCustomSnapshot', meta: undefined } } - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯ + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/5]⎯ - FAIL basic.test.ts > properties - Error: [custom error] Snapshot \`properties 1\` mismatched + FAIL basic.test.ts > properties 1 + Error: Snapshot mismatched - Expected + Received - Object { - "length": Any, - - "reversed": "opopop", + { + - "length": 6, + + "length": 11, + "reversed": "tide-opopop", } + ❯ Object.toMatchCustomSnapshot basic.test.ts:31:36 + 29| toMatchCustomSnapshot(actual: string, properties?: object) { + 30| const actualCustom = formatCustom(actual) + 31| const result = toMatchSnapshot.call(this, actualCustom, properties) + | ^ + 32| // result can be further enhanced + 33| return { ...result, message: () => \`[custom error] \${result.messag… ❯ basic.test.ts:50:25 - 48| - 49| test('properties', () => { - 50| expect(\`popopo-edit\`).toMatchCustomSnapshot({ length: expect.any(Num… - | ^ - 51| }) - 52| - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ - Serialized Error: { context: { assertionName: 'toMatchCustomSnapshot', meta: undefined } } - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯ + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/5]⎯ + + FAIL basic.test.ts > properties 2 + Error: Snapshot mismatched + + - Expected + + Received + + { + - "length": toSatisfy<[Function lessThan10]>, + + "length": 11, + + "reversed": "tide-epepep", + } + + ❯ Object.toMatchCustomSnapshot basic.test.ts:31:36 + 29| toMatchCustomSnapshot(actual: string, properties?: object) { + 30| const actualCustom = formatCustom(actual) + 31| const result = toMatchSnapshot.call(this, actualCustom, properties) + | ^ + 32| // result can be further enhanced + 33| return { ...result, message: () => \`[custom error] \${result.messag… + ❯ basic.test.ts:54:25 + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/5]⎯ FAIL basic.test.ts > inline Error: [custom error] Snapshot \`inline 1\` mismatched @@ -134,31 +175,40 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-eheheh", } - ❯ basic.test.ts:55:25 - 53| // -- TEST INLINE START -- - 54| test('inline', () => { - 55| expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` + ❯ basic.test.ts:59:25 + 57| // -- TEST INLINE START -- + 58| test('inline', () => { + 59| expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` | ^ - 56| Object { - 57| "length": 6, + 60| Object { + 61| "length": 6, ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { context: { assertionName: 'toMatchCustomInlineSnapshot', meta: undefined } } - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/3]⎯ + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/5]⎯ " `) expect(result.errorTree()).toMatchInlineSnapshot(` Object { "basic.test.ts": Object { + "__module_errors__": Array [ + "Obsolete snapshots found when no snapshot update is expected. + · properties 1 1 + · properties 2 1 + ", + ], "file": Array [ "[custom error] Snapshot \`file 1\` mismatched", ], "inline": Array [ "[custom error] Snapshot \`inline 1\` mismatched", ], - "properties": Array [ - "[custom error] Snapshot \`properties 1\` mismatched", + "properties 1": Array [ + "Snapshot mismatched", + ], + "properties 2": Array [ + "Snapshot mismatched", ], }, } @@ -166,7 +216,58 @@ test('custom snapshot matcher', async () => { // run with update result = await runVitest({ root, update: 'all' }) - expect(result.stderr).toMatchInlineSnapshot(`""`) + expect(result.stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL basic.test.ts > properties 1 + Error: Snapshot mismatched + + - Expected + + Received + + { + - "length": 6, + + "length": 11, + + "reversed": "tide-opopop", + } + + ❯ Object.toMatchCustomSnapshot basic.test.ts:31:36 + 29| toMatchCustomSnapshot(actual: string, properties?: object) { + 30| const actualCustom = formatCustom(actual) + 31| const result = toMatchSnapshot.call(this, actualCustom, properties) + | ^ + 32| // result can be further enhanced + 33| return { ...result, message: () => \`[custom error] \${result.messag… + ❯ basic.test.ts:50:25 + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ + + FAIL basic.test.ts > properties 2 + Error: Snapshot mismatched + + - Expected + + Received + + { + - "length": toSatisfy<[Function lessThan10]>, + + "length": 11, + + "reversed": "tide-epepep", + } + + ❯ Object.toMatchCustomSnapshot basic.test.ts:31:36 + 29| toMatchCustomSnapshot(actual: string, properties?: object) { + 30| const actualCustom = formatCustom(actual) + 31| const result = toMatchSnapshot.call(this, actualCustom, properties) + | ^ + 32| // result can be further enhanced + 33| return { ...result, message: () => \`[custom error] \${result.messag… + ❯ basic.test.ts:54:25 + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ + + " + `) expect(readFileSync(snapshotFile, 'utf-8')).toMatchInlineSnapshot(` "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html @@ -176,13 +277,6 @@ test('custom snapshot matcher', async () => { "reversed": "tide-ahahah", } \`; - - exports[\`properties 1\`] = \` - Object { - "length": Any, - "reversed": "tide-opopop", - } - \`; " `) expect(extractInlineBlocks(readFileSync(testFile, 'utf-8'))).toMatchInlineSnapshot(` @@ -200,7 +294,12 @@ test('custom snapshot matcher', async () => { "basic.test.ts": Object { "file": "passed", "inline": "passed", - "properties": "passed", + "properties 1": Array [ + "Snapshot mismatched", + ], + "properties 2": Array [ + "Snapshot mismatched", + ], }, } `) diff --git a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts index ceb6e081276a..67ba1e7181c3 100644 --- a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts +++ b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts @@ -46,8 +46,12 @@ test('file', () => { expect(`hahaha`).toMatchCustomSnapshot() }) -test('properties', () => { - expect(`popopo`).toMatchCustomSnapshot({ length: expect.any(Number) }) +test('properties 1', () => { + expect(`popopo`).toMatchCustomSnapshot({ length: 6 }) +}) + +test('properties 2', () => { + expect(`pepepe`).toMatchCustomSnapshot({ length: expect.toSatisfy(function lessThan10(n) { return n < 10 }) }) }) // -- TEST INLINE START -- From 1975cbcde63414adb65110535a9f767e803dd31e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 10:53:59 +0900 Subject: [PATCH 25/57] fix: fix properties subset snapshot error formatting --- packages/snapshot/src/client.ts | 36 +++++----- test/snapshots/test/custom-matcher.test.ts | 76 ++++++++++++---------- 2 files changed, 55 insertions(+), 57 deletions(-) diff --git a/packages/snapshot/src/client.ts b/packages/snapshot/src/client.ts index 1c818c0da794..7352cba3a679 100644 --- a/packages/snapshot/src/client.ts +++ b/packages/snapshot/src/client.ts @@ -51,12 +51,12 @@ interface AssertOptions { assertionName?: string } -/** same shape as expect.extend custom matcher result */ +/** Same shape as expect.extend custom matcher result (SyncExpectationResult from @vitest/expect) */ export interface MatchResult { pass: boolean message: () => string - actual?: string - expected?: string + actual?: unknown + expected?: unknown } export interface SnapshotClientOptions { @@ -130,33 +130,27 @@ export class SnapshotClient { const snapshotState = this.getSnapshotState(filepath) + // TODO: + // early return/throwing pass should consume "uncheckedKeys" + // to avoid false-flagging obsolete snapshots. + // (this is a pre-existing issue) if (typeof properties === 'object') { - // TODO: this should be also non-throwing? if (typeof received !== 'object' || !received) { throw new Error( 'Received value must be an object when the matcher has properties', ) } - try { - const pass = this.options.isEqual?.(received, properties) ?? false - // const pass = equals(received, properties, [iterableEquality, subsetEquality]) - if (!pass) { - throw createMismatchError( - 'Snapshot properties mismatched', - snapshotState.expand, - received, - properties, - ) + const propertiesPass = this.options.isEqual?.(received, properties) ?? false + if (!propertiesPass) { + return { + pass: false, + message: () => errorMessage || 'Snapshot properties mismatched', + actual: received, + expected: properties, } - else { - received = deepMergeSnapshot(received, properties) - } - } - catch (err: any) { - err.message = errorMessage || 'Snapshot mismatched' - throw err } + received = deepMergeSnapshot(received, properties) } const testName = [name, ...(message ? [message] : [])].join(' > ') diff --git a/test/snapshots/test/custom-matcher.test.ts b/test/snapshots/test/custom-matcher.test.ts index 5c32dd716283..0da39e796603 100644 --- a/test/snapshots/test/custom-matcher.test.ts +++ b/test/snapshots/test/custom-matcher.test.ts @@ -117,7 +117,7 @@ test('custom snapshot matcher', async () => { ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/5]⎯ FAIL basic.test.ts > properties 1 - Error: Snapshot mismatched + Error: [custom error] Snapshot properties mismatched - Expected + Received @@ -128,19 +128,20 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-opopop", } - ❯ Object.toMatchCustomSnapshot basic.test.ts:31:36 - 29| toMatchCustomSnapshot(actual: string, properties?: object) { - 30| const actualCustom = formatCustom(actual) - 31| const result = toMatchSnapshot.call(this, actualCustom, properties) - | ^ - 32| // result can be further enhanced - 33| return { ...result, message: () => \`[custom error] \${result.messag… ❯ basic.test.ts:50:25 + 48| + 49| test('properties 1', () => { + 50| expect(\`popopo-edit\`).toMatchCustomSnapshot({ length: 6 }) + | ^ + 51| }) + 52| + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + Serialized Error: { context: { assertionName: 'toMatchCustomSnapshot', meta: undefined } } ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/5]⎯ FAIL basic.test.ts > properties 2 - Error: Snapshot mismatched + Error: [custom error] Snapshot properties mismatched - Expected + Received @@ -151,15 +152,16 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-epepep", } - ❯ Object.toMatchCustomSnapshot basic.test.ts:31:36 - 29| toMatchCustomSnapshot(actual: string, properties?: object) { - 30| const actualCustom = formatCustom(actual) - 31| const result = toMatchSnapshot.call(this, actualCustom, properties) - | ^ - 32| // result can be further enhanced - 33| return { ...result, message: () => \`[custom error] \${result.messag… ❯ basic.test.ts:54:25 + 52| + 53| test('properties 2', () => { + 54| expect(\`pepepe-edit\`).toMatchCustomSnapshot({ length: expect.toSatis… + | ^ + 55| }) + 56| + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + Serialized Error: { context: { assertionName: 'toMatchCustomSnapshot', meta: undefined } } ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/5]⎯ FAIL basic.test.ts > inline @@ -205,10 +207,10 @@ test('custom snapshot matcher', async () => { "[custom error] Snapshot \`inline 1\` mismatched", ], "properties 1": Array [ - "Snapshot mismatched", + "[custom error] Snapshot properties mismatched", ], "properties 2": Array [ - "Snapshot mismatched", + "[custom error] Snapshot properties mismatched", ], }, } @@ -221,7 +223,7 @@ test('custom snapshot matcher', async () => { ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ FAIL basic.test.ts > properties 1 - Error: Snapshot mismatched + Error: [custom error] Snapshot properties mismatched - Expected + Received @@ -232,19 +234,20 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-opopop", } - ❯ Object.toMatchCustomSnapshot basic.test.ts:31:36 - 29| toMatchCustomSnapshot(actual: string, properties?: object) { - 30| const actualCustom = formatCustom(actual) - 31| const result = toMatchSnapshot.call(this, actualCustom, properties) - | ^ - 32| // result can be further enhanced - 33| return { ...result, message: () => \`[custom error] \${result.messag… ❯ basic.test.ts:50:25 + 48| + 49| test('properties 1', () => { + 50| expect(\`popopo-edit\`).toMatchCustomSnapshot({ length: 6 }) + | ^ + 51| }) + 52| + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + Serialized Error: { context: { assertionName: 'toMatchCustomSnapshot', meta: undefined } } ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ FAIL basic.test.ts > properties 2 - Error: Snapshot mismatched + Error: [custom error] Snapshot properties mismatched - Expected + Received @@ -255,15 +258,16 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-epepep", } - ❯ Object.toMatchCustomSnapshot basic.test.ts:31:36 - 29| toMatchCustomSnapshot(actual: string, properties?: object) { - 30| const actualCustom = formatCustom(actual) - 31| const result = toMatchSnapshot.call(this, actualCustom, properties) - | ^ - 32| // result can be further enhanced - 33| return { ...result, message: () => \`[custom error] \${result.messag… ❯ basic.test.ts:54:25 + 52| + 53| test('properties 2', () => { + 54| expect(\`pepepe-edit\`).toMatchCustomSnapshot({ length: expect.toSatis… + | ^ + 55| }) + 56| + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ + Serialized Error: { context: { assertionName: 'toMatchCustomSnapshot', meta: undefined } } ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ " @@ -295,10 +299,10 @@ test('custom snapshot matcher', async () => { "file": "passed", "inline": "passed", "properties 1": Array [ - "Snapshot mismatched", + "[custom error] Snapshot properties mismatched", ], "properties 2": Array [ - "Snapshot mismatched", + "[custom error] Snapshot properties mismatched", ], }, } From 327cd722044d7acf019a6a16800b9e65241bc2ea Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 13:41:39 +0900 Subject: [PATCH 26/57] docs: document custom snapshot matchers Add documentation for the composable `toMatchSnapshot` and `toMatchInlineSnapshot` functions exported from `vitest/runtime`. - JSDoc on exported composables in chai.ts with examples - New "Custom Snapshot Matchers" section in docs/guide/snapshot.md - Cross-link from docs/guide/extending-matchers.md - Jest migration notes in docs/guide/migration.md Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guide/extending-matchers.md | 4 ++ docs/guide/migration.md | 30 ++++++++++ docs/guide/snapshot.md | 60 +++++++++++++++++++ .../vitest/src/integrations/snapshot/chai.ts | 43 ++++++++++++- 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/docs/guide/extending-matchers.md b/docs/guide/extending-matchers.md index 27653c838a90..f81c22666c82 100644 --- a/docs/guide/extending-matchers.md +++ b/docs/guide/extending-matchers.md @@ -107,6 +107,10 @@ function customMatcher(this: MatcherState, received: unknown, arg1: unknown, arg expect.extend({ customMatcher }) ``` +::: tip +To build custom **snapshot matchers** (wrappers around `toMatchSnapshot` / `toMatchInlineSnapshot`), use the composable functions from `vitest/runtime`. See [Custom Snapshot Matchers](/guide/snapshot#custom-snapshot-matchers). +::: + Matcher function has access to `this` context with the following properties: ## `isNot` diff --git a/docs/guide/migration.md b/docs/guide/migration.md index e5742cc8b458..eb36f43a75a4 100644 --- a/docs/guide/migration.md +++ b/docs/guide/migration.md @@ -650,6 +650,36 @@ export default defineConfig({ Otherwise your snapshots will have a lot of escaped `"` characters. +### Custom Snapshot Matchers + +Jest imports snapshot composables from `jest-snapshot` and requires passing the matcher name explicitly. Vitest imports from `vitest/runtime` and infers the matcher name automatically from the `expect.extend` key: + +```ts +const { toMatchSnapshot } = require('jest-snapshot') // [!code --] +import { toMatchSnapshot } from 'vitest/runtime' // [!code ++] + +expect.extend({ + toMatchTrimmedSnapshot(received: string, length: number) { + return toMatchSnapshot.call(this, received.slice(0, length)) + }, +}) +``` + +For inline snapshots, the same applies: + +```ts +const { toMatchInlineSnapshot } = require('jest-snapshot') // [!code --] +import { toMatchInlineSnapshot } from 'vitest/runtime' // [!code ++] + +expect.extend({ + toMatchTrimmedInlineSnapshot(received: string, inlineSnapshot?: string) { + return toMatchInlineSnapshot.call(this, received.slice(0, 10), inlineSnapshot) + }, +}) +``` + +See [Custom Snapshot Matchers](/guide/snapshot#custom-snapshot-matchers) for the full guide. + ## Migrating from Mocha + Chai + Sinon {#mocha-chai-sinon} Vitest provides excellent support for migrating from Mocha+Chai+Sinon test suites. While Vitest uses a Jest-compatible API by default, it also provides Chai-style assertions for spy/mock testing, making migration easier. diff --git a/docs/guide/snapshot.md b/docs/guide/snapshot.md index 18b0b58a636a..fd68452ef59d 100644 --- a/docs/guide/snapshot.md +++ b/docs/guide/snapshot.md @@ -200,6 +200,66 @@ Pretty foo: Object { We are using Jest's `pretty-format` for serializing snapshots. You can read more about it here: [pretty-format](https://github.com/facebook/jest/blob/main/packages/pretty-format/README.md#serialize). +## Custom Snapshot Matchers + +You can build custom snapshot matchers using the composable functions exported from `vitest/runtime`. These let you transform values before snapshotting while preserving full snapshot lifecycle support (creation, update, inline rewriting). + +```ts +import { expect, test } from 'vitest' +import { toMatchInlineSnapshot, toMatchSnapshot } from 'vitest/runtime' + +expect.extend({ + toMatchTrimmedSnapshot(received: string, length: number) { + return toMatchSnapshot.call(this, received.slice(0, length)) + }, + toMatchTrimmedInlineSnapshot(received: string, inlineSnapshot?: string) { + return toMatchInlineSnapshot.call(this, received.slice(0, 10), inlineSnapshot) + }, +}) + +test('file snapshot', () => { + expect('extra long string oh my gerd').toMatchTrimmedSnapshot(10) +}) + +test('inline snapshot', () => { + expect('extra long string oh my gerd').toMatchTrimmedInlineSnapshot() +}) +``` + +The composables return `{ pass, message }` so you can further customize the error: + +```ts +expect.extend({ + toMatchTrimmedSnapshot(received: string, length: number) { + const result = toMatchSnapshot.call(this, received.slice(0, length)) + return { ...result, message: () => `Trimmed snapshot failed: ${result.message()}` } + }, +}) +``` + +The assertion name (the key you pass to `expect.extend`) is automatically used for snapshot keys and inline snapshot rewriting — no extra configuration needed. + +::: warning +For inline snapshot matchers, the snapshot argument must be the last parameter (or second-to-last when using property matchers). Vitest rewrites the last string argument in the source code, so custom arguments before the snapshot work, but custom arguments after it are not supported. +::: + +For TypeScript, extend the `Assertion` interface: + +```ts +import 'vitest' + +declare module 'vitest' { + interface Assertion { + toMatchTrimmedSnapshot: (length: number) => T + toMatchTrimmedInlineSnapshot: (inlineSnapshot?: string) => T + } +} +``` + +::: tip +See [Extending Matchers](/guide/extending-matchers) for more on `expect.extend` and custom matcher conventions. +::: + ## Difference from Jest Vitest provides an almost compatible Snapshot feature with [Jest's](https://jestjs.io/docs/snapshot-testing) with a few exceptions: diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index a04ae9dc52d8..9e7bfb17d967 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -301,7 +301,27 @@ function toMatchInlineSnapshotImpl( }) } -// TODO: docs +/** + * Composable for building custom snapshot matchers via `expect.extend`. + * Call with `this` bound to the matcher state. Returns `{ pass, message }` + * compatible with the custom matcher return contract. + * + * The assertion name is automatically inferred from the `expect.extend` key, + * so file snapshots use the custom matcher name as the snapshot key prefix. + * + * @example + * ```ts + * import { toMatchSnapshot } from 'vitest/runtime' + * + * expect.extend({ + * toMatchTrimmedSnapshot(received: string, length: number) { + * return toMatchSnapshot.call(this, received.slice(0, length)) + * }, + * }) + * ``` + * + * @see https://vitest.dev/guide/snapshot.html#custom-snapshot-matchers + */ export function toMatchSnapshot( this: MatcherState, received: unknown, @@ -318,6 +338,27 @@ export function toMatchSnapshot( ) } +/** + * Composable for building custom inline snapshot matchers via `expect.extend`. + * Call with `this` bound to the matcher state. Returns `{ pass, message }` + * compatible with the custom matcher return contract. + * + * The assertion name is automatically inferred from the `expect.extend` key, + * so inline snapshots are rewritten using the custom matcher name. + * + * @example + * ```ts + * import { toMatchInlineSnapshot } from 'vitest/runtime' + * + * expect.extend({ + * toMatchTrimmedInlineSnapshot(received: string, inlineSnapshot?: string) { + * return toMatchInlineSnapshot.call(this, received.slice(0, 10), inlineSnapshot) + * }, + * }) + * ``` + * + * @see https://vitest.dev/guide/snapshot.html#custom-snapshot-matchers + */ export function toMatchInlineSnapshot( this: MatcherState, received: unknown, From 16edc331ccacaf84cf44d4a2ee12c172a6bba9ee Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 13:44:45 +0900 Subject: [PATCH 27/57] docs: fix stale description in migration guide Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guide/migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/migration.md b/docs/guide/migration.md index eb36f43a75a4..826c246e9b25 100644 --- a/docs/guide/migration.md +++ b/docs/guide/migration.md @@ -652,7 +652,7 @@ Otherwise your snapshots will have a lot of escaped `"` characters. ### Custom Snapshot Matchers -Jest imports snapshot composables from `jest-snapshot` and requires passing the matcher name explicitly. Vitest imports from `vitest/runtime` and infers the matcher name automatically from the `expect.extend` key: +Jest imports snapshot composables from `jest-snapshot`. In Vitest, import from `vitest/runtime` instead: ```ts const { toMatchSnapshot } = require('jest-snapshot') // [!code --] From 52f4c9882fc4304352a0744cb15a76aa5a9655f0 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 13:46:49 +0900 Subject: [PATCH 28/57] docs: remove redundant sentence from snapshot guide Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/guide/snapshot.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/guide/snapshot.md b/docs/guide/snapshot.md index fd68452ef59d..ff6c0e6f509d 100644 --- a/docs/guide/snapshot.md +++ b/docs/guide/snapshot.md @@ -237,8 +237,6 @@ expect.extend({ }) ``` -The assertion name (the key you pass to `expect.extend`) is automatically used for snapshot keys and inline snapshot rewriting — no extra configuration needed. - ::: warning For inline snapshot matchers, the snapshot argument must be the last parameter (or second-to-last when using property matchers). Vitest rewrites the last string argument in the source code, so custom arguments before the snapshot work, but custom arguments after it are not supported. ::: From 6673db7a680e463f2fc1f58800e9bb0277b3764d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 15:18:56 +0900 Subject: [PATCH 29/57] refactor: consolidate to toMatchSnapshotImpl for builtin assertions --- .../vitest/src/integrations/snapshot/chai.ts | 114 +++++------------- 1 file changed, 27 insertions(+), 87 deletions(-) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 9e7bfb17d967..36bb4f7e3a9e 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -51,6 +51,16 @@ function getTestNames(test: Test) { } } +function assertMatchResult(result: SyncExpectationResult): void { + if (!result.pass) { + throw Object.assign(new Error(result.message()), { + actual: result.actual, + expected: result.expected, + // TODO: diffOptions + }) + } +} + export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { function getTest(assertionName: string, obj: object) { const test = utils.flag(obj, 'vitest-test') @@ -69,30 +79,15 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { properties?: object, message?: string, ) { - utils.flag(this, '_name', key) - const isNot = utils.flag(this, 'negate') - if (isNot) { - throw new Error(`${key} cannot be used with "not"`) - } - const expected = utils.flag(this, 'object') - const test = getTest(key, this) - if (typeof properties === 'string' && typeof message === 'undefined') { - message = properties - properties = undefined - } - const errorMessage = utils.flag(this, 'message') - getSnapshotClient().assert({ - received: expected, - message, - isInline: false, - properties, - errorMessage, - ...getTestNames(test), - }) + const received = utils.flag(this, 'object') + assertMatchResult( + toMatchSnapshotImpl(this, utils, key, received, properties, message), + ) }), ) } + // TODO: expose custom matcher equivalent? utils.addMethod( chai.Assertion.prototype, 'toMatchFileSnapshot', @@ -137,57 +132,21 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { inlineSnapshot?: string, message?: string, ) { - utils.flag(this, '_name', 'toMatchInlineSnapshot') - const isNot = utils.flag(this, 'negate') - if (isNot) { - throw new Error('toMatchInlineSnapshot cannot be used with "not"') - } - const test = getTest('toMatchInlineSnapshot', this) - const expected = utils.flag(this, 'object') - const error = utils.flag(this, 'error') - if (typeof properties === 'string') { - message = inlineSnapshot - inlineSnapshot = properties - properties = undefined - } - if (inlineSnapshot) { - inlineSnapshot = stripSnapshotIndentation(inlineSnapshot) - } - const errorMessage = utils.flag(this, 'message') - - getSnapshotClient().assert({ - received: expected, - message, - isInline: true, - properties, - inlineSnapshot, - error, - errorMessage, - ...getTestNames(test), - }) + const received = utils.flag(this, 'object') + assertMatchResult( + toMatchInlineSnapshotImpl(this, utils, 'toMatchInlineSnapshot', received, properties, inlineSnapshot, message), + ) }), ) utils.addMethod( chai.Assertion.prototype, 'toThrowErrorMatchingSnapshot', wrapAssertion(utils, 'toThrowErrorMatchingSnapshot', function (this, properties?: object, message?: string) { - utils.flag(this, '_name', 'toThrowErrorMatchingSnapshot') - const isNot = utils.flag(this, 'negate') - if (isNot) { - throw new Error( - 'toThrowErrorMatchingSnapshot cannot be used with "not"', - ) - } const expected = utils.flag(this, 'object') - const test = getTest('toThrowErrorMatchingSnapshot', this) const promise = utils.flag(this, 'promise') as string | undefined - const errorMessage = utils.flag(this, 'message') - getSnapshotClient().assert({ - received: getError(expected, promise), - message, - errorMessage, - ...getTestNames(test), - }) + assertMatchResult( + toMatchSnapshotImpl(this, utils, 'toThrowErrorMatchingSnapshot', getError(expected, promise), properties, message), + ) }), ) utils.addMethod( @@ -198,37 +157,18 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { inlineSnapshot: string, message: string, ) { - const isNot = utils.flag(this, 'negate') - if (isNot) { - throw new Error( - 'toThrowErrorMatchingInlineSnapshot cannot be used with "not"', - ) - } - const test = getTest('toThrowErrorMatchingInlineSnapshot', this) const expected = utils.flag(this, 'object') - const error = utils.flag(this, 'error') const promise = utils.flag(this, 'promise') as string | undefined - const errorMessage = utils.flag(this, 'message') - - if (inlineSnapshot) { - inlineSnapshot = stripSnapshotIndentation(inlineSnapshot) - } - - getSnapshotClient().assert({ - received: getError(expected, promise), - message, - inlineSnapshot, - isInline: true, - error, - errorMessage, - ...getTestNames(test), - }) + assertMatchResult( + toMatchInlineSnapshotImpl(this, utils, 'toThrowErrorMatchingInlineSnapshot', getError(expected, promise), undefined, inlineSnapshot, message), + ) }), ) utils.addMethod(chai.expect, 'addSnapshotSerializer', addSerializer) } -// TODO: use impl for above builtin snapshot API too. +// TODO: option object as argument +// TODO: flag to switch throwing vs non-throwing function toMatchSnapshotImpl( assertion: Chai.AssertionStatic & Chai.Assertion, utils: Chai.ChaiUtils, From 5a9069dfd39cd67e39ee07650fc0b6b87baba59b Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 15:21:41 +0900 Subject: [PATCH 30/57] fix: revive diffOptions.expand --- packages/vitest/src/integrations/snapshot/chai.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 36bb4f7e3a9e..e12c8dba15b3 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -7,6 +7,7 @@ import { SnapshotClient, stripSnapshotIndentation, } from '@vitest/snapshot' +import { getWorkerState } from '../../runtime/utils' let _client: SnapshotClient @@ -56,7 +57,9 @@ function assertMatchResult(result: SyncExpectationResult): void { throw Object.assign(new Error(result.message()), { actual: result.actual, expected: result.expected, - // TODO: diffOptions + diffOptions: { + expand: getWorkerState().config.snapshotOptions.expand, + }, }) } } From 3a79696bc72ae21d01a67677b0f52eaf437e3f74 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 15:37:04 +0900 Subject: [PATCH 31/57] refactor: single option object argument is nice --- .../vitest/src/integrations/snapshot/chai.ts | 189 +++++++++++------- 1 file changed, 113 insertions(+), 76 deletions(-) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index e12c8dba15b3..0c9b62aa7928 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -52,18 +52,6 @@ function getTestNames(test: Test) { } } -function assertMatchResult(result: SyncExpectationResult): void { - if (!result.pass) { - throw Object.assign(new Error(result.message()), { - actual: result.actual, - expected: result.expected, - diffOptions: { - expand: getWorkerState().config.snapshotOptions.expand, - }, - }) - } -} - export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { function getTest(assertionName: string, obj: object) { const test = utils.flag(obj, 'vitest-test') @@ -79,13 +67,18 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { key, wrapAssertion(utils, key, function ( this, - properties?: object, - message?: string, + propertiesOrHint?: object | string, + hint?: string, ) { - const received = utils.flag(this, 'object') - assertMatchResult( - toMatchSnapshotImpl(this, utils, key, received, properties, message), - ) + toMatchSnapshotImpl({ + assertion: this, + utils, + assertionName: key, + assert: true, + received: utils.flag(this, 'object'), + propertiesOrHint, + hint, + }) }), ) } @@ -131,25 +124,37 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { 'toMatchInlineSnapshot', wrapAssertion(utils, 'toMatchInlineSnapshot', function __INLINE_SNAPSHOT_OFFSET_3__( this, - properties?: object, + propertiesOrHint?: object | string, inlineSnapshot?: string, - message?: string, + hint?: string, ) { - const received = utils.flag(this, 'object') - assertMatchResult( - toMatchInlineSnapshotImpl(this, utils, 'toMatchInlineSnapshot', received, properties, inlineSnapshot, message), - ) + toMatchInlineSnapshotImpl({ + assertion: this, + utils, + assertionName: 'toMatchInlineSnapshot', + assert: true, + received: utils.flag(this, 'object'), + propertiesOrHint, + inlineSnapshot, + hint, + }) }), ) utils.addMethod( chai.Assertion.prototype, 'toThrowErrorMatchingSnapshot', - wrapAssertion(utils, 'toThrowErrorMatchingSnapshot', function (this, properties?: object, message?: string) { + wrapAssertion(utils, 'toThrowErrorMatchingSnapshot', function (this, propertiesOrHint?: object | string, hint?: string) { const expected = utils.flag(this, 'object') const promise = utils.flag(this, 'promise') as string | undefined - assertMatchResult( - toMatchSnapshotImpl(this, utils, 'toThrowErrorMatchingSnapshot', getError(expected, promise), properties, message), - ) + toMatchSnapshotImpl({ + assertion: this, + utils, + assertionName: 'toThrowErrorMatchingSnapshot', + assert: true, + received: getError(expected, promise), + propertiesOrHint, + hint, + }) }), ) utils.addMethod( @@ -162,24 +167,43 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { ) { const expected = utils.flag(this, 'object') const promise = utils.flag(this, 'promise') as string | undefined - assertMatchResult( - toMatchInlineSnapshotImpl(this, utils, 'toThrowErrorMatchingInlineSnapshot', getError(expected, promise), undefined, inlineSnapshot, message), - ) + toMatchInlineSnapshotImpl({ + assertion: this, + utils, + assertionName: 'toThrowErrorMatchingInlineSnapshot', + assert: true, + received: getError(expected, promise), + inlineSnapshot, + hint: message, + }) }), ) utils.addMethod(chai.expect, 'addSnapshotSerializer', addSerializer) } -// TODO: option object as argument -// TODO: flag to switch throwing vs non-throwing -function toMatchSnapshotImpl( - assertion: Chai.AssertionStatic & Chai.Assertion, - utils: Chai.ChaiUtils, - assertionName: string, - received: unknown, - propertiesOrHint?: object, - hint?: string, -): SyncExpectationResult { +interface ToMatchSnapshotImplOptions { + assertion: Chai.AssertionStatic & Chai.Assertion + utils: Chai.ChaiUtils + assertionName: string + received: unknown + assert?: boolean + propertiesOrHint?: object | string + inlineSnapshot?: string + hint?: string +} + +function toMatchSnapshotImpl(options: ToMatchSnapshotImplOptions): SyncExpectationResult { + const { assertion, utils, assertionName, received } = options + let { hint } = options + let properties: object | undefined + + if (typeof options.propertiesOrHint === 'string') { + hint = options.propertiesOrHint + } + else { + properties = options.propertiesOrHint + } + utils.flag(assertion, '_name', assertionName) const isNot = utils.flag(assertion, 'negate') if (isNot) { @@ -189,29 +213,33 @@ function toMatchSnapshotImpl( if (!test) { throw new Error(`'${assertionName}' cannot be used without test context`) } - if (typeof propertiesOrHint === 'string' && typeof hint === 'undefined') { - hint = propertiesOrHint - propertiesOrHint = undefined - } - return getSnapshotClient().match({ + const result = getSnapshotClient().match({ received, message: hint, isInline: false, - properties: propertiesOrHint, + properties, errorMessage: utils.flag(assertion, 'message'), ...getTestNames(test), }) + if (options.assert) { + assertMatchResult(result) + } + return result } -function toMatchInlineSnapshotImpl( - assertion: Chai.AssertionStatic & Chai.Assertion, - utils: Chai.ChaiUtils, - assertionName: string, - received: unknown, - propertiesOrHint?: object | string, - inlineSnapshot?: string, - hint?: string, -): SyncExpectationResult { +function toMatchInlineSnapshotImpl(options: ToMatchSnapshotImplOptions): SyncExpectationResult { + const { assertion, utils, assertionName, received } = options + let { hint, inlineSnapshot } = options + let properties: object | undefined + + if (typeof options.propertiesOrHint === 'string') { + hint = inlineSnapshot + inlineSnapshot = options.propertiesOrHint + } + else { + properties = options.propertiesOrHint + } + utils.flag(assertion, '_name', assertionName) const isNot = utils.flag(assertion, 'negate') if (isNot) { @@ -221,27 +249,36 @@ function toMatchInlineSnapshotImpl( if (!test) { throw new Error(`'${assertionName}' cannot be used without test context`) } - if (typeof propertiesOrHint === 'string') { - hint = inlineSnapshot - inlineSnapshot = propertiesOrHint - propertiesOrHint = undefined - } if (inlineSnapshot) { inlineSnapshot = stripSnapshotIndentation(inlineSnapshot) } - return getSnapshotClient().match({ + const result = getSnapshotClient().match({ received, message: hint, isInline: true, - properties: propertiesOrHint, + properties, inlineSnapshot, errorMessage: utils.flag(assertion, 'message'), - // pass `assertionName` to help stack probing assertionName, - // set by async assertion (e.g. resolves/rejects) for stack probing error: utils.flag(assertion, 'error'), ...getTestNames(test), }) + if (options.assert) { + assertMatchResult(result) + } + return result +} + +function assertMatchResult(result: SyncExpectationResult): void { + if (!result.pass) { + throw Object.assign(new Error(result.message()), { + actual: result.actual, + expected: result.expected, + diffOptions: { + expand: getWorkerState().config.snapshotOptions.expand, + }, + }) + } } /** @@ -268,17 +305,17 @@ function toMatchInlineSnapshotImpl( export function toMatchSnapshot( this: MatcherState, received: unknown, - propertiesOrHint?: object, + propertiesOrHint?: object | string, hint?: string, ): SyncExpectationResult { - return toMatchSnapshotImpl( - this.__vitest_context__.chaiAssertion, - this.__vitest_context__.chaiUtils, - this.__vitest_context__.assertionName, + return toMatchSnapshotImpl({ + assertion: this.__vitest_context__.chaiAssertion, + utils: this.__vitest_context__.chaiUtils, + assertionName: this.__vitest_context__.assertionName, received, propertiesOrHint, hint, - ) + }) } /** @@ -309,13 +346,13 @@ export function toMatchInlineSnapshot( inlineSnapshot?: string, hint?: string, ): SyncExpectationResult { - return toMatchInlineSnapshotImpl( - this.__vitest_context__.chaiAssertion, - this.__vitest_context__.chaiUtils, - this.__vitest_context__.assertionName, + return toMatchInlineSnapshotImpl({ + assertion: this.__vitest_context__.chaiAssertion, + utils: this.__vitest_context__.chaiUtils, + assertionName: this.__vitest_context__.assertionName, received, propertiesOrHint, inlineSnapshot, hint, - ) + }) } From 0c4fc8e8eb6913d0fce613d187c97464550d8dda Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 15:54:54 +0900 Subject: [PATCH 32/57] refactor: unify toMatchSnapshotImpl --- .../vitest/src/integrations/snapshot/chai.ts | 129 +++++++----------- 1 file changed, 51 insertions(+), 78 deletions(-) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 0c9b62aa7928..27b5b96e7ec0 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -76,8 +76,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { assertionName: key, assert: true, received: utils.flag(this, 'object'), - propertiesOrHint, - hint, + ...normalizeArguments(propertiesOrHint, hint), }) }), ) @@ -124,19 +123,18 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { 'toMatchInlineSnapshot', wrapAssertion(utils, 'toMatchInlineSnapshot', function __INLINE_SNAPSHOT_OFFSET_3__( this, - propertiesOrHint?: object | string, - inlineSnapshot?: string, + propertiesOrInlineSnapshot?: object | string, + inlineSnapshotOrHint?: string, hint?: string, ) { - toMatchInlineSnapshotImpl({ + toMatchSnapshotImpl({ assertion: this, utils, assertionName: 'toMatchInlineSnapshot', assert: true, received: utils.flag(this, 'object'), - propertiesOrHint, - inlineSnapshot, - hint, + isInline: true, + ...normalizeInlineArguments(propertiesOrInlineSnapshot, inlineSnapshotOrHint, hint), }) }), ) @@ -152,8 +150,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { assertionName: 'toThrowErrorMatchingSnapshot', assert: true, received: getError(expected, promise), - propertiesOrHint, - hint, + ...normalizeArguments(propertiesOrHint, hint), }) }), ) @@ -162,83 +159,61 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { 'toThrowErrorMatchingInlineSnapshot', wrapAssertion(utils, 'toThrowErrorMatchingInlineSnapshot', function __INLINE_SNAPSHOT_OFFSET_3__( this, - inlineSnapshot: string, - message: string, + inlineSnapshotOrHint?: string, + hint?: string, ) { const expected = utils.flag(this, 'object') const promise = utils.flag(this, 'promise') as string | undefined - toMatchInlineSnapshotImpl({ + toMatchSnapshotImpl({ assertion: this, utils, assertionName: 'toThrowErrorMatchingInlineSnapshot', assert: true, received: getError(expected, promise), - inlineSnapshot, - hint: message, + isInline: true, + ...normalizeInlineArguments(undefined, inlineSnapshotOrHint, hint), }) }), ) utils.addMethod(chai.expect, 'addSnapshotSerializer', addSerializer) } -interface ToMatchSnapshotImplOptions { +// toMatchSnapshot(propertiesOrHint?, hint?) +function normalizeArguments( + propertiesOrHint?: object | string, + hint?: string, +): { properties?: object; hint?: string } { + if (typeof propertiesOrHint === 'string') { + return { hint: propertiesOrHint } + } + return { properties: propertiesOrHint, hint } +} + +// toMatchInlineSnapshot(propertiesOrInlineSnapshot?, inlineSnapshotOrHint?, hint?) +function normalizeInlineArguments( + propertiesOrInlineSnapshot?: object | string, + inlineSnapshotOrHint?: string, + hint?: string, +): { properties?: object; inlineSnapshot?: string; hint?: string } { + if (typeof propertiesOrInlineSnapshot === 'string') { + return { inlineSnapshot: propertiesOrInlineSnapshot, hint: inlineSnapshotOrHint } + } + return { properties: propertiesOrInlineSnapshot, inlineSnapshot: inlineSnapshotOrHint, hint } +} + +function toMatchSnapshotImpl(options: { assertion: Chai.AssertionStatic & Chai.Assertion utils: Chai.ChaiUtils assertionName: string received: unknown assert?: boolean - propertiesOrHint?: object | string - inlineSnapshot?: string + properties?: object hint?: string -} - -function toMatchSnapshotImpl(options: ToMatchSnapshotImplOptions): SyncExpectationResult { - const { assertion, utils, assertionName, received } = options - let { hint } = options - let properties: object | undefined - - if (typeof options.propertiesOrHint === 'string') { - hint = options.propertiesOrHint - } - else { - properties = options.propertiesOrHint - } - - utils.flag(assertion, '_name', assertionName) - const isNot = utils.flag(assertion, 'negate') - if (isNot) { - throw new Error(`${assertionName} cannot be used with "not"`) - } - const test = utils.flag(assertion, 'vitest-test') as Test | undefined - if (!test) { - throw new Error(`'${assertionName}' cannot be used without test context`) - } - const result = getSnapshotClient().match({ - received, - message: hint, - isInline: false, - properties, - errorMessage: utils.flag(assertion, 'message'), - ...getTestNames(test), - }) - if (options.assert) { - assertMatchResult(result) - } - return result -} - -function toMatchInlineSnapshotImpl(options: ToMatchSnapshotImplOptions): SyncExpectationResult { - const { assertion, utils, assertionName, received } = options - let { hint, inlineSnapshot } = options - let properties: object | undefined - - if (typeof options.propertiesOrHint === 'string') { - hint = inlineSnapshot - inlineSnapshot = options.propertiesOrHint - } - else { - properties = options.propertiesOrHint - } + isInline?: boolean + inlineSnapshot?: string +}): SyncExpectationResult { + const { assertion, utils, assertionName, received, isInline } = options + let { inlineSnapshot } = options utils.flag(assertion, '_name', assertionName) const isNot = utils.flag(assertion, 'negate') @@ -254,9 +229,9 @@ function toMatchInlineSnapshotImpl(options: ToMatchSnapshotImplOptions): SyncExp } const result = getSnapshotClient().match({ received, - message: hint, - isInline: true, - properties, + message: options.hint, + isInline, + properties: options.properties, inlineSnapshot, errorMessage: utils.flag(assertion, 'message'), assertionName, @@ -313,8 +288,7 @@ export function toMatchSnapshot( utils: this.__vitest_context__.chaiUtils, assertionName: this.__vitest_context__.assertionName, received, - propertiesOrHint, - hint, + ...normalizeArguments(propertiesOrHint, hint), }) } @@ -342,17 +316,16 @@ export function toMatchSnapshot( export function toMatchInlineSnapshot( this: MatcherState, received: unknown, - propertiesOrHint?: object | string, - inlineSnapshot?: string, + propertiesOrInlineSnapshot?: object | string, + inlineSnapshotOrHint?: string, hint?: string, ): SyncExpectationResult { - return toMatchInlineSnapshotImpl({ + return toMatchSnapshotImpl({ assertion: this.__vitest_context__.chaiAssertion, utils: this.__vitest_context__.chaiUtils, assertionName: this.__vitest_context__.assertionName, received, - propertiesOrHint, - inlineSnapshot, - hint, + isInline: true, + ...normalizeInlineArguments(propertiesOrInlineSnapshot, inlineSnapshotOrHint, hint), }) } From ded5eff6d3c0c5ec743732d4630bc532b8811e26 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 15:55:48 +0900 Subject: [PATCH 33/57] chore: docs slop --- packages/vitest/src/integrations/snapshot/chai.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 27b5b96e7ec0..2823a4bdf1ef 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -261,16 +261,13 @@ function assertMatchResult(result: SyncExpectationResult): void { * Call with `this` bound to the matcher state. Returns `{ pass, message }` * compatible with the custom matcher return contract. * - * The assertion name is automatically inferred from the `expect.extend` key, - * so file snapshots use the custom matcher name as the snapshot key prefix. - * * @example * ```ts * import { toMatchSnapshot } from 'vitest/runtime' * * expect.extend({ - * toMatchTrimmedSnapshot(received: string, length: number) { - * return toMatchSnapshot.call(this, received.slice(0, length)) + * toMatchTrimmedSnapshot(received: string) { + * return toMatchSnapshot.call(this, received.slice(0, 10)) * }, * }) * ``` @@ -297,9 +294,6 @@ export function toMatchSnapshot( * Call with `this` bound to the matcher state. Returns `{ pass, message }` * compatible with the custom matcher return contract. * - * The assertion name is automatically inferred from the `expect.extend` key, - * so inline snapshots are rewritten using the custom matcher name. - * * @example * ```ts * import { toMatchInlineSnapshot } from 'vitest/runtime' From 1707111e67c3da81f555e4b2495acdac4a7ac84a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 15:57:16 +0900 Subject: [PATCH 34/57] chore: not todo --- test/snapshots/test/fixtures/custom-matcher/basic.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts index 67ba1e7181c3..59a253a0bc95 100644 --- a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts +++ b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts @@ -19,12 +19,6 @@ function formatCustom(input: string) { } } -// TODO: -// can we support inlien snapshot with arbitrary options and argument position? -// ideally users should be able to define custom matcher such as: -// expect(thing).toMatchCustomInlineSnapthot(myCustomOption1, myCustomOption2, `...snaphsot goes here...`) -// does jest supports this pattern? - expect.extend({ toMatchCustomSnapshot(actual: string, properties?: object) { const actualCustom = formatCustom(actual) From 588c2f7a49d5c3a30963862ec53f28abf9e37db2 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 16:02:43 +0900 Subject: [PATCH 35/57] chore: todo -> excuse --- packages/snapshot/src/port/inlineSnapshot.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/snapshot/src/port/inlineSnapshot.ts b/packages/snapshot/src/port/inlineSnapshot.ts index e02700cbe433..59d6c5116239 100644 --- a/packages/snapshot/src/port/inlineSnapshot.ts +++ b/packages/snapshot/src/port/inlineSnapshot.ts @@ -14,7 +14,9 @@ export interface InlineSnapshot { file: string line: number column: number - // TODO: can we rely on `method` extracted from stack? + // it maybe possible to accurately extract this from `ParsedStack.method`, + // but for now, we ask higher level assertion to pass it explicitly + // since this is useful for certain error messages before we extract stack. assertionName?: string } From 6a829480d8d05beb82143acb170bbdf08876cb5f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 16:24:03 +0900 Subject: [PATCH 36/57] test: update --- test/snapshots/test/custom-matcher.test.ts | 122 +++++++++------------ 1 file changed, 54 insertions(+), 68 deletions(-) diff --git a/test/snapshots/test/custom-matcher.test.ts b/test/snapshots/test/custom-matcher.test.ts index 0da39e796603..da0ad006414b 100644 --- a/test/snapshots/test/custom-matcher.test.ts +++ b/test/snapshots/test/custom-matcher.test.ts @@ -79,16 +79,6 @@ test('custom snapshot matcher', async () => { result = await runVitest({ root, update: 'none' }) expect(result.stderr).toMatchInlineSnapshot(` " - ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ - - FAIL basic.test.ts [ basic.test.ts ] - Error: Obsolete snapshots found when no snapshot update is expected. - · properties 1 1 - · properties 2 1 - - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/5]⎯ - - ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 4 ⎯⎯⎯⎯⎯⎯⎯ FAIL basic.test.ts > file @@ -104,17 +94,15 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-ahahah", } - ❯ basic.test.ts:46:25 - 44| - 45| test('file', () => { - 46| expect(\`hahaha-edit\`).toMatchCustomSnapshot() + ❯ basic.test.ts:40:25 + 38| + 39| test('file', () => { + 40| expect(\`hahaha-edit\`).toMatchCustomSnapshot() | ^ - 47| }) - 48| + 41| }) + 42| - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ - Serialized Error: { context: { assertionName: 'toMatchCustomSnapshot', meta: undefined } } - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/5]⎯ + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/4]⎯ FAIL basic.test.ts > properties 1 Error: [custom error] Snapshot properties mismatched @@ -128,17 +116,15 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-opopop", } - ❯ basic.test.ts:50:25 - 48| - 49| test('properties 1', () => { - 50| expect(\`popopo-edit\`).toMatchCustomSnapshot({ length: 6 }) + ❯ basic.test.ts:44:25 + 42| + 43| test('properties 1', () => { + 44| expect(\`popopo-edit\`).toMatchCustomSnapshot({ length: 6 }) | ^ - 51| }) - 52| + 45| }) + 46| - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ - Serialized Error: { context: { assertionName: 'toMatchCustomSnapshot', meta: undefined } } - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/5]⎯ + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/4]⎯ FAIL basic.test.ts > properties 2 Error: [custom error] Snapshot properties mismatched @@ -152,17 +138,15 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-epepep", } - ❯ basic.test.ts:54:25 - 52| - 53| test('properties 2', () => { - 54| expect(\`pepepe-edit\`).toMatchCustomSnapshot({ length: expect.toSatis… + ❯ basic.test.ts:48:25 + 46| + 47| test('properties 2', () => { + 48| expect(\`pepepe-edit\`).toMatchCustomSnapshot({ length: expect.toSatis… | ^ - 55| }) - 56| + 49| }) + 50| - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ - Serialized Error: { context: { assertionName: 'toMatchCustomSnapshot', meta: undefined } } - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/5]⎯ + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/4]⎯ FAIL basic.test.ts > inline Error: [custom error] Snapshot \`inline 1\` mismatched @@ -177,29 +161,21 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-eheheh", } - ❯ basic.test.ts:59:25 - 57| // -- TEST INLINE START -- - 58| test('inline', () => { - 59| expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` + ❯ basic.test.ts:53:25 + 51| // -- TEST INLINE START -- + 52| test('inline', () => { + 53| expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` | ^ - 60| Object { - 61| "length": 6, + 54| Object { + 55| "length": 6, - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ - Serialized Error: { context: { assertionName: 'toMatchCustomInlineSnapshot', meta: undefined } } - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/5]⎯ + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/4]⎯ " `) expect(result.errorTree()).toMatchInlineSnapshot(` Object { "basic.test.ts": Object { - "__module_errors__": Array [ - "Obsolete snapshots found when no snapshot update is expected. - · properties 1 1 - · properties 2 1 - ", - ], "file": Array [ "[custom error] Snapshot \`file 1\` mismatched", ], @@ -234,16 +210,14 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-opopop", } - ❯ basic.test.ts:50:25 - 48| - 49| test('properties 1', () => { - 50| expect(\`popopo-edit\`).toMatchCustomSnapshot({ length: 6 }) + ❯ basic.test.ts:44:25 + 42| + 43| test('properties 1', () => { + 44| expect(\`popopo-edit\`).toMatchCustomSnapshot({ length: 6 }) | ^ - 51| }) - 52| + 45| }) + 46| - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ - Serialized Error: { context: { assertionName: 'toMatchCustomSnapshot', meta: undefined } } ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ FAIL basic.test.ts > properties 2 @@ -258,16 +232,14 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-epepep", } - ❯ basic.test.ts:54:25 - 52| - 53| test('properties 2', () => { - 54| expect(\`pepepe-edit\`).toMatchCustomSnapshot({ length: expect.toSatis… + ❯ basic.test.ts:48:25 + 46| + 47| test('properties 2', () => { + 48| expect(\`pepepe-edit\`).toMatchCustomSnapshot({ length: expect.toSatis… | ^ - 55| }) - 56| + 49| }) + 50| - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ - Serialized Error: { context: { assertionName: 'toMatchCustomSnapshot', meta: undefined } } ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ " @@ -281,6 +253,20 @@ test('custom snapshot matcher', async () => { "reversed": "tide-ahahah", } \`; + + exports[\`properties 1 1\`] = \` + Object { + "length": 6, + "reversed": "opopop", + } + \`; + + exports[\`properties 2 1\`] = \` + Object { + "length": toSatisfy<[Function lessThan10]>, + "reversed": "epepep", + } + \`; " `) expect(extractInlineBlocks(readFileSync(testFile, 'utf-8'))).toMatchInlineSnapshot(` From fb09797dab371d9881ff7ec0c9683f73f76511f9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 16:26:23 +0900 Subject: [PATCH 37/57] chore: solved TODO --- packages/snapshot/src/client.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/snapshot/src/client.ts b/packages/snapshot/src/client.ts index 6277523c687a..b06b5cbf0062 100644 --- a/packages/snapshot/src/client.ts +++ b/packages/snapshot/src/client.ts @@ -147,10 +147,6 @@ export class SnapshotClient { inlineSnapshot, }) - // TODO: - // early return/throwing pass should consume "uncheckedKeys" - // to avoid false-flagging obsolete snapshots. - // (this is a pre-existing issue) if (typeof properties === 'object') { if (typeof received !== 'object' || !received) { expectedSnapshot.markAsChecked() From bf409415d32ac87fae24d423ba45fb8ae2705a28 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 16:29:53 +0900 Subject: [PATCH 38/57] chore: this is not slop comment --- packages/vitest/src/integrations/snapshot/chai.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 2823a4bdf1ef..d3f49d9191a7 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -234,7 +234,9 @@ function toMatchSnapshotImpl(options: { properties: options.properties, inlineSnapshot, errorMessage: utils.flag(assertion, 'message'), + // pass `assertionName` for inline snapshot stack probing assertionName, + // set by async assertion (e.g. resolves/rejects) for inline snapshot stack probing error: utils.flag(assertion, 'error'), ...getTestNames(test), }) From 64eb8f7f992813cc130f7871bea7aea7cf6fe6bb Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 16:32:47 +0900 Subject: [PATCH 39/57] chore: move stripSnapshotIndentation into normalizeInlineArguments Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/vitest/src/integrations/snapshot/chai.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index d3f49d9191a7..a790d9dab37a 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -195,10 +195,15 @@ function normalizeInlineArguments( inlineSnapshotOrHint?: string, hint?: string, ): { properties?: object; inlineSnapshot?: string; hint?: string } { + let inlineSnapshot: string | undefined if (typeof propertiesOrInlineSnapshot === 'string') { - return { inlineSnapshot: propertiesOrInlineSnapshot, hint: inlineSnapshotOrHint } + inlineSnapshot = stripSnapshotIndentation(propertiesOrInlineSnapshot) + return { inlineSnapshot, hint: inlineSnapshotOrHint } } - return { properties: propertiesOrInlineSnapshot, inlineSnapshot: inlineSnapshotOrHint, hint } + if (inlineSnapshotOrHint) { + inlineSnapshot = stripSnapshotIndentation(inlineSnapshotOrHint) + } + return { properties: propertiesOrInlineSnapshot, inlineSnapshot, hint } } function toMatchSnapshotImpl(options: { @@ -212,8 +217,7 @@ function toMatchSnapshotImpl(options: { isInline?: boolean inlineSnapshot?: string }): SyncExpectationResult { - const { assertion, utils, assertionName, received, isInline } = options - let { inlineSnapshot } = options + const { assertion, utils, assertionName, received, isInline, inlineSnapshot } = options utils.flag(assertion, '_name', assertionName) const isNot = utils.flag(assertion, 'negate') @@ -224,9 +228,6 @@ function toMatchSnapshotImpl(options: { if (!test) { throw new Error(`'${assertionName}' cannot be used without test context`) } - if (inlineSnapshot) { - inlineSnapshot = stripSnapshotIndentation(inlineSnapshot) - } const result = getSnapshotClient().match({ received, message: options.hint, From 53bdc54322d9ef9ee8014e4b30e0e32f67531012 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 16:33:35 +0900 Subject: [PATCH 40/57] refactor: nit slop --- packages/vitest/src/integrations/snapshot/chai.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index a790d9dab37a..f0d973b407e1 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -217,7 +217,7 @@ function toMatchSnapshotImpl(options: { isInline?: boolean inlineSnapshot?: string }): SyncExpectationResult { - const { assertion, utils, assertionName, received, isInline, inlineSnapshot } = options + const { assertion, utils, assertionName } = options utils.flag(assertion, '_name', assertionName) const isNot = utils.flag(assertion, 'negate') @@ -229,11 +229,11 @@ function toMatchSnapshotImpl(options: { throw new Error(`'${assertionName}' cannot be used without test context`) } const result = getSnapshotClient().match({ - received, - message: options.hint, - isInline, + received: options.received, properties: options.properties, - inlineSnapshot, + message: options.hint, + isInline: options.isInline, + inlineSnapshot: options.inlineSnapshot, errorMessage: utils.flag(assertion, 'message'), // pass `assertionName` for inline snapshot stack probing assertionName, From f853fa0fbf69b31c86e38abe1b7d93cc1887fe70 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 16:35:33 +0900 Subject: [PATCH 41/57] chore: bye merge conflict --- packages/snapshot/src/client.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/snapshot/src/client.ts b/packages/snapshot/src/client.ts index b06b5cbf0062..ca41d9e337d0 100644 --- a/packages/snapshot/src/client.ts +++ b/packages/snapshot/src/client.ts @@ -59,14 +59,6 @@ export interface MatchResult { expected?: unknown } -/** Same shape as expect.extend custom matcher result (SyncExpectationResult from @vitest/expect) */ -export interface MatchResult { - pass: boolean - message: () => string - actual?: unknown - expected?: unknown -} - export interface SnapshotClientOptions { isEqual?: (received: unknown, expected: unknown) => boolean } From de0b595dc5a1b3cf6e95b5b4e014d4da27d14d94 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 27 Mar 2026 17:01:58 +0900 Subject: [PATCH 42/57] fix: surface `not.toMatchInlineSnapshot` error properly --- .../vitest/src/integrations/snapshot/chai.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index f0d973b407e1..9ab85e0cf068 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -142,14 +142,19 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { chai.Assertion.prototype, 'toThrowErrorMatchingSnapshot', wrapAssertion(utils, 'toThrowErrorMatchingSnapshot', function (this, propertiesOrHint?: object | string, hint?: string) { - const expected = utils.flag(this, 'object') + const assertionName = 'toThrowErrorMatchingSnapshot' + const isNot = utils.flag(this, 'negate') + if (isNot) { + throw new Error(`${assertionName} cannot be used with "not"`) + } + const received = utils.flag(this, 'object') const promise = utils.flag(this, 'promise') as string | undefined toMatchSnapshotImpl({ assertion: this, utils, assertionName: 'toThrowErrorMatchingSnapshot', assert: true, - received: getError(expected, promise), + received: getError(received, promise), ...normalizeArguments(propertiesOrHint, hint), }) }), @@ -162,14 +167,19 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { inlineSnapshotOrHint?: string, hint?: string, ) { - const expected = utils.flag(this, 'object') + const assertionName = 'toThrowErrorMatchingInlineSnapshot' + const isNot = utils.flag(this, 'negate') + if (isNot) { + throw new Error(`${assertionName} cannot be used with "not"`) + } + const received = utils.flag(this, 'object') const promise = utils.flag(this, 'promise') as string | undefined toMatchSnapshotImpl({ assertion: this, utils, - assertionName: 'toThrowErrorMatchingInlineSnapshot', + assertionName, assert: true, - received: getError(expected, promise), + received: getError(received, promise), isInline: true, ...normalizeInlineArguments(undefined, inlineSnapshotOrHint, hint), }) From 0b9fcbb9308057e3ba001237f5fb27d251599a0a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 28 Mar 2026 14:47:58 +0900 Subject: [PATCH 43/57] feat: expose toMatchFileSnapshot --- .../vitest/src/integrations/snapshot/chai.ts | 97 ++++++++++---- packages/vitest/src/public/runtime.ts | 2 +- test/snapshots/test/custom-matcher.test.ts | 124 ++++++++++++------ .../fixtures/custom-matcher/basic.test.ts | 12 +- 4 files changed, 168 insertions(+), 67 deletions(-) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 9ab85e0cf068..25063a4184a7 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -82,37 +82,24 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { ) } - // TODO: expose custom matcher equivalent? utils.addMethod( chai.Assertion.prototype, 'toMatchFileSnapshot', - function (this: Assertion, file: string, message?: string) { - utils.flag(this, '_name', 'toMatchFileSnapshot') - const isNot = utils.flag(this, 'negate') - if (isNot) { - throw new Error('toMatchFileSnapshot cannot be used with "not"') - } - const error = new Error('resolves') - const expected = utils.flag(this, 'object') - const test = getTest('toMatchFileSnapshot', this) - const errorMessage = utils.flag(this, 'message') - - const promise = getSnapshotClient().assertRaw({ - received: expected, - message, - isInline: false, - rawSnapshot: { - file, - }, - errorMessage, - ...getTestNames(test), + function (this: Chai.AssertionStatic & Assertion, filepath: string, hint?: string) { + const promise = toMatchFileSnapshotImpl({ + assertion: this, + utils, + assertionName: 'toMatchFileSnapshot', + received: utils.flag(this, 'object'), + filepath, + hint, + assert: true, }) - return recordAsyncExpect( - test, + getTest('toMatchFileSnapshot', this), promise, createAssertionMessage(utils, this, true), - error, + new Error('resolves'), utils.flag(this, 'soft'), ) }, @@ -257,6 +244,51 @@ function toMatchSnapshotImpl(options: { return result } +// TODO: refactor +// - getTest +// - getTestNames +async function toMatchFileSnapshotImpl(options: { + assertion: Chai.AssertionStatic & Chai.Assertion + utils: Chai.ChaiUtils + assertionName: string + received: unknown + filepath: string + hint?: string + assert?: boolean +}): Promise { + const { assertion, utils, assertionName } = options + + utils.flag(assertion, '_name', assertionName) + const isNot = utils.flag(assertion, 'negate') + if (isNot) { + throw new Error(`${assertionName} cannot be used with "not"`) + } + const test = utils.flag(assertion, 'vitest-test') as Test | undefined + if (!test) { + throw new Error(`'${assertionName}' cannot be used without test context`) + } + + const testNames = getTestNames(test) + const snapshotState = getSnapshotClient().getSnapshotState(testNames.filepath) + const rawSnapshotFile = await snapshotState.environment.resolveRawPath(testNames.filepath, options.filepath) + const rawSnapshotContent = await snapshotState.environment.readSnapshotFile(rawSnapshotFile) + + const result = getSnapshotClient().match({ + received: options.received, + message: options.hint, + errorMessage: utils.flag(assertion, 'message'), + rawSnapshot: { + file: rawSnapshotFile, + content: rawSnapshotContent ?? undefined, + }, + ...testNames, + }) + if (options.assert) { + assertMatchResult(result) + } + return result +} + function assertMatchResult(result: SyncExpectationResult): void { if (!result.pass) { throw Object.assign(new Error(result.message()), { @@ -336,3 +368,20 @@ export function toMatchInlineSnapshot( ...normalizeInlineArguments(propertiesOrInlineSnapshot, inlineSnapshotOrHint, hint), }) } + +// TODO: docs +export function toMatchFileSnapshot( + this: MatcherState, + received: unknown, + filepath: string, + hint?: string, +): Promise { + return toMatchFileSnapshotImpl({ + assertion: this.__vitest_context__.chaiAssertion, + utils: this.__vitest_context__.chaiUtils, + assertionName: this.__vitest_context__.assertionName, + received, + filepath, + hint, + }) +} diff --git a/packages/vitest/src/public/runtime.ts b/packages/vitest/src/public/runtime.ts index 080ff790f97a..4fbf36da6fc0 100644 --- a/packages/vitest/src/public/runtime.ts +++ b/packages/vitest/src/public/runtime.ts @@ -11,7 +11,7 @@ import { getWorkerState } from '../runtime/utils' export { environments as builtinEnvironments } from '../integrations/env/index' export { populateGlobal } from '../integrations/env/utils' -export { toMatchInlineSnapshot, toMatchSnapshot } from '../integrations/snapshot/chai' +export { toMatchFileSnapshot, toMatchInlineSnapshot, toMatchSnapshot } from '../integrations/snapshot/chai' export { VitestNodeSnapshotEnvironment as VitestSnapshotEnvironment } from '../integrations/snapshot/environments/node' export type { Environment, diff --git a/test/snapshots/test/custom-matcher.test.ts b/test/snapshots/test/custom-matcher.test.ts index da0ad006414b..a0226ac2f0ab 100644 --- a/test/snapshots/test/custom-matcher.test.ts +++ b/test/snapshots/test/custom-matcher.test.ts @@ -15,6 +15,7 @@ test('custom snapshot matcher', async () => { const root = join(import.meta.dirname, 'fixtures/custom-matcher') const testFile = join(root, 'basic.test.ts') const snapshotFile = join(root, '__snapshots__/basic.test.ts.snap') + const rawSnapshotFile = join(root, '__snapshots__/raw.txt') // remove snapshots fs.rmSync(join(root, '__snapshots__'), { recursive: true, force: true }) @@ -48,6 +49,12 @@ test('custom snapshot matcher', async () => { \`; " `) + expect(readFileSync(rawSnapshotFile, 'utf-8')).toMatchInlineSnapshot(` + "Object { + "length": 6, + "reversed": "ihihih", + }" + `) expect(extractInlineBlocks(readFileSync(testFile, 'utf-8'))).toMatchInlineSnapshot(` "test('inline', () => { expect(\`hehehe\`).toMatchCustomInlineSnapshot(\` @@ -65,6 +72,7 @@ test('custom snapshot matcher', async () => { "inline": "passed", "properties 1": "passed", "properties 2": "passed", + "raw": "passed", }, } `) @@ -74,12 +82,13 @@ test('custom snapshot matcher', async () => { .replace('`hahaha`', '`hahaha-edit`') .replace('`popopo`', '`popopo-edit`') .replace('`pepepe`', '`pepepe-edit`') + .replace('`hihihi`', '`hihihi-edit`') .replace('`hehehe`', '`hehehe-edit`')) result = await runVitest({ root, update: 'none' }) expect(result.stderr).toMatchInlineSnapshot(` " - ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 4 ⎯⎯⎯⎯⎯⎯⎯ + ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯ FAIL basic.test.ts > file Error: [custom error] Snapshot \`file 1\` mismatched @@ -94,15 +103,15 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-ahahah", } - ❯ basic.test.ts:40:25 - 38| - 39| test('file', () => { - 40| expect(\`hahaha-edit\`).toMatchCustomSnapshot() + ❯ basic.test.ts:46:25 + 44| + 45| test('file', () => { + 46| expect(\`hahaha-edit\`).toMatchCustomSnapshot() | ^ - 41| }) - 42| + 47| }) + 48| - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/4]⎯ + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/5]⎯ FAIL basic.test.ts > properties 1 Error: [custom error] Snapshot properties mismatched @@ -116,15 +125,15 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-opopop", } - ❯ basic.test.ts:44:25 - 42| - 43| test('properties 1', () => { - 44| expect(\`popopo-edit\`).toMatchCustomSnapshot({ length: 6 }) + ❯ basic.test.ts:50:25 + 48| + 49| test('properties 1', () => { + 50| expect(\`popopo-edit\`).toMatchCustomSnapshot({ length: 6 }) | ^ - 45| }) - 46| + 51| }) + 52| - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/4]⎯ + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/5]⎯ FAIL basic.test.ts > properties 2 Error: [custom error] Snapshot properties mismatched @@ -138,15 +147,38 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-epepep", } - ❯ basic.test.ts:48:25 - 46| - 47| test('properties 2', () => { - 48| expect(\`pepepe-edit\`).toMatchCustomSnapshot({ length: expect.toSatis… + ❯ basic.test.ts:54:25 + 52| + 53| test('properties 2', () => { + 54| expect(\`pepepe-edit\`).toMatchCustomSnapshot({ length: expect.toSatis… | ^ - 49| }) - 50| + 55| }) + 56| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/5]⎯ + + FAIL basic.test.ts > raw + Error: [custom error] Snapshot \`raw 1\` mismatched + + - Expected + + Received + + Object { + - "length": 6, + + "length": 11, + - "reversed": "ihihih", + + "reversed": "tide-ihihih", + } - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/4]⎯ + ❯ basic.test.ts:58:3 + 56| + 57| test('raw', async () => { + 58| await expect(\`hihihi-edit\`).toMatchCustomFileSnapshot('./__snapshots… + | ^ + 59| }) + 60| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/5]⎯ FAIL basic.test.ts > inline Error: [custom error] Snapshot \`inline 1\` mismatched @@ -161,15 +193,15 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-eheheh", } - ❯ basic.test.ts:53:25 - 51| // -- TEST INLINE START -- - 52| test('inline', () => { - 53| expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` + ❯ basic.test.ts:63:25 + 61| // -- TEST INLINE START -- + 62| test('inline', () => { + 63| expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` | ^ - 54| Object { - 55| "length": 6, + 64| Object { + 65| "length": 6, - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/4]⎯ + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/5]⎯ " `) @@ -188,6 +220,9 @@ test('custom snapshot matcher', async () => { "properties 2": Array [ "[custom error] Snapshot properties mismatched", ], + "raw": Array [ + "[custom error] Snapshot \`raw 1\` mismatched", + ], }, } `) @@ -210,13 +245,13 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-opopop", } - ❯ basic.test.ts:44:25 - 42| - 43| test('properties 1', () => { - 44| expect(\`popopo-edit\`).toMatchCustomSnapshot({ length: 6 }) + ❯ basic.test.ts:50:25 + 48| + 49| test('properties 1', () => { + 50| expect(\`popopo-edit\`).toMatchCustomSnapshot({ length: 6 }) | ^ - 45| }) - 46| + 51| }) + 52| ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ @@ -232,13 +267,13 @@ test('custom snapshot matcher', async () => { + "reversed": "tide-epepep", } - ❯ basic.test.ts:48:25 - 46| - 47| test('properties 2', () => { - 48| expect(\`pepepe-edit\`).toMatchCustomSnapshot({ length: expect.toSatis… + ❯ basic.test.ts:54:25 + 52| + 53| test('properties 2', () => { + 54| expect(\`pepepe-edit\`).toMatchCustomSnapshot({ length: expect.toSatis… | ^ - 49| }) - 50| + 55| }) + 56| ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ @@ -269,6 +304,12 @@ test('custom snapshot matcher', async () => { \`; " `) + expect(readFileSync(rawSnapshotFile, 'utf-8')).toMatchInlineSnapshot(` + "Object { + "length": 11, + "reversed": "tide-ihihih", + }" + `) expect(extractInlineBlocks(readFileSync(testFile, 'utf-8'))).toMatchInlineSnapshot(` "test('inline', () => { expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` @@ -290,6 +331,7 @@ test('custom snapshot matcher', async () => { "properties 2": Array [ "[custom error] Snapshot properties mismatched", ], + "raw": "passed", }, } `) diff --git a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts index 59a253a0bc95..1da84b6d0346 100644 --- a/test/snapshots/test/fixtures/custom-matcher/basic.test.ts +++ b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts @@ -1,10 +1,11 @@ import { expect, test } from 'vitest' -import { toMatchInlineSnapshot, toMatchSnapshot } from "vitest/runtime" +import { toMatchFileSnapshot, toMatchInlineSnapshot, toMatchSnapshot } from "vitest/runtime" // custom snapshot matcher to wraper input code string interface CustomMatchers { toMatchCustomSnapshot: (properties?: object) => R toMatchCustomInlineSnapshot: (snapshot?: string) => R + toMatchCustomFileSnapshot: (filepath: string) => Promise } declare module 'vitest' { @@ -34,6 +35,11 @@ expect.extend({ const result = toMatchInlineSnapshot.call(this, actualCustom, inlineSnapshot) return { ...result, message: () => `[custom error] ${result.message()}` } }, + async toMatchCustomFileSnapshot(actual: string, filepath: string) { + const actualCustom = formatCustom(actual) + const result = await toMatchFileSnapshot.call(this, actualCustom, filepath) + return { ...result, message: () => `[custom error] ${result.message()}` } + }, }) test('file', () => { @@ -48,6 +54,10 @@ test('properties 2', () => { expect(`pepepe`).toMatchCustomSnapshot({ length: expect.toSatisfy(function lessThan10(n) { return n < 10 }) }) }) +test('raw', async () => { + await expect(`hihihi`).toMatchCustomFileSnapshot('./__snapshots__/raw.txt') +}) + // -- TEST INLINE START -- test('inline', () => { expect(`hehehe`).toMatchCustomInlineSnapshot(` From 4aba3380c1810a218b4b764f04ced48470d0a3c9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 28 Mar 2026 14:54:41 +0900 Subject: [PATCH 44/57] docs: document toMatchFileSnapshot --- docs/guide/extending-matchers.md | 2 +- docs/guide/snapshot.md | 14 +++++++++++++- .../vitest/src/integrations/snapshot/chai.ts | 19 ++++++++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/guide/extending-matchers.md b/docs/guide/extending-matchers.md index f81c22666c82..2d634c22b049 100644 --- a/docs/guide/extending-matchers.md +++ b/docs/guide/extending-matchers.md @@ -108,7 +108,7 @@ expect.extend({ customMatcher }) ``` ::: tip -To build custom **snapshot matchers** (wrappers around `toMatchSnapshot` / `toMatchInlineSnapshot`), use the composable functions from `vitest/runtime`. See [Custom Snapshot Matchers](/guide/snapshot#custom-snapshot-matchers). +To build custom **snapshot matchers** (wrappers around `toMatchSnapshot` / `toMatchInlineSnapshot` / `toMatchFileSnapshot`), use the composable functions from `vitest/runtime`. See [Custom Snapshot Matchers](/guide/snapshot#custom-snapshot-matchers). ::: Matcher function has access to `this` context with the following properties: diff --git a/docs/guide/snapshot.md b/docs/guide/snapshot.md index ff6c0e6f509d..f3fb28744b33 100644 --- a/docs/guide/snapshot.md +++ b/docs/guide/snapshot.md @@ -206,7 +206,7 @@ You can build custom snapshot matchers using the composable functions exported f ```ts import { expect, test } from 'vitest' -import { toMatchInlineSnapshot, toMatchSnapshot } from 'vitest/runtime' +import { toMatchFileSnapshot, toMatchInlineSnapshot, toMatchSnapshot } from 'vitest/runtime' expect.extend({ toMatchTrimmedSnapshot(received: string, length: number) { @@ -215,6 +215,9 @@ expect.extend({ toMatchTrimmedInlineSnapshot(received: string, inlineSnapshot?: string) { return toMatchInlineSnapshot.call(this, received.slice(0, 10), inlineSnapshot) }, + async toMatchTrimmedFileSnapshot(received: string, file: string) { + return toMatchFileSnapshot.call(this, received.slice(0, 10), file) + }, }) test('file snapshot', () => { @@ -224,6 +227,10 @@ test('file snapshot', () => { test('inline snapshot', () => { expect('extra long string oh my gerd').toMatchTrimmedInlineSnapshot() }) + +test('raw file snapshot', async () => { + await expect('extra long string oh my gerd').toMatchTrimmedFileSnapshot('./raw-file.txt') +}) ``` The composables return `{ pass, message }` so you can further customize the error: @@ -241,6 +248,10 @@ expect.extend({ For inline snapshot matchers, the snapshot argument must be the last parameter (or second-to-last when using property matchers). Vitest rewrites the last string argument in the source code, so custom arguments before the snapshot work, but custom arguments after it are not supported. ::: +::: tip +File snapshot matchers must be `async` — `toMatchFileSnapshot` returns a `Promise`. Remember to `await` the result in the matcher and in your test. +::: + For TypeScript, extend the `Assertion` interface: ```ts @@ -250,6 +261,7 @@ declare module 'vitest' { interface Assertion { toMatchTrimmedSnapshot: (length: number) => T toMatchTrimmedInlineSnapshot: (inlineSnapshot?: string) => T + toMatchTrimmedFileSnapshot: (file: string) => Promise } } ``` diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 25063a4184a7..3b5ce12b0aa2 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -369,7 +369,24 @@ export function toMatchInlineSnapshot( }) } -// TODO: docs +/** + * Composable for building custom file snapshot matchers via `expect.extend`. + * Call with `this` bound to the matcher state. Returns a `Promise<{ pass, message }>` + * compatible with the custom matcher return contract. + * + * @example + * ```ts + * import { toMatchFileSnapshot } from 'vitest/runtime' + * + * expect.extend({ + * async toMatchTrimmedFileSnapshot(received: string, file: string) { + * return toMatchFileSnapshot.call(this, received.slice(0, 10), file) + * }, + * }) + * ``` + * + * @see https://vitest.dev/guide/snapshot.html#custom-snapshot-matchers + */ export function toMatchFileSnapshot( this: MatcherState, received: unknown, From b38ccbcbec7fabdc1c451c4a873986c6b15f19ac Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 28 Mar 2026 15:07:16 +0900 Subject: [PATCH 45/57] refactor: minor nit --- .../vitest/src/integrations/snapshot/chai.ts | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 3b5ce12b0aa2..5bc6a4c1286a 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -52,15 +52,15 @@ function getTestNames(test: Test) { } } -export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { - function getTest(assertionName: string, obj: object) { - const test = utils.flag(obj, 'vitest-test') - if (!test) { - throw new Error(`'${assertionName}' cannot be used without test context`) - } - return test as Test +function getTest(obj: Chai.Assertion, utils: Chai.ChaiUtils, assertionName: string) { + const test = utils.flag(obj, 'vitest-test') + if (!test) { + throw new Error(`'${assertionName}' cannot be used without test context`) } + return test as Test +} +export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { for (const key of ['matchSnapshot', 'toMatchSnapshot']) { utils.addMethod( chai.Assertion.prototype, @@ -96,7 +96,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { assert: true, }) return recordAsyncExpect( - getTest('toMatchFileSnapshot', this), + getTest(this, utils, 'toMatchFileSnapshot'), promise, createAssertionMessage(utils, this, true), new Error('resolves'), @@ -221,10 +221,8 @@ function toMatchSnapshotImpl(options: { if (isNot) { throw new Error(`${assertionName} cannot be used with "not"`) } - const test = utils.flag(assertion, 'vitest-test') as Test | undefined - if (!test) { - throw new Error(`'${assertionName}' cannot be used without test context`) - } + + const test = getTest(assertion, utils, assertionName) const result = getSnapshotClient().match({ received: options.received, properties: options.properties, @@ -244,9 +242,6 @@ function toMatchSnapshotImpl(options: { return result } -// TODO: refactor -// - getTest -// - getTestNames async function toMatchFileSnapshotImpl(options: { assertion: Chai.AssertionStatic & Chai.Assertion utils: Chai.ChaiUtils @@ -263,11 +258,8 @@ async function toMatchFileSnapshotImpl(options: { if (isNot) { throw new Error(`${assertionName} cannot be used with "not"`) } - const test = utils.flag(assertion, 'vitest-test') as Test | undefined - if (!test) { - throw new Error(`'${assertionName}' cannot be used without test context`) - } + const test = getTest(assertion, utils, assertionName) const testNames = getTestNames(test) const snapshotState = getSnapshotClient().getSnapshotState(testNames.filepath) const rawSnapshotFile = await snapshotState.environment.resolveRawPath(testNames.filepath, options.filepath) From 09e6bc949cf898820ccf4769a1d080e633cebea7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 28 Mar 2026 15:22:37 +0900 Subject: [PATCH 46/57] fix: fix negate assertion check --- packages/expect/src/types.ts | 2 +- packages/expect/src/utils.ts | 1 + packages/vitest/src/integrations/snapshot/chai.ts | 9 +++++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 872c32d68b25..1b243e290085 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -87,7 +87,7 @@ export interface MatcherState { /** @internal */ __vitest_context__: { chaiAssertion: Chai.AssertionStatic & Chai.Assertion - chaiUtils: Chai.ChaiUtils + chaiUtils: Chai.ChaiUtils // TODO: just access from import chai.util? assertionName: string } } diff --git a/packages/expect/src/utils.ts b/packages/expect/src/utils.ts index 13b2620055da..c9a1957c7c01 100644 --- a/packages/expect/src/utils.ts +++ b/packages/expect/src/utils.ts @@ -92,6 +92,7 @@ function handleTestError(test: Test, err: unknown) { test.result.errors.push(processError(err)) } +/** wrap assertion function to support `expect.soft` */ export function wrapAssertion( utils: Chai.ChaiUtils, name: string, diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 5bc6a4c1286a..4c94bc4e665e 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -86,10 +86,15 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { chai.Assertion.prototype, 'toMatchFileSnapshot', function (this: Chai.AssertionStatic & Assertion, filepath: string, hint?: string) { + const assertionName = 'toMatchFileSnapshot' + const isNot = utils.flag(this, 'negate') + if (isNot) { + throw new Error(`${assertionName} cannot be used with "not"`) + } const promise = toMatchFileSnapshotImpl({ assertion: this, utils, - assertionName: 'toMatchFileSnapshot', + assertionName, received: utils.flag(this, 'object'), filepath, hint, @@ -216,7 +221,7 @@ function toMatchSnapshotImpl(options: { }): SyncExpectationResult { const { assertion, utils, assertionName } = options - utils.flag(assertion, '_name', assertionName) + utils.flag(assertion, '_name', assertionName) // TODO: move to caller and jest-extend? const isNot = utils.flag(assertion, 'negate') if (isNot) { throw new Error(`${assertionName} cannot be used with "not"`) From 0cee3de69be1a9df5c0dcb9dd5d10e3a3b7aaf20 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 28 Mar 2026 15:44:09 +0900 Subject: [PATCH 47/57] test: update exports --- test/core/test/exports.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/core/test/exports.test.ts b/test/core/test/exports.test.ts index d8b9e7557f97..93aaf0555edc 100644 --- a/test/core/test/exports.test.ts +++ b/test/core/test/exports.test.ts @@ -165,6 +165,7 @@ it('exports snapshot', async ({ skip, task }) => { "__INTERNAL": "object", "builtinEnvironments": "object", "populateGlobal": "function", + "toMatchFileSnapshot": "function", "toMatchInlineSnapshot": "function", "toMatchSnapshot": "function", }, From 76155eb0ca6671ad46a9ab3758c10b5e2b8a8fa9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 28 Mar 2026 15:52:32 +0900 Subject: [PATCH 48/57] refactor: replace nonsense __vitest_context__.chaiUtils --- packages/expect/src/jest-extend.ts | 1 - packages/expect/src/types.ts | 1 - packages/expect/src/utils.ts | 2 +- .../vitest/src/integrations/snapshot/chai.ts | 43 ++++++++----------- 4 files changed, 19 insertions(+), 28 deletions(-) diff --git a/packages/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index 8c0d5680e189..1c1b69e36681 100644 --- a/packages/expect/src/jest-extend.ts +++ b/packages/expect/src/jest-extend.ts @@ -46,7 +46,6 @@ function getMatcherState( ...getState(expect), __vitest_context__: { chaiAssertion: assertion, - chaiUtils: util, assertionName, }, task, diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 1b243e290085..e45d226bb71d 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -87,7 +87,6 @@ export interface MatcherState { /** @internal */ __vitest_context__: { chaiAssertion: Chai.AssertionStatic & Chai.Assertion - chaiUtils: Chai.ChaiUtils // TODO: just access from import chai.util? assertionName: string } } diff --git a/packages/expect/src/utils.ts b/packages/expect/src/utils.ts index c9a1957c7c01..b6b598ab7cd7 100644 --- a/packages/expect/src/utils.ts +++ b/packages/expect/src/utils.ts @@ -92,7 +92,7 @@ function handleTestError(test: Test, err: unknown) { test.result.errors.push(processError(err)) } -/** wrap assertion function to support `expect.soft` */ +/** wrap assertion function to support `expect.soft` and provide `_name` */ export function wrapAssertion( utils: Chai.ChaiUtils, name: string, diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 4c94bc4e665e..49b4c8053f97 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -1,6 +1,6 @@ import type { Assertion, ChaiPlugin, MatcherState, SyncExpectationResult } from '@vitest/expect' import type { Test } from '@vitest/runner' -import { createAssertionMessage, equals, iterableEquality, recordAsyncExpect, subsetEquality, wrapAssertion } from '@vitest/expect' +import { chai, createAssertionMessage, equals, iterableEquality, recordAsyncExpect, subsetEquality, wrapAssertion } from '@vitest/expect' import { getNames } from '@vitest/runner/utils' import { addSerializer, @@ -52,8 +52,8 @@ function getTestNames(test: Test) { } } -function getTest(obj: Chai.Assertion, utils: Chai.ChaiUtils, assertionName: string) { - const test = utils.flag(obj, 'vitest-test') +function getTest(obj: Chai.Assertion, assertionName: string) { + const test = chai.util.flag(obj, 'vitest-test') if (!test) { throw new Error(`'${assertionName}' cannot be used without test context`) } @@ -72,7 +72,6 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { ) { toMatchSnapshotImpl({ assertion: this, - utils, assertionName: key, assert: true, received: utils.flag(this, 'object'), @@ -93,7 +92,6 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { } const promise = toMatchFileSnapshotImpl({ assertion: this, - utils, assertionName, received: utils.flag(this, 'object'), filepath, @@ -101,7 +99,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { assert: true, }) return recordAsyncExpect( - getTest(this, utils, 'toMatchFileSnapshot'), + getTest(this, 'toMatchFileSnapshot'), promise, createAssertionMessage(utils, this, true), new Error('resolves'), @@ -121,7 +119,6 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { ) { toMatchSnapshotImpl({ assertion: this, - utils, assertionName: 'toMatchInlineSnapshot', assert: true, received: utils.flag(this, 'object'), @@ -143,7 +140,6 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { const promise = utils.flag(this, 'promise') as string | undefined toMatchSnapshotImpl({ assertion: this, - utils, assertionName: 'toThrowErrorMatchingSnapshot', assert: true, received: getError(received, promise), @@ -168,7 +164,6 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { const promise = utils.flag(this, 'promise') as string | undefined toMatchSnapshotImpl({ assertion: this, - utils, assertionName, assert: true, received: getError(received, promise), @@ -210,7 +205,6 @@ function normalizeInlineArguments( function toMatchSnapshotImpl(options: { assertion: Chai.AssertionStatic & Chai.Assertion - utils: Chai.ChaiUtils assertionName: string received: unknown assert?: boolean @@ -219,26 +213,26 @@ function toMatchSnapshotImpl(options: { isInline?: boolean inlineSnapshot?: string }): SyncExpectationResult { - const { assertion, utils, assertionName } = options + const { assertion, assertionName } = options - utils.flag(assertion, '_name', assertionName) // TODO: move to caller and jest-extend? - const isNot = utils.flag(assertion, 'negate') + chai.util.flag(assertion, '_name', assertionName) // TODO: move to caller and jest-extend? + const isNot = chai.util.flag(assertion, 'negate') if (isNot) { throw new Error(`${assertionName} cannot be used with "not"`) } - const test = getTest(assertion, utils, assertionName) + const test = getTest(assertion, assertionName) const result = getSnapshotClient().match({ received: options.received, properties: options.properties, message: options.hint, isInline: options.isInline, inlineSnapshot: options.inlineSnapshot, - errorMessage: utils.flag(assertion, 'message'), + errorMessage: chai.util.flag(assertion, 'message'), // pass `assertionName` for inline snapshot stack probing assertionName, // set by async assertion (e.g. resolves/rejects) for inline snapshot stack probing - error: utils.flag(assertion, 'error'), + error: chai.util.flag(assertion, 'error'), ...getTestNames(test), }) if (options.assert) { @@ -249,22 +243,21 @@ function toMatchSnapshotImpl(options: { async function toMatchFileSnapshotImpl(options: { assertion: Chai.AssertionStatic & Chai.Assertion - utils: Chai.ChaiUtils assertionName: string received: unknown filepath: string hint?: string assert?: boolean }): Promise { - const { assertion, utils, assertionName } = options + const { assertion, assertionName } = options - utils.flag(assertion, '_name', assertionName) - const isNot = utils.flag(assertion, 'negate') + chai.util.flag(assertion, '_name', assertionName) + const isNot = chai.util.flag(assertion, 'negate') if (isNot) { throw new Error(`${assertionName} cannot be used with "not"`) } - const test = getTest(assertion, utils, assertionName) + const test = getTest(assertion, assertionName) const testNames = getTestNames(test) const snapshotState = getSnapshotClient().getSnapshotState(testNames.filepath) const rawSnapshotFile = await snapshotState.environment.resolveRawPath(testNames.filepath, options.filepath) @@ -273,7 +266,7 @@ async function toMatchFileSnapshotImpl(options: { const result = getSnapshotClient().match({ received: options.received, message: options.hint, - errorMessage: utils.flag(assertion, 'message'), + errorMessage: chai.util.flag(assertion, 'message'), rawSnapshot: { file: rawSnapshotFile, content: rawSnapshotContent ?? undefined, @@ -324,7 +317,7 @@ export function toMatchSnapshot( ): SyncExpectationResult { return toMatchSnapshotImpl({ assertion: this.__vitest_context__.chaiAssertion, - utils: this.__vitest_context__.chaiUtils, + assertionName: this.__vitest_context__.assertionName, received, ...normalizeArguments(propertiesOrHint, hint), @@ -358,7 +351,7 @@ export function toMatchInlineSnapshot( ): SyncExpectationResult { return toMatchSnapshotImpl({ assertion: this.__vitest_context__.chaiAssertion, - utils: this.__vitest_context__.chaiUtils, + assertionName: this.__vitest_context__.assertionName, received, isInline: true, @@ -392,7 +385,7 @@ export function toMatchFileSnapshot( ): Promise { return toMatchFileSnapshotImpl({ assertion: this.__vitest_context__.chaiAssertion, - utils: this.__vitest_context__.chaiUtils, + assertionName: this.__vitest_context__.assertionName, received, filepath, From 4bd8a28b6c37cb000f32eb646ce53b6ad79f07e7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 28 Mar 2026 16:11:29 +0900 Subject: [PATCH 49/57] refactor: drop __vitest_context__.assertionName too --- packages/expect/src/jest-extend.ts | 6 ++-- packages/expect/src/types.ts | 3 +- packages/expect/src/utils.ts | 2 +- .../vitest/src/integrations/snapshot/chai.ts | 35 +++++-------------- 4 files changed, 13 insertions(+), 33 deletions(-) diff --git a/packages/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index 1c1b69e36681..b867fedf5b4a 100644 --- a/packages/expect/src/jest-extend.ts +++ b/packages/expect/src/jest-extend.ts @@ -22,7 +22,6 @@ import { wrapAssertion } from './utils' function getMatcherState( assertion: Chai.AssertionStatic & Chai.Assertion, expect: ExpectStatic, - assertionName: string, ) { const obj = assertion._obj const isNot = util.flag(assertion, 'negate') as boolean @@ -45,8 +44,7 @@ function getMatcherState( const matcherState: MatcherState = { ...getState(expect), __vitest_context__: { - chaiAssertion: assertion, - assertionName, + assertion, }, task, currentTestName, @@ -98,7 +96,7 @@ function JestExtendPlugin( this: Chai.AssertionStatic & Chai.Assertion, ...args: any[] ) { - const { state, isNot, obj, customMessage } = getMatcherState(this, expect, expectAssertionName) + const { state, isNot, obj, customMessage } = getMatcherState(this, expect) const result = expectAssertion.call(state, obj, ...args) diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index e45d226bb71d..e0532a0f718e 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -86,8 +86,7 @@ export interface MatcherState { task?: Readonly /** @internal */ __vitest_context__: { - chaiAssertion: Chai.AssertionStatic & Chai.Assertion - assertionName: string + assertion: Chai.AssertionStatic & Chai.Assertion } } diff --git a/packages/expect/src/utils.ts b/packages/expect/src/utils.ts index b6b598ab7cd7..21be1ea8f20f 100644 --- a/packages/expect/src/utils.ts +++ b/packages/expect/src/utils.ts @@ -92,7 +92,7 @@ function handleTestError(test: Test, err: unknown) { test.result.errors.push(processError(err)) } -/** wrap assertion function to support `expect.soft` and provide `_name` */ +/** wrap assertion function to support `expect.soft` and provide assertion name as `_name` */ export function wrapAssertion( utils: Chai.ChaiUtils, name: string, diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 49b4c8053f97..fd12ece3b6e5 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -72,7 +72,6 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { ) { toMatchSnapshotImpl({ assertion: this, - assertionName: key, assert: true, received: utils.flag(this, 'object'), ...normalizeArguments(propertiesOrHint, hint), @@ -85,14 +84,10 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { chai.Assertion.prototype, 'toMatchFileSnapshot', function (this: Chai.AssertionStatic & Assertion, filepath: string, hint?: string) { - const assertionName = 'toMatchFileSnapshot' - const isNot = utils.flag(this, 'negate') - if (isNot) { - throw new Error(`${assertionName} cannot be used with "not"`) - } + // set name manually since it's not wrapped by wrapAssertion + utils.flag(this, '_name', 'toMatchFileSnapshot') const promise = toMatchFileSnapshotImpl({ assertion: this, - assertionName, received: utils.flag(this, 'object'), filepath, hint, @@ -119,7 +114,6 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { ) { toMatchSnapshotImpl({ assertion: this, - assertionName: 'toMatchInlineSnapshot', assert: true, received: utils.flag(this, 'object'), isInline: true, @@ -140,7 +134,6 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { const promise = utils.flag(this, 'promise') as string | undefined toMatchSnapshotImpl({ assertion: this, - assertionName: 'toThrowErrorMatchingSnapshot', assert: true, received: getError(received, promise), ...normalizeArguments(propertiesOrHint, hint), @@ -164,7 +157,6 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { const promise = utils.flag(this, 'promise') as string | undefined toMatchSnapshotImpl({ assertion: this, - assertionName, assert: true, received: getError(received, promise), isInline: true, @@ -205,7 +197,6 @@ function normalizeInlineArguments( function toMatchSnapshotImpl(options: { assertion: Chai.AssertionStatic & Chai.Assertion - assertionName: string received: unknown assert?: boolean properties?: object @@ -213,9 +204,8 @@ function toMatchSnapshotImpl(options: { isInline?: boolean inlineSnapshot?: string }): SyncExpectationResult { - const { assertion, assertionName } = options - - chai.util.flag(assertion, '_name', assertionName) // TODO: move to caller and jest-extend? + const { assertion } = options + const assertionName = chai.util.flag(assertion, '_name') as string const isNot = chai.util.flag(assertion, 'negate') if (isNot) { throw new Error(`${assertionName} cannot be used with "not"`) @@ -243,15 +233,14 @@ function toMatchSnapshotImpl(options: { async function toMatchFileSnapshotImpl(options: { assertion: Chai.AssertionStatic & Chai.Assertion - assertionName: string received: unknown filepath: string hint?: string assert?: boolean }): Promise { - const { assertion, assertionName } = options + const { assertion } = options + const assertionName = chai.util.flag(assertion, '_name') as string - chai.util.flag(assertion, '_name', assertionName) const isNot = chai.util.flag(assertion, 'negate') if (isNot) { throw new Error(`${assertionName} cannot be used with "not"`) @@ -316,9 +305,7 @@ export function toMatchSnapshot( hint?: string, ): SyncExpectationResult { return toMatchSnapshotImpl({ - assertion: this.__vitest_context__.chaiAssertion, - - assertionName: this.__vitest_context__.assertionName, + assertion: this.__vitest_context__.assertion, received, ...normalizeArguments(propertiesOrHint, hint), }) @@ -350,9 +337,7 @@ export function toMatchInlineSnapshot( hint?: string, ): SyncExpectationResult { return toMatchSnapshotImpl({ - assertion: this.__vitest_context__.chaiAssertion, - - assertionName: this.__vitest_context__.assertionName, + assertion: this.__vitest_context__.assertion, received, isInline: true, ...normalizeInlineArguments(propertiesOrInlineSnapshot, inlineSnapshotOrHint, hint), @@ -384,9 +369,7 @@ export function toMatchFileSnapshot( hint?: string, ): Promise { return toMatchFileSnapshotImpl({ - assertion: this.__vitest_context__.chaiAssertion, - - assertionName: this.__vitest_context__.assertionName, + assertion: this.__vitest_context__.assertion, received, filepath, hint, From c6783dfe7d08731049b557c4df6f226794c69aae Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 28 Mar 2026 16:26:18 +0900 Subject: [PATCH 50/57] refactor: more nit --- packages/expect/src/types.ts | 2 + .../vitest/src/integrations/snapshot/chai.ts | 53 +++++++++---------- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index e0532a0f718e..c3c4740e4073 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -84,6 +84,8 @@ export interface MatcherState { soft?: boolean poll?: boolean task?: Readonly + // TODO: rename to __vitest_assertion__ + // TODO: not sure Vitest `Assertion` vs Chai one /** @internal */ __vitest_context__: { assertion: Chai.AssertionStatic & Chai.Assertion diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index fd12ece3b6e5..980c962f01d5 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -52,14 +52,28 @@ function getTestNames(test: Test) { } } -function getTest(obj: Chai.Assertion, assertionName: string) { +function getAssertionName(assertion: Chai.Assertion): string { + const name = chai.util.flag(assertion, '_name') as string | undefined + if (!name) { + throw new Error('Assertion name is not set. This is a bug in Vitest. Please, open a new issue with reproduction.') + } + return name +} + +function getTest(obj: Chai.Assertion) { const test = chai.util.flag(obj, 'vitest-test') if (!test) { - throw new Error(`'${assertionName}' cannot be used without test context`) + throw new Error(`'${getAssertionName(obj)}' cannot be used without test context`) } return test as Test } +function validateAssertion(assertion: Chai.Assertion): void { + if (chai.util.flag(assertion, 'negate')) { + throw new Error(`${getAssertionName(assertion)} cannot be used with "not"`) + } +} + export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { for (const key of ['matchSnapshot', 'toMatchSnapshot']) { utils.addMethod( @@ -86,6 +100,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { function (this: Chai.AssertionStatic & Assertion, filepath: string, hint?: string) { // set name manually since it's not wrapped by wrapAssertion utils.flag(this, '_name', 'toMatchFileSnapshot') + validateAssertion(this) const promise = toMatchFileSnapshotImpl({ assertion: this, received: utils.flag(this, 'object'), @@ -94,7 +109,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { assert: true, }) return recordAsyncExpect( - getTest(this, 'toMatchFileSnapshot'), + getTest(this), promise, createAssertionMessage(utils, this, true), new Error('resolves'), @@ -125,11 +140,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { chai.Assertion.prototype, 'toThrowErrorMatchingSnapshot', wrapAssertion(utils, 'toThrowErrorMatchingSnapshot', function (this, propertiesOrHint?: object | string, hint?: string) { - const assertionName = 'toThrowErrorMatchingSnapshot' - const isNot = utils.flag(this, 'negate') - if (isNot) { - throw new Error(`${assertionName} cannot be used with "not"`) - } + validateAssertion(this) const received = utils.flag(this, 'object') const promise = utils.flag(this, 'promise') as string | undefined toMatchSnapshotImpl({ @@ -148,11 +159,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { inlineSnapshotOrHint?: string, hint?: string, ) { - const assertionName = 'toThrowErrorMatchingInlineSnapshot' - const isNot = utils.flag(this, 'negate') - if (isNot) { - throw new Error(`${assertionName} cannot be used with "not"`) - } + validateAssertion(this) const received = utils.flag(this, 'object') const promise = utils.flag(this, 'promise') as string | undefined toMatchSnapshotImpl({ @@ -205,13 +212,9 @@ function toMatchSnapshotImpl(options: { inlineSnapshot?: string }): SyncExpectationResult { const { assertion } = options - const assertionName = chai.util.flag(assertion, '_name') as string - const isNot = chai.util.flag(assertion, 'negate') - if (isNot) { - throw new Error(`${assertionName} cannot be used with "not"`) - } - - const test = getTest(assertion, assertionName) + validateAssertion(assertion) + const assertionName = getAssertionName(assertion) + const test = getTest(assertion) const result = getSnapshotClient().match({ received: options.received, properties: options.properties, @@ -239,14 +242,8 @@ async function toMatchFileSnapshotImpl(options: { assert?: boolean }): Promise { const { assertion } = options - const assertionName = chai.util.flag(assertion, '_name') as string - - const isNot = chai.util.flag(assertion, 'negate') - if (isNot) { - throw new Error(`${assertionName} cannot be used with "not"`) - } - - const test = getTest(assertion, assertionName) + validateAssertion(assertion) + const test = getTest(assertion) const testNames = getTestNames(test) const snapshotState = getSnapshotClient().getSnapshotState(testNames.filepath) const rawSnapshotFile = await snapshotState.environment.resolveRawPath(testNames.filepath, options.filepath) From 7a3d73ff9c19297fb985803ad1b056f7efd2b067 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 28 Mar 2026 16:48:39 +0900 Subject: [PATCH 51/57] refactor: nit types --- packages/expect/src/jest-expect.ts | 2 +- packages/expect/src/jest-extend.ts | 7 ++++--- packages/expect/src/types.ts | 2 +- packages/vitest/src/integrations/snapshot/chai.ts | 12 ++++++------ 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index 340dec122c0a..d97cd7ff36b9 100644 --- a/packages/expect/src/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -64,7 +64,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { (['throw', 'throws', 'Throw'] as const).forEach((m) => { utils.overwriteMethod(chai.Assertion.prototype, m, (_super: any) => { return function ( - this: Chai.Assertion & Chai.AssertionStatic, + this: Chai.Assertion, ...args: any[] ) { const promise = utils.flag(this, 'promise') diff --git a/packages/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index b867fedf5b4a..5c0552b63ea0 100644 --- a/packages/expect/src/jest-extend.ts +++ b/packages/expect/src/jest-extend.ts @@ -1,5 +1,6 @@ import type { Test } from '@vitest/runner' import type { + Assertion, ChaiPlugin, ExpectStatic, MatchersObject, @@ -20,10 +21,10 @@ import { getState } from './state' import { wrapAssertion } from './utils' function getMatcherState( - assertion: Chai.AssertionStatic & Chai.Assertion, + assertion: Assertion, expect: ExpectStatic, ) { - const obj = assertion._obj + const obj = util.flag(assertion, 'object') const isNot = util.flag(assertion, 'negate') as boolean const promise = util.flag(assertion, 'promise') || '' const customMessage = util.flag(assertion, 'message') as string | undefined @@ -93,7 +94,7 @@ function JestExtendPlugin( Object.entries(matchers).forEach( ([expectAssertionName, expectAssertion]) => { function __VITEST_EXTEND_ASSERTION__( - this: Chai.AssertionStatic & Chai.Assertion, + this: Assertion, ...args: any[] ) { const { state, isNot, obj, customMessage } = getMatcherState(this, expect) diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index c3c4740e4073..6b6f68b0f722 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -88,7 +88,7 @@ export interface MatcherState { // TODO: not sure Vitest `Assertion` vs Chai one /** @internal */ __vitest_context__: { - assertion: Chai.AssertionStatic & Chai.Assertion + assertion: Assertion } } diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 980c962f01d5..2f589e66aaab 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -52,7 +52,7 @@ function getTestNames(test: Test) { } } -function getAssertionName(assertion: Chai.Assertion): string { +function getAssertionName(assertion: Assertion): string { const name = chai.util.flag(assertion, '_name') as string | undefined if (!name) { throw new Error('Assertion name is not set. This is a bug in Vitest. Please, open a new issue with reproduction.') @@ -60,7 +60,7 @@ function getAssertionName(assertion: Chai.Assertion): string { return name } -function getTest(obj: Chai.Assertion) { +function getTest(obj: Assertion) { const test = chai.util.flag(obj, 'vitest-test') if (!test) { throw new Error(`'${getAssertionName(obj)}' cannot be used without test context`) @@ -68,7 +68,7 @@ function getTest(obj: Chai.Assertion) { return test as Test } -function validateAssertion(assertion: Chai.Assertion): void { +function validateAssertion(assertion: Assertion): void { if (chai.util.flag(assertion, 'negate')) { throw new Error(`${getAssertionName(assertion)} cannot be used with "not"`) } @@ -97,7 +97,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { utils.addMethod( chai.Assertion.prototype, 'toMatchFileSnapshot', - function (this: Chai.AssertionStatic & Assertion, filepath: string, hint?: string) { + function (this: Assertion, filepath: string, hint?: string) { // set name manually since it's not wrapped by wrapAssertion utils.flag(this, '_name', 'toMatchFileSnapshot') validateAssertion(this) @@ -203,7 +203,7 @@ function normalizeInlineArguments( } function toMatchSnapshotImpl(options: { - assertion: Chai.AssertionStatic & Chai.Assertion + assertion: Assertion received: unknown assert?: boolean properties?: object @@ -235,7 +235,7 @@ function toMatchSnapshotImpl(options: { } async function toMatchFileSnapshotImpl(options: { - assertion: Chai.AssertionStatic & Chai.Assertion + assertion: Assertion received: unknown filepath: string hint?: string From 8d3a89f2d6bec01b3df114d1c17ac7e4a1d43d65 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 28 Mar 2026 16:51:54 +0900 Subject: [PATCH 52/57] refactor: nit __vitest_context__.assertion -> __vitest_assertion__ --- packages/expect/src/jest-extend.ts | 4 +--- packages/expect/src/types.ts | 12 ++++++------ packages/vitest/src/integrations/snapshot/chai.ts | 6 +++--- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index 5c0552b63ea0..281efc584a8c 100644 --- a/packages/expect/src/jest-extend.ts +++ b/packages/expect/src/jest-extend.ts @@ -44,9 +44,6 @@ function getMatcherState( const matcherState: MatcherState = { ...getState(expect), - __vitest_context__: { - assertion, - }, task, currentTestName, customTesters: getCustomEqualityTesters(), @@ -58,6 +55,7 @@ function getMatcherState( suppressedErrors: [], soft: util.flag(assertion, 'soft') as boolean | undefined, poll: util.flag(assertion, 'poll') as boolean | undefined, + __vitest_assertion__: assertion, } return { diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 6b6f68b0f722..99bf6880acf0 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -84,12 +84,12 @@ export interface MatcherState { soft?: boolean poll?: boolean task?: Readonly - // TODO: rename to __vitest_assertion__ - // TODO: not sure Vitest `Assertion` vs Chai one - /** @internal */ - __vitest_context__: { - assertion: Assertion - } + /** + * @internal + * this allows expect.extend to implement builtin chai assertion equivalent feature. + * this used for custom snapshot matcher API. + */ + __vitest_assertion__: Assertion } export interface SyncExpectationResult { diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 2f589e66aaab..aee1d9b55364 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -302,7 +302,7 @@ export function toMatchSnapshot( hint?: string, ): SyncExpectationResult { return toMatchSnapshotImpl({ - assertion: this.__vitest_context__.assertion, + assertion: this.__vitest_assertion__, received, ...normalizeArguments(propertiesOrHint, hint), }) @@ -334,7 +334,7 @@ export function toMatchInlineSnapshot( hint?: string, ): SyncExpectationResult { return toMatchSnapshotImpl({ - assertion: this.__vitest_context__.assertion, + assertion: this.__vitest_assertion__, received, isInline: true, ...normalizeInlineArguments(propertiesOrInlineSnapshot, inlineSnapshotOrHint, hint), @@ -366,7 +366,7 @@ export function toMatchFileSnapshot( hint?: string, ): Promise { return toMatchFileSnapshotImpl({ - assertion: this.__vitest_context__.assertion, + assertion: this.__vitest_assertion__, received, filepath, hint, From fe0fc0e92f06418b844104742c2971edf1cec97a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 28 Mar 2026 16:59:45 +0900 Subject: [PATCH 53/57] refactor: reduce diff --- packages/expect/src/jest-expect.ts | 2 +- packages/expect/src/jest-extend.ts | 8 ++++---- packages/expect/src/types.ts | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index d97cd7ff36b9..340dec122c0a 100644 --- a/packages/expect/src/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -64,7 +64,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { (['throw', 'throws', 'Throw'] as const).forEach((m) => { utils.overwriteMethod(chai.Assertion.prototype, m, (_super: any) => { return function ( - this: Chai.Assertion, + this: Chai.Assertion & Chai.AssertionStatic, ...args: any[] ) { const promise = utils.flag(this, 'promise') diff --git a/packages/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index 281efc584a8c..87113f37b87a 100644 --- a/packages/expect/src/jest-extend.ts +++ b/packages/expect/src/jest-extend.ts @@ -21,10 +21,10 @@ import { getState } from './state' import { wrapAssertion } from './utils' function getMatcherState( - assertion: Assertion, + assertion: Chai.AssertionStatic & Chai.Assertion, expect: ExpectStatic, ) { - const obj = util.flag(assertion, 'object') + const obj = assertion._obj const isNot = util.flag(assertion, 'negate') as boolean const promise = util.flag(assertion, 'promise') || '' const customMessage = util.flag(assertion, 'message') as string | undefined @@ -55,7 +55,7 @@ function getMatcherState( suppressedErrors: [], soft: util.flag(assertion, 'soft') as boolean | undefined, poll: util.flag(assertion, 'poll') as boolean | undefined, - __vitest_assertion__: assertion, + __vitest_assertion__: assertion as any as Assertion, } return { @@ -92,7 +92,7 @@ function JestExtendPlugin( Object.entries(matchers).forEach( ([expectAssertionName, expectAssertion]) => { function __VITEST_EXTEND_ASSERTION__( - this: Assertion, + this: Chai.AssertionStatic & Chai.Assertion, ...args: any[] ) { const { state, isNot, obj, customMessage } = getMatcherState(this, expect) diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 99bf6880acf0..06dfd29cc60a 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -85,10 +85,11 @@ export interface MatcherState { poll?: boolean task?: Readonly /** - * @internal - * this allows expect.extend to implement builtin chai assertion equivalent feature. + * this allows `expect.extend`-based custom matcher + * to implement builtin vitest/chai assertion equivalent feature. * this used for custom snapshot matcher API. */ + /** @internal */ __vitest_assertion__: Assertion } From f79beb982062cc3b78cd974a6b7d48c7af9d8c78 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 28 Mar 2026 17:06:28 +0900 Subject: [PATCH 54/57] refactor: really last nit --- .../vitest/src/integrations/snapshot/chai.ts | 37 +++++++------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index aee1d9b55364..01d55b1e225c 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -84,12 +84,12 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { propertiesOrHint?: object | string, hint?: string, ) { - toMatchSnapshotImpl({ + const result = toMatchSnapshotImpl({ assertion: this, - assert: true, received: utils.flag(this, 'object'), ...normalizeArguments(propertiesOrHint, hint), }) + return assertMatchResult(result) }), ) } @@ -101,16 +101,16 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { // set name manually since it's not wrapped by wrapAssertion utils.flag(this, '_name', 'toMatchFileSnapshot') validateAssertion(this) - const promise = toMatchFileSnapshotImpl({ + const resultPromise = toMatchFileSnapshotImpl({ assertion: this, received: utils.flag(this, 'object'), filepath, hint, - assert: true, }) + const assertPromise = resultPromise.then(result => assertMatchResult(result)) return recordAsyncExpect( getTest(this), - promise, + assertPromise, createAssertionMessage(utils, this, true), new Error('resolves'), utils.flag(this, 'soft'), @@ -127,13 +127,13 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { inlineSnapshotOrHint?: string, hint?: string, ) { - toMatchSnapshotImpl({ + const result = toMatchSnapshotImpl({ assertion: this, - assert: true, received: utils.flag(this, 'object'), isInline: true, ...normalizeInlineArguments(propertiesOrInlineSnapshot, inlineSnapshotOrHint, hint), }) + return assertMatchResult(result) }), ) utils.addMethod( @@ -143,12 +143,12 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { validateAssertion(this) const received = utils.flag(this, 'object') const promise = utils.flag(this, 'promise') as string | undefined - toMatchSnapshotImpl({ + const result = toMatchSnapshotImpl({ assertion: this, - assert: true, received: getError(received, promise), ...normalizeArguments(propertiesOrHint, hint), }) + return assertMatchResult(result) }), ) utils.addMethod( @@ -162,13 +162,13 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { validateAssertion(this) const received = utils.flag(this, 'object') const promise = utils.flag(this, 'promise') as string | undefined - toMatchSnapshotImpl({ + const result = toMatchSnapshotImpl({ assertion: this, - assert: true, received: getError(received, promise), isInline: true, ...normalizeInlineArguments(undefined, inlineSnapshotOrHint, hint), }) + return assertMatchResult(result) }), ) utils.addMethod(chai.expect, 'addSnapshotSerializer', addSerializer) @@ -205,7 +205,6 @@ function normalizeInlineArguments( function toMatchSnapshotImpl(options: { assertion: Assertion received: unknown - assert?: boolean properties?: object hint?: string isInline?: boolean @@ -215,7 +214,7 @@ function toMatchSnapshotImpl(options: { validateAssertion(assertion) const assertionName = getAssertionName(assertion) const test = getTest(assertion) - const result = getSnapshotClient().match({ + return getSnapshotClient().match({ received: options.received, properties: options.properties, message: options.hint, @@ -228,10 +227,6 @@ function toMatchSnapshotImpl(options: { error: chai.util.flag(assertion, 'error'), ...getTestNames(test), }) - if (options.assert) { - assertMatchResult(result) - } - return result } async function toMatchFileSnapshotImpl(options: { @@ -239,7 +234,6 @@ async function toMatchFileSnapshotImpl(options: { received: unknown filepath: string hint?: string - assert?: boolean }): Promise { const { assertion } = options validateAssertion(assertion) @@ -248,8 +242,7 @@ async function toMatchFileSnapshotImpl(options: { const snapshotState = getSnapshotClient().getSnapshotState(testNames.filepath) const rawSnapshotFile = await snapshotState.environment.resolveRawPath(testNames.filepath, options.filepath) const rawSnapshotContent = await snapshotState.environment.readSnapshotFile(rawSnapshotFile) - - const result = getSnapshotClient().match({ + return getSnapshotClient().match({ received: options.received, message: options.hint, errorMessage: chai.util.flag(assertion, 'message'), @@ -259,10 +252,6 @@ async function toMatchFileSnapshotImpl(options: { }, ...testNames, }) - if (options.assert) { - assertMatchResult(result) - } - return result } function assertMatchResult(result: SyncExpectationResult): void { From 0a580996948d196fe47dcf790f283b2385665871 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 28 Mar 2026 17:09:27 +0900 Subject: [PATCH 55/57] chore: excuse --- packages/vitest/src/integrations/snapshot/chai.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 01d55b1e225c..490f7dd55022 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -100,6 +100,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { function (this: Assertion, filepath: string, hint?: string) { // set name manually since it's not wrapped by wrapAssertion utils.flag(this, '_name', 'toMatchFileSnapshot') + // validate early synchronously just not to break some existing tests validateAssertion(this) const resultPromise = toMatchFileSnapshotImpl({ assertion: this, From 5e97f694a16f3b62283248134c363338d67a4795 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 28 Mar 2026 17:30:36 +0900 Subject: [PATCH 56/57] chore: fix typecheck quirks --- packages/expect/src/jest-extend.ts | 3 +-- packages/expect/src/utils.ts | 2 +- packages/vitest/src/integrations/snapshot/chai.ts | 14 +++++++------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index 87113f37b87a..6a9bbf35cd6a 100644 --- a/packages/expect/src/jest-extend.ts +++ b/packages/expect/src/jest-extend.ts @@ -1,6 +1,5 @@ import type { Test } from '@vitest/runner' import type { - Assertion, ChaiPlugin, ExpectStatic, MatchersObject, @@ -55,7 +54,7 @@ function getMatcherState( suppressedErrors: [], soft: util.flag(assertion, 'soft') as boolean | undefined, poll: util.flag(assertion, 'poll') as boolean | undefined, - __vitest_assertion__: assertion as any as Assertion, + __vitest_assertion__: assertion as any, } return { diff --git a/packages/expect/src/utils.ts b/packages/expect/src/utils.ts index 21be1ea8f20f..49138dab7c2e 100644 --- a/packages/expect/src/utils.ts +++ b/packages/expect/src/utils.ts @@ -5,7 +5,7 @@ import { noop } from '@vitest/utils/helpers' export function createAssertionMessage( util: Chai.ChaiUtils, - assertion: Assertion, + assertion: Chai.Assertion, hasArgs: boolean, ) { const soft = util.flag(assertion, 'soft') ? '.soft' : '' diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 490f7dd55022..ea0876c45a23 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -1,4 +1,4 @@ -import type { Assertion, ChaiPlugin, MatcherState, SyncExpectationResult } from '@vitest/expect' +import type { ChaiPlugin, MatcherState, SyncExpectationResult } from '@vitest/expect' import type { Test } from '@vitest/runner' import { chai, createAssertionMessage, equals, iterableEquality, recordAsyncExpect, subsetEquality, wrapAssertion } from '@vitest/expect' import { getNames } from '@vitest/runner/utils' @@ -52,7 +52,7 @@ function getTestNames(test: Test) { } } -function getAssertionName(assertion: Assertion): string { +function getAssertionName(assertion: Chai.Assertion): string { const name = chai.util.flag(assertion, '_name') as string | undefined if (!name) { throw new Error('Assertion name is not set. This is a bug in Vitest. Please, open a new issue with reproduction.') @@ -60,7 +60,7 @@ function getAssertionName(assertion: Assertion): string { return name } -function getTest(obj: Assertion) { +function getTest(obj: Chai.Assertion) { const test = chai.util.flag(obj, 'vitest-test') if (!test) { throw new Error(`'${getAssertionName(obj)}' cannot be used without test context`) @@ -68,7 +68,7 @@ function getTest(obj: Assertion) { return test as Test } -function validateAssertion(assertion: Assertion): void { +function validateAssertion(assertion: Chai.Assertion): void { if (chai.util.flag(assertion, 'negate')) { throw new Error(`${getAssertionName(assertion)} cannot be used with "not"`) } @@ -97,7 +97,7 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { utils.addMethod( chai.Assertion.prototype, 'toMatchFileSnapshot', - function (this: Assertion, filepath: string, hint?: string) { + function (this: Chai.Assertion, filepath: string, hint?: string) { // set name manually since it's not wrapped by wrapAssertion utils.flag(this, '_name', 'toMatchFileSnapshot') // validate early synchronously just not to break some existing tests @@ -204,7 +204,7 @@ function normalizeInlineArguments( } function toMatchSnapshotImpl(options: { - assertion: Assertion + assertion: Chai.Assertion received: unknown properties?: object hint?: string @@ -231,7 +231,7 @@ function toMatchSnapshotImpl(options: { } async function toMatchFileSnapshotImpl(options: { - assertion: Assertion + assertion: Chai.Assertion received: unknown filepath: string hint?: string From c28199b1e1e2f81102f7536f8d7c61a1dddb61ce Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 1 Apr 2026 18:30:45 +0900 Subject: [PATCH 57/57] docs: mark runtime snapshot composables experimental Co-authored-by: Codex --- docs/guide/migration.md | 2 +- docs/guide/snapshot.md | 2 +- packages/vitest/src/integrations/snapshot/chai.ts | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/guide/migration.md b/docs/guide/migration.md index 826c246e9b25..952da819b200 100644 --- a/docs/guide/migration.md +++ b/docs/guide/migration.md @@ -650,7 +650,7 @@ export default defineConfig({ Otherwise your snapshots will have a lot of escaped `"` characters. -### Custom Snapshot Matchers +### Custom Snapshot Matchers experimental 4.1.3 Jest imports snapshot composables from `jest-snapshot`. In Vitest, import from `vitest/runtime` instead: diff --git a/docs/guide/snapshot.md b/docs/guide/snapshot.md index f3fb28744b33..2ca266c4a8c8 100644 --- a/docs/guide/snapshot.md +++ b/docs/guide/snapshot.md @@ -200,7 +200,7 @@ Pretty foo: Object { We are using Jest's `pretty-format` for serializing snapshots. You can read more about it here: [pretty-format](https://github.com/facebook/jest/blob/main/packages/pretty-format/README.md#serialize). -## Custom Snapshot Matchers +## Custom Snapshot Matchers experimental 4.1.3 {#custom-snapshot-matchers} You can build custom snapshot matchers using the composable functions exported from `vitest/runtime`. These let you transform values before snapshotting while preserving full snapshot lifecycle support (creation, update, inline rewriting). diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index ea0876c45a23..67946107fc51 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -283,6 +283,7 @@ function assertMatchResult(result: SyncExpectationResult): void { * }) * ``` * + * @experimental * @see https://vitest.dev/guide/snapshot.html#custom-snapshot-matchers */ export function toMatchSnapshot( @@ -314,6 +315,7 @@ export function toMatchSnapshot( * }) * ``` * + * @experimental * @see https://vitest.dev/guide/snapshot.html#custom-snapshot-matchers */ export function toMatchInlineSnapshot( @@ -347,6 +349,7 @@ export function toMatchInlineSnapshot( * }) * ``` * + * @experimental * @see https://vitest.dev/guide/snapshot.html#custom-snapshot-matchers */ export function toMatchFileSnapshot(