From 66cbd51f975254d7175f617aa7194b7fb7b7c4b0 Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Sat, 4 Oct 2025 21:08:48 -0400 Subject: [PATCH] feat: automatically advance fake timers in Vitest --- src/setup/setup.ts | 8 +- src/utils/misc/timerDetection.ts | 66 +++++++++++++++++ src/utils/misc/wait.ts | 21 +++++- tests/setup/index.ts | 1 + tests/utils/misc/timerDetection.ts | 94 ++++++++++++++++++++++++ tests/utils/misc/wait.ts | 113 +++++++++++++++++++++++++++++ 6 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 src/utils/misc/timerDetection.ts create mode 100644 tests/utils/misc/timerDetection.ts diff --git a/src/setup/setup.ts b/src/setup/setup.ts index 9475a4c2..e39fe5e8 100644 --- a/src/setup/setup.ts +++ b/src/setup/setup.ts @@ -17,6 +17,12 @@ import {userEventApi} from './api' import {wrapAsync} from './wrapAsync' import {DirectOptions} from './directApi' +/** + * Default advanceTimers implementation (no-op). + * Exported so it can be checked for equality in wait.ts + */ +export const defaultAdvanceTimers = () => Promise.resolve() + /** * Default options applied when API is called per `userEvent.anyApi()` */ @@ -32,7 +38,7 @@ const defaultOptionsDirect: Required = { skipClick: false, skipHover: false, writeToClipboard: false, - advanceTimers: () => Promise.resolve(), + advanceTimers: defaultAdvanceTimers, } /** diff --git a/src/utils/misc/timerDetection.ts b/src/utils/misc/timerDetection.ts new file mode 100644 index 00000000..9332455c --- /dev/null +++ b/src/utils/misc/timerDetection.ts @@ -0,0 +1,66 @@ +interface JestGlobal { + advanceTimersByTime: (ms: number) => void +} + +interface VitestGlobal { + advanceTimersByTime: (ms: number) => void | Promise +} + +/** + * Detects which testing framework's fake timers are active, if any. + * + * @returns The name of the detected framework ('jest', 'vitest') or null if no fake timers detected + */ +export function detectFakeTimers(): 'jest' | 'vitest' | null { + // Method 1: Check for Jest global with advanceTimersByTime + // Jest exposes a global `jest` object when tests are running + if ( + 'jest' in globalThis && + typeof (globalThis as typeof globalThis & {jest: JestGlobal}).jest + .advanceTimersByTime === 'function' + ) { + return 'jest' + } + + // Method 2: Check for Vitest global with advanceTimersByTime + // Vitest exposes a global `vi` object when tests are running + if ( + 'vi' in globalThis && + typeof (globalThis as typeof globalThis & {vi: VitestGlobal}).vi + .advanceTimersByTime === 'function' + ) { + return 'vitest' + } + + // If neither Jest nor Vitest globals are detected, we can't auto-detect + // The user will need to configure advanceTimers manually + return null +} + +/** + * Gets the appropriate timer advancement function for the detected testing framework. + * + * @returns A function that advances fake timers, or null if no suitable function found + */ +export function getTimerAdvancer(): + | ((ms: number) => void | Promise) + | null { + const framework = detectFakeTimers() + + switch (framework) { + case 'jest': + // Jest's advanceTimersByTime is synchronous + return (globalThis as typeof globalThis & {jest: JestGlobal}).jest + .advanceTimersByTime.bind( + (globalThis as typeof globalThis & {jest: JestGlobal}).jest, + ) + case 'vitest': + // Vitest's advanceTimersByTime is also synchronous + return (globalThis as typeof globalThis & {vi: VitestGlobal}).vi + .advanceTimersByTime.bind( + (globalThis as typeof globalThis & {vi: VitestGlobal}).vi, + ) + default: + return null + } +} diff --git a/src/utils/misc/wait.ts b/src/utils/misc/wait.ts index 7a8ffa91..4cfc0244 100644 --- a/src/utils/misc/wait.ts +++ b/src/utils/misc/wait.ts @@ -1,12 +1,29 @@ -import {type Instance} from '../../setup' +import {defaultAdvanceTimers, type Instance} from '../../setup/setup' +import {detectFakeTimers, getTimerAdvancer} from './timerDetection' export function wait(config: Instance['config']) { const delay = config.delay if (typeof delay !== 'number') { return } + + // Determine which advanceTimers function to use + let advanceTimers = config.advanceTimers + + // If user hasn't configured advanceTimers (still using default), try to auto-detect + if (advanceTimers === defaultAdvanceTimers) { + const detectedFramework = detectFakeTimers() + + if (detectedFramework) { + const autoAdvancer = getTimerAdvancer() + if (autoAdvancer) { + advanceTimers = autoAdvancer + } + } + } + return Promise.all([ new Promise(resolve => globalThis.setTimeout(() => resolve(), delay)), - config.advanceTimers(delay), + advanceTimers(delay), ]) } diff --git a/tests/setup/index.ts b/tests/setup/index.ts index 3cdb36fa..89918aea 100644 --- a/tests/setup/index.ts +++ b/tests/setup/index.ts @@ -105,6 +105,7 @@ test.each(apiDeclarationsEntries)( const apis = userEvent.setup({[opt]: true}) + // eslint-disable-next-line testing-library/await-async-events expect(apis[name]).toHaveProperty('name', `mock-${name}`) // Replace the asyncWrapper to make sure that a delayed state update happens inside of it diff --git a/tests/utils/misc/timerDetection.ts b/tests/utils/misc/timerDetection.ts new file mode 100644 index 00000000..698961f1 --- /dev/null +++ b/tests/utils/misc/timerDetection.ts @@ -0,0 +1,94 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { + detectFakeTimers, + getTimerAdvancer, +} from '../../../src/utils/misc/timerDetection' + +describe('timerDetection', () => { + describe('detectFakeTimers', () => { + it('returns null when no fake timers are active', () => { + expect(detectFakeTimers()).toBe(null) + }) + + it('detects Jest fake timers', () => { + const mockJest = { + advanceTimersByTime: jest.fn(), + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).jest = mockJest + + expect(detectFakeTimers()).toBe('jest') + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).jest + }) + + it('detects Vitest fake timers', () => { + const mockVi = { + advanceTimersByTime: jest.fn(), + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).vi = mockVi + + expect(detectFakeTimers()).toBe('vitest') + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).vi + }) + + it('prefers Jest over Vitest when both are present', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).jest = {advanceTimersByTime: jest.fn()} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).vi = {advanceTimersByTime: jest.fn()} + + expect(detectFakeTimers()).toBe('jest') + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).vi + }) + }) + + describe('getTimerAdvancer', () => { + it('returns null when no fake timers are active', () => { + expect(getTimerAdvancer()).toBe(null) + }) + + it('returns Jest advanceTimersByTime when Jest fake timers are active', () => { + const advanceTimersByTime = jest.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).jest = {advanceTimersByTime} + + const advancer = getTimerAdvancer() + expect(advancer).toBeDefined() + + // Verify it calls the Jest function + // eslint-disable-next-line @typescript-eslint/no-floating-promises + advancer?.(100) + expect(advanceTimersByTime).toHaveBeenCalledWith(100) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).jest + }) + + it('returns Vitest advanceTimersByTime when Vitest fake timers are active', () => { + const advanceTimersByTime = jest.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).vi = {advanceTimersByTime} + + const advancer = getTimerAdvancer() + expect(advancer).toBeDefined() + + // Verify it calls the Vitest function + // eslint-disable-next-line @typescript-eslint/no-floating-promises + advancer?.(200) + expect(advanceTimersByTime).toHaveBeenCalledWith(200) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).vi + }) + }) +}) diff --git a/tests/utils/misc/wait.ts b/tests/utils/misc/wait.ts index 36efb877..46e6d6b2 100644 --- a/tests/utils/misc/wait.ts +++ b/tests/utils/misc/wait.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import {createConfig} from '#src/setup/setup' import {wait} from '#src/utils/misc/wait' @@ -16,3 +18,114 @@ test('advances timers when set', async () => { timers.useRealTimers() expect(performance.now() - beforeReal).toBeLessThan(1000) }, 10) + +test('auto-detects Jest fake timers', async () => { + const beforeReal = performance.now() + + // Simulate Jest fake timers + timers.useFakeTimers() + const beforeFake = performance.now() + + // Mock the Jest global + + const mockAdvanceTimersByTime = jest.fn((ms: number) => { + timers.advanceTimersByTime(ms) + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).jest = { + advanceTimersByTime: mockAdvanceTimersByTime, + } + + // Don't configure advanceTimers - should auto-detect + const config = createConfig({ + delay: 500, + }) + + await wait(config) + + // Verify auto-detection worked + expect(mockAdvanceTimersByTime).toHaveBeenCalledWith(500) + expect(performance.now() - beforeFake).toBe(500) + + // Cleanup + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).jest + timers.useRealTimers() + expect(performance.now() - beforeReal).toBeLessThan(1000) +}, 10) + +test('auto-detects Vitest fake timers', async () => { + const beforeReal = performance.now() + + // Simulate Vitest fake timers + timers.useFakeTimers() + const beforeFake = performance.now() + + // Temporarily hide Jest global to test Vitest detection + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const originalJest = (globalThis as any).jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).jest + + // Mock the Vitest global + + const mockAdvanceTimersByTime = jest.fn((ms: number) => { + timers.advanceTimersByTime(ms) + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).vi = { + advanceTimersByTime: mockAdvanceTimersByTime, + } + + // Don't configure advanceTimers - should auto-detect + const config = createConfig({ + delay: 750, + }) + + await wait(config) + + // Verify auto-detection worked + expect(mockAdvanceTimersByTime).toHaveBeenCalledWith(750) + expect(performance.now() - beforeFake).toBe(750) + + // Cleanup + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).vi + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).jest = originalJest + timers.useRealTimers() + expect(performance.now() - beforeReal).toBeLessThan(1000) +}, 10) + +test('manual configuration takes precedence over auto-detection', async () => { + timers.useFakeTimers() + + // Mock the Vitest global + + const autoDetectedAdvance = jest.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).vi = { + advanceTimersByTime: autoDetectedAdvance, + } + + // Provide manual configuration + + const manualAdvance = jest.fn((ms: number) => { + timers.advanceTimersByTime(ms) + }) + const config = createConfig({ + delay: 100, + advanceTimers: manualAdvance, + }) + + await wait(config) + + // Manual configuration should be used, not auto-detected + expect(manualAdvance).toHaveBeenCalledWith(100) + expect(autoDetectedAdvance).not.toHaveBeenCalled() + + // Cleanup + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).vi + timers.useRealTimers() +}, 10)