Skip to content
Permalink
Browse files

feat(common): add MockPlatformLocation to enable more robust testing …

…of Location services (#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 #30055
  • Loading branch information...
jasonaden authored and benlesh committed Mar 5, 2019
1 parent 3938563 commit d0672c252e240f08ed4286a5ed1099019ef48832
Showing with 135 additions and 0 deletions.
  1. +134 −0 packages/common/testing/src/mock_platform_location.ts
  2. +1 −0 packages/common/testing/src/testing.ts
@@ -0,0 +1,134 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {LocationChangeEvent, LocationChangeListener, PlatformLocation} from '@angular/common';
import {Injectable, InjectionToken, Optional} from '@angular/core';
import {Subject} from 'rxjs';

function parseUrl(urlStr: string, baseHref: string) {
const verifyProtocol = /^((http[s]?|ftp):\/\/)/;
let serverBase = '';

// URL class requires full URL. If the URL string doesn't start with protocol, we need to add an
// arbitrary base URL which can be removed afterward.
if (!verifyProtocol.test(urlStr)) {
serverBase = 'http://empty.com/';
}
const parsedUrl = new URL(urlStr, serverBase);
if (parsedUrl.pathname && parsedUrl.pathname.indexOf(baseHref) === 0) {
parsedUrl.pathname = parsedUrl.pathname.substring(baseHref.length);
}
return {
hostname: !serverBase && parsedUrl.hostname || '',
protocol: !serverBase && parsedUrl.protocol || '',
port: !serverBase && parsedUrl.port || '',
pathname: parsedUrl.pathname || '/',
search: parsedUrl.search || '',
hash: parsedUrl.hash || '',
};
}

export interface MockPlatformLocationConfig {
startUrl?: string;
appBaseHref?: string;
}

export const MOCK_PLATFORM_LOCATION_CONFIG = new InjectionToken('MOCK_PLATFORM_LOCATION_CONFIG');

/**
* Mock implementation of URL state.
*/
@Injectable()
export class MockPlatformLocation implements PlatformLocation {
private baseHref: string = '';
private hashUpdate = new Subject<LocationChangeEvent>();
private urlChanges: {
hostname: string,
protocol: string,
port: string,
pathname: string,
search: string,
hash: string,
state: unknown
}[] = [{hostname: '', protocol: '', port: '', pathname: '/', search: '', hash: '', state: null}];

constructor(@Optional() config?: MockPlatformLocationConfig) {
if (config) {
this.baseHref = config.appBaseHref || '';

const parsedChanges =
this.parseChanges(null, config.startUrl || 'http://<empty>', this.baseHref);
this.urlChanges[0] = {...parsedChanges};
}
}

get hostname() { return this.urlChanges[0].hostname; }
get protocol() { return this.urlChanges[0].protocol; }
get port() { return this.urlChanges[0].port; }
get pathname() { return this.urlChanges[0].pathname; }
get search() { return this.urlChanges[0].search; }
get hash() { return this.urlChanges[0].hash; }
get state() { return this.urlChanges[0].state; }


getBaseHrefFromDOM(): string { return this.baseHref; }

onPopState(fn: LocationChangeListener): void {
// No-op: a state stack is not implemented, so
// no events will ever come.
}

onHashChange(fn: LocationChangeListener): void { this.hashUpdate.subscribe(fn); }

get href(): string {
return `${this.protocol}//${this.hostname}${this.baseHref}${this.pathname === '/' ? '' : this.pathname}${this.search}${this.hash}`;
}

get url(): string { return `${this.pathname}${this.search}${this.hash}`; }

private setHash(value: string, oldUrl: string) {
if (this.hash === value) {
// Don't fire events if the hash has not changed.
return;
}
(this as{hash: string}).hash = value;
const newUrl = this.url;
scheduleMicroTask(() => this.hashUpdate.next({
type: 'hashchange', state: null, oldUrl, newUrl
} as LocationChangeEvent));
}

private parseChanges(state: unknown, url: string, baseHref: string = '') {
return {...parseUrl(url, baseHref), state};
}

replaceState(state: any, title: string, newUrl: string): void {
const oldUrl = this.url;

const {pathname, search, state: parsedState, hash} = this.parseChanges(state, newUrl);

this.urlChanges[0] = {...this.urlChanges[0], pathname, search, state: parsedState};
this.setHash(hash, oldUrl);
}

pushState(state: any, title: string, newUrl: string): void {
const {pathname, search, state: parsedState, hash} = this.parseChanges(state, newUrl);
this.urlChanges.unshift({...this.urlChanges[0], pathname, search, state: parsedState});
}

forward(): void { throw new Error('Not implemented'); }

back(): void { this.urlChanges.shift(); }

// History API isn't available on server, therefore return undefined
getState(): unknown { return this.state; }
}

export function scheduleMicroTask(cb: () => any) {
Promise.resolve(null).then(cb);
}
@@ -13,3 +13,4 @@
*/ */
export {SpyLocation} from './location_mock'; export {SpyLocation} from './location_mock';
export {MockLocationStrategy} from './mock_location_strategy'; export {MockLocationStrategy} from './mock_location_strategy';
export {MockPlatformLocation} from './mock_platform_location';

0 comments on commit d0672c2

Please sign in to comment.
You can’t perform that action at this time.