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
8 changes: 7 additions & 1 deletion src/setup/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()`
*/
Expand All @@ -32,7 +38,7 @@ const defaultOptionsDirect: Required<Options> = {
skipClick: false,
skipHover: false,
writeToClipboard: false,
advanceTimers: () => Promise.resolve(),
advanceTimers: defaultAdvanceTimers,
}

/**
Expand Down
66 changes: 66 additions & 0 deletions src/utils/misc/timerDetection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
interface JestGlobal {
advanceTimersByTime: (ms: number) => void
}

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

/**
* 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<void>)
| 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
}
}
21 changes: 19 additions & 2 deletions src/utils/misc/wait.ts
Original file line number Diff line number Diff line change
@@ -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<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
94 changes: 94 additions & 0 deletions tests/utils/misc/timerDetection.ts
Original file line number Diff line number Diff line change
@@ -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
})
})
})
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-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
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)