From fbe973cae68343f9478d6f8532f9ef38486e53fb Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 19 Oct 2026 23:42:45 +0900 Subject: [PATCH] feat: support fake timers for `node:timers` and `node:timers/promise` (#10899) Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: OpenCode Co-authored-by: Codex (GPT-6) --- docs/config/faketimers.md | 9 +- packages/vitest/src/defaults.ts | 7 +- .../vitest/src/integrations/mock/timers.ts | 12 ++- packages/vitest/src/integrations/vi.ts | 3 +- packages/vitest/src/node/types/config.ts | 2 +- packages/vitest/src/public/index.ts | 2 +- packages/vitest/src/runtime/config.ts | 2 +- .../vitest/src/runtime/external-executor.ts | 4 + packages/vitest/src/runtime/runVmTests.ts | 2 + packages/vitest/src/runtime/setup-node.ts | 3 +- .../src/runtime/vm/commonjs-executor.ts | 16 ++++ packages/vitest/src/runtime/workers/vm.ts | 4 +- patches/@sinonjs__fake-timers@15.4.0.patch | 82 +++++++++++++---- pnpm-lock.yaml | 8 +- test/unit/test/timers-node-builtins.test.ts | 87 +++++++++++++++++++ 15 files changed, 212 insertions(+), 31 deletions(-) create mode 100644 test/unit/test/timers-node-builtins.test.ts diff --git a/docs/config/faketimers.md b/docs/config/faketimers.md index 14c4544eb..fc8c4ef1c 100644 --- a/docs/config/faketimers.md +++ b/docs/config/faketimers.md @@ -7,7 +7,7 @@ outline: deep - **Type:** `FakeTimerConfig` -Options that Vitest will pass down to [`@sinon/fake-timers`](https://npmx.dev/package/@sinonjs/fake-timers) when using [`vi.useFakeTimers()`](/api/vi#vi-usefaketimers). +Options that configure fake timers when using [`vi.useFakeTimers()`](/api/vi#vi-usefaketimers). Vitest's fake timer implementation is based on [`@sinon/fake-timers`](https://npmx.dev/package/@sinonjs/fake-timers), with additional options and behavior tailored to Vitest. ## fakeTimers.now @@ -65,3 +65,10 @@ Relevant only when using with `shouldAdvanceTime: true`. increment mocked time b - **Default:** `true` Tells fake timers to clear "native" (i.e. not fake) timers by delegating to their respective handlers. When disabled, it can lead to potentially unexpected behavior if timers existed prior to starting fake timers session. + +## fakeTimers.fakeNodeBuiltins 5.0.0 {#faketimers-fakenodebuiltins} + +- **Type:** `boolean` +- **Default:** `false` + +Mock timer APIs imported from `node:timers` and `node:timers/promises`. diff --git a/packages/vitest/src/defaults.ts b/packages/vitest/src/defaults.ts index 58d808c13..272aad917 100644 --- a/packages/vitest/src/defaults.ts +++ b/packages/vitest/src/defaults.ts @@ -1,4 +1,5 @@ -import type { CoverageOptions, ResolvedBenchmarkOptions, UserConfig } from './node/types/config' +import type { FakeTimersConfig } from './integrations/mock/timers' +import type { CoverageOptions, ResolvedBenchmarkOptions } from './node/types/config' import type { FieldsWithDefaultValues } from './node/types/coverage' import os from 'node:os' import { isAgent, isCI } from './utils/env' @@ -43,7 +44,7 @@ export const coverageConfigDefaults: Required = { +const fakeTimersDefaults: FakeTimersConfig = { loopLimit: 10_000, shouldClearNativeTimers: true, } @@ -76,7 +77,7 @@ export const configDefaults: Readonly<{ include: never[] } coverage: CoverageOptions - fakeTimers: import('@sinonjs/fake-timers').Config + fakeTimers: FakeTimersConfig maxConcurrency: number dangerouslyIgnoreUnhandledErrors: boolean typecheck: { diff --git a/packages/vitest/src/integrations/mock/timers.ts b/packages/vitest/src/integrations/mock/timers.ts index a560f07ea..6d8bc1d7d 100644 --- a/packages/vitest/src/integrations/mock/timers.ts +++ b/packages/vitest/src/integrations/mock/timers.ts @@ -8,8 +8,8 @@ import type { Clock, FakeMethod, - Config as FakeTimersConfig, FakeTimers as FakeTimersContext, + Config as SinonFakeTimersConfig, TemporalTimelike, } from '@sinonjs/fake-timers' import { withGlobal } from '@sinonjs/fake-timers' @@ -17,6 +17,16 @@ import { isChildProcess } from '../../runtime/utils' const RealDate = globalThis.Date +// Custom options are implemented by patching the package +export interface FakeTimersConfig extends SinonFakeTimersConfig { + /** + * Mock timer APIs imported from `node:timers` and `node:timers/promises`. + * + * @default false + */ + fakeNodeBuiltins?: boolean +} + export class FakeTimers { private _global: typeof globalThis private _clock!: Clock diff --git a/packages/vitest/src/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts index 382430680..7533e9b90 100644 --- a/packages/vitest/src/integrations/vi.ts +++ b/packages/vitest/src/integrations/vi.ts @@ -1,4 +1,4 @@ -import type { Config as FakeTimersConfig, TemporalTimelike } from '@sinonjs/fake-timers' +import type { TemporalTimelike } from '@sinonjs/fake-timers' import type { MaybeMocked, MaybeMockedDeep, @@ -10,6 +10,7 @@ import type { Disposable } from 'vitest/optional-runtime-types.js' import type { RuntimeOptions, SerializedConfig } from '../runtime/config' import type { VitestMocker } from '../runtime/moduleRunner/moduleMocker' import type { MockFactoryWithHelper, MockOptions } from '../types/mocker' +import type { FakeTimersConfig } from './mock/timers' import { clearAllMocks, fn, diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index cc2d7ad2c..91deee096 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -1,4 +1,3 @@ -import type { Config as FakeTimersConfig } from '@sinonjs/fake-timers' import type { PrettyFormatOptions } from '@vitest/pretty-format' import type { SnapshotStateOptions } from '@vitest/snapshot' import type { Arrayable } from '@vitest/utils' @@ -12,6 +11,7 @@ import type { UserConfig as ViteUserConfig, } from 'vite' import type { ChaiConfig } from '../../integrations/chai/config' +import type { FakeTimersConfig } from '../../integrations/mock/timers' import type { SerializedConfig } from '../../runtime/config' import type { SequenceHooks, diff --git a/packages/vitest/src/public/index.ts b/packages/vitest/src/public/index.ts index 13ad975fe..5ab028c85 100644 --- a/packages/vitest/src/public/index.ts +++ b/packages/vitest/src/public/index.ts @@ -31,8 +31,8 @@ export type { } from '../api/types' export { assert, chai, createExpect, expect, should } from '../integrations/chai' export { inject } from '../integrations/inject' +export type { FakeTimersConfig } from '../integrations/mock/timers' export { Snapshots } from '../integrations/snapshot/chai' - export { vi, vitest } from '../integrations/vi' export type { VitestUtils } from '../integrations/vi' export type { diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index 6eb7905a1..4eafbf4c3 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -1,7 +1,7 @@ -import type { Config as FakeTimersConfig } from '@sinonjs/fake-timers' import type { PrettyFormatOptions } from '@vitest/pretty-format' import type { SnapshotEnvironment, SnapshotUpdateState } from '@vitest/snapshot' import type { DiffOptions, SerializedDiffOptions } from '@vitest/utils/diff' +import type { FakeTimersConfig } from '../integrations/mock/timers' import type { LabelColor } from '../types/general' import type { SequenceHooks, diff --git a/packages/vitest/src/runtime/external-executor.ts b/packages/vitest/src/runtime/external-executor.ts index 4d5b4060d..952b37bd6 100644 --- a/packages/vitest/src/runtime/external-executor.ts +++ b/packages/vitest/src/runtime/external-executor.ts @@ -96,6 +96,10 @@ export class ExternalModulesExecutor { this.resolvers = [this.vite.resolve] } + public syncBuiltinESMExports(): void { + this.cjs.syncBuiltinESMExports() + } + async import(identifier: string): Promise { const module = await this.createModule(identifier) await this.esm.evaluateModule(module) diff --git a/packages/vitest/src/runtime/runVmTests.ts b/packages/vitest/src/runtime/runVmTests.ts index 264ebd6d3..ebd72ba40 100644 --- a/packages/vitest/src/runtime/runVmTests.ts +++ b/packages/vitest/src/runtime/runVmTests.ts @@ -26,6 +26,7 @@ export async function run( config: SerializedConfig, moduleRunner: TestModuleRunner, traces: Traces, + syncBuiltinESMExports: () => void, ): Promise { const workerState = getWorkerState() @@ -63,6 +64,7 @@ export async function run( util, timers, timersPromises, + syncBuiltinESMExports, } await traces.$('vitest.runtime.coverage.start', () => diff --git a/packages/vitest/src/runtime/setup-node.ts b/packages/vitest/src/runtime/setup-node.ts index 5c93677a0..6cc6a1670 100644 --- a/packages/vitest/src/runtime/setup-node.ts +++ b/packages/vitest/src/runtime/setup-node.ts @@ -1,6 +1,6 @@ import type { Environment } from '../types/environment' import type { SerializedConfig } from './config' -import { createRequire } from 'node:module' +import { createRequire, syncBuiltinESMExports } from 'node:module' import timers from 'node:timers' import timersPromises from 'node:timers/promises' import util from 'node:util' @@ -53,6 +53,7 @@ export async function setupGlobalEnv( util, timers, timersPromises, + syncBuiltinESMExports, } if (!config.disableConsoleIntercept) { diff --git a/packages/vitest/src/runtime/vm/commonjs-executor.ts b/packages/vitest/src/runtime/vm/commonjs-executor.ts index 6d12b716e..42682275b 100644 --- a/packages/vitest/src/runtime/vm/commonjs-executor.ts +++ b/packages/vitest/src/runtime/vm/commonjs-executor.ts @@ -396,6 +396,22 @@ export class CommonjsExecutor { return module } + public syncBuiltinESMExports(): void { + _Module.syncBuiltinESMExports() + + for (const [identifier, module] of this.moduleCache) { + if (!isBuiltin(identifier) || module.status !== 'evaluated') { + continue + } + const exports = this.require(identifier) + for (const key of Object.keys(module.namespace)) { + if (key !== 'default') { + module.setExport(key, exports[key]) + } + } + } + } + public getCjsSyntheticModule(path: string, identifier: string): VMSyntheticModule { if (this.moduleCache.has(identifier)) { return this.moduleCache.get(identifier)! diff --git a/packages/vitest/src/runtime/workers/vm.ts b/packages/vitest/src/runtime/workers/vm.ts index 840acbdf7..7c492f4ff 100644 --- a/packages/vitest/src/runtime/workers/vm.ts +++ b/packages/vitest/src/runtime/workers/vm.ts @@ -177,7 +177,9 @@ export async function runVmTests( const { run } = (await moduleRunner.import(entryFile)) as typeof import('../runVmTests') try { - await run(method, ctx.files, ctx.config, moduleRunner, traces) + await run(method, ctx.files, ctx.config, moduleRunner, traces, () => + externalModulesExecutor.syncBuiltinESMExports(), + ) } finally { await traces.$('vitest.runtime.environment.teardown', () => vm.teardown?.()) // unregisters the runner from Vite's `Error.prepareStackTrace` interceptor: diff --git a/patches/@sinonjs__fake-timers@15.4.0.patch b/patches/@sinonjs__fake-timers@15.4.0.patch index 8d2f73cd8..095176ce3 100644 --- a/patches/@sinonjs__fake-timers@15.4.0.patch +++ b/patches/@sinonjs__fake-timers@15.4.0.patch @@ -1,31 +1,81 @@ diff --git a/src/fake-timers-src.js b/src/fake-timers-src.js -index 241a7ad3eeb8b5eb691439fab31a87ef13c76345..2df1b2086670bfa06cdb148a9dbe61cd41680f2e 100644 +index 241a7ad3eeb8b5eb691439fab31a87ef13c76345..23ef7b1ee2435aa4660b15a08fdbb915d53c0eb8 100644 --- a/src/fake-timers-src.js +++ b/src/fake-timers-src.js -@@ -2,14 +2,14 @@ +@@ -1,19 +1,6 @@ + "use strict"; const globalObject = require("@sinonjs/commons").global; - let timersModule, timersPromisesModule; +-let timersModule, timersPromisesModule; -if (typeof require === "function" && typeof module === "object") { -+if (typeof __vitest_required__ !== 'undefined') { - try { +- try { - timersModule = require("timers"); -+ timersModule = __vitest_required__.timers; - } catch { - // ignored - } - try { +- } catch { +- // ignored +- } +- try { - timersPromisesModule = require("timers/promises"); -+ timersPromisesModule = __vitest_required__.timersPromises; - } catch { - // ignored - } -@@ -537,7 +537,7 @@ function withGlobal(_global) { +- } catch { +- // ignored +- } +-} + + /** + * @typedef {"nextAsync" | "manual" | "interval"} TickMode +@@ -537,7 +524,8 @@ function withGlobal(_global) { isPresent.hrtime && typeof _global.process.hrtime.bigint === "function"; isPresent.nextTick = _global.process && typeof _global.process.nextTick === "function"; - const utilPromisify = _global.process && require("util").promisify; -+ const utilPromisify = _global.process && _global.__vitest_required__ && _global.__vitest_required__.util.promisify; ++ const utilPromisify = ++ _global.process && _global.__vitest_required__?.util.promisify; isPresent.performance = _global.performance && typeof _global.performance.now === "function"; const hasPerformancePrototype = +@@ -2577,6 +2565,7 @@ function withGlobal(_global) { + clock.uninstall = function () { + uninstalled = true; + clock.setTickMode({ mode: "manual" }); ++ const { timersModule, timersPromisesModule } = clock; + + if (clock.methods) { + const installedHrTime = "_hrtime"; +@@ -2750,6 +2739,16 @@ function withGlobal(_global) { + const clock = createClock(config.now, config.loopLimit); + clock.shouldClearNativeTimers = config.shouldClearNativeTimers; + ++ // Vitest injects Node builtins because this file is bundled without require. ++ const vitestRequired = ++ config.fakeNodeBuiltins && _global.__vitest_required__; ++ const timersModule = vitestRequired?.timers; ++ const timersPromisesModule = vitestRequired?.timersPromises; ++ const syncBuiltinESMExports = vitestRequired?.syncBuiltinESMExports; ++ // Keep the modules on the clock so uninstall can restore their methods. ++ clock.timersModule = timersModule; ++ clock.timersPromisesModule = timersPromisesModule; ++ + clock.abortListenerMap = new Map(); + + if (hasToFake) { +@@ -3091,6 +3090,21 @@ function withGlobal(_global) { + } + } + ++ // Keep ESM named exports in sync with the patched CommonJS modules. ++ if ( ++ syncBuiltinESMExports && ++ (clock.timersModuleMethods?.length || ++ clock.timersPromisesModuleMethods?.length) ++ ) { ++ const uninstall = clock.uninstall; ++ clock.uninstall = () => { ++ const result = uninstall(); ++ syncBuiltinESMExports(); ++ return result; ++ }; ++ syncBuiltinESMExports(); ++ } ++ + return clock; + } + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c3358545..f687e4001 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,7 +182,7 @@ overrides: vitest: workspace:* patchedDependencies: - '@sinonjs/fake-timers@15.4.0': 8fb375421f30746697394082b6fcf4a218fe07a8db36474ac335c48aff32c0cb + '@sinonjs/fake-timers@15.4.0': 5945e0a65ab7feea06ac881593dfdec757a3897ab7b7445f52a45eb6b0d79ab6 acorn@8.11.3: 62f89b815dbd769c8a4d5b19b1f6852f28922ecb581d876c8a8377d05c2483c4 cac@6.7.14: a8f0f3517a47ce716ed90c0cfe6ae382ab763b021a664ada2a608477d0621588 rrweb-snapshot@2.1.1: b66b30796877352a5f887f3f4115c4e7265ddc11772af2223da4bd92293716f6 @@ -1012,7 +1012,7 @@ importers: version: 1.9.1 '@sinonjs/fake-timers': specifier: 15.4.0 - version: 15.4.0(patch_hash=8fb375421f30746697394082b6fcf4a218fe07a8db36474ac335c48aff32c0cb) + version: 15.4.0(patch_hash=5945e0a65ab7feea06ac881593dfdec757a3897ab7b7445f52a45eb6b0d79ab6) '@types/jsdom': specifier: ^30.0.0 version: 30.0.0 @@ -13604,7 +13604,7 @@ snapshots: dependencies: type-detect: 4.0.8 - '@sinonjs/fake-timers@15.4.0(patch_hash=8fb375421f30746697394082b6fcf4a218fe07a8db36474ac335c48aff32c0cb)': + '@sinonjs/fake-timers@15.4.0(patch_hash=5945e0a65ab7feea06ac881593dfdec757a3897ab7b7445f52a45eb6b0d79ab6)': dependencies: '@sinonjs/commons': 3.0.1 @@ -18477,7 +18477,7 @@ snapshots: sinon@22.1.0: dependencies: '@sinonjs/commons': 3.0.1 - '@sinonjs/fake-timers': 15.4.0(patch_hash=8fb375421f30746697394082b6fcf4a218fe07a8db36474ac335c48aff32c0cb) + '@sinonjs/fake-timers': 15.4.0(patch_hash=5945e0a65ab7feea06ac881593dfdec757a3897ab7b7445f52a45eb6b0d79ab6) '@sinonjs/samsam': 10.0.2 diff: 9.0.0 diff --git a/test/unit/test/timers-node-builtins.test.ts b/test/unit/test/timers-node-builtins.test.ts new file mode 100644 index 000000000..92f4a0aff --- /dev/null +++ b/test/unit/test/timers-node-builtins.test.ts @@ -0,0 +1,87 @@ +import { createRequire } from 'node:module' +import timers, { setTimeout as namedSetTimeout } from 'node:timers' +import * as timersNamespace from 'node:timers' +import timersPromises, { setTimeout as namedSetTimeoutPromise } from 'node:timers/promises' +import * as timersPromisesNamespace from 'node:timers/promises' +import { afterEach, expect, test, vi } from 'vitest' + +const require = createRequire(import.meta.url) +const requiredTimers: typeof timers = require('node:timers') +const requiredTimersPromises: typeof timersPromises = require('node:timers/promises') + +afterEach(() => { + vi.useRealTimers() +}) + +test('does not mock Node timer imports by default', () => { + const originalSetTimeout = timers.setTimeout + const originalSetTimeoutPromise = timersPromises.setTimeout + const originalNamedSetTimeout = namedSetTimeout + const originalNamedSetTimeoutPromise = namedSetTimeoutPromise + + vi.useFakeTimers() + + expect(timers.setTimeout).toBe(originalSetTimeout) + expect(timersPromises.setTimeout).toBe(originalSetTimeoutPromise) + expect(namedSetTimeout).toBe(originalNamedSetTimeout) + expect(namedSetTimeoutPromise).toBe(originalNamedSetTimeoutPromise) +}) + +test('mocks only configured Node timer imports', () => { + const originalSetTimeout = timers.setTimeout + const originalSetInterval = timers.setInterval + const originalSetTimeoutPromise = timersPromises.setTimeout + const originalSetIntervalPromise = timersPromises.setInterval + + vi.useFakeTimers({ fakeNodeBuiltins: true, toFake: ['setTimeout'] }) + + expect(timers.setTimeout).not.toBe(originalSetTimeout) + expect(timers.setInterval).toBe(originalSetInterval) + expect(timersPromises.setTimeout).not.toBe(originalSetTimeoutPromise) + expect(timersPromises.setInterval).toBe(originalSetIntervalPromise) +}) + +test('mocks node:timers imports', () => { + vi.useFakeTimers({ fakeNodeBuiltins: true }) + const called: string[] = [] + + timers.setTimeout(() => called.push('default'), 100) + namedSetTimeout(() => called.push('named'), 100) + timersNamespace.setTimeout(() => called.push('namespace'), 100) + requiredTimers.setTimeout(() => called.push('require'), 100) + + vi.advanceTimersByTime(100) + expect(called).toEqual(['default', 'named', 'namespace', 'require']) +}) + +test('mocks node:timers/promises imports', async () => { + vi.useFakeTimers({ fakeNodeBuiltins: true }) + const resolved: string[] = [] + + timersPromises.setTimeout(100).then(() => resolved.push('default')) + namedSetTimeoutPromise(100).then(() => resolved.push('named')) + timersPromisesNamespace.setTimeout(100).then(() => resolved.push('namespace')) + requiredTimersPromises.setTimeout(100).then(() => resolved.push('require')) + + await vi.advanceTimersByTimeAsync(100) + expect(resolved).toEqual(['default', 'named', 'namespace', 'require']) +}) + +test('restores Node timer imports', () => { + const originalSetTimeout = timers.setTimeout + const originalSetTimeoutPromise = timersPromises.setTimeout + const originalNamedSetTimeout = namedSetTimeout + const originalNamedSetTimeoutPromise = namedSetTimeoutPromise + + vi.useFakeTimers({ fakeNodeBuiltins: true }) + expect(timers.setTimeout).not.toBe(originalSetTimeout) + expect(timersPromises.setTimeout).not.toBe(originalSetTimeoutPromise) + expect(namedSetTimeout).not.toBe(originalNamedSetTimeout) + expect(namedSetTimeoutPromise).not.toBe(originalNamedSetTimeoutPromise) + + vi.useRealTimers() + expect(timers.setTimeout).toBe(originalSetTimeout) + expect(timersPromises.setTimeout).toBe(originalSetTimeoutPromise) + expect(namedSetTimeout).toBe(originalNamedSetTimeout) + expect(namedSetTimeoutPromise).toBe(originalNamedSetTimeoutPromise) +}) -- 2.54.0