Skip to content

Commit d79cb44

Browse files
Dunqingsheremet-va
andauthored
feat(vitest): support vi.waitFor method (#4113)
Co-authored-by: Vladimir <[email protected]>
1 parent a7e0993 commit d79cb44

File tree

5 files changed

+282
-4
lines changed

5 files changed

+282
-4
lines changed

docs/api/vi.md

+69-3
Original file line numberDiff line numberDiff line change
@@ -254,10 +254,10 @@ import { vi } from 'vitest'
254254
```ts
255255
// increment.test.js
256256
import { vi } from 'vitest'
257-
257+
258258
// axios is a default export from `__mocks__/axios.js`
259259
import axios from 'axios'
260-
260+
261261
// increment is a named export from `src/__mocks__/increment.js`
262262
import { increment } from '../increment.js'
263263

@@ -371,7 +371,7 @@ test('importing the next module imports mocked one', async () => {
371371

372372
```ts
373373
import { vi } from 'vitest'
374-
374+
375375
import { data } from './data.js' // Will not get reevaluated beforeEach test
376376
377377
beforeEach(() => {
@@ -706,8 +706,74 @@ unmockedIncrement(30) === 31
706706

707707
The implementation is based internally on [`@sinonjs/fake-timers`](https://github.com/sinonjs/fake-timers).
708708

709+
## vi.isFakeTimers
710+
711+
- **Type:** `() => boolean`
712+
- **Version:** Since Vitest 0.34.5
713+
714+
Returns `true` if fake timers are enabled.
715+
709716
## vi.useRealTimers
710717

711718
- **Type:** `() => Vitest`
712719

713720
When timers are run out, you may call this method to return mocked timers to its original implementations. All timers that were run before will not be restored.
721+
722+
### vi.waitFor
723+
724+
- **Type:** `function waitFor<T>(callback: WaitForCallback<T>, options?: number | WaitForOptions): Promise<T>`
725+
- **Version**: Since Vitest 0.34.5
726+
727+
Wait for the callback to execute successfully. If the callback throws an error or returns a rejected promise it will continue to wait until it succeeds or times out.
728+
729+
This is very useful when you need to wait for some asynchronous action to complete, for example, when you start a server and need to wait for it to start.
730+
731+
```ts
732+
import { test, vi } from 'vitest'
733+
734+
test('Server started successfully', async () => {
735+
let server = false
736+
737+
setTimeout(() => {
738+
server = true
739+
}, 100)
740+
741+
function checkServerStart() {
742+
if (!server)
743+
throw new Error('Server not started')
744+
745+
console.log('Server started')
746+
}
747+
748+
const res = await vi.waitFor(checkServerStart, {
749+
timeout: 500, // default is 1000
750+
interval: 20, // default is 50
751+
})
752+
expect(server).toBe(true)
753+
})
754+
```
755+
756+
It also works for asynchronous callbacks
757+
758+
```ts
759+
import { test, vi } from 'vitest'
760+
761+
test('Server started successfully', async () => {
762+
async function startServer() {
763+
return new Promise((resolve) => {
764+
setTimeout(() => {
765+
server = true
766+
resolve('Server started')
767+
}, 100)
768+
})
769+
}
770+
771+
const server = await vi.waitFor(startServer, {
772+
timeout: 500, // default is 1000
773+
interval: 20, // default is 50
774+
})
775+
expect(server).toBe('Server started')
776+
})
777+
```
778+
779+
If `vi.useFakeTimers` is used, `vi.waitFor` automatically calls `vi.advanceTimersByTime(interval)` in every check callback.

packages/vitest/src/integrations/mock/timers.ts

+4
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@ export class FakeTimers {
175175
this._userConfig = config
176176
}
177177

178+
isFakeTimers() {
179+
return this._fakingTime
180+
}
181+
178182
private _checkFakeTimers() {
179183
if (!this._fakingTime) {
180184
throw new Error(

packages/vitest/src/integrations/vi.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import { resetModules, waitForImportsToResolve } from '../utils/modules'
99
import { FakeTimers } from './mock/timers'
1010
import type { EnhancedSpy, MaybeMocked, MaybeMockedDeep, MaybePartiallyMocked, MaybePartiallyMockedDeep } from './spy'
1111
import { fn, isMockFunction, spies, spyOn } from './spy'
12+
import { waitFor } from './wait'
1213

1314
interface VitestUtils {
15+
isFakeTimers(): boolean
1416
useFakeTimers(config?: FakeTimerInstallOpts): this
1517
useRealTimers(): this
1618
runOnlyPendingTimers(): this
@@ -30,6 +32,7 @@ interface VitestUtils {
3032

3133
spyOn: typeof spyOn
3234
fn: typeof fn
35+
waitFor: typeof waitFor
3336

3437
/**
3538
* Run the factory before imports are evaluated. You can return a value from the factory
@@ -213,6 +216,10 @@ function createVitest(): VitestUtils {
213216
return utils
214217
},
215218

219+
isFakeTimers() {
220+
return _timers.isFakeTimers()
221+
},
222+
216223
useRealTimers() {
217224
_timers.useRealTimers()
218225
_mockedDate = null
@@ -292,7 +299,7 @@ function createVitest(): VitestUtils {
292299

293300
spyOn,
294301
fn,
295-
302+
waitFor,
296303
hoisted<T>(factory: () => T): T {
297304
assertTypes(factory, '"vi.hoisted" factory', ['function'])
298305
return factory()
+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { getSafeTimers } from '@vitest/utils'
2+
import { vi } from './vi'
3+
4+
// The waitFor function was inspired by https://github.com/testing-library/web-testing-library/pull/2
5+
6+
export type WaitForCallback<T> = () => T | Promise<T>
7+
8+
export interface WaitForOptions {
9+
/**
10+
* @description Time in ms between each check callback
11+
* @default 50ms
12+
*/
13+
interval?: number
14+
/**
15+
* @description Time in ms after which the throw a timeout error
16+
* @default 1000ms
17+
*/
18+
timeout?: number
19+
}
20+
21+
function copyStackTrace(target: Error, source: Error) {
22+
if (source.stack !== undefined)
23+
target.stack = source.stack.replace(source.message, target.message)
24+
return target
25+
}
26+
27+
export function waitFor<T>(callback: WaitForCallback<T>, options: number | WaitForOptions = {}) {
28+
const { setTimeout, setInterval, clearTimeout, clearInterval } = getSafeTimers()
29+
const { interval = 50, timeout = 1000 } = typeof options === 'number' ? { timeout: options } : options
30+
const STACK_TRACE_ERROR = new Error('STACK_TRACE_ERROR')
31+
32+
return new Promise<T>((resolve, reject) => {
33+
let lastError: unknown
34+
let promiseStatus: 'idle' | 'pending' | 'resolved' | 'rejected' = 'idle'
35+
let timeoutId: ReturnType<typeof setTimeout>
36+
let intervalId: ReturnType<typeof setInterval>
37+
38+
const onResolve = (result: T) => {
39+
if (timeoutId)
40+
clearTimeout(timeoutId)
41+
if (intervalId)
42+
clearInterval(intervalId)
43+
44+
resolve(result)
45+
}
46+
47+
const handleTimeout = () => {
48+
let error = lastError
49+
if (!error)
50+
error = copyStackTrace(new Error('Timed out in waitFor!'), STACK_TRACE_ERROR)
51+
52+
reject(error)
53+
}
54+
55+
const checkCallback = () => {
56+
if (vi.isFakeTimers())
57+
vi.advanceTimersByTime(interval)
58+
59+
if (promiseStatus === 'pending')
60+
return
61+
try {
62+
const result = callback()
63+
if (
64+
result !== null
65+
&& typeof result === 'object'
66+
&& typeof (result as any).then === 'function'
67+
) {
68+
const thenable = result as PromiseLike<T>
69+
promiseStatus = 'pending'
70+
thenable.then(
71+
(resolvedValue) => {
72+
promiseStatus = 'resolved'
73+
onResolve(resolvedValue)
74+
},
75+
(rejectedValue) => {
76+
promiseStatus = 'rejected'
77+
lastError = rejectedValue
78+
},
79+
)
80+
}
81+
else {
82+
onResolve(result as T)
83+
return true
84+
}
85+
}
86+
catch (error) {
87+
lastError = error
88+
}
89+
}
90+
91+
if (checkCallback() === true)
92+
return
93+
94+
timeoutId = setTimeout(handleTimeout, timeout)
95+
intervalId = setInterval(checkCallback, interval)
96+
})
97+
}

test/core/test/wait.test.ts

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, expect, test, vi } from 'vitest'
2+
3+
describe('waitFor', () => {
4+
describe('options', () => {
5+
test('timeout', async () => {
6+
expect(async () => {
7+
await vi.waitFor(() => {
8+
return new Promise((resolve) => {
9+
setTimeout(() => {
10+
resolve(true)
11+
}, 100)
12+
})
13+
}, 50)
14+
}).rejects.toThrow('Timed out in waitFor!')
15+
})
16+
17+
test('interval', async () => {
18+
const callback = vi.fn(() => {
19+
throw new Error('interval error')
20+
})
21+
22+
await expect(
23+
vi.waitFor(callback, {
24+
timeout: 60,
25+
interval: 30,
26+
}),
27+
).rejects.toThrowErrorMatchingInlineSnapshot('"interval error"')
28+
29+
expect(callback).toHaveBeenCalledTimes(2)
30+
})
31+
})
32+
33+
test('basic', async () => {
34+
let throwError = false
35+
await vi.waitFor(() => {
36+
if (!throwError) {
37+
throwError = true
38+
throw new Error('basic error')
39+
}
40+
})
41+
expect(throwError).toBe(true)
42+
})
43+
44+
test('async function', async () => {
45+
let finished = false
46+
setTimeout(() => {
47+
finished = true
48+
}, 50)
49+
await vi.waitFor(async () => {
50+
if (finished)
51+
return Promise.resolve(true)
52+
else
53+
return Promise.reject(new Error('async function error'))
54+
})
55+
})
56+
57+
test('stacktrace correctly', async () => {
58+
const check = () => {
59+
const _a = 1
60+
// @ts-expect-error test
61+
_a += 1
62+
}
63+
try {
64+
await vi.waitFor(check, 100)
65+
}
66+
catch (error) {
67+
expect((error as Error).message).toMatchInlineSnapshot('"Assignment to constant variable."')
68+
expect.soft((error as Error).stack).toMatch(/at check/)
69+
}
70+
})
71+
72+
test('stacktrace point to waitFor', async () => {
73+
const check = async () => {
74+
return new Promise((resolve) => {
75+
setTimeout(resolve, 60)
76+
})
77+
}
78+
try {
79+
await vi.waitFor(check, 50)
80+
}
81+
catch (error) {
82+
expect(error).toMatchInlineSnapshot('[Error: Timed out in waitFor!]')
83+
expect((error as Error).stack?.split('\n')[1]).toMatch(/waitFor\s*\(.*\)?/)
84+
}
85+
})
86+
87+
test('fakeTimer works', async () => {
88+
vi.useFakeTimers()
89+
90+
setTimeout(() => {
91+
vi.advanceTimersByTime(200)
92+
}, 50)
93+
94+
await vi.waitFor(() => {
95+
return new Promise<void>((resolve) => {
96+
setTimeout(() => {
97+
resolve()
98+
}, 150)
99+
})
100+
}, 200)
101+
102+
vi.useRealTimers()
103+
})
104+
})

0 commit comments

Comments
 (0)