diff --git a/packages/snapshot/src/client.ts b/packages/snapshot/src/client.ts index cd7f7d229ac7..72da2a9e39c5 100644 --- a/packages/snapshot/src/client.ts +++ b/packages/snapshot/src/client.ts @@ -50,6 +50,14 @@ interface AssertOptions { rawSnapshot?: RawSnapshotInfo } +/** 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 } @@ -99,7 +107,7 @@ export class SnapshotClient { return state } - assert(options: AssertOptions): void { + match(options: AssertOptions): MatchResult { const { filepath, name, @@ -119,37 +127,44 @@ export class SnapshotClient { } const snapshotState = this.getSnapshotState(filepath) + const testName = [name, ...(message ? [message] : [])].join(' > ') + + // Probe first so we can mark as checked even on early return + const expectedSnapshot = snapshotState.probeExpectedSnapshot({ + testName, + testId, + isInline, + inlineSnapshot, + }) if (typeof properties === 'object') { if (typeof received !== 'object' || !received) { + expectedSnapshot.markAsChecked() throw new Error( 'Received value must be an object when the matcher has properties', ) } + let propertiesPass: boolean 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, - ) - } - else { - received = deepMergeSnapshot(received, properties) - } + propertiesPass = this.options.isEqual?.(received, properties) ?? false } - catch (err: any) { - err.message = errorMessage || 'Snapshot mismatched' + catch (err) { + expectedSnapshot.markAsChecked() throw err } + if (!propertiesPass) { + expectedSnapshot.markAsChecked() + return { + pass: false, + message: () => errorMessage || 'Snapshot properties mismatched', + actual: received, + expected: properties, + } + } + received = deepMergeSnapshot(received, properties) } - const testName = [name, ...(message ? [message] : [])].join(' > ') - const { actual, expected, key, pass } = snapshotState.match({ testId, testName, @@ -160,12 +175,23 @@ export class SnapshotClient { rawSnapshot, }) - 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/snapshot/src/port/state.ts b/packages/snapshot/src/port/state.ts index e3249f8af992..39e905808ac8 100644 --- a/packages/snapshot/src/port/state.ts +++ b/packages/snapshot/src/port/state.ts @@ -267,6 +267,24 @@ export default class SnapshotState { } } + probeExpectedSnapshot( + options: Pick, + ): { + data?: string + markAsChecked: () => void + } { + const count = this._counters.get(options.testName) + 1 + const key = testNameToKey(options.testName, count) + return { + data: options?.isInline ? options.inlineSnapshot : this._snapshotData[key], + markAsChecked: () => { + this._counters.increment(options.testName) + this._testIdToKeys.get(options.testId).push(key) + this._uncheckedKeys.delete(key) + }, + } + } + match({ testId, testName, diff --git a/test/snapshots/test/fixtures/properties/.gitignore b/test/snapshots/test/fixtures/properties/.gitignore new file mode 100644 index 000000000000..b05c2dfa7007 --- /dev/null +++ b/test/snapshots/test/fixtures/properties/.gitignore @@ -0,0 +1 @@ +__snapshots__ diff --git a/test/snapshots/test/fixtures/properties/basic.test.ts b/test/snapshots/test/fixtures/properties/basic.test.ts new file mode 100644 index 000000000000..2568ce140096 --- /dev/null +++ b/test/snapshots/test/fixtures/properties/basic.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "vitest"; + +test("file", () => { + expect({ name: "alice", age: 30 }).toMatchSnapshot({ age: expect.any(Number) }); +}); + +test("file asymmetric", () => { + expect({ name: "bob", score: 95 }).toMatchSnapshot({ + score: expect.toSatisfy(function lessThan100(n) { + return n < 100; + }), + }); +}); + +test("file snapshot-only", () => { + expect({ name: "dave", age: 42 }).toMatchSnapshot({ age: expect.any(Number) }); +}); + +// -- TEST INLINE START -- +test("inline", () => { + expect({ name: "carol", age: 25 }).toMatchInlineSnapshot({ age: expect.any(Number) }, ` + Object { + "age": Any, + "name": "carol", + } + `); +}); +// -- TEST INLINE END -- diff --git a/test/snapshots/test/properties.test.ts b/test/snapshots/test/properties.test.ts new file mode 100644 index 000000000000..53ab48326ced --- /dev/null +++ b/test/snapshots/test/properties.test.ts @@ -0,0 +1,324 @@ +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('toMatchSnapshot and toMatchInlineSnapshot with properties', async () => { + const root = join(import.meta.dirname, 'fixtures/properties') + 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(/toMatchInlineSnapshot\((\{[^}]*\}),\s*`[^`]*`\)/g, 'toMatchInlineSnapshot($1)')) + + // 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 { + "age": Any, + "name": "alice", + } + \`; + + exports[\`file asymmetric 1\`] = \` + Object { + "name": "bob", + "score": toSatisfy<[Function lessThan100]>, + } + \`; + + exports[\`file snapshot-only 1\`] = \` + Object { + "age": Any, + "name": "dave", + } + \`; + " + `) + expect(extractInlineBlocks(readFileSync(testFile, 'utf-8'))).toMatchInlineSnapshot(` + "test("inline", () => { + expect({ name: "carol", age: 25 }).toMatchInlineSnapshot({ age: expect.any(Number) }, \` + Object { + "age": Any, + "name": "carol", + } + \`); + });" + `) + expect(result.errorTree()).toMatchInlineSnapshot(` + Object { + "basic.test.ts": Object { + "file": "passed", + "file asymmetric": "passed", + "file snapshot-only": "passed", + "inline": "passed", + }, + } + `) + + // verify idempotency — re-run without update passes cleanly + const result2 = await runVitest({ root, update: 'none' }) + expect(result2.stderr).toMatchInlineSnapshot(`""`) + expect(result2.errorTree()).toEqual(result.errorTree()) + + // edit tests to break properties check + editFile(testFile, s => + s + .replace('age: 30', 'age: \'thirty\'') + .replace('score: 95', 'score: 999') + .replace('name: "dave"', 'name: "dave-edit"') + .replace('age: 25', 'age: \'twenty-five\'')) + + // properties mismatch should NOT cause false-positive obsolete snapshot + result = await runVitest({ root, update: 'none' }) + expect(result.stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 4 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL basic.test.ts > file + Error: Snapshot properties mismatched + + - Expected + + Received + + { + - "age": Any, + + "age": "thirty", + + "name": "alice", + } + + ❯ basic.test.ts:4:44 + 2| + 3| test("file", () => { + 4| expect({ name: "alice", age: 'thirty' }).toMatchSnapshot({ age: expe… + | ^ + 5| }); + 6| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/4]⎯ + + FAIL basic.test.ts > file asymmetric + Error: Snapshot properties mismatched + + - Expected + + Received + + { + - "score": toSatisfy<[Function lessThan100]>, + + "name": "bob", + + "score": 999, + } + + ❯ basic.test.ts:8:39 + 6| + 7| test("file asymmetric", () => { + 8| expect({ name: "bob", score: 999 }).toMatchSnapshot({ + | ^ + 9| score: expect.toSatisfy(function lessThan100(n) { + 10| return n < 100; + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/4]⎯ + + FAIL basic.test.ts > file snapshot-only + Error: Snapshot \`file snapshot-only 1\` mismatched + + - Expected + + Received + + Object { + "age": Any, + - "name": "dave", + + "name": "dave-edit", + } + + ❯ basic.test.ts:16:42 + 14| + 15| test("file snapshot-only", () => { + 16| expect({ name: "dave-edit", age: 42 }).toMatchSnapshot({ age: expect… + | ^ + 17| }); + 18| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/4]⎯ + + FAIL basic.test.ts > inline + Error: Snapshot properties mismatched + + - Expected + + Received + + { + - "age": Any, + + "age": "twenty-five", + + "name": "carol", + } + + ❯ basic.test.ts:21:49 + 19| // -- TEST INLINE START -- + 20| test("inline", () => { + 21| expect({ name: "carol", age: 'twenty-five' }).toMatchInlineSnapshot(… + | ^ + 22| Object { + 23| "age": Any, + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/4]⎯ + + " + `) + expect(result.errorTree()).toMatchInlineSnapshot(` + Object { + "basic.test.ts": Object { + "file": Array [ + "Snapshot properties mismatched", + ], + "file asymmetric": Array [ + "Snapshot properties mismatched", + ], + "file snapshot-only": Array [ + "Snapshot \`file snapshot-only 1\` mismatched", + ], + "inline": Array [ + "Snapshot properties mismatched", + ], + }, + } + `) + + // run with update — properties errors persist + result = await runVitest({ root, update: 'all' }) + expect(result.stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL basic.test.ts > file + Error: Snapshot properties mismatched + + - Expected + + Received + + { + - "age": Any, + + "age": "thirty", + + "name": "alice", + } + + ❯ basic.test.ts:4:44 + 2| + 3| test("file", () => { + 4| expect({ name: "alice", age: 'thirty' }).toMatchSnapshot({ age: expe… + | ^ + 5| }); + 6| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯ + + FAIL basic.test.ts > file asymmetric + Error: Snapshot properties mismatched + + - Expected + + Received + + { + - "score": toSatisfy<[Function lessThan100]>, + + "name": "bob", + + "score": 999, + } + + ❯ basic.test.ts:8:39 + 6| + 7| test("file asymmetric", () => { + 8| expect({ name: "bob", score: 999 }).toMatchSnapshot({ + | ^ + 9| score: expect.toSatisfy(function lessThan100(n) { + 10| return n < 100; + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯ + + FAIL basic.test.ts > inline + Error: Snapshot properties mismatched + + - Expected + + Received + + { + - "age": Any, + + "age": "twenty-five", + + "name": "carol", + } + + ❯ basic.test.ts:21:49 + 19| // -- TEST INLINE START -- + 20| test("inline", () => { + 21| expect({ name: "carol", age: 'twenty-five' }).toMatchInlineSnapshot(… + | ^ + 22| Object { + 23| "age": Any, + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/3]⎯ + + " + `) + expect(readFileSync(snapshotFile, 'utf-8')).toMatchInlineSnapshot(` + "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + + exports[\`file 1\`] = \` + Object { + "age": Any, + "name": "alice", + } + \`; + + exports[\`file asymmetric 1\`] = \` + Object { + "name": "bob", + "score": toSatisfy<[Function lessThan100]>, + } + \`; + + exports[\`file snapshot-only 1\`] = \` + Object { + "age": Any, + "name": "dave-edit", + } + \`; + " + `) + expect(extractInlineBlocks(readFileSync(testFile, 'utf-8'))).toMatchInlineSnapshot(` + "test("inline", () => { + expect({ name: "carol", age: 'twenty-five' }).toMatchInlineSnapshot({ age: expect.any(Number) }, \` + Object { + "age": Any, + "name": "carol", + } + \`); + });" + `) + expect(result.errorTree()).toMatchInlineSnapshot(` + Object { + "basic.test.ts": Object { + "file": Array [ + "Snapshot properties mismatched", + ], + "file asymmetric": Array [ + "Snapshot properties mismatched", + ], + "file snapshot-only": "passed", + "inline": Array [ + "Snapshot properties mismatched", + ], + }, + } + `) +})