Skip to content

Commit e0ac97c

Browse files
Dunqingsheremet-va
andauthored
feat(vitest): support vi.waitUntil method (#4129)
Co-authored-by: Vladimir Sheremet <[email protected]>
1 parent ca70a77 commit e0ac97c

File tree

4 files changed

+191
-3
lines changed

4 files changed

+191
-3
lines changed

docs/api/vi.md

+28-2
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,7 @@ Wait for the callback to execute successfully. If the callback throws an error o
729729
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.
730730

731731
```ts
732-
import { test, vi } from 'vitest'
732+
import { expect, test, vi } from 'vitest'
733733

734734
test('Server started successfully', async () => {
735735
let server = false
@@ -756,7 +756,7 @@ test('Server started successfully', async () => {
756756
It also works for asynchronous callbacks
757757

758758
```ts
759-
import { test, vi } from 'vitest'
759+
import { expect, test, vi } from 'vitest'
760760

761761
test('Server started successfully', async () => {
762762
async function startServer() {
@@ -777,3 +777,29 @@ test('Server started successfully', async () => {
777777
```
778778

779779
If `vi.useFakeTimers` is used, `vi.waitFor` automatically calls `vi.advanceTimersByTime(interval)` in every check callback.
780+
781+
### vi.waitUntil
782+
783+
- **Type:** `function waitUntil(callback: WaitUntilCallback, options?: number | WaitUntilOptions): Promise`
784+
- **Version**: Since Vitest 0.34.5
785+
786+
This is similar to `vi.waitFor`, but if the callback throws any errors, execution is immediately interrupted and an error message is received. If the callback returns falsy value, the next check will continue until truthy value is returned. This is useful when you need to wait for something to exist before taking the next step.
787+
788+
Look at the example below. We can use `vi.waitUntil` to wait for the element to appear on the page, and then we can do something with the element.
789+
790+
```ts
791+
import { expect, test, vi } from 'vitest'
792+
793+
test('Element render correctly', async () => {
794+
const element = await vi.waitUntil(
795+
() => document.querySelector('.element'),
796+
{
797+
timeout: 500, // default is 1000
798+
interval: 20, // default is 50
799+
}
800+
)
801+
802+
// do something with the element
803+
expect(element.querySelector('.element-child')).toBeTruthy()
804+
})
805+
```

packages/vitest/src/integrations/vi.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ 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'
12+
import { waitFor, waitUntil } from './wait'
1313

1414
interface VitestUtils {
1515
isFakeTimers(): boolean
@@ -33,6 +33,7 @@ interface VitestUtils {
3333
spyOn: typeof spyOn
3434
fn: typeof fn
3535
waitFor: typeof waitFor
36+
waitUntil: typeof waitUntil
3637

3738
/**
3839
* Run the factory before imports are evaluated. You can return a value from the factory
@@ -300,6 +301,7 @@ function createVitest(): VitestUtils {
300301
spyOn,
301302
fn,
302303
waitFor,
304+
waitUntil,
303305
hoisted<T>(factory: () => T): T {
304306
assertTypes(factory, '"vi.hoisted" factory', ['function'])
305307
return factory()

packages/vitest/src/integrations/wait.ts

+76
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,79 @@ export function waitFor<T>(callback: WaitForCallback<T>, options: number | WaitF
9595
intervalId = setInterval(checkCallback, interval)
9696
})
9797
}
98+
99+
export type WaitUntilCallback<T> = () => T | Promise<T>
100+
101+
export interface WaitUntilOptions extends Pick<WaitForOptions, 'interval' | 'timeout'> {}
102+
103+
export function waitUntil<T>(callback: WaitUntilCallback<T>, options: number | WaitUntilOptions = {}) {
104+
const { setTimeout, setInterval, clearTimeout, clearInterval } = getSafeTimers()
105+
const { interval = 50, timeout = 1000 } = typeof options === 'number' ? { timeout: options } : options
106+
const STACK_TRACE_ERROR = new Error('STACK_TRACE_ERROR')
107+
108+
return new Promise<T>((resolve, reject) => {
109+
let promiseStatus: 'idle' | 'pending' | 'resolved' | 'rejected' = 'idle'
110+
let timeoutId: ReturnType<typeof setTimeout>
111+
let intervalId: ReturnType<typeof setInterval>
112+
113+
const onReject = (error?: Error) => {
114+
if (!error)
115+
error = copyStackTrace(new Error('Timed out in waitUntil!'), STACK_TRACE_ERROR)
116+
reject(error)
117+
}
118+
119+
const onResolve = (result: T) => {
120+
if (!result)
121+
return
122+
123+
if (timeoutId)
124+
clearTimeout(timeoutId)
125+
if (intervalId)
126+
clearInterval(intervalId)
127+
128+
resolve(result)
129+
return true
130+
}
131+
132+
const checkCallback = () => {
133+
if (vi.isFakeTimers())
134+
vi.advanceTimersByTime(interval)
135+
136+
if (promiseStatus === 'pending')
137+
return
138+
try {
139+
const result = callback()
140+
if (
141+
result !== null
142+
&& typeof result === 'object'
143+
&& typeof (result as any).then === 'function'
144+
) {
145+
const thenable = result as PromiseLike<T>
146+
promiseStatus = 'pending'
147+
thenable.then(
148+
(resolvedValue) => {
149+
promiseStatus = 'resolved'
150+
onResolve(resolvedValue)
151+
},
152+
(rejectedValue) => {
153+
promiseStatus = 'rejected'
154+
onReject(rejectedValue)
155+
},
156+
)
157+
}
158+
else {
159+
return onResolve(result as T)
160+
}
161+
}
162+
catch (error) {
163+
onReject(error as Error)
164+
}
165+
}
166+
167+
if (checkCallback() === true)
168+
return
169+
170+
timeoutId = setTimeout(onReject, timeout)
171+
intervalId = setInterval(checkCallback, interval)
172+
})
173+
}

test/core/test/wait.test.ts

+84
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,87 @@ describe('waitFor', () => {
102102
vi.useRealTimers()
103103
})
104104
})
105+
106+
describe('waitUntil', () => {
107+
describe('options', () => {
108+
test('timeout', async () => {
109+
expect(async () => {
110+
await vi.waitUntil(() => {
111+
return new Promise((resolve) => {
112+
setTimeout(() => {
113+
resolve(true)
114+
}, 100)
115+
})
116+
}, 50)
117+
}).rejects.toThrow('Timed out in waitUntil!')
118+
})
119+
120+
test('interval', async () => {
121+
const callback = vi.fn(() => {
122+
return false
123+
})
124+
125+
await expect(
126+
vi.waitUntil(callback, {
127+
timeout: 60,
128+
interval: 30,
129+
}),
130+
).rejects.toThrowErrorMatchingInlineSnapshot('"Timed out in waitUntil!"')
131+
132+
expect(callback).toHaveBeenCalledTimes(2)
133+
})
134+
})
135+
136+
test('basic', async () => {
137+
let result = true
138+
await vi.waitUntil(() => {
139+
result = !result
140+
return result
141+
})
142+
expect(result).toBe(true)
143+
})
144+
145+
test('async function', async () => {
146+
let finished = false
147+
setTimeout(() => {
148+
finished = true
149+
}, 50)
150+
await vi.waitUntil(async () => {
151+
return Promise.resolve(finished)
152+
})
153+
})
154+
155+
test('stacktrace correctly when callback throw error', async () => {
156+
const check = () => {
157+
const _a = 1
158+
// @ts-expect-error test
159+
_a += 1
160+
return true
161+
}
162+
try {
163+
await vi.waitUntil(check, 20)
164+
}
165+
catch (error) {
166+
expect((error as Error).message).toMatchInlineSnapshot('"Assignment to constant variable."')
167+
expect.soft((error as Error).stack).toMatch(/at check/)
168+
}
169+
})
170+
171+
test('fakeTimer works', async () => {
172+
vi.useFakeTimers()
173+
174+
setTimeout(() => {
175+
vi.advanceTimersByTime(200)
176+
}, 50)
177+
178+
await vi.waitUntil(() => {
179+
return new Promise<boolean>((resolve) => {
180+
setTimeout(() => {
181+
resolve(true)
182+
}, 150)
183+
})
184+
}, 200)
185+
186+
vi.useRealTimers()
187+
})
188+
})

0 commit comments

Comments
 (0)