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

feat: Add additional auto advance time controls #509

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ setTimeout(() => {
}, 50);
```

In addition to the above, mocked time can be configured to advance more quickly
using `clock.setTickMode({ mode: "nextAsync" });`. With this mode, the clock
advances to the first scheduled timer and fires it, in a loop. Between each timer,
it will also break the event loop, allowing any scheduled promise
callbacks to execute _before_ running the next one.

## API Reference

### `var clock = FakeTimers.createClock([now[, loopLimit]])`
Expand Down Expand Up @@ -176,6 +182,29 @@ The following configuration options are available
| `config.shouldClearNativeTimers` | Boolean | false | tells FakeTimers to clear 'native' (i.e. not fake) timers by delegating to their respective handlers. These are not cleared by default, leading to potentially unexpected behavior if timers existed prior to installing FakeTimers. |
| `config.ignoreMissingTimers` | Boolean | false | tells FakeTimers to ignore missing timers that might not exist in the given environment |

### `clock.setTickMode(mode)`

Allows configuring how the clock advances time, automatically or manually.

There are 3 different types of modes for advancing timers:

- `{mode: 'manual'}`: Timers do not advance without explicit, manual calls to the tick
APIs (`jest.advanceTimersToNextTimer`, `jest.runAllTimers`, etc). This mode is equivalent to `false`.
- `{mode: 'nextAsync'}`: The clock will continuously break the event loop, then run the next timer until the mode changes.
As a result, 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.
- `{mode: 'interval', delta?: <number>}`: This is the same as specifying `shouldAdvanceTime: true` with an `advanceTimeDelta`. If the delta is
not specified, 20 will be used by default.

The 'nextAsync' mode differs from `interval` 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 interval,
without manually advancing time in the test, would take `1000 / advanceTimeDelta`
real time to reach and execute the timer.

### `var id = clock.setTimeout(callback, timeout)`

Schedules the callback to be fired once `timeout` milliseconds have ticked by.
Expand Down
137 changes: 122 additions & 15 deletions src/fake-timers-src.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,30 @@
}
}

/**
* @typedef {"nextAsync" | "manual" | "interval"} TickMode
*/

/**
* @typedef {object} NextAsyncTickMode
* @property {"nextAsync"} mode

Check warning on line 24 in src/fake-timers-src.js

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @Property "mode" description
*/

/**
* @typedef {object} ManualTickMode
* @property {"manual"} mode

Check warning on line 29 in src/fake-timers-src.js

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @Property "mode" description
*/

/**
* @typedef {object} IntervalTickMode
* @property {"interval"} mode

Check warning on line 34 in src/fake-timers-src.js

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @Property "mode" description
* @property {number} [delta]

Check warning on line 35 in src/fake-timers-src.js

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @Property "delta" description
*/

/**
* @typedef {IntervalTickMode | NextAsyncTickMode | ManualTickMode} TimerTickMode
*/

/**
* @typedef {object} IdleDeadline
* @property {boolean} didTimeout - whether or not the callback was called before reaching the optional timeout
Expand All @@ -22,10 +46,10 @@
*/

/**
* Queues a function to be called during a browser's idle periods

Check warning on line 49 in src/fake-timers-src.js

View workflow job for this annotation

GitHub Actions / lint

Expected only 0 line after block description
*
* @callback RequestIdleCallback
* @param {function(IdleDeadline)} callback

Check warning on line 52 in src/fake-timers-src.js

View workflow job for this annotation

GitHub Actions / lint

Syntax error in type: function(IdleDeadline)
* @param {{timeout: number}} options - an options object
* @returns {number} the id
*/
Expand All @@ -52,13 +76,13 @@

/**
* @typedef RequestAnimationFrame
* @property {function(number):void} requestAnimationFrame

Check warning on line 79 in src/fake-timers-src.js

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @Property "requestAnimationFrame" description
* @returns {number} - the id
*/

/**
* @typedef Performance
* @property {function(): number} now

Check warning on line 85 in src/fake-timers-src.js

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @Property "now" description
*/

/* eslint-disable jsdoc/require-property-description */
Expand Down Expand Up @@ -101,11 +125,12 @@
* @property {{methodName:string, original:any}[] | undefined} timersModuleMethods
* @property {{methodName:string, original:any}[] | undefined} timersPromisesModuleMethods
* @property {Map<function(): void, AbortSignal>} abortListenerMap
* @property {function(TimerTickMode): void} setTickMode
*/
/* eslint-enable jsdoc/require-property-description */

/**
* Configuration object for the `install` method.

Check warning on line 133 in src/fake-timers-src.js

View workflow job for this annotation

GitHub Actions / lint

Expected only 0 line after block description
*
* @typedef {object} Config
* @property {number|Date} [now] a number (in milliseconds) or a Date object (default epoch)
Expand All @@ -119,7 +144,7 @@

/* eslint-disable jsdoc/require-property-description */
/**
* The internal structure to describe a scheduled fake timer

Check warning on line 147 in src/fake-timers-src.js

View workflow job for this annotation

GitHub Actions / lint

Expected only 0 line after block description
*
* @typedef {object} Timer
* @property {Function} func
Expand Down Expand Up @@ -901,7 +926,7 @@
* @param {Config} config
* @returns {Timer[]}
*/
function uninstall(clock, config) {
function uninstall(clock) {
let method, i, l;
const installedHrTime = "_hrtime";
const installedNextTick = "_nextTick";
Expand Down Expand Up @@ -959,9 +984,7 @@
}
}

if (config.shouldAdvanceTime === true) {
_global.clearInterval(clock.attachedInterval);
}
clock.setTickMode("manual");

// Prevent multiple executions which will completely remove these props
clock.methods = [];
Expand Down Expand Up @@ -1117,6 +1140,8 @@
}

const originalSetTimeout = _global.setImmediate || _global.setTimeout;
const originalClearInterval = _global.clearInterval;
const originalSetInterval = _global.setInterval;

/**
* @param {Date|number} [start] the system time - non-integer values are floored
Expand All @@ -1135,6 +1160,7 @@
now: start,
Date: createDate(),
loopLimit: loopLimit,
tickMode: { mode: "manual", counter: 0 },
};

clock.Date.clock = clock;
Expand Down Expand Up @@ -1203,6 +1229,67 @@
clock.Intl.clock = clock;
}

clock.setTickMode = function (tickModeConfig) {
const { mode: newMode, delta: newDelta } = tickModeConfig;
const { mode: oldMode, delta: oldDelta } = clock.tickMode;
if (newMode === oldMode && newDelta === oldDelta) {
return;
}

if (oldMode === "interval") {
originalClearInterval(clock.attachedInterval);
}

clock.tickMode = {
counter: clock.tickMode.counter + 1,
mode: newMode,
delta: newDelta,
};

if (newMode === "nextAsync") {
advanceUntilModeChanges();
} else if (newMode === "interval") {
createIntervalTick(clock, newDelta || 20);
}
};

async function advanceUntilModeChanges() {
async function newMacrotask() {
// MessageChannel ensures that setTimeout is not throttled to 4ms.
// https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#reasons_for_delays_longer_than_specified
// https://stackblitz.com/edit/stackblitz-starters-qtlpcc
await new Promise((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = () => {
resolve();
channel.port1.close();
};
channel.port2.postMessage(undefined);
});
// setTimeout ensures microtask queue is emptied
await new Promise((resolve) => {
originalSetTimeout(resolve);
});
}

const { counter } = clock.tickMode;
while (clock.tickMode.counter === counter) {
await newMacrotask();
if (clock.tickMode.counter !== counter) {
return;
}
clock.next();
}
}

function setToManualIfAsync() {
if (clock.tickMode.mode === "nextAsync") {
clock.setTickMode({ mode: "manual" });
return true;
}
return false;
}

clock.requestIdleCallback = function requestIdleCallback(
func,
timeout,
Expand Down Expand Up @@ -1501,6 +1588,7 @@
* @returns {Promise}
*/
clock.tickAsync = function tickAsync(tickValue) {
const resetModeToNextAsync = setToManualIfAsync();
return new _global.Promise(function (resolve, reject) {
originalSetTimeout(function () {
try {
Expand All @@ -1509,6 +1597,10 @@
reject(e);
}
});
}).finally(() => {
if (resetModeToNextAsync) {
clock.setTickMode({ mode: "nextAsync" });
}
});
};
}
Expand All @@ -1533,6 +1625,7 @@

if (typeof _global.Promise !== "undefined") {
clock.nextAsync = function nextAsync() {
const resetModeToNextAsync = setToManualIfAsync();
return new _global.Promise(function (resolve, reject) {
originalSetTimeout(function () {
try {
Expand Down Expand Up @@ -1563,6 +1656,10 @@
reject(e);
}
});
}).finally(() => {
if (resetModeToNextAsync) {
clock.setTickMode({ mode: "nextAsync" });
}
});
};
}
Expand Down Expand Up @@ -1596,6 +1693,7 @@

if (typeof _global.Promise !== "undefined") {
clock.runAllAsync = function runAllAsync() {
const resetModeToNextAsync = setToManualIfAsync();
return new _global.Promise(function (resolve, reject) {
let i = 0;
/**
Expand Down Expand Up @@ -1640,6 +1738,10 @@
});
}
doRun();
}).finally(() => {
if (resetModeToNextAsync) {
this.setTickMode({ mode: "nextAsync" });
}
});
};
}
Expand All @@ -1656,6 +1758,7 @@

if (typeof _global.Promise !== "undefined") {
clock.runToLastAsync = function runToLastAsync() {
const resetModeToNextAsync = setToManualIfAsync();
return new _global.Promise(function (resolve, reject) {
originalSetTimeout(function () {
try {
Expand All @@ -1670,6 +1773,10 @@
reject(e);
}
});
}).finally(() => {
if (resetModeToNextAsync) {
this.setTickMode({ mode: "nextAsync" });
}
});
};
}
Expand Down Expand Up @@ -1734,6 +1841,12 @@
return clock;
}

function createIntervalTick(clock, delta) {
const intervalTick = doIntervalTick.bind(null, clock, delta);
const intervalId = originalSetInterval(intervalTick, delta);
clock.attachedInterval = intervalId;
}

/* eslint-disable complexity */

/**
Expand Down Expand Up @@ -1794,7 +1907,7 @@
clock.shouldClearNativeTimers = config.shouldClearNativeTimers;

clock.uninstall = function () {
return uninstall(clock, config);
return uninstall(clock);
};

clock.abortListenerMap = new Map();
Expand All @@ -1806,16 +1919,10 @@
}

if (config.shouldAdvanceTime === true) {
const intervalTick = doIntervalTick.bind(
null,
clock,
config.advanceTimeDelta,
);
const intervalId = _global.setInterval(
intervalTick,
config.advanceTimeDelta,
);
clock.attachedInterval = intervalId;
clock.setTickMode({
mode: "interval",
delta: config.advanceTimeDelta,
});
}

if (clock.methods.includes("performance")) {
Expand Down
Loading
Loading