From e36bfb9fd8ef927795938f58b8e467efc45caca6 Mon Sep 17 00:00:00 2001 From: David Bennett Date: Wed, 24 Apr 2013 12:33:08 -0500 Subject: [PATCH 1/3] feat($httpBackend): add timeout support for JSONP requests Documentation implies that timeout works for all requests, though it only works with XHR. To implement: - Change $httpBackend to set a timeout for JSONP requests which will immediately resolve the request when fired. - Cancel the timeout when requests are completed. --- src/ng/httpBackend.js | 25 ++++++++++-------- test/ng/httpBackendSpec.js | 52 +++++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/src/ng/httpBackend.js b/src/ng/httpBackend.js index a3f6cdc01eb8..870d38450531 100644 --- a/src/ng/httpBackend.js +++ b/src/ng/httpBackend.js @@ -33,6 +33,7 @@ function $HttpBackendProvider() { function createHttpBackend($browser, XHR, $browserDefer, callbacks, rawDocument, locationProtocol) { // TODO(vojta): fix the signature return function(method, url, post, callback, headers, timeout, withCredentials, responseType) { + var status; $browser.$$incOutstandingRequestCount(); url = url || $browser.url(); @@ -42,12 +43,12 @@ function createHttpBackend($browser, XHR, $browserDefer, callbacks, rawDocument, callbacks[callbackId].data = data; }; - jsonpReq(url.replace('JSON_CALLBACK', 'angular.callbacks.' + callbackId), + var jsonpDone = jsonpReq(url.replace('JSON_CALLBACK', 'angular.callbacks.' + callbackId), function() { if (callbacks[callbackId].data) { completeRequest(callback, 200, callbacks[callbackId].data); } else { - completeRequest(callback, -2); + completeRequest(callback, status || -2); } delete callbacks[callbackId]; }); @@ -58,8 +59,6 @@ function createHttpBackend($browser, XHR, $browserDefer, callbacks, rawDocument, if (value) xhr.setRequestHeader(key, value); }); - var status; - // In IE6 and 7, this might be called synchronously when xhr.send below is called and the // response is in the cache. the promise api will ensure that to the app code the api is // always async @@ -105,13 +104,15 @@ function createHttpBackend($browser, XHR, $browserDefer, callbacks, rawDocument, } xhr.send(post || ''); + } - if (timeout > 0) { - $browserDefer(function() { - status = -1; - xhr.abort(); - }, timeout); - } + + if (timeout > 0) { + var timeoutId = $browserDefer(function() { + status = -1; + jsonpDone && jsonpDone(); + xhr && xhr.abort(); + }, timeout); } @@ -119,6 +120,9 @@ function createHttpBackend($browser, XHR, $browserDefer, callbacks, rawDocument, // URL_MATCH is defined in src/service/location.js var protocol = (url.match(SERVER_MATCH) || ['', locationProtocol])[1]; + // cancel timeout + timeoutId && $browserDefer.cancel(timeoutId); + // fix status code for file protocol (it's always 0) status = (protocol == 'file') ? (response ? 200 : 404) : status; @@ -152,5 +156,6 @@ function createHttpBackend($browser, XHR, $browserDefer, callbacks, rawDocument, } rawDocument.body.appendChild(script); + return doneWrapper; } } diff --git a/test/ng/httpBackendSpec.js b/test/ng/httpBackendSpec.js index a7935a7c0cc6..018f641d8fdd 100644 --- a/test/ng/httpBackendSpec.js +++ b/test/ng/httpBackendSpec.js @@ -1,21 +1,36 @@ describe('$httpBackend', function() { var $backend, $browser, callbacks, - xhr, fakeDocument, callback; + xhr, fakeDocument, callback, + fakeTimeoutId = 0; // TODO(vojta): should be replaced by $defer mock function fakeTimeout(fn, delay) { fakeTimeout.fns.push(fn); fakeTimeout.delays.push(delay); + fakeTimeout.ids.push(++fakeTimeoutId); + return fakeTimeoutId; } fakeTimeout.fns = []; fakeTimeout.delays = []; + fakeTimeout.ids = []; fakeTimeout.flush = function() { var len = fakeTimeout.fns.length; fakeTimeout.delays = []; + fakeTimeout.ids = []; while (len--) fakeTimeout.fns.shift()(); }; + fakeTimeout.cancel = function(id) { + var i = indexOf(fakeTimeout.ids, id); + if (i >= 0) { + fakeTimeout.fns.splice(i, 1); + fakeTimeout.delays.splice(i, 1); + fakeTimeout.ids.splice(i, 1); + return true; + } + return false; + }; beforeEach(inject(function($injector) { @@ -102,6 +117,27 @@ describe('$httpBackend', function() { }); + it('should cancel timeout on completion', function() { + callback.andCallFake(function(status, response) { + expect(status).toBe(200); + }); + + $backend('GET', '/url', null, callback, {}, 2000); + xhr = MockXhr.$$lastInstance; + spyOn(xhr, 'abort'); + + expect(fakeTimeout.delays[0]).toBe(2000); + + xhr.status = 200; + xhr.readyState = 4; + xhr.onreadystatechange(); + expect(callback).toHaveBeenCalledOnce(); + + expect(fakeTimeout.delays.length).toBe(0); + expect(xhr.abort).not.toHaveBeenCalled(); + }); + + it('should register onreadystatechange callback before sending', function() { // send() in IE6, IE7 is sync when serving from cache function SyncXhr() { @@ -239,6 +275,20 @@ describe('$httpBackend', function() { }); + it('should abort request on timeout', function() { + callback.andCallFake(function(status, response) { + expect(status).toBe(-1); + }); + + $backend('JSONP', 'http://example.org/path?cb=JSON_CALLBACK', null, callback, null, 2000); + expect(fakeDocument.$$scripts.length).toBe(1); + expect(fakeTimeout.delays[0]).toBe(2000); + + fakeTimeout.flush(); + expect(callback).toHaveBeenCalledOnce(); + }); + + // TODO(vojta): test whether it fires "async-start" // TODO(vojta): test whether it fires "async-end" on both success and error }); From a374a2e3f196aa20e08370b95683f00f3533956a Mon Sep 17 00:00:00 2001 From: David Bennett Date: Fri, 19 Apr 2013 01:14:51 -0400 Subject: [PATCH 2/3] feat($q): add optional canceler and promise.cancel method Add an optional argument to $q.defer to provide a canceling function. This exposes a promise.cancel method that can be used to call the canceling function and reject the promise (and all derived promises). See existing implementations in Dojo Toolkit and Promised-IO library for reference. --- src/ng/q.js | 39 +++++++++++++++++++-- test/ng/qSpec.js | 88 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/ng/q.js b/src/ng/q.js index 22c9caa9a973..df1d6ae9f3bf 100644 --- a/src/ng/q.js +++ b/src/ng/q.js @@ -57,11 +57,17 @@ * * # The Deferred API * - * A new instance of deferred is constructed by calling `$q.defer()`. + * A new instance of deferred is constructed by calling `$q.defer(canceler)`. * * The purpose of the deferred object is to expose the associated Promise instance as well as APIs * that can be used for signaling the successful or unsuccessful completion of the task. * + * `$q.defer` can optionally take a canceler function. This function will cause resulting promises, + * and any derived promises, to have a `cancel()` method, and will be invoked if the promise is + * canceled. The canceler receives the reason the promise was canceled as its argument. The promise + * is rejected with the canceler's return value or the original cancel reason if nothing is + * returned. + * * **Methods** * * - `resolve(value)` – resolves the derived promise with the `value`. If the value is a rejection @@ -97,6 +103,11 @@ * specification](https://github.com/kriskowal/q/wiki/API-Reference#promisefinallycallback) for * more information. * + * - `cancel(reason)` - optionally available if a canceler was provided to `$q.defer`. The canceler + * is invoked and the promise rejected. A reason may be sent to the canceler explaining why it's + * being canceled. Returns true if the promise has not been resolved and was successfully + * canceled. + * * # Chaining promises * * Because calling `then` api of a promise returns a new derived promise, it is easily possible @@ -180,9 +191,10 @@ function qFactory(nextTick, exceptionHandler) { * @description * Creates a `Deferred` object which represents a task which will finish in the future. * + * @param {function(*)=} canceler Function which will be called if the task is canceled. * @returns {Deferred} Returns a new instance of deferred. */ - var defer = function() { + var defer = function(canceler) { var pending = [], value, deferred; @@ -214,7 +226,7 @@ function qFactory(nextTick, exceptionHandler) { promise: { then: function(callback, errback) { - var result = defer(); + var result = defer(wrappedCanceler); var wrappedCallback = function(value) { try { @@ -281,6 +293,27 @@ function qFactory(nextTick, exceptionHandler) { } }; + if (isFunction(canceler)) { + var wrappedCanceler = function(reason) { + try { + var value = canceler(reason); + if (isDefined(value)) reason = value; + } catch(e) { + exceptionHandler(e); + reason = e; + } + when(reason).then(deferred.reject, deferred.reject); + return reason; + }; + + deferred.promise.cancel = function(reason) { + if (pending) { + return !(wrappedCanceler(reason) instanceof Error); + } + return false; + }; + } + return deferred; }; diff --git a/test/ng/qSpec.js b/test/ng/qSpec.js index 0c59db89496b..bdcc5d4818d4 100644 --- a/test/ng/qSpec.js +++ b/test/ng/qSpec.js @@ -350,6 +350,94 @@ describe('q', function() { expect(typeof promise.always).toBe('function'); }); + it('should not have a cancel method if no canceler is provided', function() { + expect(promise.cancel).not.toBeDefined(); + }); + + it('should have a cancel method if a canceler is provided', function() { + deferred = defer(noop); + promise = deferred.promise; + expect(promise.cancel).toBeDefined(); + promise = promise.always(noop); + expect(promise.cancel).toBeDefined(); + }); + + + describe('cancel', function() { + var canceler; + + beforeEach(function() { + canceler = jasmine.createSpy(); + deferred = defer(canceler); + promise = deferred.promise; + }); + + it('should cancel a pending task and reject the promise', function() { + promise.then(success(), error()); + expect(promise.cancel('foo')).toBe(true); + expect(canceler).toHaveBeenCalledWith('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('error(foo)'); + }); + + it('should reject the promise with a reason returned from the canceler', function() { + canceler.andReturn('bar'); + promise.then(success(), error()); + expect(promise.cancel('foo')).toBe(true); + expect(canceler).toHaveBeenCalledWith('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('error(bar)'); + }); + + it('should log exceptions thrown in the canceler', function() { + canceler.andThrow(Error('oops')); + promise.then(success(), error()); + expect(promise.cancel('foo')).toBe(false); + expect(canceler).toHaveBeenCalledWith('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('error(Error: oops)'); + }); + + it('should not cancel a resolved promise', function() { + promise.then(success(), error()); + syncResolve(deferred, 'foo'); + expect(promise.cancel('bar')).toBe(false); + expect(canceler).not.toHaveBeenCalled(); + expect(logStr()).toBe('success(foo)'); + }); + + it('should propagate the cancel method and reasons', function() { + promise = promise.then(success(1), error(1)).then(success(2), error(2)); + expect(promise.cancel).toBeDefined(); + expect(promise.cancel('foo')).toBe(true); + expect(canceler).toHaveBeenCalledWith('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('error1(foo); error2(foo)'); + }); + + it('should reject all derived promises', function() { + var promiseA = promise.then(success('A'), error('A')); + var promiseB = promise.then(success('B'), error('B')); + var promiseC = promiseB.then(success('C'), error('C')); + var promiseD = promiseB.then(success('D'), error('D')); + var promiseE = promiseD.always(error('E')); + expect(promiseC.cancel('foo')).toBe(true); + expect(canceler).toHaveBeenCalledWith('foo'); + mockNextTick.flush(); + expect(logStr()).toBe('errorA(foo); errorB(foo); errorC(foo); errorD(foo); errorE()'); + }); + + it('should resolve promises returned by the canceler', function() { + var deferred2 = defer(); + canceler.andReturn(deferred2.promise.then(success('A'))); + promise.then(success('B'), error('B')); + expect(promise.cancel('foo')).toBe(true); + expect(canceler).toHaveBeenCalledWith('foo'); + syncResolve(deferred2, 'bar'); + expect(logStr()).toBe('successA(bar); errorB(bar)'); + }) + }); + describe('then', function() { it('should allow registration of a success callback without an errback and resolve', From ee163673cbe7eb36979f76450da6ff8442d4352c Mon Sep 17 00:00:00 2001 From: David Bennett Date: Fri, 26 Apr 2013 16:18:46 -0500 Subject: [PATCH 3/3] feat($http): add promise.cancel support Implement the canceler option of `$q.defer` to allow interrupting requests. Closes #1159 --- src/ng/http.js | 42 +++++++++-- src/ng/httpBackend.js | 23 ++++--- src/ngMock/angular-mocks.js | 45 ++++++++---- test/ng/httpBackendSpec.js | 45 ++++++++++++ test/ng/httpSpec.js | 115 ++++++++++++++++++++++++++++++- test/ngMock/angular-mocksSpec.js | 42 ++++++++++- 6 files changed, 278 insertions(+), 34 deletions(-) diff --git a/src/ng/http.js b/src/ng/http.js index dd949a6c28d2..787341589f18 100644 --- a/src/ng/http.js +++ b/src/ng/http.js @@ -664,7 +664,7 @@ function $HttpProvider() { headers[(config.xsrfHeaderName || defaults.xsrfHeaderName)] = xsrfValue; } - + var requestPromise; var serverRequest = function(config) { var reqData = transformData(config.data, headersGetter(headers), config.transformRequest); @@ -678,11 +678,15 @@ function $HttpProvider() { } // send request - return sendReq(config, reqData, headers).then(transformResponse, transformResponse); + requestPromise = sendReq(config, reqData, headers); + return requestPromise.then(transformResponse, transformResponse); }; var chain = [serverRequest, undefined]; - var promise = $q.when(config); + var canceled, promise = $q.when(config).then(function(request) { + // handle case where canceled before next digest cycle + return canceled ? $q.reject(request) : request; + }); // apply interceptors forEach(reversedInterceptors, function(interceptor) { @@ -699,7 +703,26 @@ function $HttpProvider() { var rejectFn = chain.shift(); promise = promise.then(thenFn, rejectFn); - }; + } + + var defer = $q.defer(function() { + canceled = true; + if (!requestPromise) return { + data: null, + status: 0, + headers: headersGetter({}), + config: config + }; + requestPromise.cancel(); + }); + + promise.then(function(response) { + defer.resolve(response); + }, function(response) { + defer.reject(response); + }); + + promise = defer.promise; promise.success = function(fn) { promise.then(function(response) { @@ -862,7 +885,12 @@ function $HttpProvider() { * $httpBackend, defaults, $log, $rootScope, defaultCache, $http.pendingRequests */ function sendReq(config, reqData, reqHeaders) { - var deferred = $q.defer(), + var deferred = $q.defer(function() { + canceled = true; + cancelReq && cancelReq(); + }), + canceled, + cancelReq, promise = deferred.promise, cache, cachedResp, @@ -901,7 +929,7 @@ function $HttpProvider() { // if we won't have the response in cache, send the request to the backend if (!cachedResp) { - $httpBackend(config.method, url, reqData, done, reqHeaders, config.timeout, + cancelReq = $httpBackend(config.method, url, reqData, done, reqHeaders, config.timeout, config.withCredentials, config.responseType); } @@ -925,7 +953,7 @@ function $HttpProvider() { } resolvePromise(response, status, headersString); - $rootScope.$apply(); + !canceled && $rootScope.$apply(); } diff --git a/src/ng/httpBackend.js b/src/ng/httpBackend.js index 870d38450531..b61668b73431 100644 --- a/src/ng/httpBackend.js +++ b/src/ng/httpBackend.js @@ -45,11 +45,9 @@ function createHttpBackend($browser, XHR, $browserDefer, callbacks, rawDocument, var jsonpDone = jsonpReq(url.replace('JSON_CALLBACK', 'angular.callbacks.' + callbackId), function() { - if (callbacks[callbackId].data) { - completeRequest(callback, 200, callbacks[callbackId].data); - } else { - completeRequest(callback, status || -2); - } + var data = callbacks[callbackId].data || undefined; + status = status || (data ? 200 : -2); + completeRequest(callback, status, data); delete callbacks[callbackId]; }); } else { @@ -89,7 +87,7 @@ function createHttpBackend($browser, XHR, $browserDefer, callbacks, rawDocument, // responseText is the old-school way of retrieving response (supported by IE8 & 9) // response and responseType properties were introduced in XHR Level2 spec (supported by IE10) completeRequest(callback, - status || xhr.status, + status = status || xhr.status, (xhr.responseType ? xhr.response : xhr.responseText), responseHeaders); } @@ -106,16 +104,21 @@ function createHttpBackend($browser, XHR, $browserDefer, callbacks, rawDocument, xhr.send(post || ''); } - if (timeout > 0) { - var timeoutId = $browserDefer(function() { + var timeoutId = $browserDefer(cancelRequest, timeout); + } + + return cancelRequest; + + + function cancelRequest() { + if (!status) { status = -1; jsonpDone && jsonpDone(); xhr && xhr.abort(); - }, timeout); + } } - function completeRequest(callback, status, response, headersString) { // URL_MATCH is defined in src/service/location.js var protocol = (url.match(SERVER_MATCH) || ['', locationProtocol])[1]; diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index 62b66576aaf4..2071a5ca952c 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -916,7 +916,9 @@ function createHttpBackendMock($rootScope, $delegate, $browser) { function $httpBackend(method, url, data, callback, headers) { var xhr = new MockXhr(), expectation = expectations[0], - wasExpected = false; + wasExpected = false, + complete, + timeoutId; function prettyPrint(data) { return (angular.isString(data) || angular.isFunction(data) || data instanceof RegExp) @@ -937,12 +939,8 @@ function createHttpBackendMock($rootScope, $delegate, $browser) { expectations.shift(); if (expectation.response) { - responses.push(function() { - var response = expectation.response(method, url, data, headers); - xhr.$$respHeaders = response[2]; - callback(response[0], response[1], xhr.getAllResponseHeaders()); - }); - return; + responses.push(completeRequest(expectation)); + return cancelRequest; } wasExpected = true; } @@ -952,21 +950,40 @@ function createHttpBackendMock($rootScope, $delegate, $browser) { if (definition.match(method, url, data, headers || {})) { if (definition.response) { // if $browser specified, we do auto flush all requests - ($browser ? $browser.defer : responsesPush)(function() { - var response = definition.response(method, url, data, headers); - xhr.$$respHeaders = response[2]; - callback(response[0], response[1], xhr.getAllResponseHeaders()); - }); + timeoutId = ($browser ? $browser.defer : responsesPush)(completeRequest(definition)); + return cancelRequest; } else if (definition.passThrough) { - $delegate(method, url, data, callback, headers); + return $delegate(method, url, data, callback, headers); } else throw Error('No response defined !'); - return; } } throw wasExpected ? Error('No response defined !') : Error('Unexpected request: ' + method + ' ' + url + '\n' + (expectation ? 'Expected ' + expectation : 'No more request expected')); + + function cancelRequest() { + if (complete) { + if ($browser && timeoutId) { + $browser.defer.cancel(timeoutId); + } else { + for (var i = 0, l = responses.length; i < l; i++) { + if (complete === responses[i]) responses.splice(i, 1); + } + } + callback(-1, null, null); + complete = null; + } + } + + function completeRequest(request) { + return complete = function() { + var response = request.response(method, url, data, headers); + xhr.$$respHeaders = response[2]; + callback(response[0], response[1], xhr.getAllResponseHeaders()); + complete = null; + }; + } } /** diff --git a/test/ng/httpBackendSpec.js b/test/ng/httpBackendSpec.js index 018f641d8fdd..75567d87225f 100644 --- a/test/ng/httpBackendSpec.js +++ b/test/ng/httpBackendSpec.js @@ -96,6 +96,51 @@ describe('$httpBackend', function() { }); + it('should return a cancel function', function() { + callback.andCallFake(function(status, response) { + expect(status).toBe(-1); + }); + + var cancel = $backend('GET', '/url', null, callback); + expect(typeof cancel).toBe('function'); + + xhr = MockXhr.$$lastInstance; + spyOn(xhr, 'abort'); + + cancel(); + cancel(); + expect(xhr.abort).toHaveBeenCalledOnce(); + + xhr.status = 0; + xhr.readyState = 4; + xhr.onreadystatechange(); + + expect(callback).toHaveBeenCalledOnce(); + }); + + + it('should not cancel a completed request', function() { + callback.andCallFake(function(status, response) { + expect(status).toBe(200); + }); + + var cancel = $backend('GET', '/url', null, callback); + expect(typeof cancel).toBe('function'); + + xhr = MockXhr.$$lastInstance; + spyOn(xhr, 'abort'); + + xhr.status = 200; + xhr.readyState = 4; + xhr.onreadystatechange(); + + cancel(); + expect(xhr.abort).not.toHaveBeenCalled(); + + expect(callback).toHaveBeenCalledOnce(); + }); + + it('should abort request on timeout', function() { callback.andCallFake(function(status, response) { expect(status).toBe(-1); diff --git a/test/ng/httpSpec.js b/test/ng/httpSpec.js index e6d1cf4fb0f8..6c116a3c06c8 100644 --- a/test/ng/httpSpec.js +++ b/test/ng/httpSpec.js @@ -646,7 +646,7 @@ describe('$http', function() { }).respond(''); $http({url: '/url', method: 'GET', headers: { - 'Custom': 'header', + 'Custom': 'header' }}); $httpBackend.flush(); @@ -1327,6 +1327,119 @@ describe('$http', function() { $httpBackend.flush(); }); }); + + + describe('canceler', function() { + var cache, successFn, errorFn; + + beforeEach(inject(function($cacheFactory) { + cache = $cacheFactory(); + successFn = jasmine.createSpy('success'); + errorFn = jasmine.createSpy('error'); + })); + + it('should cancel pending requests in the same tick', function() { + var promise = $http({method: 'GET', url: 'some.html'}); + promise.success(successFn); + promise.error(function(data, status, headers) { + expect(data).toBeNull(); + expect(status).toBe(0); + expect(headers()).toEqual({}); + errorFn(); + }); + expect(promise.cancel('ignored')).toBe(true); + expect(function() { + $httpBackend.flush(); + }).toThrow('No pending request to flush !'); + expect(successFn).not.toHaveBeenCalled(); + expect(errorFn).toHaveBeenCalledOnce(); + expect(promise.cancel()).toBe(false); + }); + + it('should cancel pending requests in the next tick', function() { + $httpBackend.expect('GET', 'some.html').respond(200); + var promise = $http({method: 'GET', url: 'some.html'}); + promise.success(successFn); + promise.error(function(data, status, headers) { + expect(data).toBeNull(); + expect(status).toBe(0); + expect(headers()).toEqual({}); + errorFn(); + }); + $rootScope.$digest(); + expect(promise.cancel('ignored')).toBe(true); + expect(function() { + $httpBackend.flush(); + }).toThrow('No pending request to flush !'); + expect(successFn).not.toHaveBeenCalled(); + expect(errorFn).toHaveBeenCalledOnce(); + expect(promise.cancel()).toBe(false); + }); + + it('should not cancel resolved requests', function() { + $httpBackend.expect('GET', 'some.html').respond(200); + var promise = $http({method: 'GET', url: 'some.html'}); + promise.error(errorFn); + promise.success(function(data, status, headers) { + expect(data).toBeUndefined(); + expect(status).toBe(200); + expect(headers()).toEqual({}); + successFn(); + }); + $httpBackend.flush(); + expect(promise.cancel('ignored')).toBe(false); + expect(function() { + $httpBackend.flush(); + }).toThrow('No pending request to flush !'); + expect(errorFn).not.toHaveBeenCalled(); + expect(successFn).toHaveBeenCalledOnce(); + expect(promise.cancel()).toBe(false); + }); + + it('should cancel cache requests', function() { + cache.put('/alreadyCachedURL', 'content'); + var promise = $http.get('/alreadyCachedURL', {cache: cache}); + promise.success(successFn); + promise.error(function(data, status, headers) { + expect(data).toBeNull(); + expect(status).toBe(0); + expect(headers()).toEqual({}); + errorFn(); + }); + expect(promise.cancel('ignored')).toBe(true); + expect(function() { + $httpBackend.flush(); + }).toThrow('No pending request to flush !'); + expect(successFn).not.toHaveBeenCalled(); + expect(errorFn).toHaveBeenCalledOnce(); + expect(promise.cancel()).toBe(false); + }); + + it('should not cancel resolved cache requests', function() { + cache.put('/alreadyCachedURL', 'content'); + var promise = $http.get('/alreadyCachedURL', {cache: cache}); + promise.error(errorFn); + promise.success(function(data, status, headers) { + expect(data).toBe('content'); + expect(status).toBe(200); + expect(headers()).toEqual({}); + successFn(); + }); + $rootScope.$digest(); + expect(promise.cancel('ignored')).toBe(false); + expect(errorFn).not.toHaveBeenCalled(); + expect(successFn).toHaveBeenCalledOnce(); + expect(promise.cancel()).toBe(false); + }); + + it('should not $apply if canceled', function() { + $httpBackend.expect('GET', 'some.html').respond(200); + var promise = $http({method: 'GET', url: 'some.html'}); + $rootScope.$digest(); + expect(promise.cancel()).toBe(true); + expect($rootScope.$apply).not.toHaveBeenCalled(); + }); + }); }); diff --git a/test/ngMock/angular-mocksSpec.js b/test/ngMock/angular-mocksSpec.js index 176c5c920ea4..2ab212853666 100644 --- a/test/ngMock/angular-mocksSpec.js +++ b/test/ngMock/angular-mocksSpec.js @@ -818,8 +818,46 @@ describe('ngMock', function() { hb.when('JSONP', '/url1').respond(200); hb.expect('JSONP', '/url2').respond(200); - expect(hb('JSONP', '/url1')).toBeUndefined(); - expect(hb('JSONP', '/url2')).toBeUndefined(); + hb('JSONP', '/url1', null, callback); + hb('JSONP', '/url2', null, callback); + + hb.flush(); + expect(callback).toHaveBeenCalledWith(200, undefined, ''); + hb.verifyNoOutstandingRequest(); + }); + + + it('should return a cancel function', function() { + hb.when('GET', '/url').respond(200, '', {}); + + var cancel = hb('GET', '/url', null, callback); + expect(typeof cancel).toBe('function'); + + cancel(); + cancel(); + + expect(function() { + hb.flush(); + }).toThrow('No pending request to flush !'); + expect(callback).toHaveBeenCalledOnceWith(-1, null, null); + hb.verifyNoOutstandingRequest(); + }); + + + it('should not cancel a completed request', function() { + hb.when('GET', '/url').respond(200, '', {}); + + var cancel = hb('GET', '/url', null, callback); + expect(typeof cancel).toBe('function'); + + hb.flush(); + cancel(); + + expect(function() { + hb.flush(); + }).toThrow('No pending request to flush !'); + expect(callback).toHaveBeenCalledOnceWith(200, '', ''); + hb.verifyNoOutstandingRequest(); });