diff --git a/CHANGELOG.md b/CHANGELOG.md index 502e1cdd3274..8eb8e757c644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Features +- `[jest-fake-timers]` Flush callbacks scheduled with `requestAnimationFrame` every 16ms when using legacy timers. ([#11523](https://github.com/facebook/jest/pull/11567)) + ### Fixes - `[jest-reporter]` Allow `node-notifier@10` as peer dependency ([#11523](https://github.com/facebook/jest/pull/11523)) diff --git a/e2e/fake-time/legacy/requestAnimationFrame/__tests__/test.js b/e2e/fake-time/legacy/requestAnimationFrame/__tests__/test.js new file mode 100644 index 000000000000..42c638bbf191 --- /dev/null +++ b/e2e/fake-time/legacy/requestAnimationFrame/__tests__/test.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/* global requestAnimationFrame */ + +'use strict'; + +test('requestAnimationFrame', () => { + jest.useFakeTimers('legacy'); + let frameTimestamp = -1; + requestAnimationFrame(timestamp => { + frameTimestamp = timestamp; + }); + + jest.advanceTimersByTime(15); + + expect(frameTimestamp).toBe(-1); + + jest.advanceTimersByTime(1); + + expect(frameTimestamp).toBeGreaterThan(15); +}); diff --git a/e2e/fake-time/legacy/requestAnimationFrame/package.json b/e2e/fake-time/legacy/requestAnimationFrame/package.json new file mode 100644 index 000000000000..0ded940b7cb7 --- /dev/null +++ b/e2e/fake-time/legacy/requestAnimationFrame/package.json @@ -0,0 +1,5 @@ +{ + "jest": { + "testEnvironment": "jsdom" + } +} diff --git a/e2e/fake-time/modern/requestAnimationFrame/__tests__/test.js b/e2e/fake-time/modern/requestAnimationFrame/__tests__/test.js new file mode 100644 index 000000000000..17e29447c67a --- /dev/null +++ b/e2e/fake-time/modern/requestAnimationFrame/__tests__/test.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/* global requestAnimationFrame */ + +'use strict'; + +test('requestAnimationFrame', () => { + jest.useFakeTimers('modern'); + let frameTimestamp = -1; + requestAnimationFrame(timestamp => { + frameTimestamp = timestamp; + }); + + jest.advanceTimersByTime(15); + + expect(frameTimestamp).toBe(-1); + + jest.advanceTimersByTime(1); + + expect(frameTimestamp).toBeGreaterThan(15); +}); diff --git a/e2e/fake-time/modern/requestAnimationFrame/package.json b/e2e/fake-time/modern/requestAnimationFrame/package.json new file mode 100644 index 000000000000..0ded940b7cb7 --- /dev/null +++ b/e2e/fake-time/modern/requestAnimationFrame/package.json @@ -0,0 +1,5 @@ +{ + "jest": { + "testEnvironment": "jsdom" + } +} diff --git a/packages/jest-fake-timers/src/__tests__/legacyFakeTimers.test.ts b/packages/jest-fake-timers/src/__tests__/legacyFakeTimers.test.ts index d5fec2bae04f..d6da7dd1336b 100644 --- a/packages/jest-fake-timers/src/__tests__/legacyFakeTimers.test.ts +++ b/packages/jest-fake-timers/src/__tests__/legacyFakeTimers.test.ts @@ -142,6 +142,68 @@ describe('FakeTimers', () => { timers.useFakeTimers(); expect(global.clearImmediate).not.toBe(origClearImmediate); }); + + it('does not mock requestAnimationFrame if not available', () => { + const global = { + process, + } as unknown as NodeJS.Global & Window; + const timers = new FakeTimers({ + config, + global, + moduleMocker, + timerConfig, + }); + timers.useFakeTimers(); + expect(global.requestAnimationFrame).toBe(undefined); + }); + + it('mocks requestAnimationFrame if available on global', () => { + const origRequestAnimationFrame = () => {}; + const global = { + process, + requestAnimationFrame: origRequestAnimationFrame, + } as unknown as NodeJS.Global & Window; + const timers = new FakeTimers({ + config, + global, + moduleMocker, + timerConfig, + }); + timers.useFakeTimers(); + expect(global.requestAnimationFrame).not.toBe(undefined); + expect(global.requestAnimationFrame).not.toBe(origRequestAnimationFrame); + }); + + it('does not mock cancelAnimationFrame if not available on global', () => { + const global = { + process, + } as unknown as NodeJS.Global & Window; + const timers = new FakeTimers({ + config, + global, + moduleMocker, + timerConfig, + }); + timers.useFakeTimers(); + expect(global.cancelAnimationFrame).toBe(undefined); + }); + + it('mocks cancelAnimationFrame if available on global', () => { + const origCancelAnimationFrame = () => {}; + const global = { + cancelAnimationFrame: origCancelAnimationFrame, + process, + } as unknown as NodeJS.Global & Window; + const timers = new FakeTimers({ + config, + global, + moduleMocker, + timerConfig, + }); + timers.useFakeTimers(); + expect(global.cancelAnimationFrame).not.toBe(undefined); + expect(global.cancelAnimationFrame).not.toBe(origCancelAnimationFrame); + }); }); describe('runAllTicks', () => { @@ -399,7 +461,11 @@ describe('FakeTimers', () => { describe('runAllTimers', () => { it('runs all timers in order', () => { - const global = {process} as unknown as NodeJS.Global; + const global = { + cancelAnimationFrame: () => {}, + process, + requestAnimationFrame: () => {}, + } as unknown as NodeJS.Global & Window; const timers = new FakeTimers({ config, global, @@ -415,6 +481,7 @@ describe('FakeTimers', () => { const mock4 = jest.fn(() => runOrder.push('mock4')); const mock5 = jest.fn(() => runOrder.push('mock5')); const mock6 = jest.fn(() => runOrder.push('mock6')); + const mockAnimatioNFrame = jest.fn(() => runOrder.push('animationFrame')); global.setTimeout(mock1, 100); global.setTimeout(mock2, NaN); @@ -425,6 +492,7 @@ describe('FakeTimers', () => { }, 200); global.setTimeout(mock5, Infinity); global.setTimeout(mock6, -Infinity); + global.requestAnimationFrame(mockAnimatioNFrame); timers.runAllTimers(); expect(runOrder).toEqual([ @@ -432,6 +500,7 @@ describe('FakeTimers', () => { 'mock3', 'mock5', 'mock6', + 'animationFrame', 'mock1', 'mock4', ]); @@ -585,7 +654,11 @@ describe('FakeTimers', () => { describe('advanceTimersByTime', () => { it('runs timers in order', () => { - const global = {process} as unknown as NodeJS.Global; + const global = { + cancelAnimationFrame: () => {}, + process, + requestAnimationFrame: () => {}, + } as unknown as NodeJS.Global & Window; const timers = new FakeTimers({ config, global, @@ -594,11 +667,14 @@ describe('FakeTimers', () => { }); timers.useFakeTimers(); - const runOrder: Array = []; + const runOrder: Array = []; const mock1 = jest.fn(() => runOrder.push('mock1')); const mock2 = jest.fn(() => runOrder.push('mock2')); const mock3 = jest.fn(() => runOrder.push('mock3')); const mock4 = jest.fn(() => runOrder.push('mock4')); + const mockAnimationFrame = jest.fn(timestamp => + runOrder.push(['animationFrame', timestamp]), + ); global.setTimeout(mock1, 100); global.setTimeout(mock2, 0); @@ -606,26 +682,49 @@ describe('FakeTimers', () => { global.setInterval(() => { mock4(); }, 200); + global.requestAnimationFrame(mockAnimationFrame); - // Move forward to t=50 - timers.advanceTimersByTime(50); + // Move forward to t=15 + timers.advanceTimersByTime(15); expect(runOrder).toEqual(['mock2', 'mock3']); + // Move forward to t=16 + timers.advanceTimersByTime(1); + expect(runOrder).toEqual(['mock2', 'mock3', ['animationFrame', 16]]); + // Move forward to t=60 - timers.advanceTimersByTime(10); - expect(runOrder).toEqual(['mock2', 'mock3']); + timers.advanceTimersByTime(44); + expect(runOrder).toEqual(['mock2', 'mock3', ['animationFrame', 16]]); // Move forward to t=100 timers.advanceTimersByTime(40); - expect(runOrder).toEqual(['mock2', 'mock3', 'mock1']); + expect(runOrder).toEqual([ + 'mock2', + 'mock3', + ['animationFrame', 16], + 'mock1', + ]); // Move forward to t=200 timers.advanceTimersByTime(100); - expect(runOrder).toEqual(['mock2', 'mock3', 'mock1', 'mock4']); + expect(runOrder).toEqual([ + 'mock2', + 'mock3', + ['animationFrame', 16], + 'mock1', + 'mock4', + ]); // Move forward to t=400 timers.advanceTimersByTime(200); - expect(runOrder).toEqual(['mock2', 'mock3', 'mock1', 'mock4', 'mock4']); + expect(runOrder).toEqual([ + 'mock2', + 'mock3', + ['animationFrame', 16], + 'mock1', + 'mock4', + 'mock4', + ]); }); it('does nothing when no timers have been scheduled', () => { @@ -668,7 +767,11 @@ describe('FakeTimers', () => { describe('advanceTimersToNextTimer', () => { it('runs timers in order', () => { - const global = {process} as unknown as NodeJS.Global; + const global = { + cancelAnimationFrame: () => {}, + process, + requestAnimationFrame: () => {}, + } as unknown as NodeJS.Global & Window; const timers = new FakeTimers({ config, global, @@ -682,6 +785,7 @@ describe('FakeTimers', () => { const mock2 = jest.fn(() => runOrder.push('mock2')); const mock3 = jest.fn(() => runOrder.push('mock3')); const mock4 = jest.fn(() => runOrder.push('mock4')); + const mockAnimationFrame = jest.fn(() => runOrder.push('animationFrame')); global.setTimeout(mock1, 100); global.setTimeout(mock2, 0); @@ -689,26 +793,48 @@ describe('FakeTimers', () => { global.setInterval(() => { mock4(); }, 200); + global.requestAnimationFrame(mockAnimationFrame); timers.advanceTimersToNextTimer(); // Move forward to t=0 expect(runOrder).toEqual(['mock2', 'mock3']); + timers.advanceTimersToNextTimer(); + // Move forward to t=17 + expect(runOrder).toEqual(['mock2', 'mock3', 'animationFrame']); + timers.advanceTimersToNextTimer(); // Move forward to t=100 - expect(runOrder).toEqual(['mock2', 'mock3', 'mock1']); + expect(runOrder).toEqual(['mock2', 'mock3', 'animationFrame', 'mock1']); timers.advanceTimersToNextTimer(); // Move forward to t=200 - expect(runOrder).toEqual(['mock2', 'mock3', 'mock1', 'mock4']); + expect(runOrder).toEqual([ + 'mock2', + 'mock3', + 'animationFrame', + 'mock1', + 'mock4', + ]); timers.advanceTimersToNextTimer(); // Move forward to t=400 - expect(runOrder).toEqual(['mock2', 'mock3', 'mock1', 'mock4', 'mock4']); + expect(runOrder).toEqual([ + 'mock2', + 'mock3', + 'animationFrame', + 'mock1', + 'mock4', + 'mock4', + ]); }); it('run correct amount of steps', () => { - const global = {process} as unknown as NodeJS.Global; + const global = { + cancelAnimationFrame: () => {}, + process, + requestAnimationFrame: () => {}, + } as unknown as NodeJS.Global & Window; const timers = new FakeTimers({ config, global, @@ -722,6 +848,7 @@ describe('FakeTimers', () => { const mock2 = jest.fn(() => runOrder.push('mock2')); const mock3 = jest.fn(() => runOrder.push('mock3')); const mock4 = jest.fn(() => runOrder.push('mock4')); + const mockAnimationFrame = jest.fn(() => runOrder.push('animationFrame')); global.setTimeout(mock1, 100); global.setTimeout(mock2, 0); @@ -729,16 +856,22 @@ describe('FakeTimers', () => { global.setInterval(() => { mock4(); }, 200); + global.requestAnimationFrame(mockAnimationFrame); - // Move forward to t=100 + // Move forward to t=17 timers.advanceTimersToNextTimer(2); - expect(runOrder).toEqual(['mock2', 'mock3', 'mock1']); + expect(runOrder).toEqual(['mock2', 'mock3', 'animationFrame']); + + // Move forward to t=100 + timers.advanceTimersToNextTimer(1); + expect(runOrder).toEqual(['mock2', 'mock3', 'animationFrame', 'mock1']); // Move forward to t=600 timers.advanceTimersToNextTimer(3); expect(runOrder).toEqual([ 'mock2', 'mock3', + 'animationFrame', 'mock1', 'mock4', 'mock4', @@ -825,6 +958,28 @@ describe('FakeTimers', () => { expect(mock1).toHaveBeenCalledTimes(0); }); + it('resets all pending animation frames', () => { + const global = { + cancelAnimationFrame: () => {}, + process, + requestAnimationFrame: () => {}, + } as unknown as NodeJS.Global & Window; + const timers = new FakeTimers({ + config, + global, + moduleMocker, + timerConfig, + }); + timers.useFakeTimers(); + + const mock1 = jest.fn(); + global.requestAnimationFrame(mock1); + + timers.reset(); + timers.runAllTimers(); + expect(mock1).toHaveBeenCalledTimes(0); + }); + it('resets all pending ticks callbacks & immediates', () => { const global = { process: { @@ -877,9 +1032,11 @@ describe('FakeTimers', () => { const nativeSetImmediate = jest.fn(); const global = { + cancelAnimationFrame: () => {}, process, + requestAnimationFrame: () => {}, setImmediate: nativeSetImmediate, - } as unknown as NodeJS.Global; + } as unknown as NodeJS.Global & Window; const timers = new FakeTimers({ config, @@ -914,18 +1071,32 @@ describe('FakeTimers', () => { global.setTimeout(cb, 400); }); + global.requestAnimationFrame(function cb() { + runOrder.push('animationFrame'); + global.requestAnimationFrame(cb); + }); + timers.runOnlyPendingTimers(); - expect(runOrder).toEqual(['mock4', 'mock5', 'mock2', 'mock1', 'mock3']); + expect(runOrder).toEqual([ + 'mock4', + 'mock5', + 'mock2', + 'animationFrame', + 'mock1', + 'mock3', + ]); timers.runOnlyPendingTimers(); expect(runOrder).toEqual([ 'mock4', 'mock5', 'mock2', + 'animationFrame', 'mock1', 'mock3', 'mock2', + 'animationFrame', 'mock1', 'mock3', 'mock5', @@ -1186,6 +1357,36 @@ describe('FakeTimers', () => { expect(global.setImmediate).toBe(nativeSetImmediate); expect(global.clearImmediate).toBe(nativeClearImmediate); }); + + it('resets native requestAnimationFrame when present', () => { + const nativeCancelAnimationFrame = jest.fn(); + const nativeRequestAnimationFrame = jest.fn(); + + const global = { + cancelAnimationFrame: nativeCancelAnimationFrame, + process, + requestAnimationFrame: nativeRequestAnimationFrame, + } as unknown as NodeJS.Global & Window; + const timers = new FakeTimers({ + config, + global, + moduleMocker, + timerConfig, + }); + timers.useFakeTimers(); + + // Ensure that timers has overridden the native timer APIs + // (because if it didn't, this test might pass when it shouldn't) + expect(global.cancelAnimationFrame).not.toBe(nativeCancelAnimationFrame); + expect(global.requestAnimationFrame).not.toBe( + nativeRequestAnimationFrame, + ); + + timers.useRealTimers(); + + expect(global.cancelAnimationFrame).toBe(nativeCancelAnimationFrame); + expect(global.requestAnimationFrame).toBe(nativeRequestAnimationFrame); + }); }); describe('useFakeTimers', () => { @@ -1275,6 +1476,36 @@ describe('FakeTimers', () => { expect(global.setImmediate).not.toBe(nativeSetImmediate); expect(global.clearImmediate).not.toBe(nativeClearImmediate); }); + + it('resets mock requestAnimationFrame when present', () => { + const nativeCancelAnimationFrame = jest.fn(); + const nativeRequestAnimationFrame = jest.fn(); + + const global = { + cancelAnimationFrame: nativeCancelAnimationFrame, + process, + requestAnimationFrame: nativeRequestAnimationFrame, + } as unknown as NodeJS.Global & Window; + const fakeTimers = new FakeTimers({ + config, + global, + moduleMocker, + timerConfig, + }); + fakeTimers.useRealTimers(); + + // Ensure that the real timers are installed at this point + // (because if they aren't, this test might pass when it shouldn't) + expect(global.cancelAnimationFrame).toBe(nativeCancelAnimationFrame); + expect(global.requestAnimationFrame).toBe(nativeRequestAnimationFrame); + + fakeTimers.useFakeTimers(); + + expect(global.cancelAnimationFrame).not.toBe(nativeCancelAnimationFrame); + expect(global.requestAnimationFrame).not.toBe( + nativeRequestAnimationFrame, + ); + }); }); describe('getTimerCount', () => { @@ -1336,5 +1567,27 @@ describe('FakeTimers', () => { expect(timers.getTimerCount()).toEqual(0); }); + + it('includes animation frames', () => { + const global = { + cancelAnimationFrame: () => {}, + process, + requestAnimationFrame: () => {}, + } as unknown as NodeJS.Global & Window; + const timers = new FakeTimers({ + config, + global, + moduleMocker, + timerConfig, + }); + + timers.useFakeTimers(); + + global.requestAnimationFrame(() => {}); + expect(timers.getTimerCount()).toEqual(1); + timers.clearAllTimers(); + + expect(timers.getTimerCount()).toEqual(0); + }); }); }); diff --git a/packages/jest-fake-timers/src/legacyFakeTimers.ts b/packages/jest-fake-timers/src/legacyFakeTimers.ts index 55d9121ed405..34486ead5076 100644 --- a/packages/jest-fake-timers/src/legacyFakeTimers.ts +++ b/packages/jest-fake-timers/src/legacyFakeTimers.ts @@ -29,11 +29,13 @@ type Timer = { }; type TimerAPI = { + cancelAnimationFrame: FakeTimersGlobal['cancelAnimationFrame']; clearImmediate: typeof global.clearImmediate; clearInterval: typeof global.clearInterval; clearTimeout: typeof global.clearTimeout; nextTick: typeof process.nextTick; + requestAnimationFrame: FakeTimersGlobal['requestAnimationFrame']; setImmediate: typeof global.setImmediate; setInterval: typeof global.setInterval; setTimeout: typeof global.setTimeout; @@ -46,12 +48,17 @@ type TimerConfig = { const MS_IN_A_YEAR = 31536000000; +interface FakeTimersGlobal extends NodeJS.Global { + cancelAnimationFrame?: (handle: number) => void; + requestAnimationFrame?: (callback: (time: number) => void) => number; +} + export default class FakeTimers { private _cancelledTicks!: Record; private _config: StackTraceConfig; private _disposed?: boolean; private _fakeTimerAPIs!: TimerAPI; - private _global: NodeJS.Global; + private _global: FakeTimersGlobal; private _immediates!: Array; private _maxLoops: number; private _moduleMocker: ModuleMocker; @@ -69,7 +76,7 @@ export default class FakeTimers { config, maxLoops, }: { - global: NodeJS.Global; + global: FakeTimersGlobal; moduleMocker: ModuleMocker; timerConfig: TimerConfig; config: StackTraceConfig; @@ -84,10 +91,12 @@ export default class FakeTimers { // Store original timer APIs for future reference this._timerAPIs = { + cancelAnimationFrame: global.cancelAnimationFrame, clearImmediate: global.clearImmediate, clearInterval: global.clearInterval, clearTimeout: global.clearTimeout, nextTick: global.process && global.process.nextTick, + requestAnimationFrame: global.requestAnimationFrame, setImmediate: global.setImmediate, setInterval: global.setInterval, setTimeout: global.setTimeout, @@ -317,9 +326,24 @@ export default class FakeTimers { useRealTimers(): void { const global = this._global; + + if (typeof global.cancelAnimationFrame === 'function') { + setGlobal( + global, + 'cancelAnimationFrame', + this._timerAPIs.cancelAnimationFrame, + ); + } setGlobal(global, 'clearImmediate', this._timerAPIs.clearImmediate); setGlobal(global, 'clearInterval', this._timerAPIs.clearInterval); setGlobal(global, 'clearTimeout', this._timerAPIs.clearTimeout); + if (typeof global.requestAnimationFrame === 'function') { + setGlobal( + global, + 'requestAnimationFrame', + this._timerAPIs.requestAnimationFrame, + ); + } setGlobal(global, 'setImmediate', this._timerAPIs.setImmediate); setGlobal(global, 'setInterval', this._timerAPIs.setInterval); setGlobal(global, 'setTimeout', this._timerAPIs.setTimeout); @@ -331,9 +355,23 @@ export default class FakeTimers { this._createMocks(); const global = this._global; + if (typeof global.cancelAnimationFrame === 'function') { + setGlobal( + global, + 'cancelAnimationFrame', + this._fakeTimerAPIs.cancelAnimationFrame, + ); + } setGlobal(global, 'clearImmediate', this._fakeTimerAPIs.clearImmediate); setGlobal(global, 'clearInterval', this._fakeTimerAPIs.clearInterval); setGlobal(global, 'clearTimeout', this._fakeTimerAPIs.clearTimeout); + if (typeof global.requestAnimationFrame === 'function') { + setGlobal( + global, + 'requestAnimationFrame', + this._fakeTimerAPIs.requestAnimationFrame, + ); + } setGlobal(global, 'setImmediate', this._fakeTimerAPIs.setImmediate); setGlobal(global, 'setInterval', this._fakeTimerAPIs.setInterval); setGlobal(global, 'setTimeout', this._fakeTimerAPIs.setTimeout); @@ -380,11 +418,14 @@ export default class FakeTimers { // TODO: add better typings; these are mocks, but typed as regular timers this._fakeTimerAPIs = { + cancelAnimationFrame: fn(this._fakeClearTimer.bind(this)), clearImmediate: fn(this._fakeClearImmediate.bind(this)), clearInterval: fn(this._fakeClearTimer.bind(this)), clearTimeout: fn(this._fakeClearTimer.bind(this)), nextTick: fn(this._fakeNextTick.bind(this)), // @ts-expect-error TODO: figure out better typings here + requestAnimationFrame: fn(this._fakeRequestAnimationFrame.bind(this)), + // @ts-expect-error TODO: figure out better typings here setImmediate: fn(this._fakeSetImmediate.bind(this)), // @ts-expect-error TODO: figure out better typings here setInterval: fn(this._fakeSetInterval.bind(this)), @@ -429,6 +470,13 @@ export default class FakeTimers { }); } + private _fakeRequestAnimationFrame(callback: Callback) { + return this._fakeSetTimeout(() => { + // TODO: Use performance.now() once it's mocked + callback(this._now); + }, 1000 / 60); + } + private _fakeSetImmediate(callback: Callback, ...args: Array) { if (this._disposed) { return null;