diff --git a/docs/api/browser/assertions.md b/docs/api/browser/assertions.md index bc938d5d2..46952bce2 100644 --- a/docs/api/browser/assertions.md +++ b/docs/api/browser/assertions.md @@ -55,7 +55,7 @@ interface ExpectPollOptions { ``` ::: tip -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. +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. `toMatchTextContent` and all other assertions are still available on a regular `expect` without a built-in retry-ability mechanism: diff --git a/docs/api/browser/locators.md b/docs/api/browser/locators.md index c26275ba6..07c337a15 100644 --- a/docs/api/browser/locators.md +++ b/docs/api/browser/locators.md @@ -620,6 +620,10 @@ This options narrows down the selector to only match elements that do not contai All methods are asynchronous and must be awaited. Since Vitest 3, tests will fail if a method is not awaited. +::: tip +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. +::: + ### click ```ts diff --git a/docs/api/expect.md b/docs/api/expect.md index 91971aedd..407f30811 100644 --- a/docs/api/expect.md +++ b/docs/api/expect.md @@ -118,6 +118,8 @@ The callback receives an `AbortSignal` that is aborted when the poll timeout is If an error is thrown inside the `expect.poll` callback, Vitest will retry again until the timeout runs out. +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. + ```ts import { expect, test } from 'vitest' diff --git a/docs/api/vi.md b/docs/api/vi.md index 953ef15da..174624449 100644 --- a/docs/api/vi.md +++ b/docs/api/vi.md @@ -1231,7 +1231,7 @@ Controls how fake timers are advanced. - `nextTimerAsync`: Timers will be advanced automatically to the next available timer after each macrotask. - `interval`: Timers are advanced automatically by a specified interval. -When `mode` is `'interval'`, you can also provide an `interval` in milliseconds. +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. **Example:** @@ -1346,7 +1346,7 @@ test('Element exists in a DOM', async () => { }) ``` -If `vi.useFakeTimers` is used, `vi.waitFor` automatically calls `vi.advanceTimersByTime(interval)` in every check callback. +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. ### vi.waitUntil {#vi-waituntil} @@ -1378,6 +1378,8 @@ test('Element render correctly', async () => { }) ``` +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. + ### vi.hoisted {#vi-hoisted} ```ts diff --git a/docs/guide/mocking/timers.md b/docs/guide/mocking/timers.md index 29a33c88d..599ce1924 100644 --- a/docs/guide/mocking/timers.md +++ b/docs/guide/mocking/timers.md @@ -46,3 +46,18 @@ describe('delayed execution', () => { }) }) ``` + +## Automatic Advancing {#automatic-advancing} + +Some Vitest APIs wait for a condition to be met. When fake timers are enabled, these APIs advance them while they run: + +- [`vi.waitFor`](/api/vi#vi-waitfor) and [`vi.waitUntil`](/api/vi#vi-waituntil) advance timers by `interval` before every check. +- [`expect.poll`](/api/expect#poll) advances timers by `interval` after every failed attempt. +- 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. + +## Tick Modes {#tick-modes} + +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: + +- `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. +- `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. diff --git a/packages/browser/src/client/tester/action.ts b/packages/browser/src/client/tester/action.ts index b9c6159c8..88556fbd7 100644 --- a/packages/browser/src/client/tester/action.ts +++ b/packages/browser/src/client/tester/action.ts @@ -1,4 +1,6 @@ import type { SerializedLocator } from './locators' +import { vi } from 'vitest' +import { getSafeTimers } from 'vitest/internal/browser' import { getBrowserState, getWorkerState } from '../utils' export interface ActionOptions { @@ -30,6 +32,26 @@ export function processTimeoutOptions(options?: return { ...options, timeout } as T } +const FAKE_TIMERS_TICK = 50 + +/** + * Wraps provider commands that wait for an element. The provider waits in real time, + * so fake timers must keep moving for the page to render it. Commands that act right + * away, such as `keyboard` and `tab`, are not wrapped. + */ +export function advanceFakeTimersWhilePending(promise: Promise): Promise { + if (!vi.isFakeTimers()) { + return promise + } + const { setInterval, clearInterval } = getSafeTimers() + const interval = setInterval(() => { + if (vi.isFakeTimers()) { + vi.advanceTimersByTime(FAKE_TIMERS_TICK) + } + }, FAKE_TIMERS_TICK) + return promise.finally(() => clearInterval(interval)) +} + /** * A browser command that the test awaits. The action derives its own timeout * from the running task, so it fails with a descriptive error before the task does. @@ -75,10 +97,8 @@ class Action implements Promise { const options = timeout == null ? this.#options : { ...this.#options, timeout } const args = typeof this.#args === 'function' ? await this.#args(options) : [...this.#args, options] - const promise = getBrowserState().commands.triggerCommand( - this.#command, - args, - this.#errorSource, + const promise = advanceFakeTimersWhilePending( + getBrowserState().commands.triggerCommand(this.#command, args, this.#errorSource), ) const deadline = getBrowserState().runner._deadline return deadline && timeout != null diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 3b8fc15ee..d18187c31 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -22,7 +22,7 @@ import type { BrowserTraceEntryStatus } from './trace' import { vi } from 'vitest' import { __INTERNAL, stringify } from 'vitest/internal/browser' import { ensureAwaited, getBrowserState, getWorkerState } from '../utils' -import { ScreenshotAction } from './action' +import { advanceFakeTimersWhilePending, ScreenshotAction } from './action' import { getIframeOffset, getIframeScale, @@ -123,14 +123,16 @@ export function createUserEvent( }), ) - const { coords, target, unreleased } = await triggerCommand< - PointerState & { - unreleased: string[] - } - >('__vitest_pointer', [ - serializedInputArray, - { ...pointerState, unreleased: keyboard.unreleased }, - ]) + const { coords, target, unreleased } = await advanceFakeTimersWhilePending( + triggerCommand< + PointerState & { + unreleased: string[] + } + >('__vitest_pointer', [ + serializedInputArray, + { ...pointerState, unreleased: keyboard.unreleased }, + ]), + ) pointerState.target = target pointerState.coords = coords @@ -167,10 +169,12 @@ export function createUserEvent( type(element, text, options) { return ensureAwaited(async (error) => { const serializedElement = await serializeElement(element, options) - const { unreleased } = await triggerCommand<{ unreleased: string[] }>( - '__vitest_type', - [serializedElement, text, { ...options, unreleased: keyboard.unreleased }], - error, + const { unreleased } = await advanceFakeTimersWhilePending( + triggerCommand<{ unreleased: string[] }>( + '__vitest_type', + [serializedElement, text, { ...options, unreleased: keyboard.unreleased }], + error, + ), ) keyboard.unreleased = unreleased }) diff --git a/packages/browser/src/client/tester/locators.ts b/packages/browser/src/client/tester/locators.ts index 8d9c0cd44..cc165b5e4 100644 --- a/packages/browser/src/client/tester/locators.ts +++ b/packages/browser/src/client/tester/locators.ts @@ -26,10 +26,16 @@ import { getByTitleSelector, Ivya, } from 'ivya' +import { vi } from 'vitest' import { page, server, utils } from 'vitest/browser' import { __INTERNAL, getSafeTimers } from 'vitest/internal/browser' import { ensureAwaited, getBrowserState, getWorkerState } from '../utils' -import { LocatorAction, resolveActionTimeout, UploadAction } from './action' +import { + advanceFakeTimersWhilePending, + LocatorAction, + resolveActionTimeout, + UploadAction, +} from './action' import { convertElementToCssSelector, escapeForTextSelector, @@ -113,21 +119,25 @@ export abstract class Locator { public wheel(options: UserEventWheelOptions): Promise { return ensureAwaited(async (error) => { - await getBrowserState().commands.triggerCommand( - '__vitest_wheel', - [this.serialize(), resolveUserEventWheelOptions(options)], - error, + await advanceFakeTimersWhilePending( + getBrowserState().commands.triggerCommand( + '__vitest_wheel', + [this.serialize(), resolveUserEventWheelOptions(options)], + error, + ), ) const browser = getBrowserState().config.browser.name // looks like on Chromium the scroll event gets dispatched a frame later if (browser === 'chromium' || browser === 'chrome') { - return new Promise((resolve) => { - requestAnimationFrame(() => { - resolve() - }) - }) + return advanceFakeTimersWhilePending( + new Promise((resolve) => { + requestAnimationFrame(() => { + resolve() + }) + }), + ) } }) } @@ -396,6 +406,9 @@ export abstract class Locator { } const interval = waitForIntervals[Math.min(intervalIndex++, waitForIntervals.length - 1)] const nextInterval = timeout != null ? Math.min(interval, timeout - elapsed) : interval + if (vi.isFakeTimers()) { + vi.advanceTimersByTime(nextInterval) + } await sleep(nextInterval) } } diff --git a/test/browser/test/timers.test.ts b/test/browser/test/timers.test.ts index c2e30b7d9..c558f7a32 100644 --- a/test/browser/test/timers.test.ts +++ b/test/browser/test/timers.test.ts @@ -1,7 +1,9 @@ import { afterEach, expect, it, vi } from 'vitest' +import { page, userEvent } from 'vitest/browser' afterEach(() => { vi.useRealTimers() + document.body.innerHTML = '' }) it('only runs a setTimeout callback once (ever)', () => { @@ -17,3 +19,109 @@ it('only runs a setTimeout callback once (ever)', () => { vi.runAllTimers() expect(fn).toHaveBeenCalledTimes(1) }) + +it('locator actions advance fake timers while waiting for the element', async () => { + vi.useFakeTimers() + + const onClick = vi.fn() + setTimeout(() => { + const button = document.createElement('button') + button.textContent = 'Click me!' + button.addEventListener('click', onClick) + document.body.append(button) + }, 100) + + await page.getByRole('button', { name: 'Click me!' }).click() + expect(onClick).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) +}) + +it('locator.findElement advances fake timers while waiting for the element', async () => { + vi.useFakeTimers() + + setTimeout(() => { + const button = document.createElement('button') + button.textContent = 'Find me!' + document.body.append(button) + }, 100) + + const element = await page.getByRole('button', { name: 'Find me!' }).findElement() + expect(element).toHaveTextContent('Find me!') +}) + +it('expect.element advances fake timers while waiting for the element', async () => { + vi.useFakeTimers() + + setTimeout(() => { + const button = document.createElement('button') + button.textContent = 'Expect me!' + document.body.append(button) + }, 100) + + await expect.element(page.getByRole('button', { name: 'Expect me!' })).toBeInTheDocument() +}) + +it('userEvent.type advances fake timers while waiting for the element', async () => { + vi.useFakeTimers() + + setTimeout(() => { + const input = document.createElement('input') + input.setAttribute('aria-label', 'Type here') + document.body.append(input) + }, 100) + + const input = page.getByRole('textbox', { name: 'Type here' }) + await userEvent.type(input, 'hello') + await expect.element(input).toHaveValue('hello') +}) + +it('userEvent.pointer advances fake timers while waiting for the target', async () => { + vi.useFakeTimers() + + const onClick = vi.fn() + setTimeout(() => { + const button = document.createElement('button') + button.textContent = 'Point me!' + button.addEventListener('click', onClick) + document.body.append(button) + }, 100) + + await userEvent.pointer({ + keys: '[MouseLeft]', + target: page.getByRole('button', { name: 'Point me!' }), + }) + expect(onClick).toHaveBeenCalledTimes(1) +}) + +it('locator.wheel advances fake timers while waiting for the element', async () => { + vi.useFakeTimers() + + const onWheel = vi.fn() + setTimeout(() => { + const div = document.createElement('div') + div.textContent = 'Scroll me' + div.addEventListener('wheel', onWheel) + document.body.append(div) + }, 100) + + await page.getByText('Scroll me').wheel({ direction: 'down' }) + expect(onWheel).toHaveBeenCalled() +}) + +it('does not advance fake timers past the action timeout', async () => { + vi.useFakeTimers() + + setTimeout(() => { + const button = document.createElement('button') + button.textContent = 'Too late' + document.body.append(button) + }, 5000) + + const button = page.getByRole('button', { name: 'Too late' }) + await expect(button.click({ timeout: 100 })).rejects.toThrow('Timeout 100ms exceeded') + expect(vi.getTimerCount()).toBe(1) + await expect(button.findElement({ timeout: 100 })).rejects.toThrow('Cannot find element') + expect(vi.getTimerCount()).toBe(1) + await expect(expect.element(button, { timeout: 100 }).toBeInTheDocument()).rejects.toThrow() + expect(vi.getTimerCount()).toBe(1) +})