Skip to content

Commit 54e5b8a

Browse files
macariehi-ogawa
andauthored
feat: interactively review failed snapshots (#11325)
Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com>
1 parent 9859940 commit 54e5b8a

33 files changed

Lines changed: 2137 additions & 44 deletions

‎.gitattributes‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
* text=auto eol=lf
22

33
test/e2e/fixtures/reporters/indicator-position.test.js eol=crlf
4+
test/e2e/test/watch/raw/snapshot-review.diff -text

‎packages/browser/src/client/tester/runner.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,10 @@ function createBrowserRunner(
189189
task.type === 'test' &&
190190
!(
191191
lastErrorContext &&
192-
Reflect.get(lastErrorContext, 'assertionName') === 'toMatchScreenshot' &&
193-
Reflect.get(lastErrorContext, 'meta')?.outcome !== 'unstable-screenshot'
192+
lastErrorContext.assertionName === 'toMatchScreenshot' &&
193+
lastErrorContext.meta &&
194+
'outcome' in lastErrorContext.meta &&
195+
lastErrorContext.meta.outcome !== 'unstable-screenshot'
194196
)
195197
) {
196198
const { retry, repeats } = this.testAttemptIndexMap.get(task.id) ?? { retry: 0, repeats: 0 }

‎packages/snapshot/src/client.ts‎

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ interface AssertOptions {
4343
error?: Error
4444
errorMessage?: string
4545
rawSnapshot?: RawSnapshotInfo
46-
assertionName?: string
46+
assertionName: string
4747
}
4848

4949
interface AssertDomainOptions extends Omit<AssertOptions, 'received'> {
@@ -63,8 +63,19 @@ export interface MatchResult {
6363
message: () => string
6464
actual?: unknown
6565
expected?: unknown
66+
/** @internal */
67+
meta?: MatchMeta
6668
}
6769

70+
/**
71+
* Information about the kind of processed snapshot and data useful for constructing an update
72+
*/
73+
export type MatchMeta = { type: 'snapshot'; assertionName: string; key: string } & (
74+
| { kind: 'file'; file: string; renderedContent?: string }
75+
| { kind: 'inline'; renderedContent?: string }
76+
| { kind: 'raw'; file: string }
77+
)
78+
6879
interface SnapshotClientOptions {
6980
isEqual?: (received: unknown, expected: unknown) => boolean
7081
}
@@ -167,6 +178,12 @@ export class SnapshotClient {
167178
message: () => errorMessage || 'Snapshot properties mismatched',
168179
actual: received,
169180
expected: properties,
181+
meta: createMetaObject({
182+
assertionName,
183+
isInline,
184+
rawSnapshot,
185+
snapshotState,
186+
}),
170187
}
171188
}
172189
received = deepMergeSnapshot(received, properties)
@@ -188,6 +205,13 @@ export class SnapshotClient {
188205
message: () => `Snapshot \`${key || 'unknown'}\` mismatched`,
189206
actual: rawSnapshot ? actual : actual?.trim(),
190207
expected: rawSnapshot ? expected : expected?.trim(),
208+
meta: createMetaObject({
209+
assertionName,
210+
isInline,
211+
key,
212+
rawSnapshot,
213+
snapshotState,
214+
}),
191215
}
192216
}
193217

@@ -215,6 +239,7 @@ export class SnapshotClient {
215239
isInline = false,
216240
inlineSnapshot,
217241
error,
242+
assertionName,
218243
} = options
219244

220245
if (!filepath) {
@@ -245,14 +270,21 @@ export class SnapshotClient {
245270
matchResult,
246271
isInline,
247272
error,
248-
assertionName: options.assertionName,
273+
assertionName,
249274
})
250275

251276
return {
252277
pass,
253278
message: () => `Snapshot \`${key}\` mismatched`,
254279
actual: actual?.trim(),
255280
expected: expected?.trim(),
281+
meta: createMetaObject({
282+
rendered,
283+
assertionName,
284+
isInline,
285+
key,
286+
snapshotState,
287+
}),
256288
}
257289
}
258290

@@ -269,6 +301,7 @@ export class SnapshotClient {
269301
error,
270302
timeout = 1000,
271303
interval = 50,
304+
assertionName,
272305
} = options
273306

274307
if (!filepath) {
@@ -335,14 +368,21 @@ export class SnapshotClient {
335368
matchResult,
336369
isInline,
337370
error,
338-
assertionName: options.assertionName,
371+
assertionName,
339372
})
340373

341374
return {
342375
pass,
343376
message: () => `Snapshot \`${key}\` mismatched`,
344377
actual: actual?.trim(),
345378
expected: expected?.trim(),
379+
meta: createMetaObject({
380+
rendered: stableResult.rendered,
381+
assertionName,
382+
isInline,
383+
key,
384+
snapshotState,
385+
}),
346386
}
347387
}
348388

@@ -446,3 +486,52 @@ function raceWith<A, B>(
446486
}
447487
return Promise.race([left, other.then((value) => ({ ok: false as const, value }))])
448488
}
489+
490+
interface CreateMetaOptions extends Pick<
491+
AssertOptions,
492+
'assertionName' | 'isInline' | 'rawSnapshot'
493+
> {
494+
rendered?: string
495+
snapshotState: SnapshotState
496+
key?: string
497+
}
498+
499+
function createMetaObject({
500+
rendered,
501+
assertionName,
502+
isInline,
503+
key,
504+
rawSnapshot,
505+
snapshotState,
506+
}: CreateMetaOptions): MatchMeta {
507+
const k = key ?? 'unknown'
508+
509+
if (isInline) {
510+
return {
511+
type: 'snapshot',
512+
kind: 'inline',
513+
assertionName,
514+
key: k,
515+
renderedContent: rendered,
516+
}
517+
}
518+
519+
if (rawSnapshot) {
520+
return {
521+
type: 'snapshot',
522+
kind: 'raw',
523+
assertionName,
524+
file: rawSnapshot.file,
525+
key: k,
526+
}
527+
}
528+
529+
return {
530+
type: 'snapshot',
531+
kind: 'file',
532+
assertionName,
533+
file: snapshotState.snapshotPath,
534+
key: k,
535+
renderedContent: rendered,
536+
}
537+
}

‎packages/snapshot/src/index.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export { SnapshotClient } from './client'
2-
export type { MatchResult } from './client'
2+
export type { MatchResult, MatchMeta } from './client'
33

44
export type { DomainMatchResult, DomainSnapshotAdapter } from './domain'
55

@@ -18,3 +18,6 @@ export type {
1818
SnapshotUpdateState,
1919
UncheckedSnapshot,
2020
} from './types'
21+
22+
export { saveSnapshotUpdates } from './update'
23+
export type { SnapshotUpdate } from './update'

‎packages/snapshot/src/port/rawSnapshot.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ export interface RawSnapshotInfo {
88

99
export interface RawSnapshot extends RawSnapshotInfo {
1010
snapshot: string
11-
file: string
1211
}
1312

1413
export async function saveRawSnapshots(

‎packages/snapshot/src/update.ts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { InlineSnapshot } from './port/inlineSnapshot'
2+
import type { RawSnapshot } from './port/rawSnapshot'
3+
import type { SnapshotData, SnapshotEnvironment } from './types'
4+
import { saveInlineSnapshots } from './port/inlineSnapshot'
5+
import { saveRawSnapshots } from './port/rawSnapshot'
6+
import { evaluateSnapshotFile, saveSnapshotFile } from './port/utils'
7+
8+
interface InlineSnapshotUpdate extends InlineSnapshot {
9+
type: 'inline'
10+
}
11+
12+
interface FileSnapshotUpdate {
13+
type: 'file'
14+
file: string
15+
key: string
16+
snapshot: string
17+
}
18+
19+
interface RawSnapshotUpdate extends RawSnapshot {
20+
type: 'raw'
21+
}
22+
23+
export type SnapshotUpdate = InlineSnapshotUpdate | FileSnapshotUpdate | RawSnapshotUpdate
24+
25+
export async function saveSnapshotUpdates(
26+
snapshots: Map<SnapshotEnvironment, readonly SnapshotUpdate[]>,
27+
): Promise<void> {
28+
const updatesByFile = new Map<string, SnapshotUpdate[]>()
29+
const saves: Promise<void>[] = []
30+
31+
for (const [environment, updates] of snapshots) {
32+
for (const update of updates) {
33+
const fileUpdates = updatesByFile.get(update.file) ?? []
34+
35+
if (fileUpdates.length === 0) {
36+
updatesByFile.set(update.file, fileUpdates)
37+
}
38+
39+
fileUpdates.push(update)
40+
}
41+
42+
for (const [file, fileUpdates] of updatesByFile) {
43+
const updates = Object.groupBy(fileUpdates.values(), (snapshot) => snapshot.type) as {
44+
inline?: InlineSnapshotUpdate[]
45+
file?: FileSnapshotUpdate[]
46+
raw?: RawSnapshotUpdate[]
47+
}
48+
49+
if (updates.inline) {
50+
saves.push(saveInlineSnapshots(environment, updates.inline))
51+
}
52+
53+
if (updates.file) {
54+
let data: SnapshotData | null
55+
56+
if (environment.readSnapshotFileData) {
57+
data = await environment.readSnapshotFileData(file)
58+
} else {
59+
const content = await environment.readSnapshotFile(file)
60+
data = content == null ? null : evaluateSnapshotFile(file, content)
61+
}
62+
63+
const snapshotData: SnapshotData = Object.assign(Object.create(null), data)
64+
65+
for (const snapshot of updates.file) {
66+
snapshotData[snapshot.key] = snapshot.snapshot
67+
}
68+
69+
saves.push(saveSnapshotFile(environment, snapshotData, file))
70+
}
71+
72+
if (updates.raw) {
73+
saves.push(saveRawSnapshots(environment, updates.raw))
74+
}
75+
}
76+
}
77+
78+
await Promise.all(saves)
79+
}

‎packages/utils/src/types.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,9 @@ export interface TestError extends SerializedError {
4040
diff?: string
4141
actual?: string
4242
expected?: string
43+
/** @internal */
44+
__vitest_error_context__?: {
45+
assertionName?: string
46+
meta?: object
47+
}
4348
}

‎packages/vitest/src/integrations/chai/poll.ts‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Test } from '../../runtime/runner/types'
33
import { chai } from '@vitest/expect'
44
import { delay, getSafeTimers } from '@vitest/utils/timers'
55
import { getWorkerState } from '../../runtime/utils'
6+
import { copyStackTrace } from '../../utils/errors'
67
import { vi } from '../vi'
78

89
// these matchers are not supported because they don't make sense with poll
@@ -244,13 +245,6 @@ export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] {
244245
}
245246
}
246247

247-
function copyStackTrace(target: Error, source: Error) {
248-
if (source.stack !== undefined) {
249-
target.stack = source.stack.replace(source.message, target.message)
250-
}
251-
return target
252-
}
253-
254248
function raceWith<A, B>(
255249
promise: Promise<A>,
256250
other?: Promise<B>,

0 commit comments

Comments
 (0)