Skip to content

Commit 6b86915

Browse files
hi-ogawaclaude
andauthored
fix(snapshot): fix flagging obsolete snapshots for snapshot properties mismatch (#9986)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6c44a12 commit 6b86915

File tree

6 files changed

+420
-22
lines changed

6 files changed

+420
-22
lines changed

packages/snapshot/src/client.ts

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ interface AssertOptions {
5050
rawSnapshot?: RawSnapshotInfo
5151
}
5252

53+
/** Same shape as expect.extend custom matcher result (SyncExpectationResult from @vitest/expect) */
54+
export interface MatchResult {
55+
pass: boolean
56+
message: () => string
57+
actual?: unknown
58+
expected?: unknown
59+
}
60+
5361
export interface SnapshotClientOptions {
5462
isEqual?: (received: unknown, expected: unknown) => boolean
5563
}
@@ -99,7 +107,7 @@ export class SnapshotClient {
99107
return state
100108
}
101109

102-
assert(options: AssertOptions): void {
110+
match(options: AssertOptions): MatchResult {
103111
const {
104112
filepath,
105113
name,
@@ -119,37 +127,44 @@ export class SnapshotClient {
119127
}
120128

121129
const snapshotState = this.getSnapshotState(filepath)
130+
const testName = [name, ...(message ? [message] : [])].join(' > ')
131+
132+
// Probe first so we can mark as checked even on early return
133+
const expectedSnapshot = snapshotState.probeExpectedSnapshot({
134+
testName,
135+
testId,
136+
isInline,
137+
inlineSnapshot,
138+
})
122139

123140
if (typeof properties === 'object') {
124141
if (typeof received !== 'object' || !received) {
142+
expectedSnapshot.markAsChecked()
125143
throw new Error(
126144
'Received value must be an object when the matcher has properties',
127145
)
128146
}
129147

148+
let propertiesPass: boolean
130149
try {
131-
const pass = this.options.isEqual?.(received, properties) ?? false
132-
// const pass = equals(received, properties, [iterableEquality, subsetEquality])
133-
if (!pass) {
134-
throw createMismatchError(
135-
'Snapshot properties mismatched',
136-
snapshotState.expand,
137-
received,
138-
properties,
139-
)
140-
}
141-
else {
142-
received = deepMergeSnapshot(received, properties)
143-
}
150+
propertiesPass = this.options.isEqual?.(received, properties) ?? false
144151
}
145-
catch (err: any) {
146-
err.message = errorMessage || 'Snapshot mismatched'
152+
catch (err) {
153+
expectedSnapshot.markAsChecked()
147154
throw err
148155
}
156+
if (!propertiesPass) {
157+
expectedSnapshot.markAsChecked()
158+
return {
159+
pass: false,
160+
message: () => errorMessage || 'Snapshot properties mismatched',
161+
actual: received,
162+
expected: properties,
163+
}
164+
}
165+
received = deepMergeSnapshot(received, properties)
149166
}
150167

151-
const testName = [name, ...(message ? [message] : [])].join(' > ')
152-
153168
const { actual, expected, key, pass } = snapshotState.match({
154169
testId,
155170
testName,
@@ -160,12 +175,23 @@ export class SnapshotClient {
160175
rawSnapshot,
161176
})
162177

163-
if (!pass) {
178+
return {
179+
pass,
180+
message: () => `Snapshot \`${key || 'unknown'}\` mismatched`,
181+
actual: rawSnapshot ? actual : actual?.trim(),
182+
expected: rawSnapshot ? expected : expected?.trim(),
183+
}
184+
}
185+
186+
assert(options: AssertOptions): void {
187+
const result = this.match(options)
188+
if (!result.pass) {
189+
const snapshotState = this.getSnapshotState(options.filepath)
164190
throw createMismatchError(
165-
`Snapshot \`${key || 'unknown'}\` mismatched`,
191+
result.message(),
166192
snapshotState.expand,
167-
rawSnapshot ? actual : actual?.trim(),
168-
rawSnapshot ? expected : expected?.trim(),
193+
result.actual,
194+
result.expected,
169195
)
170196
}
171197
}

packages/snapshot/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { SnapshotClient } from './client'
2+
export type { MatchResult } from './client'
23

34
export { stripSnapshotIndentation } from './port/inlineSnapshot'
45
export { addSerializer, getSerializers } from './port/plugins'

packages/snapshot/src/port/state.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,24 @@ export default class SnapshotState {
267267
}
268268
}
269269

270+
probeExpectedSnapshot(
271+
options: Pick<SnapshotMatchOptions, 'testName' | 'testId' | 'isInline' | 'inlineSnapshot'>,
272+
): {
273+
data?: string
274+
markAsChecked: () => void
275+
} {
276+
const count = this._counters.get(options.testName) + 1
277+
const key = testNameToKey(options.testName, count)
278+
return {
279+
data: options?.isInline ? options.inlineSnapshot : this._snapshotData[key],
280+
markAsChecked: () => {
281+
this._counters.increment(options.testName)
282+
this._testIdToKeys.get(options.testId).push(key)
283+
this._uncheckedKeys.delete(key)
284+
},
285+
}
286+
}
287+
270288
match({
271289
testId,
272290
testName,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__snapshots__
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { expect, test } from "vitest";
2+
3+
test("file", () => {
4+
expect({ name: "alice", age: 30 }).toMatchSnapshot({ age: expect.any(Number) });
5+
});
6+
7+
test("file asymmetric", () => {
8+
expect({ name: "bob", score: 95 }).toMatchSnapshot({
9+
score: expect.toSatisfy(function lessThan100(n) {
10+
return n < 100;
11+
}),
12+
});
13+
});
14+
15+
test("file snapshot-only", () => {
16+
expect({ name: "dave", age: 42 }).toMatchSnapshot({ age: expect.any(Number) });
17+
});
18+
19+
// -- TEST INLINE START --
20+
test("inline", () => {
21+
expect({ name: "carol", age: 25 }).toMatchInlineSnapshot({ age: expect.any(Number) }, `
22+
Object {
23+
"age": Any<Number>,
24+
"name": "carol",
25+
}
26+
`);
27+
});
28+
// -- TEST INLINE END --

0 commit comments

Comments
 (0)