Merge branch 'atscott-autoTick'
* Merges #2042 from @atscott * Fixes #1932 * Fixes #1725
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user