Skip to content

Commit bdc06dc

Browse files
authored
feat(snapshot): introduce toMatchFileSnapshot and auto queuing expect promise (#3116)
1 parent 035230b commit bdc06dc

File tree

28 files changed

+325
-23
lines changed

28 files changed

+325
-23
lines changed

docs/api/expect.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,22 @@ type Awaitable<T> = T | PromiseLike<T>
678678
})
679679
```
680680

681+
## toMatchFileSnapshot
682+
683+
- **Type:** `<T>(filepath: string, message?: string) => Promise<void>`
684+
685+
Compare or update the snapshot with the content of a file explicitly specified (instead of the `.snap` file).
686+
687+
```ts
688+
import { expect, it } from 'vitest'
689+
690+
it('render basic', async () => {
691+
const result = renderHTML(h('div', { class: 'foo' }))
692+
await expect(result).toMatchFileSnapshot('./test/basic.output.html')
693+
})
694+
```
695+
696+
Note that since file system operation is async, you need to use `await` with `toMatchFileSnapshot()`.
681697

682698
## toThrowErrorMatchingSnapshot
683699

docs/guide/snapshot.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,23 @@ Or you can use the `--update` or `-u` flag in the CLI to make Vitest update snap
7979
vitest -u
8080
```
8181

82+
## File Snapshots
83+
84+
When calling `toMatchSnapshot()`, we store all snapshots in a formatted snap file. That means we need to escaping some characters (namely the double-quote `"` and backtick `\``) in the snapshot string. Meanwhile, you might lose the syntax highlighting for the snapshot content (if they are in some language).
85+
86+
To improve this case, we introduce [`toMatchFileSnapshot()`](/api/expect#tomatchfilesnapshot) to explicitly snapshot in a file. This allows you to assign any file extension to the snapshot file, and making them more readable.
87+
88+
```ts
89+
import { expect, it } from 'vitest'
90+
91+
it('render basic', async () => {
92+
const result = renderHTML(h('div', { class: 'foo' }))
93+
await expect(result).toMatchFileSnapshot('./test/basic.output.html')
94+
})
95+
```
96+
97+
It will compare with the content of `./test/basic.output.html`. And can be written back with the `--update` flag.
98+
8299
## Image Snapshots
83100

84101
It's also possible to snapshot images using [`jest-image-snapshot`](https://github.com/americanexpress/jest-image-snapshot).

packages/browser/src/client/snapshot.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ export class BrowserSnapshotEnvironment implements SnapshotEnvironment {
2222
return rpc().resolveSnapshotPath(filepath)
2323
}
2424

25+
resolveRawPath(testPath: string, rawPath: string): Promise<string> {
26+
return rpc().resolveSnapshotRawPath(testPath, rawPath)
27+
}
28+
2529
removeSnapshotFile(filepath: string): Promise<void> {
2630
return rpc().removeFile(filepath)
2731
}

packages/expect/src/jest-expect.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { arrayBufferEquality, generateToBeMessage, iterableEquality, equals as j
88
import type { AsymmetricMatcher } from './jest-asymmetric-matchers'
99
import { diff, stringify } from './jest-matcher-utils'
1010
import { JEST_MATCHERS_OBJECT } from './constants'
11+
import { recordAsyncExpect } from './utils'
1112

1213
// Jest Expect Compact
1314
export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
@@ -633,6 +634,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
633634
utils.addProperty(chai.Assertion.prototype, 'resolves', function __VITEST_RESOLVES__(this: any) {
634635
utils.flag(this, 'promise', 'resolves')
635636
utils.flag(this, 'error', new Error('resolves'))
637+
const test = utils.flag(this, 'vitest-test')
636638
const obj = utils.flag(this, 'object')
637639

638640
if (typeof obj?.then !== 'function')
@@ -646,7 +648,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
646648
return result instanceof chai.Assertion ? proxy : result
647649

648650
return async (...args: any[]) => {
649-
return obj.then(
651+
const promise = obj.then(
650652
(value: any) => {
651653
utils.flag(this, 'object', value)
652654
return result.call(this, ...args)
@@ -655,6 +657,8 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
655657
throw new Error(`promise rejected "${String(err)}" instead of resolving`)
656658
},
657659
)
660+
661+
return recordAsyncExpect(test, promise)
658662
}
659663
},
660664
})
@@ -665,6 +669,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
665669
utils.addProperty(chai.Assertion.prototype, 'rejects', function __VITEST_REJECTS__(this: any) {
666670
utils.flag(this, 'promise', 'rejects')
667671
utils.flag(this, 'error', new Error('rejects'))
672+
const test = utils.flag(this, 'vitest-test')
668673
const obj = utils.flag(this, 'object')
669674
const wrapper = typeof obj === 'function' ? obj() : obj // for jest compat
670675

@@ -679,7 +684,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
679684
return result instanceof chai.Assertion ? proxy : result
680685

681686
return async (...args: any[]) => {
682-
return wrapper.then(
687+
const promise = wrapper.then(
683688
(value: any) => {
684689
throw new Error(`promise resolved "${String(value)}" instead of rejecting`)
685690
},
@@ -688,6 +693,8 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
688693
return result.call(this, ...args)
689694
},
690695
)
696+
697+
return recordAsyncExpect(test, promise)
691698
}
692699
},
693700
})

packages/expect/src/utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export function recordAsyncExpect(test: any, promise: Promise<any>) {
2+
// record promise for test, that resolves before test ends
3+
if (test) {
4+
// if promise is explicitly awaited, remove it from the list
5+
promise = promise.finally(() => {
6+
const index = test.promises.indexOf(promise)
7+
if (index !== -1)
8+
test.promises.splice(index, 1)
9+
})
10+
11+
// record promise
12+
if (!test.promises)
13+
test.promises = []
14+
test.promises.push(promise)
15+
}
16+
17+
return promise
18+
}

packages/runner/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export { startTests, updateTask } from './run'
22
export { test, it, describe, suite, getCurrentSuite } from './suite'
33
export { beforeAll, beforeEach, afterAll, afterEach, onTestFailed } from './hooks'
44
export { setFn, getFn } from './map'
5+
export { getCurrentTest } from './test-state'
56
export * from './types'

packages/runner/src/run.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,14 @@ export async function runTest(test: Test, runner: VitestRunner) {
145145
await fn()
146146
}
147147

148+
// some async expect will be added to this array, in case user forget to await theme
149+
if (test.promises) {
150+
const result = await Promise.allSettled(test.promises)
151+
const errors = result.map(r => r.status === 'rejected' ? r.reason : undefined).filter(Boolean)
152+
if (errors.length)
153+
throw errors
154+
}
155+
148156
await runner.onAfterTryTest?.(test, retryCount)
149157

150158
test.result.state = 'pass'
@@ -197,10 +205,15 @@ export async function runTest(test: Test, runner: VitestRunner) {
197205

198206
function failTask(result: TaskResult, err: unknown, runner: VitestRunner) {
199207
result.state = 'fail'
200-
const error = processError(err, runner.config)
201-
result.error = error
202-
result.errors ??= []
203-
result.errors.push(error)
208+
const errors = Array.isArray(err)
209+
? err
210+
: [err]
211+
for (const e of errors) {
212+
const error = processError(e, runner.config)
213+
result.error ??= error
214+
result.errors ??= []
215+
result.errors.push(error)
216+
}
204217
}
205218

206219
function markTasksAsSkipped(suite: Suite, runner: VitestRunner) {

packages/runner/src/types/tasks.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export interface Test<ExtraContext = {}> extends TaskBase {
5959
fails?: boolean
6060
context: TestContext & ExtraContext
6161
onFailed?: OnTestFailedHandler[]
62+
/**
63+
* Store promises (from async expects) to wait for them before finishing the test
64+
*/
65+
promises?: Promise<any>[]
6266
}
6367

6468
export type Task = Test | Suite | TaskCustom | File

packages/snapshot/src/client.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { deepMergeSnapshot } from './port/utils'
22
import SnapshotState from './port/state'
33
import type { SnapshotStateOptions } from './types'
4+
import type { RawSnapshotInfo } from './port/rawSnapshot'
45

56
const createMismatchError = (message: string, actual: unknown, expected: unknown) => {
67
const error = new Error(message)
@@ -35,6 +36,7 @@ interface AssertOptions {
3536
inlineSnapshot?: string
3637
error?: Error
3738
errorMessage?: string
39+
rawSnapshot?: RawSnapshotInfo
3840
}
3941

4042
export class SnapshotClient {
@@ -79,7 +81,7 @@ export class SnapshotClient {
7981
}
8082

8183
/**
82-
* Should be overriden by the consumer.
84+
* Should be overridden by the consumer.
8385
*
8486
* Vitest checks equality with @vitest/expect.
8587
*/
@@ -97,6 +99,7 @@ export class SnapshotClient {
9799
inlineSnapshot,
98100
error,
99101
errorMessage,
102+
rawSnapshot,
100103
} = options
101104
let { received } = options
102105

@@ -134,12 +137,38 @@ export class SnapshotClient {
134137
isInline,
135138
error,
136139
inlineSnapshot,
140+
rawSnapshot,
137141
})
138142

139143
if (!pass)
140144
throw createMismatchError(`Snapshot \`${key || 'unknown'}\` mismatched`, actual?.trim(), expected?.trim())
141145
}
142146

147+
async assertRaw(options: AssertOptions): Promise<void> {
148+
if (!options.rawSnapshot)
149+
throw new Error('Raw snapshot is required')
150+
151+
const {
152+
filepath = this.filepath,
153+
rawSnapshot,
154+
} = options
155+
156+
if (rawSnapshot.content == null) {
157+
if (!filepath)
158+
throw new Error('Snapshot cannot be used outside of test')
159+
160+
const snapshotState = this.getSnapshotState(filepath)
161+
162+
// save the filepath, so it don't lose even if the await make it out-of-context
163+
options.filepath ||= filepath
164+
// resolve and read the raw snapshot file
165+
rawSnapshot.file = await snapshotState.environment.resolveRawPath(filepath, rawSnapshot.file)
166+
rawSnapshot.content = await snapshotState.environment.readSnapshotFile(rawSnapshot.file) || undefined
167+
}
168+
169+
return this.assert(options)
170+
}
171+
143172
async resetCurrent() {
144173
if (!this.snapshotState)
145174
return null

packages/snapshot/src/env/node.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { existsSync, promises as fs } from 'node:fs'
2-
import { basename, dirname, join } from 'pathe'
2+
import { basename, dirname, isAbsolute, join, resolve } from 'pathe'
33
import type { SnapshotEnvironment } from '../types'
44

55
export class NodeSnapshotEnvironment implements SnapshotEnvironment {
@@ -11,6 +11,12 @@ export class NodeSnapshotEnvironment implements SnapshotEnvironment {
1111
return `// Snapshot v${this.getVersion()}`
1212
}
1313

14+
async resolveRawPath(testPath: string, rawPath: string) {
15+
return isAbsolute(rawPath)
16+
? rawPath
17+
: resolve(dirname(testPath), rawPath)
18+
}
19+
1420
async resolvePath(filepath: string): Promise<string> {
1521
return join(
1622
join(

0 commit comments

Comments
 (0)