From c9d16dec56969770587fe4503f3f1514bf23e3c5 Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Sat, 15 Aug 2026 19:15:55 -0400 Subject: [PATCH] feat: automatically advance fake timers in Jest and Vitest Both frameworks install `@sinonjs/fake-timers`, which exposes the clock on each timer function it replaces. Ticking that clock advances fake timers whichever framework installed them - and only while they are installed, so real timers are untouched. Fixes #1115 --- src/options.ts | 5 ++++ src/setup/setup.ts | 3 ++- src/utils/index.ts | 1 + src/utils/misc/timerDetection.ts | 22 ++++++++++++++++ tests/utils/misc/timerDetection.ts | 36 +++++++++++++++++++++++++ tests/utils/misc/wait.ts | 42 ++++++++++++++++++++++++++++++ 6 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/utils/misc/timerDetection.ts create mode 100644 tests/utils/misc/timerDetection.ts diff --git a/src/options.ts b/src/options.ts index a00e4170..a3b5a7db 100644 --- a/src/options.ts +++ b/src/options.ts @@ -122,6 +122,11 @@ export interface Options { /** * A function to be called internally to advance your fake timers (if applicable) * + * Fake timers installed by Vitest or Jest's modern fake timers are advanced + * automatically. + * Set this option if you use another implementation of fake timers, + * e.g. Jest's legacy fake timers. + * * @example jest.advanceTimersByTime */ advanceTimers?: ((delay: number) => Promise) | ((delay: number) => void) diff --git a/src/setup/setup.ts b/src/setup/setup.ts index 9475a4c2..7164fcec 100644 --- a/src/setup/setup.ts +++ b/src/setup/setup.ts @@ -5,6 +5,7 @@ import {defaultKeyMap as defaultKeyboardMap} from '../keyboard/keyMap' import {defaultKeyMap as defaultPointerMap} from '../pointer/keyMap' import {Options, PointerEventsCheckLevel} from '../options' import { + advanceFakeTimers, ApiLevel, attachClipboardStubToView, getDocumentFromNode, @@ -32,7 +33,7 @@ const defaultOptionsDirect: Required = { skipClick: false, skipHover: false, writeToClipboard: false, - advanceTimers: () => Promise.resolve(), + advanceTimers: advanceFakeTimers, } /** diff --git a/src/utils/index.ts b/src/utils/index.ts index 8ab15cc8..2ef48ea8 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -30,6 +30,7 @@ export * from './misc/isElementType' export * from './misc/isVisible' export * from './misc/isDisabled' export * from './misc/level' +export * from './misc/timerDetection' export * from './misc/wait' export * from './pointer/cssPointerEvents' diff --git a/src/utils/misc/timerDetection.ts b/src/utils/misc/timerDetection.ts new file mode 100644 index 00000000..2603ce8a --- /dev/null +++ b/src/utils/misc/timerDetection.ts @@ -0,0 +1,22 @@ +interface FakeClock { + tick: (ms: number) => void +} + +/** + * Advance fake timers if the test framework installed some. + * + * Jest's modern fake timers and Vitest's fake timers are both built on + * `@sinonjs/fake-timers`, which exposes the installed clock on each timer + * function it replaces. + * Looking for that clock instead of a `jest`/`vi` global detects fake timers + * however the framework is imported, and only while they are installed. + */ +export function advanceFakeTimers(delay: number): void { + const {clock} = globalThis.setTimeout as typeof globalThis.setTimeout & { + clock?: FakeClock + } + + if (typeof clock?.tick === 'function') { + clock.tick(delay) + } +} diff --git a/tests/utils/misc/timerDetection.ts b/tests/utils/misc/timerDetection.ts new file mode 100644 index 00000000..19332f82 --- /dev/null +++ b/tests/utils/misc/timerDetection.ts @@ -0,0 +1,36 @@ +import {advanceFakeTimers} from '#src/utils' + +test('advance fake timers installed on the global scope', () => { + timers.useFakeTimers() + try { + let fired = false + globalThis.setTimeout(() => { + fired = true + }, 100) + + advanceFakeTimers(100) + + expect(fired).toBe(true) + } finally { + timers.useRealTimers() + } +}) + +test('do nothing when fake timers are not installed', () => { + expect(globalThis.setTimeout).not.toHaveProperty('clock') + + expect(() => advanceFakeTimers(100)).not.toThrow() +}) + +test('do nothing when the global timers are not faked', () => { + timers.useFakeTimers({toFake: ['Date']}) + try { + const before = Date.now() + + expect(() => advanceFakeTimers(100)).not.toThrow() + + expect(Date.now()).toBe(before) + } finally { + timers.useRealTimers() + } +}) diff --git a/tests/utils/misc/wait.ts b/tests/utils/misc/wait.ts index 36efb877..2c0c7d74 100644 --- a/tests/utils/misc/wait.ts +++ b/tests/utils/misc/wait.ts @@ -16,3 +16,45 @@ test('advances timers when set', async () => { timers.useRealTimers() expect(performance.now() - beforeReal).toBeLessThan(1000) }, 10) + +test('advances fake timers without being configured', async () => { + const beforeReal = performance.now() + timers.useFakeTimers() + try { + const beforeFake = performance.now() + + const config = createConfig({delay: 500}) + await wait(config) + + expect(performance.now() - beforeFake).toBe(500) + } finally { + timers.useRealTimers() + } + expect(performance.now() - beforeReal).toBeLessThan(1000) +}, 10) + +test('does not interfere with real timers', async () => { + expect(globalThis.setTimeout).not.toHaveProperty('clock') + + const config = createConfig({delay: 1}) + await wait(config) +}) + +test('configured function takes precedence over fake timer detection', async () => { + timers.useFakeTimers() + try { + const calls: number[] = [] + const config = createConfig({ + delay: 100, + advanceTimers: t => { + calls.push(t) + timers.advanceTimersByTime(t) + }, + }) + await wait(config) + + expect(calls).toEqual([100]) + } finally { + timers.useRealTimers() + } +}, 10)