Skip to content

Commit

Permalink
feat: add jest.advanceTimersToFrame() (#14598)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexreardon authored Oct 5, 2023
1 parent 1bacb5e commit 00ef0ed
Show file tree
Hide file tree
Showing 9 changed files with 313 additions and 0 deletions.
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`.

```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

0 comments on commit 00ef0ed

Please sign in to comment.