diff --git a/CHANGELOG.md b/CHANGELOG.md index 7651ae36c978..0e24c0b8cb2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - `[@jest/core, @jest/test-sequencer]` [**BREAKING**] Exposes `globalConfig` & `contexts` to `TestSequencer` ([#14535](https://github.com/jestjs/jest/pull/14535), & [#14543](https://github.com/jestjs/jest/pull/14543)) - `[jest-environment-jsdom]` [**BREAKING**] Upgrade JSDOM to v22 ([#13825](https://github.com/jestjs/jest/pull/13825)) - `[@jest/fake-timers]` [**BREAKING**] Upgrade `@sinonjs/fake-timers` to v11 ([#14544](https://github.com/jestjs/jest/pull/14544)) +- `[@jest/fake-timers]` Exposing new modern timers function `advanceTimersToFrame()` which advances all timers by the needed milliseconds to execute callbacks currently scheduled with `requestAnimationFrame` ([#14598](https://github.com/jestjs/jest/pull/14598)) +- `[jest-runtime]` Exposing new modern timers function `jest.advanceTimersToFrame()` from `@jest/fake-timers` ([#14598](https://github.com/jestjs/jest/pull/14598)) - `[@jest/schemas]` Upgrade `@sinclair/typebox` to v0.31 ([#14072](https://github.com/jestjs/jest/pull/14072)) - `[@jest/types]` `test.each()`: Accept a readonly (`as const`) table properly ([#14565](https://github.com/jestjs/jest/pull/14565)) - `[jest-snapshot]` [**BREAKING**] Add support for [Error causes](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) in snapshots ([#13965](https://github.com/facebook/jest/pull/13965)) diff --git a/docs/JestObjectAPI.md b/docs/JestObjectAPI.md index c830e0ab7295..4ced956f6e42 100644 --- a/docs/JestObjectAPI.md +++ b/docs/JestObjectAPI.md @@ -989,6 +989,16 @@ This function is not available when using legacy fake timers implementation. ::: +### `jest.advanceTimersToNextFrame()` + +Advances all timers by the needed milliseconds to execute callbacks currently scheduled with `requestAnimationFrame`. `advanceTimersToNextFrame()` is a helpful way to execute code that is scheduled using `requestAnimationFrame`. + +:::info + +This function is not available when using legacy fake timers implementation. + +::: + ### `jest.clearAllTimers()` Removes any pending timers from the timer system. diff --git a/docs/TimerMocks.md b/docs/TimerMocks.md index 1e0260c7def6..4fe263131964 100644 --- a/docs/TimerMocks.md +++ b/docs/TimerMocks.md @@ -167,6 +167,30 @@ it('calls the callback after 1 second via advanceTimersByTime', () => { Lastly, it may occasionally be useful in some tests to be able to clear all of the pending timers. For this, we have `jest.clearAllTimers()`. +## Advance Timers to the next Frame + +In applications, often you want to schedule work inside of an animation frame (with `requestAnimationFrame`). We expose a convenience method `jest.advanceTimersToNextFrame()` to advance all timers enough milliseconds to execute all actively scheduled animation frames. + +For mock timing purposes, animation frames are executed every `16ms` (mapping to roughly `60` frames per second) after the clock starts. When you schedule a callback in an animation frame (with `requestAnimationFrame(callback)`), the `callback` will be called when the clock has advanced `16ms`. `jest.advanceTimersToNextFrame()` will advance the clock just enough to get to the next `16ms` increment. If the clock has already advanced `6ms` since a animation frame `callback` was scheduled, then the clock will be advanced by `10ms`. + +```javascript +jest.useFakeTimers(); +it('calls the animation frame callback after advanceTimersToNextFrame()', () => { + const callback = jest.fn(); + + requestAnimationFrame(callback); + + // At this point in time, the callback should not have been called yet + expect(callback).not.toBeCalled(); + + jest.advanceTimersToNextFrame(); + + // Now our callback should have been called! + expect(callback).toBeCalled(); + expect(callback).toHaveBeenCalledTimes(1); +}); +``` + ## Selective Faking Sometimes your code may require to avoid overwriting the original implementation of one or another API. If that is the case, you can use `doNotFake` option. For example, here is how you could provide a custom mock function for `performance.mark()` in jsdom environment: diff --git a/packages/jest-environment/src/index.ts b/packages/jest-environment/src/index.ts index 6a99b3c7fe39..254f323c99e7 100644 --- a/packages/jest-environment/src/index.ts +++ b/packages/jest-environment/src/index.ts @@ -67,6 +67,14 @@ export interface Jest { * Not available when using legacy fake timers implementation. */ advanceTimersByTimeAsync(msToRun: number): Promise; + /** + * Advances all timers by the needed milliseconds to execute callbacks currently scheduled with `requestAnimationFrame`. + * `advanceTimersToNextFrame()` is a helpful way to execute code that is scheduled using `requestAnimationFrame`. + * + * @remarks + * Not available when using legacy fake timers implementation. + */ + advanceTimersToNextFrame(): void; /** * Advances all timers by the needed milliseconds so that only the next * timeouts/intervals will run. Optionally, you can provide steps, so it will diff --git a/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts b/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts index 4ce6749bfab5..faa83ceb5741 100644 --- a/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts +++ b/packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts @@ -103,6 +103,30 @@ describe('FakeTimers', () => { timers.useFakeTimers(); expect(global.clearImmediate).not.toBe(origClearImmediate); }); + + it('mocks requestAnimationFrame if it exists on global', () => { + const global = { + Date, + clearTimeout, + requestAnimationFrame: () => -1, + setTimeout, + } as unknown as typeof globalThis; + const timers = new FakeTimers({config: makeProjectConfig(), global}); + timers.useFakeTimers(); + expect(global.requestAnimationFrame).toBeDefined(); + }); + + it('mocks cancelAnimationFrame if it exists on global', () => { + const global = { + Date, + cancelAnimationFrame: () => {}, + clearTimeout, + setTimeout, + } as unknown as typeof globalThis; + const timers = new FakeTimers({config: makeProjectConfig(), global}); + timers.useFakeTimers(); + expect(global.cancelAnimationFrame).toBeDefined(); + }); }); describe('runAllTicks', () => { @@ -570,6 +594,202 @@ describe('FakeTimers', () => { }); }); + describe('advanceTimersToNextFrame', () => { + it('runs scheduled animation frame callbacks in order', () => { + const global = { + Date, + clearTimeout, + process, + requestAnimationFrame: () => -1, + setTimeout, + } as unknown as typeof globalThis; + + const timers = new FakeTimers({config: makeProjectConfig(), global}); + timers.useFakeTimers(); + + const runOrder: Array = []; + const mock1 = jest.fn(() => runOrder.push('mock1')); + const mock2 = jest.fn(() => runOrder.push('mock2')); + const mock3 = jest.fn(() => runOrder.push('mock3')); + + global.requestAnimationFrame(mock1); + global.requestAnimationFrame(mock2); + global.requestAnimationFrame(mock3); + + timers.advanceTimersToNextFrame(); + + expect(runOrder).toEqual(['mock1', 'mock2', 'mock3']); + }); + + it('should only run currently scheduled animation frame callbacks', () => { + const global = { + Date, + clearTimeout, + process, + requestAnimationFrame: () => -1, + setTimeout, + } as unknown as typeof globalThis; + + const timers = new FakeTimers({config: makeProjectConfig(), global}); + timers.useFakeTimers(); + + const runOrder: Array = []; + function run() { + runOrder.push('first-frame'); + + // scheduling another animation frame in the first frame + global.requestAnimationFrame(() => runOrder.push('second-frame')); + } + + global.requestAnimationFrame(run); + + // only the first frame should be executed + timers.advanceTimersToNextFrame(); + + expect(runOrder).toEqual(['first-frame']); + + timers.advanceTimersToNextFrame(); + + expect(runOrder).toEqual(['first-frame', 'second-frame']); + }); + + it('should allow cancelling of scheduled animation frame callbacks', () => { + const global = { + Date, + cancelAnimationFrame: () => {}, + clearTimeout, + process, + requestAnimationFrame: () => -1, + setTimeout, + } as unknown as typeof globalThis; + + const timers = new FakeTimers({config: makeProjectConfig(), global}); + const callback = jest.fn(); + timers.useFakeTimers(); + + const timerId = global.requestAnimationFrame(callback); + global.cancelAnimationFrame(timerId); + + timers.advanceTimersToNextFrame(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should only advance as much time is needed to get to the next frame', () => { + const global = { + Date, + cancelAnimationFrame: () => {}, + clearTimeout, + process, + requestAnimationFrame: () => -1, + setTimeout, + } as unknown as typeof globalThis; + + const timers = new FakeTimers({config: makeProjectConfig(), global}); + timers.useFakeTimers(); + + const runOrder: Array = []; + const start = global.Date.now(); + + const callback = () => runOrder.push('frame'); + global.requestAnimationFrame(callback); + + // Advancing timers less than a frame (which is 16ms) + timers.advanceTimersByTime(6); + expect(global.Date.now()).toEqual(start + 6); + + // frame not yet executed + expect(runOrder).toEqual([]); + + // move timers forward to execute frame + timers.advanceTimersToNextFrame(); + + // frame has executed as time has moved forward 10ms to get to the 16ms frame time + expect(runOrder).toEqual(['frame']); + expect(global.Date.now()).toEqual(start + 16); + }); + + it('should execute any timers on the way to the animation frame', () => { + const global = { + Date, + cancelAnimationFrame: () => {}, + clearTimeout, + process, + requestAnimationFrame: () => -1, + setTimeout, + } as unknown as typeof globalThis; + + const timers = new FakeTimers({config: makeProjectConfig(), global}); + timers.useFakeTimers(); + + const runOrder: Array = []; + + global.requestAnimationFrame(() => runOrder.push('frame')); + + // scheduling a timeout that will be executed on the way to the frame + global.setTimeout(() => runOrder.push('timeout'), 10); + + // move timers forward to execute frame + timers.advanceTimersToNextFrame(); + + expect(runOrder).toEqual(['timeout', 'frame']); + }); + + it('should not execute any timers scheduled inside of an animation frame callback', () => { + const global = { + Date, + cancelAnimationFrame: () => {}, + clearTimeout, + process, + requestAnimationFrame: () => -1, + setTimeout, + } as unknown as typeof globalThis; + + const timers = new FakeTimers({config: makeProjectConfig(), global}); + timers.useFakeTimers(); + + const runOrder: Array = []; + + global.requestAnimationFrame(() => { + runOrder.push('frame'); + // scheduling a timer inside of a frame + global.setTimeout(() => runOrder.push('timeout'), 1); + }); + + timers.advanceTimersToNextFrame(); + + // timeout not yet executed + expect(runOrder).toEqual(['frame']); + + // validating that the timer will still be executed + timers.advanceTimersByTime(1); + expect(runOrder).toEqual(['frame', 'timeout']); + }); + + it('should call animation frame callbacks with the latest system time', () => { + const global = { + Date, + clearTimeout, + performance, + process, + requestAnimationFrame: () => -1, + setTimeout, + } as unknown as typeof globalThis; + + const timers = new FakeTimers({config: makeProjectConfig(), global}); + timers.useFakeTimers(); + + const callback = jest.fn(); + + global.requestAnimationFrame(callback); + + timers.advanceTimersToNextFrame(); + + // `requestAnimationFrame` callbacks are called with a `DOMHighResTimeStamp` + expect(callback).toHaveBeenCalledWith(global.performance.now()); + }); + }); + describe('reset', () => { it('resets all pending setTimeouts', () => { const global = { @@ -649,6 +869,25 @@ describe('FakeTimers', () => { timers.advanceTimersByTime(50); expect(mock1).toHaveBeenCalledTimes(0); }); + + it('resets all scheduled animation frames', () => { + const global = { + Date, + clearTimeout, + process, + requestAnimationFrame: () => -1, + setTimeout, + } as unknown as typeof globalThis; + const timers = new FakeTimers({config: makeProjectConfig(), global}); + timers.useFakeTimers(); + + const mock1 = jest.fn(); + global.requestAnimationFrame(mock1); + + timers.reset(); + timers.runAllTimers(); + expect(mock1).toHaveBeenCalledTimes(0); + }); }); describe('runOnlyPendingTimers', () => { diff --git a/packages/jest-fake-timers/src/__tests__/sinon-integration.test.ts b/packages/jest-fake-timers/src/__tests__/sinon-integration.test.ts index 2d465699e6b3..8e06e211c968 100644 --- a/packages/jest-fake-timers/src/__tests__/sinon-integration.test.ts +++ b/packages/jest-fake-timers/src/__tests__/sinon-integration.test.ts @@ -17,6 +17,7 @@ const mockWithGlobal = { install: mockInstall, timers: { Date: jest.fn(), + cancelAnimationFrame: jest.fn(), clearImmediate: jest.fn(), clearInterval: jest.fn(), clearTimeout: jest.fn(), @@ -24,6 +25,7 @@ const mockWithGlobal = { nextTick: jest.fn(), performance: jest.fn(), queueMicrotask: jest.fn(), + requestAnimationFrame: jest.fn(), setImmediate: jest.fn(), setInterval: jest.fn(), setTimeout: jest.fn(), @@ -57,6 +59,7 @@ describe('`@sinonjs/fake-timers` integration', () => { shouldClearNativeTimers: true, toFake: [ 'Date', + 'cancelAnimationFrame', 'clearImmediate', 'clearInterval', 'clearTimeout', @@ -64,6 +67,7 @@ describe('`@sinonjs/fake-timers` integration', () => { 'nextTick', 'performance', 'queueMicrotask', + 'requestAnimationFrame', 'setImmediate', 'setInterval', 'setTimeout', @@ -93,12 +97,14 @@ describe('`@sinonjs/fake-timers` integration', () => { shouldAdvanceTime: true, shouldClearNativeTimers: true, toFake: [ + 'cancelAnimationFrame', 'clearImmediate', 'clearInterval', 'clearTimeout', 'hrtime', 'performance', 'queueMicrotask', + 'requestAnimationFrame', 'setImmediate', 'setInterval', 'setTimeout', @@ -126,12 +132,14 @@ describe('`@sinonjs/fake-timers` integration', () => { shouldAdvanceTime: true, shouldClearNativeTimers: true, toFake: [ + 'cancelAnimationFrame', 'clearImmediate', 'clearInterval', 'clearTimeout', 'hrtime', 'nextTick', 'performance', + 'requestAnimationFrame', 'setImmediate', 'setInterval', 'setTimeout', @@ -166,12 +174,14 @@ describe('`@sinonjs/fake-timers` integration', () => { shouldClearNativeTimers: true, toFake: [ 'Date', + 'cancelAnimationFrame', 'clearImmediate', 'clearInterval', 'clearTimeout', 'nextTick', 'performance', 'queueMicrotask', + 'requestAnimationFrame', 'setImmediate', 'setInterval', 'setTimeout', diff --git a/packages/jest-fake-timers/src/modernFakeTimers.ts b/packages/jest-fake-timers/src/modernFakeTimers.ts index 3208697353a0..b4982b757c2c 100644 --- a/packages/jest-fake-timers/src/modernFakeTimers.ts +++ b/packages/jest-fake-timers/src/modernFakeTimers.ts @@ -110,6 +110,12 @@ export default class FakeTimers { } } + advanceTimersToNextFrame(): void { + if (this._checkFakeTimers()) { + this._clock.runToFrame(); + } + } + runAllTicks(): void { if (this._checkFakeTimers()) { // @ts-expect-error - doesn't exist? diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 1057ee8a0d84..1e3dfef0a199 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -2289,6 +2289,16 @@ export default class Runtime { ); } }, + advanceTimersToNextFrame: () => { + const fakeTimers = _getFakeTimers(); + + if (fakeTimers === this._environment.fakeTimersModern) { + return fakeTimers.advanceTimersToNextFrame(); + } + throw new TypeError( + '`jest.advanceTimersToNextFrame()` is not available when using legacy fake timers.', + ); + }, advanceTimersToNextTimer: steps => _getFakeTimers().advanceTimersToNextTimer(steps), advanceTimersToNextTimerAsync: async steps => { diff --git a/packages/jest-types/__typetests__/jest.test.ts b/packages/jest-types/__typetests__/jest.test.ts index 7a513623bd87..c119d51b052c 100644 --- a/packages/jest-types/__typetests__/jest.test.ts +++ b/packages/jest-types/__typetests__/jest.test.ts @@ -527,6 +527,10 @@ expectError(jest.runOnlyPendingTimers(true)); expectType>(jest.runOnlyPendingTimersAsync()); expectError(jest.runOnlyPendingTimersAsync(true)); +expectType(jest.advanceTimersToNextFrame()); +expectError(jest.advanceTimersToNextFrame(true)); +expectError(jest.advanceTimersToNextFrame(100)); + expectType(jest.setSystemTime()); expectType(jest.setSystemTime(1_483_228_800_000)); expectType(jest.setSystemTime(Date.now()));