From d0672c252e240f08ed4286a5ed1099019ef48832 Mon Sep 17 00:00:00 2001 From: Jason Aden Date: Tue, 5 Mar 2019 13:09:23 -0800 Subject: [PATCH] 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 --- .../testing/src/mock_platform_location.ts | 134 ++++++++++++++++++ packages/common/testing/src/testing.ts | 1 + 2 files changed, 135 insertions(+) create mode 100644 packages/common/testing/src/mock_platform_location.ts diff --git a/packages/common/testing/src/mock_platform_location.ts b/packages/common/testing/src/mock_platform_location.ts new file mode 100644 index 0000000000..f37b4c5bb9 --- /dev/null +++ b/packages/common/testing/src/mock_platform_location.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(); + 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://', 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); +} \ No newline at end of file diff --git a/packages/common/testing/src/testing.ts b/packages/common/testing/src/testing.ts index c5f4cf9e20..0906b3d189 100644 --- a/packages/common/testing/src/testing.ts +++ b/packages/common/testing/src/testing.ts @@ -13,3 +13,4 @@ */ export {SpyLocation} from './location_mock'; export {MockLocationStrategy} from './mock_location_strategy'; +export {MockPlatformLocation} from './mock_platform_location'; \ No newline at end of file