diff --git a/lib/jasmine-core/jasmine.js b/lib/jasmine-core/jasmine.js index 8644dc35..92802426 100644 --- a/lib/jasmine-core/jasmine.js +++ b/lib/jasmine-core/jasmine.js @@ -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} + 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} + 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; } diff --git a/spec/core/ClockSpec.js b/spec/core/ClockSpec.js index 474fbab8..6774aefb 100644 --- a/spec/core/ClockSpec.js +++ b/spec/core/ClockSpec.js @@ -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(), diff --git a/spec/core/DelayedFunctionSchedulerSpec.js b/spec/core/DelayedFunctionSchedulerSpec.js index d14b937e..5175d3f5 100644 --- a/spec/core/DelayedFunctionSchedulerSpec.js +++ b/spec/core/DelayedFunctionSchedulerSpec.js @@ -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'); diff --git a/src/core/Clock.js b/src/core/Clock.js index 8541d639..0d4a669d 100644 --- a/src/core/Clock.js +++ b/src/core/Clock.js @@ -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} + 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} + 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 && diff --git a/src/core/DelayedFunctionScheduler.js b/src/core/DelayedFunctionScheduler.js index 9ea6c5f3..cca8ba46 100644 --- a/src/core/DelayedFunctionScheduler.js +++ b/src/core/DelayedFunctionScheduler.js @@ -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; }