Skip to content

Commit e2e0ff4

Browse files
authored
feat: add expect.poll utility (#5708)
1 parent 3ccbcc4 commit e2e0ff4

File tree

12 files changed

+252
-28
lines changed

12 files changed

+252
-28
lines changed

docs/.vitepress/components.d.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ declare module 'vue' {
1313
HomePage: typeof import('./components/HomePage.vue')['default']
1414
ListItem: typeof import('./components/ListItem.vue')['default']
1515
NonProjectOption: typeof import('./components/NonProjectOption.vue')['default']
16-
RouterLink: typeof import('vue-router')['RouterLink']
17-
RouterView: typeof import('vue-router')['RouterView']
1816
Version: typeof import('./components/Version.vue')['default']
1917
}
2018
}

docs/api/expect.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,47 @@ test('expect.soft test', () => {
5959
`expect.soft` can only be used inside the [`test`](/api/#test) function.
6060
:::
6161

62+
## poll
63+
64+
- **Type:** `ExpectStatic & (actual: () => any, options: { interval, timeout, message }) => Assertions`
65+
66+
`expect.poll` reruns the _assertion_ until it is succeeded. You can configure how many times Vitest should rerun the `expect.poll` callback by setting `interval` and `timeout` options.
67+
68+
If an error is thrown inside the `expect.poll` callback, Vitest will retry again until the timeout runs out.
69+
70+
```ts twoslash
71+
function asyncInjectElement() {
72+
// example function
73+
}
74+
75+
// ---cut---
76+
import { expect, test } from 'vitest'
77+
78+
test('element exists', async () => {
79+
asyncInjectElement()
80+
81+
await expect.poll(() => document.querySelector('.element')).toBeTruthy()
82+
})
83+
```
84+
85+
::: warning
86+
`expect.poll` makes every assertion asynchronous, so do not forget to await it otherwise you might get unhandled promise rejections.
87+
88+
`expect.poll` doesn't work with several matchers:
89+
90+
- Snapshot matchers are not supported because they will always succeed. If your condition is flaky, consider using [`vi.waitFor`](/api/vi#vi-waitfor) instead to resolve it first:
91+
92+
```ts
93+
import { expect, vi } from 'vitest'
94+
95+
const flakyValue = await vi.waitFor(() => getFlakyValue())
96+
expect(flakyValue).toMatchSnapshot()
97+
```
98+
99+
- `.resolves` and `.rejects` are not supported. `expect.poll` already awaits the condition if it's asynchronous.
100+
- `toThrow` and its aliases are not supported because the `expect.poll` condition is always resolved before the matcher gets the value
101+
:::
102+
62103
## not
63104

64105
Using `not` will negate the assertion. For example, this code asserts that an `input` value is not equal to `2`. If it's equal, the assertion will throw an error, and the test will fail.

packages/expect/src/jest-expect.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,13 +722,23 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
722722
return this.be.satisfy(matcher, message)
723723
})
724724

725+
// @ts-expect-error @internal
726+
def('withContext', function (this: any, context: Record<string, any>) {
727+
for (const key in context)
728+
utils.flag(this, key, context[key])
729+
return this
730+
})
731+
725732
utils.addProperty(chai.Assertion.prototype, 'resolves', function __VITEST_RESOLVES__(this: any) {
726733
const error = new Error('resolves')
727734
utils.flag(this, 'promise', 'resolves')
728735
utils.flag(this, 'error', error)
729736
const test: Test = utils.flag(this, 'vitest-test')
730737
const obj = utils.flag(this, 'object')
731738

739+
if (utils.flag(this, 'poll'))
740+
throw new SyntaxError(`expect.poll() is not supported in combination with .resolves`)
741+
732742
if (typeof obj?.then !== 'function')
733743
throw new TypeError(`You must provide a Promise to expect() when using .resolves, not '${typeof obj}'.`)
734744

@@ -772,6 +782,9 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
772782
const obj = utils.flag(this, 'object')
773783
const wrapper = typeof obj === 'function' ? obj() : obj // for jest compat
774784

785+
if (utils.flag(this, 'poll'))
786+
throw new SyntaxError(`expect.poll() is not supported in combination with .rejects`)
787+
775788
if (typeof wrapper?.then !== 'function')
776789
throw new TypeError(`You must provide a Promise to expect() when using .rejects, not '${typeof wrapper}'.`)
777790

packages/expect/src/jest-extend.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ function getMatcherState(assertion: Chai.AssertionStatic & Chai.Assertion, expec
4040
equals,
4141
// needed for built-in jest-snapshots, but we don't use it
4242
suppressedErrors: [],
43+
soft: util.flag(assertion, 'soft') as boolean | undefined,
44+
poll: util.flag(assertion, 'poll') as boolean | undefined,
4345
}
4446

4547
return {

packages/expect/src/types.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export interface MatcherState {
7070
subsetEquality: Tester
7171
}
7272
soft?: boolean
73+
poll?: boolean
7374
}
7475

7576
export interface SyncExpectationResult {
@@ -91,12 +92,7 @@ export type MatchersObject<T extends MatcherState = MatcherState> = Record<strin
9192

9293
export interface ExpectStatic extends Chai.ExpectStatic, AsymmetricMatchersContaining {
9394
<T>(actual: T, message?: string): Assertion<T>
94-
unreachable: (message?: string) => never
95-
soft: <T>(actual: T, message?: string) => Assertion<T>
9695
extend: (expects: MatchersObject) => void
97-
addEqualityTesters: (testers: Array<Tester>) => void
98-
assertions: (expected: number) => void
99-
hasAssertions: () => void
10096
anything: () => any
10197
any: (constructor: unknown) => any
10298
getState: () => MatcherState
@@ -175,13 +171,15 @@ type Promisify<O> = {
175171
: O[K]
176172
}
177173

174+
export type PromisifyAssertion<T> = Promisify<Assertion<T>>
175+
178176
export interface Assertion<T = any> extends VitestAssertion<Chai.Assertion, T>, JestAssertion<T> {
179177
toBeTypeOf: (expected: 'bigint' | 'boolean' | 'function' | 'number' | 'object' | 'string' | 'symbol' | 'undefined') => void
180178
toHaveBeenCalledOnce: () => void
181179
toSatisfy: <E>(matcher: (value: E) => boolean, message?: string) => void
182180

183-
resolves: Promisify<Assertion<T>>
184-
rejects: Promisify<Assertion<T>>
181+
resolves: PromisifyAssertion<T>
182+
rejects: PromisifyAssertion<T>
185183
}
186184

187185
declare global {

packages/expect/src/utils.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { processError } from '@vitest/utils/error'
22
import type { Test } from '@vitest/runner/types'
3-
import { GLOBAL_EXPECT } from './constants'
4-
import { getState } from './state'
5-
import type { Assertion, MatcherState } from './types'
3+
import type { Assertion } from './types'
64

75
export function recordAsyncExpect(test: any, promise: Promise<any> | PromiseLike<any>) {
86
// record promise for test, that resolves before test ends
@@ -25,16 +23,11 @@ export function recordAsyncExpect(test: any, promise: Promise<any> | PromiseLike
2523

2624
export function wrapSoft(utils: Chai.ChaiUtils, fn: (this: Chai.AssertionStatic & Assertion, ...args: any[]) => void) {
2725
return function (this: Chai.AssertionStatic & Assertion, ...args: any[]) {
28-
const test: Test = utils.flag(this, 'vitest-test')
29-
30-
// @ts-expect-error local is untyped
31-
const state: MatcherState = test?.context._local
32-
? test.context.expect.getState()
33-
: getState((globalThis as any)[GLOBAL_EXPECT])
34-
35-
if (!state.soft)
26+
if (!utils.flag(this, 'soft'))
3627
return fn.apply(this, args)
3728

29+
const test: Test = utils.flag(this, 'vitest-test')
30+
3831
if (!test)
3932
throw new Error('expect.soft() can only be used inside a test')
4033

packages/vitest/src/integrations/chai/index.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ import type { Assertion, ExpectStatic } from '@vitest/expect'
99
import type { MatcherState } from '../../types/chai'
1010
import { getTestName } from '../../utils/tasks'
1111
import { getCurrentEnvironment, getWorkerState } from '../../utils/global'
12+
import { createExpectPoll } from './poll'
1213

1314
export function createExpect(test?: TaskPopulated) {
1415
const expect = ((value: any, message?: string): Assertion => {
1516
const { assertionCalls } = getState(expect)
16-
setState({ assertionCalls: assertionCalls + 1, soft: false }, expect)
17+
setState({ assertionCalls: assertionCalls + 1 }, expect)
1718
const assert = chai.expect(value, message) as unknown as Assertion
1819
const _test = test || getCurrentTest()
1920
if (_test)
@@ -51,13 +52,12 @@ export function createExpect(test?: TaskPopulated) {
5152
addCustomEqualityTesters(customTesters)
5253

5354
expect.soft = (...args) => {
54-
const assert = expect(...args)
55-
expect.setState({
56-
soft: true,
57-
})
58-
return assert
55+
// @ts-expect-error private soft access
56+
return expect(...args).withContext({ soft: true }) as Assertion
5957
}
6058

59+
expect.poll = createExpectPoll(expect)
60+
6161
expect.unreachable = (message?: string) => {
6262
chai.assert.fail(`expected${message ? ` "${message}" ` : ' '}not to be reached`)
6363
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import * as chai from 'chai'
2+
import type { ExpectStatic } from '@vitest/expect'
3+
import { getSafeTimers } from '@vitest/utils'
4+
5+
// these matchers are not supported because they don't make sense with poll
6+
const unsupported = [
7+
// .poll is meant to retry matchers until they succeed, and
8+
// snapshots will always succeed as long as the poll method doesn't thow an error
9+
// in this case using the `vi.waitFor` method is more appropriate
10+
'matchSnapshot',
11+
'toMatchSnapshot',
12+
'toMatchInlineSnapshot',
13+
'toThrowErrorMatchingSnapshot',
14+
'toThrowErrorMatchingInlineSnapshot',
15+
// toThrow will never succeed because we call the poll callback until it doesn't throw
16+
'throws',
17+
'Throw',
18+
'throw',
19+
'toThrow',
20+
'toThrowError',
21+
// these are not supported because you can call them without `.poll`,
22+
// we throw an error inside the rejects/resolves methods to prevent this
23+
// rejects,
24+
// resolves
25+
]
26+
27+
export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] {
28+
return function poll(fn, options = {}) {
29+
const { interval = 50, timeout = 1000, message } = options
30+
// @ts-expect-error private poll access
31+
const assertion = expect(null, message).withContext({ poll: true }) as Assertion
32+
const proxy: any = new Proxy(assertion, {
33+
get(target, key, receiver) {
34+
const result = Reflect.get(target, key, receiver)
35+
36+
if (typeof result !== 'function')
37+
return result instanceof chai.Assertion ? proxy : result
38+
39+
if (key === 'assert')
40+
return result
41+
42+
if (typeof key === 'string' && unsupported.includes(key))
43+
throw new SyntaxError(`expect.poll() is not supported in combination with .${key}(). Use vi.waitFor() if your assertion condition is unstable.`)
44+
45+
return function (this: any, ...args: any[]) {
46+
const STACK_TRACE_ERROR = new Error('STACK_TRACE_ERROR')
47+
return new Promise((resolve, reject) => {
48+
let intervalId: any
49+
let lastError: any
50+
const { setTimeout, clearTimeout } = getSafeTimers()
51+
const timeoutId = setTimeout(() => {
52+
clearTimeout(intervalId)
53+
reject(copyStackTrace(new Error(`Matcher did not succeed in ${timeout}ms`, { cause: lastError }), STACK_TRACE_ERROR))
54+
}, timeout)
55+
const check = async () => {
56+
try {
57+
chai.util.flag(this, 'object', await fn())
58+
resolve(await result.call(this, ...args))
59+
clearTimeout(intervalId)
60+
clearTimeout(timeoutId)
61+
}
62+
catch (err) {
63+
lastError = err
64+
intervalId = setTimeout(check, interval)
65+
}
66+
}
67+
check()
68+
})
69+
}
70+
},
71+
})
72+
return proxy
73+
}
74+
}
75+
76+
function copyStackTrace(target: Error, source: Error) {
77+
if (source.stack !== undefined)
78+
target.stack = source.stack.replace(source.message, target.message)
79+
return target
80+
}

packages/vitest/src/node/error.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ const skipErrorProperties = new Set([
160160
'stackStr',
161161
'type',
162162
'showDiff',
163+
'ok',
164+
'operator',
163165
'diff',
164166
'codeFrame',
165167
'actual',

packages/vitest/src/types/global.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Plugin as PrettyFormatPlugin } from 'pretty-format'
22
import type { SnapshotState } from '@vitest/snapshot'
3-
import type { ExpectStatic } from '@vitest/expect'
3+
import type { ExpectStatic, PromisifyAssertion, Tester } from '@vitest/expect'
44
import type { UserConsoleLog } from './general'
55
import type { VitestEnvironment } from './config'
66
import type { BenchmarkResult } from './benchmark'
@@ -33,7 +33,19 @@ declare module '@vitest/expect' {
3333
snapshotState: SnapshotState
3434
}
3535

36+
interface ExpectPollOptions {
37+
interval?: number
38+
timeout?: number
39+
message?: string
40+
}
41+
3642
interface ExpectStatic {
43+
unreachable: (message?: string) => never
44+
soft: <T>(actual: T, message?: string) => Assertion<T>
45+
poll: <T>(actual: () => T, options?: ExpectPollOptions) => PromisifyAssertion<Awaited<T>>
46+
addEqualityTesters: (testers: Array<Tester>) => void
47+
assertions: (expected: number) => void
48+
hasAssertions: () => void
3749
addSnapshotSerializer: (plugin: PrettyFormatPlugin) => void
3850
}
3951

0 commit comments

Comments
 (0)