From b0a28d9a7b38baabc22284c553f9c9ae354c7e01 Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Sat, 16 Jun 2018 11:00:47 +0300 Subject: [PATCH 01/13] feat(*): implement more granular pending task tracking Previously, all pending async tasks (tracked via `$browser`) are treated the same. I.e. things like `$$testability.whenStable()` and `ngMock#$timeout.verifyNoPendingTasks()` take all tasks into account. Yet, in some cases we might be interested in specific tasks only. For example, if one wants to verify there are no pending `$timeout`s, they don't care if there are other pending tasks, such as `$http` requests. Similarly, one might want to get notified when all `$http` requests have completed and does not care about pending promises. This commit adds support for more granular task tracking, by enabling callers to specify the type of task that is being added/removed from the queue and enabling listeners to be triggered when specific types of tasks are completed (even if there are more pending tasks of different types). The change is backwards compatible. I.e. calling the affected methods with no explicit task-type, behaves the same as before. Related to #14336. --- src/ng/browser.js | 99 +++++++++++++++------ src/ng/http.js | 4 +- src/ng/rootScope.js | 4 +- src/ng/testability.js | 18 +++- src/ng/timeout.js | 2 +- src/ngMock/angular-mocks.js | 140 ++++++++++++++++++++++-------- src/ngRoute/route.js | 4 +- test/ng/browserSpecs.js | 143 +++++++++++++++++++++++++++---- test/ng/httpSpec.js | 40 ++++----- test/ng/rootScopeSpec.js | 1 - test/ng/testabilitySpec.js | 13 +++ test/ngMock/angular-mocksSpec.js | 2 +- test/ngRoute/routeSpec.js | 49 +++-------- 13 files changed, 373 insertions(+), 146 deletions(-) diff --git a/src/ng/browser.js b/src/ng/browser.js index d5fbc9b6054a..cc51a71d00cd 100644 --- a/src/ng/browser.js +++ b/src/ng/browser.js @@ -23,35 +23,48 @@ * @param {object} $sniffer $sniffer service */ function Browser(window, document, $log, $sniffer) { + var ALL_TASKS_TYPE = '$$all$$', + DEFAULT_TASK_TYPE = '$$default$$'; + var self = this, location = window.location, history = window.history, setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, - pendingDeferIds = {}; + pendingDeferIds = {}, + outstandingRequestCounts = {}, + outstandingRequestCallbacks = []; self.isMock = false; - var outstandingRequestCount = 0; - var outstandingRequestCallbacks = []; - // TODO(vojta): remove this temporary api self.$$completeOutstandingRequest = completeOutstandingRequest; - self.$$incOutstandingRequestCount = function() { outstandingRequestCount++; }; + self.$$incOutstandingRequestCount = incOutstandingRequestCount; /** - * Executes the `fn` function(supports currying) and decrements the `outstandingRequestCallbacks` - * counter. If the counter reaches 0, all the `outstandingRequestCallbacks` are executed. + * Executes the `fn` function and decrements the appropriate `outstandingRequestCounts` counter. + * If the counter reaches 0, all the corresponding `outstandingRequestCallbacks` are executed. + * @param {Function} fn - The function to execute. + * @param {string=} [taskType=DEFAULT_TASK_TYPE] The type of task that is being completed. */ - function completeOutstandingRequest(fn) { + function completeOutstandingRequest(fn, taskType) { + taskType = taskType || DEFAULT_TASK_TYPE; try { - fn.apply(null, sliceArgs(arguments, 1)); + fn(); } finally { - outstandingRequestCount--; - if (outstandingRequestCount === 0) { - while (outstandingRequestCallbacks.length) { + decOutstandingRequestCount(taskType); + + var countForType = outstandingRequestCounts[taskType]; + var countForAll = outstandingRequestCounts[ALL_TASKS_TYPE]; + + // If at least one of the queues (`ALL_TASKS_TYPE` or `taskType`) is empty, run callbacks. + if (!countForAll || !countForType) { + var getNextCallback = !countForAll ? getLastCallback : getLastCallbackForType; + var nextCb; + + while ((nextCb = getNextCallback(taskType))) { try { - outstandingRequestCallbacks.pop()(); + nextCb(); } catch (e) { $log.error(e); } @@ -60,6 +73,33 @@ function Browser(window, document, $log, $sniffer) { } } + function decOutstandingRequestCount(taskType) { + taskType = taskType || DEFAULT_TASK_TYPE; + if (outstandingRequestCounts[taskType]) outstandingRequestCounts[taskType]--; + if (outstandingRequestCounts[ALL_TASKS_TYPE]) outstandingRequestCounts[ALL_TASKS_TYPE]--; + } + + function incOutstandingRequestCount(taskType) { + taskType = taskType || DEFAULT_TASK_TYPE; + outstandingRequestCounts[taskType] = (outstandingRequestCounts[taskType] || 0) + 1; + outstandingRequestCounts[ALL_TASKS_TYPE] = (outstandingRequestCounts[ALL_TASKS_TYPE] || 0) + 1; + } + + function getLastCallback() { + var cbInfo = outstandingRequestCallbacks.pop(); + return cbInfo && cbInfo.cb; + } + + function getLastCallbackForType(taskType) { + for (var i = outstandingRequestCallbacks.length - 1; i >= 0; --i) { + var cbInfo = outstandingRequestCallbacks[i]; + if (cbInfo.type === taskType) { + outstandingRequestCallbacks.splice(i, 1); + return cbInfo.cb; + } + } + } + function getHash(url) { var index = url.indexOf('#'); return index === -1 ? '' : url.substr(index); @@ -68,13 +108,15 @@ function Browser(window, document, $log, $sniffer) { /** * @private * TODO(vojta): prefix this method with $$ ? - * @param {function()} callback Function that will be called when no outstanding request + * @param {function()} callback Function that will be called when no outstanding request. + * @param {string=} [taskType=ALL_TASKS_TYPE] The type of tasks that will be waited for. */ - self.notifyWhenNoOutstandingRequests = function(callback) { - if (outstandingRequestCount === 0) { + self.notifyWhenNoOutstandingRequests = function(callback, taskType) { + taskType = taskType || ALL_TASKS_TYPE; + if (!outstandingRequestCounts[taskType]) { callback(); } else { - outstandingRequestCallbacks.push(callback); + outstandingRequestCallbacks.push({type: taskType, cb: callback}); } }; @@ -307,7 +349,8 @@ function Browser(window, document, $log, $sniffer) { /** * @name $browser#defer * @param {function()} fn A function, who's execution should be deferred. - * @param {number=} [delay=0] of milliseconds to defer the function execution. + * @param {number=} [delay=0] Number of milliseconds to defer the function execution. + * @param {string=} [taskType=DEFAULT_TASK_TYPE] The type of task that is deferred. * @returns {*} DeferId that can be used to cancel the task via `$browser.defer.cancel()`. * * @description @@ -318,14 +361,19 @@ function Browser(window, document, $log, $sniffer) { * via `$browser.defer.flush()`. * */ - self.defer = function(fn, delay) { + self.defer = function(fn, delay, taskType) { var timeoutId; - outstandingRequestCount++; + + delay = delay || 0; + taskType = taskType || DEFAULT_TASK_TYPE; + + incOutstandingRequestCount(taskType); timeoutId = setTimeout(function() { delete pendingDeferIds[timeoutId]; - completeOutstandingRequest(fn); - }, delay || 0); - pendingDeferIds[timeoutId] = true; + completeOutstandingRequest(fn, taskType); + }, delay); + pendingDeferIds[timeoutId] = taskType; + return timeoutId; }; @@ -341,10 +389,11 @@ function Browser(window, document, $log, $sniffer) { * canceled. */ self.defer.cancel = function(deferId) { - if (pendingDeferIds[deferId]) { + if (pendingDeferIds.hasOwnProperty(deferId)) { + var taskType = pendingDeferIds[deferId]; delete pendingDeferIds[deferId]; clearTimeout(deferId); - completeOutstandingRequest(noop); + completeOutstandingRequest(noop, taskType); return true; } return false; diff --git a/src/ng/http.js b/src/ng/http.js index c8f69ee58ab1..81d150519b5b 100644 --- a/src/ng/http.js +++ b/src/ng/http.js @@ -1054,7 +1054,7 @@ function $HttpProvider() { config.paramSerializer = isString(config.paramSerializer) ? $injector.get(config.paramSerializer) : config.paramSerializer; - $browser.$$incOutstandingRequestCount(); + $browser.$$incOutstandingRequestCount('$http'); var requestInterceptors = []; var responseInterceptors = []; @@ -1092,7 +1092,7 @@ function $HttpProvider() { } function completeOutstandingRequest() { - $browser.$$completeOutstandingRequest(noop); + $browser.$$completeOutstandingRequest(noop, '$http'); } function executeHeaderFns(headers, config) { diff --git a/src/ng/rootScope.js b/src/ng/rootScope.js index e145c5102611..2a1d85ccbb2b 100644 --- a/src/ng/rootScope.js +++ b/src/ng/rootScope.js @@ -1122,7 +1122,7 @@ function $RootScopeProvider() { if (asyncQueue.length) { $rootScope.$digest(); } - }); + }, null, '$evalAsync'); } asyncQueue.push({scope: this, fn: $parse(expr), locals: locals}); @@ -1493,7 +1493,7 @@ function $RootScopeProvider() { if (applyAsyncId === null) { applyAsyncId = $browser.defer(function() { $rootScope.$apply(flushApplyAsync); - }); + }, null, '$applyAsync'); } } }]; diff --git a/src/ng/testability.js b/src/ng/testability.js index 413f0c2a4461..1a6898e39f13 100644 --- a/src/ng/testability.js +++ b/src/ng/testability.js @@ -104,12 +104,24 @@ function $$TestabilityProvider() { * @name $$testability#whenStable * * @description - * Calls the callback when $timeout and $http requests are completed. + * Calls the callback when all pending requests (such as `$timeout` and `$http`) are completed. + * You can optionally pass a `taskType`, in which case the callback will be called when all + * pending requests of that type are completed (even if there are tasks of different types still + * pending). + * + * Available task types: + * - `$timeout`: Pending timeouts (via {@link $timeout}). + * - `$http`: Pending HTTP requests (via {@link $http}). + * - `$route`: A route transition is in progress (via {@link $route}). + * - `$applyAsync`: Pending tasks scheduled via {@link $rootScope#$applyAsync}. + * - `$evalAsync`: Pending tasks scheduled via {@link $rootScope#$evalAsync}. + * These include tasks scheduled via `$evalAsync()` indirectly (such as {@link $q} promises). * * @param {function} callback + * @param {string=} taskType */ - testability.whenStable = function(callback) { - $browser.notifyWhenNoOutstandingRequests(callback); + testability.whenStable = function(callback, taskType) { + $browser.notifyWhenNoOutstandingRequests(callback, taskType); }; return testability; diff --git a/src/ng/timeout.js b/src/ng/timeout.js index 1e4eaad3349f..122c9a90e6eb 100644 --- a/src/ng/timeout.js +++ b/src/ng/timeout.js @@ -63,7 +63,7 @@ function $TimeoutProvider() { } if (!skipApply) $rootScope.$apply(); - }, delay); + }, delay, '$timeout'); promise.$$timeoutId = timeoutId; deferreds[timeoutId] = deferred; diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index 0771324621b8..629f847ad65f 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -32,6 +32,8 @@ angular.mock.$BrowserProvider = function() { }; angular.mock.$Browser = function() { + var ALL_TASKS_TYPE = '$$all$$'; + var DEFAULT_TASK_TYPE = '$$default$$'; var self = this; this.isMock = true; @@ -41,28 +43,65 @@ angular.mock.$Browser = function() { // Testability API - var outstandingRequestCount = 0; + var outstandingRequestCounts = {}; var outstandingRequestCallbacks = []; - self.$$incOutstandingRequestCount = function() { outstandingRequestCount++; }; - self.$$completeOutstandingRequest = function(fn) { + + self.$$completeOutstandingRequest = completeOutstandingRequest; + self.$$incOutstandingRequestCount = incOutstandingRequestCount; + self.notifyWhenNoOutstandingRequests = notifyWhenNoOutstandingRequests; + + function decOutstandingRequestCount(taskType) { + taskType = taskType || DEFAULT_TASK_TYPE; + if (outstandingRequestCounts[taskType]) outstandingRequestCounts[taskType]--; + if (outstandingRequestCounts[ALL_TASKS_TYPE]) outstandingRequestCounts[ALL_TASKS_TYPE]--; + } + function incOutstandingRequestCount(taskType) { + taskType = taskType || DEFAULT_TASK_TYPE; + outstandingRequestCounts[taskType] = (outstandingRequestCounts[taskType] || 0) + 1; + outstandingRequestCounts[ALL_TASKS_TYPE] = (outstandingRequestCounts[ALL_TASKS_TYPE] || 0) + 1; + } + function completeOutstandingRequest(fn, taskType) { + taskType = taskType || DEFAULT_TASK_TYPE; try { fn(); } finally { - outstandingRequestCount--; - if (!outstandingRequestCount) { - while (outstandingRequestCallbacks.length) { - outstandingRequestCallbacks.pop()(); + decOutstandingRequestCount(taskType); + + var countForType = outstandingRequestCounts[taskType]; + var countForAll = outstandingRequestCounts[ALL_TASKS_TYPE]; + + // If at least one of the queues (`ALL_TASKS_TYPE` or `taskType`) is empty, run callbacks. + if (!countForAll || !countForType) { + var getNextCallback = !countForAll ? getLastCallback : getLastCallbackForType; + var nextCb; + + while ((nextCb = getNextCallback(taskType))) { + nextCb(); } } } - }; - self.notifyWhenNoOutstandingRequests = function(callback) { - if (outstandingRequestCount) { - outstandingRequestCallbacks.push(callback); - } else { + } + function getLastCallback() { + var cbInfo = outstandingRequestCallbacks.pop(); + return cbInfo && cbInfo.cb; + } + function getLastCallbackForType(taskType) { + for (var i = outstandingRequestCallbacks.length - 1; i >= 0; --i) { + var cbInfo = outstandingRequestCallbacks[i]; + if (cbInfo.type === taskType) { + outstandingRequestCallbacks.splice(i, 1); + return cbInfo.cb; + } + } + } + function notifyWhenNoOutstandingRequests(callback, taskType) { + taskType = taskType || ALL_TASKS_TYPE; + if (!outstandingRequestCounts[taskType]) { callback(); + } else { + outstandingRequestCallbacks.push({type: taskType, cb: callback}); } - }; + } // register url polling fn @@ -86,13 +125,22 @@ angular.mock.$Browser = function() { self.deferredFns = []; self.deferredNextId = 0; - self.defer = function(fn, delay) { - // Note that we do not use `$$incOutstandingRequestCount` or `$$completeOutstandingRequest` - // in this mock implementation. + self.defer = function(fn, delay, taskType) { + var timeoutId = self.deferredNextId++; + delay = delay || 0; - self.deferredFns.push({time:(self.defer.now + delay), fn:fn, id: self.deferredNextId}); - self.deferredFns.sort(function(a, b) { return a.time - b.time;}); - return self.deferredNextId++; + taskType = taskType || DEFAULT_TASK_TYPE; + + incOutstandingRequestCount(taskType); + self.deferredFns.push({ + id: timeoutId, + type: taskType, + time: (self.defer.now + delay), + fn: fn + }); + self.deferredFns.sort(function(a, b) { return a.time - b.time; }); + + return timeoutId; }; @@ -106,14 +154,15 @@ angular.mock.$Browser = function() { self.defer.cancel = function(deferId) { - var fnIndex; + var taskIndex; - angular.forEach(self.deferredFns, function(fn, index) { - if (fn.id === deferId) fnIndex = index; + angular.forEach(self.deferredFns, function(task, index) { + if (task.id === deferId) taskIndex = index; }); - if (angular.isDefined(fnIndex)) { - self.deferredFns.splice(fnIndex, 1); + if (angular.isDefined(taskIndex)) { + var task = self.deferredFns.splice(taskIndex, 1)[0]; + completeOutstandingRequest(angular.noop, task.type); return true; } @@ -148,13 +197,40 @@ angular.mock.$Browser = function() { while (self.deferredFns.length && self.deferredFns[0].time <= nextTime) { // Increment the time and call the next deferred function self.defer.now = self.deferredFns[0].time; - self.deferredFns.shift().fn(); + var task = self.deferredFns.shift(); + completeOutstandingRequest(task.fn, task.type); } // Ensure that the current time is correct self.defer.now = nextTime; }; + /** + * @name $browser#defer.verifyNoPendingTasks + * + * @description + * Verifies that there are no pending tasks that need to be flushed. + * You can check for a specific type of tasks only, by specifying a `taskType`. + * + * @param {string=} taskType - The type task to check for. + */ + self.defer.verifyNoPendingTasks = function(taskType) { + var pendingTasks = !taskType + ? self.deferredFns + : self.deferredFns.filter(function(task) { return task.type === taskType; }); + + if (pendingTasks.length) { + var formattedTasks = pendingTasks + .map(function(task) { + return '{id: ' + task.id + ', type: ' + task.type + ', time: ' + task.time + '}'; + }) + .join('\n '); + + throw new Error('Deferred tasks to flush (' + pendingTasks.length + '):\n ' + + formattedTasks); + } + }; + self.$$baseHref = '/'; self.baseHref = function() { return this.$$baseHref; @@ -2198,21 +2274,9 @@ angular.mock.$TimeoutDecorator = ['$delegate', '$browser', function($delegate, $ * Verifies that there are no pending tasks that need to be flushed. */ $delegate.verifyNoPendingTasks = function() { - if ($browser.deferredFns.length) { - throw new Error('Deferred tasks to flush (' + $browser.deferredFns.length + '): ' + - formatPendingTasksAsString($browser.deferredFns)); - } + $browser.defer.verifyNoPendingTasks(); }; - function formatPendingTasksAsString(tasks) { - var result = []; - angular.forEach(tasks, function(task) { - result.push('{id: ' + task.id + ', time: ' + task.time + '}'); - }); - - return result.join(', '); - } - return $delegate; }]; diff --git a/src/ngRoute/route.js b/src/ngRoute/route.js index a241d0c9824d..d6a2f336c734 100644 --- a/src/ngRoute/route.js +++ b/src/ngRoute/route.js @@ -653,7 +653,7 @@ function $RouteProvider() { var nextRoutePromise = $q.resolve(nextRoute); - $browser.$$incOutstandingRequestCount(); + $browser.$$incOutstandingRequestCount('$route'); nextRoutePromise. then(getRedirectionData). @@ -681,7 +681,7 @@ function $RouteProvider() { // `outstandingRequestCount` to hit zero. This is important in case we are redirecting // to a new route which also requires some asynchronous work. - $browser.$$completeOutstandingRequest(noop); + $browser.$$completeOutstandingRequest(noop, '$route'); }); } } diff --git a/test/ng/browserSpecs.js b/test/ng/browserSpecs.js index 074e4404830a..7b8d7268dee8 100644 --- a/test/ng/browserSpecs.js +++ b/test/ng/browserSpecs.js @@ -34,9 +34,9 @@ function MockWindow(options) { timeouts[id] = noop; }; - this.setTimeout.flush = function() { - var length = timeouts.length; - while (length-- > 0) timeouts.shift()(); + this.setTimeout.flush = function(count) { + count = count || timeouts.length; + while (count-- > 0) timeouts.shift()(); }; this.addEventListener = function(name, listener) { @@ -144,21 +144,21 @@ function MockDocument() { describe('browser', function() { /* global Browser: false */ - var browser, fakeWindow, fakeDocument, fakeLog, logs, scripts, removedScripts; + var browser, fakeWindow, fakeDocument, fakeLog, logs; beforeEach(function() { - scripts = []; - removedScripts = []; sniffer = {history: true}; fakeWindow = new MockWindow(); fakeDocument = new MockDocument(); logs = {log:[], warn:[], info:[], error:[]}; - fakeLog = {log: function() { logs.log.push(slice.call(arguments)); }, - warn: function() { logs.warn.push(slice.call(arguments)); }, - info: function() { logs.info.push(slice.call(arguments)); }, - error: function() { logs.error.push(slice.call(arguments)); }}; + fakeLog = { + log: function() { logs.log.push(slice.call(arguments)); }, + warn: function() { logs.warn.push(slice.call(arguments)); }, + info: function() { logs.info.push(slice.call(arguments)); }, + error: function() { logs.error.push(slice.call(arguments)); } + }; browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer); }); @@ -214,12 +214,66 @@ describe('browser', function() { } }); - describe('outstanding requests', function() { - it('should process callbacks immediately with no outstanding requests', function() { + + describe('notifyWhenNoOutstandingRequests', function() { + it('should invoke callbacks immediately if there are no pending tasks', function() { + var callback = jasmine.createSpy('callback'); + browser.notifyWhenNoOutstandingRequests(callback); + expect(callback).toHaveBeenCalled(); + }); + + + it('should invoke callbacks immediately if there are no pending tasks (for specific task-type)', + function() { + var callbackAll = jasmine.createSpy('callbackAll'); + var callbackFoo = jasmine.createSpy('callbackFoo'); + + browser.$$incOutstandingRequestCount(); + browser.notifyWhenNoOutstandingRequests(callbackAll); + browser.notifyWhenNoOutstandingRequests(callbackFoo, 'foo'); + + expect(callbackAll).not.toHaveBeenCalled(); + expect(callbackFoo).toHaveBeenCalled(); + } + ); + + + it('should invoke callbacks as soon as there are no pending tasks', function() { var callback = jasmine.createSpy('callback'); + + browser.$$incOutstandingRequestCount(); browser.notifyWhenNoOutstandingRequests(callback); + expect(callback).not.toHaveBeenCalled(); + + browser.$$completeOutstandingRequest(noop); expect(callback).toHaveBeenCalled(); }); + + + it('should invoke callbacks as soon as there are no pending tasks (for specific task-type)', + function() { + var callbackAll = jasmine.createSpy('callbackAll'); + var callbackFoo = jasmine.createSpy('callbackFoo'); + + browser.$$incOutstandingRequestCount(); + browser.$$incOutstandingRequestCount('foo'); + browser.notifyWhenNoOutstandingRequests(callbackAll); + browser.notifyWhenNoOutstandingRequests(callbackFoo, 'foo'); + + expect(callbackAll).not.toHaveBeenCalled(); + expect(callbackFoo).not.toHaveBeenCalled(); + + browser.$$completeOutstandingRequest(noop, 'foo'); + + expect(callbackAll).not.toHaveBeenCalled(); + expect(callbackFoo).toHaveBeenCalledOnce(); + + browser.$$completeOutstandingRequest(noop); + + expect(callbackAll).toHaveBeenCalledOnce(); + expect(callbackFoo).toHaveBeenCalledOnce(); + } + ); }); @@ -236,13 +290,36 @@ describe('browser', function() { it('should update outstandingRequests counter', function() { - var callback = jasmine.createSpy('deferred'); + var noPendingTasksSpy = jasmine.createSpy('noPendingTasks'); - browser.defer(callback); - expect(callback).not.toHaveBeenCalled(); + browser.defer(noop); + browser.notifyWhenNoOutstandingRequests(noPendingTasksSpy); + expect(noPendingTasksSpy).not.toHaveBeenCalled(); fakeWindow.setTimeout.flush(); - expect(callback).toHaveBeenCalledOnce(); + expect(noPendingTasksSpy).toHaveBeenCalledOnce(); + }); + + + it('should update outstandingRequests counter (for specific task-type)', function() { + var noPendingFooTasksSpy = jasmine.createSpy('noPendingFooTasks'); + var noPendingTasksSpy = jasmine.createSpy('noPendingTasks'); + + browser.defer(noop, 0, 'foo'); + browser.defer(noop, 0, 'bar'); + + browser.notifyWhenNoOutstandingRequests(noPendingFooTasksSpy, 'foo'); + browser.notifyWhenNoOutstandingRequests(noPendingTasksSpy); + expect(noPendingFooTasksSpy).not.toHaveBeenCalled(); + expect(noPendingTasksSpy).not.toHaveBeenCalled(); + + fakeWindow.setTimeout.flush(1); + expect(noPendingFooTasksSpy).toHaveBeenCalledOnce(); + expect(noPendingTasksSpy).not.toHaveBeenCalled(); + + fakeWindow.setTimeout.flush(1); + expect(noPendingFooTasksSpy).toHaveBeenCalledOnce(); + expect(noPendingTasksSpy).toHaveBeenCalledOnce(); }); @@ -270,6 +347,40 @@ describe('browser', function() { expect(log).toEqual(['ok']); expect(browser.defer.cancel(deferId2)).toBe(false); }); + + + it('should update outstandingRequests counter', function() { + var noPendingTasksSpy = jasmine.createSpy('noPendingTasks'); + var deferId = browser.defer(noop); + + browser.notifyWhenNoOutstandingRequests(noPendingTasksSpy); + expect(noPendingTasksSpy).not.toHaveBeenCalled(); + + browser.defer.cancel(deferId); + expect(noPendingTasksSpy).toHaveBeenCalledOnce(); + }); + + + it('should update outstandingRequests counter (for specific task-type)', function() { + var noPendingFooTasksSpy = jasmine.createSpy('noPendingFooTasks'); + var noPendingTasksSpy = jasmine.createSpy('noPendingTasks'); + + var deferId1 = browser.defer(noop, 0, 'foo'); + var deferId2 = browser.defer(noop, 0, 'bar'); + + browser.notifyWhenNoOutstandingRequests(noPendingFooTasksSpy, 'foo'); + browser.notifyWhenNoOutstandingRequests(noPendingTasksSpy); + expect(noPendingFooTasksSpy).not.toHaveBeenCalled(); + expect(noPendingTasksSpy).not.toHaveBeenCalled(); + + browser.defer.cancel(deferId1); + expect(noPendingFooTasksSpy).toHaveBeenCalledOnce(); + expect(noPendingTasksSpy).not.toHaveBeenCalled(); + + browser.defer.cancel(deferId2); + expect(noPendingFooTasksSpy).toHaveBeenCalledOnce(); + expect(noPendingTasksSpy).toHaveBeenCalledOnce(); + }); }); }); diff --git a/test/ng/httpSpec.js b/test/ng/httpSpec.js index f1cf0e896fb5..065d93ac439f 100644 --- a/test/ng/httpSpec.js +++ b/test/ng/httpSpec.js @@ -2002,7 +2002,7 @@ describe('$http', function() { it('should immediately call `$browser.$$incOutstandingRequestCount()`', function() { expect(incOutstandingRequestCountSpy).not.toHaveBeenCalled(); $http.get(''); - expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnce(); + expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnceWith('$http'); }); @@ -2012,7 +2012,7 @@ describe('$http', function() { $http.get(''); expect(completeOutstandingRequestSpy).not.toHaveBeenCalled(); $httpBackend.flush(); - expect(completeOutstandingRequestSpy).toHaveBeenCalledOnce(); + expect(completeOutstandingRequestSpy).toHaveBeenCalledOnceWith(noop, '$http'); }); @@ -2022,7 +2022,7 @@ describe('$http', function() { $http.get('').catch(noop); expect(completeOutstandingRequestSpy).not.toHaveBeenCalled(); $httpBackend.flush(); - expect(completeOutstandingRequestSpy).toHaveBeenCalledOnce(); + expect(completeOutstandingRequestSpy).toHaveBeenCalledOnceWith(noop, '$http'); }); @@ -2033,13 +2033,13 @@ describe('$http', function() { $http.get('', {transformRequest: function() { throw new Error(); }}).catch(noop); - expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnce(); + expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnceWith('$http'); expect(completeOutstandingRequestSpy).not.toHaveBeenCalled(); $rootScope.$digest(); - expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnce(); - expect(completeOutstandingRequestSpy).toHaveBeenCalledOnce(); + expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnceWith('$http'); + expect(completeOutstandingRequestSpy).toHaveBeenCalledOnceWith(noop, '$http'); } ); @@ -2052,13 +2052,13 @@ describe('$http', function() { $httpBackend.when('GET').respond(200); $http.get('', {transformResponse: function() { throw new Error(); }}).catch(noop); - expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnce(); + expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnceWith('$http'); expect(completeOutstandingRequestSpy).not.toHaveBeenCalled(); $httpBackend.flush(); - expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnce(); - expect(completeOutstandingRequestSpy).toHaveBeenCalledOnce(); + expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnceWith('$http'); + expect(completeOutstandingRequestSpy).toHaveBeenCalledOnceWith(noop, '$http'); } ); }); @@ -2112,7 +2112,7 @@ describe('$http', function() { expect(reqInterceptorFulfilled).toBe(false); expect(resInterceptorFulfilled).toBe(false); - expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnce(); + expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnceWith('$http'); expect(completeOutstandingRequestSpy).not.toHaveBeenCalled(); reqInterceptorDeferred.resolve(); @@ -2120,7 +2120,7 @@ describe('$http', function() { expect(reqInterceptorFulfilled).toBe(true); expect(resInterceptorFulfilled).toBe(false); - expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnce(); + expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnceWith('$http'); expect(completeOutstandingRequestSpy).not.toHaveBeenCalled(); resInterceptorDeferred.resolve(); @@ -2128,8 +2128,8 @@ describe('$http', function() { expect(reqInterceptorFulfilled).toBe(true); expect(resInterceptorFulfilled).toBe(true); - expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnce(); - expect(completeOutstandingRequestSpy).toHaveBeenCalledOnce(); + expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnceWith('$http'); + expect(completeOutstandingRequestSpy).toHaveBeenCalledOnceWith(noop, '$http'); } ); @@ -2144,15 +2144,15 @@ describe('$http', function() { $rootScope.$digest(); expect(reqInterceptorFulfilled).toBe(false); - expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnce(); + expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnceWith('$http'); expect(completeOutstandingRequestSpy).not.toHaveBeenCalled(); reqInterceptorDeferred.reject(); $rootScope.$digest(); expect(reqInterceptorFulfilled).toBe(true); - expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnce(); - expect(completeOutstandingRequestSpy).toHaveBeenCalledOnce(); + expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnceWith('$http'); + expect(completeOutstandingRequestSpy).toHaveBeenCalledOnceWith(noop, '$http'); } ); @@ -2169,7 +2169,7 @@ describe('$http', function() { expect(reqInterceptorFulfilled).toBe(false); expect(resInterceptorFulfilled).toBe(false); - expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnce(); + expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnceWith('$http'); expect(completeOutstandingRequestSpy).not.toHaveBeenCalled(); reqInterceptorDeferred.resolve(); @@ -2177,7 +2177,7 @@ describe('$http', function() { expect(reqInterceptorFulfilled).toBe(true); expect(resInterceptorFulfilled).toBe(false); - expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnce(); + expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnceWith('$http'); expect(completeOutstandingRequestSpy).not.toHaveBeenCalled(); resInterceptorDeferred.reject(); @@ -2185,8 +2185,8 @@ describe('$http', function() { expect(reqInterceptorFulfilled).toBe(true); expect(resInterceptorFulfilled).toBe(true); - expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnce(); - expect(completeOutstandingRequestSpy).toHaveBeenCalledOnce(); + expect(incOutstandingRequestCountSpy).toHaveBeenCalledOnceWith('$http'); + expect(completeOutstandingRequestSpy).toHaveBeenCalledOnceWith(noop, '$http'); } ); }); diff --git a/test/ng/rootScopeSpec.js b/test/ng/rootScopeSpec.js index 8503d220547e..d5c66ae24db2 100644 --- a/test/ng/rootScopeSpec.js +++ b/test/ng/rootScopeSpec.js @@ -2387,7 +2387,6 @@ describe('Scope', function() { it('should be cancelled if a $rootScope digest occurs before the next tick', inject(function($rootScope, $browser) { - var apply = spyOn($rootScope, '$apply').and.callThrough(); var cancel = spyOn($browser.defer, 'cancel').and.callThrough(); var expression = jasmine.createSpy('expr'); diff --git a/test/ng/testabilitySpec.js b/test/ng/testabilitySpec.js index ce40de5f4966..343f4d29edd0 100644 --- a/test/ng/testabilitySpec.js +++ b/test/ng/testabilitySpec.js @@ -194,5 +194,18 @@ describe('$$testability', function() { $$testability.whenStable(callback); expect(callback).toHaveBeenCalled(); })); + + it('should delegate to `$browser.notifyWhenNoOutstandingRequests()`', + inject(function($$testability, $browser) { + var spy = spyOn($browser, 'notifyWhenNoOutstandingRequests'); + var callback = noop; + + $$testability.whenStable(callback); + expect(spy).toHaveBeenCalledWith(callback, undefined); + + spy.calls.reset(); + $$testability.whenStable(callback, 'taskType'); + expect(spy).toHaveBeenCalledWith(callback, 'taskType'); + })); }); }); diff --git a/test/ngMock/angular-mocksSpec.js b/test/ngMock/angular-mocksSpec.js index 9704525b11ea..1b0c9aee8a0e 100644 --- a/test/ngMock/angular-mocksSpec.js +++ b/test/ngMock/angular-mocksSpec.js @@ -722,7 +722,7 @@ describe('ngMock', function() { it('should throw an exception when not flushed', inject(function($timeout) { $timeout(noop); - var expectedError = 'Deferred tasks to flush (1): {id: 0, time: 0}'; + var expectedError = 'Deferred tasks to flush (1):\n {id: 0, type: $timeout, time: 0}'; expect(function() {$timeout.verifyNoPendingTasks();}).toThrowError(expectedError); })); diff --git a/test/ngRoute/routeSpec.js b/test/ngRoute/routeSpec.js index 14d655af83e9..cdf755f42e12 100644 --- a/test/ngRoute/routeSpec.js +++ b/test/ngRoute/routeSpec.js @@ -2419,9 +2419,8 @@ describe('$route', function() { it('should wait for $resolve promises before calling callbacks', function() { var deferred; - module(function($provide, $routeProvider) { + module(function($routeProvider) { $routeProvider.when('/path', { - template: '', resolve: { a: function($q) { deferred = $q.defer(); @@ -2431,7 +2430,7 @@ describe('$route', function() { }); }); - inject(function($location, $route, $rootScope, $httpBackend, $$testability) { + inject(function($browser, $location, $rootScope, $$testability) { $location.path('/path'); $rootScope.$digest(); @@ -2440,7 +2439,7 @@ describe('$route', function() { expect(callback).not.toHaveBeenCalled(); deferred.resolve(); - $rootScope.$digest(); + $browser.defer.flush(); expect(callback).toHaveBeenCalled(); }); }); @@ -2448,9 +2447,8 @@ describe('$route', function() { it('should call callback after $resolve promises are rejected', function() { var deferred; - module(function($provide, $routeProvider) { + module(function($routeProvider) { $routeProvider.when('/path', { - template: '', resolve: { a: function($q) { deferred = $q.defer(); @@ -2460,7 +2458,7 @@ describe('$route', function() { }); }); - inject(function($location, $route, $rootScope, $httpBackend, $$testability) { + inject(function($browser, $location, $rootScope, $$testability) { $location.path('/path'); $rootScope.$digest(); @@ -2469,7 +2467,7 @@ describe('$route', function() { expect(callback).not.toHaveBeenCalled(); deferred.reject(); - $rootScope.$digest(); + $browser.defer.flush(); expect(callback).toHaveBeenCalled(); }); }); @@ -2477,7 +2475,7 @@ describe('$route', function() { it('should wait for resolveRedirectTo promises before calling callbacks', function() { var deferred; - module(function($provide, $routeProvider) { + module(function($routeProvider) { $routeProvider.when('/path', { resolveRedirectTo: function($q) { deferred = $q.defer(); @@ -2486,7 +2484,7 @@ describe('$route', function() { }); }); - inject(function($location, $route, $rootScope, $httpBackend, $$testability) { + inject(function($browser, $location, $rootScope, $$testability) { $location.path('/path'); $rootScope.$digest(); @@ -2495,7 +2493,7 @@ describe('$route', function() { expect(callback).not.toHaveBeenCalled(); deferred.resolve(); - $rootScope.$digest(); + $browser.defer.flush(); expect(callback).toHaveBeenCalled(); }); }); @@ -2503,7 +2501,7 @@ describe('$route', function() { it('should call callback after resolveRedirectTo promises are rejected', function() { var deferred; - module(function($provide, $routeProvider) { + module(function($routeProvider) { $routeProvider.when('/path', { resolveRedirectTo: function($q) { deferred = $q.defer(); @@ -2512,7 +2510,7 @@ describe('$route', function() { }); }); - inject(function($location, $route, $rootScope, $httpBackend, $$testability) { + inject(function($browser, $location, $rootScope, $$testability) { $location.path('/path'); $rootScope.$digest(); @@ -2521,7 +2519,7 @@ describe('$route', function() { expect(callback).not.toHaveBeenCalled(); deferred.reject(); - $rootScope.$digest(); + $browser.defer.flush(); expect(callback).toHaveBeenCalled(); }); }); @@ -2529,30 +2527,11 @@ describe('$route', function() { it('should wait for all route promises before calling callbacks', function() { var deferreds = {}; - module(function($provide, $routeProvider) { - // While normally `$browser.defer()` modifies the `outstandingRequestCount`, the mocked - // version (provided by `ngMock`) does not. This doesn't matter in most tests, but in this - // case we need the `outstandingRequestCount` logic to ensure that we don't call the - // `$$testability.whenStable()` callbacks part way through a `$rootScope.$evalAsync` block. - // See ngRoute's commitRoute()'s finally() block for details. - $provide.decorator('$browser', function($delegate) { - var oldDefer = $delegate.defer; - var newDefer = function(fn, delay) { - var requestCountAwareFn = function() { $delegate.$$completeOutstandingRequest(fn); }; - $delegate.$$incOutstandingRequestCount(); - return oldDefer.call($delegate, requestCountAwareFn, delay); - }; - - $delegate.defer = angular.extend(newDefer, oldDefer); - - return $delegate; - }); - + module(function($routeProvider) { addRouteWithAsyncRedirect('/foo', '/bar'); addRouteWithAsyncRedirect('/bar', '/baz'); addRouteWithAsyncRedirect('/baz', '/qux'); $routeProvider.when('/qux', { - template: '', resolve: { a: function($q) { var deferred = deferreds['/qux'] = $q.defer(); @@ -2572,7 +2551,7 @@ describe('$route', function() { } }); - inject(function($browser, $location, $rootScope, $route, $$testability) { + inject(function($browser, $location, $rootScope, $$testability) { $location.path('/foo'); $rootScope.$digest(); From 5d68b934693504ac4f4daa522461ef5f4b2ce551 Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Tue, 26 Jun 2018 16:34:01 +0300 Subject: [PATCH 02/13] fixup! feat(*): implement more granular pending task tracking --- src/ng/browser.js | 6 +- src/ng/testability.js | 22 ++- src/ngMock/angular-mocks.js | 23 +-- test/ng/testabilitySpec.js | 6 +- test/ngMock/angular-mocksSpec.js | 247 +++++++++++++++++++++++++------ 5 files changed, 231 insertions(+), 73 deletions(-) diff --git a/src/ng/browser.js b/src/ng/browser.js index cc51a71d00cd..089ef911ac23 100644 --- a/src/ng/browser.js +++ b/src/ng/browser.js @@ -75,8 +75,10 @@ function Browser(window, document, $log, $sniffer) { function decOutstandingRequestCount(taskType) { taskType = taskType || DEFAULT_TASK_TYPE; - if (outstandingRequestCounts[taskType]) outstandingRequestCounts[taskType]--; - if (outstandingRequestCounts[ALL_TASKS_TYPE]) outstandingRequestCounts[ALL_TASKS_TYPE]--; + if (outstandingRequestCounts[taskType]) { + outstandingRequestCounts[taskType]--; + outstandingRequestCounts[ALL_TASKS_TYPE]--; + } } function incOutstandingRequestCount(taskType) { diff --git a/src/ng/testability.js b/src/ng/testability.js index 1a6898e39f13..4471021af70f 100644 --- a/src/ng/testability.js +++ b/src/ng/testability.js @@ -104,24 +104,20 @@ function $$TestabilityProvider() { * @name $$testability#whenStable * * @description - * Calls the callback when all pending requests (such as `$timeout` and `$http`) are completed. - * You can optionally pass a `taskType`, in which case the callback will be called when all - * pending requests of that type are completed (even if there are tasks of different types still - * pending). + * Calls the callback when all pending tasks are completed. * - * Available task types: - * - `$timeout`: Pending timeouts (via {@link $timeout}). - * - `$http`: Pending HTTP requests (via {@link $http}). - * - `$route`: A route transition is in progress (via {@link $route}). - * - `$applyAsync`: Pending tasks scheduled via {@link $rootScope#$applyAsync}. - * - `$evalAsync`: Pending tasks scheduled via {@link $rootScope#$evalAsync}. + * Types of tasks waited for include: + * - Pending timeouts (via {@link $timeout}). + * - Pending HTTP requests (via {@link $http}). + * - In-progress route transitions (via {@link $route}). + * - Pending tasks scheduled via {@link $rootScope#$applyAsync}. + * - Pending tasks scheduled via {@link $rootScope#$evalAsync}. * These include tasks scheduled via `$evalAsync()` indirectly (such as {@link $q} promises). * * @param {function} callback - * @param {string=} taskType */ - testability.whenStable = function(callback, taskType) { - $browser.notifyWhenNoOutstandingRequests(callback, taskType); + testability.whenStable = function(callback) { + $browser.notifyWhenNoOutstandingRequests(callback); }; return testability; diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index 629f847ad65f..b709f6e61b29 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -52,8 +52,10 @@ angular.mock.$Browser = function() { function decOutstandingRequestCount(taskType) { taskType = taskType || DEFAULT_TASK_TYPE; - if (outstandingRequestCounts[taskType]) outstandingRequestCounts[taskType]--; - if (outstandingRequestCounts[ALL_TASKS_TYPE]) outstandingRequestCounts[ALL_TASKS_TYPE]--; + if (outstandingRequestCounts[taskType]) { + outstandingRequestCounts[taskType]--; + outstandingRequestCounts[ALL_TASKS_TYPE]--; + } } function incOutstandingRequestCount(taskType) { taskType = taskType || DEFAULT_TASK_TYPE; @@ -184,14 +186,12 @@ angular.mock.$Browser = function() { if (angular.isDefined(delay)) { // A delay was passed so compute the next time nextTime = self.defer.now + delay; + } else if (self.deferredFns.length) { + // No delay was passed so set the next time so that it clears the deferred queue + nextTime = self.deferredFns[self.deferredFns.length - 1].time; } else { - if (self.deferredFns.length) { - // No delay was passed so set the next time so that it clears the deferred queue - nextTime = self.deferredFns[self.deferredFns.length - 1].time; - } else { - // No delay passed, but there are no deferred tasks so flush - indicates an error! - throw new Error('No deferred tasks to be flushed'); - } + // No delay passed, but there are no deferred tasks so flush - indicates an error! + throw new Error('No deferred tasks to be flushed'); } while (self.deferredFns.length && self.deferredFns[0].time <= nextTime) { @@ -2263,6 +2263,9 @@ angular.mock.$TimeoutDecorator = ['$delegate', '$browser', function($delegate, $ * @param {number=} delay maximum timeout amount to flush up until */ $delegate.flush = function(delay) { + // For historical reasons, `$timeout.flush()` flushes all types of pending tasks. + // Keep the same behavior for backwards compatibility (and because it doesn't make sense to + // selectively flush scheduled events out of order). $browser.defer.flush(delay); }; @@ -2274,6 +2277,8 @@ angular.mock.$TimeoutDecorator = ['$delegate', '$browser', function($delegate, $ * Verifies that there are no pending tasks that need to be flushed. */ $delegate.verifyNoPendingTasks = function() { + // For historical reasons, `$timeout.verifyNoPendingTasks()` takes all types of pending tasks + // into account. Keep the same behavior for backwards compatibility. $browser.defer.verifyNoPendingTasks(); }; diff --git a/test/ng/testabilitySpec.js b/test/ng/testabilitySpec.js index 343f4d29edd0..c65aacfb0dd7 100644 --- a/test/ng/testabilitySpec.js +++ b/test/ng/testabilitySpec.js @@ -201,11 +201,7 @@ describe('$$testability', function() { var callback = noop; $$testability.whenStable(callback); - expect(spy).toHaveBeenCalledWith(callback, undefined); - - spy.calls.reset(); - $$testability.whenStable(callback, 'taskType'); - expect(spy).toHaveBeenCalledWith(callback, 'taskType'); + expect(spy).toHaveBeenCalledWith(callback); })); }); }); diff --git a/test/ngMock/angular-mocksSpec.js b/test/ngMock/angular-mocksSpec.js index 1b0c9aee8a0e..10bcbb8fad70 100644 --- a/test/ngMock/angular-mocksSpec.js +++ b/test/ngMock/angular-mocksSpec.js @@ -601,7 +601,7 @@ describe('ngMock', function() { }); - describe('defer', function() { + describe('$browser', function() { var browser, log; beforeEach(inject(function($browser) { browser = $browser; @@ -614,47 +614,199 @@ describe('ngMock', function() { }; } - it('should flush', function() { - browser.defer(logFn('A')); - expect(log).toEqual(''); - browser.defer.flush(); - expect(log).toEqual('A;'); + describe('defer.flush', function() { + it('should flush', function() { + browser.defer(logFn('A')); + browser.defer(logFn('B'), null, 'taskType'); + expect(log).toEqual(''); + + browser.defer.flush(); + expect(log).toEqual('A;B;'); + }); + + it('should flush delayed', function() { + browser.defer(logFn('A')); + browser.defer(logFn('B'), 10, 'taskType'); + browser.defer(logFn('C'), 20); + expect(log).toEqual(''); + expect(browser.defer.now).toEqual(0); + + browser.defer.flush(0); + expect(log).toEqual('A;'); + + browser.defer.flush(); + expect(log).toEqual('A;B;C;'); + }); + + it('should defer and flush over time', function() { + browser.defer(logFn('A'), 1); + browser.defer(logFn('B'), 2, 'taskType'); + browser.defer(logFn('C'), 3); + + browser.defer.flush(0); + expect(browser.defer.now).toEqual(0); + expect(log).toEqual(''); + + browser.defer.flush(1); + expect(browser.defer.now).toEqual(1); + expect(log).toEqual('A;'); + + browser.defer.flush(2); + expect(browser.defer.now).toEqual(3); + expect(log).toEqual('A;B;C;'); + }); + + it('should throw an exception if there is nothing to be flushed', function() { + expect(function() {browser.defer.flush();}).toThrowError('No deferred tasks to be flushed'); + }); + + it('should not throw an exception when passing a specific delay', function() { + expect(function() {browser.defer.flush(100);}).not.toThrow(); + }); }); - it('should flush delayed', function() { - browser.defer(logFn('A')); - browser.defer(logFn('B'), 10); - browser.defer(logFn('C'), 20); - expect(log).toEqual(''); + describe('defer.cancel', function() { + it('should cancel a pending task', function() { + var taskId1 = browser.defer(logFn('A'), 100, 'fooType'); + var taskId2 = browser.defer(logFn('B'), 200); + + expect(log).toBe(''); + expect(function() {browser.defer.verifyNoPendingTasks('fooType');}).toThrow(); + expect(function() {browser.defer.verifyNoPendingTasks();}).toThrow(); + + browser.defer.cancel(taskId1); + expect(function() {browser.defer.verifyNoPendingTasks('fooType');}).not.toThrow(); + expect(function() {browser.defer.verifyNoPendingTasks();}).toThrow(); - expect(browser.defer.now).toEqual(0); - browser.defer.flush(0); - expect(log).toEqual('A;'); + browser.defer.cancel(taskId2); + expect(function() {browser.defer.verifyNoPendingTasks('fooType');}).not.toThrow(); + expect(function() {browser.defer.verifyNoPendingTasks();}).not.toThrow(); - browser.defer.flush(); - expect(log).toEqual('A;B;C;'); + browser.defer.flush(1000); + expect(log).toBe(''); + }); }); - it('should defer and flush over time', function() { - browser.defer(logFn('A'), 1); - browser.defer(logFn('B'), 2); - browser.defer(logFn('C'), 3); + describe('defer.verifyNoPendingTasks', function() { + it('should throw if there are pending tasks', function() { + expect(browser.defer.verifyNoPendingTasks).not.toThrow(); + + browser.defer(noop); + expect(browser.defer.verifyNoPendingTasks).toThrow(); + }); + + it('should list the pending tasks (in order) in the error message', function() { + browser.defer(noop, 100); + browser.defer(noop, 300, 'fooType'); + browser.defer(noop, 200, 'barType'); - browser.defer.flush(0); - expect(browser.defer.now).toEqual(0); - expect(log).toEqual(''); + var expectedError = + 'Deferred tasks to flush (3):\n' + + ' {id: 0, type: $$default$$, time: 100}\n' + + ' {id: 2, type: barType, time: 200}\n' + + ' {id: 1, type: fooType, time: 300}'; + expect(browser.defer.verifyNoPendingTasks).toThrowError(expectedError); + }); + + describe('with specific task type', function() { + it('should throw if there are pending tasks', function() { + browser.defer(noop, 0, 'fooType') - browser.defer.flush(1); - expect(browser.defer.now).toEqual(1); - expect(log).toEqual('A;'); + expect(function() {browser.defer.verifyNoPendingTasks('barType');}).not.toThrow(); + expect(function() {browser.defer.verifyNoPendingTasks('fooType');}).toThrow(); + expect(function() {browser.defer.verifyNoPendingTasks();}).toThrow(); + }); - browser.defer.flush(2); - expect(browser.defer.now).toEqual(3); - expect(log).toEqual('A;B;C;'); + it('should list the pending tasks (in order) in the error message', function() { + browser.defer(noop, 100); + browser.defer(noop, 300, 'fooType'); + browser.defer(noop, 200, 'barType'); + browser.defer(noop, 400, 'fooType'); + + var expectedError = + 'Deferred tasks to flush (2):\n' + + ' {id: 1, type: fooType, time: 300}\n' + + ' {id: 3, type: fooType, time: 400}'; + expect(function() {browser.defer.verifyNoPendingTasks('fooType');}). + toThrowError(expectedError); + }); + }); }); - it('should throw an exception if there is nothing to be flushed', function() { - expect(function() {browser.defer.flush();}).toThrowError('No deferred tasks to be flushed'); + describe('notifyWhenNoOutstandingRequests', function() { + var callback; + beforeEach(function() { + callback = jasmine.createSpy('callback'); + }); + + it('should immediatelly run the callback if no pending tasks', function() { + browser.notifyWhenNoOutstandingRequests(callback); + expect(callback).toHaveBeenCalled(); + }); + + it('should run the callback as soon as there are no pending tasks', function() { + browser.defer(noop, 100); + browser.defer(noop, 200); + + browser.notifyWhenNoOutstandingRequests(callback); + expect(callback).not.toHaveBeenCalled(); + + browser.defer.flush(100); + expect(callback).not.toHaveBeenCalled(); + + browser.defer.flush(100); + expect(callback).toHaveBeenCalled(); + }); + + it('should not run the callback more than once', function() { + browser.defer(noop, 100); + browser.notifyWhenNoOutstandingRequests(callback); + expect(callback).not.toHaveBeenCalled(); + + browser.defer.flush(100); + expect(callback).toHaveBeenCalledOnce(); + + browser.defer(noop, 200); + browser.defer.flush(100); + expect(callback).toHaveBeenCalledOnce(); + }); + + describe('with specific task type', function() { + it('should immediatelly run the callback if no pending tasks', function() { + browser.notifyWhenNoOutstandingRequests(callback, 'fooType'); + expect(callback).toHaveBeenCalled(); + }); + + it('should run the callback as soon as there are no pending tasks', function() { + browser.defer(noop, 100, 'fooType'); + browser.defer(noop, 200, 'barType'); + + browser.notifyWhenNoOutstandingRequests(callback, 'fooType'); + expect(callback).not.toHaveBeenCalled(); + + browser.defer.flush(100); + expect(callback).toHaveBeenCalled(); + }); + + it('should not run the callback more than once', function() { + browser.defer(noop, 100, 'fooType'); + browser.defer(noop, 200); + + browser.notifyWhenNoOutstandingRequests(callback, 'fooType'); + expect(callback).not.toHaveBeenCalled(); + + browser.defer.flush(100); + expect(callback).toHaveBeenCalledOnce(); + + browser.defer.flush(100); + expect(callback).toHaveBeenCalledOnce(); + + browser.defer(noop, 100, 'fooType'); + browser.defer(noop, 200); + browser.defer.flush(); + expect(callback).toHaveBeenCalledOnce(); + }); + }); }); }); @@ -705,47 +857,53 @@ describe('ngMock', function() { describe('$timeout', function() { it('should expose flush method that will flush the pending queue of tasks', inject( - function($timeout) { + function($rootScope, $timeout) { var logger = [], logFn = function(msg) { return function() { logger.push(msg); }; }; $timeout(logFn('t1')); $timeout(logFn('t2'), 200); + $rootScope.$evalAsync(logFn('rs')); // Non-timeout tasks are flushed as well. $timeout(logFn('t3')); expect(logger).toEqual([]); $timeout.flush(); - expect(logger).toEqual(['t1', 't3', 't2']); + expect(logger).toEqual(['t1', 'rs', 't3', 't2']); })); - it('should throw an exception when not flushed', inject(function($timeout) { - $timeout(noop); + it('should throw an exception when not flushed', inject(function($rootScope, $timeout) { + $timeout(noop, 100); + $rootScope.$evalAsync(noop); - var expectedError = 'Deferred tasks to flush (1):\n {id: 0, type: $timeout, time: 0}'; - expect(function() {$timeout.verifyNoPendingTasks();}).toThrowError(expectedError); + var expectedError = + 'Deferred tasks to flush (2):\n' + + ' {id: 1, type: $evalAsync, time: 0}\n' + + ' {id: 0, type: $timeout, time: 100}'; + expect($timeout.verifyNoPendingTasks).toThrowError(expectedError); })); - it('should do nothing when all tasks have been flushed', inject(function($timeout) { - $timeout(noop); + it('should do nothing when all tasks have been flushed', inject(function($rootScope, $timeout) { + $timeout(noop, 100); + $rootScope.$evalAsync(noop); $timeout.flush(); - expect(function() {$timeout.verifyNoPendingTasks();}).not.toThrow(); + expect($timeout.verifyNoPendingTasks).not.toThrow(); })); it('should check against the delay if provided within timeout', inject(function($timeout) { $timeout(noop, 100); $timeout.flush(100); - expect(function() {$timeout.verifyNoPendingTasks();}).not.toThrow(); + expect($timeout.verifyNoPendingTasks).not.toThrow(); $timeout(noop, 1000); $timeout.flush(100); - expect(function() {$timeout.verifyNoPendingTasks();}).toThrow(); + expect($timeout.verifyNoPendingTasks).toThrow(); $timeout.flush(900); - expect(function() {$timeout.verifyNoPendingTasks();}).not.toThrow(); + expect($timeout.verifyNoPendingTasks).not.toThrow(); })); @@ -763,6 +921,7 @@ describe('ngMock', function() { expect(count).toBe(2); })); + it('should resolve timeout functions following the timeline', inject(function($timeout) { var count1 = 0, count2 = 0; var iterate1 = function() { @@ -1056,7 +1215,7 @@ describe('ngMock', function() { describe('$httpBackend', function() { - var hb, callback, realBackendSpy; + var hb, callback; beforeEach(inject(function($httpBackend) { callback = jasmine.createSpy('callback'); From fbbaf8903f8a8a9f7d4573e69b16a28b3f4baa29 Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Mon, 2 Jul 2018 17:58:38 +0300 Subject: [PATCH 03/13] fixup! feat(*): implement more granular pending task tracking --- test/ngMock/angular-mocksSpec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ngMock/angular-mocksSpec.js b/test/ngMock/angular-mocksSpec.js index 10bcbb8fad70..e205acbec5d7 100644 --- a/test/ngMock/angular-mocksSpec.js +++ b/test/ngMock/angular-mocksSpec.js @@ -710,7 +710,7 @@ describe('ngMock', function() { describe('with specific task type', function() { it('should throw if there are pending tasks', function() { - browser.defer(noop, 0, 'fooType') + browser.defer(noop, 0, 'fooType'); expect(function() {browser.defer.verifyNoPendingTasks('barType');}).not.toThrow(); expect(function() {browser.defer.verifyNoPendingTasks('fooType');}).toThrow(); From d39460f7b839503c93f7153aa2cdeb64368961c3 Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Tue, 26 Jun 2018 16:50:59 +0300 Subject: [PATCH 04/13] refactor(ngMock/$interval): more closely follow actual `$interval`'s internal implementation --- src/ngMock/angular-mocks.js | 16 +++++++++++++--- test/ngMock/angular-mocksSpec.js | 6 +++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index b709f6e61b29..be5e5aa30462 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -552,13 +552,15 @@ angular.mock.$IntervalProvider = function() { promise = deferred.promise; count = (angular.isDefined(count)) ? count : 0; - promise.then(null, function() {}, (!hasParams) ? fn : function() { - fn.apply(null, args); - }); promise.$$intervalId = nextRepeatId; function tick() { + if (skipApply) { + $browser.defer(callback); + } else { + $rootScope.$evalAsync(callback); + } deferred.notify(iteration++); if (count > 0 && iteration >= count) { @@ -581,6 +583,14 @@ angular.mock.$IntervalProvider = function() { } } + function callback() { + if (!hasParams) { + fn(iteration); + } else { + fn.apply(null, args); + } + } + repeatFns.push({ nextTime: (now + (delay || 0)), delay: delay || 1, diff --git a/test/ngMock/angular-mocksSpec.js b/test/ngMock/angular-mocksSpec.js index e205acbec5d7..53dc15f51a94 100644 --- a/test/ngMock/angular-mocksSpec.js +++ b/test/ngMock/angular-mocksSpec.js @@ -323,16 +323,16 @@ describe('ngMock', function() { it('should NOT call $apply if invokeApply is set to false', inject(function($interval, $rootScope) { - var applySpy = spyOn($rootScope, '$apply').and.callThrough(); + var digestSpy = spyOn($rootScope, '$digest').and.callThrough(); var counter = 0; $interval(function increment() { counter++; }, 1000, 0, false); - expect(applySpy).not.toHaveBeenCalled(); + expect(digestSpy).not.toHaveBeenCalled(); expect(counter).toBe(0); $interval.flush(2000); - expect(applySpy).not.toHaveBeenCalled(); + expect(digestSpy).not.toHaveBeenCalled(); expect(counter).toBe(2); })); From a44620375e49317b6e521ba3c8137a51fc98da48 Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Tue, 26 Jun 2018 20:45:44 +0300 Subject: [PATCH 05/13] refactor($interval): share code between `$interval` and `ngMock/$interval` This avoids code/logic duplication and helps the implementations stay in-sync. --- angularFiles.js | 1 + src/AngularPublic.js | 2 + src/ng/interval.js | 61 +++++---------------- src/ng/intervalFactory.js | 48 +++++++++++++++++ src/ngMock/angular-mocks.js | 104 ++++++++++++------------------------ 5 files changed, 99 insertions(+), 117 deletions(-) create mode 100644 src/ng/intervalFactory.js diff --git a/angularFiles.js b/angularFiles.js index 16edba44f35d..f0925f94af2a 100644 --- a/angularFiles.js +++ b/angularFiles.js @@ -28,6 +28,7 @@ var angularFiles = { 'src/ng/httpBackend.js', 'src/ng/interpolate.js', 'src/ng/interval.js', + 'src/ng/intervalFactory.js', 'src/ng/jsonpCallbacks.js', 'src/ng/locale.js', 'src/ng/location.js', diff --git a/src/AngularPublic.js b/src/AngularPublic.js index dca14bdd6ffd..3d94d77d456d 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -70,6 +70,7 @@ $FilterProvider, $$ForceReflowProvider, $InterpolateProvider, + $$IntervalFactoryProvider, $IntervalProvider, $HttpProvider, $HttpParamSerializerProvider, @@ -241,6 +242,7 @@ function publishExternalAPI(angular) { $$forceReflow: $$ForceReflowProvider, $interpolate: $InterpolateProvider, $interval: $IntervalProvider, + $$intervalFactory: $$IntervalFactoryProvider, $http: $HttpProvider, $httpParamSerializer: $HttpParamSerializerProvider, $httpParamSerializerJQLike: $HttpParamSerializerJQLikeProvider, diff --git a/src/ng/interval.js b/src/ng/interval.js index 750a6ba3df1c..fa032276a382 100644 --- a/src/ng/interval.js +++ b/src/ng/interval.js @@ -4,10 +4,18 @@ var $intervalMinErr = minErr('$interval'); /** @this */ function $IntervalProvider() { - this.$get = ['$rootScope', '$window', '$q', '$$q', '$browser', - function($rootScope, $window, $q, $$q, $browser) { + this.$get = ['$$intervalFactory', '$window', + function($$intervalFactory, $window) { var intervals = {}; - + var setIntervalFn = function(tick, delay, deferred) { + var id = $window.setInterval(tick, delay); + intervals[id] = deferred; + return id; + }; + var clearIntervalFn = function(id) { + $window.clearInterval(id); + delete intervals[id]; + }; /** * @ngdoc service @@ -135,49 +143,7 @@ function $IntervalProvider() { * * */ - function interval(fn, delay, count, invokeApply) { - var hasParams = arguments.length > 4, - args = hasParams ? sliceArgs(arguments, 4) : [], - setInterval = $window.setInterval, - clearInterval = $window.clearInterval, - iteration = 0, - skipApply = (isDefined(invokeApply) && !invokeApply), - deferred = (skipApply ? $$q : $q).defer(), - promise = deferred.promise; - - count = isDefined(count) ? count : 0; - - promise.$$intervalId = setInterval(function tick() { - if (skipApply) { - $browser.defer(callback); - } else { - $rootScope.$evalAsync(callback); - } - deferred.notify(iteration++); - - if (count > 0 && iteration >= count) { - deferred.resolve(iteration); - clearInterval(promise.$$intervalId); - delete intervals[promise.$$intervalId]; - } - - if (!skipApply) $rootScope.$apply(); - - }, delay); - - intervals[promise.$$intervalId] = deferred; - - return promise; - - function callback() { - if (!hasParams) { - fn(iteration); - } else { - fn.apply(null, args); - } - } - } - + var interval = $$intervalFactory(setIntervalFn, clearIntervalFn); /** * @ngdoc method @@ -205,8 +171,7 @@ function $IntervalProvider() { // Interval cancels should not report an unhandled promise. markQExceptionHandled(deferred.promise); deferred.reject('canceled'); - $window.clearInterval(id); - delete intervals[id]; + clearIntervalFn(id); return true; }; diff --git a/src/ng/intervalFactory.js b/src/ng/intervalFactory.js new file mode 100644 index 000000000000..077b6ad92650 --- /dev/null +++ b/src/ng/intervalFactory.js @@ -0,0 +1,48 @@ +'use strict'; + +/** @this */ +function $$IntervalFactoryProvider() { + this.$get = ['$browser', '$q', '$$q', '$rootScope', + function($browser, $q, $$q, $rootScope) { + return function intervalFactory(setIntervalFn, clearIntervalFn) { + return function intervalFn(fn, delay, count, invokeApply) { + var hasParams = arguments.length > 4, + args = hasParams ? sliceArgs(arguments, 4) : [], + iteration = 0, + skipApply = isDefined(invokeApply) && !invokeApply, + deferred = (skipApply ? $$q : $q).defer(), + promise = deferred.promise; + + count = isDefined(count) ? count : 0; + + function callback() { + if (!hasParams) { + fn(iteration); + } else { + fn.apply(null, args); + } + } + + function tick() { + if (skipApply) { + $browser.defer(callback); + } else { + $rootScope.$evalAsync(callback); + } + deferred.notify(iteration++); + + if (count > 0 && iteration >= count) { + deferred.resolve(iteration); + clearIntervalFn(promise.$$intervalId); + } + + if (!skipApply) $rootScope.$apply(); + } + + promise.$$intervalId = setIntervalFn(tick, delay, deferred, skipApply); + + return promise; + }; + }; + }]; +} diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index be5e5aa30462..71bff4a40ccb 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -537,72 +537,40 @@ angular.mock.$LogProvider = function() { * @returns {promise} A promise which will be notified on each iteration. */ angular.mock.$IntervalProvider = function() { - this.$get = ['$browser', '$rootScope', '$q', '$$q', - function($browser, $rootScope, $q, $$q) { + this.$get = ['$browser', '$$intervalFactory', + function($browser, $$intervalFactory) { var repeatFns = [], nextRepeatId = 0, - now = 0; - - var $interval = function(fn, delay, count, invokeApply) { - var hasParams = arguments.length > 4, - args = hasParams ? Array.prototype.slice.call(arguments, 4) : [], - iteration = 0, - skipApply = (angular.isDefined(invokeApply) && !invokeApply), - deferred = (skipApply ? $$q : $q).defer(), - promise = deferred.promise; - - count = (angular.isDefined(count)) ? count : 0; - - promise.$$intervalId = nextRepeatId; - - function tick() { - if (skipApply) { - $browser.defer(callback); - } else { - $rootScope.$evalAsync(callback); - } - deferred.notify(iteration++); - - if (count > 0 && iteration >= count) { - var fnIndex; - deferred.resolve(iteration); - - angular.forEach(repeatFns, function(fn, index) { - if (fn.id === promise.$$intervalId) fnIndex = index; + now = 0, + setIntervalFn = function(tick, delay, deferred, skipApply) { + var id = nextRepeatId++; + var fn = !skipApply ? tick : function() { + tick(); + $browser.defer.flush(); + }; + + repeatFns.push({ + nextTime: (now + (delay || 0)), + delay: delay || 1, + fn: fn, + id: id, + deferred: deferred }); + repeatFns.sort(function(a, b) { return a.nextTime - b.nextTime; }); - if (angular.isDefined(fnIndex)) { - repeatFns.splice(fnIndex, 1); + return id; + }, + clearIntervalFn = function(id) { + for (var fnIndex = repeatFns.length - 1; fnIndex >= 0; fnIndex--) { + if (repeatFns[fnIndex].id === id) { + repeatFns.splice(fnIndex, 1); + break; + } } - } - - if (skipApply) { - $browser.defer.flush(); - } else { - $rootScope.$apply(); - } - } + }; - function callback() { - if (!hasParams) { - fn(iteration); - } else { - fn.apply(null, args); - } - } + var $interval = $$intervalFactory(setIntervalFn, clearIntervalFn); - repeatFns.push({ - nextTime: (now + (delay || 0)), - delay: delay || 1, - fn: tick, - id: nextRepeatId, - deferred: deferred - }); - repeatFns.sort(function(a, b) { return a.nextTime - b.nextTime;}); - - nextRepeatId++; - return promise; - }; /** * @ngdoc method * @name $interval#cancel @@ -615,17 +583,15 @@ angular.mock.$IntervalProvider = function() { */ $interval.cancel = function(promise) { if (!promise) return false; - var fnIndex; - - angular.forEach(repeatFns, function(fn, index) { - if (fn.id === promise.$$intervalId) fnIndex = index; - }); - if (angular.isDefined(fnIndex)) { - repeatFns[fnIndex].deferred.promise.then(undefined, function() {}); - repeatFns[fnIndex].deferred.reject('canceled'); - repeatFns.splice(fnIndex, 1); - return true; + for (var fnIndex = repeatFns.length - 1; fnIndex >= 0; fnIndex--) { + if (repeatFns[fnIndex].id === promise.$$intervalId) { + var deferred = repeatFns[fnIndex].deferred; + deferred.promise.then(undefined, function() {}); + deferred.reject('canceled'); + repeatFns.splice(fnIndex, 1); + return true; + } } return false; From fd4f70733d8dc095cdf2ef4d25dcfff119162409 Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Wed, 27 Jun 2018 00:22:49 +0300 Subject: [PATCH 06/13] refactor($browser): share task-tracking code between `$browser` and `ngMock/$browser` This avoids code/logic duplication and helps the implementations stay in-sync. --- angularFiles.js | 1 + src/AngularPublic.js | 2 + src/ng/browser.js | 119 +++++++--------------------------- src/ng/taskTrackerFactory.js | 122 +++++++++++++++++++++++++++++++++++ src/ngMock/angular-mocks.js | 89 +++++-------------------- test/ng/browserSpecs.js | 22 ++++--- test/ng/locationSpec.js | 4 +- 7 files changed, 178 insertions(+), 181 deletions(-) create mode 100644 src/ng/taskTrackerFactory.js diff --git a/angularFiles.js b/angularFiles.js index f0925f94af2a..6c4bcab00cec 100644 --- a/angularFiles.js +++ b/angularFiles.js @@ -41,6 +41,7 @@ var angularFiles = { 'src/ng/sanitizeUri.js', 'src/ng/sce.js', 'src/ng/sniffer.js', + 'src/ng/taskTrackerFactory.js', 'src/ng/templateRequest.js', 'src/ng/testability.js', 'src/ng/timeout.js', diff --git a/src/AngularPublic.js b/src/AngularPublic.js index 3d94d77d456d..725e2877078f 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -89,6 +89,7 @@ $SceProvider, $SceDelegateProvider, $SnifferProvider, + $$TaskTrackerFactoryProvider, $TemplateCacheProvider, $TemplateRequestProvider, $$TestabilityProvider, @@ -258,6 +259,7 @@ function publishExternalAPI(angular) { $sce: $SceProvider, $sceDelegate: $SceDelegateProvider, $sniffer: $SnifferProvider, + $$taskTrackerFactory: $$TaskTrackerFactoryProvider, $templateCache: $TemplateCacheProvider, $templateRequest: $TemplateRequestProvider, $$testability: $$TestabilityProvider, diff --git a/src/ng/browser.js b/src/ng/browser.js index 089ef911ac23..2f1494c905bf 100644 --- a/src/ng/browser.js +++ b/src/ng/browser.js @@ -22,105 +22,27 @@ * @param {object} $log window.console or an object with the same interface. * @param {object} $sniffer $sniffer service */ -function Browser(window, document, $log, $sniffer) { - var ALL_TASKS_TYPE = '$$all$$', - DEFAULT_TASK_TYPE = '$$default$$'; - +function Browser(window, document, $log, $sniffer, $$taskTrackerFactory) { var self = this, location = window.location, history = window.history, setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, pendingDeferIds = {}, - outstandingRequestCounts = {}, - outstandingRequestCallbacks = []; + taskTracker = $$taskTrackerFactory($log); self.isMock = false; - // TODO(vojta): remove this temporary api - self.$$completeOutstandingRequest = completeOutstandingRequest; - self.$$incOutstandingRequestCount = incOutstandingRequestCount; - - /** - * Executes the `fn` function and decrements the appropriate `outstandingRequestCounts` counter. - * If the counter reaches 0, all the corresponding `outstandingRequestCallbacks` are executed. - * @param {Function} fn - The function to execute. - * @param {string=} [taskType=DEFAULT_TASK_TYPE] The type of task that is being completed. - */ - function completeOutstandingRequest(fn, taskType) { - taskType = taskType || DEFAULT_TASK_TYPE; - try { - fn(); - } finally { - decOutstandingRequestCount(taskType); - - var countForType = outstandingRequestCounts[taskType]; - var countForAll = outstandingRequestCounts[ALL_TASKS_TYPE]; - - // If at least one of the queues (`ALL_TASKS_TYPE` or `taskType`) is empty, run callbacks. - if (!countForAll || !countForType) { - var getNextCallback = !countForAll ? getLastCallback : getLastCallbackForType; - var nextCb; - - while ((nextCb = getNextCallback(taskType))) { - try { - nextCb(); - } catch (e) { - $log.error(e); - } - } - } - } - } - - function decOutstandingRequestCount(taskType) { - taskType = taskType || DEFAULT_TASK_TYPE; - if (outstandingRequestCounts[taskType]) { - outstandingRequestCounts[taskType]--; - outstandingRequestCounts[ALL_TASKS_TYPE]--; - } - } - - function incOutstandingRequestCount(taskType) { - taskType = taskType || DEFAULT_TASK_TYPE; - outstandingRequestCounts[taskType] = (outstandingRequestCounts[taskType] || 0) + 1; - outstandingRequestCounts[ALL_TASKS_TYPE] = (outstandingRequestCounts[ALL_TASKS_TYPE] || 0) + 1; - } - - function getLastCallback() { - var cbInfo = outstandingRequestCallbacks.pop(); - return cbInfo && cbInfo.cb; - } - - function getLastCallbackForType(taskType) { - for (var i = outstandingRequestCallbacks.length - 1; i >= 0; --i) { - var cbInfo = outstandingRequestCallbacks[i]; - if (cbInfo.type === taskType) { - outstandingRequestCallbacks.splice(i, 1); - return cbInfo.cb; - } - } - } + ////////////////////////////////////////////////////////////// + // Task-tracking API + ////////////////////////////////////////////////////////////// - function getHash(url) { - var index = url.indexOf('#'); - return index === -1 ? '' : url.substr(index); - } + // TODO(vojta): remove this temporary api + self.$$completeOutstandingRequest = taskTracker.completeTask; + self.$$incOutstandingRequestCount = taskTracker.incTaskCount; - /** - * @private - * TODO(vojta): prefix this method with $$ ? - * @param {function()} callback Function that will be called when no outstanding request. - * @param {string=} [taskType=ALL_TASKS_TYPE] The type of tasks that will be waited for. - */ - self.notifyWhenNoOutstandingRequests = function(callback, taskType) { - taskType = taskType || ALL_TASKS_TYPE; - if (!outstandingRequestCounts[taskType]) { - callback(); - } else { - outstandingRequestCallbacks.push({type: taskType, cb: callback}); - } - }; + // TODO(vojta): prefix this method with $$ ? + self.notifyWhenNoOutstandingRequests = taskTracker.notifyWhenNoPendingTasks; ////////////////////////////////////////////////////////////// // URL API @@ -140,6 +62,11 @@ function Browser(window, document, $log, $sniffer) { cacheState(); + function getHash(url) { + var index = url.indexOf('#'); + return index === -1 ? '' : url.substr(index); + } + /** * @name $browser#url * @@ -367,12 +294,12 @@ function Browser(window, document, $log, $sniffer) { var timeoutId; delay = delay || 0; - taskType = taskType || DEFAULT_TASK_TYPE; + taskType = taskType || taskTracker.DEFAULT_TASK_TYPE; - incOutstandingRequestCount(taskType); + taskTracker.incTaskCount(taskType); timeoutId = setTimeout(function() { delete pendingDeferIds[timeoutId]; - completeOutstandingRequest(fn, taskType); + taskTracker.completeTask(fn, taskType); }, delay); pendingDeferIds[timeoutId] = taskType; @@ -395,7 +322,7 @@ function Browser(window, document, $log, $sniffer) { var taskType = pendingDeferIds[deferId]; delete pendingDeferIds[deferId]; clearTimeout(deferId); - completeOutstandingRequest(noop, taskType); + taskTracker.completeTask(noop, taskType); return true; } return false; @@ -405,8 +332,8 @@ function Browser(window, document, $log, $sniffer) { /** @this */ function $BrowserProvider() { - this.$get = ['$window', '$log', '$sniffer', '$document', - function($window, $log, $sniffer, $document) { - return new Browser($window, $document, $log, $sniffer); - }]; + this.$get = ['$window', '$log', '$sniffer', '$document', '$$taskTrackerFactory', + function($window, $log, $sniffer, $document, $$taskTrackerFactory) { + return new Browser($window, $document, $log, $sniffer, $$taskTrackerFactory); + }]; } diff --git a/src/ng/taskTrackerFactory.js b/src/ng/taskTrackerFactory.js new file mode 100644 index 000000000000..dc4369431d44 --- /dev/null +++ b/src/ng/taskTrackerFactory.js @@ -0,0 +1,122 @@ +'use strict'; + +/** + * ! This is a private undocumented service ! + * + * @name $$taskTrackerFactory + * @description + * A function to create `TaskTracker` instances. + * + * A `TaskTracker` can keep track of pending tasks (grouped by type) and can notify interested + * parties when all pending tasks (or tasks of a specific type) have been completed. + * + * @param {$log} log - A logger instance (such as `$log`). Used to log error during callback + * execution. + * + * @this + */ +function $$TaskTrackerFactoryProvider() { + this.$get = valueFn(function(log) { return new TaskTracker(log); }); +} + +function TaskTracker(log) { + var self = this; + var taskCounts = {}; + var taskCallbacks = []; + + var ALL_TASKS_TYPE = self.ALL_TASKS_TYPE = '$$all$$'; + var DEFAULT_TASK_TYPE = self.DEFAULT_TASK_TYPE = '$$default$$'; + + /** + * Execute the specified function and decrement the appropriate `taskCounts` counter. + * If the counter reaches 0, all corresponding `taskCallbacks` are executed. + * + * @param {Function} fn - The function to execute. + * @param {string=} [taskType=DEFAULT_TASK_TYPE] - The type of task that is being completed. + */ + self.completeTask = completeTask; + + /** + * Increase the task count for the specified task type (or the default task type if non is + * specified). + * + * @param {string=} [taskType=DEFAULT_TASK_TYPE] - The type of task whose count will be increased. + */ + self.incTaskCount = incTaskCount; + + /** + * Execute the specified callback when all pending tasks have been completed. + * + * If there are no pending tasks, the callback is executed immediatelly. You can optionally limit + * the tasks that will be waited for to a specific type, by passing a `taskType`. + * + * @param {function} callback - The function to call when there are no pending tasks. + * @param {string=} [taskType=ALL_TASKS_TYPE] - The type of tasks that will be waited for. + */ + self.notifyWhenNoPendingTasks = notifyWhenNoPendingTasks; + + function completeTask(fn, taskType) { + taskType = taskType || DEFAULT_TASK_TYPE; + + try { + fn(); + } finally { + decTaskCount(taskType); + + var countForType = taskCounts[taskType]; + var countForAll = taskCounts[ALL_TASKS_TYPE]; + + // If at least one of the queues (`ALL_TASKS_TYPE` or `taskType`) is empty, run callbacks. + if (!countForAll || !countForType) { + var getNextCallback = !countForAll ? getLastCallback : getLastCallbackForType; + var nextCb; + + while ((nextCb = getNextCallback(taskType))) { + try { + nextCb(); + } catch (e) { + log.error(e); + } + } + } + } + } + + function decTaskCount(taskType) { + taskType = taskType || DEFAULT_TASK_TYPE; + if (taskCounts[taskType]) { + taskCounts[taskType]--; + taskCounts[ALL_TASKS_TYPE]--; + } + } + + function getLastCallback() { + var cbInfo = taskCallbacks.pop(); + return cbInfo && cbInfo.cb; + } + + function getLastCallbackForType(taskType) { + for (var i = taskCallbacks.length - 1; i >= 0; --i) { + var cbInfo = taskCallbacks[i]; + if (cbInfo.type === taskType) { + taskCallbacks.splice(i, 1); + return cbInfo.cb; + } + } + } + + function incTaskCount(taskType) { + taskType = taskType || DEFAULT_TASK_TYPE; + taskCounts[taskType] = (taskCounts[taskType] || 0) + 1; + taskCounts[ALL_TASKS_TYPE] = (taskCounts[ALL_TASKS_TYPE] || 0) + 1; + } + + function notifyWhenNoPendingTasks(callback, taskType) { + taskType = taskType || ALL_TASKS_TYPE; + if (!taskCounts[taskType]) { + callback(); + } else { + taskCallbacks.push({type: taskType, cb: callback}); + } + } +} diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index 71bff4a40ccb..76da4c72d82f 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -26,84 +26,27 @@ angular.mock = {}; * that there are several helper methods available which can be used in tests. */ angular.mock.$BrowserProvider = function() { - this.$get = function() { - return new angular.mock.$Browser(); - }; + this.$get = [ + '$log', '$$taskTrackerFactory', + function($log, $$taskTrackerFactory) { + return new angular.mock.$Browser($log, $$taskTrackerFactory); + } + ]; }; -angular.mock.$Browser = function() { - var ALL_TASKS_TYPE = '$$all$$'; - var DEFAULT_TASK_TYPE = '$$default$$'; +angular.mock.$Browser = function($log, $$taskTrackerFactory) { var self = this; + var taskTracker = $$taskTrackerFactory($log); this.isMock = true; self.$$url = 'http://server/'; self.$$lastUrl = self.$$url; // used by url polling fn self.pollFns = []; - // Testability API - - var outstandingRequestCounts = {}; - var outstandingRequestCallbacks = []; - - self.$$completeOutstandingRequest = completeOutstandingRequest; - self.$$incOutstandingRequestCount = incOutstandingRequestCount; - self.notifyWhenNoOutstandingRequests = notifyWhenNoOutstandingRequests; - - function decOutstandingRequestCount(taskType) { - taskType = taskType || DEFAULT_TASK_TYPE; - if (outstandingRequestCounts[taskType]) { - outstandingRequestCounts[taskType]--; - outstandingRequestCounts[ALL_TASKS_TYPE]--; - } - } - function incOutstandingRequestCount(taskType) { - taskType = taskType || DEFAULT_TASK_TYPE; - outstandingRequestCounts[taskType] = (outstandingRequestCounts[taskType] || 0) + 1; - outstandingRequestCounts[ALL_TASKS_TYPE] = (outstandingRequestCounts[ALL_TASKS_TYPE] || 0) + 1; - } - function completeOutstandingRequest(fn, taskType) { - taskType = taskType || DEFAULT_TASK_TYPE; - try { - fn(); - } finally { - decOutstandingRequestCount(taskType); - - var countForType = outstandingRequestCounts[taskType]; - var countForAll = outstandingRequestCounts[ALL_TASKS_TYPE]; - - // If at least one of the queues (`ALL_TASKS_TYPE` or `taskType`) is empty, run callbacks. - if (!countForAll || !countForType) { - var getNextCallback = !countForAll ? getLastCallback : getLastCallbackForType; - var nextCb; - - while ((nextCb = getNextCallback(taskType))) { - nextCb(); - } - } - } - } - function getLastCallback() { - var cbInfo = outstandingRequestCallbacks.pop(); - return cbInfo && cbInfo.cb; - } - function getLastCallbackForType(taskType) { - for (var i = outstandingRequestCallbacks.length - 1; i >= 0; --i) { - var cbInfo = outstandingRequestCallbacks[i]; - if (cbInfo.type === taskType) { - outstandingRequestCallbacks.splice(i, 1); - return cbInfo.cb; - } - } - } - function notifyWhenNoOutstandingRequests(callback, taskType) { - taskType = taskType || ALL_TASKS_TYPE; - if (!outstandingRequestCounts[taskType]) { - callback(); - } else { - outstandingRequestCallbacks.push({type: taskType, cb: callback}); - } - } + // Task-tracking API + self.$$completeOutstandingRequest = taskTracker.completeTask; + self.$$incOutstandingRequestCount = taskTracker.incTaskCount; + self.notifyWhenNoOutstandingRequests = taskTracker.notifyWhenNoPendingTasks; // register url polling fn @@ -131,9 +74,9 @@ angular.mock.$Browser = function() { var timeoutId = self.deferredNextId++; delay = delay || 0; - taskType = taskType || DEFAULT_TASK_TYPE; + taskType = taskType || taskTracker.DEFAULT_TASK_TYPE; - incOutstandingRequestCount(taskType); + taskTracker.incTaskCount(taskType); self.deferredFns.push({ id: timeoutId, type: taskType, @@ -164,7 +107,7 @@ angular.mock.$Browser = function() { if (angular.isDefined(taskIndex)) { var task = self.deferredFns.splice(taskIndex, 1)[0]; - completeOutstandingRequest(angular.noop, task.type); + taskTracker.completeTask(angular.noop, task.type); return true; } @@ -198,7 +141,7 @@ angular.mock.$Browser = function() { // Increment the time and call the next deferred function self.defer.now = self.deferredFns[0].time; var task = self.deferredFns.shift(); - completeOutstandingRequest(task.fn, task.type); + taskTracker.completeTask(task.fn, task.type); } // Ensure that the current time is correct diff --git a/test/ng/browserSpecs.js b/test/ng/browserSpecs.js index 7b8d7268dee8..acaf63e18d23 100644 --- a/test/ng/browserSpecs.js +++ b/test/ng/browserSpecs.js @@ -143,13 +143,14 @@ function MockDocument() { } describe('browser', function() { - /* global Browser: false */ - var browser, fakeWindow, fakeDocument, fakeLog, logs; + /* global Browser: false, TaskTracker: false */ + var browser, fakeWindow, fakeDocument, fakeLog, logs, taskTrackerFactory; beforeEach(function() { sniffer = {history: true}; fakeWindow = new MockWindow(); fakeDocument = new MockDocument(); + taskTrackerFactory = function(log) { return new TaskTracker(log); }; logs = {log:[], warn:[], info:[], error:[]}; @@ -160,7 +161,8 @@ describe('browser', function() { error: function() { logs.error.push(slice.call(arguments)); } }; - browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer); + + browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer, taskTrackerFactory); }); describe('MockBrowser', function() { @@ -200,7 +202,7 @@ describe('browser', function() { fakeWindow = new MockWindow({msie: msie}); fakeWindow.location.state = {prop: 'val'}; - browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer); + browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer, taskTrackerFactory); browser.url(https://github.com/fakeWindow.location.href,%20false,%20%7Bprop:%20'val'%7D); if (msie) { @@ -573,7 +575,7 @@ describe('browser', function() { // the initial URL contains a lengthy oauth token in the hash var initialUrl = 'http://test.com/oauthcallback#state=xxx%3D¬-before-policy=0'; fakeWindow.location.href = initialUrl; - browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer); + browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer, taskTrackerFactory); // somehow, $location gets a version of this url where the = is no longer escaped, and tells the browser: var initialUrlFixedByLocation = initialUrl.replace('%3D', '='); @@ -608,7 +610,7 @@ describe('browser', function() { replaceState = spyOn(fakeWindow.history, 'replaceState').and.callThrough(); locationReplace = spyOn(fakeWindow.location, 'replace').and.callThrough(); - browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer); + browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer, taskTrackerFactory); browser.onUrlChange(function() {}); }); @@ -707,7 +709,7 @@ describe('browser', function() { } }); - var browser = new Browser(mockWindow, fakeDocument, fakeLog, mockSniffer); + var browser = new Browser(mockWindow, fakeDocument, fakeLog, mockSniffer, taskTrackerFactory); expect(historyStateAccessed).toBe(false); }); @@ -720,7 +722,7 @@ describe('browser', function() { return function() { beforeEach(function() { fakeWindow = new MockWindow({msie: options.msie}); - browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer); + browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer, taskTrackerFactory); }); it('should return history.state', function() { @@ -823,7 +825,7 @@ describe('browser', function() { return function() { beforeEach(function() { fakeWindow = new MockWindow({msie: options.msie}); - browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer); + browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer, taskTrackerFactory); }); it('should fire onUrlChange listeners only once if both popstate and hashchange triggered', function() { @@ -892,7 +894,7 @@ describe('browser', function() { function setup(options) { fakeWindow = new MockWindow(options); - browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer); + browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer, taskTrackerFactory); module(function($provide, $locationProvider) { diff --git a/test/ng/locationSpec.js b/test/ng/locationSpec.js index 5814b405b090..58d4d013d968 100644 --- a/test/ng/locationSpec.js +++ b/test/ng/locationSpec.js @@ -2873,9 +2873,9 @@ describe('$location', function() { }; return win; }; - $browserProvider.$get = function($document, $window, $log, $sniffer) { + $browserProvider.$get = function($document, $window, $log, $sniffer, $$taskTrackerFactory) { /* global Browser: false */ - browser = new Browser($window, $document, $log, $sniffer); + browser = new Browser($window, $document, $log, $sniffer, $$taskTrackerFactory); browser.baseHref = function() { return options.baseHref; }; From ef5f6f7fb0836dffe213739e4e030e8fc432280a Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Wed, 27 Jun 2018 13:26:03 +0300 Subject: [PATCH 07/13] feat(ngMock): add `$flushPendingTasks()` and `$verifyNoPendingTasks()` `$flushPendingTasks([delay])` allows flushing all pending tasks (or up to a specific delay). This includes `$timeout`s, `$q` promises and tasks scheduled via `$rootScope.$applyAsync()` and `$rootScope.$evalAsync()`. (ATM, it only flushes tasks scheduled via `$browser.defer()`, which does not include `$http` requests and `$route` transitions.) `$verifyNoPendingTasks([taskType])` allows verifying that there are no pending tasks (in general or of a specific type). This includes tasks flushed by `$flushPendingTasks()` as well as pending `$http` requests and in-progress `$route` transitions. Background: `ngMock/$timeout` has `flush()` and `verifyNoPendingTasks()` methods, but they take all kinds of tasks into account which is confusing. For example, `$timeout.verifyNoPendingTasks()` can fail (even if there are no pending timeouts) because of an unrelated pending `$http` request. This behavior is retained for backwards compatibility, but the new methods are more generic (and thus less confusing) and also allow more fine-grained control (when appropriate). Closes #14336 --- src/ngMock/angular-mocks.js | 99 +++++++++++++++++++++++++++++++- test/ngMock/angular-mocksSpec.js | 36 ++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index 76da4c72d82f..aa0f96e6dbcb 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -121,6 +121,8 @@ angular.mock.$Browser = function($log, $$taskTrackerFactory) { * @description * Flushes all pending requests and executes the defer callbacks. * + * See {@link ngMock.$flushPendingsTasks} for more info. + * * @param {number=} number of milliseconds to flush. See {@link #defer.now} */ self.defer.flush = function(delay) { @@ -155,7 +157,9 @@ angular.mock.$Browser = function($log, $$taskTrackerFactory) { * Verifies that there are no pending tasks that need to be flushed. * You can check for a specific type of tasks only, by specifying a `taskType`. * - * @param {string=} taskType - The type task to check for. + * See {@link $verifyNoPendingsTasks} for more info. + * + * @param {string=} taskType - The type tasks to check for. */ self.defer.verifyNoPendingTasks = function(taskType) { var pendingTasks = !taskType @@ -212,6 +216,72 @@ angular.mock.$Browser.prototype = { } }; +/** + * @ngdoc function + * @name $flushPendingTasks + * + * @description + * Flushes all pending tasks and executes the corresponding callbacks. + * + * The types of tasks that are flushed include: + * + * - Pending timeouts (via {@link $timeout}). + * - Pending tasks scheduled via {@link ng.$rootScope.Scope#$applyAsync}. + * - Pending tasks scheduled via {@link ng.$rootScope.Scope#$evalAsync}. + * These include tasks scheduled via `$evalAsync()` indirectly (such as {@link $q} promises). + * + *
+ * Periodic tasks scheduled via {@link $interval} use a different queue and are flushed by + * `$flushPendingTasks()`. Use {@link ngMock.$interval#flush $interval.flush([millis])} instead. + *
+ * + * @param {number=} delay - The number of milliseconds to flush. + */ +angular.mock.$FlushPendingTasksProvider = function() { + this.$get = [ + '$browser', + function($browser) { + return function $flushPendingTasks(delay) { + return $browser.defer.flush(delay); + }; + } + ]; +}; + +/** + * @ngdoc function + * @name $verifyNoPendingTasks + * + * @description + * Verifies that there are no pending tasks that need to be flushed. + * You can check for a specific type of tasks only, by specifying a `taskType`. + * + * Available task types: + * + * - `$timeout`: Pending timeouts (via {@link $timeout}). + * - `$http`: Pending HTTP requests (via {@link $http}). + * - `$route`: In-progress route transitions (via {@link $route}). + * - `$applyAsync`: Pending tasks scheduled via {@link ng.$rootScope.Scope#$applyAsync}. + * - `$evalAsync`: Pending tasks scheduled via {@link ng.$rootScope.Scope#$evalAsync}. + * These include tasks scheduled via `$evalAsync()` indirectly (such as {@link $q} promises). + * + *
+ * Periodic tasks scheduled via {@link $interval} use a different queue and are not taken into + * account by `$verifyNoPendingTasks()`. + *
+ * + * @param {string=} taskType - The type of tasks to check for. + */ +angular.mock.$VerifyNoPendingTasksProvider = function() { + this.$get = [ + '$browser', + function($browser) { + return function $verifyNoPendingTasks(taskType) { + return $browser.defer.verifyNoPendingTasks(taskType); + }; + } + ]; +}; /** * @ngdoc provider @@ -2178,6 +2248,13 @@ angular.mock.$TimeoutDecorator = ['$delegate', '$browser', function($delegate, $ * @description * * Flushes the queue of pending tasks. + * _This method is essentially an alias of {@link ngMock.$flushPendingTasks}._ + * + *
+ * For historical reasons, this method will also flush non-`$timeout` pending tasks, such as + * {@link $q} promises and tasks scheduled via {@link ng.$rootScope.Scope#$applyAsync} and + * {@link ng.$rootScope.Scope#$evalAsync}. + *
* * @param {number=} delay maximum timeout amount to flush up until */ @@ -2194,6 +2271,22 @@ angular.mock.$TimeoutDecorator = ['$delegate', '$browser', function($delegate, $ * @description * * Verifies that there are no pending tasks that need to be flushed. + * _This method is essentially an alias of {@link ngMock.$verifyNoPendingTasks} (called with no + * arguments)._ + * + *
+ *

+ * For historical reasons, this method will also verify non-`$timeout` pending tasks, such as + * pending {@link $http} requests, in-progress {@link $route} transitions, unresolved + * {@link $q} promises and tasks scheduled via {@link ng.$rootScope.Scope#$applyAsync} and + * {@link ng.$rootScope.Scope#$evalAsync}. + *

+ *

+ * It is recommended to use {@link ngMock.$verifyNoPendingTasks} instead, which additionally + * supports verifying a specific type of tasks. For example, you can verify there are no + * pending timeouts with `$verifyNoPendingTasks('$timeout')`. + *

+ *
*/ $delegate.verifyNoPendingTasks = function() { // For historical reasons, `$timeout.verifyNoPendingTasks()` takes all types of pending tasks @@ -2422,7 +2515,9 @@ angular.module('ngMock', ['ng']).provider({ $log: angular.mock.$LogProvider, $interval: angular.mock.$IntervalProvider, $rootElement: angular.mock.$RootElementProvider, - $componentController: angular.mock.$ComponentControllerProvider + $componentController: angular.mock.$ComponentControllerProvider, + $flushPendingTasks: angular.mock.$FlushPendingTasksProvider, + $verifyNoPendingTasks: angular.mock.$VerifyNoPendingTasksProvider }).config(['$provide', '$compileProvider', function($provide, $compileProvider) { $provide.decorator('$timeout', angular.mock.$TimeoutDecorator); $provide.decorator('$$rAF', angular.mock.$RAFDecorator); diff --git a/test/ngMock/angular-mocksSpec.js b/test/ngMock/angular-mocksSpec.js index 53dc15f51a94..859d42417f11 100644 --- a/test/ngMock/angular-mocksSpec.js +++ b/test/ngMock/angular-mocksSpec.js @@ -811,6 +811,42 @@ describe('ngMock', function() { }); + describe('$flushPendingTasks', function() { + var $flushPendingTasks; + var browserDeferFlushSpy; + + beforeEach(inject(function($browser, _$flushPendingTasks_) { + $flushPendingTasks = _$flushPendingTasks_; + browserDeferFlushSpy = spyOn($browser.defer, 'flush').and.returnValue('flushed'); + })); + + it('should delegate to `$browser.defer.flush()`', function() { + var result = $flushPendingTasks(42); + + expect(browserDeferFlushSpy).toHaveBeenCalledOnceWith(42); + expect(result).toBe('flushed'); + }); + }); + + + describe('$verifyNoPendingTasks', function() { + var $verifyNoPendingTasks; + var browserDeferVerifySpy; + + beforeEach(inject(function($browser, _$verifyNoPendingTasks_) { + $verifyNoPendingTasks = _$verifyNoPendingTasks_; + browserDeferVerifySpy = spyOn($browser.defer, 'verifyNoPendingTasks').and.returnValue('verified'); + })); + + it('should delegate to `$browser.defer.verifyNoPendingTasks()`', function() { + var result = $verifyNoPendingTasks('fortyTwo'); + + expect(browserDeferVerifySpy).toHaveBeenCalledOnceWith('fortyTwo'); + expect(result).toBe('verified'); + }); + }); + + describe('$exceptionHandler', function() { it('should rethrow exceptions', inject(function($exceptionHandler) { expect(function() { $exceptionHandler('myException'); }).toThrow('myException'); From 2c78686100d4071fdec21d58aa1aee6198495a63 Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Wed, 4 Jul 2018 13:30:37 +0300 Subject: [PATCH 08/13] fixup! feat(ngMock): add `$flushPendingTasks()` and `$verifyNoPendingTasks()` --- src/ngMock/angular-mocks.js | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index aa0f96e6dbcb..0259339b91a4 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -157,7 +157,7 @@ angular.mock.$Browser = function($log, $$taskTrackerFactory) { * Verifies that there are no pending tasks that need to be flushed. * You can check for a specific type of tasks only, by specifying a `taskType`. * - * See {@link $verifyNoPendingsTasks} for more info. + * See {@link $verifyNoPendingTasks} for more info. * * @param {string=} taskType - The type tasks to check for. */ @@ -226,12 +226,12 @@ angular.mock.$Browser.prototype = { * The types of tasks that are flushed include: * * - Pending timeouts (via {@link $timeout}). - * - Pending tasks scheduled via {@link ng.$rootScope.Scope#$applyAsync}. - * - Pending tasks scheduled via {@link ng.$rootScope.Scope#$evalAsync}. + * - Pending tasks scheduled via {@link ng.$rootScope.Scope#$applyAsync $applyAsync}. + * - Pending tasks scheduled via {@link ng.$rootScope.Scope#$evalAsync $evalAsync}. * These include tasks scheduled via `$evalAsync()` indirectly (such as {@link $q} promises). * *
- * Periodic tasks scheduled via {@link $interval} use a different queue and are flushed by + * Periodic tasks scheduled via {@link $interval} use a different queue and are not flushed by * `$flushPendingTasks()`. Use {@link ngMock.$interval#flush $interval.flush([millis])} instead. *
* @@ -253,7 +253,9 @@ angular.mock.$FlushPendingTasksProvider = function() { * @name $verifyNoPendingTasks * * @description - * Verifies that there are no pending tasks that need to be flushed. + * Verifies that there are no pending tasks that need to be flushed. It throws an error if there are + * still pending tasks. + * * You can check for a specific type of tasks only, by specifying a `taskType`. * * Available task types: @@ -261,13 +263,14 @@ angular.mock.$FlushPendingTasksProvider = function() { * - `$timeout`: Pending timeouts (via {@link $timeout}). * - `$http`: Pending HTTP requests (via {@link $http}). * - `$route`: In-progress route transitions (via {@link $route}). - * - `$applyAsync`: Pending tasks scheduled via {@link ng.$rootScope.Scope#$applyAsync}. - * - `$evalAsync`: Pending tasks scheduled via {@link ng.$rootScope.Scope#$evalAsync}. + * - `$applyAsync`: Pending tasks scheduled via {@link ng.$rootScope.Scope#$applyAsync $applyAsync}. + * - `$evalAsync`: Pending tasks scheduled via {@link ng.$rootScope.Scope#$evalAsync $evalAsync}. * These include tasks scheduled via `$evalAsync()` indirectly (such as {@link $q} promises). * *
* Periodic tasks scheduled via {@link $interval} use a different queue and are not taken into - * account by `$verifyNoPendingTasks()`. + * account by `$verifyNoPendingTasks()`. There is currently no way to verify that there are no + * pending {@link $interval} tasks. *
* * @param {string=} taskType - The type of tasks to check for. @@ -2248,12 +2251,14 @@ angular.mock.$TimeoutDecorator = ['$delegate', '$browser', function($delegate, $ * @description * * Flushes the queue of pending tasks. + * * _This method is essentially an alias of {@link ngMock.$flushPendingTasks}._ * *
* For historical reasons, this method will also flush non-`$timeout` pending tasks, such as - * {@link $q} promises and tasks scheduled via {@link ng.$rootScope.Scope#$applyAsync} and - * {@link ng.$rootScope.Scope#$evalAsync}. + * {@link $q} promises and tasks scheduled via + * {@link ng.$rootScope.Scope#$applyAsync $applyAsync} and + * {@link ng.$rootScope.Scope#$evalAsync $evalAsync}. *
* * @param {number=} delay maximum timeout amount to flush up until @@ -2270,7 +2275,9 @@ angular.mock.$TimeoutDecorator = ['$delegate', '$browser', function($delegate, $ * @name $timeout#verifyNoPendingTasks * @description * - * Verifies that there are no pending tasks that need to be flushed. + * Verifies that there are no pending tasks that need to be flushed. It throws an error if there + * are still pending tasks. + * * _This method is essentially an alias of {@link ngMock.$verifyNoPendingTasks} (called with no * arguments)._ * @@ -2278,8 +2285,9 @@ angular.mock.$TimeoutDecorator = ['$delegate', '$browser', function($delegate, $ *

* For historical reasons, this method will also verify non-`$timeout` pending tasks, such as * pending {@link $http} requests, in-progress {@link $route} transitions, unresolved - * {@link $q} promises and tasks scheduled via {@link ng.$rootScope.Scope#$applyAsync} and - * {@link ng.$rootScope.Scope#$evalAsync}. + * {@link $q} promises and tasks scheduled via + * {@link ng.$rootScope.Scope#$applyAsync $applyAsync} and + * {@link ng.$rootScope.Scope#$evalAsync $evalAsync}. *

*

* It is recommended to use {@link ngMock.$verifyNoPendingTasks} instead, which additionally From 2d498a3e9af769d777010f08c9524f6996be468a Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Mon, 2 Jul 2018 16:00:58 +0300 Subject: [PATCH 09/13] docs(ngMock/$timeout): recommend `$verifyNoPendingTasks()` when appropriate For historical reasons, `$timeout.verifyNoPendingTasks()` throws if there is any type of task pending (even if it is not a timeout). When calling `$timeout.verifyNoPendingTasks()`, the user is most likely interested in verifying pending timeouts only, which is now possible with `$verifyNoPendingTasks('$timeout')`. To raise awareness of `$verifyNoPendingTasks()`, it is mentioned in the error message thrown by `$timeoutverifyNoPendingTasks()` if none of the pending tasks is a timeout. --- src/ngMock/angular-mocks.js | 54 ++++++++++++++++++++++++++------ test/ngMock/angular-mocksSpec.js | 21 +++++++++++++ 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index 0259339b91a4..2424af0740a2 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -150,6 +150,36 @@ angular.mock.$Browser = function($log, $$taskTrackerFactory) { self.defer.now = nextTime; }; + /** + * @name $browser#defer.getPendingTasks + * + * @description + * Returns the currently pending tasks that need to be flushed. + * You can request a specific type of tasks only, by specifying a `taskType`. + * + * @param {string=} taskType - The type tasks to return. + */ + self.defer.getPendingTasks = function(taskType) { + return !taskType + ? self.deferredFns + : self.deferredFns.filter(function(task) { return task.type === taskType; }); + }; + + /** + * @name $browser#defer.formatPendingTasks + * + * @description + * Formats each task in a list of pending tasks as a string, suitable for use in error messages. + * + * @param {Array} pendingTasks - A list of task objects. + * @return {Array} A list of stringified tasks. + */ + self.defer.formatPendingTasks = function(pendingTasks) { + return pendingTasks.map(function(task) { + return '{id: ' + task.id + ', type: ' + task.type + ', time: ' + task.time + '}'; + }); + }; + /** * @name $browser#defer.verifyNoPendingTasks * @@ -162,17 +192,10 @@ angular.mock.$Browser = function($log, $$taskTrackerFactory) { * @param {string=} taskType - The type tasks to check for. */ self.defer.verifyNoPendingTasks = function(taskType) { - var pendingTasks = !taskType - ? self.deferredFns - : self.deferredFns.filter(function(task) { return task.type === taskType; }); + var pendingTasks = self.defer.getPendingTasks(taskType); if (pendingTasks.length) { - var formattedTasks = pendingTasks - .map(function(task) { - return '{id: ' + task.id + ', type: ' + task.type + ', time: ' + task.time + '}'; - }) - .join('\n '); - + var formattedTasks = self.defer.formatPendingTasks(pendingTasks).join('\n '); throw new Error('Deferred tasks to flush (' + pendingTasks.length + '):\n ' + formattedTasks); } @@ -2299,7 +2322,18 @@ angular.mock.$TimeoutDecorator = ['$delegate', '$browser', function($delegate, $ $delegate.verifyNoPendingTasks = function() { // For historical reasons, `$timeout.verifyNoPendingTasks()` takes all types of pending tasks // into account. Keep the same behavior for backwards compatibility. - $browser.defer.verifyNoPendingTasks(); + var pendingTasks = $browser.defer.getPendingTasks(); + + if (pendingTasks.length) { + var formattedTasks = $browser.defer.formatPendingTasks(pendingTasks).join('\n '); + var hasPendingTimeout = pendingTasks.some(function(task) { return task.type === '$timeout'; }); + var extraMessage = hasPendingTimeout ? '' : '\n\nNone of the pending tasks are timeouts. ' + + 'If you only want to verify pending timeouts, use ' + + '`$verifyNoPendingTasks(\'$timeout\')` instead.'; + + throw new Error('Deferred tasks to flush (' + pendingTasks.length + '):\n ' + + formattedTasks + extraMessage); + } }; return $delegate; diff --git a/test/ngMock/angular-mocksSpec.js b/test/ngMock/angular-mocksSpec.js index 859d42417f11..21b6bd5eac1d 100644 --- a/test/ngMock/angular-mocksSpec.js +++ b/test/ngMock/angular-mocksSpec.js @@ -920,6 +920,27 @@ describe('ngMock', function() { })); + it('should recommend `$verifyNoPendingTasks()` when all pending tasks are not timeouts', + inject(function($rootScope, $timeout) { + var extraMessage = 'None of the pending tasks are timeouts. If you only want to verify ' + + 'pending timeouts, use `$verifyNoPendingTasks(\'$timeout\')` instead.'; + var errorMessage; + + $timeout(noop, 100); + $rootScope.$evalAsync(noop); + try { $timeout.verifyNoPendingTasks(); } catch (err) { errorMessage = err.message; } + + expect(errorMessage).not.toContain(extraMessage); + + $timeout.flush(100); + $rootScope.$evalAsync(noop); + try { $timeout.verifyNoPendingTasks(); } catch (err) { errorMessage = err.message; } + + expect(errorMessage).toContain(extraMessage); + }) + ); + + it('should do nothing when all tasks have been flushed', inject(function($rootScope, $timeout) { $timeout(noop, 100); $rootScope.$evalAsync(noop); From 552733596c489c8363f733cb932fe5a955d306cc Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Mon, 2 Jul 2018 16:13:47 +0300 Subject: [PATCH 10/13] docs(ngMock/$timeout): deprecate `flush()` and `verifyNoPendingTasks()` --- src/ngMock/angular-mocks.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index 2424af0740a2..09dc1c5ac53b 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -2271,6 +2271,13 @@ angular.mock.$TimeoutDecorator = ['$delegate', '$browser', function($delegate, $ /** * @ngdoc method * @name $timeout#flush + * + * @deprecated + * sinceVersion="1.7.3" + * + * This method flushes all types of tasks (not only timeouts), which is unintuitive. + * It is recommended to use {@link ngMock.$flushPendingTasks} instead. + * * @description * * Flushes the queue of pending tasks. @@ -2296,6 +2303,14 @@ angular.mock.$TimeoutDecorator = ['$delegate', '$browser', function($delegate, $ /** * @ngdoc method * @name $timeout#verifyNoPendingTasks + * + * @deprecated + * sinceVersion="1.7.3" + * + * This method takes all types of tasks (not only timeouts) into account, which is unintuitive. + * It is recommended to use {@link ngMock.$verifyNoPendingTasks} instead, which additionally + * allows checking for timeouts only (with `$verifyNoPendingTasks('$timeout')`). + * * @description * * Verifies that there are no pending tasks that need to be flushed. It throws an error if there From 8dfd9d9af75001110ae291328890032966223e35 Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Sat, 7 Jul 2018 17:46:27 +0300 Subject: [PATCH 11/13] fixup! feat(ngMock): add `$flushPendingTasks()` and `$verifyNoPendingTasks()` --- src/ngMock/angular-mocks.js | 9 ++++- test/ngMock/angular-mocksSpec.js | 65 ++++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index 09dc1c5ac53b..ef436e43033c 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -244,7 +244,14 @@ angular.mock.$Browser.prototype = { * @name $flushPendingTasks * * @description - * Flushes all pending tasks and executes the corresponding callbacks. + * Flushes all currently pending tasks and executes the corresponding callbacks. + * + * Optionally, you can also pass a `delay` argument to only flush tasks that are scheduled to be + * executed within `delay` milliseconds. Currently, `delay` only applies to timeouts, since all + * other tasks have a delay of 0 (i.e. they are scheduled to be executed as soon as possible, but + * still asynchronously). + * + * If no delay is specified, it uses a delay such that all currently pending tasks are flushed. * * The types of tasks that are flushed include: * diff --git a/test/ngMock/angular-mocksSpec.js b/test/ngMock/angular-mocksSpec.js index 21b6bd5eac1d..7285eccdfbcc 100644 --- a/test/ngMock/angular-mocksSpec.js +++ b/test/ngMock/angular-mocksSpec.js @@ -626,16 +626,17 @@ describe('ngMock', function() { it('should flush delayed', function() { browser.defer(logFn('A')); - browser.defer(logFn('B'), 10, 'taskType'); - browser.defer(logFn('C'), 20); + browser.defer(logFn('B'), 0, 'taskTypeB'); + browser.defer(logFn('C'), 10, 'taskTypeC'); + browser.defer(logFn('D'), 20); expect(log).toEqual(''); expect(browser.defer.now).toEqual(0); browser.defer.flush(0); - expect(log).toEqual('A;'); + expect(log).toEqual('A;B;'); browser.defer.flush(); - expect(log).toEqual('A;B;C;'); + expect(log).toEqual('A;B;C;D;'); }); it('should defer and flush over time', function() { @@ -663,6 +664,62 @@ describe('ngMock', function() { it('should not throw an exception when passing a specific delay', function() { expect(function() {browser.defer.flush(100);}).not.toThrow(); }); + + describe('tasks scheduled during flushing', function() { + it('should be flushed if they do not exceed the target delay (when no delay specified)', + function() { + browser.defer(function() { + logFn('1')(); + browser.defer(function() { + logFn('3')(); + browser.defer(logFn('4'), 1); + }, 2); + }, 1); + browser.defer(function() { + logFn('2')(); + browser.defer(logFn('6'), 4); + }, 2); + browser.defer(logFn('5'), 5); + + browser.defer.flush(0); + expect(browser.defer.now).toEqual(0); + expect(log).toEqual(''); + + browser.defer.flush(); + expect(browser.defer.now).toEqual(5); + expect(log).toEqual('1;2;3;4;5;'); + } + ); + + it('should be flushed if they do not exceed the specified delay', + function() { + browser.defer(function() { + logFn('1')(); + browser.defer(function() { + logFn('3')(); + browser.defer(logFn('4'), 1); + }, 2); + }, 1); + browser.defer(function() { + logFn('2')(); + browser.defer(logFn('6'), 4); + }, 2); + browser.defer(logFn('5'), 5); + + browser.defer.flush(0); + expect(browser.defer.now).toEqual(0); + expect(log).toEqual(''); + + browser.defer.flush(4); + expect(browser.defer.now).toEqual(4); + expect(log).toEqual('1;2;3;4;'); + + browser.defer.flush(6); + expect(browser.defer.now).toEqual(10); + expect(log).toEqual('1;2;3;4;5;6;'); + } + ); + }); }); describe('defer.cancel', function() { From e93405dad3290dc3c75e4a40f448dea922580978 Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Tue, 10 Jul 2018 18:59:11 +0300 Subject: [PATCH 12/13] fixup! feat(*): implement more granular pending task tracking --- test/ngMock/angular-mocksSpec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/ngMock/angular-mocksSpec.js b/test/ngMock/angular-mocksSpec.js index 7285eccdfbcc..3470241fb2af 100644 --- a/test/ngMock/angular-mocksSpec.js +++ b/test/ngMock/angular-mocksSpec.js @@ -796,7 +796,7 @@ describe('ngMock', function() { callback = jasmine.createSpy('callback'); }); - it('should immediatelly run the callback if no pending tasks', function() { + it('should immediately run the callback if no pending tasks', function() { browser.notifyWhenNoOutstandingRequests(callback); expect(callback).toHaveBeenCalled(); }); @@ -829,7 +829,7 @@ describe('ngMock', function() { }); describe('with specific task type', function() { - it('should immediatelly run the callback if no pending tasks', function() { + it('should immediately run the callback if no pending tasks', function() { browser.notifyWhenNoOutstandingRequests(callback, 'fooType'); expect(callback).toHaveBeenCalled(); }); From 86b0c03195ac1fb79cffad6c5c5a3d1bbf267b9c Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Tue, 10 Jul 2018 18:59:29 +0300 Subject: [PATCH 13/13] fixup! refactor($browser): share task-tracking code between `$browser` and `ngMock/$browser` --- src/ng/taskTrackerFactory.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ng/taskTrackerFactory.js b/src/ng/taskTrackerFactory.js index dc4369431d44..04717da376e6 100644 --- a/src/ng/taskTrackerFactory.js +++ b/src/ng/taskTrackerFactory.js @@ -47,7 +47,7 @@ function TaskTracker(log) { /** * Execute the specified callback when all pending tasks have been completed. * - * If there are no pending tasks, the callback is executed immediatelly. You can optionally limit + * If there are no pending tasks, the callback is executed immediately. You can optionally limit * the tasks that will be waited for to a specific type, by passing a `taskType`. * * @param {function} callback - The function to call when there are no pending tasks.