From 2b5ce84fca7b41fca24707e163ec6af84bc12e83 Mon Sep 17 00:00:00 2001 From: Julie Date: Fri, 13 Sep 2013 12:47:05 -0700 Subject: [PATCH] feat($interval): add a service wrapping setInterval The $interval service simplifies creating and testing recurring tasks. This service does not increment $browser's outstanding request count, which means that scenario tests and Protractor tests will not timeout when a site uses a polling function registered by $interval. Provides a workaround for #2402. For unit tests, repeated tasks can be controlled using ngMock$interval's tick(), tickNext(), and tickAll() functions. --- angularFiles.js | 1 + src/AngularPublic.js | 1 + src/ng/interval.js | 90 +++++++++++ src/ngMock/angular-mocks.js | 114 +++++++++++++ test/ng/intervalSpec.js | 270 +++++++++++++++++++++++++++++++ test/ng/timeoutSpec.js | 14 ++ test/ngMock/angular-mocksSpec.js | 235 +++++++++++++++++++++++++++ 7 files changed, 725 insertions(+) create mode 100644 src/ng/interval.js create mode 100644 test/ng/intervalSpec.js diff --git a/angularFiles.js b/angularFiles.js index cd23aca09aa7..946cff459a6f 100755 --- a/angularFiles.js +++ b/angularFiles.js @@ -20,6 +20,7 @@ angularFiles = { 'src/ng/http.js', 'src/ng/httpBackend.js', 'src/ng/interpolate.js', + 'src/ng/interval.js', 'src/ng/locale.js', 'src/ng/location.js', 'src/ng/log.js', diff --git a/src/AngularPublic.js b/src/AngularPublic.js index b225fc85e00e..9bd7fd7ddc6b 100755 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -114,6 +114,7 @@ function publishExternalAPI(angular){ $exceptionHandler: $ExceptionHandlerProvider, $filter: $FilterProvider, $interpolate: $InterpolateProvider, + $interval: $IntervalProvider, $http: $HttpProvider, $httpBackend: $HttpBackendProvider, $location: $LocationProvider, diff --git a/src/ng/interval.js b/src/ng/interval.js new file mode 100644 index 000000000000..e612f3e40b27 --- /dev/null +++ b/src/ng/interval.js @@ -0,0 +1,90 @@ +'use strict'; + + +function $IntervalProvider() { + this.$get = ['$rootScope', '$window', '$q', + function($rootScope, $window, $q) { + var intervals = {}; + + + /** + * @ngdoc function + * @name ng.$interval + * + * @description + * Angular's wrapper for `window.setInterval`. The `fn` function is executed every `delay` + * milliseconds. + * + * The return value of registering an interval function is a promise. This promise will be + * notified upon each tick of the interval, and will be resolved after `count` iterations, or + * run indefinitely if `count` is not defined. The value of the notification will be the + * number of iterations that have run. + * To cancel an interval, call `$interval.cancel(promise)`. + * + * In tests you can use {@link ngMock.$interval#flush `$interval.flush(millis)`} to + * move forward by `millis` milliseconds and trigger any functions scheduled to run in that + * time. + * + * @param {function()} fn A function that should be called repeatedly. + * @param {number} delay Number of milliseconds between each function call. + * @param {number=} [count=0] Number of times to repeat. If not set, or 0, will repeat + * indefinitely. + * @param {boolean=} [invokeApply=true] If set to `false` skips model dirty checking, otherwise + * will invoke `fn` within the {@link ng.$rootScope.Scope#$apply $apply} block. + * @returns {promise} A promise which will be notified on each iteration. + */ + function interval(fn, delay, count, invokeApply) { + var setInterval = $window.setInterval, + clearInterval = $window.clearInterval; + + var deferred = $q.defer(), + promise = deferred.promise, + count = (isDefined(count)) ? count : 0, + iteration = 0, + skipApply = (isDefined(invokeApply) && !invokeApply); + + promise.then(null, null, fn); + + promise.$$intervalId = setInterval(function tick() { + 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; + } + + + /** + * @ngdoc function + * @name ng.$interval#cancel + * @methodOf ng.$interval + * + * @description + * Cancels a task associated with the `promise`. + * + * @param {number} promise Promise returned by the `$interval` function. + * @returns {boolean} Returns `true` if the task was successfully canceled. + */ + interval.cancel = function(promise) { + if (promise && promise.$$intervalId in intervals) { + intervals[promise.$$intervalId].reject('canceled'); + clearInterval(promise.$$intervalId); + delete intervals[promise.$$intervalId]; + return true; + } + return false; + }; + + return interval; + }]; +} diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index 9bc80ff31cce..11f6f045ef11 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -438,6 +438,119 @@ angular.mock.$LogProvider = function() { }; +/** + * @ngdoc service + * @name ngMock.$interval + * + * @description + * Mock implementation of the $interval service. + * + * Use {@link ngMock.$interval#flush `$interval.flush(millis)`} to + * move forward by `millis` milliseconds and trigger any functions scheduled to run in that + * time. + * + * @param {function()} fn A function that should be called repeatedly. + * @param {number} delay Number of milliseconds between each function call. + * @param {number=} [count=0] Number of times to repeat. If not set, or 0, will repeat + * indefinitely. + * @param {boolean=} [invokeApply=true] If set to `false` skips model dirty checking, otherwise + * will invoke `fn` within the {@link ng.$rootScope.Scope#$apply $apply} block. + * @returns {promise} A promise which will be notified on each iteration. + */ +angular.mock.$IntervalProvider = function() { + this.$get = ['$rootScope', '$q', + function($rootScope, $q) { + var repeatFns = [], + nextRepeatId = 0, + now = 0; + + var $interval = function(fn, delay, count, invokeApply) { + var deferred = $q.defer(), + promise = deferred.promise, + count = (isDefined(count)) ? count : 0, + iteration = 0, + skipApply = (isDefined(invokeApply) && !invokeApply); + + promise.then(null, null, fn); + + promise.$$intervalId = nextRepeatId; + + function tick() { + 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; + }); + + if (fnIndex !== undefined) { + repeatFns.splice(fnIndex, 1); + } + } + + if (!skipApply) $rootScope.$apply(); + }; + + repeatFns.push({ + nextTime:(now + delay), + delay: delay, + fn: tick, + id: nextRepeatId, + deferred: deferred + }); + repeatFns.sort(function(a,b){ return a.nextTime - b.nextTime;}); + + nextRepeatId++; + return promise; + }; + + $interval.cancel = function(promise) { + var fnIndex; + + angular.forEach(repeatFns, function(fn, index) { + if (fn.id === promise.$$intervalId) fnIndex = index; + }); + + if (fnIndex !== undefined) { + repeatFns[fnIndex].deferred.reject('canceled'); + repeatFns.splice(fnIndex, 1); + return true; + } + + return false; + }; + + /** + * @ngdoc method + * @name ngMock.$interval#flush + * @methodOf ngMock.$interval + * @description + * + * Runs interval tasks scheduled to be run in the next `millis` milliseconds. + * + * @param {number=} millis maximum timeout amount to flush up until. + * + * @return {number} The amount of time moved forward. + */ + $interval.flush = function(millis) { + now += millis; + while (repeatFns.length && repeatFns[0].nextTime <= now) { + var task = repeatFns[0]; + task.fn(); + task.nextTime += task.delay; + repeatFns.sort(function(a,b){ return a.nextTime - b.nextTime;}); + } + return millis; + }; + + return $interval; + }]; +}; + + (function() { var R_ISO8061_STR = /^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?:\:?(\d\d)(?:\:?(\d\d)(?:\.(\d{3}))?)?)?(Z|([+-])(\d\d):?(\d\d)))?$/; @@ -1581,6 +1694,7 @@ angular.module('ngMock', ['ng']).provider({ $browser: angular.mock.$BrowserProvider, $exceptionHandler: angular.mock.$ExceptionHandlerProvider, $log: angular.mock.$LogProvider, + $interval: angular.mock.$IntervalProvider, $httpBackend: angular.mock.$HttpBackendProvider, $rootElement: angular.mock.$RootElementProvider }).config(function($provide) { diff --git a/test/ng/intervalSpec.js b/test/ng/intervalSpec.js new file mode 100644 index 000000000000..6999f75098d6 --- /dev/null +++ b/test/ng/intervalSpec.js @@ -0,0 +1,270 @@ +'use strict'; + +describe('$interval', function() { + + beforeEach(module(function($provide){ + var repeatFns = [], + nextRepeatId = 0, + now = 0, + $window; + + $window = { + setInterval: function(fn, delay, count) { + repeatFns.push({ + nextTime:(now + delay), + delay: delay, + fn: fn, + id: nextRepeatId, + }); + repeatFns.sort(function(a,b){ return a.nextTime - b.nextTime;}); + + return nextRepeatId++; + }, + + clearInterval: function(id) { + var fnIndex; + + angular.forEach(repeatFns, function(fn, index) { + if (fn.id === id) fnIndex = index; + }); + + if (fnIndex !== undefined) { + repeatFns.splice(fnIndex, 1); + return true; + } + + return false; + }, + + flush: function(millis) { + now += millis; + while (repeatFns.length && repeatFns[0].nextTime <= now) { + var task = repeatFns[0]; + task.fn(); + task.nextTime += task.delay; + repeatFns.sort(function(a,b){ return a.nextTime - b.nextTime;}); + } + return millis; + } + }; + + $provide.provider('$interval', $IntervalProvider); + $provide.value('$window', $window); + })); + + it('should run tasks repeatedly', inject(function($interval, $window) { + var counter = 0; + $interval(function() { counter++; }, 1000); + + expect(counter).toBe(0); + + $window.flush(1000) + expect(counter).toBe(1); + + $window.flush(1000); + + expect(counter).toBe(2); + })); + + it('should call $apply after each task is executed', + inject(function($interval, $rootScope, $window) { + var applySpy = spyOn($rootScope, '$apply').andCallThrough(); + + $interval(noop, 1000); + expect(applySpy).not.toHaveBeenCalled(); + + $window.flush(1000); + expect(applySpy).toHaveBeenCalledOnce(); + + applySpy.reset(); + + $interval(noop, 1000); + $interval(noop, 1000); + $window.flush(1000); + expect(applySpy.callCount).toBe(3); + })); + + + it('should NOT call $apply if invokeApply is set to false', + inject(function($interval, $rootScope, $window) { + var applySpy = spyOn($rootScope, '$apply').andCallThrough(); + + $interval(noop, 1000, 0, false); + expect(applySpy).not.toHaveBeenCalled(); + + $window.flush(2000); + expect(applySpy).not.toHaveBeenCalled(); + })); + + + it('should allow you to specify the delay time', inject(function($interval, $window) { + var counter = 0; + $interval(function() { counter++; }, 123); + + expect(counter).toBe(0); + + $window.flush(122); + expect(counter).toBe(0); + + $window.flush(1); + expect(counter).toBe(1); + })); + + + it('should allow you to specify a number of iterations', inject(function($interval, $window) { + var counter = 0; + $interval(function() {counter++}, 1000, 2); + + $window.flush(1000); + expect(counter).toBe(1); + $window.flush(1000); + expect(counter).toBe(2); + $window.flush(1000); + expect(counter).toBe(2); + })); + + + it('should return a promise which will be updated with the count on each iteration', + inject(function($interval, $window) { + var log = [], + promise = $interval(function() { log.push('tick'); }, 1000); + + promise.then(function(value) { log.push('promise success: ' + value); }, + function(err) { log.push('promise error: ' + err); }, + function(note) { log.push('promise update: ' + note); }); + expect(log).toEqual([]); + + $window.flush(1000); + expect(log).toEqual(['tick', 'promise update: 0']); + + $window.flush(1000); + expect(log).toEqual(['tick', 'promise update: 0', 'tick', 'promise update: 1']); + })); + + + it('should return a promise which will be resolved after the specified number of iterations', + inject(function($interval, $window) { + var log = [], + promise = $interval(function() { log.push('tick'); }, 1000, 2); + + promise.then(function(value) { log.push('promise success: ' + value); }, + function(err) { log.push('promise error: ' + err); }, + function(note) { log.push('promise update: ' + note); }); + expect(log).toEqual([]); + + $window.flush(1000); + expect(log).toEqual(['tick', 'promise update: 0']); + $window.flush(1000); + + expect(log).toEqual([ + 'tick', 'promise update: 0', 'tick', 'promise update: 1', 'promise success: 2']); + + })); + + + describe('exception handling', function() { + beforeEach(module(function($exceptionHandlerProvider) { + $exceptionHandlerProvider.mode('log'); + })); + + + it('should delegate exception to the $exceptionHandler service', inject( + function($interval, $exceptionHandler, $window) { + $interval(function() { throw "Test Error"; }, 1000); + expect($exceptionHandler.errors).toEqual([]); + + $window.flush(1000); + expect($exceptionHandler.errors).toEqual(["Test Error"]); + + $window.flush(1000); + expect($exceptionHandler.errors).toEqual(["Test Error", "Test Error"]); + })); + + + it('should call $apply even if an exception is thrown in callback', inject( + function($interval, $rootScope, $window) { + var applySpy = spyOn($rootScope, '$apply').andCallThrough(); + + $interval(function() { throw "Test Error"; }, 1000); + expect(applySpy).not.toHaveBeenCalled(); + + $window.flush(1000); + expect(applySpy).toHaveBeenCalled(); + })); + + + it('should still update the interval promise when an exception is thrown', + inject(function($interval, $window) { + var log = [], + promise = $interval(function() { throw "Some Error"; }, 1000); + + promise.then(function(value) { log.push('promise success: ' + value); }, + function(err) { log.push('promise error: ' + err); }, + function(note) { log.push('promise update: ' + note); }); + $window.flush(1000); + + expect(log).toEqual(['promise update: 0']); + })); + }); + + + describe('cancel', function() { + it('should cancel tasks', inject(function($interval, $window) { + var task1 = jasmine.createSpy('task1', 1000), + task2 = jasmine.createSpy('task2', 1000), + task3 = jasmine.createSpy('task3', 1000), + promise1, promise3; + + promise1 = $interval(task1, 200); + $interval(task2, 1000); + promise3 = $interval(task3, 333); + + $interval.cancel(promise3); + $interval.cancel(promise1); + $window.flush(1000); + + expect(task1).not.toHaveBeenCalled(); + expect(task2).toHaveBeenCalledOnce(); + expect(task3).not.toHaveBeenCalled(); + })); + + + it('should cancel the promise', inject(function($interval, $rootScope, $window) { + var promise = $interval(noop, 1000), + log = []; + promise.then(function(value) { log.push('promise success: ' + value); }, + function(err) { log.push('promise error: ' + err); }, + function(note) { log.push('promise update: ' + note); }); + expect(log).toEqual([]); + + $window.flush(1000); + $interval.cancel(promise); + $window.flush(1000); + $rootScope.$apply(); // For resolving the promise - + // necessary since q uses $rootScope.evalAsync. + + expect(log).toEqual(['promise update: 0', 'promise error: canceled']); + })); + + + it('should return true if a task was successfully canceled', + inject(function($interval, $window) { + var task1 = jasmine.createSpy('task1'), + task2 = jasmine.createSpy('task2'), + promise1, promise2; + + promise1 = $interval(task1, 1000, 1); + $window.flush(1000); + promise2 = $interval(task2, 1000, 1); + + expect($interval.cancel(promise1)).toBe(false); + expect($interval.cancel(promise2)).toBe(true); + })); + + + it('should not throw a runtime exception when given an undefined promise', + inject(function($interval) { + expect($interval.cancel()).toBe(false); + })); + }); +}); diff --git a/test/ng/timeoutSpec.js b/test/ng/timeoutSpec.js index 8de63bec8cd0..97c8448eedce 100644 --- a/test/ng/timeoutSpec.js +++ b/test/ng/timeoutSpec.js @@ -165,6 +165,20 @@ describe('$timeout', function() { })); + it('should cancel the promise', inject(function($timeout, log) { + var promise = $timeout(noop); + promise.then(function(value) { log('promise success: ' + value); }, + function(err) { log('promise error: ' + err); }, + function(note) { log('promise update: ' + note); }); + expect(log).toEqual([]); + + $timeout.cancel(promise); + $timeout.flush(); + + expect(log).toEqual(['promise error: canceled']); + })); + + it('should return true if a task was successfully canceled', inject(function($timeout) { var task1 = jasmine.createSpy('task1'), task2 = jasmine.createSpy('task2'), diff --git a/test/ngMock/angular-mocksSpec.js b/test/ngMock/angular-mocksSpec.js index 1a4290e006f1..851f780350a7 100644 --- a/test/ngMock/angular-mocksSpec.js +++ b/test/ngMock/angular-mocksSpec.js @@ -283,6 +283,241 @@ describe('ngMock', function() { }); + describe('$interval', function() { + it('should run tasks repeatedly', inject(function($interval) { + var counter = 0; + $interval(function() { counter++; }, 1000); + + expect(counter).toBe(0); + + $interval.flush(1000); + expect(counter).toBe(1); + + $interval.flush(1000); + + expect(counter).toBe(2); + })); + + + it('should call $apply after each task is executed', inject(function($interval, $rootScope) { + var applySpy = spyOn($rootScope, '$apply').andCallThrough(); + + $interval(noop, 1000); + expect(applySpy).not.toHaveBeenCalled(); + + $interval.flush(1000); + expect(applySpy).toHaveBeenCalledOnce(); + + applySpy.reset(); + + $interval(noop, 1000); + $interval(noop, 1000); + $interval.flush(1000); + expect(applySpy.callCount).toBe(3); + })); + + + it('should NOT call $apply if invokeApply is set to false', + inject(function($interval, $rootScope) { + var applySpy = spyOn($rootScope, '$apply').andCallThrough(); + + $interval(noop, 1000, 0, false); + expect(applySpy).not.toHaveBeenCalled(); + + $interval.flush(2000); + expect(applySpy).not.toHaveBeenCalled(); + })); + + + it('should allow you to specify the delay time', inject(function($interval) { + var counter = 0; + $interval(function() { counter++; }, 123); + + expect(counter).toBe(0); + + $interval.flush(122); + expect(counter).toBe(0); + + $interval.flush(1); + expect(counter).toBe(1); + })); + + + it('should allow you to specify a number of iterations', inject(function($interval) { + var counter = 0; + $interval(function() {counter++}, 1000, 2); + + $interval.flush(1000); + expect(counter).toBe(1); + $interval.flush(1000); + expect(counter).toBe(2); + $interval.flush(1000); + expect(counter).toBe(2); + })); + + + describe('flush', function() { + it('should move the clock forward by the specified time', inject(function($interval) { + var counterA = 0; + var counterB = 0; + $interval(function() { counterA++; }, 100); + $interval(function() { counterB++; }, 401); + + $interval.flush(200); + expect(counterA).toEqual(2); + + $interval.flush(201); + expect(counterA).toEqual(4); + expect(counterB).toEqual(1); + })); + }); + + + it('should return a promise which will be updated with the count on each iteration', + inject(function($interval) { + var log = [], + promise = $interval(function() { log.push('tick'); }, 1000); + + promise.then(function(value) { log.push('promise success: ' + value); }, + function(err) { log.push('promise error: ' + err); }, + function(note) { log.push('promise update: ' + note); }); + expect(log).toEqual([]); + + $interval.flush(1000); + expect(log).toEqual(['tick', 'promise update: 0']); + + $interval.flush(1000); + expect(log).toEqual(['tick', 'promise update: 0', 'tick', 'promise update: 1']); + })); + + + it('should return a promise which will be resolved after the specified number of iterations', + inject(function($interval) { + var log = [], + promise = $interval(function() { log.push('tick'); }, 1000, 2); + + promise.then(function(value) { log.push('promise success: ' + value); }, + function(err) { log.push('promise error: ' + err); }, + function(note) { log.push('promise update: ' + note); }); + expect(log).toEqual([]); + + $interval.flush(1000); + expect(log).toEqual(['tick', 'promise update: 0']); + $interval.flush(1000); + + expect(log).toEqual([ + 'tick', 'promise update: 0', 'tick', 'promise update: 1', 'promise success: 2']); + + })); + + + describe('exception handling', function() { + beforeEach(module(function($exceptionHandlerProvider) { + $exceptionHandlerProvider.mode('log'); + })); + + + it('should delegate exception to the $exceptionHandler service', inject( + function($interval, $exceptionHandler) { + $interval(function() { throw "Test Error"; }, 1000); + expect($exceptionHandler.errors).toEqual([]); + + $interval.flush(1000); + expect($exceptionHandler.errors).toEqual(["Test Error"]); + + $interval.flush(1000); + expect($exceptionHandler.errors).toEqual(["Test Error", "Test Error"]); + })); + + + it('should call $apply even if an exception is thrown in callback', inject( + function($interval, $rootScope) { + var applySpy = spyOn($rootScope, '$apply').andCallThrough(); + + $interval(function() { throw "Test Error"; }, 1000); + expect(applySpy).not.toHaveBeenCalled(); + + $interval.flush(1000); + expect(applySpy).toHaveBeenCalled(); + })); + + + it('should still update the interval promise when an exception is thrown', + inject(function($interval) { + var log = [], + promise = $interval(function() { throw "Some Error"; }, 1000); + + promise.then(function(value) { log.push('promise success: ' + value); }, + function(err) { log.push('promise error: ' + err); }, + function(note) { log.push('promise update: ' + note); }); + $interval.flush(1000); + + expect(log).toEqual(['promise update: 0']); + })); + }); + + + describe('cancel', function() { + it('should cancel tasks', inject(function($interval) { + var task1 = jasmine.createSpy('task1', 1000), + task2 = jasmine.createSpy('task2', 1000), + task3 = jasmine.createSpy('task3', 1000), + promise1, promise3; + + promise1 = $interval(task1, 200); + $interval(task2, 1000); + promise3 = $interval(task3, 333); + + $interval.cancel(promise3); + $interval.cancel(promise1); + $interval.flush(1000); + + expect(task1).not.toHaveBeenCalled(); + expect(task2).toHaveBeenCalledOnce(); + expect(task3).not.toHaveBeenCalled(); + })); + + + it('should cancel the promise', inject(function($interval, $rootScope) { + var promise = $interval(noop, 1000), + log = []; + promise.then(function(value) { log.push('promise success: ' + value); }, + function(err) { log.push('promise error: ' + err); }, + function(note) { log.push('promise update: ' + note); }); + expect(log).toEqual([]); + + $interval.flush(1000); + $interval.cancel(promise); + $interval.flush(1000); + $rootScope.$apply(); // For resolving the promise - + // necessary since q uses $rootScope.evalAsync. + + expect(log).toEqual(['promise update: 0', 'promise error: canceled']); + })); + + + it('should return true if a task was successfully canceled', inject(function($interval) { + var task1 = jasmine.createSpy('task1'), + task2 = jasmine.createSpy('task2'), + promise1, promise2; + + promise1 = $interval(task1, 1000, 1); + $interval.flush(1000); + promise2 = $interval(task2, 1000, 1); + + expect($interval.cancel(promise1)).toBe(false); + expect($interval.cancel(promise2)).toBe(true); + })); + + + it('should not throw a runtime exception when given an undefined promise', + inject(function($interval) { + expect($interval.cancel()).toBe(false); + })); + }); + }); + + describe('defer', function() { var browser, log; beforeEach(inject(function($browser) {