diff --git a/CHANGELOG.md b/CHANGELOG.md index 564e7585c84f..8e5947320ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Features - `[jest-config]` Add `defineConfig` and `mergeConfig` helpers for type-safe Jest config ([#15844](https://github.com/jestjs/jest/pull/15844)) +- `[jest-fake-timers]` Add `setTimerTickMode` to configure how timers advance ### Fixes @@ -12,6 +13,7 @@ ### Chore & Maintenance - `[docs]` Update V30 migration guide to notify users on `jest.mock()` work with case-sensitive path ([#15849](https://github.com/jestjs/jest/pull/15849)) +- `[deps]` Update to sinon/fake-timers v15 ## 30.2.0 diff --git a/docs/JestObjectAPI.md b/docs/JestObjectAPI.md index fa7771c7a6b0..6c976c64e0da 100644 --- a/docs/JestObjectAPI.md +++ b/docs/JestObjectAPI.md @@ -1126,6 +1126,29 @@ This function is not available when using legacy fake timers implementation. ::: +### `jest.setTimerTickMode(mode)` + +Allows configuring how fake timers advance time. + +Configuration options: + +```ts +type TimerTickMode = + | {mode: 'manual'} + | {mode: 'nextAsync'} + | {mode: 'interval'; delta?: number}; +``` + +- `manual`: Timers do not advance without explicit, manual calls to the tick APIs (`jest.advanceTimersByTime(ms)`, `jest.runAllTimers()`, 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 `advanceTimers: true` with an `advanceTimeDelta`. If the delta is not specified, 20 will be used by default. + +:::info + +This function is not available when using legacy fake timers implementation. + +::: + ### `jest.getRealSystemTime()` When mocking time, `Date.now()` will also be mocked. If you for some reason need access to the real current time, you can invoke this function. diff --git a/e2e/fake-timers/set-timer-tick-mode/__tests__/setTimerTickMode.test.js b/e2e/fake-timers/set-timer-tick-mode/__tests__/setTimerTickMode.test.js new file mode 100644 index 000000000000..91145725d452 --- /dev/null +++ b/e2e/fake-timers/set-timer-tick-mode/__tests__/setTimerTickMode.test.js @@ -0,0 +1,42 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const realSetTimeout = setTimeout; + +describe('jest.setTimerTickMode', () => { + it('should not advance the clock with manual', async () => { + jest.useFakeTimers(); + jest.setTimerTickMode({mode: 'manual'}); + const spy = jest.fn(); + setTimeout(spy, 5); + await new Promise(resolve => realSetTimeout(resolve, 20)); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should advance the clock to next timer with nextAsync', async () => { + jest.useFakeTimers(); + jest.setTimerTickMode({mode: 'nextAsync'}); + await new Promise(resolve => setTimeout(resolve, 5000)); + await new Promise(resolve => setTimeout(resolve, 5000)); + await new Promise(resolve => setTimeout(resolve, 5000)); + await new Promise(resolve => setTimeout(resolve, 5000)); + // test should not time out + }); + + it('should advance the clock in real time with delta', async () => { + jest.useFakeTimers(); + jest.setTimerTickMode({delta: 10, mode: 'interval'}); + const spy = jest.fn(); + setTimeout(spy, 10); + await new Promise(resolve => realSetTimeout(resolve, 5)); + expect(spy).not.toHaveBeenCalled(); + await new Promise(resolve => realSetTimeout(resolve, 5)); + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/e2e/fake-timers/set-timer-tick-mode/package.json b/e2e/fake-timers/set-timer-tick-mode/package.json new file mode 100644 index 000000000000..b1625059d3df --- /dev/null +++ b/e2e/fake-timers/set-timer-tick-mode/package.json @@ -0,0 +1,5 @@ +{ + "name": "set-timer-tick-mode-e2e", + "private": true, + "version": "1.0.0" +} diff --git a/packages/jest-environment/src/index.ts b/packages/jest-environment/src/index.ts index 307c73eb9b04..4d8773118343 100644 --- a/packages/jest-environment/src/index.ts +++ b/packages/jest-environment/src/index.ts @@ -425,4 +425,25 @@ export interface Jest { * performance, time and timer APIs. */ useRealTimers(): Jest; + /** + * Updates the mode of advancing timers when using fake timers. + * + * @param config The configuration to use for advancing timers + * + * When the mode is 'interval', timers will be advanced automatically by the [delta] + * milliseconds every [delta] milliseconds of real time. The default delta is 20 milliseconds. + * + * When mode is 'nextAsync', configures whether timers advance automatically to the next timer in the queue after each macrotask. + * This mode differs from 'interval' in that it advances all the way to the next timer, regardless + * of how far in the future that timer is scheduled (e.g. advanceTimersToNextTimerAsync). + * + * When mode is 'manual' (the default), timers will not advance automatically. Instead, + * timers must be advanced using APIs such as `advanceTimersToNextTimer`, `advanceTimersByTime`, etc. + * + * @remarks + * Not available when using legacy fake timers implementation. + */ + setTimerTickMode( + config: {mode: 'manual' | 'nextAsync'} | {mode: 'interval'; delta?: number}, + ): Jest; } diff --git a/packages/jest-fake-timers/package.json b/packages/jest-fake-timers/package.json index 0d6436e31f43..e95667ba90d5 100644 --- a/packages/jest-fake-timers/package.json +++ b/packages/jest-fake-timers/package.json @@ -20,7 +20,7 @@ }, "dependencies": { "@jest/types": "workspace:*", - "@sinonjs/fake-timers": "^13.0.0", + "@sinonjs/fake-timers": "^15.0.0", "@types/node": "*", "jest-message-util": "workspace:*", "jest-mock": "workspace:*", @@ -28,7 +28,7 @@ }, "devDependencies": { "@jest/test-utils": "workspace:*", - "@types/sinonjs__fake-timers": "^8.1.5" + "@types/sinonjs__fake-timers": "15.0.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" diff --git a/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts b/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts index e70222671511..a9dfbf91bf09 100644 --- a/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts +++ b/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts @@ -73,9 +73,7 @@ describe('FakeTimers', () => { Date, clearInterval, clearTimeout, - process: { - nextTick: origNextTick, - }, + process: {nextTick: origNextTick}, setInterval, setTimeout, } as unknown as typeof globalThis; @@ -153,9 +151,7 @@ describe('FakeTimers', () => { Date, clearInterval, clearTimeout, - process: { - nextTick: () => {}, - }, + process: {nextTick: () => {}}, setInterval, setTimeout, } as unknown as typeof globalThis; @@ -186,9 +182,7 @@ describe('FakeTimers', () => { Date, clearInterval, clearTimeout, - process: { - nextTick, - }, + process: {nextTick}, setInterval, setTimeout, } as unknown as typeof globalThis; @@ -205,9 +199,7 @@ describe('FakeTimers', () => { Date, clearInterval, clearTimeout, - process: { - nextTick: () => {}, - }, + process: {nextTick: () => {}}, setInterval, setTimeout, } as unknown as typeof globalThis; @@ -231,9 +223,7 @@ describe('FakeTimers', () => { Date, clearInterval, clearTimeout, - process: { - nextTick: () => {}, - }, + process: {nextTick: () => {}}, setInterval, setTimeout, } as unknown as typeof globalThis; @@ -902,9 +892,7 @@ describe('FakeTimers', () => { Date, clearInterval, clearTimeout, - process: { - nextTick: () => {}, - }, + process: {nextTick: () => {}}, setImmediate: () => {}, setInterval, setTimeout, @@ -1430,6 +1418,48 @@ describe('FakeTimers', () => { }); }); + describe('setTimerTickMode', () => { + let global: typeof globalThis; + let timers: FakeTimers; + const realSetTimeout = setTimeout; + beforeEach(() => { + global = { + Date, + Promise, + clearInterval, + clearTimeout, + process, + setInterval, + setTimeout, + } as unknown as typeof globalThis; + timers = new FakeTimers({config: makeProjectConfig(), global}); + timers.useFakeTimers(); + }); + + afterEach(() => { + timers.useRealTimers(); + }); + + it('should advance the clock to next timer with nextAsync', async () => { + timers.setTimerTickMode({mode: 'nextAsync'}); + await new Promise(resolve => global.setTimeout(resolve, 5000)); + await new Promise(resolve => global.setTimeout(resolve, 5000)); + await new Promise(resolve => global.setTimeout(resolve, 5000)); + await new Promise(resolve => global.setTimeout(resolve, 5000)); + // test should not time out + }); + + it('should advance the clock in real time with delta', async () => { + timers.setTimerTickMode({delta: 10, mode: 'interval'}); + const spy = jest.fn(); + global.setTimeout(spy, 10); + await new Promise(resolve => realSetTimeout(resolve, 5)); + expect(spy).not.toHaveBeenCalled(); + await new Promise(resolve => realSetTimeout(resolve, 5)); + expect(spy).toHaveBeenCalled(); + }); + }); + describe('now', () => { let timers: FakeTimers; let fakedGlobal: typeof globalThis; diff --git a/packages/jest-fake-timers/src/modernFakeTimers.ts b/packages/jest-fake-timers/src/modernFakeTimers.ts index 310cf4be6b85..51d7fe259957 100644 --- a/packages/jest-fake-timers/src/modernFakeTimers.ts +++ b/packages/jest-fake-timers/src/modernFakeTimers.ts @@ -118,7 +118,7 @@ export default class FakeTimers { runAllTicks(): void { if (this._checkFakeTimers()) { - // @ts-expect-error - doesn't exist? + // @ts-expect-error needs an upstream fix: https://github.com/DefinitelyTyped/DefinitelyTyped/pull/73943 this._clock.runMicrotasks(); } } @@ -156,6 +156,15 @@ export default class FakeTimers { } } + setTimerTickMode(tickModeConfig: { + mode: 'interval' | 'manual' | 'nextAsync'; + delta?: number; + }): void { + if (this._checkFakeTimers()) { + this._clock.setTickMode(tickModeConfig); + } + } + getRealSystemTime(): number { return Date.now(); } diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 45f33e27c352..1e8cb33d1fa8 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -1161,10 +1161,7 @@ export default class Runtime { private _getFullTransformationOptions( options: InternalModuleOptions = defaultTransformOptions, ): TransformationOptions { - return { - ...options, - ...this._coverageOptions, - }; + return {...options, ...this._coverageOptions}; } requireModuleOrMock(from: string, moduleName: string): T { @@ -1337,10 +1334,7 @@ export default class Runtime { .map(result => { const transformedFile = this._v8CoverageSources!.get(result.url); - return { - codeTransformResult: transformedFile, - result, - }; + return {codeTransformResult: transformedFile, result}; }); } @@ -1444,9 +1438,7 @@ export default class Runtime { private _resolveCjsModule(from: string, to: string | undefined) { return to - ? this._resolver.resolveModule(from, to, { - conditions: this.cjsConditions, - }) + ? this._resolver.resolveModule(from, to, {conditions: this.cjsConditions}) : from; } @@ -2120,10 +2112,7 @@ export default class Runtime { get: (_target, key) => typeof key === 'string' ? this._moduleRegistry.get(key) : undefined, getOwnPropertyDescriptor() { - return { - configurable: true, - enumerable: true, - }; + return {configurable: true, enumerable: true}; }, has: (_target, key) => typeof key === 'string' && this._moduleRegistry.has(key), @@ -2428,6 +2417,21 @@ export default class Runtime { } }, setTimeout, + setTimerTickMode: ( + mode: + | {mode: 'manual' | 'nextAsync'} + | {mode: 'interval'; delta?: number}, + ) => { + const fakeTimers = _getFakeTimers(); + if (fakeTimers === this._environment.fakeTimersModern) { + fakeTimers.setTimerTickMode(mode); + } else { + throw new TypeError( + '`jest.setTimerTickMode()` is not available when using legacy fake timers.', + ); + } + return jestObject; + }, spyOn, unmock, unstable_mockModule: mockModule, diff --git a/packages/jest-types/__typetests__/jest.test.ts b/packages/jest-types/__typetests__/jest.test.ts index c4edd54dc8b0..095c1cec595d 100644 --- a/packages/jest-types/__typetests__/jest.test.ts +++ b/packages/jest-types/__typetests__/jest.test.ts @@ -651,6 +651,11 @@ expect(jest.useFakeTimers('modern')).type.toRaiseError(); expect(jest.useRealTimers()).type.toBe(); expect(jest.useRealTimers(true)).type.toRaiseError(); +expect(jest.setTimerTickMode('manual')).type.toRaiseError(); +expect(jest.setTimerTickMode({mode: 'interval'})).type.toBe(); +expect(jest.setTimerTickMode({mode: 'manual'})).type.toBe(); +expect(jest.setTimerTickMode({mode: 'nextAsync'})).type.toBe(); + // Misc expect(jest.retryTimes(3)).type.toBe(); diff --git a/yarn.lock b/yarn.lock index 29e7b20101c8..6b890a48726b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4385,9 +4385,9 @@ __metadata: dependencies: "@jest/test-utils": "workspace:*" "@jest/types": "workspace:*" - "@sinonjs/fake-timers": "npm:^13.0.0" + "@sinonjs/fake-timers": "npm:^15.0.0" "@types/node": "npm:*" - "@types/sinonjs__fake-timers": "npm:^8.1.5" + "@types/sinonjs__fake-timers": "npm:15.0.0" jest-message-util: "workspace:*" jest-mock: "workspace:*" jest-util: "workspace:*" @@ -6061,12 +6061,12 @@ __metadata: languageName: node linkType: hard -"@sinonjs/fake-timers@npm:^13.0.0": - version: 13.0.5 - resolution: "@sinonjs/fake-timers@npm:13.0.5" +"@sinonjs/fake-timers@npm:^15.0.0": + version: 15.0.0 + resolution: "@sinonjs/fake-timers@npm:15.0.0" dependencies: "@sinonjs/commons": "npm:^3.0.1" - checksum: 10/11ee417968fc4dce1896ab332ac13f353866075a9d2a88ed1f6258f17cc4f7d93e66031b51fcddb8c203aa4d53fd980b0ae18aba06269f4682164878a992ec3f + checksum: 10/ae9e6fc11ec9e053d4658e276d8b5fac3204b62870f1610feb837baf86d73c604d1464004668e385093d46c22b54eb7f1db6c64593216fadf8c9c501fc2537d4 languageName: node linkType: hard @@ -7038,10 +7038,10 @@ __metadata: languageName: node linkType: hard -"@types/sinonjs__fake-timers@npm:^8.1.5": - version: 8.1.5 - resolution: "@types/sinonjs__fake-timers@npm:8.1.5" - checksum: 10/3a0b285fcb8e1eca435266faa27ffff206608b69041022a42857274e44d9305822e85af5e7a43a9fae78d2ab7dc0fcb49f3ae3bda1fa81f0203064dbf5afd4f6 +"@types/sinonjs__fake-timers@npm:15.0.0": + version: 15.0.0 + resolution: "@types/sinonjs__fake-timers@npm:15.0.0" + checksum: 10/ca16587fdeed07c9fab84cbca54b844763046f6a2e21de3664ea75c417b372a3288ad339d396119fd167cd9e42ada90758ce9a5b372124261886e69749cdf223 languageName: node linkType: hard