From b7dffe8f7af6971622562ba6432a8285d44dd20e Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 16 Oct 2026 15:55:07 +0200 Subject: [PATCH] feat: add fakeTimers.fakeNodeBuiltins option --- docs/api/vi.md | 8 ++ docs/config/faketimers.md | 19 ++++ .../vitest/src/integrations/mock/timers.ts | 8 +- packages/vitest/src/integrations/vi.ts | 3 +- packages/vitest/src/node/types/config.ts | 3 +- packages/vitest/src/runtime/config.ts | 11 +- .../vitest/src/runtime/external-executor.ts | 4 + packages/vitest/src/runtime/runVmTests.ts | 3 + packages/vitest/src/runtime/setup-node.ts | 3 +- .../src/runtime/vm/commonjs-executor.ts | 16 +++ patches/@sinonjs__fake-timers@15.4.0.patch | 71 +++++++++--- pnpm-lock.yaml | 8 +- .../test/fake-timers-node-builtins.test.ts | 102 ++++++++++++++++++ 13 files changed, 229 insertions(+), 30 deletions(-) create mode 100644 test/e2e/test/fake-timers-node-builtins.test.ts diff --git a/docs/api/vi.md b/docs/api/vi.md index 43760c14f..f7480bf59 100644 --- a/docs/api/vi.md +++ b/docs/api/vi.md @@ -1219,6 +1219,14 @@ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) vi.useFakeTimers({ toNotFake: ['setInterval'] }) ``` +The timers exported by `node:timers` and `node:timers/promises` are not faked by default. Enable [`fakeNodeBuiltins`](/config/faketimers#faketimers-fakenodebuiltins) to fake them as well: + +```ts +import { setTimeout as sleep } from 'node:timers/promises' + +vi.useFakeTimers({ fakeNodeBuiltins: true }) +``` + ### vi.setTimerTickMode 4.1.0 {#vi-settimertickmode} - **Type:** `(mode: 'manual' | 'nextTimerAsync') => Vitest | (mode: 'interval', interval?: number) => Vitest` diff --git a/docs/config/faketimers.md b/docs/config/faketimers.md index 14c4544eb..a30a10dbe 100644 --- a/docs/config/faketimers.md +++ b/docs/config/faketimers.md @@ -59,6 +59,25 @@ Tells @sinonjs/fake-timers to increment mocked time automatically based on the r Relevant only when using with `shouldAdvanceTime: true`. increment mocked time by advanceTimeDelta ms every advanceTimeDelta ms change in the real system time. +## fakeTimers.fakeNodeBuiltins 5.1.0 {#faketimers-fakenodebuiltins} + +- **Type:** `boolean` +- **Default:** `false` + +Also fake the timers exported by the `node:timers` and `node:timers/promises` modules, following the same `toFake` and `toNotFake` selection as the global timers. This covers the `setTimeout`, `setInterval`, `setImmediate` and `clear*` functions of `node:timers`, and the promise-based `setTimeout`, `setInterval` and `setImmediate` of `node:timers/promises`. Named imports are updated together with the module objects. + +```ts +import { setTimeout as sleep } from 'node:timers/promises' + +vi.useFakeTimers({ fakeNodeBuiltins: true }) + +const promise = sleep(1000) +await vi.advanceTimersByTimeAsync(1000) +await promise // resolved by the fake clock +``` + +This option has no effect in the browser. + ## fakeTimers.shouldClearNativeTimers - **Type:** `boolean` diff --git a/packages/vitest/src/integrations/mock/timers.ts b/packages/vitest/src/integrations/mock/timers.ts index 4f5922392..d7a95c0bf 100644 --- a/packages/vitest/src/integrations/mock/timers.ts +++ b/packages/vitest/src/integrations/mock/timers.ts @@ -5,12 +5,8 @@ * LICENSE file in the root directory of https://github.com/jestjs/jest. */ -import type { - Clock, - FakeMethod, - Config as FakeTimersConfig, - FakeTimers as FakeTimersContext, -} from '@sinonjs/fake-timers' +import type { Clock, FakeMethod, FakeTimers as FakeTimersContext } from '@sinonjs/fake-timers' +import type { FakeTimersConfig } from '../../runtime/config' import { withGlobal } from '@sinonjs/fake-timers' import { isChildProcess } from '../../runtime/utils' diff --git a/packages/vitest/src/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts index f26ba158b..512aa16d6 100644 --- a/packages/vitest/src/integrations/vi.ts +++ b/packages/vitest/src/integrations/vi.ts @@ -1,4 +1,3 @@ -import type { Config as FakeTimersConfig } from '@sinonjs/fake-timers' import type { MaybeMocked, MaybeMockedDeep, @@ -7,7 +6,7 @@ import type { MockInstance, } from '@vitest/spy' import type { Disposable } from 'vitest/optional-runtime-types.js' -import type { RuntimeOptions, SerializedConfig } from '../runtime/config' +import type { FakeTimersConfig, RuntimeOptions, SerializedConfig } from '../runtime/config' import type { VitestMocker } from '../runtime/moduleRunner/moduleMocker' import type { MockFactoryWithHelper, MockOptions } from '../types/mocker' import { diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index e56dd7816..9ec40a91e 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,7 +11,7 @@ import type { UserConfig as ViteUserConfig, } from 'vite' import type { ChaiConfig } from '../../integrations/chai/config' -import type { SerializedConfig } from '../../runtime/config' +import type { FakeTimersConfig, SerializedConfig } from '../../runtime/config' import type { SequenceHooks, SequenceSetupFiles, diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index 4ed880a37..da1b44c22 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -1,4 +1,4 @@ -import type { Config as FakeTimersConfig } from '@sinonjs/fake-timers' +import type { Config as SinonFakeTimersConfig } 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' @@ -10,6 +10,15 @@ import type { TestTagDefinition, } from './runner/types' +export interface FakeTimersConfig extends SinonFakeTimersConfig { + /** + * Also fake the timers exported by `node:timers` and `node:timers/promises`. + * Has no effect in the browser. + * @default false + */ + fakeNodeBuiltins?: boolean +} + /** * Config that tests have access to. */ diff --git a/packages/vitest/src/runtime/external-executor.ts b/packages/vitest/src/runtime/external-executor.ts index 4d5b4060d..4eabb78c6 100644 --- a/packages/vitest/src/runtime/external-executor.ts +++ b/packages/vitest/src/runtime/external-executor.ts @@ -110,6 +110,10 @@ export class ExternalModulesExecutor { return this.cjs.createRequire(identifier) } + syncBuiltinESMExports(): void { + this.cjs.syncBuiltinESMExports() + } + #esmSyntaxCache = new Map() // require() dispatches to the sync ESM loader only for files that are diff --git a/packages/vitest/src/runtime/runVmTests.ts b/packages/vitest/src/runtime/runVmTests.ts index 264ebd6d3..f5e0b8687 100644 --- a/packages/vitest/src/runtime/runVmTests.ts +++ b/packages/vitest/src/runtime/runVmTests.ts @@ -15,6 +15,7 @@ import * as VitestIndex from '../public/index' import { collectDependencies, getEvaluatedImports } from '../utils/module-dependencies' import { detectAsyncLeaks } from './detect-async-leaks' import { closeInspector } from './inspector' +import { VITEST_VM_CONTEXT_SYMBOL } from './moduleRunner/startVitestModuleRunner' import { collectTests, startTests } from './runner/run' import { resolveTestRunner } from './runners' import { setupCommonEnv } from './setup-common' @@ -58,11 +59,13 @@ export async function run( process.env.SSR = '1' } + const { externalModulesExecutor } = (globalThis as any)[VITEST_VM_CONTEXT_SYMBOL] // @ts-expect-error not typed global for patched timers globalThis.__vitest_required__ = { util, timers, timersPromises, + syncBuiltinESMExports: () => externalModulesExecutor.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..aad722380 100644 --- a/packages/vitest/src/runtime/vm/commonjs-executor.ts +++ b/packages/vitest/src/runtime/vm/commonjs-executor.ts @@ -54,6 +54,7 @@ export class CommonjsExecutor { private moduleCache = new Map() private builtinCache: Record = Object.create(null) + private coreModules: { module: VMSyntheticModule; exports: any; keys: string[] }[] = [] private extensions: Record unknown> = Object.create(null) @@ -393,9 +394,24 @@ export class CommonjsExecutor { { context: this.context, identifier }, ) this.moduleCache.set(identifier, module) + this.coreModules.push({ module, exports, keys }) return module } + // the context sees builtins through synthetic modules whose exports are + // copied once, so they need the same sync Node does for its own bindings + public syncBuiltinESMExports(): void { + _Module.syncBuiltinESMExports() + for (const { module, exports, keys } of this.coreModules) { + if (module.status !== 'linked' && module.status !== 'evaluated') { + continue + } + for (const key of keys) { + 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/patches/@sinonjs__fake-timers@15.4.0.patch b/patches/@sinonjs__fake-timers@15.4.0.patch index 8d2f73cd8..8cd275583 100644 --- a/patches/@sinonjs__fake-timers@15.4.0.patch +++ b/patches/@sinonjs__fake-timers@15.4.0.patch @@ -1,26 +1,27 @@ diff --git a/src/fake-timers-src.js b/src/fake-timers-src.js -index 241a7ad3eeb8b5eb691439fab31a87ef13c76345..2df1b2086670bfa06cdb148a9dbe61cd41680f2e 100644 +index 241a7ad3eeb8b5eb691439fab31a87ef13c76345..65e01d3f7ddf04889512a2d681eb9b9ae27f164c 100644 --- a/src/fake-timers-src.js +++ b/src/fake-timers-src.js -@@ -2,14 +2,14 @@ +@@ -2,18 +2,6 @@ const globalObject = require("@sinonjs/commons").global; 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 +525,7 @@ function withGlobal(_global) { isPresent.hrtime && typeof _global.process.hrtime.bigint === "function"; isPresent.nextTick = _global.process && typeof _global.process.nextTick === "function"; @@ -29,3 +30,45 @@ index 241a7ad3eeb8b5eb691439fab31a87ef13c76345..2df1b2086670bfa06cdb148a9dbe61cd isPresent.performance = _global.performance && typeof _global.performance.now === "function"; const hasPerformancePrototype = +@@ -2641,6 +2629,13 @@ function withGlobal(_global) { + } + } + ++ if ( ++ clock.timersModuleMethods !== undefined || ++ clock.timersPromisesModuleMethods !== undefined ++ ) { ++ _global.__vitest_required__.syncBuiltinESMExports(); ++ } ++ + // Prevent multiple executions which will completely remove these props + clock.methods = []; + } +@@ -2809,6 +2804,13 @@ function withGlobal(_global) { + handleMissingTimer("performance"); + } + } ++ const nodeModules = config.fakeNodeBuiltins ++ ? _global.__vitest_required__ ++ : undefined; ++ timersModule = nodeModules ? nodeModules.timers : undefined; ++ timersPromisesModule = nodeModules ++ ? nodeModules.timersPromises ++ : undefined; + if (_global === globalObject && timersModule) { + clock.timersModuleMethods = []; + } +@@ -3091,6 +3093,13 @@ function withGlobal(_global) { + } + } + ++ if ( ++ clock.timersModuleMethods !== undefined || ++ clock.timersPromisesModuleMethods !== undefined ++ ) { ++ nodeModules.syncBuiltinESMExports(); ++ } ++ + return clock; + } + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5674aeb7c..0393273cb 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': eb7d99deb6e6015df71726ab01d2317cea8d805b762a5e4b085d04ab3c0f1cf3 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=eb7d99deb6e6015df71726ab01d2317cea8d805b762a5e4b085d04ab3c0f1cf3) '@types/jsdom': specifier: ^30.0.0 version: 30.0.0 @@ -13589,7 +13589,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=eb7d99deb6e6015df71726ab01d2317cea8d805b762a5e4b085d04ab3c0f1cf3)': dependencies: '@sinonjs/commons': 3.0.1 @@ -18397,7 +18397,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=eb7d99deb6e6015df71726ab01d2317cea8d805b762a5e4b085d04ab3c0f1cf3) '@sinonjs/samsam': 10.0.2 diff: 9.0.0 diff --git a/test/e2e/test/fake-timers-node-builtins.test.ts b/test/e2e/test/fake-timers-node-builtins.test.ts new file mode 100644 index 000000000..3188bc7af --- /dev/null +++ b/test/e2e/test/fake-timers-node-builtins.test.ts @@ -0,0 +1,102 @@ +import { expect, test } from 'vitest' +import { runInlineTests } from '../../test-utils' + +const testFile = ` +import { createRequire } from 'node:module' +import { setTimeout as nodeSetTimeout } from 'node:timers' +import timers from 'node:timers' +import { setTimeout as sleep } from 'node:timers/promises' +import { afterEach, expect, test, vi } from 'vitest' + +const require = createRequire(import.meta.url) +const requiredTimers = require('node:timers') + +afterEach(() => { + vi.useRealTimers() +}) + +test('node:timers is not faked by default', () => { + vi.useFakeTimers() + nodeSetTimeout(() => {}, 0) + timers.setTimeout(() => {}, 0) + expect(vi.getTimerCount()).toBe(0) +}) + +test('fakes node:timers', () => { + vi.useFakeTimers({ fakeNodeBuiltins: true }) + const callback = vi.fn() + nodeSetTimeout(callback, 1000) + timers.setTimeout(callback, 1000) + requiredTimers.setTimeout(callback, 1000) + expect(vi.getTimerCount()).toBe(3) + vi.advanceTimersByTime(1000) + expect(callback).toHaveBeenCalledTimes(3) +}) + +test('fakes node:timers/promises', async () => { + vi.useFakeTimers({ fakeNodeBuiltins: true }) + let resolved = false + const promise = sleep(1000).then(() => { + resolved = true + }) + await vi.advanceTimersByTimeAsync(999) + expect(resolved).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await promise + expect(resolved).toBe(true) +}) + +test('useRealTimers restores the modules', async () => { + vi.useFakeTimers({ fakeNodeBuiltins: true }) + vi.useRealTimers() + await new Promise(resolve => nodeSetTimeout(resolve, 1)) + await sleep(1) +}) +` + +test.for(['threads', 'forks', 'vmThreads', 'vmForks'] as const)( + '%s fakes node:timers with fakeNodeBuiltins', + async (pool) => { + const { stderr, testTree } = await runInlineTests({ 'timers.test.js': testFile }, { pool }) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "timers.test.js": { + "fakes node:timers": "passed", + "fakes node:timers/promises": "passed", + "node:timers is not faked by default": "passed", + "useRealTimers restores the modules": "passed", + }, + } + `) + }, +) + +test('fakeNodeBuiltins can be enabled in the config', async () => { + const { stderr, testTree } = await runInlineTests( + { + 'timers.test.js': ` + import { setTimeout as sleep } from 'node:timers/promises' + import { expect, test, vi } from 'vitest' + + test('node:timers/promises is faked', () => { + vi.useFakeTimers() + sleep(1000) + expect(vi.getTimerCount()).toBe(1) + vi.useRealTimers() + }) + `, + }, + { fakeTimers: { fakeNodeBuiltins: true } }, + ) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "timers.test.js": { + "node:timers/promises is faked": "passed", + }, + } + `) +}) -- 2.54.0