refactor(router): split remainder of describes in integration test file (#60313)

to more easily navigate and identify different suites of integration tests,
this commit splits them into several different files

PR Close #60313
This commit is contained in:
Andrew Scott 2025-03-10 16:30:54 -07:00 committed by Andrew Kushnir
parent 0a9403ec7c
commit 0b6084e1bf
5 changed files with 1608 additions and 1483 deletions

View file

@ -0,0 +1,187 @@
/**
* @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.dev/license
*/
import {Injectable, inject as coreInject} from '@angular/core';
import {Location} from '@angular/common';
import {TestBed, fakeAsync, tick} from '@angular/core/testing';
import {
Router,
ActivatedRouteSnapshot,
RouterStateSnapshot,
provideRouter,
withRouterConfig,
NavigationStart,
GuardsCheckEnd,
} from '@angular/router/src';
import {createRoot, SimpleCmp, advance, RootCmp, BlankCmp} from './integration_helpers';
export function duplicateInFlightNavigationsIntegrationSuite() {
describe('duplicate in-flight navigations', () => {
@Injectable()
class RedirectingGuard {
skipLocationChange = false;
constructor(private router: Router) {}
canActivate() {
this.router.navigate(['/simple'], {skipLocationChange: this.skipLocationChange});
return false;
}
}
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{
provide: 'in1Second',
useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => {
let res: any = null;
const p = new Promise((_) => (res = _));
setTimeout(() => res(true), 1000);
return p;
},
},
RedirectingGuard,
],
});
});
it('should reset location if a navigation by location is successful', fakeAsync(() => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const fixture = createRoot(router, RootCmp);
router.resetConfig([{path: 'simple', component: SimpleCmp, canActivate: ['in1Second']}]);
// Trigger two location changes to the same URL.
// Because of the guard the order will look as follows:
// - location change 'simple'
// - start processing the change, start a guard
// - location change 'simple'
// - the first location change gets canceled, the URL gets reset to '/'
// - the second location change gets finished, the URL should be reset to '/simple'
location.go('/simple');
location.historyGo(0);
location.historyGo(0);
tick(2000);
advance(fixture);
expect(location.path()).toEqual('/simple');
}));
it('should skip duplicate location events', fakeAsync(() => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const fixture = createRoot(router, RootCmp);
router.resetConfig([
{
path: 'blocked',
component: BlankCmp,
canActivate: [() => coreInject(RedirectingGuard).canActivate()],
},
{path: 'simple', component: SimpleCmp},
]);
router.navigateByUrl('/simple');
advance(fixture);
location.go('/blocked');
location.historyGo(0);
advance(fixture);
expect(fixture.nativeElement.innerHTML).toContain('simple');
}));
it('should not cause URL thrashing', async () => {
TestBed.configureTestingModule({
providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))],
});
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const fixture = TestBed.createComponent(RootCmp);
fixture.detectChanges();
router.resetConfig([
{path: 'home', component: SimpleCmp},
{
path: 'blocked',
component: BlankCmp,
canActivate: [() => coreInject(RedirectingGuard).canActivate()],
},
{path: 'simple', component: SimpleCmp},
]);
await router.navigateByUrl('/home');
const urlChanges: string[] = [];
location.onUrlChange((change) => {
urlChanges.push(change);
});
await router.navigateByUrl('/blocked');
await fixture.whenStable();
expect(fixture.nativeElement.innerHTML).toContain('simple');
// We do not want the URL to flicker to `/home` between the /blocked and /simple routes
expect(urlChanges).toEqual(['/blocked', '/simple']);
});
it('can render a 404 page without changing the URL', fakeAsync(() => {
TestBed.configureTestingModule({
providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))],
});
const router = TestBed.inject(Router);
TestBed.inject(RedirectingGuard).skipLocationChange = true;
const location = TestBed.inject(Location);
const fixture = createRoot(router, RootCmp);
router.resetConfig([
{path: 'home', component: SimpleCmp},
{
path: 'blocked',
component: BlankCmp,
canActivate: [() => coreInject(RedirectingGuard).canActivate()],
},
{path: 'simple', redirectTo: '404'},
{path: '404', component: SimpleCmp},
]);
router.navigateByUrl('/home');
advance(fixture);
location.go('/blocked');
location.historyGo(0);
advance(fixture);
expect(location.path()).toEqual('/blocked');
expect(fixture.nativeElement.innerHTML).toContain('simple');
}));
it('should accurately track currentNavigation', fakeAsync(() => {
const router = TestBed.inject(Router);
router.resetConfig([
{path: 'one', component: SimpleCmp, canActivate: ['in1Second']},
{path: 'two', component: BlankCmp, canActivate: ['in1Second']},
]);
router.events.subscribe((e) => {
if (e instanceof NavigationStart) {
if (e.url === '/one') {
router.navigateByUrl('two');
}
router.events.subscribe((e) => {
if (e instanceof GuardsCheckEnd) {
expect(router.getCurrentNavigation()?.extractedUrl.toString()).toEqual('/two');
expect(router.getCurrentNavigation()?.extras).toBeDefined();
}
});
}
});
router.navigateByUrl('one');
tick(1000);
}));
});
}

View file

@ -0,0 +1,244 @@
/**
* @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.dev/license
*/
import {Injectable, inject as coreInject} from '@angular/core';
import {Location} from '@angular/common';
import {TestBed, fakeAsync, tick, inject} from '@angular/core/testing';
import {
DefaultUrlSerializer,
provideRouter,
withRouterConfig,
Router,
GuardsCheckStart,
NavigationStart,
RoutesRecognized,
Navigation,
} from '../../src';
import {of} from 'rxjs';
import {delay, mapTo} from 'rxjs/operators';
import {
advance,
TeamCmp,
RootCmp,
BlankCmp,
SimpleCmp,
AbsoluteSimpleLinkCmp,
createRoot,
} from './integration_helpers';
import {expect} from '@angular/platform-browser/testing/src/matchers';
export function eagerUrlUpdateStrategyIntegrationSuite() {
describe('"eager" urlUpdateStrategy', () => {
@Injectable()
class AuthGuard {
canActivateResult = true;
canActivate() {
return this.canActivateResult;
}
}
@Injectable()
class DelayedGuard {
canActivate() {
return of('').pipe(delay(1000), mapTo(true));
}
}
beforeEach(() => {
const serializer = new DefaultUrlSerializer();
TestBed.configureTestingModule({
providers: [
{
provide: 'authGuardFail',
useValue: (a: any, b: any) => {
return new Promise((res) => {
setTimeout(() => res(serializer.parse('/login')), 1);
});
},
},
AuthGuard,
DelayedGuard,
provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'})),
],
});
});
it('should eagerly update the URL', fakeAsync(
inject([Router, Location], (router: Router, location: Location) => {
const fixture = TestBed.createComponent(RootCmp);
advance(fixture);
router.resetConfig([{path: 'team/:id', component: TeamCmp}]);
router.navigateByUrl('/team/22');
advance(fixture);
expect(location.path()).toEqual('/team/22');
expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]');
router.events.subscribe((e) => {
if (!(e instanceof GuardsCheckStart)) {
return;
}
expect(location.path()).toEqual('/team/33');
expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]');
return of(null);
});
router.navigateByUrl('/team/33');
advance(fixture);
expect(fixture.nativeElement).toHaveText('team 33 [ , right: ]');
}),
));
it('should eagerly update the URL', fakeAsync(
inject([Router, Location], (router: Router, location: Location) => {
const fixture = TestBed.createComponent(RootCmp);
advance(fixture);
router.resetConfig([
{path: 'team/:id', component: SimpleCmp, canActivate: ['authGuardFail']},
{path: 'login', component: AbsoluteSimpleLinkCmp},
]);
router.navigateByUrl('/team/22');
advance(fixture);
expect(location.path()).toEqual('/team/22');
// Redirects to /login
advance(fixture, 1);
expect(location.path()).toEqual('/login');
// Perform the same logic again, and it should produce the same result
router.navigateByUrl('/team/22');
advance(fixture);
expect(location.path()).toEqual('/team/22');
// Redirects to /login
advance(fixture, 1);
expect(location.path()).toEqual('/login');
}),
));
it('should eagerly update URL after redirects are applied', fakeAsync(
inject([Router, Location], (router: Router, location: Location) => {
const fixture = TestBed.createComponent(RootCmp);
advance(fixture);
router.resetConfig([{path: 'team/:id', component: TeamCmp}]);
router.navigateByUrl('/team/22');
advance(fixture);
expect(location.path()).toEqual('/team/22');
expect(fixture.nativeElement).toHaveText('team 22 [ , right: ]');
let urlAtNavStart = '';
let urlAtRoutesRecognized = '';
router.events.subscribe((e) => {
if (e instanceof NavigationStart) {
urlAtNavStart = location.path();
}
if (e instanceof RoutesRecognized) {
urlAtRoutesRecognized = location.path();
}
});
router.navigateByUrl('/team/33');
advance(fixture);
expect(urlAtNavStart).toBe('/team/22');
expect(urlAtRoutesRecognized).toBe('/team/33');
expect(fixture.nativeElement).toHaveText('team 33 [ , right: ]');
}),
));
it('should set `state`', fakeAsync(
inject([Router, Location], (router: Router, location: Location) => {
router.resetConfig([
{path: '', component: SimpleCmp},
{path: 'simple', component: SimpleCmp},
]);
const fixture = createRoot(router, RootCmp);
let navigation: Navigation = null!;
router.events.subscribe((e) => {
if (e instanceof NavigationStart) {
navigation = router.getCurrentNavigation()!;
}
});
router.navigateByUrl('/simple', {state: {foo: 'bar'}});
tick();
const state = location.getState() as any;
expect(state).toEqual({foo: 'bar', navigationId: 2});
expect(navigation.extras.state).toBeDefined();
expect(navigation.extras.state).toEqual({foo: 'bar'});
}),
));
it('can renavigate to rejected URL', fakeAsync(() => {
const router = TestBed.inject(Router);
const canActivate = TestBed.inject(AuthGuard);
const location = TestBed.inject(Location);
router.resetConfig([
{path: '', component: BlankCmp},
{
path: 'simple',
component: SimpleCmp,
canActivate: [() => coreInject(AuthGuard).canActivate()],
},
]);
const fixture = createRoot(router, RootCmp);
// Try to navigate to /simple but guard rejects
canActivate.canActivateResult = false;
router.navigateByUrl('/simple');
advance(fixture);
expect(location.path()).toEqual('');
expect(fixture.nativeElement.innerHTML).not.toContain('simple');
// Renavigate to /simple without guard rejection, should succeed.
canActivate.canActivateResult = true;
router.navigateByUrl('/simple');
advance(fixture);
expect(location.path()).toEqual('/simple');
expect(fixture.nativeElement.innerHTML).toContain('simple');
}));
it('can renavigate to same URL during in-flight navigation', fakeAsync(() => {
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
router.resetConfig([
{path: '', component: BlankCmp},
{
path: 'simple',
component: SimpleCmp,
canActivate: [() => coreInject(DelayedGuard).canActivate()],
},
]);
const fixture = createRoot(router, RootCmp);
// Start navigating to /simple, but do not flush the guard delay
router.navigateByUrl('/simple');
tick();
// eager update strategy so URL is already updated.
expect(location.path()).toEqual('/simple');
expect(fixture.nativeElement.innerHTML).not.toContain('simple');
// Start an additional navigation to /simple and ensure at least one of those succeeds.
// It's not super important which one gets processed, but in the past, the router would
// cancel the in-flight one and not process the new one.
router.navigateByUrl('/simple');
tick(1000);
expect(location.path()).toEqual('/simple');
expect(fixture.nativeElement.innerHTML).toContain('simple');
}));
});
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,718 @@
/**
* @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.dev/license
*/
import {Component, inject as coreInject, NgModule} from '@angular/core';
import {Location} from '@angular/common';
import {fakeAsync, TestBed, tick, inject} from '@angular/core/testing';
import {
provideRouter,
Navigation,
withRouterConfig,
Router,
NavigationStart,
NavigationEnd,
RouterLink,
ActivatedRoute,
Params,
RouterModule,
} from '../../src';
import {
RootCmp,
SimpleCmp,
onlyNavigationStartAndEnd,
expectEvents,
RelativeLinkCmp,
createRoot,
advance,
} from './integration_helpers';
export function navigationIntegrationTestSuite() {
describe('navigation', () => {
it('should navigate to the current URL', fakeAsync(() => {
TestBed.configureTestingModule({
providers: [provideRouter([], withRouterConfig({onSameUrlNavigation: 'reload'}))],
});
const router = TestBed.inject(Router);
router.resetConfig([
{path: '', component: SimpleCmp},
{path: 'simple', component: SimpleCmp},
]);
const events: (NavigationStart | NavigationEnd)[] = [];
router.events.subscribe((e) => onlyNavigationStartAndEnd(e) && events.push(e));
router.navigateByUrl('/simple');
tick();
router.navigateByUrl('/simple');
tick();
expectEvents(events, [
[NavigationStart, '/simple'],
[NavigationEnd, '/simple'],
[NavigationStart, '/simple'],
[NavigationEnd, '/simple'],
]);
}));
it('should override default onSameUrlNavigation with extras', async () => {
TestBed.configureTestingModule({
providers: [provideRouter([], withRouterConfig({onSameUrlNavigation: 'ignore'}))],
});
const router = TestBed.inject(Router);
router.resetConfig([
{path: '', component: SimpleCmp},
{path: 'simple', component: SimpleCmp},
]);
const events: (NavigationStart | NavigationEnd)[] = [];
router.events.subscribe((e) => onlyNavigationStartAndEnd(e) && events.push(e));
await router.navigateByUrl('/simple');
await router.navigateByUrl('/simple');
// By default, the second navigation is ignored
expectEvents(events, [
[NavigationStart, '/simple'],
[NavigationEnd, '/simple'],
]);
await router.navigateByUrl('/simple', {onSameUrlNavigation: 'reload'});
// We overrode the `onSameUrlNavigation` value. This navigation should be processed.
expectEvents(events, [
[NavigationStart, '/simple'],
[NavigationEnd, '/simple'],
[NavigationStart, '/simple'],
[NavigationEnd, '/simple'],
]);
});
it('should override default onSameUrlNavigation with extras', async () => {
TestBed.configureTestingModule({
providers: [provideRouter([], withRouterConfig({onSameUrlNavigation: 'reload'}))],
});
const router = TestBed.inject(Router);
router.resetConfig([
{path: '', component: SimpleCmp},
{path: 'simple', component: SimpleCmp},
]);
const events: (NavigationStart | NavigationEnd)[] = [];
router.events.subscribe((e) => onlyNavigationStartAndEnd(e) && events.push(e));
await router.navigateByUrl('/simple');
await router.navigateByUrl('/simple');
expectEvents(events, [
[NavigationStart, '/simple'],
[NavigationEnd, '/simple'],
[NavigationStart, '/simple'],
[NavigationEnd, '/simple'],
]);
events.length = 0;
await router.navigateByUrl('/simple', {onSameUrlNavigation: 'ignore'});
expectEvents(events, []);
});
it('should set transient navigation info', async () => {
let observedInfo: unknown;
const router = TestBed.inject(Router);
router.resetConfig([
{
path: 'simple',
component: SimpleCmp,
canActivate: [
() => {
observedInfo = coreInject(Router).getCurrentNavigation()?.extras?.info;
return true;
},
],
},
]);
await router.navigateByUrl('/simple', {info: 'navigation info'});
expect(observedInfo).toEqual('navigation info');
});
it('should set transient navigation info for routerlink', async () => {
let observedInfo: unknown;
const router = TestBed.inject(Router);
router.resetConfig([
{
path: 'simple',
component: SimpleCmp,
canActivate: [
() => {
observedInfo = coreInject(Router).getCurrentNavigation()?.extras?.info;
return true;
},
],
},
]);
@Component({
imports: [RouterLink],
template: `<a #simpleLink [routerLink]="'/simple'" [info]="simpleLink"></a>`,
})
class App {}
const fixture = TestBed.createComponent(App);
fixture.autoDetectChanges();
const anchor = fixture.nativeElement.querySelector('a');
anchor.click();
await fixture.whenStable();
// An example use-case might be to pass the clicked link along with the navigation
// information
expect(observedInfo).toBeInstanceOf(HTMLAnchorElement);
});
it('should make transient navigation info available in redirect', async () => {
let observedInfo: unknown;
const router = TestBed.inject(Router);
router.resetConfig([
{
path: 'redirect',
component: SimpleCmp,
canActivate: [() => coreInject(Router).parseUrl('/simple')],
},
{
path: 'simple',
component: SimpleCmp,
canActivate: [
() => {
observedInfo = coreInject(Router).getCurrentNavigation()?.extras?.info;
return true;
},
],
},
]);
await router.navigateByUrl('/redirect', {info: 'navigation info'});
expect(observedInfo).toBe('navigation info');
expect(router.url).toEqual('/simple');
});
it('should ignore empty paths in relative links', fakeAsync(
inject([Router], (router: Router) => {
router.resetConfig([
{
path: 'foo',
children: [{path: 'bar', children: [{path: '', component: RelativeLinkCmp}]}],
},
]);
const fixture = createRoot(router, RootCmp);
router.navigateByUrl('/foo/bar');
advance(fixture);
const link = fixture.nativeElement.querySelector('a');
expect(link.getAttribute('href')).toEqual('/foo/simple');
}),
));
it('should set the restoredState to null when executing imperative navigations', fakeAsync(
inject([Router], (router: Router) => {
router.resetConfig([
{path: '', component: SimpleCmp},
{path: 'simple', component: SimpleCmp},
]);
const fixture = createRoot(router, RootCmp);
let event: NavigationStart;
router.events.subscribe((e) => {
if (e instanceof NavigationStart) {
event = e;
}
});
router.navigateByUrl('/simple');
tick();
expect(event!.navigationTrigger).toEqual('imperative');
expect(event!.restoredState).toEqual(null);
}),
));
it('should set history.state if passed using imperative navigation', fakeAsync(
inject([Router, Location], (router: Router, location: Location) => {
router.resetConfig([
{path: '', component: SimpleCmp},
{path: 'simple', component: SimpleCmp},
]);
const fixture = createRoot(router, RootCmp);
let navigation: Navigation = null!;
router.events.subscribe((e) => {
if (e instanceof NavigationStart) {
navigation = router.getCurrentNavigation()!;
}
});
router.navigateByUrl('/simple', {state: {foo: 'bar'}});
tick();
const state = location.getState() as any;
expect(state.foo).toBe('bar');
expect(state).toEqual({foo: 'bar', navigationId: 2});
expect(navigation.extras.state).toBeDefined();
expect(navigation.extras.state).toEqual({foo: 'bar'});
}),
));
it('should set history.state when navigation with browser back and forward', fakeAsync(
inject([Router, Location], (router: Router, location: Location) => {
router.resetConfig([
{path: '', component: SimpleCmp},
{path: 'simple', component: SimpleCmp},
]);
const fixture = createRoot(router, RootCmp);
let navigation: Navigation = null!;
router.events.subscribe((e) => {
if (e instanceof NavigationStart) {
navigation = <Navigation>router.getCurrentNavigation()!;
}
});
let state: Record<string, string> = {foo: 'bar'};
router.navigateByUrl('/simple', {state});
tick();
location.back();
tick();
location.forward();
tick();
expect(navigation.extras.state).toBeDefined();
expect(navigation.extras.state).toEqual(state);
// Manually set state rather than using navigate()
state = {bar: 'foo'};
location.replaceState(location.path(), '', state);
location.back();
tick();
location.forward();
tick();
expect(navigation.extras.state).toBeDefined();
expect(navigation.extras.state).toEqual(state);
}),
));
it('should navigate correctly when using `Location#historyGo', fakeAsync(
inject([Router, Location], (router: Router, location: Location) => {
router.resetConfig([
{path: 'first', component: SimpleCmp},
{path: 'second', component: SimpleCmp},
]);
createRoot(router, RootCmp);
router.navigateByUrl('/first');
tick();
router.navigateByUrl('/second');
tick();
expect(router.url).toEqual('/second');
location.historyGo(-1);
tick();
expect(router.url).toEqual('/first');
location.historyGo(1);
tick();
expect(router.url).toEqual('/second');
location.historyGo(-100);
tick();
expect(router.url).toEqual('/second');
location.historyGo(100);
tick();
expect(router.url).toEqual('/second');
location.historyGo(0);
tick();
expect(router.url).toEqual('/second');
location.historyGo();
tick();
expect(router.url).toEqual('/second');
}),
));
it('should not error if state is not {[key: string]: any}', fakeAsync(
inject([Router, Location], (router: Router, location: Location) => {
router.resetConfig([
{path: '', component: SimpleCmp},
{path: 'simple', component: SimpleCmp},
]);
const fixture = createRoot(router, RootCmp);
let navigation: Navigation = null!;
router.events.subscribe((e) => {
if (e instanceof NavigationStart) {
navigation = <Navigation>router.getCurrentNavigation()!;
}
});
location.replaceState('', '', 42);
router.navigateByUrl('/simple');
tick();
location.back();
advance(fixture);
// Angular does not support restoring state to the primitive.
expect(navigation.extras.state).toEqual(undefined);
expect(location.getState()).toEqual({navigationId: 3});
}),
));
it('should not pollute browser history when replaceUrl is set to true', fakeAsync(
inject([Router, Location], (router: Router, location: Location) => {
router.resetConfig([
{path: '', component: SimpleCmp},
{path: 'a', component: SimpleCmp},
{path: 'b', component: SimpleCmp},
]);
createRoot(router, RootCmp);
const replaceSpy = spyOn(location, 'replaceState');
router.navigateByUrl('/a', {replaceUrl: true});
router.navigateByUrl('/b', {replaceUrl: true});
tick();
expect(replaceSpy.calls.count()).toEqual(1);
}),
));
it('should skip navigation if another navigation is already scheduled', fakeAsync(
inject([Router, Location], (router: Router, location: Location) => {
router.resetConfig([
{path: '', component: SimpleCmp},
{path: 'a', component: SimpleCmp},
{path: 'b', component: SimpleCmp},
]);
const fixture = createRoot(router, RootCmp);
router.navigate(['/a'], {
queryParams: {a: true},
queryParamsHandling: 'merge',
replaceUrl: true,
});
router.navigate(['/b'], {
queryParams: {b: true},
queryParamsHandling: 'merge',
replaceUrl: true,
});
tick();
/**
* Why do we have '/b?b=true' and not '/b?a=true&b=true'?
*
* This is because the router has the right to stop a navigation mid-flight if another
* navigation has been already scheduled. This is why we can use a top-level guard
* to perform redirects. Calling `navigate` in such a guard will stop the navigation, and
* the components won't be instantiated.
*
* This is a fundamental property of the router: it only cares about its latest state.
*
* This means that components should only map params to something else, not reduce them.
* In other words, the following component is asking for trouble:
*
* ```
* class MyComponent {
* constructor(a: ActivatedRoute) {
* a.params.scan(...)
* }
* }
* ```
*
* This also means "queryParamsHandling: 'merge'" should only be used to merge with
* long-living query parameters (e.g., debug).
*/
expect(router.url).toEqual('/b?b=true');
}),
));
});
describe('should execute navigations serially', () => {
let log: Array<string | Params> = [];
beforeEach(() => {
log = [];
TestBed.configureTestingModule({
providers: [
{
provide: 'trueRightAway',
useValue: () => {
log.push('trueRightAway');
return true;
},
},
{
provide: 'trueIn2Seconds',
useValue: () => {
log.push('trueIn2Seconds-start');
let res: (value: boolean) => void;
const p = new Promise<boolean>((r) => (res = r));
setTimeout(() => {
log.push('trueIn2Seconds-end');
res(true);
}, 2000);
return p;
},
},
],
});
});
describe('route activation', () => {
@Component({
template: '<router-outlet></router-outlet>',
standalone: false,
})
class Parent {
constructor(route: ActivatedRoute) {
route.params.subscribe((s: Params) => {
log.push(s);
});
}
}
@Component({
template: `
<router-outlet (deactivate)="logDeactivate('primary')"></router-outlet>
<router-outlet name="first" (deactivate)="logDeactivate('first')"></router-outlet>
<router-outlet name="second" (deactivate)="logDeactivate('second')"></router-outlet>
`,
standalone: false,
})
class NamedOutletHost {
logDeactivate(route: string) {
log.push(route + ' deactivate');
}
}
@Component({
template: 'child1',
standalone: false,
})
class Child1 {
constructor() {
log.push('child1 constructor');
}
ngOnDestroy() {
log.push('child1 destroy');
}
}
@Component({
template: 'child2',
standalone: false,
})
class Child2 {
constructor() {
log.push('child2 constructor');
}
ngOnDestroy() {
log.push('child2 destroy');
}
}
@Component({
template: 'child3',
standalone: false,
})
class Child3 {
constructor() {
log.push('child3 constructor');
}
ngOnDestroy() {
log.push('child3 destroy');
}
}
@NgModule({
declarations: [Parent, NamedOutletHost, Child1, Child2, Child3],
imports: [RouterModule.forRoot([])],
})
class TestModule {}
it('should advance the parent route after deactivating its children', fakeAsync(() => {
TestBed.configureTestingModule({imports: [TestModule]});
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const fixture = createRoot(router, RootCmp);
router.resetConfig([
{
path: 'parent/:id',
component: Parent,
children: [
{path: 'child1', component: Child1},
{path: 'child2', component: Child2},
],
},
]);
router.navigateByUrl('/parent/1/child1');
advance(fixture);
router.navigateByUrl('/parent/2/child2');
advance(fixture);
expect(location.path()).toEqual('/parent/2/child2');
expect(log).toEqual([
{id: '1'},
'child1 constructor',
'child1 destroy',
{id: '2'},
'child2 constructor',
]);
}));
it('should deactivate outlet children with componentless parent', fakeAsync(() => {
TestBed.configureTestingModule({imports: [TestModule]});
const router = TestBed.inject(Router);
const fixture = createRoot(router, RootCmp);
router.resetConfig([
{
path: 'named-outlets',
component: NamedOutletHost,
children: [
{
path: 'home',
children: [
{path: '', component: Child1, outlet: 'first'},
{path: '', component: Child2, outlet: 'second'},
{path: 'primary', component: Child3},
],
},
{
path: 'about',
children: [
{path: '', component: Child1, outlet: 'first'},
{path: '', component: Child2, outlet: 'second'},
],
},
],
},
{
path: 'other',
component: Parent,
},
]);
router.navigateByUrl('/named-outlets/home/primary');
advance(fixture);
expect(log).toEqual([
'child3 constructor', // primary outlet always first
'child1 constructor',
'child2 constructor',
]);
log.length = 0;
router.navigateByUrl('/named-outlets/about');
advance(fixture);
expect(log).toEqual([
'child3 destroy',
'primary deactivate',
'child1 destroy',
'first deactivate',
'child2 destroy',
'second deactivate',
'child1 constructor',
'child2 constructor',
]);
log.length = 0;
router.navigateByUrl('/other');
advance(fixture);
expect(log).toEqual([
'child1 destroy',
'first deactivate',
'child2 destroy',
'second deactivate',
// route param subscription from 'Parent' component
{},
]);
}));
it('should work between aux outlets under two levels of empty path parents', fakeAsync(() => {
TestBed.configureTestingModule({imports: [TestModule]});
const router = TestBed.inject(Router);
router.resetConfig([
{
path: '',
children: [
{
path: '',
component: NamedOutletHost,
children: [
{path: 'one', component: Child1, outlet: 'first'},
{path: 'two', component: Child2, outlet: 'first'},
],
},
],
},
]);
const fixture = createRoot(router, RootCmp);
router.navigateByUrl('/(first:one)');
advance(fixture);
expect(log).toEqual(['child1 constructor']);
log.length = 0;
router.navigateByUrl('/(first:two)');
advance(fixture);
expect(log).toEqual(['child1 destroy', 'first deactivate', 'child2 constructor']);
}));
});
it('should not wait for prior navigations to start a new navigation', fakeAsync(
inject([Router, Location], (router: Router) => {
const fixture = createRoot(router, RootCmp);
router.resetConfig([
{path: 'a', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']},
{path: 'b', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']},
]);
router.navigateByUrl('/a');
tick(100);
fixture.detectChanges();
router.navigateByUrl('/b');
tick(100); // 200
fixture.detectChanges();
expect(log).toEqual([
'trueRightAway',
'trueIn2Seconds-start',
'trueRightAway',
'trueIn2Seconds-start',
]);
tick(2000); // 2200
fixture.detectChanges();
expect(log).toEqual([
'trueRightAway',
'trueIn2Seconds-start',
'trueRightAway',
'trueIn2Seconds-start',
'trueIn2Seconds-end',
'trueIn2Seconds-end',
]);
}),
));
});
}

View file

@ -0,0 +1,449 @@
/**
* @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.dev/license
*/
import {inject as coreInject, Injectable} from '@angular/core';
import {expect} from '@angular/platform-browser/testing/src/matchers';
import {Location} from '@angular/common';
import {fakeAsync, TestBed, tick, inject} from '@angular/core/testing';
import {
Router,
NavigationStart,
NavigationError,
RoutesRecognized,
GuardsCheckStart,
Event,
ChildActivationStart,
ActivationStart,
GuardsCheckEnd,
ResolveStart,
ResolveEnd,
ActivationEnd,
ChildActivationEnd,
NavigationEnd,
provideRouter,
withRouterConfig,
withNavigationErrorHandler,
RouterModule,
RedirectCommand,
NavigationCancel,
NavigationCancellationCode,
} from '../../src';
import {RouterTestingHarness} from '@angular/router/testing';
import {
createRoot,
RootCmp,
BlankCmp,
UserCmp,
advance,
expectEvents,
SimpleCmp,
ThrowingCmp,
ConditionalThrowingCmp,
EmptyQueryParamsCmp,
} from './integration_helpers';
export function navigationErrorsIntegrationSuite() {
it('should handle failed navigations gracefully', fakeAsync(
inject([Router], (router: Router) => {
const fixture = createRoot(router, RootCmp);
router.resetConfig([{path: 'user/:name', component: UserCmp}]);
const recordedEvents: Event[] = [];
router.events.forEach((e) => recordedEvents.push(e));
let e: any;
router.navigateByUrl('/invalid').catch((_) => (e = _));
advance(fixture);
expect(e.message).toContain('Cannot match any routes');
router.navigateByUrl('/user/fedor');
advance(fixture);
expect(fixture.nativeElement).toHaveText('user fedor');
expectEvents(recordedEvents, [
[NavigationStart, '/invalid'],
[NavigationError, '/invalid'],
[NavigationStart, '/user/fedor'],
[RoutesRecognized, '/user/fedor'],
[GuardsCheckStart, '/user/fedor'],
[ChildActivationStart],
[ActivationStart],
[GuardsCheckEnd, '/user/fedor'],
[ResolveStart, '/user/fedor'],
[ResolveEnd, '/user/fedor'],
[ActivationEnd],
[ChildActivationEnd],
[NavigationEnd, '/user/fedor'],
]);
}),
));
it('should be able to provide an error handler with DI dependencies', async () => {
@Injectable({providedIn: 'root'})
class Handler {
handlerCalled = false;
}
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'throw',
canMatch: [
() => {
throw new Error('');
},
],
component: BlankCmp,
},
],
withRouterConfig({resolveNavigationPromiseOnError: true}),
withNavigationErrorHandler(() => (coreInject(Handler).handlerCalled = true)),
),
],
});
const router = TestBed.inject(Router);
await router.navigateByUrl('/throw');
expect(TestBed.inject(Handler).handlerCalled).toBeTrue();
});
it('can redirect from error handler with RouterModule.forRoot', async () => {
TestBed.configureTestingModule({
imports: [
RouterModule.forRoot(
[
{
path: 'throw',
canMatch: [
() => {
throw new Error('');
},
],
component: BlankCmp,
},
{path: 'error', component: BlankCmp},
],
{
resolveNavigationPromiseOnError: true,
errorHandler: () => new RedirectCommand(coreInject(Router).parseUrl('/error')),
},
),
],
});
const router = TestBed.inject(Router);
let emitNavigationError = false;
let emitNavigationCancelWithRedirect = false;
router.events.subscribe((e) => {
if (e instanceof NavigationError) {
emitNavigationError = true;
}
if (e instanceof NavigationCancel && e.code === NavigationCancellationCode.Redirect) {
emitNavigationCancelWithRedirect = true;
}
});
await router.navigateByUrl('/throw');
expect(router.url).toEqual('/error');
expect(emitNavigationError).toBe(false);
expect(emitNavigationCancelWithRedirect).toBe(true);
});
it('can redirect from error handler', async () => {
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'throw',
canMatch: [
() => {
throw new Error('');
},
],
component: BlankCmp,
},
{path: 'error', component: BlankCmp},
],
withRouterConfig({resolveNavigationPromiseOnError: true}),
withNavigationErrorHandler(
() => new RedirectCommand(coreInject(Router).parseUrl('/error')),
),
),
],
});
const router = TestBed.inject(Router);
let emitNavigationError = false;
let emitNavigationCancelWithRedirect = false;
router.events.subscribe((e) => {
if (e instanceof NavigationError) {
emitNavigationError = true;
}
if (e instanceof NavigationCancel && e.code === NavigationCancellationCode.Redirect) {
emitNavigationCancelWithRedirect = true;
}
});
await router.navigateByUrl('/throw');
expect(router.url).toEqual('/error');
expect(emitNavigationError).toBe(false);
expect(emitNavigationCancelWithRedirect).toBe(true);
});
it('should not break navigation if an error happens in NavigationErrorHandler', async () => {
TestBed.configureTestingModule({
providers: [
provideRouter(
[
{
path: 'throw',
canMatch: [
() => {
throw new Error('');
},
],
component: BlankCmp,
},
{path: '**', component: BlankCmp},
],
withRouterConfig({resolveNavigationPromiseOnError: true}),
withNavigationErrorHandler(() => {
throw new Error('e');
}),
),
],
});
const router = TestBed.inject(Router);
});
// Errors should behave the same for both deferred and eager URL update strategies
(['deferred', 'eager'] as const).forEach((urlUpdateStrategy) => {
it('should dispatch NavigationError after the url has been reset back', fakeAsync(() => {
TestBed.configureTestingModule({
providers: [provideRouter([], withRouterConfig({urlUpdateStrategy}))],
});
const router: Router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const fixture = createRoot(router, RootCmp);
router.resetConfig([
{path: 'simple', component: SimpleCmp},
{path: 'throwing', component: ThrowingCmp},
]);
router.navigateByUrl('/simple');
advance(fixture);
let routerUrlBeforeEmittingError = '';
let locationUrlBeforeEmittingError = '';
router.events.forEach((e) => {
if (e instanceof NavigationError) {
routerUrlBeforeEmittingError = router.url;
locationUrlBeforeEmittingError = location.path();
}
});
router.navigateByUrl('/throwing').catch(() => null);
advance(fixture);
expect(routerUrlBeforeEmittingError).toEqual('/simple');
expect(locationUrlBeforeEmittingError).toEqual('/simple');
}));
it('can renavigate to throwing component', fakeAsync(() => {
TestBed.configureTestingModule({
providers: [provideRouter([], withRouterConfig({urlUpdateStrategy: 'eager'}))],
});
const router = TestBed.inject(Router);
const location = TestBed.inject(Location);
router.resetConfig([
{path: '', component: BlankCmp},
{path: 'throwing', component: ConditionalThrowingCmp},
]);
const fixture = createRoot(router, RootCmp);
// Try navigating to a component which throws an error during activation.
ConditionalThrowingCmp.throwError = true;
expect(() => {
router.navigateByUrl('/throwing');
advance(fixture);
}).toThrow();
expect(location.path()).toEqual('');
expect(fixture.nativeElement.innerHTML).not.toContain('throwing');
// Ensure we can re-navigate to that same URL and succeed.
ConditionalThrowingCmp.throwError = false;
router.navigateByUrl('/throwing');
advance(fixture);
expect(location.path()).toEqual('/throwing');
expect(fixture.nativeElement.innerHTML).toContain('throwing');
}));
it('should reset the url with the right state when navigation errors', fakeAsync(() => {
TestBed.configureTestingModule({
providers: [provideRouter([], withRouterConfig({urlUpdateStrategy}))],
});
const router: Router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const fixture = createRoot(router, RootCmp);
router.resetConfig([
{path: 'simple1', component: SimpleCmp},
{path: 'simple2', component: SimpleCmp},
{path: 'throwing', component: ThrowingCmp},
]);
let event: NavigationStart;
router.events.subscribe((e) => {
if (e instanceof NavigationStart) {
event = e;
}
});
router.navigateByUrl('/simple1');
advance(fixture);
const simple1NavStart = event!;
router.navigateByUrl('/throwing').catch(() => null);
advance(fixture);
router.navigateByUrl('/simple2');
advance(fixture);
location.back();
tick();
expect(event!.restoredState!.navigationId).toEqual(simple1NavStart.id);
}));
it('should not trigger another navigation when resetting the url back due to a NavigationError', fakeAsync(() => {
TestBed.configureTestingModule({
providers: [provideRouter([], withRouterConfig({urlUpdateStrategy}))],
});
const router = TestBed.inject(Router);
router.onSameUrlNavigation = 'reload';
const fixture = createRoot(router, RootCmp);
router.resetConfig([
{path: 'simple', component: SimpleCmp},
{path: 'throwing', component: ThrowingCmp},
]);
const events: any[] = [];
router.events.forEach((e: any) => {
if (e instanceof NavigationStart) {
events.push(e.url);
}
});
router.navigateByUrl('/simple');
advance(fixture);
router.navigateByUrl('/throwing').catch(() => null);
advance(fixture);
// we do not trigger another navigation to /simple
expect(events).toEqual(['/simple', '/throwing']);
}));
});
it('should dispatch NavigationCancel after the url has been reset back', fakeAsync(() => {
TestBed.configureTestingModule({
providers: [{provide: 'returnsFalse', useValue: () => false}],
});
const router: Router = TestBed.inject(Router);
const location = TestBed.inject(Location);
const fixture = createRoot(router, RootCmp);
router.resetConfig([
{path: 'simple', component: SimpleCmp},
{
path: 'throwing',
loadChildren: jasmine.createSpy('doesnotmatter'),
canLoad: ['returnsFalse'],
},
]);
router.navigateByUrl('/simple');
advance(fixture);
let routerUrlBeforeEmittingError = '';
let locationUrlBeforeEmittingError = '';
router.events.forEach((e) => {
if (e instanceof NavigationCancel) {
expect(e.code).toBe(NavigationCancellationCode.GuardRejected);
routerUrlBeforeEmittingError = router.url;
locationUrlBeforeEmittingError = location.path();
}
});
location.go('/throwing');
location.historyGo(0);
advance(fixture);
expect(routerUrlBeforeEmittingError).toEqual('/simple');
expect(locationUrlBeforeEmittingError).toEqual('/simple');
}));
it('should recover from malformed uri errors', fakeAsync(
inject([Router, Location], (router: Router, location: Location) => {
router.resetConfig([{path: 'simple', component: SimpleCmp}]);
const fixture = createRoot(router, RootCmp);
router.navigateByUrl('/invalid/url%with%percent');
advance(fixture);
expect(location.path()).toEqual('');
}),
));
it('should not swallow errors', fakeAsync(
inject([Router], (router: Router) => {
const fixture = createRoot(router, RootCmp);
router.resetConfig([{path: 'simple', component: SimpleCmp}]);
router.navigateByUrl('/invalid');
expect(() => advance(fixture)).toThrow();
router.navigateByUrl('/invalid2');
expect(() => advance(fixture)).toThrow();
}),
));
it('should not swallow errors from browser state update', async () => {
const routerEvents: Event[] = [];
TestBed.inject(Router).resetConfig([{path: '**', component: BlankCmp}]);
TestBed.inject(Router).events.subscribe((e) => {
routerEvents.push(e);
});
spyOn(TestBed.inject(Location), 'go').and.callFake(() => {
throw new Error();
});
try {
await RouterTestingHarness.create('/abc123');
} catch {}
// Ensure the first event is the start and that we get to the ResolveEnd event. If this is not
// true, then NavigationError may have been triggered at a time we don't expect here.
expect(routerEvents[0]).toBeInstanceOf(NavigationStart);
expect(routerEvents[routerEvents.length - 2]).toBeInstanceOf(ResolveEnd);
expect(routerEvents[routerEvents.length - 1]).toBeInstanceOf(NavigationError);
});
it('should throw an error when one of the commands is null/undefined', fakeAsync(
inject([Router], (router: Router) => {
createRoot(router, RootCmp);
router.resetConfig([{path: 'query', component: EmptyQueryParamsCmp}]);
expect(() => router.navigate([undefined, 'query'])).toThrowError(
/The requested path contains undefined segment at index 0/,
);
}),
));
}