diff --git a/docs/Configuration.md b/docs/Configuration.md index 1ca9a15e1341..018cf8498bd1 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -674,11 +674,30 @@ type FakeableAPI = type ModernFakeTimersConfig = { /** - * If set to `true` all timers will be advanced automatically by 20 milliseconds - * every 20 milliseconds. A custom time delta may be provided by passing a number. - * The default is `false`. + * There are 3 different types of modes for advancing timers: + * + * - 'manual': Timers do not advance without explicit, manual calls to the tick + * APIs (`jest.advanceTimersToNextTimer`, `jest.runAllTimers`, etc). This mode is equivalent to `false`. + * - 'nextAsync': Jest will continuously await 'jest.advanceTimersToNextTimerAsync' until the mode changes. + * With this mode, jest will advance the clock to the next timer in the queue after a macrotask. + * As a result, tests can be written in a way that is independent from whether fake timers are installed. + * Tests can always be written to wait for timers to resolve, even when using fake timers. + * - 'interval': In this mode, all timers will be advanced automatically + * by the number of milliseconds provided in the delta. If the delta is + * not specified, 20 will be used by default. This mode is equivalent to `true` or providing a number for the delta. + * + * The 'nextAsync' mode differs from `interval` in two key ways: + * 1. The microtask queue is allowed to empty between each timer execution, + * as would be the case without fake timers installed. + * 1. It advances as quickly and as far as necessary. If the next timer in + * the queue is at 1000ms, it will advance 1000ms immediately whereas interval, + * without manually advancing time in the test, would take `1000 / advanceTimersMs` + * real time to reach and execute the timer. + * + * @defaultValue + * The default mode is `'manual'` (equivalent to `false`). */ - advanceTimers?: boolean | number; + advanceTimers?: boolean | number | AdvanceTimersConfig; /** * List of names of APIs that should not be faked. The default is `[]`, meaning * all APIs are faked. diff --git a/docs/JestObjectAPI.md b/docs/JestObjectAPI.md index 8b0e6ece428f..8d4a27742d23 100644 --- a/docs/JestObjectAPI.md +++ b/docs/JestObjectAPI.md @@ -882,11 +882,30 @@ type FakeableAPI = type FakeTimersConfig = { /** - * If set to `true` all timers will be advanced automatically by 20 milliseconds - * every 20 milliseconds. A custom time delta may be provided by passing a number. - * The default is `false`. + * There are 3 different types of modes for advancing timers: + * + * - 'manual': Timers do not advance without explicit, manual calls to the tick + * APIs (`jest.advanceTimersToNextTimer`, `jest.runAllTimers`, etc). This mode is equivalent to `false`. + * - 'nextAsync': Jest will continuously await 'jest.advanceTimersToNextTimerAsync' until the mode changes. + * With this mode, jest will advance the clock to the next timer in the queue after a macrotask. + * As a result, tests can be written in a way that is independent from whether fake timers are installed. + * Tests can always be written to wait for timers to resolve, even when using fake timers. + * - 'interval': In this mode, all timers will be advanced automatically + * by the number of milliseconds provided in the delta. If the delta is + * not specified, 20 will be used by default. This mode is equivalent to `true` or providing a number for the delta. + * + * The 'nextAsync' mode differs from `interval` in two key ways: + * 1. The microtask queue is allowed to empty between each timer execution, + * as would be the case without fake timers installed. + * 1. It advances as quickly and as far as necessary. If the next timer in + * the queue is at 1000ms, it will advance 1000ms immediately whereas interval, + * without manually advancing time in the test, would take `1000 / advanceTimersMs` + * real time to reach and execute the timer. + * + * @defaultValue + * The default mode is `'manual'` (equivalent to `false`). */ - advanceTimers?: boolean | number; + advanceTimers?: boolean | number | AdvanceTimersConfig; /** * List of names of APIs that should not be faked. The default is `[]`, meaning * all APIs are faked. @@ -1067,10 +1086,20 @@ This means, if any timers have been scheduled (but have not yet executed), they Returns the number of fake timers still left to run. +### `jest.setAdvanceTimers(config)` + +Used to update the configured `AdvanceTimersConfig` after the fake timers have been installed. See `jest.useFakeTimers` for more information. + ### `jest.now()` Returns the time in ms of the current clock. This is equivalent to `Date.now()` if real timers are in use, or if `Date` is mocked. In other cases (such as legacy timers) it may be useful for implementing custom mocks of `Date.now()`, `performance.now()`, etc. +:::info + +This function is not available when using legacy fake timers implementation. + +::: + ### `jest.setSystemTime(now?: number | Date)` Set the current system time used by fake timers. Simulates a user changing the system clock while your program is running. It affects the current time but it does not in itself cause e.g. timers to fire; they will fire exactly as they would have done without the call to `jest.setSystemTime()`. diff --git a/packages/jest-environment/src/index.ts b/packages/jest-environment/src/index.ts index 9f1eccc3c378..907d30ab4918 100644 --- a/packages/jest-environment/src/index.ts +++ b/packages/jest-environment/src/index.ts @@ -415,4 +415,23 @@ 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 mode is 'nextAsync', configures whether timers advance automatically. With automatically advancing + * timers enabled, tests can be written in a way that is independent from whether + * fake timers are installed. Tests can always be written to wait for timers to + * resolve, even when using fake timers. + * + * 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. + * In addition, the mode can only be changed from 'nextAsync' to 'manual' or vice versa. + * It cannot currently be used with 'interval' from `AdvanceTimersConfig`. + */ + setAdvanceTimers(config: {mode: 'manual' | 'nextAsync'}): void; } diff --git a/packages/jest-fake-timers/src/__tests__/__snapshots__/modernFakeTimers.test.ts.snap b/packages/jest-fake-timers/src/__tests__/__snapshots__/modernFakeTimers.test.ts.snap index daf980ad8701..1ba242c55088 100644 --- a/packages/jest-fake-timers/src/__tests__/__snapshots__/modernFakeTimers.test.ts.snap +++ b/packages/jest-fake-timers/src/__tests__/__snapshots__/modernFakeTimers.test.ts.snap @@ -1,3 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`FakeTimers advanceTimers nextAsync warns when trying to set tick mode when already using interval 1`] = `"\`setTickMode\` cannot be used when fake timers are configured to advance at an interval."`; + exports[`FakeTimers runAllTimers warns when trying to advance timers while real timers are used 1`] = `"A function to advance timers was called but the timers APIs are not replaced with fake timers. Call \`jest.useFakeTimers()\` in this test file or enable fake timers for all tests by setting 'fakeTimers': {'enableGlobally': true} in Jest configuration file."`; diff --git a/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts b/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts index 71a542c607da..764f88a83a94 100644 --- a/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts +++ b/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts @@ -1251,6 +1251,159 @@ describe('FakeTimers', () => { }); }); + describe('advanceTimers', () => { + let global: typeof globalThis; + let timers: FakeTimers; + beforeEach(() => { + global = { + Date, + Promise, + clearInterval, + clearTimeout, + console, + process, + setInterval, + setTimeout, + } as unknown as typeof globalThis; + + timers = new FakeTimers({config: makeProjectConfig(), global}); + }); + + afterEach(() => { + timers.clearAllTimers(); + timers.dispose(); + }); + + describe('nextAsync', () => { + beforeEach(() => { + timers.useFakeTimers({advanceTimers: {mode: 'nextAsync'}}); + }); + + it('can always wait for a timer to execute', async () => { + const p = new Promise(resolve => { + global.setTimeout(resolve, 100); + }); + await expect(p).resolves.toBeUndefined(); + }); + + it('can mix promises inside timers', async () => { + const p = new Promise(resolve => + global.setTimeout(async () => { + await Promise.resolve(); + global.setTimeout(resolve, 100); + }, 100), + ); + await expect(p).resolves.toBeUndefined(); + }); + + it('automatically advances all timers', async () => { + const p1 = new Promise(resolve => global.setTimeout(resolve, 50)); + const p2 = new Promise(resolve => global.setTimeout(resolve, 50)); + const p3 = new Promise(resolve => global.setTimeout(resolve, 100)); + await expect(Promise.all([p1, p2, p3])).resolves.toEqual([ + undefined, + undefined, + undefined, + ]); + }); + + it('can turn off and on auto advancing of time', async () => { + let p2Resolved = false; + const p1 = new Promise(resolve => global.setTimeout(resolve, 1)); + const p2 = new Promise(resolve => + global.setTimeout(() => { + p2Resolved = true; + resolve(); + }, 2), + ); + const p3 = new Promise(resolve => global.setTimeout(resolve, 3)); + + await expect(p1).resolves.toBeUndefined(); + + timers.setTickMode('manual'); + // wait real, unpatched time to ensure p2 doesn't resolve on its own + await new Promise(resolve => setTimeout(resolve, 5)); + expect(p2Resolved).toBe(false); + + // simply updating the tick mode should not result in time immediately advancing + timers.setTickMode('nextAsync'); + expect(p2Resolved).toBe(false); + + // wait real, unpatched time and observe p2 and p3 resolve on their own + await new Promise(resolve => setTimeout(resolve, 5)); + await expect(p2).resolves.toBeUndefined(); + await expect(p3).resolves.toBeUndefined(); + expect(p2Resolved).toBe(true); + }); + + it('warns when trying to set tick mode when already using interval', () => { + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => { + // nothing + }); + timers.useFakeTimers({advanceTimers: {mode: 'interval'}}); + timers.setTickMode('nextAsync'); + expect( + consoleWarnSpy.mock.calls[0][0].split('\nStack Trace')[0], + ).toMatchSnapshot(); + consoleWarnSpy.mockRestore(); + }); + + describe('works with manual calls to async tick functions', () => { + let timerLog: number[]; + let allTimersDone: Promise; + beforeEach(() => { + timerLog = []; + allTimersDone = new Promise(resolve => { + global.setTimeout(() => timerLog.push(1), 1); + global.setTimeout(() => timerLog.push(2), 2); + global.setTimeout(() => timerLog.push(3), 3); + global.setTimeout(() => { + timerLog.push(4); + global.setTimeout(() => { + timerLog.push(5); + resolve(); + }, 1); + }, 5); + }); + }); + + afterEach(async () => { + await allTimersDone; + expect(timerLog).toEqual([1, 2, 3, 4, 5]); + }); + + it('runAllTimersAsync', async () => { + await timers.runAllTimersAsync(); + expect(timerLog).toEqual([1, 2, 3, 4, 5]); + }); + + it('runOnlyPendingTimersAsync', async () => { + await timers.runOnlyPendingTimersAsync(); + // 5 should not resolve because it wasn't queued when we called "only pending timers" + expect(timerLog).toEqual([1, 2, 3, 4]); + }); + + it('advanceTimersToNextTimerAsync', async () => { + await timers.advanceTimersToNextTimerAsync(); + expect(timerLog).toEqual([1]); + await timers.advanceTimersToNextTimerAsync(); + expect(timerLog).toEqual([1, 2]); + await timers.advanceTimersToNextTimerAsync(); + expect(timerLog).toEqual([1, 2, 3]); + }); + + it('advanceTimersByTimeAsync', async () => { + await timers.advanceTimersByTimeAsync(2); + expect(timerLog).toEqual([1, 2]); + await timers.advanceTimersByTimeAsync(1); + expect(timerLog).toEqual([1, 2, 3]); + }); + }); + }); + }); + describe('runAllTimersAsync', () => { it('should advance the clock to the last scheduled timer', async () => { const global = { @@ -1260,6 +1413,7 @@ describe('FakeTimers', () => { process, setTimeout, } as unknown as typeof globalThis; + const timers = new FakeTimers({config: makeProjectConfig(), global}); timers.useFakeTimers(); timers.setSystemTime(0); diff --git a/packages/jest-fake-timers/src/index.ts b/packages/jest-fake-timers/src/index.ts index 95290b87d9cd..1c9175953fe8 100644 --- a/packages/jest-fake-timers/src/index.ts +++ b/packages/jest-fake-timers/src/index.ts @@ -7,3 +7,4 @@ export {default as LegacyFakeTimers} from './legacyFakeTimers'; export {default as ModernFakeTimers} from './modernFakeTimers'; +export type {TimerTickMode} from './modernFakeTimers'; diff --git a/packages/jest-fake-timers/src/modernFakeTimers.ts b/packages/jest-fake-timers/src/modernFakeTimers.ts index 310cf4be6b85..4d35d797ffee 100644 --- a/packages/jest-fake-timers/src/modernFakeTimers.ts +++ b/packages/jest-fake-timers/src/modernFakeTimers.ts @@ -15,12 +15,20 @@ import { import type {Config} from '@jest/types'; import {formatStackTrace} from 'jest-message-util'; +export type TimerTickMode = 'manual' | 'nextAsync' | 'interval'; + export default class FakeTimers { private _clock!: InstalledClock; + private _nativeTimeout: typeof setTimeout; private readonly _config: Config.ProjectConfig; private _fakingTime: boolean; + private _usingSinonAdvanceTime = false; private readonly _global: typeof globalThis; private readonly _fakeTimers: FakeTimerWithContext; + private tickMode: {counter: number; mode: TimerTickMode} = { + counter: 0, + mode: 'manual', + }; constructor({ global, @@ -31,6 +39,7 @@ export default class FakeTimers { }) { this._global = global; this._config = config; + this._nativeTimeout = global.setTimeout; this._fakingTime = false; this._fakeTimers = withGlobal(global); @@ -54,7 +63,7 @@ export default class FakeTimers { async runAllTimersAsync(): Promise { if (this._checkFakeTimers()) { - await this._clock.runAllAsync(); + await this._runWithoutNextAsyncTickMode(() => this._clock.runAllAsync()); } } @@ -66,7 +75,9 @@ export default class FakeTimers { async runOnlyPendingTimersAsync(): Promise { if (this._checkFakeTimers()) { - await this._clock.runToLastAsync(); + await this._runWithoutNextAsyncTickMode(() => + this._clock.runToLastAsync(), + ); } } @@ -87,9 +98,11 @@ export default class FakeTimers { async advanceTimersToNextTimerAsync(steps = 1): Promise { if (this._checkFakeTimers()) { for (let i = steps; i > 0; i--) { - await this._clock.nextAsync(); - // Fire all timers at this point: https://github.com/sinonjs/fake-timers/issues/250 - await this._clock.tickAsync(0); + await this._runWithoutNextAsyncTickMode(async () => { + await this._clock.nextAsync(); + // Fire all timers at this point: https://github.com/sinonjs/fake-timers/issues/250 + await this._clock.tickAsync(0); + }); if (this._clock.countTimers() === 0) { break; @@ -106,7 +119,9 @@ export default class FakeTimers { async advanceTimersByTimeAsync(msToRun: number): Promise { if (this._checkFakeTimers()) { - await this._clock.tickAsync(msToRun); + await this._runWithoutNextAsyncTickMode(() => + this._clock.tickAsync(msToRun), + ); } } @@ -140,6 +155,47 @@ export default class FakeTimers { ); this._fakingTime = true; + if ( + fakeTimersConfig && + typeof fakeTimersConfig.advanceTimers === 'object' + ) { + this._setTickModeInternal(fakeTimersConfig.advanceTimers.mode); + } + } + + setTickMode(newMode: 'nextAsync' | 'manual'): void { + if (!this._checkFakeTimers()) { + return; + } + if (this._usingSinonAdvanceTime) { + this._global.console.warn( + '`setTickMode` cannot be used when fake timers are configured to advance at an interval.' + + `\nStack Trace:\n${formatStackTrace( + // eslint-disable-next-line unicorn/error-message + new Error().stack!, + this._config, + {noStackTrace: false}, + )}`, + ); + return; + } + + this._setTickModeInternal(newMode); + } + + private _setTickModeInternal(newMode: TimerTickMode): void { + if (newMode === this.tickMode.mode) { + return; + } + + this.tickMode = { + counter: this.tickMode.counter + 1, + mode: newMode, + }; + + if (newMode === 'nextAsync') { + this._advanceUntilModeChanges(); + } } reset(): void { @@ -201,10 +257,22 @@ export default class FakeTimers { ...fakeTimersConfig, } as Config.FakeTimersConfig; - const advanceTimeDelta = - typeof fakeTimersConfig.advanceTimers === 'number' - ? fakeTimersConfig.advanceTimers - : undefined; + let advanceTimeDelta: number | undefined = undefined; + let shouldAdvanceTime = false; + const advanceTimersConfig = fakeTimersConfig.advanceTimers; + if (typeof advanceTimersConfig === 'number') { + shouldAdvanceTime = true; + advanceTimeDelta = advanceTimersConfig; + } else if (typeof advanceTimersConfig === 'boolean') { + shouldAdvanceTime = advanceTimersConfig; + } else if ( + typeof advanceTimersConfig === 'object' && + advanceTimersConfig.mode === 'interval' + ) { + shouldAdvanceTime = true; + advanceTimeDelta = advanceTimersConfig.delta; + } + this._usingSinonAdvanceTime = shouldAdvanceTime; const toFake = new Set( Object.keys(this._fakeTimers.timers) as Array, @@ -219,9 +287,47 @@ export default class FakeTimers { advanceTimeDelta, loopLimit: fakeTimersConfig.timerLimit || 100_000, now: fakeTimersConfig.now ?? Date.now(), - shouldAdvanceTime: Boolean(fakeTimersConfig.advanceTimers), + shouldAdvanceTime, shouldClearNativeTimers: true, toFake: [...toFake], }; } + + /** + * Advances the Clock's time until the mode changes. + * + * The time is advanced asynchronously, giving microtasks and events a chance + * to run before each timer runs. + */ + private async _advanceUntilModeChanges() { + if (!this._checkFakeTimers()) { + return; + } + const {counter} = this.tickMode; + + // Wait a macrotask to prevent advancing time immediately when + await new Promise(resolve => void this._nativeTimeout(resolve)); + while (this.tickMode.counter === counter && this._fakingTime) { + // nextAsync always resolves in a setTimeout, even when there are no timers. + // https://github.com/sinonjs/fake-timers/blob/710cafad25abe9465c807efd8ed9cf3a15985fb1/src/fake-timers-src.js#L1517-L1546 + await this._clock.nextAsync(); + } + } + + /** + * Temporarily disables the `nextAsync` tick mode while the given function + * executes. Used to prevent the auto-advance from advancing while the + * user is waiting for a manually requested async tick. + */ + private async _runWithoutNextAsyncTickMode(fn: () => Promise) { + let resetModeToNextAsync = false; + if (this.tickMode.mode === 'nextAsync') { + this.setTickMode('manual'); + resetModeToNextAsync = true; + } + await fn(); + if (resetModeToNextAsync) { + this.setTickMode('nextAsync'); + } + } } diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 477be4b7ef22..fce0ef2bfdb2 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -2407,6 +2407,17 @@ export default class Runtime { ); } }, + setAdvanceTimers: config => { + const fakeTimers = _getFakeTimers(); + + if (fakeTimers === this._environment.fakeTimersModern) { + fakeTimers.setTickMode(config.mode); + } else { + throw new TypeError( + '`jest.advanceTimersToNextTimerAsync()` is not available when using legacy fake timers.', + ); + } + }, setMock: (moduleName, mock) => setMockFactory(moduleName, () => mock), setSystemTime: now => { const fakeTimers = _getFakeTimers(); diff --git a/packages/jest-schemas/src/raw-types.ts b/packages/jest-schemas/src/raw-types.ts index be58048ff0f8..9241bdc08568 100644 --- a/packages/jest-schemas/src/raw-types.ts +++ b/packages/jest-schemas/src/raw-types.ts @@ -142,12 +142,28 @@ const RawFakeableAPI = Type.Union([ const RawFakeTimersConfig = Type.Partial( Type.Object({ - advanceTimers: Type.Union([Type.Boolean(), Type.Number({minimum: 0})], { - description: - 'If set to `true` all timers will be advanced automatically by 20 milliseconds every 20 milliseconds. A custom ' + - 'time delta may be provided by passing a number.', - default: false, - }), + advanceTimers: Type.Union( + [ + Type.Boolean(), + Type.Number({minimum: 0}), + Type.Partial( + Type.Object({ + mode: Type.Union([ + Type.Literal('nextAsync'), + Type.Literal('manual'), + Type.Literal('interval'), + ]), + delta: Type.Number({minimum: 0}), + }), + ), + ], + { + description: + "If set to `true` (equivalent to `{mode: 'interval'}`) all timers will be advanced automatically by 20 milliseconds every 20 milliseconds. A custom " + + "time delta may be provided by passing a number (equivalent to `{mode: 'interval', delta: myDelta}`).", + default: false, + }, + ), doNotFake: Type.Array(RawFakeableAPI, { description: 'List of names of APIs (e.g. `Date`, `nextTick()`, `setImmediate()`, `setTimeout()`) that should not be faked.' + diff --git a/packages/jest-types/src/Config.ts b/packages/jest-types/src/Config.ts index debea999ed27..cef824112dd9 100644 --- a/packages/jest-types/src/Config.ts +++ b/packages/jest-types/src/Config.ts @@ -42,16 +42,45 @@ export type GlobalFakeTimersConfig = { enableGlobally?: boolean; }; +export type AdvanceTimersConfig = + | { + mode: 'manual' | 'nextAsync'; + } + | { + mode: 'interval'; + delta?: number; + }; + export type FakeTimersConfig = { /** * If set to `true` all timers will be advanced automatically * by 20 milliseconds every 20 milliseconds. A custom time delta * may be provided by passing a number. * + * There are 3 different types of modes for advancing timers: + * + * - 'manual': Timers do not advance without explicit, manual calls to the tick + * APIs (`jest.advanceTimersToNextTimer`, `jest.runAllTimers`, etc). + * - 'nextAsync': Jest will continuously await 'jest.advanceTimersToNextTimerAsync' until the mode changes. + * With this mode, jest will advance the clock to the next timer in the queue after a macrotask. + * As a result, tests can be written in a way that is independent from whether fake timers are installed. + * Tests can always be written to wait for timers to resolve, even when using fake timers. + * - 'interval': In this mode, all timers will be advanced automatically + * by the number of milliseconds provided in the delta. If the delta is + * not specified, 20 will be used by default. + * + * The 'nextAsync' mode differs from `interval` in two key ways: + * 1. The microtask queue is allowed to empty between each timer execution, + * as would be the case without fake timers installed. + * 1. It advances as quickly and as far as necessary. If the next timer in + * the queue is at 1000ms, it will advance 1000ms immediately whereas interval, + * without manually advancing time in the test, would take `1000 / advanceTimersMs` + * real time to reach and execute the timer. + * * @defaultValue - * The default is `false`. + * The default mode is `'manual'` (equivalent to `false`). */ - advanceTimers?: boolean | number; + advanceTimers?: boolean | number | AdvanceTimersConfig; /** * List of names of APIs (e.g. `Date`, `nextTick()`, `setImmediate()`, * `setTimeout()`) that should not be faked.