From 0b0c91713ffa4e3dbe36b6e2651e02b8df70bf72 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Fri, 20 Sep 2024 13:41:03 -0700 Subject: [PATCH] fixup! feat(jest-fake-timers): Add feature to enable automatically advancing timers --- docs/JestObjectAPI.md | 30 ++-- packages/jest-environment/src/index.ts | 26 ++-- .../src/__tests__/modernFakeTimers.test.ts | 140 +++++++++--------- packages/jest-fake-timers/src/index.ts | 1 + .../jest-fake-timers/src/modernFakeTimers.ts | 20 ++- packages/jest-runtime/src/index.ts | 20 +-- 6 files changed, 123 insertions(+), 114 deletions(-) diff --git a/docs/JestObjectAPI.md b/docs/JestObjectAPI.md index 810cf9772522..082916391e5e 100644 --- a/docs/JestObjectAPI.md +++ b/docs/JestObjectAPI.md @@ -1037,6 +1037,21 @@ Advances all timers by the needed milliseconds so that only the next timeouts/in Optionally, you can provide `steps`, so it will run `steps` amount of next timeouts/intervals. +### `jest.advanceTimersToNextTimerAsync(mode)` + +Configures whether timers advance automatically. When 'auto', 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: + +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. + +:::info + +This function is not available when using legacy fake timers implementation. + +::: + ### `jest.advanceTimersToNextTimerAsync(steps)` Asynchronous equivalent of `jest.advanceTimersToNextTimer(steps)`. It allows any scheduled promise callbacks to execute _before_ running the timers. @@ -1067,25 +1082,10 @@ 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: - -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. - ### `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 d9bb2b844a3c..0ecd493d8218 100644 --- a/packages/jest-environment/src/index.ts +++ b/packages/jest-environment/src/index.ts @@ -6,7 +6,11 @@ */ import type {Context} from 'vm'; -import type {LegacyFakeTimers, ModernFakeTimers} from '@jest/fake-timers'; +import type { + LegacyFakeTimers, + ModernFakeTimers, + TimerTickMode, +} from '@jest/fake-timers'; import type {Circus, Config, Global} from '@jest/types'; import type {Mocked, ModuleMocker} from 'jest-mock'; @@ -82,24 +86,28 @@ export interface Jest { */ advanceTimersToNextTimer(steps?: number): void; /** - * Advances the clock to the the moment of the first scheduled timer, firing it. + * When called with no arguments, advances the clock to the moment of the first + * scheduled timer, firing it. * Optionally, you can provide steps, so it will run steps amount of * next timeouts/intervals. * - * @remarks - * Not available when using legacy fake timers implementation. - */ - advanceTimersToNextTimerAsync(steps?: number): Promise; - /** - * Configures whether timers advance automatically. With automatically advancing + * When called with a `TimerTickMode`, either 'manual' or 'auto', updates the + * behavior of the timer advancement without. + * + * When 'automatic', 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 '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. */ - setAdvanceTimersAutomatically(autoAdvance: boolean): void; + advanceTimersToNextTimerAsync( + stepsOrTickMode?: number | TimerTickMode, + ): Promise; /** * Disables automatic mocking in the module loader. */ diff --git a/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts b/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts index 12b677042c14..1c089dced2c1 100644 --- a/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts +++ b/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts @@ -1249,6 +1249,78 @@ describe('FakeTimers', () => { expect(timers.now()).toBe(200); expect(spy).toHaveBeenCalled(); }); + + describe('auto advance', () => { + 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.advanceTimersToNextTimerAsync('auto'); + }); + + afterEach(() => { + timers.dispose(); + }); + + 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.advanceTimersToNextTimerAsync('manual'); + await new Promise(resolve => setTimeout(resolve, 5)); + expect(p2Resolved).toBe(false); + + timers.advanceTimersToNextTimerAsync('auto'); + await new Promise(resolve => setTimeout(resolve, 5)); + await expect(p2).resolves.toBe(true); + await expect(p3).resolves.toBeUndefined(); + expect(p2Resolved).toBe(true); + }); + }); }); describe('runAllTimersAsync', () => { @@ -1330,74 +1402,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..b09500cb8a0a 100644 --- a/packages/jest-fake-timers/src/modernFakeTimers.ts +++ b/packages/jest-fake-timers/src/modernFakeTimers.ts @@ -15,13 +15,15 @@ import { import type {Config} from '@jest/types'; import {formatStackTrace} from 'jest-message-util'; +export type TimerTickMode = 'manual' | 'auto'; + export default class FakeTimers { private _clock!: InstalledClock; private readonly _config: Config.ProjectConfig; private _fakingTime: boolean; private readonly _global: typeof globalThis; private readonly _fakeTimers: FakeTimerWithContext; - private autoTickMode: {counter: number; mode: 'manual' | 'auto'} = { + private autoTickMode: {counter: number; mode: TimerTickMode} = { counter: 0, mode: 'manual', }; @@ -88,9 +90,16 @@ export default class FakeTimers { } } - async advanceTimersToNextTimerAsync(steps = 1): Promise { + async advanceTimersToNextTimerAsync( + stepsOrMode: number | TimerTickMode = 1, + ): Promise { + if (typeof stepsOrMode === 'string') { + this._setTickMode(stepsOrMode); + return; + } + if (this._checkFakeTimers()) { - for (let i = steps; i > 0; i--) { + for (let i = stepsOrMode; 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); @@ -146,18 +155,17 @@ export default class FakeTimers { this._fakingTime = true; } - setAdvanceTimersAutomatically(autoAdvance: boolean): void { + private _setTickMode(newMode: TimerTickMode): void { if (!this._checkFakeTimers()) { return; } - const newMode = autoAdvance ? 'auto' : 'manual'; if (newMode === this.autoTickMode.mode) { return; } this.autoTickMode = {counter: this.autoTickMode.counter + 1, mode: newMode}; - if (autoAdvance) { + if (newMode === 'auto') { this._advanceUntilModeChanges(); } } diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index cc65ca0250b2..6add6c9a7dc0 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -2322,13 +2322,12 @@ export default class Runtime { }, advanceTimersToNextTimer: steps => _getFakeTimers().advanceTimersToNextTimer(steps), - advanceTimersToNextTimerAsync: async steps => { + advanceTimersToNextTimerAsync: stepsOrTickMode => { const fakeTimers = _getFakeTimers(); - if (fakeTimers === this._environment.fakeTimersModern) { - await fakeTimers.advanceTimersToNextTimerAsync(steps); - } else { - throw new TypeError( + return fakeTimers.advanceTimersToNextTimerAsync(stepsOrTickMode); + } else { + throw new TypeError( '`jest.advanceTimersToNextTimerAsync()` is not available when using legacy fake timers.', ); } @@ -2407,17 +2406,6 @@ export default class Runtime { ); } }, - setAdvanceTimersAutomatically: (autoAdvance: boolean) => { - const fakeTimers = _getFakeTimers(); - - if (fakeTimers === this._environment.fakeTimersModern) { - fakeTimers.setAdvanceTimersAutomatically(autoAdvance); - } else { - throw new TypeError( - '`jest.setAdvanceTimersAutomatically()` is not available when using legacy fake timers.', - ); - } - }, setMock: (moduleName, mock) => setMockFactory(moduleName, () => mock), setSystemTime: now => { const fakeTimers = _getFakeTimers();