Skip to content

Commit d0672c2

Browse files
jasonadenbenlesh
authored andcommitted
feat(common): add MockPlatformLocation to enable more robust testing of Location services (angular#30055)
Prior to this change we had a MockLocationStrategy to replace the Path and Hash Location Strategies. However, there wasn't a good way to test the PlatformLocation which is needed for doing things such as setting history.state, using back()/forward(), etc. PR Close angular#30055
1 parent 3938563 commit d0672c2

File tree

2 files changed

+135
-0
lines changed

2 files changed

+135
-0
lines changed
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {LocationChangeEvent, LocationChangeListener, PlatformLocation} from '@angular/common';
10+
import {Injectable, InjectionToken, Optional} from '@angular/core';
11+
import {Subject} from 'rxjs';
12+
13+
function parseUrl(urlStr: string, baseHref: string) {
14+
const verifyProtocol = /^((http[s]?|ftp):\/\/)/;
15+
let serverBase = '';
16+
17+
// URL class requires full URL. If the URL string doesn't start with protocol, we need to add an
18+
// arbitrary base URL which can be removed afterward.
19+
if (!verifyProtocol.test(urlStr)) {
20+
serverBase = 'http://empty.com/';
21+
}
22+
const parsedUrl = new URL(urlStr, serverBase);
23+
if (parsedUrl.pathname && parsedUrl.pathname.indexOf(baseHref) === 0) {
24+
parsedUrl.pathname = parsedUrl.pathname.substring(baseHref.length);
25+
}
26+
return {
27+
hostname: !serverBase && parsedUrl.hostname || '',
28+
protocol: !serverBase && parsedUrl.protocol || '',
29+
port: !serverBase && parsedUrl.port || '',
30+
pathname: parsedUrl.pathname || '/',
31+
search: parsedUrl.search || '',
32+
hash: parsedUrl.hash || '',
33+
};
34+
}
35+
36+
export interface MockPlatformLocationConfig {
37+
startUrl?: string;
38+
appBaseHref?: string;
39+
}
40+
41+
export const MOCK_PLATFORM_LOCATION_CONFIG = new InjectionToken('MOCK_PLATFORM_LOCATION_CONFIG');
42+
43+
/**
44+
* Mock implementation of URL state.
45+
*/
46+
@Injectable()
47+
export class MockPlatformLocation implements PlatformLocation {
48+
private baseHref: string = '';
49+
private hashUpdate = new Subject<LocationChangeEvent>();
50+
private urlChanges: {
51+
hostname: string,
52+
protocol: string,
53+
port: string,
54+
pathname: string,
55+
search: string,
56+
hash: string,
57+
state: unknown
58+
}[] = [{hostname: '', protocol: '', port: '', pathname: '/', search: '', hash: '', state: null}];
59+
60+
constructor(@Optional() config?: MockPlatformLocationConfig) {
61+
if (config) {
62+
this.baseHref = config.appBaseHref || '';
63+
64+
const parsedChanges =
65+
this.parseChanges(null, config.startUrl || 'http://<empty>', this.baseHref);
66+
this.urlChanges[0] = {...parsedChanges};
67+
}
68+
}
69+
70+
get hostname() { return this.urlChanges[0].hostname; }
71+
get protocol() { return this.urlChanges[0].protocol; }
72+
get port() { return this.urlChanges[0].port; }
73+
get pathname() { return this.urlChanges[0].pathname; }
74+
get search() { return this.urlChanges[0].search; }
75+
get hash() { return this.urlChanges[0].hash; }
76+
get state() { return this.urlChanges[0].state; }
77+
78+
79+
getBaseHrefFromDOM(): string { return this.baseHref; }
80+
81+
onPopState(fn: LocationChangeListener): void {
82+
// No-op: a state stack is not implemented, so
83+
// no events will ever come.
84+
}
85+
86+
onHashChange(fn: LocationChangeListener): void { this.hashUpdate.subscribe(fn); }
87+
88+
get href(): string {
89+
return `${this.protocol}//${this.hostname}${this.baseHref}${this.pathname === '/' ? '' : this.pathname}${this.search}${this.hash}`;
90+
}
91+
92+
get url(): string { return `${this.pathname}${this.search}${this.hash}`; }
93+
94+
private setHash(value: string, oldUrl: string) {
95+
if (this.hash === value) {
96+
// Don't fire events if the hash has not changed.
97+
return;
98+
}
99+
(this as{hash: string}).hash = value;
100+
const newUrl = this.url;
101+
scheduleMicroTask(() => this.hashUpdate.next({
102+
type: 'hashchange', state: null, oldUrl, newUrl
103+
} as LocationChangeEvent));
104+
}
105+
106+
private parseChanges(state: unknown, url: string, baseHref: string = '') {
107+
return {...parseUrl(url, baseHref), state};
108+
}
109+
110+
replaceState(state: any, title: string, newUrl: string): void {
111+
const oldUrl = this.url;
112+
113+
const {pathname, search, state: parsedState, hash} = this.parseChanges(state, newUrl);
114+
115+
this.urlChanges[0] = {...this.urlChanges[0], pathname, search, state: parsedState};
116+
this.setHash(hash, oldUrl);
117+
}
118+
119+
pushState(state: any, title: string, newUrl: string): void {
120+
const {pathname, search, state: parsedState, hash} = this.parseChanges(state, newUrl);
121+
this.urlChanges.unshift({...this.urlChanges[0], pathname, search, state: parsedState});
122+
}
123+
124+
forward(): void { throw new Error('Not implemented'); }
125+
126+
back(): void { this.urlChanges.shift(); }
127+
128+
// History API isn't available on server, therefore return undefined
129+
getState(): unknown { return this.state; }
130+
}
131+
132+
export function scheduleMicroTask(cb: () => any) {
133+
Promise.resolve(null).then(cb);
134+
}

packages/common/testing/src/testing.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
*/
1414
export {SpyLocation} from './location_mock';
1515
export {MockLocationStrategy} from './mock_location_strategy';
16+
export {MockPlatformLocation} from './mock_platform_location';

0 commit comments

Comments
 (0)