diff --git a/docs/api/vi.md b/docs/api/vi.md index 2ef0c70ce3b8..c69fdd1004d5 100644 --- a/docs/api/vi.md +++ b/docs/api/vi.md @@ -1034,6 +1034,46 @@ The implementation is based internally on [`@sinonjs/fake-timers`](https://githu But you can enable it by specifying the option in `toFake` argument: `vi.useFakeTimers({ toFake: ['nextTick', 'queueMicrotask'] })`. ::: +### vi.setTimerTickMode + +- **Type:** `(mode: 'manual' | 'nextTimerAsync') => Vitest | (mode: 'interval', interval?: number) => Vitest` + +Controls how fake timers are advanced. + +- `manual`: The default behavior. Timers will only advance when you call one of `vi.advanceTimers...()` methods. +- `nextTimerAsync`: Timers will be advanced automatically to the next available timer after each macrotask. +- `interval`: Timers are advanced automatically by a specified interval. + +When `mode` is `'interval'`, you can also provide an `interval` in milliseconds. + +**Example:** + +```ts +import { vi } from 'vitest' + +vi.useFakeTimers() + +// Manual mode (default) +vi.setTimerTickMode({ mode: 'manual' }) + +let i = 0 +setInterval(() => console.log(++i), 50) + +vi.advanceTimersByTime(150) // logs 1, 2, 3 + +// nextTimerAsync mode +vi.setTimerTickMode({ mode: 'nextTimerAsync' }) + +// Timers will advance automatically after each macrotask +await new Promise(resolve => setTimeout(resolve, 150)) // logs 4, 5, 6 + +// interval mode (default when 'fakeTimers.shouldAdvanceTime' is `true`) +vi.setTimerTickMode({ mode: 'interval', interval: 50 }) + +// Timers will advance automatically every 50ms +await new Promise(resolve => setTimeout(resolve, 150)) // logs 7, 8, 9 +``` + ### vi.isFakeTimers {#vi-isfaketimers} ```ts diff --git a/package.json b/package.json index fb5f7f4ed552..40b93d76b2ad 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ ] }, "patchedDependencies": { - "@sinonjs/fake-timers@14.0.0": "patches/@sinonjs__fake-timers@14.0.0.patch", + "@sinonjs/fake-timers@15.0.0": "patches/@sinonjs__fake-timers@15.0.0.patch", "cac@6.7.14": "patches/cac@6.7.14.patch", "@types/sinonjs__fake-timers@8.1.5": "patches/@types__sinonjs__fake-timers@8.1.5.patch", "acorn@8.11.3": "patches/acorn@8.11.3.patch" diff --git a/packages/vitest/package.json b/packages/vitest/package.json index 9faf77edb8bc..162ea69dcb6f 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -195,7 +195,7 @@ "@antfu/install-pkg": "^1.1.0", "@edge-runtime/vm": "^5.0.0", "@jridgewell/trace-mapping": "catalog:", - "@sinonjs/fake-timers": "14.0.0", + "@sinonjs/fake-timers": "15.0.0", "@types/debug": "catalog:", "@types/estree": "catalog:", "@types/istanbul-lib-coverage": "catalog:", diff --git a/packages/vitest/src/integrations/mock/timers.ts b/packages/vitest/src/integrations/mock/timers.ts index 7f6bf296d55f..00f9f4755641 100644 --- a/packages/vitest/src/integrations/mock/timers.ts +++ b/packages/vitest/src/integrations/mock/timers.ts @@ -212,6 +212,23 @@ export class FakeTimers { return 0 } + setTimerTickMode(mode: 'manual' | 'nextTimerAsync' | 'interval', interval?: number): void { + if (this._checkFakeTimers()) { + if (mode === 'manual') { + this._clock.setTickMode({ mode: 'manual' }) + } + else if (mode === 'nextTimerAsync') { + this._clock.setTickMode({ mode: 'nextAsync' }) + } + else if (mode === 'interval') { + this._clock.setTickMode({ mode: 'interval', delta: interval }) + } + else { + throw new Error(`Invalid tick mode: ${mode}`) + } + } + } + configure(config: FakeTimerInstallOpts): void { this._userConfig = config } diff --git a/packages/vitest/src/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts index 9b4499cc7274..d9cf78557cdb 100644 --- a/packages/vitest/src/integrations/vi.ts +++ b/packages/vitest/src/integrations/vi.ts @@ -97,6 +97,16 @@ export interface VitestUtils { */ clearAllTimers: () => VitestUtils + /** + * Controls how fake timers are advanced. + * @param mode The mode to use for advancing timers. + * - `manual`: The default behavior. Timers will only advance when you call one of `vi.advanceTimers...()` methods. + * - `nextTimerAsync`: Timers will be advanced automatically to the next available timer after each macrotask. + * - `interval`: Timers are advanced automatically by a specified interval. + * @param interval The interval in milliseconds to use when `mode` is `'interval'`. + */ + setTimerTickMode: ((mode: 'manual' | 'nextTimerAsync') => VitestUtils) & ((mode: 'interval', interval?: number) => VitestUtils) + /** * Creates a spy on a method or getter/setter of an object similar to [`vi.fn()`](https://vitest.dev/api/vi#vi-fn). It returns a [mock function](https://vitest.dev/api/mock). * @example @@ -553,6 +563,11 @@ function createVitest(): VitestUtils { return utils }, + setTimerTickMode(mode: 'manual' | 'nextTimerAsync' | 'interval', interval?: number) { + timers().setTimerTickMode(mode, interval) + return utils + }, + // mocks spyOn, diff --git a/patches/@sinonjs__fake-timers@14.0.0.patch b/patches/@sinonjs__fake-timers@15.0.0.patch similarity index 89% rename from patches/@sinonjs__fake-timers@14.0.0.patch rename to patches/@sinonjs__fake-timers@15.0.0.patch index 4051ff49aa3d..ec8ed6bd8217 100644 --- a/patches/@sinonjs__fake-timers@14.0.0.patch +++ b/patches/@sinonjs__fake-timers@15.0.0.patch @@ -1,5 +1,5 @@ diff --git a/src/fake-timers-src.js b/src/fake-timers-src.js -index 11dab90bd4bafd8c3a232df20f82ec5bcf06e76d..1f633e6293bc4bff97ccf9a23214944c0f6f8395 100644 +index a9bcfd1ca..942539085 100644 --- a/src/fake-timers-src.js +++ b/src/fake-timers-src.js @@ -2,14 +2,14 @@ @@ -20,7 +20,7 @@ index 11dab90bd4bafd8c3a232df20f82ec5bcf06e76d..1f633e6293bc4bff97ccf9a23214944c } catch (e) { // ignored } -@@ -172,7 +172,7 @@ function withGlobal(_global) { +@@ -197,7 +197,7 @@ function withGlobal(_global) { isPresent.hrtime && typeof _global.process.hrtime.bigint === "function"; isPresent.nextTick = _global.process && typeof _global.process.nextTick === "function"; diff --git a/patches/@types__sinonjs__fake-timers@8.1.5.patch b/patches/@types__sinonjs__fake-timers@8.1.5.patch index e91f5b0f4a7d..d09e5ada4430 100644 --- a/patches/@types__sinonjs__fake-timers@8.1.5.patch +++ b/patches/@types__sinonjs__fake-timers@8.1.5.patch @@ -1,40 +1,92 @@ diff --git a/index.d.ts b/index.d.ts -index 5aa018cde4336aca4dadefb8338549c378792e14..1b8136e5fb4c6666a46dbef765c9624d62fdb3a5 100644 +index 5aa018cde4336aca4dadefb8338549c378792e14..d3a07cc966f10c70950fc8e2e8b897038a7a6a11 100644 --- a/index.d.ts +++ b/index.d.ts -@@ -329,13 +329,15 @@ export interface FakeTimerInstallOpts { - now?: number | Date | undefined; - +@@ -84,12 +84,12 @@ export interface GlobalTimers { + */ + export interface NodeTimer { /** -- * An array with names of global methods and APIs to fake. By default, `@sinonjs/fake-timers` does not replace `nextTick()` and `queueMicrotask()`. -- * For instance, `FakeTimers.install({ toFake: ['setTimeout', 'nextTick'] })` will fake only `setTimeout()` and `nextTick()` -+ * An array with names of global methods and APIs to fake. -+ * For instance, `vi.useFakeTimer({ toFake: ['setTimeout', 'performance'] })` will fake only `setTimeout()` and `performance.now()` -+ * @default everything available globally except `nextTick` +- * Stub method call. Does nothing. ++ * Marks the timer as referenced. */ - toFake?: FakeMethod[] | undefined; + ref(): NodeTimer; /** -- * The maximum number of timers that will be run when calling runAll() (default: 1000) -+ * The maximum number of timers that will be run when calling runAll() -+ * @default 10000 +- * Stub method call. Does nothing. ++ * Marks the timer as unreferenced. */ - loopLimit?: number | undefined; + unref(): NodeTimer; -@@ -352,10 +354,16 @@ export interface FakeTimerInstallOpts { - advanceTimeDelta?: number | undefined; +@@ -97,6 +97,11 @@ export interface NodeTimer { + * Refreshes the timer. + */ + refresh(): NodeTimer; ++ ++ /** ++ * Returns true if the timer will keep the event loop active. ++ */ ++ hasRef(): boolean; + } + + /** +@@ -104,6 +109,18 @@ export interface NodeTimer { + */ + export type TimerId = number | NodeTimer; + ++/** ++ * Allows configuring how the clock advances time, automatically or manually. ++ * ++ * - `manual`: Timers do not advance without explicit, manual calls to the tick APIs (`clock.nextAsync`, `clock.runAllAsync`, etc). ++ * - `nextAsync`: The clock will continuously break the event loop, then run the next timer until the mode changes. ++ * - `interval`: This is the same as specifying `shouldAdvanceTime: true` with an `advanceTimeDelta`. If the delta is not specified, 20 will be used by default. ++ */ ++export type TimerTickMode = ++ | { mode: "manual" } ++ | { mode: "nextAsync" } ++ | { mode: "interval"; delta?: number }; ++ + /** + * Controls the flow of time. + */ +@@ -246,6 +263,18 @@ export interface FakeClock extends GlobalTimers Promise; ++ /** ++ * Advances the clock by `time` milliseconds. ++ * ++ * Any timers within the affected range will be moved to the end of the range. ++ * Then, the clock will be advanced by `time` milliseconds, firing any timers ++ * that are now due. ++ * ++ * @param time How many ticks to advance by. ++ * @returns Fake milliseconds since the unix epoch. ++ */ ++ jump: (time: number | string) => number; ++ /** -- * Tells FakeTimers to clear 'native' (i.e. not fake) timers by delegating to their respective handlers. These are not cleared by -- * default, leading to potentially unexpected behavior if timers existed prior to installing FakeTimers. (default: false) -+ * Tells FakeTimers to clear 'native' (i.e. not fake) timers by delegating to their respective handlers. -+ * @default true + * Simulates a user changing the system clock. + * +@@ -253,6 +282,12 @@ export interface FakeClock extends GlobalTimers void; ++ ++ /** ++ * Allows configuring how the clock advances time, automatically or manually. ++ * @param tickModeConfig The new configuration for how the clock should tick. ++ */ ++ setTickMode: (tickModeConfig: TimerTickMode) => void; + } + + /** +@@ -356,6 +391,11 @@ export interface FakeTimerInstallOpts { + * default, leading to potentially unexpected behavior if timers existed prior to installing FakeTimers. (default: false) */ shouldClearNativeTimers?: boolean | undefined; + + /** -+ * Don't throw error when asked to fake timers that are not present. -+ * @default false ++ * Tells FakeTimers to not throw an error when faking a timer that does not exist in the global object. (default: false) + */ + ignoreMissingTimers?: boolean | undefined; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57925746feaa..d34f9460d694 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,11 +131,11 @@ overrides: vitest: workspace:* patchedDependencies: - '@sinonjs/fake-timers@14.0.0': - hash: 6f7091fcf11165157b4bfb8dde6b5b6b7697e3efd6e9b53bb4bdef442f60a278 - path: patches/@sinonjs__fake-timers@14.0.0.patch + '@sinonjs/fake-timers@15.0.0': + hash: 8f3309cba0158608885141fb640e96b064570f7399136966ff13523bdaf678b2 + path: patches/@sinonjs__fake-timers@15.0.0.patch '@types/sinonjs__fake-timers@8.1.5': - hash: 0218b33f433e26861380c2b90c757bde6fea871cb988083c0bd4a9a1f6c00252 + hash: 6e3576fcd6d68149bfb3f392527e01c1c01543c5e12a9e0dc311202562c5e3a2 path: patches/@types__sinonjs__fake-timers@8.1.5.patch acorn@8.11.3: hash: 62f89b815dbd769c8a4d5b19b1f6852f28922ecb581d876c8a8377d05c2483c4 @@ -1034,8 +1034,8 @@ importers: specifier: 'catalog:' version: 0.3.31 '@sinonjs/fake-timers': - specifier: 14.0.0 - version: 14.0.0(patch_hash=6f7091fcf11165157b4bfb8dde6b5b6b7697e3efd6e9b53bb4bdef442f60a278) + specifier: 15.0.0 + version: 15.0.0(patch_hash=8f3309cba0158608885141fb640e96b064570f7399136966ff13523bdaf678b2) '@types/debug': specifier: 'catalog:' version: 4.1.12 @@ -1062,7 +1062,7 @@ importers: version: 2.4.9 '@types/sinonjs__fake-timers': specifier: ^8.1.5 - version: 8.1.5(patch_hash=0218b33f433e26861380c2b90c757bde6fea871cb988083c0bd4a9a1f6c00252) + version: 8.1.5(patch_hash=6e3576fcd6d68149bfb3f392527e01c1c01543c5e12a9e0dc311202562c5e3a2) acorn-walk: specifier: 'catalog:' version: 8.3.4 @@ -4024,8 +4024,8 @@ packages: '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} - '@sinonjs/fake-timers@14.0.0': - resolution: {integrity: sha512-QfoXRaUTjMVVn/ZbnD4LS3TPtqOkOdKIYCKldIVPnuClcwRKat6LI2mRZ2s5qiBfO6Fy03An35dSls/2/FEc0Q==} + '@sinonjs/fake-timers@15.0.0': + resolution: {integrity: sha512-dlUB2oL+hDIYkIq/OWFBDhQAuU6kDey3eeMiYpVb7UXHhkMq/r1HloKXAbJwJZpYWkFWsydLjMqDpueMUEOjXQ==} '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -11932,7 +11932,7 @@ snapshots: dependencies: type-detect: 4.0.8 - '@sinonjs/fake-timers@14.0.0(patch_hash=6f7091fcf11165157b4bfb8dde6b5b6b7697e3efd6e9b53bb4bdef442f60a278)': + '@sinonjs/fake-timers@15.0.0(patch_hash=8f3309cba0158608885141fb640e96b064570f7399136966ff13523bdaf678b2)': dependencies: '@sinonjs/commons': 3.0.1 @@ -12223,7 +12223,7 @@ snapshots: '@types/resolve@1.20.2': {} - '@types/sinonjs__fake-timers@8.1.5(patch_hash=0218b33f433e26861380c2b90c757bde6fea871cb988083c0bd4a9a1f6c00252)': {} + '@types/sinonjs__fake-timers@8.1.5(patch_hash=6e3576fcd6d68149bfb3f392527e01c1c01543c5e12a9e0dc311202562c5e3a2)': {} '@types/statuses@2.0.5': {} @@ -18324,7 +18324,7 @@ snapshots: webdriverio@9.19.2: dependencies: '@types/node': 20.19.17 - '@types/sinonjs__fake-timers': 8.1.5(patch_hash=0218b33f433e26861380c2b90c757bde6fea871cb988083c0bd4a9a1f6c00252) + '@types/sinonjs__fake-timers': 8.1.5(patch_hash=6e3576fcd6d68149bfb3f392527e01c1c01543c5e12a9e0dc311202562c5e3a2) '@wdio/config': 9.19.2 '@wdio/logger': 9.18.0 '@wdio/protocols': 9.16.2 diff --git a/test/core/test/fixtures/timers.suite.ts b/test/core/test/fixtures/timers.suite.ts index 9647fd0a2f92..5cbf1cfdd0b1 100644 --- a/test/core/test/fixtures/timers.suite.ts +++ b/test/core/test/fixtures/timers.suite.ts @@ -10,7 +10,7 @@ * LICENSE file in the root directory of https://github.com/facebook/jest. */ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FakeTimers } from '../../../../packages/vitest/src/integrations/mock/timers' class FakeDate extends Date {} @@ -1503,4 +1503,86 @@ describe('FakeTimers', () => { timers.useRealTimers() }) }) + + describe('setTimerTickMode', () => { + const realTimeout = setTimeout; + let timers: FakeTimers; + + beforeEach(() => { + timers = new FakeTimers({ global }) + timers.useFakeTimers(); + }) + afterEach(() => { + timers.useRealTimers(); + }) + + it('can be set to manual', async () => { + const spy = vi.fn() + setTimeout(spy, 10) + + timers.setTimerTickMode('manual') + await new Promise(resolve => realTimeout(resolve, 20)) + + expect(spy).not.toHaveBeenCalled() + + timers.advanceTimersByTime(100) + expect(spy).toHaveBeenCalledOnce() + }) + + it('can be set to nextTimerAsync', async () => { + const spy = vi.fn() + setTimeout(spy, 10_000_000) + + timers.setTimerTickMode('nextTimerAsync') + await new Promise(resolve => setTimeout(resolve, 20_000_000)) + + expect(spy).toHaveBeenCalledOnce() + }) + + it('can be set to interval', async () => { + const spy = vi.fn() + setTimeout(spy, 10) + + timers.setTimerTickMode('interval', 5) + await new Promise(resolve => setTimeout(resolve, 15)) + + expect(spy).toHaveBeenCalledOnce() + }) + + it('can switch from nextTimerAsync to manual', async () => { + const spy = vi.fn() + setTimeout(spy, 10) + + timers.setTimerTickMode('nextTimerAsync') + + // Let one macrotask run, but the timer is not due yet. + await new Promise(resolve => setTimeout(resolve, 5)) + expect(spy).not.toHaveBeenCalled() + + timers.setTimerTickMode('manual') + + await new Promise(resolve => realTimeout(resolve, 10)) + + expect(spy).not.toHaveBeenCalled() + + timers.advanceTimersByTime(100) + expect(spy).toHaveBeenCalledOnce() + }) + + it('nextTimerAsync advances timers scheduled inside other timers', async () => { + const nestedSpy = vi.fn() + const spy = vi.fn(() => { + setTimeout(nestedSpy, 50) + }) + setTimeout(spy, 100) + + timers.setTimerTickMode('nextTimerAsync') + + // Wait long enough for both timers to have a chance to run + await new Promise(resolve => setTimeout(resolve, 300)) + + expect(spy).toHaveBeenCalledOnce() + expect(nestedSpy).toHaveBeenCalledOnce() + }) + }) })