4a3ae43407
Calls to `$browser.url` now normalize the inputted URL ensuring multiple calls only differing in formatting do not force a browser `pushState`. Normalization is done the same as the browser location URL and may differ per browser and may be changed by browsers. Today no browsers fully normalize URLs so this does not fix all instances of this issue. See #16100 Closes #16606
1178 lines
39 KiB
JavaScript
1178 lines
39 KiB
JavaScript
'use strict';
|
|
|
|
/* global getHash:true, stripHash:true */
|
|
|
|
var historyEntriesLength;
|
|
var sniffer = {};
|
|
|
|
function MockWindow(options) {
|
|
if (typeof options !== 'object') {
|
|
options = {};
|
|
}
|
|
var events = {};
|
|
var timeouts = this.timeouts = [];
|
|
var locationHref = window.document.createElement('a');
|
|
var committedHref = window.document.createElement('a');
|
|
locationHref.href = committedHref.href = 'http://server/';
|
|
var mockWindow = this;
|
|
var msie = options.msie;
|
|
var ieState;
|
|
|
|
historyEntriesLength = 1;
|
|
|
|
function replaceHash(href, hash) {
|
|
// replace the hash with the new one (stripping off a leading hash if there is one)
|
|
// See hash setter spec: https://url.spec.whatwg.org/#urlutils-and-urlutilsreadonly-members
|
|
return stripHash(href) + '#' + hash.replace(/^#/,'');
|
|
}
|
|
|
|
|
|
this.setTimeout = function(fn) {
|
|
return timeouts.push(fn) - 1;
|
|
};
|
|
|
|
this.clearTimeout = function(id) {
|
|
timeouts[id] = noop;
|
|
};
|
|
|
|
this.setTimeout.flush = function(count) {
|
|
count = count || timeouts.length;
|
|
while (count-- > 0) timeouts.shift()();
|
|
};
|
|
|
|
this.addEventListener = function(name, listener) {
|
|
if (angular.isUndefined(events[name])) events[name] = [];
|
|
events[name].push(listener);
|
|
};
|
|
|
|
this.removeEventListener = noop;
|
|
|
|
this.fire = function(name) {
|
|
forEach(events[name], function(fn) {
|
|
// type/target to make jQuery happy
|
|
fn({
|
|
type: name,
|
|
target: {
|
|
nodeType: 1
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
this.location = {
|
|
get href() {
|
|
return committedHref.href;
|
|
},
|
|
set href(value) {
|
|
locationHref.href = value;
|
|
mockWindow.history.state = null;
|
|
historyEntriesLength++;
|
|
if (!options.updateAsync) this.flushHref();
|
|
},
|
|
get hash() {
|
|
return getHash(committedHref.href);
|
|
},
|
|
set hash(value) {
|
|
locationHref.href = replaceHash(locationHref.href, value);
|
|
if (!options.updateAsync) this.flushHref();
|
|
},
|
|
replace: function(url) {
|
|
locationHref.href = url;
|
|
mockWindow.history.state = null;
|
|
if (!options.updateAsync) this.flushHref();
|
|
},
|
|
flushHref: function() {
|
|
committedHref.href = locationHref.href;
|
|
}
|
|
};
|
|
|
|
this.history = {
|
|
pushState: function() {
|
|
this.replaceState.apply(this, arguments);
|
|
historyEntriesLength++;
|
|
},
|
|
replaceState: function(state, title, url) {
|
|
locationHref.href = url;
|
|
if (!options.updateAsync) committedHref.href = locationHref.href;
|
|
mockWindow.history.state = copy(state);
|
|
if (!options.updateAsync) this.flushHref();
|
|
},
|
|
flushHref: function() {
|
|
committedHref.href = locationHref.href;
|
|
}
|
|
};
|
|
// IE 10-11 deserialize history.state on each read making subsequent reads
|
|
// different object.
|
|
if (!msie) {
|
|
this.history.state = null;
|
|
} else {
|
|
ieState = null;
|
|
Object.defineProperty(this.history, 'state', {
|
|
get: function() {
|
|
return copy(ieState);
|
|
},
|
|
set: function(value) {
|
|
ieState = value;
|
|
},
|
|
configurable: true,
|
|
enumerable: true
|
|
});
|
|
}
|
|
}
|
|
|
|
function MockDocument() {
|
|
var self = this;
|
|
|
|
this[0] = window.document;
|
|
this.basePath = '/';
|
|
|
|
this.find = function(name) {
|
|
if (name === 'base') {
|
|
return {
|
|
attr: function(name) {
|
|
if (name === 'href') {
|
|
return self.basePath;
|
|
} else {
|
|
throw new Error(name);
|
|
}
|
|
}
|
|
};
|
|
} else {
|
|
throw new Error(name);
|
|
}
|
|
};
|
|
}
|
|
|
|
describe('browser', function() {
|
|
/* global Browser: false, TaskTracker: false */
|
|
var browser, fakeWindow, fakeDocument, fakeLog, logs, taskTrackerFactory;
|
|
|
|
beforeEach(function() {
|
|
sniffer = {history: true};
|
|
fakeWindow = new MockWindow();
|
|
fakeDocument = new MockDocument();
|
|
taskTrackerFactory = function(log) { return new TaskTracker(log); };
|
|
|
|
logs = {log:[], warn:[], info:[], error:[]};
|
|
|
|
fakeLog = {
|
|
log: function() { logs.log.push(slice.call(arguments)); },
|
|
warn: function() { logs.warn.push(slice.call(arguments)); },
|
|
info: function() { logs.info.push(slice.call(arguments)); },
|
|
error: function() { logs.error.push(slice.call(arguments)); }
|
|
};
|
|
|
|
|
|
browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer, taskTrackerFactory);
|
|
});
|
|
|
|
describe('MockBrowser', function() {
|
|
describe('historyEntriesLength', function() {
|
|
it('should increment historyEntriesLength when setting location.href', function() {
|
|
expect(historyEntriesLength).toBe(1);
|
|
fakeWindow.location.href = '/foo';
|
|
expect(historyEntriesLength).toBe(2);
|
|
});
|
|
|
|
it('should not increment historyEntriesLength when using location.replace', function() {
|
|
expect(historyEntriesLength).toBe(1);
|
|
fakeWindow.location.replace('/foo');
|
|
expect(historyEntriesLength).toBe(1);
|
|
});
|
|
|
|
it('should increment historyEntriesLength when using history.pushState', function() {
|
|
expect(historyEntriesLength).toBe(1);
|
|
fakeWindow.history.pushState({a: 2}, 'foo', '/bar');
|
|
expect(historyEntriesLength).toBe(2);
|
|
});
|
|
|
|
it('should not increment historyEntriesLength when using history.replaceState', function() {
|
|
expect(historyEntriesLength).toBe(1);
|
|
fakeWindow.history.replaceState({a: 2}, 'foo', '/bar');
|
|
expect(historyEntriesLength).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('in IE', runTests({msie: true}));
|
|
describe('not in IE', runTests({msie: false}));
|
|
|
|
function runTests(options) {
|
|
return function() {
|
|
it('should return the same state object on every read', function() {
|
|
var msie = options.msie;
|
|
|
|
fakeWindow = new MockWindow({msie: msie});
|
|
fakeWindow.location.state = {prop: 'val'};
|
|
browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer, taskTrackerFactory);
|
|
|
|
browser.url(fakeWindow.location.href, false, {prop: 'val'});
|
|
if (msie) {
|
|
expect(fakeWindow.history.state).not.toBe(fakeWindow.history.state);
|
|
expect(fakeWindow.history.state).toEqual(fakeWindow.history.state);
|
|
} else {
|
|
expect(fakeWindow.history.state).toBe(fakeWindow.history.state);
|
|
}
|
|
});
|
|
};
|
|
}
|
|
});
|
|
|
|
|
|
describe('notifyWhenNoOutstandingRequests', function() {
|
|
it('should invoke callbacks immediately if there are no pending tasks', function() {
|
|
var callback = jasmine.createSpy('callback');
|
|
browser.notifyWhenNoOutstandingRequests(callback);
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
|
|
|
|
it('should invoke callbacks immediately if there are no pending tasks (for specific task-type)',
|
|
function() {
|
|
var callbackAll = jasmine.createSpy('callbackAll');
|
|
var callbackFoo = jasmine.createSpy('callbackFoo');
|
|
|
|
browser.$$incOutstandingRequestCount();
|
|
browser.notifyWhenNoOutstandingRequests(callbackAll);
|
|
browser.notifyWhenNoOutstandingRequests(callbackFoo, 'foo');
|
|
|
|
expect(callbackAll).not.toHaveBeenCalled();
|
|
expect(callbackFoo).toHaveBeenCalled();
|
|
}
|
|
);
|
|
|
|
|
|
it('should invoke callbacks as soon as there are no pending tasks', function() {
|
|
var callback = jasmine.createSpy('callback');
|
|
|
|
browser.$$incOutstandingRequestCount();
|
|
browser.notifyWhenNoOutstandingRequests(callback);
|
|
expect(callback).not.toHaveBeenCalled();
|
|
|
|
browser.$$completeOutstandingRequest(noop);
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
|
|
|
|
it('should invoke callbacks as soon as there are no pending tasks (for specific task-type)',
|
|
function() {
|
|
var callbackAll = jasmine.createSpy('callbackAll');
|
|
var callbackFoo = jasmine.createSpy('callbackFoo');
|
|
|
|
browser.$$incOutstandingRequestCount();
|
|
browser.$$incOutstandingRequestCount('foo');
|
|
browser.notifyWhenNoOutstandingRequests(callbackAll);
|
|
browser.notifyWhenNoOutstandingRequests(callbackFoo, 'foo');
|
|
|
|
expect(callbackAll).not.toHaveBeenCalled();
|
|
expect(callbackFoo).not.toHaveBeenCalled();
|
|
|
|
browser.$$completeOutstandingRequest(noop, 'foo');
|
|
|
|
expect(callbackAll).not.toHaveBeenCalled();
|
|
expect(callbackFoo).toHaveBeenCalledOnce();
|
|
|
|
browser.$$completeOutstandingRequest(noop);
|
|
|
|
expect(callbackAll).toHaveBeenCalledOnce();
|
|
expect(callbackFoo).toHaveBeenCalledOnce();
|
|
}
|
|
);
|
|
});
|
|
|
|
|
|
describe('defer', function() {
|
|
it('should execute fn asynchronously via setTimeout', function() {
|
|
var callback = jasmine.createSpy('deferred');
|
|
|
|
browser.defer(callback);
|
|
expect(callback).not.toHaveBeenCalled();
|
|
|
|
fakeWindow.setTimeout.flush();
|
|
expect(callback).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
|
|
it('should update outstandingRequests counter', function() {
|
|
var noPendingTasksSpy = jasmine.createSpy('noPendingTasks');
|
|
|
|
browser.defer(noop);
|
|
browser.notifyWhenNoOutstandingRequests(noPendingTasksSpy);
|
|
expect(noPendingTasksSpy).not.toHaveBeenCalled();
|
|
|
|
fakeWindow.setTimeout.flush();
|
|
expect(noPendingTasksSpy).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
|
|
it('should update outstandingRequests counter (for specific task-type)', function() {
|
|
var noPendingFooTasksSpy = jasmine.createSpy('noPendingFooTasks');
|
|
var noPendingTasksSpy = jasmine.createSpy('noPendingTasks');
|
|
|
|
browser.defer(noop, 0, 'foo');
|
|
browser.defer(noop, 0, 'bar');
|
|
|
|
browser.notifyWhenNoOutstandingRequests(noPendingFooTasksSpy, 'foo');
|
|
browser.notifyWhenNoOutstandingRequests(noPendingTasksSpy);
|
|
expect(noPendingFooTasksSpy).not.toHaveBeenCalled();
|
|
expect(noPendingTasksSpy).not.toHaveBeenCalled();
|
|
|
|
fakeWindow.setTimeout.flush(1);
|
|
expect(noPendingFooTasksSpy).toHaveBeenCalledOnce();
|
|
expect(noPendingTasksSpy).not.toHaveBeenCalled();
|
|
|
|
fakeWindow.setTimeout.flush(1);
|
|
expect(noPendingFooTasksSpy).toHaveBeenCalledOnce();
|
|
expect(noPendingTasksSpy).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
|
|
it('should return unique deferId', function() {
|
|
var deferId1 = browser.defer(noop),
|
|
deferId2 = browser.defer(noop);
|
|
|
|
expect(deferId1).toBeDefined();
|
|
expect(deferId2).toBeDefined();
|
|
expect(deferId1).not.toEqual(deferId2);
|
|
});
|
|
|
|
|
|
describe('cancel', function() {
|
|
it('should allow tasks to be canceled with returned deferId', function() {
|
|
var log = [],
|
|
deferId1 = browser.defer(function() { log.push('cancel me'); }),
|
|
deferId2 = browser.defer(function() { log.push('ok'); }),
|
|
deferId3 = browser.defer(function() { log.push('cancel me, now!'); });
|
|
|
|
expect(log).toEqual([]);
|
|
expect(browser.defer.cancel(deferId1)).toBe(true);
|
|
expect(browser.defer.cancel(deferId3)).toBe(true);
|
|
fakeWindow.setTimeout.flush();
|
|
expect(log).toEqual(['ok']);
|
|
expect(browser.defer.cancel(deferId2)).toBe(false);
|
|
});
|
|
|
|
|
|
it('should update outstandingRequests counter', function() {
|
|
var noPendingTasksSpy = jasmine.createSpy('noPendingTasks');
|
|
var deferId = browser.defer(noop);
|
|
|
|
browser.notifyWhenNoOutstandingRequests(noPendingTasksSpy);
|
|
expect(noPendingTasksSpy).not.toHaveBeenCalled();
|
|
|
|
browser.defer.cancel(deferId);
|
|
expect(noPendingTasksSpy).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
|
|
it('should update outstandingRequests counter (for specific task-type)', function() {
|
|
var noPendingFooTasksSpy = jasmine.createSpy('noPendingFooTasks');
|
|
var noPendingTasksSpy = jasmine.createSpy('noPendingTasks');
|
|
|
|
var deferId1 = browser.defer(noop, 0, 'foo');
|
|
var deferId2 = browser.defer(noop, 0, 'bar');
|
|
|
|
browser.notifyWhenNoOutstandingRequests(noPendingFooTasksSpy, 'foo');
|
|
browser.notifyWhenNoOutstandingRequests(noPendingTasksSpy);
|
|
expect(noPendingFooTasksSpy).not.toHaveBeenCalled();
|
|
expect(noPendingTasksSpy).not.toHaveBeenCalled();
|
|
|
|
browser.defer.cancel(deferId1);
|
|
expect(noPendingFooTasksSpy).toHaveBeenCalledOnce();
|
|
expect(noPendingTasksSpy).not.toHaveBeenCalled();
|
|
|
|
browser.defer.cancel(deferId2);
|
|
expect(noPendingFooTasksSpy).toHaveBeenCalledOnce();
|
|
expect(noPendingTasksSpy).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
});
|
|
|
|
|
|
describe('url', function() {
|
|
var pushState, replaceState, locationReplace;
|
|
|
|
beforeEach(function() {
|
|
pushState = spyOn(fakeWindow.history, 'pushState');
|
|
replaceState = spyOn(fakeWindow.history, 'replaceState');
|
|
locationReplace = spyOn(fakeWindow.location, 'replace');
|
|
});
|
|
|
|
it('should return current location.href', function() {
|
|
fakeWindow.location.href = 'http://test.com';
|
|
expect(browser.url()).toEqual('http://test.com/');
|
|
|
|
fakeWindow.location.href = 'https://another.com';
|
|
expect(browser.url()).toEqual('https://another.com/');
|
|
});
|
|
|
|
it('should strip an empty hash fragment', function() {
|
|
fakeWindow.location.href = 'http://test.com/#';
|
|
expect(browser.url()).toEqual('http://test.com/');
|
|
|
|
fakeWindow.location.href = 'https://another.com/#foo';
|
|
expect(browser.url()).toEqual('https://another.com/#foo');
|
|
});
|
|
|
|
it('should use history.pushState when available', function() {
|
|
sniffer.history = true;
|
|
browser.url('http://new.org');
|
|
|
|
expect(pushState).toHaveBeenCalledOnce();
|
|
expect(pushState.calls.argsFor(0)[2]).toEqual('http://new.org/');
|
|
|
|
expect(replaceState).not.toHaveBeenCalled();
|
|
expect(locationReplace).not.toHaveBeenCalled();
|
|
expect(fakeWindow.location.href).toEqual('http://server/');
|
|
});
|
|
|
|
it('should use history.replaceState when available', function() {
|
|
sniffer.history = true;
|
|
browser.url('http://new.org', true);
|
|
|
|
expect(replaceState).toHaveBeenCalledOnce();
|
|
expect(replaceState.calls.argsFor(0)[2]).toEqual('http://new.org/');
|
|
|
|
expect(pushState).not.toHaveBeenCalled();
|
|
expect(locationReplace).not.toHaveBeenCalled();
|
|
expect(fakeWindow.location.href).toEqual('http://server/');
|
|
});
|
|
|
|
it('should set location.href when pushState not available', function() {
|
|
sniffer.history = false;
|
|
browser.url('http://new.org');
|
|
|
|
expect(fakeWindow.location.href).toEqual('http://new.org/');
|
|
|
|
expect(pushState).not.toHaveBeenCalled();
|
|
expect(replaceState).not.toHaveBeenCalled();
|
|
expect(locationReplace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should set location.href and not use pushState when the url only changed in the hash fragment to please IE10/11', function() {
|
|
sniffer.history = true;
|
|
browser.url('http://server/#123');
|
|
|
|
expect(fakeWindow.location.href).toEqual('http://server/#123');
|
|
|
|
expect(pushState).not.toHaveBeenCalled();
|
|
expect(replaceState).not.toHaveBeenCalled();
|
|
expect(locationReplace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should retain the # character when the only change is clearing the hash fragment, to prevent page reload', function() {
|
|
sniffer.history = true;
|
|
|
|
browser.url('http://server/#123');
|
|
expect(fakeWindow.location.href).toEqual('http://server/#123');
|
|
|
|
browser.url('http://server/');
|
|
expect(fakeWindow.location.href).toEqual('http://server/#');
|
|
|
|
});
|
|
|
|
it('should use location.replace when history.replaceState not available', function() {
|
|
sniffer.history = false;
|
|
browser.url('http://new.org', true);
|
|
|
|
expect(locationReplace).toHaveBeenCalledWith('http://new.org/');
|
|
|
|
expect(pushState).not.toHaveBeenCalled();
|
|
expect(replaceState).not.toHaveBeenCalled();
|
|
expect(fakeWindow.location.href).toEqual('http://server/');
|
|
});
|
|
|
|
|
|
it('should use location.replace and not use replaceState when the url only changed in the hash fragment to please IE10/11', function() {
|
|
sniffer.history = true;
|
|
browser.url('http://server/#123', true);
|
|
|
|
expect(locationReplace).toHaveBeenCalledWith('http://server/#123');
|
|
|
|
expect(pushState).not.toHaveBeenCalled();
|
|
expect(replaceState).not.toHaveBeenCalled();
|
|
expect(fakeWindow.location.href).toEqual('http://server/');
|
|
});
|
|
|
|
|
|
it('should return $browser to allow chaining', function() {
|
|
expect(browser.url('http://any.com')).toBe(browser);
|
|
});
|
|
|
|
it('should return $browser to allow chaining even if the previous and current URLs and states match', function() {
|
|
expect(browser.url('http://any.com').url('http://any.com')).toBe(browser);
|
|
var state = { any: 'foo' };
|
|
expect(browser.url('http://any.com', false, state).url('http://any.com', false, state)).toBe(browser);
|
|
expect(browser.url('http://any.com', true, state).url('http://any.com', true, state)).toBe(browser);
|
|
});
|
|
|
|
it('should not set URL when the URL is already set', function() {
|
|
var current = fakeWindow.location.href;
|
|
sniffer.history = false;
|
|
fakeWindow.location.href = 'http://dontchange/';
|
|
browser.url(current);
|
|
expect(fakeWindow.location.href).toBe('http://dontchange/');
|
|
});
|
|
|
|
it('should not read out location.href if a reload was triggered but still allow to change the url', function() {
|
|
sniffer.history = false;
|
|
browser.url('http://server/someOtherUrlThatCausesReload');
|
|
expect(fakeWindow.location.href).toBe('http://server/someOtherUrlThatCausesReload');
|
|
|
|
fakeWindow.location.href = 'http://someNewUrl';
|
|
expect(browser.url()).toBe('http://server/someOtherUrlThatCausesReload');
|
|
|
|
browser.url('http://server/someOtherUrl');
|
|
expect(browser.url()).toBe('http://server/someOtherUrl');
|
|
expect(fakeWindow.location.href).toBe('http://server/someOtherUrl');
|
|
});
|
|
|
|
it('assumes that changes to location.hash occur in sync', function(done) {
|
|
// This is an asynchronous integration test that changes the
|
|
// hash in all possible ways and checks
|
|
// - whether the change to the hash can be read out in sync
|
|
// - whether the change to the hash can be read out in the hashchange event
|
|
var realWin = window,
|
|
$realWin = jqLite(realWin),
|
|
hashInHashChangeEvent = [];
|
|
|
|
var job = createAsync(done);
|
|
job.runs(function() {
|
|
$realWin.on('hashchange', hashListener);
|
|
|
|
realWin.location.hash = '1';
|
|
realWin.location.href += '2';
|
|
realWin.location.replace(realWin.location.href + '3');
|
|
realWin.location.assign(realWin.location.href + '4');
|
|
|
|
expect(realWin.location.hash).toBe('#1234');
|
|
})
|
|
.waitsFor(function() {
|
|
return hashInHashChangeEvent.length > 3;
|
|
})
|
|
.runs(function() {
|
|
$realWin.off('hashchange', hashListener);
|
|
|
|
forEach(hashInHashChangeEvent, function(hash) {
|
|
expect(hash).toBe('#1234');
|
|
});
|
|
}).done();
|
|
job.start();
|
|
|
|
function hashListener() {
|
|
hashInHashChangeEvent.push(realWin.location.hash);
|
|
}
|
|
});
|
|
|
|
});
|
|
|
|
describe('url (with ie 11 weirdnesses)', function() {
|
|
|
|
it('url() should actually set the url, even if IE 11 is weird and replaces HTML entities in the URL', function() {
|
|
// this test can not be expressed with the Jasmine spies in the previous describe block, because $browser.url()
|
|
// needs to observe the change to location.href during its invocation to enter the failing code path, but the spies
|
|
// are not callThrough
|
|
|
|
sniffer.history = true;
|
|
var originalReplace = fakeWindow.location.replace;
|
|
fakeWindow.location.replace = function(url) {
|
|
url = url.replace('¬', '¬');
|
|
// I really don't know why IE 11 (sometimes) does this, but I am not the only one to notice:
|
|
// https://connect.microsoft.com/IE/feedback/details/1040980/bug-in-ie-which-interprets-document-location-href-as-html
|
|
originalReplace.call(this, url);
|
|
};
|
|
|
|
// the initial URL contains a lengthy oauth token in the hash
|
|
var initialUrl = 'http://test.com/oauthcallback#state=xxx%3D¬-before-policy=0';
|
|
fakeWindow.location.href = initialUrl;
|
|
browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer, taskTrackerFactory);
|
|
|
|
// somehow, $location gets a version of this url where the = is no longer escaped, and tells the browser:
|
|
var initialUrlFixedByLocation = initialUrl.replace('%3D', '=');
|
|
browser.url(initialUrlFixedByLocation, true, null);
|
|
expect(browser.url()).toEqual(initialUrlFixedByLocation);
|
|
|
|
// a little later (but in the same digest cycle) the view asks $location to replace the url, which tells $browser
|
|
var secondUrl = 'http://test.com/otherView';
|
|
browser.url(secondUrl, true, null);
|
|
expect(browser.url()).toEqual(secondUrl);
|
|
});
|
|
|
|
});
|
|
|
|
describe('url (when state passed)', function() {
|
|
var currentHref, pushState, replaceState, locationReplace;
|
|
|
|
beforeEach(function() {
|
|
});
|
|
|
|
describe('in IE', runTests({msie: true}));
|
|
describe('not in IE', runTests({msie: false}));
|
|
|
|
function runTests(options) {
|
|
return function() {
|
|
beforeEach(function() {
|
|
sniffer = {history: true};
|
|
|
|
fakeWindow = new MockWindow({msie: options.msie});
|
|
currentHref = fakeWindow.location.href;
|
|
pushState = spyOn(fakeWindow.history, 'pushState').and.callThrough();
|
|
replaceState = spyOn(fakeWindow.history, 'replaceState').and.callThrough();
|
|
locationReplace = spyOn(fakeWindow.location, 'replace').and.callThrough();
|
|
|
|
browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer, taskTrackerFactory);
|
|
browser.onUrlChange(function() {});
|
|
});
|
|
|
|
it('should change state', function() {
|
|
browser.url(currentHref, false, {prop: 'val1'});
|
|
expect(fakeWindow.history.state).toEqual({prop: 'val1'});
|
|
browser.url(currentHref + '/something', false, {prop: 'val2'});
|
|
expect(fakeWindow.history.state).toEqual({prop: 'val2'});
|
|
});
|
|
|
|
it('should allow to set falsy states (except `undefined`)', function() {
|
|
fakeWindow.history.state = {prop: 'val1'};
|
|
fakeWindow.fire('popstate');
|
|
|
|
browser.url(currentHref, false, null);
|
|
expect(fakeWindow.history.state).toBe(null);
|
|
|
|
browser.url(currentHref, false, false);
|
|
expect(fakeWindow.history.state).toBe(false);
|
|
|
|
browser.url(currentHref, false, '');
|
|
expect(fakeWindow.history.state).toBe('');
|
|
|
|
browser.url(currentHref, false, 0);
|
|
expect(fakeWindow.history.state).toBe(0);
|
|
});
|
|
|
|
it('should treat `undefined` state as `null`', function() {
|
|
fakeWindow.history.state = {prop: 'val1'};
|
|
fakeWindow.fire('popstate');
|
|
|
|
browser.url(currentHref, false, undefined);
|
|
expect(fakeWindow.history.state).toBe(null);
|
|
});
|
|
|
|
it('should do pushState with the same URL and a different state', function() {
|
|
browser.url(currentHref, false, {prop: 'val1'});
|
|
expect(fakeWindow.history.state).toEqual({prop: 'val1'});
|
|
|
|
browser.url(currentHref, false, null);
|
|
expect(fakeWindow.history.state).toBe(null);
|
|
|
|
browser.url(currentHref, false, {prop: 'val2'});
|
|
browser.url(currentHref, false, {prop: 'val3'});
|
|
expect(fakeWindow.history.state).toEqual({prop: 'val3'});
|
|
});
|
|
|
|
it('should do pushState with the same URL and deep equal but referentially different state', function() {
|
|
fakeWindow.history.state = {prop: 'val'};
|
|
fakeWindow.fire('popstate');
|
|
expect(historyEntriesLength).toBe(1);
|
|
|
|
browser.url(currentHref, false, {prop: 'val'});
|
|
expect(fakeWindow.history.state).toEqual({prop: 'val'});
|
|
expect(historyEntriesLength).toBe(2);
|
|
});
|
|
|
|
it('should not do pushState with the same URL and state from $browser.state()', function() {
|
|
browser.url(currentHref, false, {prop: 'val'});
|
|
|
|
pushState.calls.reset();
|
|
replaceState.calls.reset();
|
|
locationReplace.calls.reset();
|
|
|
|
browser.url(currentHref, false, browser.state());
|
|
expect(pushState).not.toHaveBeenCalled();
|
|
expect(replaceState).not.toHaveBeenCalled();
|
|
expect(locationReplace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not do pushState with a URL using relative protocol', function() {
|
|
browser.url('http://server/');
|
|
|
|
pushState.calls.reset();
|
|
replaceState.calls.reset();
|
|
locationReplace.calls.reset();
|
|
|
|
browser.url('//server');
|
|
expect(pushState).not.toHaveBeenCalled();
|
|
expect(replaceState).not.toHaveBeenCalled();
|
|
expect(locationReplace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not do pushState with a URL only adding a trailing slash after domain', function() {
|
|
// A domain without a trailing /
|
|
browser.url('http://server');
|
|
|
|
pushState.calls.reset();
|
|
replaceState.calls.reset();
|
|
locationReplace.calls.reset();
|
|
|
|
// A domain from something such as window.location.href with a trailing slash
|
|
browser.url('http://server/');
|
|
expect(pushState).not.toHaveBeenCalled();
|
|
expect(replaceState).not.toHaveBeenCalled();
|
|
expect(locationReplace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not do pushState with a URL only removing a trailing slash after domain', function() {
|
|
// A domain from something such as window.location.href with a trailing slash
|
|
browser.url('http://server/');
|
|
|
|
pushState.calls.reset();
|
|
replaceState.calls.reset();
|
|
locationReplace.calls.reset();
|
|
|
|
// A domain without a trailing /
|
|
browser.url('http://server');
|
|
expect(pushState).not.toHaveBeenCalled();
|
|
expect(replaceState).not.toHaveBeenCalled();
|
|
expect(locationReplace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should do pushState with a URL only adding a trailing slash after the path', function() {
|
|
browser.url('http://server/foo');
|
|
|
|
pushState.calls.reset();
|
|
replaceState.calls.reset();
|
|
locationReplace.calls.reset();
|
|
|
|
browser.url('http://server/foo/');
|
|
expect(pushState).toHaveBeenCalledOnce();
|
|
expect(fakeWindow.location.href).toEqual('http://server/foo/');
|
|
});
|
|
|
|
it('should do pushState with a URL only removing a trailing slash after the path', function() {
|
|
browser.url('http://server/foo/');
|
|
|
|
pushState.calls.reset();
|
|
replaceState.calls.reset();
|
|
locationReplace.calls.reset();
|
|
|
|
browser.url('http://server/foo');
|
|
expect(pushState).toHaveBeenCalledOnce();
|
|
expect(fakeWindow.location.href).toEqual('http://server/foo');
|
|
});
|
|
};
|
|
}
|
|
});
|
|
|
|
describe('state', function() {
|
|
var currentHref;
|
|
|
|
beforeEach(function() {
|
|
sniffer = {history: true};
|
|
currentHref = fakeWindow.location.href;
|
|
});
|
|
|
|
it('should not access `history.state` when `$sniffer.history` is false', function() {
|
|
// In the context of a Chrome Packaged App, although `history.state` is present, accessing it
|
|
// is not allowed and logs an error in the console. We should not try to access
|
|
// `history.state` in contexts where `$sniffer.history` is false.
|
|
|
|
var historyStateAccessed = false;
|
|
var mockSniffer = {history: false};
|
|
var mockWindow = new MockWindow();
|
|
|
|
var _state = mockWindow.history.state;
|
|
Object.defineProperty(mockWindow.history, 'state', {
|
|
get: function() {
|
|
historyStateAccessed = true;
|
|
return _state;
|
|
}
|
|
});
|
|
|
|
var browser = new Browser(mockWindow, fakeDocument, fakeLog, mockSniffer, taskTrackerFactory);
|
|
|
|
expect(historyStateAccessed).toBe(false);
|
|
});
|
|
|
|
describe('in IE', runTests({msie: true}));
|
|
describe('not in IE', runTests({msie: false}));
|
|
|
|
|
|
function runTests(options) {
|
|
return function() {
|
|
beforeEach(function() {
|
|
fakeWindow = new MockWindow({msie: options.msie});
|
|
browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer, taskTrackerFactory);
|
|
});
|
|
|
|
it('should return history.state', function() {
|
|
browser.url(currentHref, false, {prop: 'val'});
|
|
expect(browser.state()).toEqual({prop: 'val'});
|
|
browser.url(currentHref, false, 2);
|
|
expect(browser.state()).toEqual(2);
|
|
browser.url(currentHref, false, null);
|
|
expect(browser.state()).toEqual(null);
|
|
});
|
|
|
|
it('should return null if history.state is undefined', function() {
|
|
browser.url(currentHref, false, undefined);
|
|
expect(browser.state()).toBe(null);
|
|
});
|
|
|
|
it('should return the same state object in subsequent invocations in IE', function() {
|
|
browser.url(currentHref, false, {prop: 'val'});
|
|
expect(browser.state()).toBe(browser.state());
|
|
});
|
|
};
|
|
}
|
|
});
|
|
|
|
describe('urlChange', function() {
|
|
var callback;
|
|
|
|
beforeEach(function() {
|
|
callback = jasmine.createSpy('onUrlChange');
|
|
});
|
|
|
|
afterEach(function() {
|
|
if (!jQuery) jqLiteDealoc(fakeWindow);
|
|
});
|
|
|
|
it('should return registered callback', function() {
|
|
expect(browser.onUrlChange(callback)).toBe(callback);
|
|
});
|
|
|
|
it('should forward popstate event with new url when history supported', function() {
|
|
sniffer.history = true;
|
|
browser.onUrlChange(callback);
|
|
fakeWindow.location.href = 'http://server/new';
|
|
|
|
fakeWindow.fire('popstate');
|
|
expect(callback).toHaveBeenCalledWith('http://server/new', null);
|
|
|
|
fakeWindow.fire('hashchange');
|
|
fakeWindow.setTimeout.flush();
|
|
expect(callback).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('should forward only popstate event when history supported', function() {
|
|
sniffer.history = true;
|
|
browser.onUrlChange(callback);
|
|
fakeWindow.location.href = 'http://server/new';
|
|
|
|
fakeWindow.fire('popstate');
|
|
expect(callback).toHaveBeenCalledWith('http://server/new', null);
|
|
|
|
fakeWindow.fire('hashchange');
|
|
fakeWindow.setTimeout.flush();
|
|
expect(callback).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('should forward hashchange event with new url when history not supported', function() {
|
|
sniffer.history = false;
|
|
browser.onUrlChange(callback);
|
|
fakeWindow.location.href = 'http://server/new';
|
|
|
|
fakeWindow.fire('hashchange');
|
|
expect(callback).toHaveBeenCalledWith('http://server/new', null);
|
|
|
|
fakeWindow.fire('popstate');
|
|
fakeWindow.setTimeout.flush();
|
|
expect(callback).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('should not fire urlChange if changed by browser.url method', function() {
|
|
sniffer.history = false;
|
|
browser.onUrlChange(callback);
|
|
browser.url('http://new.com/');
|
|
|
|
fakeWindow.fire('hashchange');
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe('state handling', function() {
|
|
var currentHref;
|
|
|
|
beforeEach(function() {
|
|
sniffer = {history: true};
|
|
currentHref = fakeWindow.location.href;
|
|
});
|
|
|
|
describe('in IE', runTests({msie: true}));
|
|
describe('not in IE', runTests({msie: false}));
|
|
|
|
function runTests(options) {
|
|
return function() {
|
|
beforeEach(function() {
|
|
fakeWindow = new MockWindow({msie: options.msie});
|
|
browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer, taskTrackerFactory);
|
|
});
|
|
|
|
it('should fire onUrlChange listeners only once if both popstate and hashchange triggered', function() {
|
|
fakeWindow.history.state = {prop: 'val'};
|
|
browser.onUrlChange(callback);
|
|
|
|
fakeWindow.fire('hashchange');
|
|
fakeWindow.fire('popstate');
|
|
expect(callback).toHaveBeenCalledOnce();
|
|
});
|
|
};
|
|
}
|
|
});
|
|
|
|
|
|
it('should stop calling callbacks when application has been torn down', function() {
|
|
sniffer.history = true;
|
|
browser.onUrlChange(callback);
|
|
fakeWindow.location.href = 'http://server/new';
|
|
|
|
browser.$$applicationDestroyed();
|
|
|
|
fakeWindow.fire('popstate');
|
|
expect(callback).not.toHaveBeenCalled();
|
|
|
|
fakeWindow.fire('hashchange');
|
|
fakeWindow.setTimeout.flush();
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
});
|
|
|
|
|
|
describe('baseHref', function() {
|
|
var jqDocHead;
|
|
|
|
beforeEach(function() {
|
|
jqDocHead = jqLite(window.document).find('head');
|
|
});
|
|
|
|
it('should return value from <base href>', function() {
|
|
fakeDocument.basePath = '/base/path/';
|
|
expect(browser.baseHref()).toEqual('/base/path/');
|
|
});
|
|
|
|
it('should return \'\' (empty string) if no <base href>', function() {
|
|
fakeDocument.basePath = undefined;
|
|
expect(browser.baseHref()).toEqual('');
|
|
});
|
|
|
|
it('should remove domain from <base href>', function() {
|
|
fakeDocument.basePath = 'http://host.com/base/path/';
|
|
expect(browser.baseHref()).toEqual('/base/path/');
|
|
|
|
fakeDocument.basePath = 'http://host.com/base/path/index.html';
|
|
expect(browser.baseHref()).toEqual('/base/path/index.html');
|
|
});
|
|
|
|
it('should remove domain from <base href> beginning with \'//\'', function() {
|
|
fakeDocument.basePath = '//google.com/base/path/';
|
|
expect(browser.baseHref()).toEqual('/base/path/');
|
|
});
|
|
});
|
|
|
|
describe('integration tests with $location', function() {
|
|
|
|
function setup(options) {
|
|
fakeWindow = new MockWindow(options);
|
|
browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer, taskTrackerFactory);
|
|
|
|
module(function($provide, $locationProvider) {
|
|
|
|
spyOn(fakeWindow.history, 'pushState').and.callFake(function(stateObj, title, newUrl) {
|
|
fakeWindow.location.href = newUrl;
|
|
});
|
|
spyOn(fakeWindow.location, 'replace').and.callFake(function(newUrl) {
|
|
fakeWindow.location.href = newUrl;
|
|
});
|
|
$provide.value('$browser', browser);
|
|
|
|
sniffer.history = options.history;
|
|
$provide.value('$sniffer', sniffer);
|
|
|
|
$locationProvider.html5Mode(options.html5Mode);
|
|
});
|
|
}
|
|
|
|
describe('update $location when it was changed outside of AngularJS in sync ' +
|
|
'before $digest was called', function() {
|
|
|
|
it('should work with no history support, no html5Mode', function() {
|
|
setup({
|
|
history: false,
|
|
html5Mode: false
|
|
});
|
|
inject(function($rootScope, $location) {
|
|
$rootScope.$apply(function() {
|
|
$location.path('/initialPath');
|
|
});
|
|
expect(fakeWindow.location.href).toBe('http://server/#!/initialPath');
|
|
|
|
fakeWindow.location.href = 'http://server/#!/someTestHash';
|
|
|
|
$rootScope.$digest();
|
|
|
|
expect($location.path()).toBe('/someTestHash');
|
|
});
|
|
});
|
|
|
|
it('should work with history support, no html5Mode', function() {
|
|
setup({
|
|
history: true,
|
|
html5Mode: false
|
|
});
|
|
inject(function($rootScope, $location) {
|
|
$rootScope.$apply(function() {
|
|
$location.path('/initialPath');
|
|
});
|
|
expect(fakeWindow.location.href).toBe('http://server/#!/initialPath');
|
|
|
|
fakeWindow.location.href = 'http://server/#!/someTestHash';
|
|
|
|
$rootScope.$digest();
|
|
|
|
expect($location.path()).toBe('/someTestHash');
|
|
});
|
|
});
|
|
|
|
it('should work with no history support, with html5Mode', function() {
|
|
setup({
|
|
history: false,
|
|
html5Mode: true
|
|
});
|
|
inject(function($rootScope, $location) {
|
|
$rootScope.$apply(function() {
|
|
$location.path('/initialPath');
|
|
});
|
|
expect(fakeWindow.location.href).toBe('http://server/#!/initialPath');
|
|
|
|
fakeWindow.location.href = 'http://server/#!/someTestHash';
|
|
|
|
$rootScope.$digest();
|
|
|
|
expect($location.path()).toBe('/someTestHash');
|
|
});
|
|
});
|
|
|
|
it('should work with history support, with html5Mode', function() {
|
|
setup({
|
|
history: true,
|
|
html5Mode: true
|
|
});
|
|
inject(function($rootScope, $location) {
|
|
$rootScope.$apply(function() {
|
|
$location.path('/initialPath');
|
|
});
|
|
expect(fakeWindow.location.href).toBe('http://server/initialPath');
|
|
|
|
fakeWindow.location.href = 'http://server/someTestHash';
|
|
|
|
$rootScope.$digest();
|
|
|
|
expect($location.path()).toBe('/someTestHash');
|
|
});
|
|
});
|
|
|
|
});
|
|
|
|
it('should not reload the page on every $digest when the page will be reloaded due to url rewrite on load', function() {
|
|
setup({
|
|
history: false,
|
|
html5Mode: true
|
|
});
|
|
fakeWindow.location.href = 'http://server/some/deep/path';
|
|
var changeUrlCount = 0;
|
|
var _url = browser.url;
|
|
browser.url = function(newUrl, replace, state) {
|
|
if (newUrl) {
|
|
changeUrlCount++;
|
|
}
|
|
return _url.call(this, newUrl, replace);
|
|
};
|
|
spyOn(browser, 'url').and.callThrough();
|
|
inject(function($rootScope, $location) {
|
|
$rootScope.$digest();
|
|
$rootScope.$digest();
|
|
$rootScope.$digest();
|
|
$rootScope.$digest();
|
|
|
|
// from $location for rewriting the initial url into a hash url
|
|
expect(browser.url).toHaveBeenCalledWith('http://server/#!/some/deep/path', true);
|
|
expect(changeUrlCount).toBe(1);
|
|
});
|
|
|
|
});
|
|
|
|
// issue #12241
|
|
it('should not infinite digest if the browser does not synchronously update the location properties', function() {
|
|
setup({
|
|
history: true,
|
|
html5Mode: true,
|
|
updateAsync: true // Simulate a browser that doesn't update the href synchronously
|
|
});
|
|
|
|
inject(function($location, $rootScope) {
|
|
|
|
// Change the hash within AngularJS and check that we don't infinitely digest
|
|
$location.hash('newHash');
|
|
expect(function() { $rootScope.$digest(); }).not.toThrow();
|
|
expect($location.absUrl()).toEqual('http://server/#newHash');
|
|
|
|
// Now change the hash from outside AngularJS and check that $location updates correctly
|
|
fakeWindow.location.hash = '#otherHash';
|
|
|
|
// simulate next tick - since this browser doesn't update synchronously
|
|
fakeWindow.location.flushHref();
|
|
fakeWindow.fire('hashchange');
|
|
|
|
expect($location.absUrl()).toEqual('http://server/#otherHash');
|
|
});
|
|
});
|
|
|
|
// issue #16632
|
|
it('should not trigger `$locationChangeStart` more than once due to trailing `#`', function() {
|
|
setup({
|
|
history: true,
|
|
html5Mode: true
|
|
});
|
|
|
|
inject(function($flushPendingTasks, $location, $rootScope) {
|
|
$rootScope.$digest();
|
|
|
|
var spy = jasmine.createSpy('$locationChangeStart');
|
|
$rootScope.$on('$locationChangeStart', spy);
|
|
|
|
$rootScope.$evalAsync(function() {
|
|
fakeWindow.location.href += '#';
|
|
});
|
|
$rootScope.$digest();
|
|
|
|
expect(fakeWindow.location.href).toBe('http://server/#');
|
|
expect($location.absUrl()).toBe('http://server/');
|
|
|
|
expect(spy.calls.count()).toBe(0);
|
|
expect(spy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('integration test with $rootScope', function() {
|
|
|
|
beforeEach(module(function($provide, $locationProvider) {
|
|
$provide.value('$browser', browser);
|
|
}));
|
|
|
|
it('should not interfere with legacy browser url replace behavior', function() {
|
|
inject(function($rootScope) {
|
|
var current = fakeWindow.location.href;
|
|
var newUrl = 'http://notyet/';
|
|
sniffer.history = false;
|
|
expect(historyEntriesLength).toBe(1);
|
|
browser.url(newUrl, true);
|
|
expect(browser.url()).toBe(newUrl);
|
|
expect(historyEntriesLength).toBe(1);
|
|
$rootScope.$digest();
|
|
expect(browser.url()).toBe(newUrl);
|
|
expect(historyEntriesLength).toBe(1);
|
|
});
|
|
});
|
|
|
|
});
|
|
|
|
});
|