Skip to content

Commit 2e77fcc

Browse files
committed
feat: automatically advance fake timers in Vitest
1 parent ebab6c6 commit 2e77fcc

File tree

6 files changed

+310
-6
lines changed

6 files changed

+310
-6
lines changed

src/setup/setup.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {userEventApi} from './api'
1717
import {wrapAsync} from './wrapAsync'
1818
import {DirectOptions} from './directApi'
1919

20+
import {defaultAdvanceTimers} from '../utils/misc/timerDetection'
21+
2022
/**
2123
* Default options applied when API is called per `userEvent.anyApi()`
2224
*/
@@ -32,7 +34,7 @@ const defaultOptionsDirect: Required<Options> = {
3234
skipClick: false,
3335
skipHover: false,
3436
writeToClipboard: false,
35-
advanceTimers: () => Promise.resolve(),
37+
advanceTimers: defaultAdvanceTimers,
3638
}
3739

3840
/**
@@ -146,9 +148,9 @@ export function createInstance(
146148
config: Config,
147149
system: System = new System(),
148150
): {
149-
instance: Instance
150-
api: UserEvent
151-
} {
151+
instance: Instance
152+
api: UserEvent
153+
} {
152154
const instance = {} as Instance
153155
Object.assign(instance, {
154156
config,

src/utils/misc/timerDetection.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Default advanceTimers implementation (no-op).
3+
* Exported so it can be checked for equality in wait.ts
4+
* and referenced as the default in setup.ts.
5+
*/
6+
export const defaultAdvanceTimers = () => Promise.resolve()
7+
8+
interface JestGlobal {
9+
advanceTimersByTime: (ms: number) => void
10+
}
11+
12+
interface VitestGlobal {
13+
advanceTimersByTime: (ms: number) => void | Promise<void>
14+
}
15+
16+
/**
17+
* Detects which testing framework's fake timers are active, if any.
18+
*
19+
* @returns The name of the detected framework ('jest', 'vitest') or null if no fake timers detected
20+
*/
21+
export function detectFakeTimers(): 'jest' | 'vitest' | null {
22+
// Method 1: Check for Jest global with advanceTimersByTime
23+
// Jest exposes a global `jest` object when tests are running
24+
if (
25+
'jest' in globalThis &&
26+
typeof (globalThis as typeof globalThis & {jest: JestGlobal}).jest
27+
.advanceTimersByTime === 'function'
28+
) {
29+
return 'jest'
30+
}
31+
32+
// Method 2: Check for Vitest global with advanceTimersByTime
33+
// Vitest exposes a global `vi` object when tests are running
34+
if (
35+
'vi' in globalThis &&
36+
typeof (globalThis as typeof globalThis & {vi: VitestGlobal}).vi
37+
.advanceTimersByTime === 'function'
38+
) {
39+
return 'vitest'
40+
}
41+
42+
// If neither Jest nor Vitest globals are detected, we can't auto-detect
43+
// The user will need to configure advanceTimers manually
44+
return null
45+
}
46+
47+
/**
48+
* Gets the appropriate timer advancement function for the detected testing framework.
49+
*
50+
* @returns A function that advances fake timers, or null if no suitable function found
51+
*/
52+
export function getTimerAdvancer():
53+
| ((ms: number) => void | Promise<void>) |
54+
null {
55+
const framework = detectFakeTimers()
56+
57+
switch (framework) {
58+
case 'jest':
59+
// Jest's advanceTimersByTime is synchronous
60+
return (globalThis as typeof globalThis & {jest: JestGlobal}).jest
61+
.advanceTimersByTime.bind(
62+
(globalThis as typeof globalThis & {jest: JestGlobal}).jest,
63+
)
64+
case 'vitest':
65+
// Vitest's advanceTimersByTime is also synchronous
66+
return (globalThis as typeof globalThis & {vi: VitestGlobal}).vi
67+
.advanceTimersByTime.bind(
68+
(globalThis as typeof globalThis & {vi: VitestGlobal}).vi,
69+
)
70+
default:
71+
return null
72+
}
73+
}

src/utils/misc/wait.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,33 @@
1-
import {type Instance} from '../../setup'
1+
import {type Instance} from '../../setup/setup'
2+
import {
3+
defaultAdvanceTimers,
4+
detectFakeTimers,
5+
getTimerAdvancer,
6+
} from './timerDetection'
27

38
export function wait(config: Instance['config']) {
49
const delay = config.delay
510
if (typeof delay !== 'number') {
611
return
712
}
13+
14+
// Determine which advanceTimers function to use
15+
let advanceTimers = config.advanceTimers
16+
17+
// If user hasn't configured advanceTimers (still using default), try to auto-detect
18+
if (advanceTimers === defaultAdvanceTimers) {
19+
const detectedFramework = detectFakeTimers()
20+
21+
if (detectedFramework) {
22+
const autoAdvancer = getTimerAdvancer()
23+
if (autoAdvancer) {
24+
advanceTimers = autoAdvancer
25+
}
26+
}
27+
}
28+
829
return Promise.all([
930
new Promise<void>(resolve => globalThis.setTimeout(() => resolve(), delay)),
10-
config.advanceTimers(delay),
31+
advanceTimers(delay),
1132
])
1233
}

tests/setup/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ test.each(apiDeclarationsEntries)(
105105

106106
const apis = userEvent.setup({[opt]: true})
107107

108+
// eslint-disable-next-line testing-library/await-async-events
108109
expect(apis[name]).toHaveProperty('name', `mock-${name}`)
109110

110111
// Replace the asyncWrapper to make sure that a delayed state update happens inside of it

tests/utils/misc/timerDetection.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
2+
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
3+
import {
4+
detectFakeTimers,
5+
getTimerAdvancer,
6+
} from '../../../src/utils/misc/timerDetection'
7+
8+
describe('timerDetection', () => {
9+
describe('detectFakeTimers', () => {
10+
it('returns null when no fake timers are active', () => {
11+
expect(detectFakeTimers()).toBe(null)
12+
})
13+
14+
it('detects Jest fake timers', () => {
15+
const mockJest = {
16+
advanceTimersByTime: jest.fn(),
17+
}
18+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
19+
;(globalThis as any).jest = mockJest
20+
21+
expect(detectFakeTimers()).toBe('jest')
22+
23+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
24+
delete (globalThis as any).jest
25+
})
26+
27+
it('detects Vitest fake timers', () => {
28+
const mockVi = {
29+
advanceTimersByTime: jest.fn(),
30+
}
31+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
32+
;(globalThis as any).vi = mockVi
33+
34+
expect(detectFakeTimers()).toBe('vitest')
35+
36+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
37+
delete (globalThis as any).vi
38+
})
39+
40+
it('prefers Jest over Vitest when both are present', () => {
41+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
42+
;(globalThis as any).jest = {advanceTimersByTime: jest.fn()}
43+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
44+
;(globalThis as any).vi = {advanceTimersByTime: jest.fn()}
45+
46+
expect(detectFakeTimers()).toBe('jest')
47+
48+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
49+
delete (globalThis as any).jest
50+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
51+
delete (globalThis as any).vi
52+
})
53+
})
54+
55+
describe('getTimerAdvancer', () => {
56+
it('returns null when no fake timers are active', () => {
57+
expect(getTimerAdvancer()).toBe(null)
58+
})
59+
60+
it('returns Jest advanceTimersByTime when Jest fake timers are active', () => {
61+
const advanceTimersByTime = jest.fn()
62+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
63+
;(globalThis as any).jest = {advanceTimersByTime}
64+
65+
const advancer = getTimerAdvancer()
66+
expect(advancer).toBeDefined()
67+
68+
// Verify it calls the Jest function
69+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
70+
advancer?.(100)
71+
expect(advanceTimersByTime).toHaveBeenCalledWith(100)
72+
73+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
74+
delete (globalThis as any).jest
75+
})
76+
77+
it('returns Vitest advanceTimersByTime when Vitest fake timers are active', () => {
78+
const advanceTimersByTime = jest.fn()
79+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
80+
;(globalThis as any).vi = {advanceTimersByTime}
81+
82+
const advancer = getTimerAdvancer()
83+
expect(advancer).toBeDefined()
84+
85+
// Verify it calls the Vitest function
86+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
87+
advancer?.(200)
88+
expect(advanceTimersByTime).toHaveBeenCalledWith(200)
89+
90+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
91+
delete (globalThis as any).vi
92+
})
93+
})
94+
})

tests/utils/misc/wait.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
2+
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
13
import {createConfig} from '#src/setup/setup'
24
import {wait} from '#src/utils/misc/wait'
35

@@ -16,3 +18,114 @@ test('advances timers when set', async () => {
1618
timers.useRealTimers()
1719
expect(performance.now() - beforeReal).toBeLessThan(1000)
1820
}, 10)
21+
22+
test('auto-detects Jest fake timers', async () => {
23+
const beforeReal = performance.now()
24+
25+
// Simulate Jest fake timers
26+
timers.useFakeTimers()
27+
const beforeFake = performance.now()
28+
29+
// Mock the Jest global
30+
31+
const mockAdvanceTimersByTime = jest.fn((ms: number) => {
32+
timers.advanceTimersByTime(ms)
33+
})
34+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
35+
;(globalThis as any).jest = {
36+
advanceTimersByTime: mockAdvanceTimersByTime,
37+
}
38+
39+
// Don't configure advanceTimers - should auto-detect
40+
const config = createConfig({
41+
delay: 500,
42+
})
43+
44+
await wait(config)
45+
46+
// Verify auto-detection worked
47+
expect(mockAdvanceTimersByTime).toHaveBeenCalledWith(500)
48+
expect(performance.now() - beforeFake).toBe(500)
49+
50+
// Cleanup
51+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
52+
delete (globalThis as any).jest
53+
timers.useRealTimers()
54+
expect(performance.now() - beforeReal).toBeLessThan(1000)
55+
}, 10)
56+
57+
test('auto-detects Vitest fake timers', async () => {
58+
const beforeReal = performance.now()
59+
60+
// Simulate Vitest fake timers
61+
timers.useFakeTimers()
62+
const beforeFake = performance.now()
63+
64+
// Temporarily hide Jest global to test Vitest detection
65+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
66+
const originalJest = (globalThis as any).jest
67+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
68+
delete (globalThis as any).jest
69+
70+
// Mock the Vitest global
71+
72+
const mockAdvanceTimersByTime = jest.fn((ms: number) => {
73+
timers.advanceTimersByTime(ms)
74+
})
75+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
76+
;(globalThis as any).vi = {
77+
advanceTimersByTime: mockAdvanceTimersByTime,
78+
}
79+
80+
// Don't configure advanceTimers - should auto-detect
81+
const config = createConfig({
82+
delay: 750,
83+
})
84+
85+
await wait(config)
86+
87+
// Verify auto-detection worked
88+
expect(mockAdvanceTimersByTime).toHaveBeenCalledWith(750)
89+
expect(performance.now() - beforeFake).toBe(750)
90+
91+
// Cleanup
92+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
93+
delete (globalThis as any).vi
94+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
95+
;(globalThis as any).jest = originalJest
96+
timers.useRealTimers()
97+
expect(performance.now() - beforeReal).toBeLessThan(1000)
98+
}, 10)
99+
100+
test('manual configuration takes precedence over auto-detection', async () => {
101+
timers.useFakeTimers()
102+
103+
// Mock the Vitest global
104+
105+
const autoDetectedAdvance = jest.fn()
106+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
107+
;(globalThis as any).vi = {
108+
advanceTimersByTime: autoDetectedAdvance,
109+
}
110+
111+
// Provide manual configuration
112+
113+
const manualAdvance = jest.fn((ms: number) => {
114+
timers.advanceTimersByTime(ms)
115+
})
116+
const config = createConfig({
117+
delay: 100,
118+
advanceTimers: manualAdvance,
119+
})
120+
121+
await wait(config)
122+
123+
// Manual configuration should be used, not auto-detected
124+
expect(manualAdvance).toHaveBeenCalledWith(100)
125+
expect(autoDetectedAdvance).not.toHaveBeenCalled()
126+
127+
// Cleanup
128+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
129+
delete (globalThis as any).vi
130+
timers.useRealTimers()
131+
}, 10)

0 commit comments

Comments
 (0)