diff --git a/docs/guide/extending-matchers.md b/docs/guide/extending-matchers.md index 27653c838a90..2d634c22b049 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` / `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: ## `isNot` diff --git a/docs/guide/migration.md b/docs/guide/migration.md index e5742cc8b458..826c246e9b25 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`. In Vitest, import from `vitest/runtime` instead: + +```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..f3fb28744b33 100644 --- a/docs/guide/snapshot.md +++ b/docs/guide/snapshot.md @@ -200,6 +200,76 @@ 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 { toMatchFileSnapshot, 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) + }, + async toMatchTrimmedFileSnapshot(received: string, file: string) { + return toMatchFileSnapshot.call(this, received.slice(0, 10), file) + }, +}) + +test('file snapshot', () => { + expect('extra long string oh my gerd').toMatchTrimmedSnapshot(10) +}) + +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: + +```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()}` } + }, +}) +``` + +::: 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. +::: + +::: 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 +import 'vitest' + +declare module 'vitest' { + interface Assertion { + toMatchTrimmedSnapshot: (length: number) => T + toMatchTrimmedInlineSnapshot: (inlineSnapshot?: string) => T + toMatchTrimmedFileSnapshot: (file: string) => Promise + } +} +``` + +::: 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/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index 8857f4357a70..6a9bbf35cd6a 100644 --- a/packages/expect/src/jest-extend.ts +++ b/packages/expect/src/jest-extend.ts @@ -54,6 +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, } return { @@ -89,7 +90,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 +134,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/expect/src/types.ts b/packages/expect/src/types.ts index eea5e1f38e54..eeaf681d3714 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -82,6 +82,13 @@ export interface MatcherState { } soft?: boolean poll?: boolean + /** + * 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 } export interface SyncExpectationResult { diff --git a/packages/expect/src/utils.ts b/packages/expect/src/utils.ts index 13b2620055da..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' : '' @@ -92,6 +92,7 @@ function handleTestError(test: Test, err: unknown) { test.result.errors.push(processError(err)) } +/** 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/snapshot/src/client.ts b/packages/snapshot/src/client.ts index 72da2a9e39c5..ca41d9e337d0 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 + assertionName?: string } /** Same shape as expect.extend custom matcher result (SyncExpectationResult from @vitest/expect) */ @@ -119,6 +120,7 @@ export class SnapshotClient { error, errorMessage, rawSnapshot, + assertionName, } = options let { received } = options @@ -173,6 +175,7 @@ export class SnapshotClient { error, inlineSnapshot, rawSnapshot, + assertionName, }) return { diff --git a/packages/snapshot/src/port/inlineSnapshot.ts b/packages/snapshot/src/port/inlineSnapshot.ts index 185080f54ba6..59d6c5116239 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 @@ -13,6 +14,10 @@ export interface InlineSnapshot { file: string line: number column: number + // 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 } export async function saveInlineSnapshots( @@ -33,7 +38,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.assertionName) } const transformed = s.toString() @@ -44,17 +49,31 @@ 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, '\\$&') +} + +const buildStartObjectRegex = memo((assertionName: string) => { + const replaced = defaultStartObjectRegex.source.replace( + 'toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot', + escapeRegExp(assertionName), + ) + return new RegExp(replaced) +}) + function replaceObjectSnap( code: string, s: MagicString, index: number, newSnap: string, + assertionName?: string, ) { let _code = code.slice(index) - const startMatch = startObjectRegex.exec(_code) + const regex = assertionName ? buildStartObjectRegex(assertionName) : defaultStartObjectRegex + const startMatch = regex.exec(_code) if (!startMatch) { return false } @@ -121,23 +140,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 +159,35 @@ 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$]*(['"`)])/ + +const buildStartRegex = memo((assertionName: string) => { + const replaced = defaultStartRegex.source.replace( + 'toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot', + escapeRegExp(assertionName), + ) + return new RegExp(replaced) +}) + export function replaceInlineSnap( code: string, s: MagicString, currentIndex: number, newSnap: string, + assertionName?: string, ): boolean { - const { code: codeStartingAtIndex, index } = getCodeStartingAtIndex(code, currentIndex) + const methodNames = assertionName ? [assertionName] : defaultMethodNames + const { code: codeStartingAtIndex, index } = getCodeStartingAtIndex(code, currentIndex, methodNames) + const startRegex = assertionName ? buildStartRegex(assertionName) : defaultStartRegex const startMatch = startRegex.exec(codeStartingAtIndex) - const firstKeywordMatch = /toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot/.exec( - codeStartingAtIndex, - ) + 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) + 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 39e905808ac8..e2e9ddc2870a 100644 --- a/packages/snapshot/src/port/state.ts +++ b/packages/snapshot/src/port/state.ts @@ -177,6 +177,15 @@ export default class SnapshotState { } } + // 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__ // in integrations/snapshot/chai.ts const stackIndex = stacks.findIndex(i => @@ -188,14 +197,15 @@ export default class SnapshotState { private _addSnapshot( key: string, receivedSerialized: string, - options: { rawSnapshot?: RawSnapshotInfo; stack?: ParsedStack; testId: 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, + assertionName: options.assertionName, }) } else if (options.rawSnapshot) { @@ -294,6 +304,7 @@ export default class SnapshotState { isInline, error, rawSnapshot, + assertionName, }: SnapshotMatchOptions): SnapshotReturnOptions { // this also increments counter for inline snapshots. maybe we shouldn't? this._counters.increment(testName) @@ -422,6 +433,7 @@ export default class SnapshotState { stack, testId, rawSnapshot, + assertionName, }) } else { @@ -433,6 +445,7 @@ export default class SnapshotState { stack, testId, rawSnapshot, + assertionName, }) this.added.increment(testId) } 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)! + } +} diff --git a/packages/snapshot/src/types/index.ts b/packages/snapshot/src/types/index.ts index f314d3befa15..e58d99428fa7 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 + assertionName?: string } export interface SnapshotResult { diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index 908f1061d616..ea0876c45a23 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -1,12 +1,13 @@ -import type { Assertion, ChaiPlugin } from '@vitest/expect' +import type { 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, SnapshotClient, stripSnapshotIndentation, } from '@vitest/snapshot' +import { getWorkerState } from '../../runtime/utils' let _client: SnapshotClient @@ -51,44 +52,44 @@ 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 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(`'${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( chai.Assertion.prototype, key, wrapAssertion(utils, key, function ( this, - properties?: object, - message?: string, + propertiesOrHint?: object | string, + hint?: 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 result = toMatchSnapshotImpl({ + assertion: this, + received: utils.flag(this, 'object'), + ...normalizeArguments(propertiesOrHint, hint), }) + return assertMatchResult(result) }), ) } @@ -96,33 +97,23 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { utils.addMethod( chai.Assertion.prototype, 'toMatchFileSnapshot', - function (this: Assertion, file: string, message?: string) { + function (this: Chai.Assertion, filepath: string, hint?: string) { + // set name manually since it's not wrapped by wrapAssertion 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), + // validate early synchronously just not to break some existing tests + validateAssertion(this) + const resultPromise = toMatchFileSnapshotImpl({ + assertion: this, + received: utils.flag(this, 'object'), + filepath, + hint, }) - + const assertPromise = resultPromise.then(result => assertMatchResult(result)) return recordAsyncExpect( - test, - promise, + getTest(this), + assertPromise, createAssertionMessage(utils, this, true), - error, + new Error('resolves'), utils.flag(this, 'soft'), ) }, @@ -133,61 +124,32 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => { 'toMatchInlineSnapshot', wrapAssertion(utils, 'toMatchInlineSnapshot', function __INLINE_SNAPSHOT_OFFSET_3__( this, - properties?: object, - inlineSnapshot?: string, - message?: string, + propertiesOrInlineSnapshot?: object | string, + inlineSnapshotOrHint?: string, + hint?: 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, + const result = toMatchSnapshotImpl({ + assertion: this, + received: utils.flag(this, 'object'), isInline: true, - properties, - inlineSnapshot, - error, - errorMessage, - ...getTestNames(test), + ...normalizeInlineArguments(propertiesOrInlineSnapshot, inlineSnapshotOrHint, hint), }) + return assertMatchResult(result) }), ) 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) + wrapAssertion(utils, 'toThrowErrorMatchingSnapshot', function (this, propertiesOrHint?: object | string, hint?: string) { + validateAssertion(this) + const received = utils.flag(this, 'object') 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), + const result = toMatchSnapshotImpl({ + assertion: this, + received: getError(received, promise), + ...normalizeArguments(propertiesOrHint, hint), }) + return assertMatchResult(result) }), ) utils.addMethod( @@ -195,35 +157,208 @@ 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 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') + validateAssertion(this) + const received = utils.flag(this, 'object') 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, + const result = toMatchSnapshotImpl({ + assertion: this, + received: getError(received, promise), isInline: true, - error, - errorMessage, - ...getTestNames(test), + ...normalizeInlineArguments(undefined, inlineSnapshotOrHint, hint), }) + return assertMatchResult(result) }), ) utils.addMethod(chai.expect, 'addSnapshotSerializer', addSerializer) } + +// 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 } { + let inlineSnapshot: string | undefined + if (typeof propertiesOrInlineSnapshot === 'string') { + inlineSnapshot = stripSnapshotIndentation(propertiesOrInlineSnapshot) + return { inlineSnapshot, hint: inlineSnapshotOrHint } + } + if (inlineSnapshotOrHint) { + inlineSnapshot = stripSnapshotIndentation(inlineSnapshotOrHint) + } + return { properties: propertiesOrInlineSnapshot, inlineSnapshot, hint } +} + +function toMatchSnapshotImpl(options: { + assertion: Chai.Assertion + received: unknown + properties?: object + hint?: string + isInline?: boolean + inlineSnapshot?: string +}): SyncExpectationResult { + const { assertion } = options + validateAssertion(assertion) + const assertionName = getAssertionName(assertion) + const test = getTest(assertion) + return getSnapshotClient().match({ + received: options.received, + properties: options.properties, + message: options.hint, + isInline: options.isInline, + inlineSnapshot: options.inlineSnapshot, + 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: chai.util.flag(assertion, 'error'), + ...getTestNames(test), + }) +} + +async function toMatchFileSnapshotImpl(options: { + assertion: Chai.Assertion + received: unknown + filepath: string + hint?: string +}): Promise { + const { assertion } = options + 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) + const rawSnapshotContent = await snapshotState.environment.readSnapshotFile(rawSnapshotFile) + return getSnapshotClient().match({ + received: options.received, + message: options.hint, + errorMessage: chai.util.flag(assertion, 'message'), + rawSnapshot: { + file: rawSnapshotFile, + content: rawSnapshotContent ?? undefined, + }, + ...testNames, + }) +} + +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, + }, + }) + } +} + +/** + * 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. + * + * @example + * ```ts + * import { toMatchSnapshot } from 'vitest/runtime' + * + * expect.extend({ + * toMatchTrimmedSnapshot(received: string) { + * return toMatchSnapshot.call(this, received.slice(0, 10)) + * }, + * }) + * ``` + * + * @see https://vitest.dev/guide/snapshot.html#custom-snapshot-matchers + */ +export function toMatchSnapshot( + this: MatcherState, + received: unknown, + propertiesOrHint?: object | string, + hint?: string, +): SyncExpectationResult { + return toMatchSnapshotImpl({ + assertion: this.__vitest_assertion__, + received, + ...normalizeArguments(propertiesOrHint, hint), + }) +} + +/** + * 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. + * + * @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, + propertiesOrInlineSnapshot?: object | string, + inlineSnapshotOrHint?: string, + hint?: string, +): SyncExpectationResult { + return toMatchSnapshotImpl({ + assertion: this.__vitest_assertion__, + received, + isInline: true, + ...normalizeInlineArguments(propertiesOrInlineSnapshot, inlineSnapshotOrHint, hint), + }) +} + +/** + * 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, + filepath: string, + hint?: string, +): Promise { + return toMatchFileSnapshotImpl({ + assertion: this.__vitest_assertion__, + received, + filepath, + hint, + }) +} diff --git a/packages/vitest/src/public/runtime.ts b/packages/vitest/src/public/runtime.ts index f8d048111bf7..4fbf36da6fc0 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 { toMatchFileSnapshot, 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..93aaf0555edc 100644 --- a/test/core/test/exports.test.ts +++ b/test/core/test/exports.test.ts @@ -165,6 +165,9 @@ it('exports snapshot', async ({ skip, task }) => { "__INTERNAL": "object", "builtinEnvironments": "object", "populateGlobal": "function", + "toMatchFileSnapshot": "function", + "toMatchInlineSnapshot": "function", + "toMatchSnapshot": "function", }, "./snapshot": { "VitestSnapshotEnvironment": "function", diff --git a/test/snapshots/test/custom-matcher.test.ts b/test/snapshots/test/custom-matcher.test.ts new file mode 100644 index 000000000000..a0226ac2f0ab --- /dev/null +++ b/test/snapshots/test/custom-matcher.test.ts @@ -0,0 +1,338 @@ +import fs, { readFileSync } from 'node:fs' +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 { + return [...content.matchAll(INLINE_BLOCK_RE)] + .map(m => m[1].trim()) + .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') + const rawSnapshotFile = join(root, '__snapshots__/raw.txt') + + // remove snapshots + fs.rmSync(join(root, '__snapshots__'), { recursive: true, force: true }) + editFile(testFile, s => s.replace(/toMatchCustomInlineSnapshot\(`[^`]*`\)/g, '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[\`file 1\`] = \` + Object { + "length": 6, + "reversed": "ahahah", + } + \`; + + exports[\`properties 1 1\`] = \` + Object { + "length": 6, + "reversed": "opopop", + } + \`; + + exports[\`properties 2 1\`] = \` + Object { + "length": toSatisfy<[Function lessThan10]>, + "reversed": "epepep", + } + \`; + " + `) + expect(readFileSync(rawSnapshotFile, 'utf-8')).toMatchInlineSnapshot(` + "Object { + "length": 6, + "reversed": "ihihih", + }" + `) + expect(extractInlineBlocks(readFileSync(testFile, 'utf-8'))).toMatchInlineSnapshot(` + "test('inline', () => { + expect(\`hehehe\`).toMatchCustomInlineSnapshot(\` + Object { + "length": 6, + "reversed": "eheheh", + } + \`) + })" + `) + expect(result.errorTree()).toMatchInlineSnapshot(` + Object { + "basic.test.ts": Object { + "file": "passed", + "inline": "passed", + "properties 1": "passed", + "properties 2": "passed", + "raw": "passed", + }, + } + `) + + // edit tests to introduce snapshot errors + editFile(testFile, s => s + .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 5 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL basic.test.ts > file + Error: [custom error] Snapshot \`file 1\` mismatched + + - Expected + + Received + + Object { + - "length": 6, + + "length": 11, + - "reversed": "ahahah", + + "reversed": "tide-ahahah", + } + + ❯ basic.test.ts:46:25 + 44| + 45| test('file', () => { + 46| expect(\`hahaha-edit\`).toMatchCustomSnapshot() + | ^ + 47| }) + 48| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/5]⎯ + + FAIL basic.test.ts > properties 1 + Error: [custom error] Snapshot properties mismatched + + - Expected + + Received + + { + - "length": 6, + + "length": 11, + + "reversed": "tide-opopop", + } + + ❯ basic.test.ts:50:25 + 48| + 49| test('properties 1', () => { + 50| expect(\`popopo-edit\`).toMatchCustomSnapshot({ length: 6 }) + | ^ + 51| }) + 52| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/5]⎯ + + FAIL basic.test.ts > properties 2 + Error: [custom error] Snapshot properties mismatched + + - Expected + + Received + + { + - "length": toSatisfy<[Function lessThan10]>, + + "length": 11, + + "reversed": "tide-epepep", + } + + ❯ basic.test.ts:54:25 + 52| + 53| test('properties 2', () => { + 54| expect(\`pepepe-edit\`).toMatchCustomSnapshot({ length: expect.toSatis… + | ^ + 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", + } + + ❯ 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 + + - Expected + + Received + + Object { + - "length": 6, + + "length": 11, + - "reversed": "eheheh", + + "reversed": "tide-eheheh", + } + + ❯ basic.test.ts:63:25 + 61| // -- TEST INLINE START -- + 62| test('inline', () => { + 63| expect(\`hehehe-edit\`).toMatchCustomInlineSnapshot(\` + | ^ + 64| Object { + 65| "length": 6, + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/5]⎯ + + " + `) + expect(result.errorTree()).toMatchInlineSnapshot(` + Object { + "basic.test.ts": Object { + "file": Array [ + "[custom error] Snapshot \`file 1\` mismatched", + ], + "inline": Array [ + "[custom error] Snapshot \`inline 1\` mismatched", + ], + "properties 1": Array [ + "[custom error] Snapshot properties mismatched", + ], + "properties 2": Array [ + "[custom error] Snapshot properties mismatched", + ], + "raw": Array [ + "[custom error] Snapshot \`raw 1\` mismatched", + ], + }, + } + `) + + // run with update + result = await runVitest({ root, update: 'all' }) + expect(result.stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL basic.test.ts > properties 1 + Error: [custom error] Snapshot properties mismatched + + - Expected + + Received + + { + - "length": 6, + + "length": 11, + + "reversed": "tide-opopop", + } + + ❯ basic.test.ts:50:25 + 48| + 49| test('properties 1', () => { + 50| expect(\`popopo-edit\`).toMatchCustomSnapshot({ length: 6 }) + | ^ + 51| }) + 52| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ + + FAIL basic.test.ts > properties 2 + Error: [custom error] Snapshot properties mismatched + + - Expected + + Received + + { + - "length": toSatisfy<[Function lessThan10]>, + + "length": 11, + + "reversed": "tide-epepep", + } + + ❯ basic.test.ts:54:25 + 52| + 53| test('properties 2', () => { + 54| expect(\`pepepe-edit\`).toMatchCustomSnapshot({ length: expect.toSatis… + | ^ + 55| }) + 56| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ + + " + `) + expect(readFileSync(snapshotFile, 'utf-8')).toMatchInlineSnapshot(` + "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + + exports[\`file 1\`] = \` + Object { + "length": 11, + "reversed": "tide-ahahah", + } + \`; + + exports[\`properties 1 1\`] = \` + Object { + "length": 6, + "reversed": "opopop", + } + \`; + + exports[\`properties 2 1\`] = \` + Object { + "length": toSatisfy<[Function lessThan10]>, + "reversed": "epepep", + } + \`; + " + `) + 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(\` + Object { + "length": 11, + "reversed": "tide-eheheh", + } + \`) + })" + `) + expect(result.errorTree()).toMatchInlineSnapshot(` + Object { + "basic.test.ts": Object { + "file": "passed", + "inline": "passed", + "properties 1": Array [ + "[custom error] Snapshot properties mismatched", + ], + "properties 2": Array [ + "[custom error] Snapshot properties mismatched", + ], + "raw": "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 new file mode 100644 index 000000000000..1da84b6d0346 --- /dev/null +++ b/test/snapshots/test/fixtures/custom-matcher/basic.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from 'vitest' +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' { + interface Assertion extends CustomMatchers {} + interface AsymmetricMatchersContaining extends CustomMatchers {} +} + +function formatCustom(input: string) { + return { + reversed: input.split('').reverse().join(''), + length: input.length, + } +} + +expect.extend({ + toMatchCustomSnapshot(actual: string, properties?: object) { + const actualCustom = formatCustom(actual) + const result = toMatchSnapshot.call(this, actualCustom, properties) + // result can be further enhanced + return { ...result, message: () => `[custom error] ${result.message()}` } + }, + toMatchCustomInlineSnapshot( + actual: string, + inlineSnapshot?: string, + ) { + const actualCustom = formatCustom(actual) + 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', () => { + expect(`hahaha`).toMatchCustomSnapshot() +}) + +test('properties 1', () => { + expect(`popopo`).toMatchCustomSnapshot({ length: 6 }) +}) + +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(` + Object { + "length": 6, + "reversed": "eheheh", + } + `) +}) +// -- TEST INLINE END --