Skip to content

Commit 6819008

Browse files
julienwhi-ogawaclaude
authored
fix(browser): advance fake timers in actions and expect.element (#11242)
Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 21be412 commit 6819008

9 files changed

Lines changed: 198 additions & 30 deletions

File tree

‎docs/api/browser/assertions.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ interface ExpectPollOptions {
5555
```
5656

5757
::: tip
58-
Like [`expect.poll`](/api/expect#poll), `expect.element` retries DOM assertions until they pass or the timeout is reached. When it receives a locator, Vitest resolves it with [`locator.findElement()`](/api/browser/locators#findelement) before running the DOM assertion. The `timeout` option applies to the whole retry operation. The `interval` option controls how often failed DOM assertions are retried, but locator resolution uses `findElement`'s own increasing retry intervals.
58+
Like [`expect.poll`](/api/expect#poll), `expect.element` retries DOM assertions until they pass or the timeout is reached. When it receives a locator, Vitest resolves it with [`locator.findElement()`](/api/browser/locators#findelement) before running the DOM assertion. The `timeout` option applies to the whole retry operation. The `interval` option controls how often failed DOM assertions are retried, but locator resolution uses `findElement`'s own increasing retry intervals. If [fake timers](/api/vi#vi-usefaketimers) are enabled, `expect.element` advances them while it retries. See [Automatic Advancing](/guide/mocking/timers#automatic-advancing) for details.
5959

6060
`toMatchTextContent` and all other assertions are still available on a regular `expect` without a built-in retry-ability mechanism:
6161

‎docs/api/browser/locators.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,10 @@ This options narrows down the selector to only match elements that do not contai
620620

621621
All methods are asynchronous and must be awaited. Since Vitest 3, tests will fail if a method is not awaited.
622622

623+
::: tip
624+
If [fake timers](/api/vi#vi-usefaketimers) are enabled, these methods advance them while they run. See [Automatic Advancing](/guide/mocking/timers#automatic-advancing) for details.
625+
:::
626+
623627
### click
624628

625629
```ts

‎docs/api/expect.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ The callback receives an `AbortSignal` that is aborted when the poll timeout is
118118

119119
If an error is thrown inside the `expect.poll` callback, Vitest will retry again until the timeout runs out.
120120

121+
If `vi.useFakeTimers` is used, `expect.poll` automatically calls `vi.advanceTimersByTime(interval)` after every failed attempt. See [Automatic Advancing](/guide/mocking/timers#automatic-advancing) for details.
122+
121123
```ts
122124
import { expect, test } from 'vitest'
123125

‎docs/api/vi.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,7 +1231,7 @@ Controls how fake timers are advanced.
12311231
- `nextTimerAsync`: Timers will be advanced automatically to the next available timer after each macrotask.
12321232
- `interval`: Timers are advanced automatically by a specified interval.
12331233

1234-
When `mode` is `'interval'`, you can also provide an `interval` in milliseconds.
1234+
When `mode` is `'interval'`, you can also provide an `interval` in milliseconds. See [Tick Modes](/guide/mocking/timers#tick-modes) for when to use each mode.
12351235

12361236
**Example:**
12371237

@@ -1346,7 +1346,7 @@ test('Element exists in a DOM', async () => {
13461346
})
13471347
```
13481348

1349-
If `vi.useFakeTimers` is used, `vi.waitFor` automatically calls `vi.advanceTimersByTime(interval)` in every check callback.
1349+
If `vi.useFakeTimers` is used, `vi.waitFor` automatically calls `vi.advanceTimersByTime(interval)` in every check callback. See [Automatic Advancing](/guide/mocking/timers#automatic-advancing) for details.
13501350

13511351
### vi.waitUntil {#vi-waituntil}
13521352

@@ -1378,6 +1378,8 @@ test('Element render correctly', async () => {
13781378
})
13791379
```
13801380

1381+
If `vi.useFakeTimers` is used, `vi.waitUntil` automatically calls `vi.advanceTimersByTime(interval)` in every check callback. See [Automatic Advancing](/guide/mocking/timers#automatic-advancing) for details.
1382+
13811383
### vi.hoisted {#vi-hoisted}
13821384

13831385
```ts

‎docs/guide/mocking/timers.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,18 @@ describe('delayed execution', () => {
4646
})
4747
})
4848
```
49+
50+
## Automatic Advancing {#automatic-advancing}
51+
52+
Some Vitest APIs wait for a condition to be met. When fake timers are enabled, these APIs advance them while they run:
53+
54+
- [`vi.waitFor`](/api/vi#vi-waitfor) and [`vi.waitUntil`](/api/vi#vi-waituntil) advance timers by `interval` before every check.
55+
- [`expect.poll`](/api/expect#poll) advances timers by `interval` after every failed attempt.
56+
- In Browser Mode, [`expect.element`](/api/browser/assertions) advances timers while it retries, and [locator](/api/browser/locators#methods) and [`userEvent`](/api/browser/interactivity) methods that take an element advance them while they run, even when the element already exists. The amount depends on how long the action takes in real time, so it can vary between runs. Methods that don't take an element, such as `userEvent.keyboard` and `userEvent.tab`, don't advance timers.
57+
58+
## Tick Modes {#tick-modes}
59+
60+
By default, fake timers only move when you advance them or when one of the APIs above runs. [`vi.setTimerTickMode`](/api/vi#vi-settimertickmode) makes them advance on their own:
61+
62+
- `interval` advances fake time by a fixed step on a real-time interval, so it stays roughly in step with real time. It suits code where some state only lasts for a limited time.
63+
- `nextTimerAsync` runs the next pending timer after each macrotask, so chained timers fire almost back to back. It suits code that eventually settles, but state that only lasts for a limited time can disappear before an assertion or action sees it.

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { SerializedLocator } from './locators'
2+
import { vi } from 'vitest'
3+
import { getSafeTimers } from 'vitest/internal/browser'
24
import { getBrowserState, getWorkerState } from '../utils'
35

46
export interface ActionOptions {
@@ -30,6 +32,26 @@ export function processTimeoutOptions<T extends { timeout?: number }>(options?:
3032
return { ...options, timeout } as T
3133
}
3234

35+
const FAKE_TIMERS_TICK = 50
36+
37+
/**
38+
* Wraps provider commands that wait for an element. The provider waits in real time,
39+
* so fake timers must keep moving for the page to render it. Commands that act right
40+
* away, such as `keyboard` and `tab`, are not wrapped.
41+
*/
42+
export function advanceFakeTimersWhilePending<T>(promise: Promise<T>): Promise<T> {
43+
if (!vi.isFakeTimers()) {
44+
return promise
45+
}
46+
const { setInterval, clearInterval } = getSafeTimers()
47+
const interval = setInterval(() => {
48+
if (vi.isFakeTimers()) {
49+
vi.advanceTimersByTime(FAKE_TIMERS_TICK)
50+
}
51+
}, FAKE_TIMERS_TICK)
52+
return promise.finally(() => clearInterval(interval))
53+
}
54+
3355
/**
3456
* A browser command that the test awaits. The action derives its own timeout
3557
* from the running task, so it fails with a descriptive error before the task does.
@@ -75,10 +97,8 @@ class Action<T = void> implements Promise<T> {
7597
const options = timeout == null ? this.#options : { ...this.#options, timeout }
7698
const args =
7799
typeof this.#args === 'function' ? await this.#args(options) : [...this.#args, options]
78-
const promise = getBrowserState().commands.triggerCommand<T>(
79-
this.#command,
80-
args,
81-
this.#errorSource,
100+
const promise = advanceFakeTimersWhilePending(
101+
getBrowserState().commands.triggerCommand<T>(this.#command, args, this.#errorSource),
82102
)
83103
const deadline = getBrowserState().runner._deadline
84104
return deadline && timeout != null

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

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import type { BrowserTraceEntryStatus } from './trace'
2222
import { vi } from 'vitest'
2323
import { __INTERNAL, stringify } from 'vitest/internal/browser'
2424
import { ensureAwaited, getBrowserState, getWorkerState } from '../utils'
25-
import { ScreenshotAction } from './action'
25+
import { advanceFakeTimersWhilePending, ScreenshotAction } from './action'
2626
import {
2727
getIframeOffset,
2828
getIframeScale,
@@ -123,14 +123,16 @@ export function createUserEvent(
123123
}),
124124
)
125125

126-
const { coords, target, unreleased } = await triggerCommand<
127-
PointerState & {
128-
unreleased: string[]
129-
}
130-
>('__vitest_pointer', [
131-
serializedInputArray,
132-
{ ...pointerState, unreleased: keyboard.unreleased },
133-
])
126+
const { coords, target, unreleased } = await advanceFakeTimersWhilePending(
127+
triggerCommand<
128+
PointerState & {
129+
unreleased: string[]
130+
}
131+
>('__vitest_pointer', [
132+
serializedInputArray,
133+
{ ...pointerState, unreleased: keyboard.unreleased },
134+
]),
135+
)
134136

135137
pointerState.target = target
136138
pointerState.coords = coords
@@ -167,10 +169,12 @@ export function createUserEvent(
167169
type(element, text, options) {
168170
return ensureAwaited(async (error) => {
169171
const serializedElement = await serializeElement(element, options)
170-
const { unreleased } = await triggerCommand<{ unreleased: string[] }>(
171-
'__vitest_type',
172-
[serializedElement, text, { ...options, unreleased: keyboard.unreleased }],
173-
error,
172+
const { unreleased } = await advanceFakeTimersWhilePending(
173+
triggerCommand<{ unreleased: string[] }>(
174+
'__vitest_type',
175+
[serializedElement, text, { ...options, unreleased: keyboard.unreleased }],
176+
error,
177+
),
174178
)
175179
keyboard.unreleased = unreleased
176180
})

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

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,16 @@ import {
2626
getByTitleSelector,
2727
Ivya,
2828
} from 'ivya'
29+
import { vi } from 'vitest'
2930
import { page, server, utils } from 'vitest/browser'
3031
import { __INTERNAL, getSafeTimers } from 'vitest/internal/browser'
3132
import { ensureAwaited, getBrowserState, getWorkerState } from '../utils'
32-
import { LocatorAction, resolveActionTimeout, UploadAction } from './action'
33+
import {
34+
advanceFakeTimersWhilePending,
35+
LocatorAction,
36+
resolveActionTimeout,
37+
UploadAction,
38+
} from './action'
3339
import {
3440
convertElementToCssSelector,
3541
escapeForTextSelector,
@@ -113,21 +119,25 @@ export abstract class Locator {
113119

114120
public wheel(options: UserEventWheelOptions): Promise<void> {
115121
return ensureAwaited<void>(async (error) => {
116-
await getBrowserState().commands.triggerCommand<void>(
117-
'__vitest_wheel',
118-
[this.serialize(), resolveUserEventWheelOptions(options)],
119-
error,
122+
await advanceFakeTimersWhilePending(
123+
getBrowserState().commands.triggerCommand<void>(
124+
'__vitest_wheel',
125+
[this.serialize(), resolveUserEventWheelOptions(options)],
126+
error,
127+
),
120128
)
121129

122130
const browser = getBrowserState().config.browser.name
123131

124132
// looks like on Chromium the scroll event gets dispatched a frame later
125133
if (browser === 'chromium' || browser === 'chrome') {
126-
return new Promise((resolve) => {
127-
requestAnimationFrame(() => {
128-
resolve()
129-
})
130-
})
134+
return advanceFakeTimersWhilePending(
135+
new Promise((resolve) => {
136+
requestAnimationFrame(() => {
137+
resolve()
138+
})
139+
}),
140+
)
131141
}
132142
})
133143
}
@@ -396,6 +406,9 @@ export abstract class Locator {
396406
}
397407
const interval = waitForIntervals[Math.min(intervalIndex++, waitForIntervals.length - 1)]
398408
const nextInterval = timeout != null ? Math.min(interval, timeout - elapsed) : interval
409+
if (vi.isFakeTimers()) {
410+
vi.advanceTimersByTime(nextInterval)
411+
}
399412
await sleep(nextInterval)
400413
}
401414
}

‎test/browser/test/timers.test.ts‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { afterEach, expect, it, vi } from 'vitest'
2+
import { page, userEvent } from 'vitest/browser'
23

34
afterEach(() => {
45
vi.useRealTimers()
6+
document.body.innerHTML = ''
57
})
68

79
it('only runs a setTimeout callback once (ever)', () => {
@@ -17,3 +19,109 @@ it('only runs a setTimeout callback once (ever)', () => {
1719
vi.runAllTimers()
1820
expect(fn).toHaveBeenCalledTimes(1)
1921
})
22+
23+
it('locator actions advance fake timers while waiting for the element', async () => {
24+
vi.useFakeTimers()
25+
26+
const onClick = vi.fn()
27+
setTimeout(() => {
28+
const button = document.createElement('button')
29+
button.textContent = 'Click me!'
30+
button.addEventListener('click', onClick)
31+
document.body.append(button)
32+
}, 100)
33+
34+
await page.getByRole('button', { name: 'Click me!' }).click()
35+
expect(onClick).toHaveBeenCalledTimes(1)
36+
expect(vi.getTimerCount()).toBe(0)
37+
})
38+
39+
it('locator.findElement advances fake timers while waiting for the element', async () => {
40+
vi.useFakeTimers()
41+
42+
setTimeout(() => {
43+
const button = document.createElement('button')
44+
button.textContent = 'Find me!'
45+
document.body.append(button)
46+
}, 100)
47+
48+
const element = await page.getByRole('button', { name: 'Find me!' }).findElement()
49+
expect(element).toHaveTextContent('Find me!')
50+
})
51+
52+
it('expect.element advances fake timers while waiting for the element', async () => {
53+
vi.useFakeTimers()
54+
55+
setTimeout(() => {
56+
const button = document.createElement('button')
57+
button.textContent = 'Expect me!'
58+
document.body.append(button)
59+
}, 100)
60+
61+
await expect.element(page.getByRole('button', { name: 'Expect me!' })).toBeInTheDocument()
62+
})
63+
64+
it('userEvent.type advances fake timers while waiting for the element', async () => {
65+
vi.useFakeTimers()
66+
67+
setTimeout(() => {
68+
const input = document.createElement('input')
69+
input.setAttribute('aria-label', 'Type here')
70+
document.body.append(input)
71+
}, 100)
72+
73+
const input = page.getByRole('textbox', { name: 'Type here' })
74+
await userEvent.type(input, 'hello')
75+
await expect.element(input).toHaveValue('hello')
76+
})
77+
78+
it('userEvent.pointer advances fake timers while waiting for the target', async () => {
79+
vi.useFakeTimers()
80+
81+
const onClick = vi.fn()
82+
setTimeout(() => {
83+
const button = document.createElement('button')
84+
button.textContent = 'Point me!'
85+
button.addEventListener('click', onClick)
86+
document.body.append(button)
87+
}, 100)
88+
89+
await userEvent.pointer({
90+
keys: '[MouseLeft]',
91+
target: page.getByRole('button', { name: 'Point me!' }),
92+
})
93+
expect(onClick).toHaveBeenCalledTimes(1)
94+
})
95+
96+
it('locator.wheel advances fake timers while waiting for the element', async () => {
97+
vi.useFakeTimers()
98+
99+
const onWheel = vi.fn()
100+
setTimeout(() => {
101+
const div = document.createElement('div')
102+
div.textContent = 'Scroll me'
103+
div.addEventListener('wheel', onWheel)
104+
document.body.append(div)
105+
}, 100)
106+
107+
await page.getByText('Scroll me').wheel({ direction: 'down' })
108+
expect(onWheel).toHaveBeenCalled()
109+
})
110+
111+
it('does not advance fake timers past the action timeout', async () => {
112+
vi.useFakeTimers()
113+
114+
setTimeout(() => {
115+
const button = document.createElement('button')
116+
button.textContent = 'Too late'
117+
document.body.append(button)
118+
}, 5000)
119+
120+
const button = page.getByRole('button', { name: 'Too late' })
121+
await expect(button.click({ timeout: 100 })).rejects.toThrow('Timeout 100ms exceeded')
122+
expect(vi.getTimerCount()).toBe(1)
123+
await expect(button.findElement({ timeout: 100 })).rejects.toThrow('Cannot find element')
124+
expect(vi.getTimerCount()).toBe(1)
125+
await expect(expect.element(button, { timeout: 100 }).toBeInTheDocument()).rejects.toThrow()
126+
expect(vi.getTimerCount()).toBe(1)
127+
})

0 commit comments

Comments
 (0)