diff --git a/src/setup/setup.ts b/src/setup/setup.ts index 9475a4c2..62a98817 100644 --- a/src/setup/setup.ts +++ b/src/setup/setup.ts @@ -16,6 +16,9 @@ import {System} from '../system' import {userEventApi} from './api' import {wrapAsync} from './wrapAsync' import {DirectOptions} from './directApi' +import {getTimerAdvancer} from '../utils/misc/timerDetection' + +const noopAdvanceTimers = () => Promise.resolve() /** * Default options applied when API is called per `userEvent.anyApi()` @@ -32,7 +35,7 @@ const defaultOptionsDirect: Required = { skipClick: false, skipHover: false, writeToClipboard: false, - advanceTimers: () => Promise.resolve(), + advanceTimers: noopAdvanceTimers, } /** @@ -69,10 +72,13 @@ export function createConfig( node?: Node, ): Config { const document = getDocument(options, node, defaults) + const advanceTimers = + options.advanceTimers ?? getTimerAdvancer() ?? defaults.advanceTimers return { ...defaults, ...options, + advanceTimers, document, } } @@ -146,9 +152,9 @@ export function createInstance( config: Config, system: System = new System(), ): { - instance: Instance - api: UserEvent - } { + instance: Instance + api: UserEvent +} { const instance = {} as Instance Object.assign(instance, { config, diff --git a/src/utils/misc/timerDetection.ts b/src/utils/misc/timerDetection.ts new file mode 100644 index 00000000..091cc4b5 --- /dev/null +++ b/src/utils/misc/timerDetection.ts @@ -0,0 +1,39 @@ +interface JestGlobal { + advanceTimersByTime: (ms: number) => void +} + +interface VitestGlobal { + advanceTimersByTime: (ms: number) => void | Promise +} + +type GlobalWithTimers = typeof globalThis & { + jest?: JestGlobal + vi?: VitestGlobal +} + +/** + * Gets the timer advancement function from the detected testing framework, if any. + * + * Checks for `globalThis.jest` (Jest) and `globalThis.vi` (Vitest) globals. + * When both are present, Jest takes precedence for backward compatibility. + * + * Note: This detects the presence of the framework global, not whether + * fake timers are currently active. Calling `advanceTimersByTime` with + * real timers is a no-op in both Jest and Vitest. + * + * @returns A bound function that advances fake timers, or null if no framework detected + */ +export function getTimerAdvancer(): ((ms: number) => void | Promise) | + null { + const g = globalThis as GlobalWithTimers + + if (g.jest && typeof g.jest.advanceTimersByTime === 'function') { + return g.jest.advanceTimersByTime.bind(g.jest) + } + + if (g.vi && typeof g.vi.advanceTimersByTime === 'function') { + return g.vi.advanceTimersByTime.bind(g.vi) + } + + return null +} 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..d4e330d0 --- /dev/null +++ b/tests/utils/misc/timerDetection.ts @@ -0,0 +1,62 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import {getTimerAdvancer} from '../../../src/utils/misc/timerDetection' + +describe('getTimerAdvancer', () => { + it('returns null when no framework global is present', () => { + expect(getTimerAdvancer()).toBe(null) + }) + + it('returns Jest advanceTimersByTime when jest global is present', () => { + const advanceTimersByTime = jest.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).jest = {advanceTimersByTime} + + const advancer = getTimerAdvancer() + expect(advancer).toBeDefined() + + // 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 vi global is present', () => { + const advanceTimersByTime = jest.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).vi = {advanceTimersByTime} + + const advancer = getTimerAdvancer() + expect(advancer).toBeDefined() + + // 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 + }) + + it('prefers Jest over Vitest when both globals are present', () => { + const jestAdvance = jest.fn() + const viAdvance = jest.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).jest = {advanceTimersByTime: jestAdvance} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).vi = {advanceTimersByTime: viAdvance} + + const advancer = getTimerAdvancer() + // eslint-disable-next-line @typescript-eslint/no-floating-promises + advancer?.(300) + + expect(jestAdvance).toHaveBeenCalledWith(300) + expect(viAdvance).not.toHaveBeenCalled() + + // 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 + }) +}) diff --git a/tests/utils/misc/wait.ts b/tests/utils/misc/wait.ts index 36efb877..1a0db16a 100644 --- a/tests/utils/misc/wait.ts +++ b/tests/utils/misc/wait.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ 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)