diff --git a/angularFiles.js b/angularFiles.js index a67aa6cc6b4a..4f7da92b197d 100644 --- a/angularFiles.js +++ b/angularFiles.js @@ -9,6 +9,7 @@ angularFiles = { 'src/jqLite.js', 'src/apis.js', 'src/service/autoScroll.js', + 'src/Deferred.js', 'src/service/browser.js', 'src/service/cacheFactory.js', 'src/service/compiler.js', diff --git a/src/AngularPublic.js b/src/AngularPublic.js index 105059dcf4e0..7839ea63bb9a 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -86,6 +86,7 @@ function ngModule($provide, $injector) { $provide.service('$route', $RouteProvider); $provide.service('$routeParams', $RouteParamsProvider); $provide.service('$rootScope', $RootScopeProvider); + $provide.service('$q', $QProvider); $provide.service('$sniffer', $SnifferProvider); $provide.service('$templateCache', $TemplateCacheProvider); $provide.service('$window', $WindowProvider); diff --git a/src/Deferred.js b/src/Deferred.js new file mode 100644 index 000000000000..bf20d4135994 --- /dev/null +++ b/src/Deferred.js @@ -0,0 +1,206 @@ +'use strict'; + +/** + * inspired by Kris Kowal's Q (https://github.com/kriskowal/q) + */ + +/** + * Constructs a promise manager. + * + * @param {function(function)=} nextTick Function for executing functions in the next turn. Falls + * back to `setTimeout` if undefined. + * @param {function(...*)=} exceptionHandler Function into which unexpected exceptions are passed for + * debugging purposes. Falls back to `console.error` if undefined, + * @returns {object} Promise manager. + */ +function qFactory(nextTick, exceptionHandler) { + + nextTick = nextTick || function(callback) { + setTimeout(callback, 0); // very rare since most of queueing will be handled within $apply + }; + + exceptionHandler = exceptionHandler || function(e) { + // TODO(i): console.error is somehow reset to function(a) {}, it might be a JSTD bug + if (console && console.log) { + console.log(e); + } + } + + var defer = function() { + var pending = [], + value, deferred; + + deferred = { + + resolve: function(val) { + if (pending) { + var callbacks = pending; + pending = undefined; + value = ref(val); + + if (callbacks.length) { + nextTick(function() { + var callback; + for (var i = 0, ii = callbacks.length; i < ii; i++) { + callback = callbacks[i]; + value.then(callback[0], callback[1]); + } + }); + } + } + }, + + + reject: function(reason) { + deferred.resolve(reject(reason)); + }, + + + promise: { + then: function(callback, errback) { + var result = defer(); + + var wrappedCallback = function(value) { + try { + result.resolve((callback || defaultCallback)(value)); + } catch(e) { + exceptionHandler(e); + result.reject(e); + } + }; + + var wrappedErrback = function(reason) { + try { + result.resolve((errback || defaultErrback)(reason)); + } catch(e) { + exceptionHandler(e); + result.reject(e); + } + }; + + if (pending) { + pending.push([wrappedCallback, wrappedErrback]); + } else { + value.then(wrappedCallback, wrappedErrback); + } + + return result.promise; + } + } + }; + + return deferred; + }; + + + var ref = function(value) { + if (value && value.then) return value; + return { + then: function(callback) { + var result = defer(); + nextTick(function() { + result.resolve(callback(value)); + }); + return result.promise; + } + }; + }; + + + var reject = function(reason) { + return { + then: function(callback, errback) { + var result = defer(); + nextTick(function() { + result.resolve(errback(reason)); + }); + return result.promise; + } + }; + }; + + + var when = function(value, callback, errback) { + var result = defer(), + done; + + var wrappedCallback = function(value) { + try { + return (callback || defaultCallback)(value); + } catch (e) { + exceptionHandler(e); + return reject(e); + } + }; + + var wrappedErrback = function(reason) { + try { + return (errback || defaultErrback)(reason); + } catch (e) { + exceptionHandler(e); + return reject(e); + } + }; + + nextTick(function() { + ref(value).then(function(value) { + if (done) return; + done = true; + result.resolve(ref(value).then(wrappedCallback, wrappedErrback)); + }, function(reason) { + if (done) return; + done = true; + result.resolve(wrappedErrback(reason)); + }); + }); + + return result.promise; + }; + + + function defaultCallback(value) { + return value; + } + + + function defaultErrback(reason) { + return reject(reason); + } + + + function all(promises) { + var deferred = defer(), + counter = promises.length, + results = []; + + forEach(promises, function(promise, index) { + promise.then(function(value) { + if (index in results) return; + results[index] = value; + if (!(--counter)) deferred.resolve(results); + }, function(reason) { + if (index in results) return; + deferred.reject(reason); + }); + }); + + return deferred.promise; + } + + return { + defer: defer, + reject: reject, + when: when, + all: all + }; +} + +// TODO(i): move elsewhere +function $QProvider() { + + this.$get = ['$rootScope', '$exceptionHandler', function($rootScope, $exceptionHandler) { + return qFactory(function(callback) { + $rootScope.$evalAsync(callback); + }, $exceptionHandler); + }]; +} diff --git a/src/service/http.js b/src/service/http.js index 6efe747400b3..8458617b1cc2 100644 --- a/src/service/http.js +++ b/src/service/http.js @@ -229,7 +229,7 @@ function $HttpProvider() { /** * Represents Request object, returned by $http() * - * !!! ACCESS CLOSURE VARS: + * !!! ACCESSES CLOSURE VARS: * $httpBackend, $browser, $config, $log, $rootScope, defaultCache, $http.pendingRequests */ function XhrFuture() { diff --git a/src/service/scope.js b/src/service/scope.js index cbda44954bd5..d6cf60c9fab4 100644 --- a/src/service/scope.js +++ b/src/service/scope.js @@ -381,11 +381,11 @@ function $RootScopeProvider(){ } } while ((current = next)); - if(!(ttl--)) { + if(dirty && !(ttl--)) { throw Error('100 $digest() iterations reached. Aborting!\n' + 'Watchers fired in the last 5 iterations: ' + toJson(watchLog)); } - } while (dirty); + } while (dirty || asyncQueue.length); }, /** diff --git a/test/DeferredSpec.js b/test/DeferredSpec.js new file mode 100644 index 000000000000..e592ab87a9e0 --- /dev/null +++ b/test/DeferredSpec.js @@ -0,0 +1,823 @@ +'use strict'; + +/** + http://wiki.commonjs.org/wiki/Promises + http://www.slideshare.net/domenicdenicola/callbacks-promises-and-coroutines-oh-my-the-evolution-of-asynchronicity-in-javascript + + Q: https://github.com/kriskowal/q + https://github.com/kriskowal/q/blob/master/design/README.js + https://github.com/kriskowal/uncommonjs/blob/master/promises/specification.md + http://jsconf.eu/2010/speaker/commonjs_i_promise_by_kris_kow.html + - good walkthrough of the Q api's and design, jump to 15:30 + + twisted: http://twistedmatrix.com/documents/11.0.0/api/twisted.internet.defer.Deferred.html + dojo: https://github.com/dojo/dojo/blob/master/_base/Deferred.js + http://dojotoolkit.org/api/1.6/dojo/Deferred + http://dojotoolkit.org/documentation/tutorials/1.6/promises/ + when.js: https://github.com/briancavalier/when.js + DART: http://www.dartlang.org/docs/api/Promise.html#Promise::Promise + http://code.google.com/p/dart/source/browse/trunk/dart/corelib/src/promise.dart + http://codereview.chromium.org/8271014/patch/11003/12005 + https://chromereviews.googleplex.com/3365018/ + WinJS: http://msdn.microsoft.com/en-us/library/windows/apps/br211867.aspx + + http://download.oracle.com/javase/1.5.0/docs/api/java/util/concurrent/Future.html + http://en.wikipedia.org/wiki/Futures_and_promises + http://wiki.ecmascript.org/doku.php?id=strawman:deferred_functions + http://wiki.ecmascript.org/doku.php?id=strawman:async_functions + + + http://jsperf.com/throw-vs-return +*/ + +describe('q', function() { + var q, defer, deferred, promise, log; + + /** + * Creates a callback that logs its invocation in `log`. + * + * @param {(number|string)} name Suffix for 'success' name. e.g. success(1) => success1 + * @param {*=} returnVal Value that the callback should return. If unspecified, the passed in + * value is returned. + * @param {boolean=} throwReturnVal If true, the `returnVal` will be thrown rather than returned. + */ + function success(name, returnVal, throwReturnVal) { + var returnValDefined = (arguments.length >= 2); + + return function() { + name = 'success' + (name || ''); + var args = toJson(sliceArgs(arguments)).replace(/(^\[|"|\]$)/g, ''); + log.push(name + '(' + args + ')'); + returnVal = returnValDefined ? returnVal : arguments[0]; + if (throwReturnVal) throw returnVal; + return returnVal; + } + } + + + /** + * Creates a callback that logs its invocation in `log`. + * + * @param {(number|string)} name Suffix for 'error' name. e.g. error(1) => error1 + * @param {*=} returnVal Value that the callback should return. If unspecified, the passed in + * value is rethrown. + * @param {boolean=} throwReturnVal If true, the `returnVal` will be thrown rather than returned. + */ + function error(name, returnVal, throwReturnVal) { + var returnValDefined = (arguments.length >= 2); + + return function(){ + name = 'error' + (name || ''); + log.push(name + '(' + [].join.call(arguments, ',') + ')'); + returnVal = returnValDefined ? returnVal : q.reject(arguments[0]); + if (throwReturnVal) throw returnVal; + return returnVal; + } + } + + + /** helper for synchronous resolution of deferred */ + function syncResolve(deferred, result) { + deferred.resolve(result); + mockNextTick.flush(); + } + + + /** helper for synchronous rejection of deferred */ + function syncReject(deferred, reason) { + deferred.reject(reason); + mockNextTick.flush(); + } + + + /** converts the `log` to a '; '-separated string */ + function logStr() { + return log.join('; '); + } + + + var mockNextTick = { + nextTick: function(task) { + mockNextTick.queue.push(task); + }, + queue: [], + flush: function() { + if (!mockNextTick.queue.length) throw new Error('Nothing to be flushed!'); + while (mockNextTick.queue.length) { + var queue = mockNextTick.queue; + mockNextTick.queue = []; + forEach(queue, function(task) { + try { + task(); + } catch(e) { + dump('exception in mockNextTick:', e, e.name, e.message, task); + } + }); + } + } + } + + + beforeEach(function() { + q = qFactory(mockNextTick.nextTick, noop), + defer = q.defer; + deferred = defer() + promise = deferred.promise; + log = []; + mockNextTick.queue = []; + }); + + + afterEach(function() { + expect(mockNextTick.queue.length).toBe(0); + }); + + + describe('defer', function() { + it('should create a new deferred', function() { + expect(deferred.promise).toBeDefined(); + expect(deferred.resolve).toBeDefined(); + expect(deferred.reject).toBeDefined(); + }); + + + describe('resolve', function() { + it('should fulfill the promise and execute all success callbacks in the registration order', + function() { + promise.then(success(1), error()); + promise.then(success(2), error()); + expect(logStr()).toBe(''); + + deferred.resolve('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('success1(foo); success2(foo)'); + }); + + + it('should do nothing if a promise was previously resolved', function() { + promise.then(success(), error()); + expect(logStr()).toBe(''); + + deferred.resolve('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('success(foo)'); + + log = []; + deferred.resolve('bar'); + deferred.reject('baz'); + expect(mockNextTick.queue.length).toBe(0); + expect(logStr()).toBe(''); + }); + + + it('should do nothing if a promise was previously rejected', function() { + promise.then(success(), error()); + expect(logStr()).toBe(''); + + deferred.reject('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('error(foo)'); + + log = []; + deferred.resolve('bar'); + deferred.reject('baz'); + expect(mockNextTick.queue.length).toBe(0); + expect(logStr()).toBe(''); + }); + + + it('should allow deferred resolution with a new promise', function() { + var deferred2 = defer(); + promise.then(success(), error()); + + deferred.resolve(deferred2.promise); + mockNextTick.flush(); + expect(logStr()).toBe(''); + + deferred2.resolve('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('success(foo)'); + }); + + + it('should call the callback in the next turn', function() { + promise.then(success()); + expect(logStr()).toBe(''); + + deferred.resolve('foo'); + expect(logStr()).toBe(''); + + mockNextTick.flush(); + expect(logStr()).toBe('success(foo)'); + }); + + + it('should support non-bound execution', function() { + var resolver = deferred.resolve; + promise.then(success(), error()); + resolver('detached'); + mockNextTick.flush(); + expect(logStr()).toBe('success(detached)'); + }); + + + it('should not break if a callbacks registers another callback', function() { + promise.then(function() { + log.push('outer'); + promise.then(function() { + log.push('inner'); + }); + }); + + deferred.resolve('foo'); + expect(logStr()).toBe(''); + + mockNextTick.flush(); + expect(logStr()).toBe('outer; inner'); + }); + + + it('should not break if a callbacks tries to resolve the deferred again', function() { + promise.then(function(val) { + log.push('success1(' + val + ')'); + deferred.resolve('bar'); + }); + + promise.then(success(2)); + + deferred.resolve('foo'); + expect(logStr()).toBe(''); + + mockNextTick.flush(); + expect(logStr()).toBe('success1(foo); success2(foo)'); + }); + }); + + + describe('reject', function() { + it('should reject the promise and execute all error callbacks in the registration order', + function() { + promise.then(success(), error(1)); + promise.then(success(), error(2)); + expect(logStr()).toBe(''); + + deferred.reject('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('error1(foo); error2(foo)'); + }); + + + it('should do nothing if a promise was previously resolved', function() { + promise.then(success(1), error(1)); + expect(logStr()).toBe(''); + + deferred.resolve('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('success1(foo)'); + + log = []; + deferred.reject('bar'); + deferred.resolve('baz'); + expect(mockNextTick.queue.length).toBe(0); + expect(logStr()).toBe(''); + + promise.then(success(2), error(2)) + expect(logStr()).toBe(''); + mockNextTick.flush(); + expect(logStr()).toBe('success2(foo)'); + }); + + + it('should do nothing if a promise was previously rejected', function() { + promise.then(success(1), error(1)); + expect(logStr()).toBe(''); + + deferred.reject('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('error1(foo)'); + + log = []; + deferred.reject('bar'); + deferred.resolve('baz'); + expect(mockNextTick.queue.length).toBe(0); + expect(logStr()).toBe(''); + + promise.then(success(2), error(2)) + expect(logStr()).toBe(''); + mockNextTick.flush(); + expect(logStr()).toBe('error2(foo)'); + }); + + + it('should not defer rejection with a new promise', function() { + var deferred2 = defer(); + promise.then(success(), error()); + + deferred.reject(deferred2.promise); + mockNextTick.flush(); + expect(logStr()).toBe('error([object Object])'); + }); + + + it('should call the error callback in the next turn', function() { + promise.then(success(), error()); + expect(logStr()).toBe(''); + + deferred.reject('foo'); + expect(logStr()).toBe(''); + + mockNextTick.flush(); + expect(logStr()).toBe('error(foo)'); + }); + + + it('should support non-bound execution', function() { + var rejector = deferred.reject; + promise.then(success(), error()); + rejector('detached'); + mockNextTick.flush(); + expect(logStr()).toBe('error(detached)'); + }); + }); + + + describe('promise', function() { + it('should have a then method', function() { + expect(typeof promise.then).toBe('function'); + }); + + + describe('then', function() { + it('should allow registration of a success callback without an errback and resolve', + function() { + promise.then(success()); + syncResolve(deferred, 'foo'); + expect(logStr()).toBe('success(foo)'); + }); + + it('should allow registration of a success callback without an errback and reject', + function() { + promise.then(success()); + syncReject(deferred, 'foo'); + expect(logStr()).toBe(''); + }); + + + it('should allow registration of an errback without a success callback and reject', + function() { + promise.then(null, error()); + syncReject(deferred, 'oops!'); + expect(logStr()).toBe('error(oops!)'); + }); + + + it('should allow registration of an errback without a success callback and resolve', + function() { + promise.then(null, error()); + syncResolve(deferred, 'done'); + expect(logStr()).toBe(''); + }); + + + it('should resolve all callbacks with the original value', function() { + promise.then(success('A', 'aVal'), error()); + promise.then(success('B', 'bErr', true), error()); + promise.then(success('C', q.reject('cReason')), error()); + promise.then(success('D', 'dVal'), error()); + + expect(logStr()).toBe(''); + syncResolve(deferred, 'yup'); + expect(logStr()).toBe('successA(yup); successB(yup); successC(yup); successD(yup)'); + }); + + + it('should reject all callbacks with the original reason', function() { + promise.then(success(), error('A', 'aVal')); + promise.then(success(), error('B', 'bEr', true)); + promise.then(success(), error('C', q.reject('cReason'))); + promise.then(success(), error('D', 'dVal')); + + expect(logStr()).toBe(''); + syncReject(deferred, 'noo!'); + expect(logStr()).toBe('errorA(noo!); errorB(noo!); errorC(noo!); errorD(noo!)'); + }); + + + it('should propagate resolution and rejection between dependent promises', function() { + promise.then(success(1, 'x'), error('1')). + then(success(2, 'y', true), error('2')). + then(success(3), error(3, 'z', true)). + then(success(4), error(4, 'done')). + then(success(5), error(5)); + + expect(logStr()).toBe(''); + syncResolve(deferred, 'sweet!'); + expect(log).toEqual(['success1(sweet!)', + 'success2(x)', + 'error3(y)', + 'error4(z)', + 'success5(done)']); + }); + + + it('should reject a derived promise if an exception is thrown while resolving its parent', + function() { + promise.then(success(1, 'oops', true)). + then(success(2), error(2)); + syncResolve(deferred, 'done!'); + expect(logStr()).toBe('success1(done!); error2(oops)'); + }); + + + it('should reject a derived promise if an exception is thrown while rejecting its parent', + function() { + promise.then(null, error(1, 'oops', true)). + then(success(2), error(2)); + syncReject(deferred, 'timeout'); + expect(logStr()).toBe('error1(timeout); error2(oops)'); + }); + + + it('should call success callback in the next turn even if promise is already resolved', + function() { + deferred.resolve('done!'); + + promise.then(success()); + expect(logStr()).toBe(''); + + mockNextTick.flush(); + expect(log).toEqual(['success(done!)']); + }); + + + it('should call errpr callback in the next turn even if promise is already rejected', + function() { + deferred.reject('oops!'); + + promise.then(null, error()); + expect(logStr()).toBe(''); + + mockNextTick.flush(); + expect(log).toEqual(['error(oops!)']); + }); + }); + }); + }); + + + describe('reject', function() { + it('should package a string into a rejected promise', function() { + var rejectedPromise = q.reject('not gonna happen'); + promise.then(success(), error()); + syncResolve(deferred, rejectedPromise); + expect(log).toEqual(['error(not gonna happen)']); + }); + + + it('should package an exception into a rejected promise', function() { + var rejectedPromise = q.reject(Error('not gonna happen')); + promise.then(success(), error()); + syncResolve(deferred, rejectedPromise); + expect(log).toEqual(['error(Error: not gonna happen)']); + }); + }); + + + describe('when', function() { + describe('resolution', function() { + it('should call the success callback in the next turn when the value is a non-promise', + function() { + q.when('hello', success(), error()); + expect(logStr()).toBe(''); + mockNextTick.flush(); + expect(logStr()).toBe('success(hello)'); + }); + + + it('should call the success callback in the next turn when the value is a resolved promise', + function() { + deferred.resolve('hello'); + q.when(deferred.promise, success(), error()); + expect(logStr()).toBe(''); + mockNextTick.flush(); + expect(logStr()).toBe('success(hello)'); + }); + + + it('should call the errback in the next turn when the value is a rejected promise', function() { + deferred.reject('nope'); + q.when(deferred.promise, success(), error()); + expect(logStr()).toBe(''); + mockNextTick.flush(); + expect(logStr()).toBe('error(nope)'); + }); + + + it('should call the success callback after the original promise is resolved', + function() { + q.when(deferred.promise, success(), error()); + expect(logStr()).toBe(''); + mockNextTick.flush(); + expect(logStr()).toBe(''); + syncResolve(deferred, 'hello'); + expect(logStr()).toBe('success(hello)'); + }); + + + it('should call the errback after the orignal promise is rejected', + function() { + q.when(deferred.promise, success(), error()); + expect(logStr()).toBe(''); + mockNextTick.flush(); + expect(logStr()).toBe(''); + syncReject(deferred, 'nope'); + expect(logStr()).toBe('error(nope)'); + }); + }); + + + describe('optional callbacks', function() { + it('should not require success callback and propagate resolution', function() { + q.when('hi', null, error()).then(success(2), error()); + expect(logStr()).toBe(''); + mockNextTick.flush(); + expect(logStr()).toBe('success2(hi)'); + }); + + + it('should not require success callback and propagate rejection', function() { + q.when(q.reject('sorry'), null, error(1)).then(success(), error(2)); + expect(logStr()).toBe(''); + mockNextTick.flush(); + expect(logStr()).toBe('error1(sorry); error2(sorry)'); + }); + + + it('should not require errback and propagate resolution', function() { + q.when('hi', success(1, 'hello')).then(success(2), error()); + expect(logStr()).toBe(''); + mockNextTick.flush(); + expect(logStr()).toBe('success1(hi); success2(hello)'); + }); + + + it('should not require errback and propagate rejection', function() { + q.when(q.reject('sorry'), success()).then(success(2), error(2)); + expect(logStr()).toBe(''); + mockNextTick.flush(); + expect(logStr()).toBe('error2(sorry)'); + }); + }); + + + describe('returned promise', function() { + it('should return a promise that can be resolved with a value returned from the success ' + + 'callback', function() { + q.when('hello', success(1, 'hi'), error()).then(success(2), error()); + mockNextTick.flush(); + expect(logStr()).toBe('success1(hello); success2(hi)'); + }); + + + it('should return a promise that can be rejected with a rejected promise returned from the ' + + 'success callback', function() { + q.when('hello', success(1, q.reject('sorry')), error()).then(success(), error(2)); + mockNextTick.flush(); + expect(logStr()).toBe('success1(hello); error2(sorry)'); + }); + + + it('should return a promise that can be resolved with a value returned from the errback', + function() { + q.when(q.reject('sorry'), success(), error(1, 'hi')).then(success(2), error()); + mockNextTick.flush(); + expect(logStr()).toBe('error1(sorry); success2(hi)'); + }); + + + it('should return a promise that can be rejected with a rejected promise returned from the ' + + 'errback', function() { + q.when(q.reject('sorry'), success(), error(1, q.reject('sigh'))).then(success(), error(2)); + mockNextTick.flush(); + expect(logStr()).toBe('error1(sorry); error2(sigh)'); + }); + + + it('should return a promise that can be resolved with a promise returned from the success ' + + 'callback', function() { + var deferred2 = defer(); + q.when('hi', success(1, deferred2.promise), error()).then(success(2), error()); + mockNextTick.flush(); + expect(logStr()).toBe('success1(hi)'); + syncResolve(deferred2, 'finally!'); + expect(logStr()).toBe('success1(hi); success2(finally!)'); + }); + + + it('should return a promise that can be resolved with promise returned from the errback ' + + 'callback', function() { + var deferred2 = defer(); + q.when(q.reject('sorry'), success(), error(1, deferred2.promise)).then(success(2), error()); + mockNextTick.flush(); + expect(logStr()).toBe('error1(sorry)'); + syncResolve(deferred2, 'finally!'); + expect(logStr()).toBe('error1(sorry); success2(finally!)'); + }); + }); + + + describe('security', function() { + it('should call success callback only once even if the original promise gets fullfilled ' + + 'multiple times', function() { + var evilPromise = { + then: function(success, error) { + evilPromise.success = success; + evilPromise.error = error; + } + } + + q.when(evilPromise, success(), error()); + mockNextTick.flush(); + expect(logStr()).toBe(''); + evilPromise.success('done'); + mockNextTick.flush(); // TODO(i) wrong queue, evil promise would be resolved outside of the + // scope.$apply lifecycle and in that case we should have some kind + // of fallback queue for calling our callbacks from. Otherwise the + // application will get stuck until something triggers next $apply. + expect(logStr()).toBe('success(done)'); + + evilPromise.success('evil is me'); + evilPromise.error('burn burn'); + expect(logStr()).toBe('success(done)'); + }); + + + it('should call errback only once even if the original promise gets fullfilled multiple ' + + 'times', function() { + var evilPromise = { + then: function(success, error) { + evilPromise.success = success; + evilPromise.error = error; + } + } + + q.when(evilPromise, success(), error()); + mockNextTick.flush(); + expect(logStr()).toBe(''); + evilPromise.error('failed'); + expect(logStr()).toBe('error(failed)'); + + evilPromise.error('muhaha'); + evilPromise.success('take this'); + expect(logStr()).toBe('error(failed)'); + }); + }); + }); + + + describe('all', function() { + it('should take an array of promises and return a promise for an array of results', function() { + var deferred1 = defer(), + deferred2 = defer(); + + q.all([promise, deferred1.promise, deferred2.promise]).then(success(), error()); + expect(logStr()).toBe(''); + syncResolve(deferred, 'hi'); + expect(logStr()).toBe(''); + syncResolve(deferred2, 'cau'); + expect(logStr()).toBe(''); + syncResolve(deferred1, 'hola'); + expect(logStr()).toBe('success([hi,hola,cau])'); + }); + + + it('should reject the derived promise if at least one of the promises in the array is rejected', + function() { + var deferred1 = defer(), + deferred2 = defer(); + + q.all([promise, deferred1.promise, deferred2.promise]).then(success(), error()); + expect(logStr()).toBe(''); + syncResolve(deferred2, 'cau'); + expect(logStr()).toBe(''); + syncReject(deferred1, 'oops'); + expect(logStr()).toBe('error(oops)'); + }); + + + it('should ignore multiple resolutions of an (evil) array promise', function() { + var evilPromise = { + then: function(success, error) { + evilPromise.success = success; + evilPromise.error = error; + } + } + + q.all([promise, evilPromise]).then(success(), error()); + expect(logStr()).toBe(''); + + evilPromise.success('first'); + evilPromise.success('muhaha'); + evilPromise.error('arghhh'); + expect(logStr()).toBe(''); + + syncResolve(deferred, 'done'); + expect(logStr()).toBe('success([done,first])'); + }); + }); + + + describe('exception logging', function() { + var mockExceptionLogger = { + log: [], + logger: function(e) { + mockExceptionLogger.log.push(e); + } + } + + + beforeEach(function() { + q = qFactory(mockNextTick.nextTick, mockExceptionLogger.logger), + defer = q.defer; + deferred = defer() + promise = deferred.promise; + log = []; + mockExceptionLogger.log = []; + }); + + + describe('in then', function() { + it('should log exceptions thrown in a success callback and reject the derived promise', + function() { + var success1 = success(1, 'oops', true); + promise.then(success1).then(success(2), error(2)); + syncResolve(deferred, 'done'); + expect(logStr()).toBe('success1(done); error2(oops)'); + expect(mockExceptionLogger.log).toEqual(['oops']); + }); + + + it('should NOT log exceptions when a success callback returns rejected promise', function() { + promise.then(success(1, q.reject('rejected'))).then(success(2), error(2)); + syncResolve(deferred, 'done'); + expect(logStr()).toBe('success1(done); error2(rejected)'); + expect(mockExceptionLogger.log).toEqual([]); + }); + + + it('should log exceptions thrown in a errback and reject the derived promise', function() { + var error1 = error(1, 'oops', true); + promise.then(null, error1).then(success(2), error(2)); + syncReject(deferred, 'nope'); + expect(logStr()).toBe('error1(nope); error2(oops)'); + expect(mockExceptionLogger.log).toEqual(['oops']); + }); + + + it('should NOT log exceptions when an errback returns a rejected promise', function() { + promise.then(null, error(1, q.reject('rejected'))).then(success(2), error(2)); + syncReject(deferred, 'nope'); + expect(logStr()).toBe('error1(nope); error2(rejected)'); + expect(mockExceptionLogger.log).toEqual([]); + }); + }); + + + describe('in when', function() { + it('should log exceptions thrown in a success callback and reject the derived promise', + function() { + var success1 = success(1, 'oops', true); + q.when('hi', success1, error()).then(success(), error(2)); + mockNextTick.flush(); + expect(logStr()).toBe('success1(hi); error2(oops)'); + expect(mockExceptionLogger.log).toEqual(['oops']); + }); + + + it('should NOT log exceptions when a success callback returns rejected promise', function() { + q.when('hi', success(1, q.reject('rejected'))).then(success(2), error(2)); + mockNextTick.flush(); + expect(logStr()).toBe('success1(hi); error2(rejected)'); + expect(mockExceptionLogger.log).toEqual([]); + }); + + + it('should log exceptions thrown in a errback and reject the derived promise', function() { + var error1 = error(1, 'oops', true); + q.when(q.reject('sorry'), success(), error1).then(success(), error(2)); + mockNextTick.flush(); + expect(logStr()).toBe('error1(sorry); error2(oops)'); + expect(mockExceptionLogger.log).toEqual(['oops']); + }); + + + it('should NOT log exceptions when an errback returns a rejected promise', function() { + q.when(q.reject('sorry'), success(), error(1, q.reject('rejected'))). + then(success(2), error(2)); + mockNextTick.flush(); + expect(logStr()).toBe('error1(sorry); error2(rejected)'); + expect(mockExceptionLogger.log).toEqual([]); + }); + }); + }); +}); diff --git a/test/matchers.js b/test/matchers.js index 9cd582e17674..1e0ae92a9dbf 100644 --- a/test/matchers.js +++ b/test/matchers.js @@ -89,6 +89,44 @@ beforeEach(function() { }, + toHaveBeenCalledOnceWith: function() { + var expectedArgs = jasmine.util.argsToArray(arguments); + + if (!jasmine.isSpy(this.actual)) { + throw new Error('Expected a spy, but got ' + jasmine.pp(this.actual) + '.'); + } + + this.message = function() { + if (this.actual.callCount != 1) { + if (this.actual.callCount == 0) { + return [ + 'Expected spy ' + this.actual.identity + ' to have been called with ' + + jasmine.pp(expectedArgs) + ' but it was never called.', + 'Expected spy ' + this.actual.identity + ' not to have been called with ' + + jasmine.pp(expectedArgs) + ' but it was.' + ]; + } + + return [ + 'Expected spy ' + this.actual.identity + ' to have been called with ' + + jasmine.pp(expectedArgs) + ' but it was never called.', + 'Expected spy ' + this.actual.identity + ' not to have been called with ' + + jasmine.pp(expectedArgs) + ' but it was.' + ]; + } else { + return [ + 'Expected spy ' + this.actual.identity + ' to have been called with ' + + jasmine.pp(expectedArgs) + ' but was called with ' + jasmine.pp(this.actual.argsForCall), + 'Expected spy ' + this.actual.identity + ' not to have been called with ' + + jasmine.pp(expectedArgs) + ' but was called with ' + jasmine.pp(this.actual.argsForCall) + ]; + } + }; + + return this.actual.callCount === 1 && this.env.contains_(this.actual.argsForCall, expectedArgs); + }, + + toBeOneOf: function() { return indexOf(arguments, this.actual) !== -1; },