Skip to content

Commit

Permalink
refactor(router): Update integration tests to cover navigation and hi…
Browse files Browse the repository at this point in the history
…story API (#53799)

This commit updates the router integration tests to cover both the
classic History and the new Navigation API. There is more work to be
done here, but this commit works to prove the efficacy of the
`FakeNavigation` implementation.

PR Close #53799
  • Loading branch information
atscott authored and thePunderWoman committed Jan 30, 2024
1 parent 7b4d275 commit 15c4811
Show file tree
Hide file tree
Showing 14 changed files with 6,396 additions and 5,900 deletions.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@
"@types/chrome": "^0.0.258",
"@types/convert-source-map": "^2.0.0",
"@types/diff": "^5.0.0",
"@types/dom-navigation": "^1.0.2",
"@types/dom-view-transitions": "^1.0.1",
"@types/hammerjs": "2.0.45",
"@types/jasmine": "^5.0.0",
Expand Down
1 change: 0 additions & 1 deletion packages/common/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ ng_module(
),
deps = [
"//packages/core",
"@npm//@types/dom-navigation",
"@npm//rxjs",
],
)
Expand Down
181 changes: 181 additions & 0 deletions packages/common/src/navigation/navigation_types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* @license
* Copyright Google LLC 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
*/

export interface NavigationEventMap {
navigate: NavigateEvent;
navigatesuccess: Event;
navigateerror: ErrorEvent;
currententrychange: NavigationCurrentEntryChangeEvent;
}

export interface NavigationResult {
committed: Promise<NavigationHistoryEntry>;
finished: Promise<NavigationHistoryEntry>;
}

export declare class Navigation extends EventTarget {
entries(): NavigationHistoryEntry[];
readonly currentEntry: NavigationHistoryEntry|null;
updateCurrentEntry(options: NavigationUpdateCurrentEntryOptions): void;
readonly transition: NavigationTransition|null;

readonly canGoBack: boolean;
readonly canGoForward: boolean;

navigate(url: string, options?: NavigationNavigateOptions): NavigationResult;
reload(options?: NavigationReloadOptions): NavigationResult;

traverseTo(key: string, options?: NavigationOptions): NavigationResult;
back(options?: NavigationOptions): NavigationResult;
forward(options?: NavigationOptions): NavigationResult;

onnavigate: ((this: Navigation, ev: NavigateEvent) => any)|null;
onnavigatesuccess: ((this: Navigation, ev: Event) => any)|null;
onnavigateerror: ((this: Navigation, ev: ErrorEvent) => any)|null;
oncurrententrychange: ((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any)|null;

addEventListener<K extends keyof NavigationEventMap>(
type: K,
listener: (this: Navigation, ev: NavigationEventMap[K]) => any,
options?: boolean|AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean|AddEventListenerOptions,
): void;
removeEventListener<K extends keyof NavigationEventMap>(
type: K,
listener: (this: Navigation, ev: NavigationEventMap[K]) => any,
options?: boolean|EventListenerOptions,
): void;
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean|EventListenerOptions,
): void;
}

export declare class NavigationTransition {
readonly navigationType: NavigationTypeString;
readonly from: NavigationHistoryEntry;
readonly finished: Promise<void>;
}

export interface NavigationHistoryEntryEventMap {
dispose: Event;
}

export declare class NavigationHistoryEntry extends EventTarget {
readonly key: string;
readonly id: string;
readonly url: string|null;
readonly index: number;
readonly sameDocument: boolean;

getState(): unknown;

ondispose: ((this: NavigationHistoryEntry, ev: Event) => any)|null;

addEventListener<K extends keyof NavigationHistoryEntryEventMap>(
type: K,
listener: (this: NavigationHistoryEntry, ev: NavigationHistoryEntryEventMap[K]) => any,
options?: boolean|AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean|AddEventListenerOptions,
): void;
removeEventListener<K extends keyof NavigationHistoryEntryEventMap>(
type: K,
listener: (this: NavigationHistoryEntry, ev: NavigationHistoryEntryEventMap[K]) => any,
options?: boolean|EventListenerOptions,
): void;
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean|EventListenerOptions,
): void;
}

type NavigationTypeString = 'reload'|'push'|'replace'|'traverse';

export interface NavigationUpdateCurrentEntryOptions {
state: unknown;
}

export interface NavigationOptions {
info?: unknown;
}

export interface NavigationNavigateOptions extends NavigationOptions {
state?: unknown;
history?: 'auto'|'push'|'replace';
}

export interface NavigationReloadOptions extends NavigationOptions {
state?: unknown;
}

export declare class NavigationCurrentEntryChangeEvent extends Event {
constructor(type: string, eventInit?: NavigationCurrentEntryChangeEventInit);

readonly navigationType: NavigationTypeString|null;
readonly from: NavigationHistoryEntry;
}

export interface NavigationCurrentEntryChangeEventInit extends EventInit {
navigationType?: NavigationTypeString|null;
from: NavigationHistoryEntry;
}

export declare class NavigateEvent extends Event {
constructor(type: string, eventInit?: NavigateEventInit);

readonly navigationType: NavigationTypeString;
readonly canIntercept: boolean;
readonly userInitiated: boolean;
readonly hashChange: boolean;
readonly destination: NavigationDestination;
readonly signal: AbortSignal;
readonly formData: FormData|null;
readonly downloadRequest: string|null;
readonly info?: unknown;

intercept(options?: NavigationInterceptOptions): void;
scroll(): void;
}

export interface NavigateEventInit extends EventInit {
navigationType?: NavigationTypeString;
canIntercept?: boolean;
userInitiated?: boolean;
hashChange?: boolean;
destination: NavigationDestination;
signal: AbortSignal;
formData?: FormData|null;
downloadRequest?: string|null;
info?: unknown;
}

export interface NavigationInterceptOptions {
handler?: () => Promise<void>;
focusReset?: 'after-transition'|'manual';
scroll?: 'after-transition'|'manual';
}

export declare class NavigationDestination {
readonly url: string;
readonly key: string|null;
readonly id: string|null;
readonly index: number;
readonly sameDocument: boolean;

getState(): unknown;
}
4 changes: 3 additions & 1 deletion packages/common/src/navigation/platform_navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@

import {Injectable} from '@angular/core';

import {NavigateEvent, Navigation, NavigationCurrentEntryChangeEvent, NavigationHistoryEntry, NavigationNavigateOptions, NavigationOptions, NavigationReloadOptions, NavigationResult, NavigationTransition, NavigationUpdateCurrentEntryOptions} from './navigation_types';

/**
* This class wraps the platform Navigation API which allows server-specific and test
* implementations.
*/
@Injectable({providedIn: 'platform', useFactory: () => window.navigation})
@Injectable({providedIn: 'platform', useFactory: () => (window as any).navigation})
export abstract class PlatformNavigation implements Navigation {
abstract entries(): NavigationHistoryEntry[];
abstract currentEntry: NavigationHistoryEntry|null;
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/private_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
*/

export {DomAdapter as ɵDomAdapter, getDOM as ɵgetDOM, setRootDomAdapter as ɵsetRootDomAdapter} from './dom_adapter';
export {PlatformNavigation as ɵPlatformNavigation} from './navigation/platform_navigation';
85 changes: 83 additions & 2 deletions packages/common/testing/src/mock_platform_location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
* found in the LICENSE file at https://angular.io/license
*/

import {LocationChangeEvent, LocationChangeListener, PlatformLocation} from '@angular/common';
import {Inject, Injectable, InjectionToken, Optional} from '@angular/core';
import {DOCUMENT, LocationChangeEvent, LocationChangeListener, PlatformLocation, ɵPlatformNavigation as PlatformNavigation} from '@angular/common';
import {Inject, inject, Injectable, InjectionToken, Optional} from '@angular/core';
import {Subject} from 'rxjs';

import {FakeNavigation} from './navigation/fake_navigation';

/**
* Parser from https://tools.ietf.org/html/rfc3986#appendix-B
* ^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?
Expand Down Expand Up @@ -250,3 +252,82 @@ export class MockPlatformLocation implements PlatformLocation {
}
}
}

/**
* Mock implementation of URL state.
*/
@Injectable()
export class FakeNavigationPlatformLocation implements PlatformLocation {
private _platformNavigation = inject(PlatformNavigation) as FakeNavigation;
private window = inject(DOCUMENT).defaultView!;

constructor() {
if (!(this._platformNavigation instanceof FakeNavigation)) {
throw new Error(
'FakePlatformNavigation cannot be used without FakeNavigation. Use ' +
'`provideFakeNavigation` to have all these services provided together.',
);
}
}

private config = inject(MOCK_PLATFORM_LOCATION_CONFIG, {optional: true});
getBaseHrefFromDOM(): string {
return this.config?.appBaseHref ?? '';
}

onPopState(fn: LocationChangeListener): VoidFunction {
this.window.addEventListener('popstate', fn);
return () => this.window.removeEventListener('popstate', fn);
}

onHashChange(fn: LocationChangeListener): VoidFunction {
this.window.addEventListener('hashchange', fn as any);
return () => this.window.removeEventListener('hashchange', fn as any);
}

get href(): string {
return this._platformNavigation.currentEntry.url!;
}
get protocol(): string {
return new URL(this._platformNavigation.currentEntry.url!).protocol;
}
get hostname(): string {
return new URL(this._platformNavigation.currentEntry.url!).hostname;
}
get port(): string {
return new URL(this._platformNavigation.currentEntry.url!).port;
}
get pathname(): string {
return new URL(this._platformNavigation.currentEntry.url!).pathname;
}
get search(): string {
return new URL(this._platformNavigation.currentEntry.url!).search;
}
get hash(): string {
return new URL(this._platformNavigation.currentEntry.url!).hash;
}

pushState(state: any, title: string, url: string): void {
this._platformNavigation.pushState(state, title, url);
}

replaceState(state: any, title: string, url: string): void {
this._platformNavigation.replaceState(state, title, url);
}

forward(): void {
this._platformNavigation.forward();
}

back(): void {
this._platformNavigation.back();
}

historyGo(relativePosition: number = 0): void {
this._platformNavigation.go(relativePosition);
}

getState(): unknown {
return this._platformNavigation.currentEntry.getHistoryState();
}
}
3 changes: 1 addition & 2 deletions packages/common/testing/src/navigation/fake_navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

// Prevents deletion of `Event` from `globalThis` during module loading.
const Event = globalThis.Event;
import {NavigateEvent, Navigation, NavigationCurrentEntryChangeEvent, NavigationDestination, NavigationHistoryEntry, NavigationInterceptOptions, NavigationNavigateOptions, NavigationOptions, NavigationReloadOptions, NavigationResult, NavigationTransition, NavigationTypeString, NavigationUpdateCurrentEntryOptions} from './navigation_types';

/**
* Fake implementation of user agent history and navigation behavior. This is a
Expand Down
Loading

0 comments on commit 15c4811

Please sign in to comment.