fix($route): make asynchronous tasks count as pending requests

Protractor users were having a problem where if they had asynchonous code in a
`route.resolve` or `route.resolveRedirectTo` variable, Protractor was not
waiting for that code to complete before continuing. See
https://github.com/angular/protractor/issues/789#issuecomment-190983200 for
details.

This commit fixes it by ensuring that `$browser#outstandingRequestCount` is
properly increased/decreased while `$route` (asynchronously) processes a route.

Also, enhanced `ngMock` to wait for pending requests, before calling callbacks
from `$browser.notifyWhenNoOutstandingRequests()`.

Related to angular/protractor#789.

Closes #14159
This commit is contained in:
Sammy Jelin
2016-03-01 16:12:51 -08:00
committed by Georgios Kalpakas
parent 9c722cfcd2
commit c522a43f41
6 changed files with 307 additions and 8 deletions
+25 -7
View File
@@ -37,10 +37,30 @@ angular.mock.$Browser = function() {
self.$$lastUrl = self.$$url; // used by url polling fn
self.pollFns = [];
// TODO(vojta): remove this temporary api
self.$$completeOutstandingRequest = angular.noop;
self.$$incOutstandingRequestCount = angular.noop;
// Testability API
var outstandingRequestCount = 0;
var outstandingRequestCallbacks = [];
self.$$incOutstandingRequestCount = function() { outstandingRequestCount++; };
self.$$completeOutstandingRequest = function(fn) {
try {
fn();
} finally {
outstandingRequestCount--;
if (!outstandingRequestCount) {
while (outstandingRequestCallbacks.length) {
outstandingRequestCallbacks.pop()();
}
}
}
};
self.notifyWhenNoOutstandingRequests = function(callback) {
if (outstandingRequestCount) {
outstandingRequestCallbacks.push(callback);
} else {
callback();
}
};
// register url polling fn
@@ -65,6 +85,8 @@ angular.mock.$Browser = function() {
self.deferredNextId = 0;
self.defer = function(fn, delay) {
// Note that we do not use `$$incOutstandingRequestCount` or `$$completeOutstandingRequest`
// in this mock implementation.
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;});
@@ -166,10 +188,6 @@ angular.mock.$Browser.prototype = {
state: function() {
return this.$$state;
},
notifyWhenNoOutstandingRequests: function(fn) {
fn();
}
};
+13 -1
View File
@@ -7,6 +7,7 @@
var isArray;
var isObject;
var isDefined;
var noop;
/**
* @ngdoc module
@@ -54,6 +55,7 @@ function $RouteProvider() {
isArray = angular.isArray;
isObject = angular.isObject;
isDefined = angular.isDefined;
noop = angular.noop;
function inherit(parent, extra) {
return angular.extend(Object.create(parent), extra);
@@ -350,7 +352,8 @@ function $RouteProvider() {
'$injector',
'$templateRequest',
'$sce',
function($rootScope, $location, $routeParams, $q, $injector, $templateRequest, $sce) {
'$browser',
function($rootScope, $location, $routeParams, $q, $injector, $templateRequest, $sce, $browser) {
/**
* @ngdoc service
@@ -680,6 +683,8 @@ function $RouteProvider() {
var nextRoutePromise = $q.resolve(nextRoute);
$browser.$$incOutstandingRequestCount();
nextRoutePromise.
then(getRedirectionData).
then(handlePossibleRedirection).
@@ -700,6 +705,13 @@ function $RouteProvider() {
if (nextRoute === $route.current) {
$rootScope.$broadcast('$routeChangeError', nextRoute, lastRoute, error);
}
}).finally(function() {
// Because `commitRoute()` is called from a `$rootScope.$evalAsync` block (see
// `$locationWatch`), this `$$completeOutstandingRequest()` call will not cause
// `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);
});
}
}
@@ -0,0 +1,9 @@
<html ng-app="lettersApp">
<body>
<div ng-view></div>
<script src="angular.js"></script>
<script src="angular-route.js"></script>
<script src="script.js"></script>
</body>
</html>
@@ -0,0 +1,43 @@
'use strict';
angular.
module('lettersApp', ['ngRoute']).
config(function($routeProvider) {
$routeProvider.
otherwise(resolveRedirectTo('/foo1')).
when('/foo1', resolveRedirectTo('/bar1')).
when('/bar1', resolveRedirectTo('/baz1')).
when('/baz1', resolveRedirectTo('/qux1')).
when('/qux1', {
template: '<ul><li ng-repeat="letter in $resolve.letters">{{ letter }}</li></ul>',
resolve: resolveLetters()
}).
when('/foo2', resolveRedirectTo('/bar2')).
when('/bar2', resolveRedirectTo('/baz2')).
when('/baz2', resolveRedirectTo('/qux2')).
when('/qux2', {
template: '{{ $resolve.letters.length }}',
resolve: resolveLetters()
});
// Helpers
function resolveLetters() {
return {
letters: function($q) {
return $q(function(resolve) {
window.setTimeout(resolve, 1000, ['a', 'b', 'c', 'd', 'e']);
});
}
};
}
function resolveRedirectTo(path) {
return {
resolveRedirectTo: function($q) {
return $q(function(resolve) {
window.setTimeout(resolve, 250, path);
});
}
};
}
});
+33
View File
@@ -0,0 +1,33 @@
'use strict';
describe('ngRoute promises', function() {
beforeEach(function() {
loadFixture('ng-route-promise');
});
it('should wait for route promises', function() {
expect(element.all(by.tagName('li')).count()).toBe(5);
});
it('should time out if the promise takes long enough', function() {
// Don't try this at home kids, I'm a protractor dev
browser.manage().timeouts().setScriptTimeout(1500);
browser.waitForAngular().then(function() {
fail('waitForAngular() should have timed out, but didn\'t');
}, function(error) {
expect(error.message).toContain('Timed out waiting for asynchronous Angular tasks to finish');
});
});
it('should wait for route promises when navigating to another route', function() {
browser.setLocation('/foo2');
expect(element(by.tagName('body')).getText()).toBe('5');
});
afterEach(function(done) {
// Restore old timeout limit
browser.getProcessedConfig().then(function(config) {
return browser.manage().timeouts().setScriptTimeout(config.allScriptsTimeout);
}).then(done);
});
});
+184
View File
@@ -2082,4 +2082,188 @@ describe('$route', function() {
expect(function() { $route.updateParams(); }).toThrowMinErr('ngRoute', 'norout');
}));
});
describe('testability', function() {
it('should wait for $resolve promises before calling callbacks', function() {
var deferred;
module(function($provide, $routeProvider) {
$routeProvider.when('/path', {
template: '',
resolve: {
a: function($q) {
deferred = $q.defer();
return deferred.promise;
}
}
});
});
inject(function($location, $route, $rootScope, $httpBackend, $$testability) {
$location.path('/path');
$rootScope.$digest();
var callback = jasmine.createSpy('callback');
$$testability.whenStable(callback);
expect(callback).not.toHaveBeenCalled();
deferred.resolve();
$rootScope.$digest();
expect(callback).toHaveBeenCalled();
});
});
it('should call callback after $resolve promises are rejected', function() {
var deferred;
module(function($provide, $routeProvider) {
$routeProvider.when('/path', {
template: '',
resolve: {
a: function($q) {
deferred = $q.defer();
return deferred.promise;
}
}
});
});
inject(function($location, $route, $rootScope, $httpBackend, $$testability) {
$location.path('/path');
$rootScope.$digest();
var callback = jasmine.createSpy('callback');
$$testability.whenStable(callback);
expect(callback).not.toHaveBeenCalled();
deferred.reject();
$rootScope.$digest();
expect(callback).toHaveBeenCalled();
});
});
it('should wait for resolveRedirectTo promises before calling callbacks', function() {
var deferred;
module(function($provide, $routeProvider) {
$routeProvider.when('/path', {
resolveRedirectTo: function($q) {
deferred = $q.defer();
return deferred.promise;
}
});
});
inject(function($location, $route, $rootScope, $httpBackend, $$testability) {
$location.path('/path');
$rootScope.$digest();
var callback = jasmine.createSpy('callback');
$$testability.whenStable(callback);
expect(callback).not.toHaveBeenCalled();
deferred.resolve();
$rootScope.$digest();
expect(callback).toHaveBeenCalled();
});
});
it('should call callback after resolveRedirectTo promises are rejected', function() {
var deferred;
module(function($provide, $routeProvider) {
$routeProvider.when('/path', {
resolveRedirectTo: function($q) {
deferred = $q.defer();
return deferred.promise;
}
});
});
inject(function($location, $route, $rootScope, $httpBackend, $$testability) {
$location.path('/path');
$rootScope.$digest();
var callback = jasmine.createSpy('callback');
$$testability.whenStable(callback);
expect(callback).not.toHaveBeenCalled();
deferred.reject();
$rootScope.$digest();
expect(callback).toHaveBeenCalled();
});
});
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;
});
addRouteWithAsyncRedirect('/foo', '/bar');
addRouteWithAsyncRedirect('/bar', '/baz');
addRouteWithAsyncRedirect('/baz', '/qux');
$routeProvider.when('/qux', {
template: '',
resolve: {
a: function($q) {
var deferred = deferreds['/qux'] = $q.defer();
return deferred.promise;
}
}
});
// Helpers
function addRouteWithAsyncRedirect(fromPath, toPath) {
$routeProvider.when(fromPath, {
resolveRedirectTo: function($q) {
var deferred = deferreds[fromPath] = $q.defer();
return deferred.promise.then(function() { return toPath; });
}
});
}
});
inject(function($browser, $location, $rootScope, $route, $$testability) {
$location.path('/foo');
$rootScope.$digest();
var callback = jasmine.createSpy('callback');
$$testability.whenStable(callback);
expect(callback).not.toHaveBeenCalled();
deferreds['/foo'].resolve();
$browser.defer.flush();
expect(callback).not.toHaveBeenCalled();
deferreds['/bar'].resolve();
$browser.defer.flush();
expect(callback).not.toHaveBeenCalled();
deferreds['/baz'].resolve();
$browser.defer.flush();
expect(callback).not.toHaveBeenCalled();
deferreds['/qux'].resolve();
$browser.defer.flush();
expect(callback).toHaveBeenCalled();
});
});
});
});