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 810cf9772522..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,14 +1086,9 @@ 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.setAdvanceTimersAutomatically()` - -Configures whether timers advance automatically. When enabled, jest will advance the clock to the next timer in the queue after a macrotask. 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. - -This feature differs from the `advanceTimers` in two key ways: +### `jest.setAdvanceTimers(config)` -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 `advanceTimers`, without manually advancing time in the test, would take `1000 / advanceTimersMs` real time to reach and execute the timer. +Used to update the configured `AdvanceTimersConfig` after the fake timers have been installed. See `jest.useFakeTimers` for more information. ### `jest.now()` diff --git a/packages/jest-environment/src/index.ts b/packages/jest-environment/src/index.ts index d9bb2b844a3c..907d30ab4918 100644 --- a/packages/jest-environment/src/index.ts +++ b/packages/jest-environment/src/index.ts @@ -90,16 +90,6 @@ export interface Jest { * Not available when using legacy fake timers implementation. */ advanceTimersToNextTimerAsync(steps?: number): Promise; - /** - * 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. - * - * @remarks - * Not available when using legacy fake timers implementation. - */ - setAdvanceTimersAutomatically(autoAdvance: boolean): void; /** * Disables automatic mocking in the module loader. */ @@ -425,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 12b677042c14..feacc59fec38 100644 --- a/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts +++ b/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts @@ -1251,6 +1251,98 @@ 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.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, 50)); + const p2 = new Promise(resolve => global.setTimeout(resolve, 51)).then( + () => (p2Resolved = true), + ); + const p3 = new Promise(resolve => global.setTimeout(resolve, 52)); + + await expect(p1).resolves.toBeUndefined(); + + timers.setTickMode('manual'); + await new Promise(resolve => setTimeout(resolve, 5)); + expect(p2Resolved).toBe(false); + + timers.setTickMode('nextAsync'); + await new Promise(resolve => setTimeout(resolve, 5)); + await expect(p2).resolves.toBe(true); + 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('runAllTimersAsync', () => { it('should advance the clock to the last scheduled timer', async () => { const global = { @@ -1260,6 +1352,7 @@ describe('FakeTimers', () => { process, setTimeout, } as unknown as typeof globalThis; + const timers = new FakeTimers({config: makeProjectConfig(), global}); timers.useFakeTimers(); timers.setSystemTime(0); @@ -1330,74 +1423,6 @@ describe('FakeTimers', () => { }); }); - describe('setAdvanceTimersAutomatically', () => { - let global: typeof globalThis; - let timers: FakeTimers; - beforeEach(() => { - global = { - Date, - Promise, - clearTimeout, - process, - setTimeout, - } as unknown as typeof globalThis; - - timers = new FakeTimers({config: makeProjectConfig(), global}); - - timers.useFakeTimers(); - timers.setAdvanceTimersAutomatically(true); - }); - - 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, 50)); - const p2 = new Promise(resolve => global.setTimeout(resolve, 51)).then( - () => (p2Resolved = true), - ); - const p3 = new Promise(resolve => global.setTimeout(resolve, 52)); - - await expect(p1).resolves.toBeUndefined(); - - timers.setAdvanceTimersAutomatically(false); - await new Promise(resolve => setTimeout(resolve, 5)); - expect(p2Resolved).toBe(false); - - timers.setAdvanceTimersAutomatically(true); - await new Promise(resolve => setTimeout(resolve, 5)); - await expect(p2).resolves.toBe(true); - await expect(p3).resolves.toBeUndefined(); - expect(p2Resolved).toBe(true); - }); - }); - describe('now', () => { let timers: FakeTimers; let fakedGlobal: typeof globalThis; 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 2454ee90a7bc..5095adcb1ce3 100644 --- a/packages/jest-fake-timers/src/modernFakeTimers.ts +++ b/packages/jest-fake-timers/src/modernFakeTimers.ts @@ -15,13 +15,16 @@ 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 readonly _config: Config.ProjectConfig; private _fakingTime: boolean; + private _usingSinonAdvanceTime = false; private readonly _global: typeof globalThis; private readonly _fakeTimers: FakeTimerWithContext; - private autoTickMode: {counter: number; mode: 'manual' | 'auto'} = { + private tickMode: {counter: number; mode: TimerTickMode} = { counter: 0, mode: 'manual', }; @@ -144,20 +147,45 @@ export default class FakeTimers { ); this._fakingTime = true; + if ( + fakeTimersConfig && + typeof fakeTimersConfig.advanceTimers === 'object' + ) { + this._setTickModeInternal(fakeTimersConfig.advanceTimers.mode); + } } - setAdvanceTimersAutomatically(autoAdvance: boolean): void { + 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); + } - const newMode = autoAdvance ? 'auto' : 'manual'; - if (newMode === this.autoTickMode.mode) { + private _setTickModeInternal(newMode: TimerTickMode): void { + if (newMode === this.tickMode.mode) { return; } - this.autoTickMode = {counter: this.autoTickMode.counter + 1, mode: newMode}; - if (autoAdvance) { + this.tickMode = { + counter: this.tickMode.counter + 1, + mode: newMode, + }; + + if (newMode === 'nextAsync') { this._advanceUntilModeChanges(); } } @@ -221,10 +249,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 = true; + } 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, @@ -239,7 +279,7 @@ export default class FakeTimers { advanceTimeDelta, loopLimit: fakeTimersConfig.timerLimit || 100_000, now: fakeTimersConfig.now ?? Date.now(), - shouldAdvanceTime: Boolean(fakeTimersConfig.advanceTimers), + shouldAdvanceTime, shouldClearNativeTimers: true, toFake: [...toFake], }; @@ -255,9 +295,9 @@ export default class FakeTimers { if (!this._checkFakeTimers()) { return; } - const {counter} = this.autoTickMode; + const {counter} = this.tickMode; - while (this.autoTickMode.counter === counter && this._fakingTime) { + 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(); diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index cc65ca0250b2..fce0ef2bfdb2 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -2407,14 +2407,14 @@ export default class Runtime { ); } }, - setAdvanceTimersAutomatically: (autoAdvance: boolean) => { + setAdvanceTimers: config => { const fakeTimers = _getFakeTimers(); if (fakeTimers === this._environment.fakeTimersModern) { - fakeTimers.setAdvanceTimersAutomatically(autoAdvance); + fakeTimers.setTickMode(config.mode); } else { throw new TypeError( - '`jest.setAdvanceTimersAutomatically()` is not available when using legacy fake timers.', + '`jest.advanceTimersToNextTimerAsync()` is not available when using legacy fake timers.', ); } }, diff --git a/packages/jest-schemas/src/raw-types.ts b/packages/jest-schemas/src/raw-types.ts index be58048ff0f8..157508de9e4c 100644 --- a/packages/jest-schemas/src/raw-types.ts +++ b/packages/jest-schemas/src/raw-types.ts @@ -142,12 +142,25 @@ 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.