Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Exposing jest.runToFrame() from sinon/fake_timers #14598

Merged
merged 13 commits into from
Oct 5, 2023
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
10 changes: 10 additions & 0 deletions docs/JestObjectAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions docs/TimerMocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A added some more detail here to hopefully make things clearer for consumers


```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:
Expand Down
8 changes: 8 additions & 0 deletions packages/jest-environment/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ export interface Jest {
* Not available when using legacy fake timers implementation.
*/
advanceTimersByTimeAsync(msToRun: number): Promise<void>;
/**
* 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
Expand Down
239 changes: 239 additions & 0 deletions packages/jest-fake-timers/src/__tests__/modernFakeTimers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<string> = [];
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<string> = [];
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<string> = [];
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<string> = [];

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<string> = [];

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 = {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading