Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/setup/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {userEventApi} from './api'
import {wrapAsync} from './wrapAsync'
import {DirectOptions} from './directApi'

import {defaultAdvanceTimers} from '../utils/misc/timerDetection'

/**
* Default options applied when API is called per `userEvent.anyApi()`
*/
Expand All @@ -32,7 +34,7 @@ const defaultOptionsDirect: Required<Options> = {
skipClick: false,
skipHover: false,
writeToClipboard: false,
advanceTimers: () => Promise.resolve(),
advanceTimers: defaultAdvanceTimers,
}

/**
Expand Down Expand Up @@ -146,9 +148,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,
Expand Down
46 changes: 46 additions & 0 deletions src/utils/misc/timerDetection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Default advanceTimers implementation (no-op).
* Exported so it can be checked for equality in wait.ts
* and referenced as the default in setup.ts.
*/
export const defaultAdvanceTimers = () => Promise.resolve()

interface JestGlobal {
advanceTimersByTime: (ms: number) => void
}

interface VitestGlobal {
advanceTimersByTime: (ms: number) => void | Promise<void>
}

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<void>) |
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
}
12 changes: 10 additions & 2 deletions src/utils/misc/wait.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import {type Instance} from '../../setup'
import {type Instance} from '../../setup/setup'
import {defaultAdvanceTimers, getTimerAdvancer} from './timerDetection'

export function wait(config: Instance['config']) {
const delay = config.delay
if (typeof delay !== 'number') {
return
}

// If user hasn't configured advanceTimers (still using default), try to auto-detect
let advanceTimers = config.advanceTimers
if (advanceTimers === defaultAdvanceTimers) {
advanceTimers = getTimerAdvancer() ?? advanceTimers
}

return Promise.all([
new Promise<void>(resolve => globalThis.setTimeout(() => resolve(), delay)),
config.advanceTimers(delay),
advanceTimers(delay),
])
}
1 change: 1 addition & 0 deletions tests/setup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions tests/utils/misc/timerDetection.ts
Original file line number Diff line number Diff line change
@@ -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
})
})
113 changes: 113 additions & 0 deletions tests/utils/misc/wait.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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)