From 4b498242362ce6133645b57d452604e940e8c1bb Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 22 Jul 2021 12:02:59 -0700 Subject: [PATCH] test(router): move computed state restoration tests to own file (#42933) To reduce the enormouse size of the integration.spec.ts file, move tests related to the computed state restoration to their own file. PR Close #42933 --- packages/router/src/router.ts | 7 + .../test/computed_state_restoration.spec.ts | 645 ++++++++++++++++++ packages/router/test/integration.spec.ts | 587 ---------------- 3 files changed, 652 insertions(+), 587 deletions(-) create mode 100644 packages/router/test/computed_state_restoration.spec.ts diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index af30d2e32b..db34072a35 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -915,6 +915,13 @@ export class Router { // reflect the current state of the whole transition because some operations // return a new object rather than modifying the one in the outermost // `switchMap`. + // The fix can likely be to: + // 1. Rename the outer `t` variable so it's not shadowed all the time and + // confusing + // 2. Keep reassigning to the outer variable after each stage to ensure it + // gets updated. Or change the implementations to not return a copy. + // Not changed yet because it affects existing code and would need to be + // tested more thoroughly. errored = true; /* This error type is issued during Redirect, and is handled as a * cancellation rather than an error. */ diff --git a/packages/router/test/computed_state_restoration.spec.ts b/packages/router/test/computed_state_restoration.spec.ts new file mode 100644 index 0000000000..e4925938e4 --- /dev/null +++ b/packages/router/test/computed_state_restoration.spec.ts @@ -0,0 +1,645 @@ +/** + * @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 + */ + +import {CommonModule, Location, LocationStrategy, PlatformLocation} from '@angular/common'; +import {SpyLocation} from '@angular/common/testing'; +import {Component, EventEmitter, Injectable, NgModule} from '@angular/core'; +import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; +import {expect} from '@angular/platform-browser/testing/src/matchers'; +import {CanActivate, CanDeactivate, Resolve, Router, RouterModule, UrlTree} from '@angular/router'; +import {EMPTY, Observable, of, SubscriptionLike} from 'rxjs'; + +import {isUrlTree} from '../src/utils/type_guards'; +import {RouterTestingModule} from '../testing'; + +describe('`restoredState#ɵrouterPageId`', () => { + // TODO: Remove RouterSpyLocation after #38884 is submitted. + class RouterSpyLocation implements Location { + urlChanges: string[] = []; + private _history: LocationState[] = [new LocationState('', '', null)]; + private _historyIndex: number = 0; + /** @internal */ + _subject: EventEmitter = new EventEmitter(); + /** @internal */ + _baseHref: string = ''; + /** @internal */ + _platformStrategy: LocationStrategy = null!; + /** @internal */ + _platformLocation: PlatformLocation = null!; + /** @internal */ + _urlChangeListeners: ((url: string, state: unknown) => void)[] = []; + /** @internal */ + _urlChangeSubscription?: SubscriptionLike; + + setInitialPath(url: string) { + this._history[this._historyIndex].path = url; + } + + setBaseHref(url: string) { + this._baseHref = url; + } + + path(): string { + return this._history[this._historyIndex].path; + } + + getState(): unknown { + return this._history[this._historyIndex].state; + } + + isCurrentPathEqualTo(path: string, query: string = ''): boolean { + const givenPath = path.endsWith('/') ? path.substring(0, path.length - 1) : path; + const currPath = this.path().endsWith('/') ? + this.path().substring(0, this.path().length - 1) : + this.path(); + + return currPath == givenPath + (query.length > 0 ? ('?' + query) : ''); + } + + simulateUrlPop(pathname: string) { + this._subject.emit({'url': pathname, 'pop': true, 'type': 'popstate'}); + } + + simulateHashChange(pathname: string) { + // Because we don't prevent the native event, the browser will independently update the path + this.setInitialPath(pathname); + this.urlChanges.push('hash: ' + pathname); + this._subject.emit({'url': pathname, 'pop': true, 'type': 'hashchange'}); + } + + prepareExternalUrl(url: string): string { + if (url.length > 0 && !url.startsWith('/')) { + url = '/' + url; + } + return this._baseHref + url; + } + + go(path: string, query: string = '', state: any = null) { + path = this.prepareExternalUrl(path); + + if (this._historyIndex > 0) { + this._history.splice(this._historyIndex + 1); + } + this._history.push(new LocationState(path, query, state)); + this._historyIndex = this._history.length - 1; + + const locationState = this._history[this._historyIndex - 1]; + if (locationState.path == path && locationState.query == query) { + return; + } + + const url = path + (query.length > 0 ? ('?' + query) : ''); + this.urlChanges.push(url); + } + + replaceState(path: string, query: string = '', state: any = null) { + path = this.prepareExternalUrl(path); + + const history = this._history[this._historyIndex]; + if (history.path == path && history.query == query) { + return; + } + + history.path = path; + history.query = query; + history.state = state; + + const url = path + (query.length > 0 ? ('?' + query) : ''); + this.urlChanges.push('replace: ' + url); + } + + forward() { + if (this._historyIndex < (this._history.length - 1)) { + this._historyIndex++; + this._subject.emit( + {'url': this.path(), 'state': this.getState(), 'pop': true, 'type': 'popstate'}); + } + } + + back() { + if (this._historyIndex > 0) { + this._historyIndex--; + this._subject.emit( + {'url': this.path(), 'state': this.getState(), 'pop': true, 'type': 'popstate'}); + } + } + + historyGo(relativePosition: number = 0): void { + const nextPageIndex = this._historyIndex + relativePosition; + if (nextPageIndex >= 0 && nextPageIndex < this._history.length) { + this._historyIndex = nextPageIndex; + this._subject.emit( + {'url': this.path(), 'state': this.getState(), 'pop': true, 'type': 'popstate'}); + } + } + + onUrlChange(fn: (url: string, state: unknown) => void) { + this._urlChangeListeners.push(fn); + + if (!this._urlChangeSubscription) { + this._urlChangeSubscription = this.subscribe(v => { + this._notifyUrlChangeListeners(v.url, v.state); + }); + } + } + + /** @internal */ + _notifyUrlChangeListeners(url: string = '', state: unknown) { + this._urlChangeListeners.forEach(fn => fn(url, state)); + } + + subscribe( + onNext: (value: any) => void, onThrow?: ((error: any) => void)|null, + onReturn?: (() => void)|null): SubscriptionLike { + return this._subject.subscribe({next: onNext, error: onThrow, complete: onReturn}); + } + + normalize(url: string): string { + return null!; + } + } + + class LocationState { + constructor(public path: string, public query: string, public state: any) {} + } + + @Injectable({providedIn: 'root'}) + class MyCanDeactivateGuard implements CanDeactivate { + allow: boolean = true; + canDeactivate(): boolean { + return this.allow; + } + } + + @Injectable({providedIn: 'root'}) + class ThrowingCanActivateGuard implements CanActivate { + throw = false; + + constructor(private router: Router) {} + + canActivate(): boolean { + if (this.throw) { + throw new Error('error in guard'); + } + return true; + } + } + + @Injectable({providedIn: 'root'}) + class MyCanActivateGuard implements CanActivate { + allow: boolean = true; + redirectTo: string|null|UrlTree = null; + + constructor(private router: Router) {} + + canActivate(): boolean|UrlTree { + if (typeof this.redirectTo === 'string') { + this.router.navigateByUrl(this.redirectTo); + } else if (isUrlTree(this.redirectTo)) { + return this.redirectTo; + } + return this.allow; + } + } + @Injectable({providedIn: 'root'}) + class MyResolve implements Resolve> { + myresolve: Observable = of(2); + resolve(): Observable { + return this.myresolve; + } + } + + @NgModule( + {imports: [RouterModule.forChild([{path: '', component: SimpleCmp}])]}, + ) + class LoadedModule { + } + + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [TestModule], + providers: [ + {provide: 'alwaysFalse', useValue: (a: any) => false}, + {provide: Location, useClass: RouterSpyLocation} + ] + }); + const router = TestBed.inject(Router); + (router as any).canceledNavigationResolution = 'computed'; + const location = TestBed.inject(Location); + fixture = createRoot(router, RootCmp); + router.resetConfig([ + { + path: 'first', + component: SimpleCmp, + canDeactivate: [MyCanDeactivateGuard], + canActivate: [MyCanActivateGuard, ThrowingCanActivateGuard], + resolve: [MyResolve] + }, + { + path: 'second', + component: SimpleCmp, + canDeactivate: [MyCanDeactivateGuard], + canActivate: [MyCanActivateGuard, ThrowingCanActivateGuard], + resolve: [MyResolve] + }, + { + path: 'third', + component: SimpleCmp, + canDeactivate: [MyCanDeactivateGuard], + canActivate: [MyCanActivateGuard, ThrowingCanActivateGuard], + resolve: [MyResolve] + }, + { + path: 'unguarded', + component: SimpleCmp, + }, + { + path: 'throwing', + component: ThrowingCmp, + }, + {path: 'loaded', loadChildren: () => of(LoadedModule), canLoad: ['alwaysFalse']} + ]); + router.navigateByUrl('/first'); + advance(fixture); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); + + router.navigateByUrl('/second'); + advance(fixture); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + router.navigateByUrl('/third'); + advance(fixture); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3})); + + location.back(); + advance(fixture); + })); + + it('should work when CanActivate returns false', fakeAsync(() => { + const location = TestBed.inject(Location); + const router = TestBed.inject(Router); + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + TestBed.inject(MyCanActivateGuard).allow = false; + location.back(); + advance(fixture); + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + TestBed.inject(MyCanActivateGuard).allow = true; + location.back(); + advance(fixture); + expect(location.path()).toEqual('/first'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); + + TestBed.inject(MyCanActivateGuard).allow = false; + location.forward(); + advance(fixture); + expect(location.path()).toEqual('/first'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); + + router.navigateByUrl('/second'); + advance(fixture); + expect(location.path()).toEqual('/first'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); + })); + + + it('should work when CanDeactivate returns false', fakeAsync(() => { + const location = TestBed.inject(Location); + const router = TestBed.inject(Router); + + TestBed.inject(MyCanDeactivateGuard).allow = false; + location.back(); + advance(fixture); + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + location.forward(); + advance(fixture); + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + router.navigateByUrl('third'); + advance(fixture); + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + + TestBed.inject(MyCanDeactivateGuard).allow = true; + location.forward(); + advance(fixture); + expect(location.path()).toEqual('/third'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3})); + })); + + it('should work when using `NavigationExtras.skipLocationChange`', fakeAsync(() => { + const location = TestBed.inject(Location); + const router = TestBed.inject(Router); + + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + router.navigateByUrl('/first', {skipLocationChange: true}); + advance(fixture); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + router.navigateByUrl('/third'); + advance(fixture); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3})); + + location.back(); + advance(fixture); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + })); + + it('should work when using `NavigationExtras.replaceUrl`', fakeAsync(() => { + const location = TestBed.inject(Location); + const router = TestBed.inject(Router); + + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + router.navigateByUrl('/first', {replaceUrl: true}); + advance(fixture); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + expect(location.path()).toEqual('/first'); + })); + + it('should work when CanLoad returns false', fakeAsync(() => { + const location = TestBed.inject(Location); + const router = TestBed.inject(Router); + + router.navigateByUrl('/loaded'); + advance(fixture); + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + })); + + it('should work when resolve empty', fakeAsync(() => { + const location = TestBed.inject(Location); + const router = TestBed.inject(Router); + + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + TestBed.inject(MyResolve).myresolve = EMPTY; + + location.back(); + advance(fixture); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + expect(location.path()).toEqual('/second'); + + TestBed.inject(MyResolve).myresolve = of(2); + + location.back(); + advance(fixture); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); + expect(location.path()).toEqual('/first'); + + TestBed.inject(MyResolve).myresolve = EMPTY; + + // We should cancel the navigation to `/third` when myresolve is empty + router.navigateByUrl('/third'); + advance(fixture); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); + expect(location.path()).toEqual('/first'); + + location.historyGo(2); + advance(fixture); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); + expect(location.path()).toEqual('/first'); + + TestBed.inject(MyResolve).myresolve = of(2); + location.historyGo(2); + advance(fixture); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3})); + expect(location.path()).toEqual('/third'); + + TestBed.inject(MyResolve).myresolve = EMPTY; + location.historyGo(-2); + advance(fixture); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3})); + expect(location.path()).toEqual('/third'); + })); + + + it('should work when an error occured during navigation', fakeAsync(() => { + const location = TestBed.inject(Location); + const router = TestBed.inject(Router); + + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + + router.navigateByUrl('/invalid').catch(() => null); + advance(fixture); + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + location.back(); + advance(fixture); + expect(location.path()).toEqual('/first'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); + })); + + it('should work when urlUpdateStrategy="eager"', fakeAsync(() => { + const location = TestBed.inject(Location) as SpyLocation; + const router = TestBed.inject(Router); + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + router.urlUpdateStrategy = 'eager'; + + TestBed.inject(MyCanActivateGuard).allow = false; + router.navigateByUrl('/first'); + advance(fixture); + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + location.back(); + advance(fixture); + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + })); + + it('should work when CanActivate redirects', fakeAsync(() => { + const location = TestBed.inject(Location); + + TestBed.inject(MyCanActivateGuard).redirectTo = '/unguarded'; + location.back(); + advance(fixture); + expect(location.path()).toEqual('/unguarded'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + TestBed.inject(MyCanActivateGuard).redirectTo = null; + + location.back(); + advance(fixture); + expect(location.path()).toEqual('/first'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); + })); + + it('should work when CanActivate redirects and urlUpdateStrategy="eager"', fakeAsync(() => { + const location = TestBed.inject(Location); + const router = TestBed.inject(Router); + router.urlUpdateStrategy = 'eager'; + + TestBed.inject(MyCanActivateGuard).redirectTo = '/unguarded'; + router.navigateByUrl('/third'); + advance(fixture); + expect(location.path()).toEqual('/unguarded'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 4})); + + TestBed.inject(MyCanActivateGuard).redirectTo = null; + + location.back(); + advance(fixture); + expect(location.path()).toEqual('/third'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3})); + })); + + it('should work when CanActivate redirects with UrlTree and urlUpdateStrategy="eager"', + fakeAsync(() => { + // Note that this test is different from the above case because we are able to specifically + // handle the `UrlTree` case as a proper redirect and set `replaceUrl: true` on the + // follow-up navigation. + const location = TestBed.inject(Location); + const router = TestBed.inject(Router); + router.urlUpdateStrategy = 'eager'; + + TestBed.inject(MyCanActivateGuard).redirectTo = router.createUrlTree(['unguarded']); + router.navigateByUrl('/third'); + advance(fixture); + expect(location.path()).toEqual('/unguarded'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3})); + + TestBed.inject(MyCanActivateGuard).redirectTo = null; + + location.back(); + advance(fixture); + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + })); + + for (const urlUpdateSrategy of ['deferred', 'eager'] as const) { + it(`restores history correctly when an error is thrown in guard with urlUpdateStrategy ${ + urlUpdateSrategy}`, + fakeAsync(() => { + const location = TestBed.inject(Location); + const router = TestBed.inject(Router); + router.urlUpdateStrategy = urlUpdateSrategy; + + TestBed.inject(ThrowingCanActivateGuard).throw = true; + + expect(() => { + location.back(); + advance(fixture); + }).toThrow(); + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + TestBed.inject(ThrowingCanActivateGuard).throw = false; + location.back(); + advance(fixture); + expect(location.path()).toEqual('/first'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); + })); + + it(`restores history correctly when component throws error in constructor with urlUpdateStrategy ${ + urlUpdateSrategy}`, + fakeAsync(() => { + const location = TestBed.inject(Location); + const router = TestBed.inject(Router); + router.urlUpdateStrategy = urlUpdateSrategy; + + router.navigateByUrl('/throwing').catch(() => null); + advance(fixture); + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + location.back(); + advance(fixture); + expect(location.path()).toEqual('/first'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); + })); + } + + it('restores history correctly when component throws error in constructor and replaceUrl=true', + fakeAsync(() => { + const location = TestBed.inject(Location); + const router = TestBed.inject(Router); + + router.navigateByUrl('/throwing', {replaceUrl: true}).catch(() => null); + advance(fixture); + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + location.back(); + advance(fixture); + expect(location.path()).toEqual('/first'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); + })); + + it('restores history correctly when component throws error in constructor and skipLocationChange=true', + fakeAsync(() => { + const location = TestBed.inject(Location); + const router = TestBed.inject(Router); + + router.navigateByUrl('/throwing', {skipLocationChange: true}).catch(() => null); + advance(fixture); + expect(location.path()).toEqual('/second'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); + + location.back(); + advance(fixture); + expect(location.path()).toEqual('/first'); + expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); + })); +}); + +function createRoot(router: Router, type: any): ComponentFixture { + const f = TestBed.createComponent(type); + advance(f); + router.initialNavigation(); + advance(f); + return f; +} + +@Component({selector: 'simple-cmp', template: `simple`}) +class SimpleCmp { +} + +@Component({selector: 'root-cmp', template: ``}) +class RootCmp { +} + +@Component({selector: 'throwing-cmp', template: ''}) +class ThrowingCmp { + constructor() { + throw new Error('Throwing Cmp'); + } +} + + + +function advance(fixture: ComponentFixture, millis?: number): void { + tick(millis); + fixture.detectChanges(); +} + +@NgModule({ + imports: [RouterTestingModule, CommonModule], + exports: [SimpleCmp, RootCmp, ThrowingCmp], + entryComponents: [SimpleCmp, RootCmp, ThrowingCmp], + declarations: [SimpleCmp, RootCmp, ThrowingCmp] +}) +class TestModule { +} diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index 3d95fa71a1..3a221459a5 100644 --- a/packages/router/test/integration.spec.ts +++ b/packages/router/test/integration.spec.ts @@ -2663,593 +2663,6 @@ describe('Integration', () => { expect(location.path()).toEqual('/initial'); }))); }); - describe('`restoredState#ɵrouterPageId`', () => { - // TODO: Remove RouterSpyLocation after #38884 is submitted. - class RouterSpyLocation implements Location { - urlChanges: string[] = []; - private _history: LocationState[] = [new LocationState('', '', null)]; - private _historyIndex: number = 0; - /** @internal */ - _subject: EventEmitter = new EventEmitter(); - /** @internal */ - _baseHref: string = ''; - /** @internal */ - _platformStrategy: LocationStrategy = null!; - /** @internal */ - _platformLocation: PlatformLocation = null!; - /** @internal */ - _urlChangeListeners: ((url: string, state: unknown) => void)[] = []; - /** @internal */ - _urlChangeSubscription?: SubscriptionLike; - - setInitialPath(url: string) { - this._history[this._historyIndex].path = url; - } - - setBaseHref(url: string) { - this._baseHref = url; - } - - path(): string { - return this._history[this._historyIndex].path; - } - - getState(): unknown { - return this._history[this._historyIndex].state; - } - - isCurrentPathEqualTo(path: string, query: string = ''): boolean { - const givenPath = path.endsWith('/') ? path.substring(0, path.length - 1) : path; - const currPath = this.path().endsWith('/') ? - this.path().substring(0, this.path().length - 1) : - this.path(); - - return currPath == givenPath + (query.length > 0 ? ('?' + query) : ''); - } - - simulateUrlPop(pathname: string) { - this._subject.emit({'url': pathname, 'pop': true, 'type': 'popstate'}); - } - - simulateHashChange(pathname: string) { - // Because we don't prevent the native event, the browser will independently update the path - this.setInitialPath(pathname); - this.urlChanges.push('hash: ' + pathname); - this._subject.emit({'url': pathname, 'pop': true, 'type': 'hashchange'}); - } - - prepareExternalUrl(url: string): string { - if (url.length > 0 && !url.startsWith('/')) { - url = '/' + url; - } - return this._baseHref + url; - } - - go(path: string, query: string = '', state: any = null) { - path = this.prepareExternalUrl(path); - - if (this._historyIndex > 0) { - this._history.splice(this._historyIndex + 1); - } - this._history.push(new LocationState(path, query, state)); - this._historyIndex = this._history.length - 1; - - const locationState = this._history[this._historyIndex - 1]; - if (locationState.path == path && locationState.query == query) { - return; - } - - const url = path + (query.length > 0 ? ('?' + query) : ''); - this.urlChanges.push(url); - } - - replaceState(path: string, query: string = '', state: any = null) { - path = this.prepareExternalUrl(path); - - const history = this._history[this._historyIndex]; - if (history.path == path && history.query == query) { - return; - } - - history.path = path; - history.query = query; - history.state = state; - - const url = path + (query.length > 0 ? ('?' + query) : ''); - this.urlChanges.push('replace: ' + url); - } - - forward() { - if (this._historyIndex < (this._history.length - 1)) { - this._historyIndex++; - this._subject.emit( - {'url': this.path(), 'state': this.getState(), 'pop': true, 'type': 'popstate'}); - } - } - - back() { - if (this._historyIndex > 0) { - this._historyIndex--; - this._subject.emit( - {'url': this.path(), 'state': this.getState(), 'pop': true, 'type': 'popstate'}); - } - } - - historyGo(relativePosition: number = 0): void { - const nextPageIndex = this._historyIndex + relativePosition; - if (nextPageIndex >= 0 && nextPageIndex < this._history.length) { - this._historyIndex = nextPageIndex; - this._subject.emit( - {'url': this.path(), 'state': this.getState(), 'pop': true, 'type': 'popstate'}); - } - } - - onUrlChange(fn: (url: string, state: unknown) => void) { - this._urlChangeListeners.push(fn); - - if (!this._urlChangeSubscription) { - this._urlChangeSubscription = this.subscribe(v => { - this._notifyUrlChangeListeners(v.url, v.state); - }); - } - } - - /** @internal */ - _notifyUrlChangeListeners(url: string = '', state: unknown) { - this._urlChangeListeners.forEach(fn => fn(url, state)); - } - - subscribe( - onNext: (value: any) => void, onThrow?: ((error: any) => void)|null, - onReturn?: (() => void)|null): SubscriptionLike { - return this._subject.subscribe({next: onNext, error: onThrow, complete: onReturn}); - } - - normalize(url: string): string { - return null!; - } - } - - class LocationState { - constructor(public path: string, public query: string, public state: any) {} - } - - @Injectable({providedIn: 'root'}) - class MyCanDeactivateGuard implements CanDeactivate { - allow: boolean = true; - canDeactivate(): boolean { - return this.allow; - } - } - - @Injectable({providedIn: 'root'}) - class ThrowingCanActivateGuard implements CanActivate { - throw = false; - - constructor(private router: Router) {} - - canActivate(): boolean { - if (this.throw) { - throw new Error('error in guard'); - } - return true; - } - } - - @Injectable({providedIn: 'root'}) - class MyCanActivateGuard implements CanActivate { - allow: boolean = true; - redirectTo: string|null|UrlTree = null; - - constructor(private router: Router) {} - - canActivate(): boolean|UrlTree { - if (typeof this.redirectTo === 'string') { - this.router.navigateByUrl(this.redirectTo); - } else if (isUrlTree(this.redirectTo)) { - return this.redirectTo; - } - return this.allow; - } - } - @Injectable({providedIn: 'root'}) - class MyResolve implements Resolve> { - myresolve: Observable = of(2); - resolve(): Observable { - return this.myresolve; - } - } - - @NgModule( - {imports: [RouterModule.forChild([{path: '', component: BlankCmp}])]}, - ) - class LoadedModule { - } - - let fixture: ComponentFixture; - - beforeEach(fakeAsync(() => { - TestBed.configureTestingModule({ - imports: [TestModule], - providers: [ - {provide: 'alwaysFalse', useValue: (a: any) => false}, - {provide: Location, useClass: RouterSpyLocation} - ] - }); - const router = TestBed.inject(Router); - (router as any).canceledNavigationResolution = 'computed'; - const location = TestBed.inject(Location); - fixture = createRoot(router, RootCmp); - router.resetConfig([ - { - path: 'first', - component: SimpleCmp, - canDeactivate: [MyCanDeactivateGuard], - canActivate: [MyCanActivateGuard, ThrowingCanActivateGuard], - resolve: [MyResolve] - }, - { - path: 'second', - component: SimpleCmp, - canDeactivate: [MyCanDeactivateGuard], - canActivate: [MyCanActivateGuard, ThrowingCanActivateGuard], - resolve: [MyResolve] - }, - { - path: 'third', - component: SimpleCmp, - canDeactivate: [MyCanDeactivateGuard], - canActivate: [MyCanActivateGuard, ThrowingCanActivateGuard], - resolve: [MyResolve] - }, - { - path: 'unguarded', - component: SimpleCmp, - }, - { - path: 'throwing', - component: ThrowingCmp, - }, - {path: 'loaded', loadChildren: () => of(LoadedModule), canLoad: ['alwaysFalse']} - ]); - router.navigateByUrl('/first'); - advance(fixture); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); - - router.navigateByUrl('/second'); - advance(fixture); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - router.navigateByUrl('/third'); - advance(fixture); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3})); - - location.back(); - advance(fixture); - })); - - it('should work when CanActivate returns false', fakeAsync(() => { - const location = TestBed.inject(Location); - const router = TestBed.inject(Router); - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - TestBed.inject(MyCanActivateGuard).allow = false; - location.back(); - advance(fixture); - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - TestBed.inject(MyCanActivateGuard).allow = true; - location.back(); - advance(fixture); - expect(location.path()).toEqual('/first'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); - - TestBed.inject(MyCanActivateGuard).allow = false; - location.forward(); - advance(fixture); - expect(location.path()).toEqual('/first'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); - - router.navigateByUrl('/second'); - advance(fixture); - expect(location.path()).toEqual('/first'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); - })); - - - it('should work when CanDeactivate returns false', fakeAsync(() => { - const location = TestBed.inject(Location); - const router = TestBed.inject(Router); - - TestBed.inject(MyCanDeactivateGuard).allow = false; - location.back(); - advance(fixture); - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - location.forward(); - advance(fixture); - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - router.navigateByUrl('third'); - advance(fixture); - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - - TestBed.inject(MyCanDeactivateGuard).allow = true; - location.forward(); - advance(fixture); - expect(location.path()).toEqual('/third'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3})); - })); - - it('should work when using `NavigationExtras.skipLocationChange`', fakeAsync(() => { - const location = TestBed.inject(Location); - const router = TestBed.inject(Router); - - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - router.navigateByUrl('/first', {skipLocationChange: true}); - advance(fixture); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - router.navigateByUrl('/third'); - advance(fixture); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3})); - - location.back(); - advance(fixture); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - })); - - it('should work when using `NavigationExtras.replaceUrl`', fakeAsync(() => { - const location = TestBed.inject(Location); - const router = TestBed.inject(Router); - - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - router.navigateByUrl('/first', {replaceUrl: true}); - advance(fixture); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - expect(location.path()).toEqual('/first'); - })); - - it('should work when CanLoad returns false', fakeAsync(() => { - const location = TestBed.inject(Location); - const router = TestBed.inject(Router); - - router.navigateByUrl('/loaded'); - advance(fixture); - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - })); - - it('should work when resolve empty', fakeAsync(() => { - const location = TestBed.inject(Location); - const router = TestBed.inject(Router); - - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - TestBed.inject(MyResolve).myresolve = EMPTY; - - location.back(); - advance(fixture); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - expect(location.path()).toEqual('/second'); - - TestBed.inject(MyResolve).myresolve = of(2); - - location.back(); - advance(fixture); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); - expect(location.path()).toEqual('/first'); - - TestBed.inject(MyResolve).myresolve = EMPTY; - - // We should cancel the navigation to `/third` when myresolve is empty - router.navigateByUrl('/third'); - advance(fixture); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); - expect(location.path()).toEqual('/first'); - - location.historyGo(2); - advance(fixture); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); - expect(location.path()).toEqual('/first'); - - TestBed.inject(MyResolve).myresolve = of(2); - location.historyGo(2); - advance(fixture); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3})); - expect(location.path()).toEqual('/third'); - - TestBed.inject(MyResolve).myresolve = EMPTY; - location.historyGo(-2); - advance(fixture); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3})); - expect(location.path()).toEqual('/third'); - })); - - - it('should work when an error occured during navigation', fakeAsync(() => { - const location = TestBed.inject(Location); - const router = TestBed.inject(Router); - - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - - router.navigateByUrl('/invalid').catch(() => null); - advance(fixture); - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - location.back(); - advance(fixture); - expect(location.path()).toEqual('/first'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); - })); - - it('should work when urlUpdateStrategy="eager"', fakeAsync(() => { - const location = TestBed.inject(Location) as SpyLocation; - const router = TestBed.inject(Router); - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - router.urlUpdateStrategy = 'eager'; - - TestBed.inject(MyCanActivateGuard).allow = false; - router.navigateByUrl('/first'); - advance(fixture); - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - location.back(); - advance(fixture); - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - })); - - it('should work when CanActivate redirects', fakeAsync(() => { - const location = TestBed.inject(Location); - - TestBed.inject(MyCanActivateGuard).redirectTo = '/unguarded'; - location.back(); - advance(fixture); - expect(location.path()).toEqual('/unguarded'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - TestBed.inject(MyCanActivateGuard).redirectTo = null; - - location.back(); - advance(fixture); - expect(location.path()).toEqual('/first'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); - })); - - it('should work when CanActivate redirects and urlUpdateStrategy="eager"', fakeAsync(() => { - const location = TestBed.inject(Location); - const router = TestBed.inject(Router); - router.urlUpdateStrategy = 'eager'; - - TestBed.inject(MyCanActivateGuard).redirectTo = '/unguarded'; - router.navigateByUrl('/third'); - advance(fixture); - expect(location.path()).toEqual('/unguarded'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 4})); - - TestBed.inject(MyCanActivateGuard).redirectTo = null; - - location.back(); - advance(fixture); - expect(location.path()).toEqual('/third'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3})); - })); - - it('should work when CanActivate redirects with UrlTree and urlUpdateStrategy="eager"', - fakeAsync(() => { - // Note that this test is different from the above case because we are able to specifically - // handle the `UrlTree` case as a proper redirect and set `replaceUrl: true` on the - // follow-up navigation. - const location = TestBed.inject(Location); - const router = TestBed.inject(Router); - router.urlUpdateStrategy = 'eager'; - - TestBed.inject(MyCanActivateGuard).redirectTo = router.createUrlTree(['unguarded']); - router.navigateByUrl('/third'); - advance(fixture); - expect(location.path()).toEqual('/unguarded'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 3})); - - TestBed.inject(MyCanActivateGuard).redirectTo = null; - - location.back(); - advance(fixture); - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - })); - - for (const urlUpdateSrategy of ['deferred', 'eager'] as const) { - it(`restores history correctly when an error is thrown in guard with urlUpdateStrategy ${ - urlUpdateSrategy}`, - fakeAsync(() => { - const location = TestBed.inject(Location); - const router = TestBed.inject(Router); - router.urlUpdateStrategy = urlUpdateSrategy; - - TestBed.inject(ThrowingCanActivateGuard).throw = true; - - expect(() => { - location.back(); - advance(fixture); - }).toThrow(); - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - TestBed.inject(ThrowingCanActivateGuard).throw = false; - location.back(); - advance(fixture); - expect(location.path()).toEqual('/first'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); - })); - - it(`restores history correctly when component throws error in constructor with urlUpdateStrategy ${ - urlUpdateSrategy}`, - fakeAsync(() => { - const location = TestBed.inject(Location); - const router = TestBed.inject(Router); - router.urlUpdateStrategy = urlUpdateSrategy; - - router.navigateByUrl('/throwing').catch(() => null); - advance(fixture); - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - location.back(); - advance(fixture); - expect(location.path()).toEqual('/first'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); - })); - } - - it('restores history correctly when component throws error in constructor and replaceUrl=true', - fakeAsync(() => { - const location = TestBed.inject(Location); - const router = TestBed.inject(Router); - - router.navigateByUrl('/throwing', {replaceUrl: true}).catch(() => null); - advance(fixture); - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - location.back(); - advance(fixture); - expect(location.path()).toEqual('/first'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); - })); - - it('restores history correctly when component throws error in constructor and skipLocationChange=true', - fakeAsync(() => { - const location = TestBed.inject(Location); - const router = TestBed.inject(Router); - - router.navigateByUrl('/throwing', {skipLocationChange: true}).catch(() => null); - advance(fixture); - expect(location.path()).toEqual('/second'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 2})); - - location.back(); - advance(fixture); - expect(location.path()).toEqual('/first'); - expect(location.getState()).toEqual(jasmine.objectContaining({ɵrouterPageId: 1})); - })); - }); describe('guards', () => { describe('CanActivate', () => { describe('should not activate a route when CanActivate returns false', () => {