Merge branch 'atscott-autoTick'

* Merges #2042 from @atscott
* Fixes #1932
* Fixes #1725
This commit is contained in:
Steve Gravrock
2025-03-21 09:19:56 -07:00
5 changed files with 426 additions and 0 deletions

View File

@@ -3066,6 +3066,15 @@ getJasmineRequireObj().Clock = function() {
let installed = false;
let delayedFunctionScheduler;
let timer;
// Tracks how the clock ticking behaves.
// By default, the clock only ticks when the user manually calls a tick method.
// There is also an 'auto' mode which will advance the clock automatically to
// to the next task. Once enabled, there is currently no mechanism for users
// to disable the auto ticking.
let tickMode = {
mode: 'manual',
counter: 0
};
this.FakeTimeout = FakeTimeout;
@@ -3175,8 +3184,95 @@ getJasmineRequireObj().Clock = function() {
}
};
/**
* Updates the clock to automatically advance time.
*
* 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.
*
* This mode allows tests to be authored in a way that does not need to be aware of the
* mock clock. Consequently, tests which have been authored without a mock clock installed
* can one with auto tick enabled without any other updates to the test logic.
*
* In many cases, this can greatly improve test execution speed because asynchronous tasks
* will execute as quickly as possible rather than waiting real time to complete.
*
* Furthermore, tests can be authored in a consitent manner. They can always be written in an asynchronous style
* rather than having `tick` sprinkled throughout the tests with mock time in order to manually
* advance the clock.
*
* When auto tick is enabled, `tick` can still be used to synchronously advance the clock if necessary.
*/
this.autoTick = function() {
if (tickMode.mode === 'auto') {
return;
}
tickMode = { mode: 'auto', counter: tickMode.counter + 1 };
advanceUntilModeChanges();
};
return this;
// Advances the Clock's time until the mode changes.
//
// The time is advanced asynchronously, giving microtasks and events a chance
// to run before each timer runs.
//
// @function
// @return {!Promise<undefined>}
async function advanceUntilModeChanges() {
if (!installed) {
throw new Error(
'Mock clock is not installed, use jasmine.clock().install()'
);
}
const { counter } = tickMode;
while (true) {
await newMacrotask();
if (
tickMode.counter !== counter ||
!installed ||
delayedFunctionScheduler === null
) {
return;
}
if (!delayedFunctionScheduler.isEmpty()) {
delayedFunctionScheduler.runNextQueuedFunction(function(millis) {
mockDate.tick(millis);
});
}
}
}
// Waits until a new macro task.
//
// Used with autoTick(), which is meant to act when the test is waiting, we need
// to insert ourselves in the macro task queue.
//
// @return {!Promise<undefined>}
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
// Note: This trick does not work in Safari, which will still throttle the setTimeout
const channel = new MessageChannel();
await new Promise(resolve => {
channel.port1.onmessage = resolve;
channel.port2.postMessage(undefined);
});
channel.port1.close();
channel.port2.close();
// setTimeout ensures that we interleave with other setTimeouts.
await new Promise(resolve => {
realTimingFunctions.setTimeout.call(global, resolve);
});
}
function originalTimingFunctionsIntact() {
return (
global.setTimeout === realTimingFunctions.setTimeout &&
@@ -3407,6 +3503,37 @@ getJasmineRequireObj().DelayedFunctionScheduler = function(j$) {
}
};
// Returns whether there are any scheduled functions.
// Returns true if there are any scheduled functions, otherwise false.
this.isEmpty = function() {
return this.scheduledFunctions_.length === 0;
};
// Runs the next timeout in the queue, advancing the clock.
this.runNextQueuedFunction = function(tickDate) {
if (this.scheduledLookup_.length === 0) {
return;
}
const newCurrentTime = this.scheduledLookup_[0];
if (newCurrentTime >= this.currentTime_) {
tickDate(newCurrentTime - this.currentTime_);
this.currentTime_ = newCurrentTime;
}
const funcsAtTime = this.scheduledFunctions_[this.currentTime_];
const fn = funcsAtTime.shift();
if (funcsAtTime.length === 0) {
delete this.scheduledFunctions_[this.currentTime_];
this.scheduledLookup_.splice(0, 1);
}
if (fn.recurring) {
this.reschedule_(fn);
}
fn.funcToCall.apply(null, fn.params || []);
};
return this;
}

View File

@@ -687,6 +687,142 @@ describe('Clock (acceptance)', function() {
expect(recurring1.calls.count()).toBe(4);
});
describe('auto tick mode', () => {
let delayedFunctionScheduler;
let mockDate;
let clock;
beforeEach(() => {
delayedFunctionScheduler = new jasmineUnderTest.DelayedFunctionScheduler();
mockDate = {
install: function() {},
tick: function() {},
uninstall: function() {}
};
clock = new jasmineUnderTest.Clock(
// We use the real window for global or firefox is displeased when we try to call a real setTimeout on an object "that doesn't implement window".
typeof window !== 'undefined' ? window : { setTimeout: setTimeout },
function() {
return delayedFunctionScheduler;
},
mockDate
);
clock.install();
clock.autoTick();
});
afterEach(() => {
clock.uninstall();
});
it('can run setTimeouts/setIntervals asynchronously', function() {
const recurring = jasmine.createSpy('recurring'),
fn1 = jasmine.createSpy('fn1'),
fn2 = jasmine.createSpy('fn2'),
fn3 = jasmine.createSpy('fn3');
const intervalId = clock.setInterval(recurring, 50);
// In a microtask, add some timeouts.
Promise.resolve()
.then(function() {
return new Promise(function(resolve) {
clock.setTimeout(resolve, 25);
});
})
.then(function() {
fn1();
return new Promise(function(resolve) {
clock.setTimeout(resolve, 200);
});
})
.then(function() {
fn2();
return new Promise(function(resolve) {
clock.setTimeout(resolve, 100);
});
})
.then(function() {
fn3();
});
expect(recurring).not.toHaveBeenCalled();
expect(fn1).not.toHaveBeenCalled();
expect(fn2).not.toHaveBeenCalled();
expect(fn3).not.toHaveBeenCalled();
return new Promise(resolve => clock.setTimeout(resolve, 50))
.then(function() {
expect(recurring).toHaveBeenCalledTimes(1);
expect(fn1).toHaveBeenCalled();
expect(fn2).not.toHaveBeenCalled();
expect(fn3).not.toHaveBeenCalled();
return new Promise(resolve => clock.setTimeout(resolve, 175));
})
.then(function() {
expect(recurring).toHaveBeenCalledTimes(4);
expect(fn1).toHaveBeenCalled();
expect(fn2).toHaveBeenCalled();
expect(fn3).not.toHaveBeenCalled();
clock.clearInterval(intervalId);
return new Promise(resolve => clock.setTimeout(resolve, 100));
})
.then(function() {
expect(recurring).toHaveBeenCalledTimes(4);
expect(fn1).toHaveBeenCalled();
expect(fn2).toHaveBeenCalled();
expect(fn3).toHaveBeenCalled();
});
});
it('speeds up the execution of the timers in all browsers', async () => {
const startTimeMs = performance.now() / 1000;
await new Promise(resolve => clock.setTimeout(resolve, 5000));
await new Promise(resolve => clock.setTimeout(resolve, 5000));
await new Promise(resolve => clock.setTimeout(resolve, 5000));
await new Promise(resolve => clock.setTimeout(resolve, 5000));
const endTimeMs = performance.now() / 1000;
// Ensure we didn't take 20s to complete the awaits above and, in fact, can do it in a fraction of a second
expect(endTimeMs - startTimeMs).toBeLessThan(100);
});
it('avoids throttling in browsers other than Safari', async () => {
if (
typeof navigator !== 'undefined' &&
/^((?!chrome|android|firefox).)*safari/i.test(navigator.userAgent)
) {
return;
}
// This test ensures the setTimeout loop isn't getting throttled by browsers
const promises = [];
// 2000 timers at ~4ms throttling = 8_000ms would time out if we weren't
// preventing the throttle with the MessageChannel trick.
for (let i = 0; i < 2000; i++) {
promises.push(new Promise(resolve => clock.setTimeout(resolve)));
}
const startTimeMs = performance.now() / 1000;
await Promise.all(promises);
const endTimeMs = performance.now() / 1000;
expect(endTimeMs - startTimeMs).toBeLessThan(1000);
});
it('is easy to test async functions with interleaved timers and microtasks', async () => {
async function blackBoxWithLotsOfAsyncStuff() {
await new Promise(r => clock.setTimeout(r, 10));
await Promise.resolve();
await Promise.resolve();
await new Promise(r => clock.setTimeout(r, 20));
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
return 'done';
}
const result = await blackBoxWithLotsOfAsyncStuff();
expect(result).toBe('done');
});
});
it('can clear a previously set timeout', function() {
const clearedFn = jasmine.createSpy('clearedFn'),
delayedFunctionScheduler = new jasmineUnderTest.DelayedFunctionScheduler(),

View File

@@ -264,6 +264,42 @@ describe('DelayedFunctionScheduler', function() {
expect(fn).toHaveBeenCalled();
});
it('runs the next scheduled funtion', function() {
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler();
const fn = jasmine.createSpy('fn');
const tickSpy = jasmine.createSpy('tick');
scheduler.scheduleFunction(fn, 10, [], false, 'foo');
expect(fn).not.toHaveBeenCalled();
scheduler.runNextQueuedFunction(tickSpy);
expect(fn).toHaveBeenCalled();
expect(tickSpy).toHaveBeenCalledWith(10);
});
it('runs the only a single scheduled funtion in a time slot', function() {
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler();
const fn1 = jasmine.createSpy('fn');
const fn2 = jasmine.createSpy('fn2');
const tickSpy = jasmine.createSpy('tick');
scheduler.scheduleFunction(fn1, 10, [], false, 'foo1');
scheduler.scheduleFunction(fn2, 10, [], false, 'foo2');
scheduler.runNextQueuedFunction(tickSpy);
expect(fn1).toHaveBeenCalled();
expect(fn2).not.toHaveBeenCalled();
expect(tickSpy).toHaveBeenCalledWith(10);
tickSpy.calls.reset();
scheduler.runNextQueuedFunction(tickSpy);
expect(fn2).toHaveBeenCalled();
expect(tickSpy).toHaveBeenCalledWith(0);
});
it('updates the mockDate per scheduled time', function() {
const scheduler = new jasmineUnderTest.DelayedFunctionScheduler(),
tickDate = jasmine.createSpy('tickDate');

View File

@@ -29,6 +29,15 @@ getJasmineRequireObj().Clock = function() {
let installed = false;
let delayedFunctionScheduler;
let timer;
// Tracks how the clock ticking behaves.
// By default, the clock only ticks when the user manually calls a tick method.
// There is also an 'auto' mode which will advance the clock automatically to
// to the next task. Once enabled, there is currently no mechanism for users
// to disable the auto ticking.
let tickMode = {
mode: 'manual',
counter: 0
};
this.FakeTimeout = FakeTimeout;
@@ -138,8 +147,95 @@ getJasmineRequireObj().Clock = function() {
}
};
/**
* Updates the clock to automatically advance time.
*
* 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.
*
* This mode allows tests to be authored in a way that does not need to be aware of the
* mock clock. Consequently, tests which have been authored without a mock clock installed
* can one with auto tick enabled without any other updates to the test logic.
*
* In many cases, this can greatly improve test execution speed because asynchronous tasks
* will execute as quickly as possible rather than waiting real time to complete.
*
* Furthermore, tests can be authored in a consitent manner. They can always be written in an asynchronous style
* rather than having `tick` sprinkled throughout the tests with mock time in order to manually
* advance the clock.
*
* When auto tick is enabled, `tick` can still be used to synchronously advance the clock if necessary.
*/
this.autoTick = function() {
if (tickMode.mode === 'auto') {
return;
}
tickMode = { mode: 'auto', counter: tickMode.counter + 1 };
advanceUntilModeChanges();
};
return this;
// Advances the Clock's time until the mode changes.
//
// The time is advanced asynchronously, giving microtasks and events a chance
// to run before each timer runs.
//
// @function
// @return {!Promise<undefined>}
async function advanceUntilModeChanges() {
if (!installed) {
throw new Error(
'Mock clock is not installed, use jasmine.clock().install()'
);
}
const { counter } = tickMode;
while (true) {
await newMacrotask();
if (
tickMode.counter !== counter ||
!installed ||
delayedFunctionScheduler === null
) {
return;
}
if (!delayedFunctionScheduler.isEmpty()) {
delayedFunctionScheduler.runNextQueuedFunction(function(millis) {
mockDate.tick(millis);
});
}
}
}
// Waits until a new macro task.
//
// Used with autoTick(), which is meant to act when the test is waiting, we need
// to insert ourselves in the macro task queue.
//
// @return {!Promise<undefined>}
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
// Note: This trick does not work in Safari, which will still throttle the setTimeout
const channel = new MessageChannel();
await new Promise(resolve => {
channel.port1.onmessage = resolve;
channel.port2.postMessage(undefined);
});
channel.port1.close();
channel.port2.close();
// setTimeout ensures that we interleave with other setTimeouts.
await new Promise(resolve => {
realTimingFunctions.setTimeout.call(global, resolve);
});
}
function originalTimingFunctionsIntact() {
return (
global.setTimeout === realTimingFunctions.setTimeout &&

View File

@@ -87,6 +87,37 @@ getJasmineRequireObj().DelayedFunctionScheduler = function(j$) {
}
};
// Returns whether there are any scheduled functions.
// Returns true if there are any scheduled functions, otherwise false.
this.isEmpty = function() {
return this.scheduledFunctions_.length === 0;
};
// Runs the next timeout in the queue, advancing the clock.
this.runNextQueuedFunction = function(tickDate) {
if (this.scheduledLookup_.length === 0) {
return;
}
const newCurrentTime = this.scheduledLookup_[0];
if (newCurrentTime >= this.currentTime_) {
tickDate(newCurrentTime - this.currentTime_);
this.currentTime_ = newCurrentTime;
}
const funcsAtTime = this.scheduledFunctions_[this.currentTime_];
const fn = funcsAtTime.shift();
if (funcsAtTime.length === 0) {
delete this.scheduledFunctions_[this.currentTime_];
this.scheduledLookup_.splice(0, 1);
}
if (fn.recurring) {
this.reschedule_(fn);
}
fn.funcToCall.apply(null, fn.params || []);
};
return this;
}