diff --git a/packages/common/BUILD.bazel b/packages/common/BUILD.bazel index 242dd5458df..79d51e45a7a 100644 --- a/packages/common/BUILD.bazel +++ b/packages/common/BUILD.bazel @@ -31,8 +31,8 @@ ng_module( ), deps = [ "//packages/core", - "@npm//rxjs", "@npm//@types/dom-navigation", + "@npm//rxjs", ], ) diff --git a/packages/common/src/navigation/index.ts b/packages/common/src/navigation/index.ts deleted file mode 100644 index 28388f46e1e..00000000000 --- a/packages/common/src/navigation/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @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 {PlatformNavigation} from './platform_navigation'; diff --git a/packages/common/src/navigation/platform_navigation.ts b/packages/common/src/navigation/platform_navigation.ts index 1961843be13..18665217c06 100644 --- a/packages/common/src/navigation/platform_navigation.ts +++ b/packages/common/src/navigation/platform_navigation.ts @@ -11,8 +11,6 @@ import {Injectable} from '@angular/core'; /** * This class wraps the platform Navigation API which allows server-specific and test * implementations. - * - * @internal */ @Injectable({providedIn: 'platform', useFactory: () => window.navigation}) export abstract class PlatformNavigation extends Navigation { diff --git a/packages/common/test/navigation/fake_platform_navigation.spec.ts b/packages/common/test/navigation/fake_platform_navigation.spec.ts new file mode 100644 index 00000000000..954e43c561a --- /dev/null +++ b/packages/common/test/navigation/fake_platform_navigation.spec.ts @@ -0,0 +1,1995 @@ +/** + * @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 {ExperimentalNavigationInterceptOptions, FakeNavigateEvent, FakeNavigation, FakeNavigationCurrentEntryChangeEvent,} from '../../testing/src/navigation/fake_navigation'; + +interface Locals { + navigation: FakeNavigation; + navigateEvents: FakeNavigateEvent[]; + navigationCurrentEntryChangeEvents: FakeNavigationCurrentEntryChangeEvent[]; + popStateEvents: PopStateEvent[]; + pendingInterceptOptions: ExperimentalNavigationInterceptOptions[]; + nextNavigateEvent: () => Promise; + setExtraNavigateCallback: (callback: (event: FakeNavigateEvent) => void) => void; +} + +describe('navigation', () => { + let locals: Locals; + + const popStateListener = (event: Event) => { + const popStateEvent = event as PopStateEvent; + locals.popStateEvents.push(popStateEvent); + }; + + beforeEach(() => { + window.addEventListener('popstate', popStateListener); + }); + + afterEach(() => { + window.removeEventListener('popstate', popStateListener); + }); + + beforeEach(() => { + const navigation = new FakeNavigation(window, 'https://test.com'); + const navigateEvents: FakeNavigateEvent[] = []; + let nextNavigateEventResolve!: (value: FakeNavigateEvent) => void; + let nextNavigateEventPromise = new Promise((resolve) => { + nextNavigateEventResolve = resolve; + }); + const navigationCurrentEntryChangeEvents: FakeNavigationCurrentEntryChangeEvent[] = []; + const popStateEvents: PopStateEvent[] = []; + const pendingInterceptOptions: ExperimentalNavigationInterceptOptions[] = []; + let extraNavigateCallback:|((event: FakeNavigateEvent) => void)|undefined = undefined; + + navigation.addEventListener('navigate', (event: Event) => { + const navigateEvent = event as FakeNavigateEvent; + nextNavigateEventResolve(navigateEvent); + nextNavigateEventPromise = new Promise((resolve) => { + nextNavigateEventResolve = resolve; + }); + locals.navigateEvents.push(navigateEvent); + const interceptOptions = pendingInterceptOptions.shift(); + if (interceptOptions) { + navigateEvent.intercept(interceptOptions); + } + extraNavigateCallback?.(navigateEvent); + }); + navigation.addEventListener('currententrychange', (event: Event) => { + const currentNavigationEntryChangeEvent = event as FakeNavigationCurrentEntryChangeEvent; + locals.navigationCurrentEntryChangeEvents.push( + currentNavigationEntryChangeEvent, + ); + }); + locals = { + navigation, + navigateEvents, + navigationCurrentEntryChangeEvents, + popStateEvents, + pendingInterceptOptions, + nextNavigateEvent() { + return nextNavigateEventPromise; + }, + setExtraNavigateCallback(callback: (event: FakeNavigateEvent) => void) { + extraNavigateCallback = callback; + }, + }; + }); + + const setUpEntries = async ({hash = false} = {}) => { + locals.pendingInterceptOptions.push({}); + const pathPrefix = hash ? '#' : '/'; + const firstPageEntry = await locals.navigation + .navigate( + `${pathPrefix}page1`, + {state: {page1: true}}, + ) + .finished; + locals.pendingInterceptOptions.push({}); + const secondPageEntry = await locals.navigation + .navigate( + `${pathPrefix}page2`, + {state: {page2: true}}, + ) + .finished; + locals.pendingInterceptOptions.push({}); + const thirdPageEntry = await locals.navigation + .navigate( + `${pathPrefix}page3`, + {state: {page3: true}}, + ) + .finished; + locals.navigateEvents.length = 0; + locals.navigationCurrentEntryChangeEvents.length = 0; + locals.popStateEvents.length = 0; + return [firstPageEntry, secondPageEntry, thirdPageEntry]; + }; + + const setUpEntriesWithHistory = ({hash = false} = {}) => { + const pathPrefix = hash ? '#' : '/'; + locals.navigation.pushState( + {state: {page1: true}}, + '', + `${pathPrefix}page1`, + ); + const firstPageEntry = locals.navigation.currentEntry; + locals.navigation.pushState( + {state: {page2: true}}, + '', + `${pathPrefix}page2`, + ); + const secondPageEntry = locals.navigation.currentEntry; + locals.navigation.pushState( + {state: {page3: true}}, + '', + `${pathPrefix}page3`, + ); + const thirdPageEntry = locals.navigation.currentEntry; + locals.navigateEvents.length = 0; + locals.navigationCurrentEntryChangeEvents.length = 0; + locals.popStateEvents.length = 0; + return [firstPageEntry, secondPageEntry, thirdPageEntry]; + }; + + it('disposes', async () => { + expect(locals.navigation.isDisposed()).toBeFalse(); + const navigateEvents: Event[] = []; + locals.navigation.addEventListener('navigate', (event: Event) => { + navigateEvents.push(event); + }); + const navigationCurrentEntryChangeEvents: Event[] = []; + locals.navigation.addEventListener('currententrychange', (event: Event) => { + navigationCurrentEntryChangeEvents.push(event); + }); + + await locals.navigation.navigate('#page1').finished; + expect(navigateEvents.length).toBe(1); + expect(navigationCurrentEntryChangeEvents.length).toBe(1); + locals.navigation.dispose(); + // After a dispose, a different singleton. + expect(locals.navigation.isDisposed()).toBeTrue(); + await locals.navigation.navigate('#page2').finished; + // Listeners are disposed. + expect(navigateEvents.length).toBe(1); + expect(navigationCurrentEntryChangeEvents.length).toBe(1); + }); + + describe('navigate', () => { + it('push URL', async () => { + const initialEntry = locals.navigation.currentEntry; + locals.pendingInterceptOptions.push({}); + const {committed, finished} = locals.navigation.navigate('/test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(navigateEvent) + .toEqual( + jasmine.objectContaining({ + canIntercept: true, + hashChange: false, + info: undefined, + navigationType: 'push', + userInitiated: false, + signal: jasmine.any(AbortSignal), + destination: jasmine.objectContaining({ + url: 'https://test.com/test', + key: null, + id: null, + index: -1, + sameDocument: false, + }), + }), + ); + expect(navigateEvent.destination.getState()).toBeUndefined(); + const committedEntry = await committed; + expect(committedEntry) + .toEqual( + jasmine.objectContaining({ + url: 'https://test.com/test', + key: '1', + id: '1', + index: 1, + sameDocument: true, + }), + ); + expect(committedEntry.getState()).toBeUndefined(); + expect(locals.navigation.currentEntry).toBe(committedEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + const currentEntryChangeEvent = locals.navigationCurrentEntryChangeEvents[0]; + expect(currentEntryChangeEvent) + .toEqual( + jasmine.objectContaining({ + navigationType: 'push', + from: jasmine.objectContaining({ + url: initialEntry.url, + key: initialEntry.key, + id: initialEntry.id, + index: initialEntry.index, + sameDocument: initialEntry.sameDocument, + }), + }), + ); + expect(currentEntryChangeEvent.from.getState()) + .toBe( + initialEntry.getState(), + ); + expect(locals.popStateEvents.length).toBe(1); + const popStateEvent = locals.popStateEvents[0]; + expect(popStateEvent.state).toBeNull(); + const finishedEntry = await finished; + expect(committedEntry).toBe(finishedEntry); + expect(locals.navigation.currentEntry).toBe(finishedEntry); + expect(locals.navigateEvents.length).toBe(1); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + }); + + it('replace URL', async () => { + const initialEntry = locals.navigation.currentEntry; + locals.pendingInterceptOptions.push({}); + const {committed, finished} = locals.navigation.navigate('/test', { + history: 'replace', + }); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(navigateEvent) + .toEqual( + jasmine.objectContaining({ + canIntercept: true, + hashChange: false, + info: undefined, + navigationType: 'replace', + userInitiated: false, + signal: jasmine.any(AbortSignal), + destination: jasmine.objectContaining({ + url: 'https://test.com/test', + key: null, + id: null, + index: -1, + sameDocument: false, + }), + }), + ); + expect(navigateEvent.destination.getState()).toBeUndefined(); + const committedEntry = await committed; + expect(committedEntry) + .toEqual( + jasmine.objectContaining({ + url: 'https://test.com/test', + key: '0', + id: '1', + index: 0, + sameDocument: true, + }), + ); + expect(committedEntry.getState()).toBeUndefined(); + expect(locals.navigation.currentEntry).toBe(committedEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + const currentEntryChangeEvent = locals.navigationCurrentEntryChangeEvents[0]; + expect(currentEntryChangeEvent) + .toEqual( + jasmine.objectContaining({ + navigationType: 'replace', + from: jasmine.objectContaining({ + url: initialEntry.url, + key: initialEntry.key, + id: initialEntry.id, + index: initialEntry.index, + sameDocument: initialEntry.sameDocument, + }), + }), + ); + expect(currentEntryChangeEvent.from.getState()) + .toBe( + initialEntry.getState(), + ); + expect(locals.popStateEvents.length).toBe(1); + const popStateEvent = locals.popStateEvents[0]; + expect(popStateEvent.state).toBeNull(); + const finishedEntry = await finished; + expect(committedEntry).toBe(finishedEntry); + expect(locals.navigation.currentEntry).toBe(finishedEntry); + expect(locals.navigateEvents.length).toBe(1); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + }); + + it('push URL with state', async () => { + locals.pendingInterceptOptions.push({}); + const state = {test: true}; + const {committed, finished} = locals.navigation.navigate('/test', { + state, + }); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(navigateEvent.destination.getState()).toEqual(state); + const committedEntry = await committed; + expect(committedEntry.getState()).toEqual(state); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + const finishedEntry = await finished; + expect(committedEntry).toBe(finishedEntry); + }); + + it('replace URL with state', async () => { + locals.pendingInterceptOptions.push({}); + const state = {test: true}; + const {committed, finished} = locals.navigation.navigate('/test', { + state, + history: 'replace', + }); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(navigateEvent.destination.getState()).toEqual(state); + const committedEntry = await committed; + expect(committedEntry.getState()).toEqual(state); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + const finishedEntry = await finished; + expect(committedEntry).toBe(finishedEntry); + }); + + it('push URL with hashchange', async () => { + const {committed, finished} = locals.navigation.navigate('#test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(navigateEvent.destination.url).toBe('https://test.com/#test'); + expect(navigateEvent.hashChange).toBeTrue(); + const committedEntry = await committed; + expect(committedEntry.url).toBe('https://test.com/#test'); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + const finishedEntry = await finished; + expect(committedEntry).toBe(finishedEntry); + }); + + it('replace URL with hashchange', async () => { + const {committed, finished} = locals.navigation.navigate('#test', { + history: 'replace', + }); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(navigateEvent.destination.url).toBe('https://test.com/#test'); + expect(navigateEvent.hashChange).toBeTrue(); + const committedEntry = await committed; + expect(committedEntry.url).toBe('https://test.com/#test'); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + const finishedEntry = await finished; + expect(committedEntry).toBe(finishedEntry); + }); + + it('push URL with info', async () => { + locals.pendingInterceptOptions.push({}); + const info = {test: true}; + const {finished, committed} = locals.navigation.navigate('/test', {info}); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(navigateEvent.info).toBe(info); + await committed; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await finished; + }); + + it('replace URL with info', async () => { + locals.pendingInterceptOptions.push({}); + const info = {test: true}; + const {finished, committed} = locals.navigation.navigate('/test', { + info, + history: 'replace', + }); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(navigateEvent.info).toBe(info); + await committed; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await finished; + }); + + it('push URL with handler', async () => { + let handlerFinishedResolve!: ( + value: Promise|undefined, + ) => void; + const handlerFinished = new Promise((resolve) => { + handlerFinishedResolve = resolve; + }); + locals.pendingInterceptOptions.push({ + handler: () => handlerFinished, + }); + const {committed, finished} = locals.navigation.navigate('/test'); + expect(locals.navigateEvents.length).toBe(1); + const committedEntry = await committed; + expect(committedEntry) + .toEqual( + jasmine.objectContaining({ + url: 'https://test.com/test', + key: '1', + id: '1', + index: 1, + sameDocument: true, + }), + ); + expect(committedEntry.getState()).toBeUndefined(); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await expectAsync(finished).toBePending(); + handlerFinishedResolve(undefined); + await expectAsync(finished).toBeResolvedTo(committedEntry); + }); + + it('replace URL with handler', async () => { + let handlerFinishedResolve!: ( + value: Promise|undefined, + ) => void; + const handlerFinished = new Promise((resolve) => { + handlerFinishedResolve = resolve; + }); + locals.pendingInterceptOptions.push({ + handler: () => handlerFinished, + }); + const {committed, finished} = locals.navigation.navigate('/test', { + history: 'replace', + }); + expect(locals.navigateEvents.length).toBe(1); + const committedEntry = await committed; + expect(committedEntry) + .toEqual( + jasmine.objectContaining({ + url: 'https://test.com/test', + key: '0', + id: '1', + index: 0, + sameDocument: true, + }), + ); + expect(committedEntry.getState()).toBeUndefined(); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await expectAsync(finished).toBePending(); + handlerFinishedResolve(undefined); + await expectAsync(finished).toBeResolvedTo(committedEntry); + }); + + it('deferred commit', async () => { + let handlerFinishedResolve!: ( + value: Promise|undefined, + ) => void; + const handlerFinished = new Promise((resolve) => { + handlerFinishedResolve = resolve; + }); + locals.pendingInterceptOptions.push({ + handler: () => handlerFinished, + commit: 'after-transition', + }); + const {committed, finished} = locals.navigation.navigate('/test'); + expect(locals.navigateEvents.length).toBe(1); + await expectAsync(committed).toBePending(); + expect(locals.navigation.currentEntry.url).toBe('https://test.com/'); + locals.navigateEvents[0].commit(); + const committedEntry = await committed; + expect(committedEntry) + .toEqual( + jasmine.objectContaining({ + url: 'https://test.com/test', + key: '1', + id: '1', + index: 1, + sameDocument: true, + }), + ); + expect(committedEntry.getState()).toBeUndefined(); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await expectAsync(finished).toBePending(); + handlerFinishedResolve(undefined); + await expectAsync(finished).toBeResolvedTo(committedEntry); + }); + + it('deferred commit early resolve', async () => { + let handlerFinishedResolve!: ( + value: Promise|undefined, + ) => void; + const handlerFinished = new Promise((resolve) => { + handlerFinishedResolve = resolve; + }); + locals.pendingInterceptOptions.push({ + handler: () => handlerFinished, + commit: 'after-transition', + }); + const {committed, finished} = locals.navigation.navigate('/test'); + expect(locals.navigateEvents.length).toBe(1); + await expectAsync(committed).toBePending(); + expect(locals.navigation.currentEntry.url).toBe('https://test.com/'); + handlerFinishedResolve(undefined); + const committedEntry = await committed; + expect(committedEntry) + .toEqual( + jasmine.objectContaining({ + url: 'https://test.com/test', + key: '1', + id: '1', + index: 1, + sameDocument: true, + }), + ); + expect(committedEntry.getState()).toBeUndefined(); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await expectAsync(finished).toBeResolvedTo(committedEntry); + }); + + it('deferred commit during dispatch throws', async () => { + locals.pendingInterceptOptions.push({ + commit: 'after-transition', + }); + let called = false; + locals.setExtraNavigateCallback((event: FakeNavigateEvent) => { + expect(() => { + called = true; + event.commit(); + }).toThrowError(DOMException); + }); + const {finished} = locals.navigation.navigate('/test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + navigateEvent.commit(); + await finished; + expect(called).toBeTrue(); + }); + + it('deferred commit with immediate throws', async () => { + locals.pendingInterceptOptions.push({}); + const {finished} = locals.navigation.navigate('/test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(() => { + navigateEvent.commit(); + }).toThrowError(DOMException); + await finished; + }); + + it('deferred commit twice throws', async () => { + let handlerFinishedResolve!: ( + value: Promise|undefined, + ) => void; + const handlerFinished = new Promise((resolve) => { + handlerFinishedResolve = resolve; + }); + locals.pendingInterceptOptions.push({ + handler: () => handlerFinished, + commit: 'after-transition', + }); + const {committed, finished} = locals.navigation.navigate('/test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + await expectAsync(committed).toBePending(); + expect(locals.navigation.currentEntry.url).toBe('https://test.com/'); + navigateEvent.commit(); + expect(() => { + navigateEvent.commit(); + }).toThrowError(DOMException); + handlerFinishedResolve(undefined); + await finished; + }); + + it('deferred commit resolves on finished', async () => { + let handlerFinishedResolve!: ( + value: Promise|undefined, + ) => void; + const handlerFinished = new Promise((resolve) => { + handlerFinishedResolve = resolve; + }); + locals.pendingInterceptOptions.push({ + handler: () => handlerFinished, + commit: 'after-transition', + }); + const {committed, finished} = locals.navigation.navigate('/test'); + expect(locals.navigateEvents.length).toBe(1); + await expectAsync(committed).toBePending(); + expect(locals.navigation.currentEntry.url).toBe('https://test.com/'); + handlerFinishedResolve(undefined); + const committedEntry = await committed; + expect(committedEntry) + .toEqual( + jasmine.objectContaining({ + url: 'https://test.com/test', + key: '1', + id: '1', + index: 1, + sameDocument: true, + }), + ); + expect(committedEntry.getState()).toBeUndefined(); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await expectAsync(finished).toBeResolvedTo(committedEntry); + }); + + it('push with interruption', async () => { + locals.pendingInterceptOptions.push({ + handler: () => new Promise(() => {}), + }); + + const {committed, finished} = locals.navigation.navigate('/test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + await committed; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await expectAsync(finished).toBePending(); + locals.pendingInterceptOptions.push({}); + const interruptResult = locals.navigation.navigate('/interrupt'); + await expectAsync(finished).toBeRejectedWithError(DOMException); + expect(navigateEvent.signal.aborted).toBeTrue(); + await interruptResult.committed; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(2); + expect(locals.popStateEvents.length).toBe(2); + await interruptResult.finished; + }); + + it('replace with interruption', async () => { + locals.pendingInterceptOptions.push({ + handler: () => new Promise(() => {}), + }); + + const {committed, finished} = locals.navigation.navigate('/test', { + history: 'replace', + }); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + await committed; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await expectAsync(finished).toBePending(); + locals.pendingInterceptOptions.push({}); + const interruptResult = locals.navigation.navigate('/interrupt'); + await expectAsync(finished).toBeRejectedWithError(DOMException); + expect(navigateEvent.signal.aborted).toBeTrue(); + await interruptResult.committed; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(2); + expect(locals.popStateEvents.length).toBe(2); + await interruptResult.finished; + }); + + it('push with handler reject', async () => { + let handlerFinishedReject!: (reason: unknown) => void; + locals.pendingInterceptOptions.push({ + handler: () => new Promise((resolve, reject) => { + handlerFinishedReject = reject; + }), + }); + + const {committed, finished} = locals.navigation.navigate('/test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + await committed; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await expectAsync(finished).toBePending(); + const error = new Error('rejected'); + handlerFinishedReject(error); + await expectAsync(finished).toBeRejectedWith(error); + expect(navigateEvent.signal.aborted).toBeTrue(); + }); + + it('replace with reject', async () => { + let handlerFinishedReject!: (reason: unknown) => void; + locals.pendingInterceptOptions.push({ + handler: () => new Promise((resolve, reject) => { + handlerFinishedReject = reject; + }), + }); + + const {committed, finished} = locals.navigation.navigate('/test', { + history: 'replace', + }); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + await committed; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await expectAsync(finished).toBePending(); + const error = new Error('rejected'); + handlerFinishedReject(error); + await expectAsync(finished).toBeRejectedWith(error); + expect(navigateEvent.signal.aborted).toBeTrue(); + }); + }); + + describe('traversal', () => { + it('traverses back', async () => { + expect(locals.navigation.canGoBack).toBeFalse(); + expect(locals.navigation.canGoForward).toBeFalse(); + const [firstPageEntry, , thirdPageEntry] = await setUpEntries(); + + expect(locals.navigation.canGoBack).toBeTrue(); + expect(locals.navigation.canGoForward).toBeFalse(); + const {committed, finished} = locals.navigation.traverseTo( + firstPageEntry.key, + ); + const navigateEvent = await locals.nextNavigateEvent(); + expect(navigateEvent) + .toEqual( + jasmine.objectContaining({ + canIntercept: true, + hashChange: false, + info: undefined, + navigationType: 'traverse', + signal: jasmine.any(AbortSignal), + userInitiated: false, + destination: jasmine.objectContaining({ + url: firstPageEntry.url!, + key: firstPageEntry.key, + id: firstPageEntry.id, + index: firstPageEntry.index, + sameDocument: true, + }), + }), + ); + expect(navigateEvent.destination.getState()) + .toEqual( + firstPageEntry.getState(), + ); + const committedEntry = await committed; + expect(committedEntry).toBe(firstPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + const currentEntryChangeEvent = locals.navigationCurrentEntryChangeEvents[0]; + expect(currentEntryChangeEvent) + .toEqual( + jasmine.objectContaining({ + navigationType: 'traverse', + from: jasmine.objectContaining({ + url: thirdPageEntry.url!, + key: thirdPageEntry.key, + id: thirdPageEntry.id, + index: thirdPageEntry.index, + sameDocument: true, + }), + }), + ); + expect(currentEntryChangeEvent.from.getState()) + .toEqual( + thirdPageEntry.getState(), + ); + expect(locals.popStateEvents.length).toBe(1); + const popStateEvent = locals.popStateEvents[0]; + expect(popStateEvent.state).toBeNull(); + expect(locals.navigation.canGoBack).toBeTrue(); + expect(locals.navigation.canGoForward).toBeTrue(); + const finishedEntry = await finished; + expect(finishedEntry).toBe(firstPageEntry); + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + expect(locals.navigateEvents.length).toBe(1); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + }); + + it('traverses forward', async () => { + expect(locals.navigation.canGoBack).toBeFalse(); + expect(locals.navigation.canGoForward).toBeFalse(); + const [firstPageEntry, , thirdPageEntry] = await setUpEntries(); + expect(locals.navigation.canGoBack).toBeTrue(); + expect(locals.navigation.canGoForward).toBeFalse(); + await locals.navigation.traverseTo(firstPageEntry.key).finished; + locals.navigateEvents.length = 0; + locals.navigationCurrentEntryChangeEvents.length = 0; + locals.popStateEvents.length = 0; + expect(locals.navigation.canGoBack).toBeTrue(); + expect(locals.navigation.canGoForward).toBeTrue(); + + const {committed, finished} = locals.navigation.traverseTo( + thirdPageEntry.key, + ); + const navigateEvent = await locals.nextNavigateEvent(); + expect(navigateEvent) + .toEqual( + jasmine.objectContaining({ + canIntercept: true, + hashChange: false, + info: undefined, + navigationType: 'traverse', + signal: jasmine.any(AbortSignal), + userInitiated: false, + destination: jasmine.objectContaining({ + url: thirdPageEntry.url!, + key: thirdPageEntry.key, + id: thirdPageEntry.id, + index: thirdPageEntry.index, + sameDocument: true, + }), + }), + ); + expect(navigateEvent.destination.getState()) + .toEqual( + thirdPageEntry.getState(), + ); + const committedEntry = await committed; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + const currentEntryChangeEvent = locals.navigationCurrentEntryChangeEvents[0]; + expect(currentEntryChangeEvent) + .toEqual( + jasmine.objectContaining({ + navigationType: 'traverse', + from: jasmine.objectContaining({ + url: firstPageEntry.url!, + key: firstPageEntry.key, + id: firstPageEntry.id, + index: firstPageEntry.index, + sameDocument: true, + }), + }), + ); + expect(currentEntryChangeEvent.from.getState()) + .toEqual( + firstPageEntry.getState(), + ); + expect(locals.popStateEvents.length).toBe(1); + const popStateEvent = locals.popStateEvents[0]; + expect(popStateEvent.state).toBeNull(); + expect(committedEntry).toBe(thirdPageEntry); + const finishedEntry = await finished; + expect(finishedEntry).toBe(thirdPageEntry); + expect(locals.navigation.currentEntry).toBe(thirdPageEntry); + expect(locals.navigateEvents.length).toBe(1); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + expect(locals.navigation.canGoBack).toBeTrue(); + expect(locals.navigation.canGoForward).toBeFalse(); + }); + + it('traverses back with hashchange', async () => { + const [firstPageEntry] = await setUpEntries({hash: true}); + + const {finished, committed} = locals.navigation.traverseTo( + firstPageEntry.key, + ); + const navigateEvent = await locals.nextNavigateEvent(); + expect(navigateEvent.hashChange).toBeTrue(); + await committed; + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await finished; + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + }); + + it('traverses forward with hashchange', async () => { + const [firstPageEntry, thirdPageEntry] = await setUpEntries({hash: true}); + await locals.navigation.traverseTo(firstPageEntry.key).finished; + locals.navigateEvents.length = 0; + locals.navigationCurrentEntryChangeEvents.length = 0; + locals.popStateEvents.length = 0; + + const {finished, committed} = locals.navigation.traverseTo( + thirdPageEntry.key, + ); + const navigateEvent = await locals.nextNavigateEvent(); + expect(navigateEvent.hashChange).toBeTrue(); + await committed; + expect(locals.navigation.currentEntry).toBe(thirdPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await finished; + expect(locals.navigation.currentEntry).toBe(thirdPageEntry); + }); + + it('traverses with info', async () => { + const [firstPageEntry] = await setUpEntries(); + const info = {test: true}; + const {finished, committed} = locals.navigation.traverseTo( + firstPageEntry.key, + {info}, + ); + const navigateEvent = await locals.nextNavigateEvent(); + expect(navigateEvent.info).toBe(info); + await committed; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await finished; + }); + + it('traverses with history state', async () => { + const [firstPageEntry] = setUpEntriesWithHistory(); + + const {finished, committed} = locals.navigation.traverseTo( + firstPageEntry.key, + ); + const navigateEvent = await locals.nextNavigateEvent(); + expect(navigateEvent.destination.getHistoryState()) + .toEqual( + firstPageEntry.getHistoryState(), + ); + await committed; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + const popStateEvent = locals.popStateEvents[0]; + expect(popStateEvent.state).toEqual(firstPageEntry.getHistoryState()); + await finished; + }); + + it('traverses with handler', async () => { + const [firstPageEntry] = await setUpEntries(); + let handlerFinishedResolve!: ( + value: Promise|undefined, + ) => void; + const handlerFinished = new Promise((resolve) => { + handlerFinishedResolve = resolve; + }); + locals.pendingInterceptOptions.push({ + handler: () => handlerFinished, + }); + const {committed, finished} = locals.navigation.traverseTo( + firstPageEntry.key, + ); + const committedEntry = await committed; + expect(committedEntry).toBe(firstPageEntry); + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + expect(locals.navigateEvents.length).toBe(1); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await expectAsync(finished).toBePending(); + handlerFinishedResolve(undefined); + await expectAsync(finished).toBeResolvedTo(firstPageEntry); + }); + + it('traverses with interruption', async () => { + const [firstPageEntry] = await setUpEntries(); + locals.pendingInterceptOptions.push({ + handler: () => new Promise(() => {}), + }); + const {committed, finished} = locals.navigation.traverseTo( + firstPageEntry.key, + ); + const navigateEvent = await locals.nextNavigateEvent(); + await committed; + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + expect(navigateEvent.signal.aborted).toBeFalse(); + await expectAsync(finished).toBePending(); + locals.pendingInterceptOptions.push({}); + const interruptResult = locals.navigation.navigate('/interrupt'); + await expectAsync(finished).toBeRejectedWithError(DOMException); + expect(navigateEvent.signal.aborted).toBeTrue(); + await interruptResult.committed; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(2); + expect(locals.popStateEvents.length).toBe(2); + await interruptResult.finished; + }); + + it('traverses with reject', async () => { + const [firstPageEntry] = await setUpEntries(); + let handlerFinishedReject!: (reason: unknown) => void; + locals.pendingInterceptOptions.push({ + handler: () => new Promise((resolve, reject) => { + handlerFinishedReject = reject; + }), + }); + + const {committed, finished} = locals.navigation.traverseTo( + firstPageEntry.key, + ); + const navigateEvent = await locals.nextNavigateEvent(); + await committed; + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + expect(navigateEvent.signal.aborted).toBeFalse(); + await expectAsync(finished).toBePending(); + const error = new Error('rejected'); + handlerFinishedReject(error); + await expectAsync(finished).toBeRejectedWith(error); + expect(navigateEvent.signal.aborted).toBeTrue(); + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + }); + + it('traverses to non-existent', async () => { + const {committed, finished} = locals.navigation.traverseTo('non-existent'); + await expectAsync(committed).toBeRejectedWithError(DOMException); + await expectAsync(finished).toBeRejectedWithError(DOMException); + expect(locals.navigateEvents.length).toBe(0); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(0); + expect(locals.popStateEvents.length).toBe(0); + }); + + it('back', async () => { + const [, secondPageEntry, thirdPageEntry] = await setUpEntries(); + const {committed, finished} = locals.navigation.back(); + const navigateEvent = await locals.nextNavigateEvent(); + expect(navigateEvent) + .toEqual( + jasmine.objectContaining({ + canIntercept: true, + hashChange: false, + info: undefined, + navigationType: 'traverse', + signal: jasmine.any(AbortSignal), + userInitiated: false, + destination: jasmine.objectContaining({ + url: secondPageEntry.url!, + key: secondPageEntry.key, + id: secondPageEntry.id, + index: secondPageEntry.index, + sameDocument: true, + }), + }), + ); + expect(navigateEvent.destination.getState()) + .toEqual( + secondPageEntry.getState(), + ); + const committedEntry = await committed; + expect(committedEntry).toBe(secondPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + const currentEntryChangeEvent = locals.navigationCurrentEntryChangeEvents[0]; + expect(currentEntryChangeEvent) + .toEqual( + jasmine.objectContaining({ + navigationType: 'traverse', + from: jasmine.objectContaining({ + url: thirdPageEntry.url!, + key: thirdPageEntry.key, + id: thirdPageEntry.id, + index: thirdPageEntry.index, + sameDocument: true, + }), + }), + ); + expect(currentEntryChangeEvent.from.getState()) + .toEqual( + thirdPageEntry.getState(), + ); + expect(locals.popStateEvents.length).toBe(1); + const popStateEvent = locals.popStateEvents[0]; + expect(popStateEvent.state).toBeNull(); + const finishedEntry = await finished; + expect(finishedEntry).toBe(secondPageEntry); + expect(locals.navigation.currentEntry).toBe(secondPageEntry); + }); + + it('back with info', async () => { + await setUpEntries(); + const info = {test: true}; + const {committed, finished} = locals.navigation.back({info}); + const navigateEvent = await locals.nextNavigateEvent(); + expect(navigateEvent.info).toBe(info); + await committed; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await finished; + }); + + it('back out of bounds', async () => { + const {committed, finished} = locals.navigation.back(); + await expectAsync(committed).toBeRejectedWithError(DOMException); + await expectAsync(finished).toBeRejectedWithError(DOMException); + expect(locals.navigateEvents.length).toBe(0); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(0); + expect(locals.popStateEvents.length).toBe(0); + }); + + it('forward', async () => { + const [firstPageEntry, secondPageEntry] = await setUpEntries(); + await locals.navigation.traverseTo(firstPageEntry.key).finished; + locals.navigateEvents.length = 0; + locals.navigationCurrentEntryChangeEvents.length = 0; + locals.popStateEvents.length = 0; + + const {committed, finished} = locals.navigation.forward(); + const navigateEvent = await locals.nextNavigateEvent(); + expect(navigateEvent) + .toEqual( + jasmine.objectContaining({ + canIntercept: true, + hashChange: false, + info: undefined, + navigationType: 'traverse', + signal: jasmine.any(AbortSignal), + userInitiated: false, + destination: jasmine.objectContaining({ + url: secondPageEntry.url!, + key: secondPageEntry.key, + id: secondPageEntry.id, + index: secondPageEntry.index, + sameDocument: true, + }), + }), + ); + expect(navigateEvent.destination.getState()) + .toEqual( + secondPageEntry.getState(), + ); + const committedEntry = await committed; + expect(committedEntry).toBe(secondPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + const currentEntryChangeEvent = locals.navigationCurrentEntryChangeEvents[0]; + expect(currentEntryChangeEvent) + .toEqual( + jasmine.objectContaining({ + navigationType: 'traverse', + from: jasmine.objectContaining({ + url: firstPageEntry.url!, + key: firstPageEntry.key, + id: firstPageEntry.id, + index: firstPageEntry.index, + sameDocument: true, + }), + }), + ); + expect(currentEntryChangeEvent.from.getState()) + .toEqual( + firstPageEntry.getState(), + ); + expect(locals.popStateEvents.length).toBe(1); + const popStateEvent = locals.popStateEvents[0]; + expect(popStateEvent.state).toBeNull(); + const finishedEntry = await finished; + expect(finishedEntry).toBe(secondPageEntry); + expect(locals.navigation.currentEntry).toBe(secondPageEntry); + }); + + it('forward with info', async () => { + const [firstPageEntry] = await setUpEntries(); + await locals.navigation.traverseTo(firstPageEntry.key).finished; + locals.navigateEvents.length = 0; + locals.navigationCurrentEntryChangeEvents.length = 0; + locals.popStateEvents.length = 0; + + const info = {test: true}; + const {committed, finished} = locals.navigation.forward({info}); + const navigateEvent = await locals.nextNavigateEvent(); + expect(navigateEvent.info).toBe(info); + await committed; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await finished; + }); + + it('forward out of bounds', async () => { + const {committed, finished} = locals.navigation.forward(); + await expectAsync(committed).toBeRejectedWithError(DOMException); + await expectAsync(finished).toBeRejectedWithError(DOMException); + expect(locals.navigateEvents.length).toBe(0); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(0); + expect(locals.popStateEvents.length).toBe(0); + }); + + it('traverse synchronously', async () => { + const [, secondPageEntry] = await setUpEntries(); + locals.navigation.setSynchronousTraversalsForTesting(true); + + const {committed, finished} = locals.navigation.back(); + // Synchronously navigates. + expect(locals.navigation.currentEntry).toBe(secondPageEntry); + await expectAsync(committed).toBeResolvedTo(secondPageEntry); + await expectAsync(finished).toBeResolvedTo(secondPageEntry); + }); + + it('traversal current entry', async () => { + const {committed, finished} = locals.navigation.traverseTo( + locals.navigation.currentEntry.key, + ); + await expectAsync(committed).toBeResolvedTo( + locals.navigation.currentEntry, + ); + await expectAsync(finished).toBeResolvedTo( + locals.navigation.currentEntry, + ); + expect(locals.navigateEvents.length).toBe(0); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(0); + expect(locals.popStateEvents.length).toBe(0); + }); + + it('second traversal to same entry', async () => { + const [firstPageEntry] = await setUpEntries(); + const traverseResult = locals.navigation.traverseTo(firstPageEntry.key); + const duplicateTraverseResult = locals.navigation.traverseTo( + firstPageEntry.key, + ); + expect(traverseResult.committed).toBe(duplicateTraverseResult.committed); + expect(traverseResult.finished).toBe(duplicateTraverseResult.finished); + await Promise.all([ + traverseResult.committed, + duplicateTraverseResult.committed, + ]); + // Only one NavigationCurrentEntryChangeEvent for duplicate traversals + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + await Promise.all([ + traverseResult.finished, + duplicateTraverseResult.finished, + ]); + // Only one NavigateEvent for duplicate traversals. + expect(locals.navigateEvents.length).toBe(1); + }); + + it('queues traverses', async () => { + const [firstPageEntry, secondPageEntry] = await setUpEntries(); + + const firstTraverseResult = locals.navigation.traverseTo( + firstPageEntry.key, + ); + const secondTraverseResult = locals.navigation.traverseTo( + secondPageEntry.key, + ); + + const firstTraverseCommittedEntry = await firstTraverseResult.committed; + expect(firstTraverseCommittedEntry).toBe(firstPageEntry); + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + expect(locals.navigateEvents.length).toBe(1); + const firstNavigateEvent = locals.navigateEvents[0]; + expect(firstNavigateEvent) + .toEqual( + jasmine.objectContaining({ + navigationType: 'traverse', + destination: jasmine.objectContaining({ + key: firstPageEntry.key, + }), + }), + ); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + const firstTraverseFinishedEntry = await firstTraverseResult.finished; + expect(firstTraverseFinishedEntry).toBe(firstPageEntry); + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + + const secondTraverseCommittedEntry = await secondTraverseResult.committed; + expect(secondTraverseCommittedEntry).toBe(secondPageEntry); + expect(locals.navigation.currentEntry).toBe(secondPageEntry); + expect(locals.navigateEvents.length).toBe(2); + const secondNavigateEvent = locals.navigateEvents[1]; + expect(secondNavigateEvent) + .toEqual( + jasmine.objectContaining({ + navigationType: 'traverse', + destination: jasmine.objectContaining({ + key: secondPageEntry.key, + }), + }), + ); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(2); + expect(locals.popStateEvents.length).toBe(2); + const secondTraverseFinishedEntry = await secondTraverseResult.finished; + expect(secondTraverseFinishedEntry).toBe(secondPageEntry); + expect(locals.navigation.currentEntry).toBe(secondPageEntry); + }); + }); + + describe('integration', () => { + it('queues traverses after navigate', async () => { + const [firstPageEntry, secondPageEntry] = await setUpEntries(); + + const firstTraverseResult = locals.navigation.traverseTo( + firstPageEntry.key, + ); + const secondTraverseResult = locals.navigation.traverseTo( + secondPageEntry.key, + ); + locals.pendingInterceptOptions.push({}); + const navigateResult = locals.navigation.navigate('/page4', { + state: {page4: true}, + }); + + const navigateResultCommittedEntry = await navigateResult.committed; + expect(navigateResultCommittedEntry.url).toBe('https://test.com/page4'); + expect(locals.navigation.currentEntry).toBe(navigateResultCommittedEntry); + expect(locals.navigateEvents.length).toBe(1); + const firstNavigateEvent = locals.navigateEvents[0]; + expect(firstNavigateEvent) + .toEqual( + jasmine.objectContaining({ + navigationType: 'push', + destination: jasmine.objectContaining({ + url: 'https://test.com/page4', + }), + }), + ); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + const navigateResultFinishedEntry = await navigateResult.finished; + expect(navigateResultFinishedEntry).toBe(navigateResultCommittedEntry); + expect(locals.navigation.currentEntry).toBe(navigateResultCommittedEntry); + + const firstTraverseCommittedEntry = await firstTraverseResult.committed; + expect(firstTraverseCommittedEntry).toBe(firstPageEntry); + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + expect(locals.navigateEvents.length).toBe(2); + const secondNavigateEvent = locals.navigateEvents[1]; + expect(secondNavigateEvent) + .toEqual( + jasmine.objectContaining({ + navigationType: 'traverse', + destination: jasmine.objectContaining({ + key: firstPageEntry.key, + }), + }), + ); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(2); + expect(locals.popStateEvents.length).toBe(2); + const firstTraverseFinishedEntry = await firstTraverseResult.finished; + expect(firstTraverseFinishedEntry).toBe(firstPageEntry); + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + + const secondTraverseCommittedEntry = await secondTraverseResult.committed; + expect(secondTraverseCommittedEntry).toBe(secondPageEntry); + expect(locals.navigation.currentEntry).toBe(secondPageEntry); + expect(locals.navigateEvents.length).toBe(3); + const thirdNavigateEvent = locals.navigateEvents[2]; + expect(thirdNavigateEvent) + .toEqual( + jasmine.objectContaining({ + navigationType: 'traverse', + destination: jasmine.objectContaining({ + key: secondPageEntry.key, + }), + }), + ); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(3); + expect(locals.popStateEvents.length).toBe(3); + const secondTraverseFinishedEntry = await secondTraverseResult.finished; + expect(secondTraverseFinishedEntry).toBe(secondPageEntry); + expect(locals.navigation.currentEntry).toBe(secondPageEntry); + }); + }); + + describe('history API', () => { + describe('push and replace', () => { + it('push URL', async () => { + const initialEntry = locals.navigation.currentEntry; + + locals.navigation.pushState(undefined, '', '/test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(navigateEvent) + .toEqual( + jasmine.objectContaining({ + canIntercept: true, + hashChange: false, + info: undefined, + navigationType: 'push', + userInitiated: false, + signal: jasmine.any(AbortSignal), + destination: jasmine.objectContaining({ + url: 'https://test.com/test', + key: null, + id: null, + index: -1, + sameDocument: true, + }), + }), + ); + expect(navigateEvent.destination.getState()).toBeUndefined(); + expect(navigateEvent.destination.getHistoryState()).toBeUndefined(); + const currentEntry = locals.navigation.currentEntry; + expect(currentEntry) + .toEqual( + jasmine.objectContaining({ + url: 'https://test.com/test', + key: '1', + id: '1', + index: 1, + sameDocument: true, + }), + ); + expect(currentEntry.getState()).toBeUndefined(); + expect(currentEntry.getHistoryState()).toBeUndefined(); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + const currentEntryChangeEvent = locals.navigationCurrentEntryChangeEvents[0]; + expect(currentEntryChangeEvent) + .toEqual( + jasmine.objectContaining({ + navigationType: 'push', + from: jasmine.objectContaining({ + url: initialEntry.url, + key: initialEntry.key, + id: initialEntry.id, + index: initialEntry.index, + sameDocument: initialEntry.sameDocument, + }), + }), + ); + expect(currentEntryChangeEvent.from.getState()) + .toBe( + initialEntry.getState(), + ); + expect(currentEntryChangeEvent.from.getHistoryState()).toBeNull(); + expect(locals.popStateEvents.length).toBe(0); + }); + + it('replace URL', async () => { + const initialEntry = locals.navigation.currentEntry; + + locals.navigation.replaceState(null, '', '/test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(navigateEvent) + .toEqual( + jasmine.objectContaining({ + canIntercept: true, + hashChange: false, + info: undefined, + navigationType: 'replace', + userInitiated: false, + signal: jasmine.any(AbortSignal), + destination: jasmine.objectContaining({ + url: 'https://test.com/test', + key: null, + id: null, + index: -1, + sameDocument: true, + }), + }), + ); + expect(navigateEvent.destination.getState()).toBeUndefined(); + expect(navigateEvent.destination.getHistoryState()).toBeNull(); + const currentEntry = locals.navigation.currentEntry; + expect(currentEntry) + .toEqual( + jasmine.objectContaining({ + url: 'https://test.com/test', + key: '0', + id: '1', + index: 0, + sameDocument: true, + }), + ); + expect(currentEntry.getState()).toBeUndefined(); + expect(currentEntry.getHistoryState()).toBeNull(); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + const currentEntryChangeEvent = locals.navigationCurrentEntryChangeEvents[0]; + expect(currentEntryChangeEvent) + .toEqual( + jasmine.objectContaining({ + navigationType: 'replace', + from: jasmine.objectContaining({ + url: initialEntry.url, + key: initialEntry.key, + id: initialEntry.id, + index: initialEntry.index, + sameDocument: initialEntry.sameDocument, + }), + }), + ); + expect(currentEntryChangeEvent.from.getState()) + .toBe( + initialEntry.getState(), + ); + expect(currentEntryChangeEvent.from.getHistoryState()).toBeNull(); + expect(locals.popStateEvents.length).toBe(0); + }); + + it('push URL with history state', async () => { + locals.pendingInterceptOptions.push({}); + const state = {test: true}; + locals.navigation.pushState(state, '', '/test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(navigateEvent.destination.getHistoryState()).toEqual(state); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.navigation.currentEntry.getHistoryState()).toEqual(state); + expect(locals.popStateEvents.length).toBe(0); + }); + + it('replace URL with history state', async () => { + locals.pendingInterceptOptions.push({}); + const state = {test: true}; + locals.navigation.replaceState(state, '', '/test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(navigateEvent.destination.getHistoryState()).toEqual(state); + expect(locals.navigation.currentEntry.getHistoryState()).toEqual(state); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(0); + }); + + it('push URL with hashchange', async () => { + locals.navigation.pushState(null, '', '#test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(navigateEvent.destination.url).toBe('https://test.com/#test'); + expect(navigateEvent.hashChange).toBeTrue(); + expect(locals.navigation.currentEntry.url) + .toBe( + 'https://test.com/#test', + ); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(0); + }); + + it('replace URL with hashchange', async () => { + locals.navigation.replaceState(null, '', '#test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(navigateEvent.destination.url).toBe('https://test.com/#test'); + expect(navigateEvent.hashChange).toBeTrue(); + expect(locals.navigation.currentEntry.url) + .toBe( + 'https://test.com/#test', + ); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(0); + }); + + it('push URL with handler', async () => { + let handlerFinishedResolve!: ( + value: Promise|undefined, + ) => void; + const handlerFinished = new Promise((resolve) => { + handlerFinishedResolve = resolve; + }); + locals.pendingInterceptOptions.push({ + handler: () => handlerFinished, + }); + locals.navigation.pushState(null, '', '/test'); + expect(locals.navigateEvents.length).toBe(1); + const currentEntry = locals.navigation.currentEntry; + expect(currentEntry.url).toBe('https://test.com/test'); + expect(currentEntry.key).toBe('1'); + expect(currentEntry.id).toBe('1'); + expect(currentEntry.index).toBe(1); + expect(currentEntry.sameDocument).toBeTrue(); + expect(currentEntry.getState()).toBeUndefined(); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(0); + handlerFinishedResolve(undefined); + expect(locals.navigation.currentEntry).toBe(currentEntry); + }); + + it('replace URL with handler', async () => { + let handlerFinishedResolve!: ( + value: Promise|undefined, + ) => void; + const handlerFinished = new Promise((resolve) => { + handlerFinishedResolve = resolve; + }); + locals.pendingInterceptOptions.push({ + handler: () => handlerFinished, + }); + locals.navigation.replaceState(null, '', '/test'); + expect(locals.navigateEvents.length).toBe(1); + const currentEntry = locals.navigation.currentEntry; + expect(currentEntry.url).toBe('https://test.com/test'); + expect(currentEntry.key).toBe('0'); + expect(currentEntry.id).toBe('1'); + expect(currentEntry.index).toBe(0); + expect(currentEntry.sameDocument).toBeTrue(); + expect(currentEntry.getState()).toBeUndefined(); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(0); + handlerFinishedResolve(undefined); + expect(locals.navigation.currentEntry).toBe(currentEntry); + }); + + it('push with interruption', async () => { + locals.pendingInterceptOptions.push({ + handler: () => new Promise(() => {}), + }); + + locals.navigation.pushState(null, '', '/test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(0); + locals.pendingInterceptOptions.push({}); + const interruptResult = locals.navigation.navigate('/interrupt'); + expect(navigateEvent.signal.aborted).toBeTrue(); + await interruptResult.committed; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(2); + expect(locals.popStateEvents.length).toBe(1); + await interruptResult.finished; + }); + + it('replace with interruption', async () => { + locals.pendingInterceptOptions.push({ + handler: () => new Promise(() => {}), + }); + + locals.navigation.replaceState(null, '', '/test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(0); + locals.pendingInterceptOptions.push({}); + const interruptResult = locals.navigation.navigate('/interrupt'); + expect(navigateEvent.signal.aborted).toBeTrue(); + await interruptResult.committed; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(2); + expect(locals.popStateEvents.length).toBe(1); + await interruptResult.finished; + }); + + it('push with handler reject', async () => { + let handlerFinishedReject!: (reason: unknown) => void; + const handlerPromise = new Promise((resolve, reject) => { + handlerFinishedReject = reject; + }); + locals.pendingInterceptOptions.push({ + handler: () => handlerPromise, + }); + + locals.navigation.pushState(null, '', '/test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(0); + const error = new Error('rejected'); + handlerFinishedReject(error); + await expectAsync(handlerPromise).toBeRejectedWith(error); + expect(navigateEvent.signal.aborted).toBeTrue(); + }); + + it('replace with reject', async () => { + let handlerFinishedReject!: (reason: unknown) => void; + const handlerPromise = new Promise((resolve, reject) => { + handlerFinishedReject = reject; + }); + locals.pendingInterceptOptions.push({ + handler: () => handlerPromise, + }); + + locals.navigation.replaceState(null, '', '/test'); + expect(locals.navigateEvents.length).toBe(1); + const navigateEvent = locals.navigateEvents[0]; + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(0); + const error = new Error('rejected'); + handlerFinishedReject(error); + await expectAsync(handlerPromise).toBeRejectedWith(error); + expect(navigateEvent.signal.aborted).toBeTrue(); + }); + }); + + describe('traversal', () => { + it('go back', async () => { + expect(locals.navigation.canGoBack).toBeFalse(); + expect(locals.navigation.canGoForward).toBeFalse(); + const [firstPageEntry, , thirdPageEntry] = await setUpEntries(); + expect(locals.navigation.canGoBack).toBeTrue(); + expect(locals.navigation.canGoForward).toBeFalse(); + locals.navigation.go(-2); + const navigateEvent = await locals.nextNavigateEvent(); + expect(navigateEvent) + .toEqual( + jasmine.objectContaining({ + canIntercept: true, + hashChange: false, + info: undefined, + navigationType: 'traverse', + signal: jasmine.any(AbortSignal), + userInitiated: false, + destination: jasmine.objectContaining({ + url: firstPageEntry.url!, + key: firstPageEntry.key, + id: firstPageEntry.id, + index: firstPageEntry.index, + sameDocument: true, + }), + }), + ); + expect(navigateEvent.destination.getState()) + .toEqual( + firstPageEntry.getState(), + ); + expect(navigateEvent.destination.getHistoryState()) + .toEqual( + firstPageEntry.getHistoryState(), + ); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + const currentEntryChangeEvent = locals.navigationCurrentEntryChangeEvents[0]; + expect(currentEntryChangeEvent) + .toEqual( + jasmine.objectContaining({ + navigationType: 'traverse', + from: jasmine.objectContaining({ + url: thirdPageEntry.url!, + key: thirdPageEntry.key, + id: thirdPageEntry.id, + index: thirdPageEntry.index, + sameDocument: true, + }), + }), + ); + expect(currentEntryChangeEvent.from.getState()) + .toEqual( + thirdPageEntry.getState(), + ); + expect(currentEntryChangeEvent.from.getHistoryState()) + .toEqual( + thirdPageEntry.getHistoryState(), + ); + expect(locals.popStateEvents.length).toBe(1); + const popStateEvent = locals.popStateEvents[0]; + expect(popStateEvent.state).toBeNull(); + expect(locals.navigation.canGoBack).toBeTrue(); + expect(locals.navigation.canGoForward).toBeTrue(); + }); + + it('go forward', async () => { + expect(locals.navigation.canGoBack).toBeFalse(); + expect(locals.navigation.canGoForward).toBeFalse(); + const [firstPageEntry, , thirdPageEntry] = await setUpEntries(); + await locals.navigation.traverseTo(firstPageEntry.key).finished; + locals.navigateEvents.length = 0; + locals.navigationCurrentEntryChangeEvents.length = 0; + locals.popStateEvents.length = 0; + expect(locals.navigation.canGoBack).toBeTrue(); + expect(locals.navigation.canGoForward).toBeTrue(); + + locals.navigation.go(2); + const navigateEvent = await locals.nextNavigateEvent(); + expect(navigateEvent) + .toEqual( + jasmine.objectContaining({ + canIntercept: true, + hashChange: false, + info: undefined, + navigationType: 'traverse', + signal: jasmine.any(AbortSignal), + userInitiated: false, + destination: jasmine.objectContaining({ + url: thirdPageEntry.url!, + key: thirdPageEntry.key, + id: thirdPageEntry.id, + index: thirdPageEntry.index, + sameDocument: true, + }), + }), + ); + expect(navigateEvent.destination.getState()) + .toEqual( + thirdPageEntry.getState(), + ); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + const currentEntryChangeEvent = locals.navigationCurrentEntryChangeEvents[0]; + expect(currentEntryChangeEvent) + .toEqual( + jasmine.objectContaining({ + navigationType: 'traverse', + from: jasmine.objectContaining({ + url: firstPageEntry.url!, + key: firstPageEntry.key, + id: firstPageEntry.id, + index: firstPageEntry.index, + sameDocument: true, + }), + }), + ); + expect(currentEntryChangeEvent.from.getState()) + .toEqual( + firstPageEntry.getState(), + ); + expect(locals.popStateEvents.length).toBe(1); + const popStateEvent = locals.popStateEvents[0]; + expect(popStateEvent.state).toBeNull(); + expect(locals.navigation.currentEntry).toBe(thirdPageEntry); + expect(locals.navigateEvents.length).toBe(1); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + expect(locals.navigation.canGoBack).toBeTrue(); + expect(locals.navigation.canGoForward).toBeFalse(); + }); + + it('go back with hashchange', async () => { + await setUpEntries({hash: true}); + + locals.navigation.go(-2); + const navigateEvent = await locals.nextNavigateEvent(); + expect(navigateEvent.hashChange).toBeTrue(); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + }); + + it('go back with history state', async () => { + const [firstPageEntry] = setUpEntriesWithHistory(); + + locals.navigation.go(-2); + const navigateEvent = await locals.nextNavigateEvent(); + expect(navigateEvent.destination.getHistoryState()) + .toEqual( + firstPageEntry.getHistoryState(), + ); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + const popStateEvent = locals.popStateEvents[0]; + expect(popStateEvent.state).toEqual(firstPageEntry.getHistoryState()); + }); + + it('go forward with hashchange', async () => { + const [firstPageEntry] = await setUpEntries({hash: true}); + await locals.navigation.traverseTo(firstPageEntry.key).finished; + locals.navigateEvents.length = 0; + locals.navigationCurrentEntryChangeEvents.length = 0; + locals.popStateEvents.length = 0; + + locals.navigation.go(2); + const navigateEvent = await locals.nextNavigateEvent(); + expect(navigateEvent.hashChange).toBeTrue(); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + }); + + it('go with handler', async () => { + const [firstPageEntry] = await setUpEntries(); + let handlerFinishedResolve!: ( + value: Promise|undefined, + ) => void; + const handlerFinished = new Promise((resolve) => { + handlerFinishedResolve = resolve; + }); + locals.pendingInterceptOptions.push({ + handler: () => handlerFinished, + }); + locals.navigation.go(-2); + await locals.nextNavigateEvent(); + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + handlerFinishedResolve(undefined); + }); + + it('go with interruption', async () => { + const [firstPageEntry] = await setUpEntries(); + locals.pendingInterceptOptions.push({ + handler: () => new Promise(() => {}), + }); + locals.navigation.go(-2); + const navigateEvent = await locals.nextNavigateEvent(); + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + expect(navigateEvent.signal.aborted).toBeFalse(); + locals.pendingInterceptOptions.push({}); + const interruptResult = locals.navigation.navigate('/interrupt'); + await interruptResult.committed; + expect(navigateEvent.signal.aborted).toBeTrue(); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(2); + expect(locals.popStateEvents.length).toBe(2); + await interruptResult.finished; + }); + + it('go with reject', async () => { + const [firstPageEntry] = await setUpEntries(); + let handlerFinishedReject!: (reason: unknown) => void; + locals.pendingInterceptOptions.push({ + handler: () => new Promise((resolve, reject) => { + handlerFinishedReject = reject; + }), + }); + + locals.navigation.go(-2); + const navigateEvent = await locals.nextNavigateEvent(); + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + expect(navigateEvent.signal.aborted).toBeFalse(); + const error = new Error('rejected'); + handlerFinishedReject(error); + await new Promise((resolve) => { + navigateEvent.signal.addEventListener('abort', resolve); + }); + expect(navigateEvent.signal.aborted).toBeTrue(); + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + }); + + it('go synchronously', async () => { + const [, secondPageEntry] = await setUpEntries(); + locals.navigation.setSynchronousTraversalsForTesting(true); + + locals.navigation.go(-1); + // Synchronously navigates. + expect(locals.navigation.currentEntry).toBe(secondPageEntry); + await expectAsync(locals.nextNavigateEvent()).toBePending(); + }); + + it('go out of bounds', async () => { + locals.navigation.go(-1); + await expectAsync(locals.nextNavigateEvent()).toBePending(); + locals.navigation.go(1); + await expectAsync(locals.nextNavigateEvent()).toBePending(); + }); + + it('go queues', async () => { + const [firstPageEntry, secondPageEntry] = await setUpEntries(); + + locals.navigation.go(-1); + locals.navigation.go(-1); + const firstNavigateEvent = await locals.nextNavigateEvent(); + expect(firstNavigateEvent.destination.key).toBe(secondPageEntry.key); + expect(locals.navigation.currentEntry).toBe(secondPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + const secondNavigateEvent = await locals.nextNavigateEvent(); + expect(secondNavigateEvent.destination.key).toBe(firstPageEntry.key); + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(2); + expect(locals.popStateEvents.length).toBe(2); + }); + + it('go queues both directions', async () => { + const [firstPageEntry, secondPageEntry] = await setUpEntries(); + + locals.navigation.go(-2); + locals.navigation.go(1); + const firstNavigateEvent = await locals.nextNavigateEvent(); + expect(firstNavigateEvent.destination.key).toBe(firstPageEntry.key); + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + const secondNavigateEvent = await locals.nextNavigateEvent(); + expect(secondNavigateEvent.destination.key).toBe(secondPageEntry.key); + expect(locals.navigation.currentEntry).toBe(secondPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(2); + expect(locals.popStateEvents.length).toBe(2); + }); + + it('go queues with back', async () => { + const [firstPageEntry, secondPageEntry] = await setUpEntries(); + + locals.navigation.back(); + locals.navigation.go(-1); + const firstNavigateEvent = await locals.nextNavigateEvent(); + expect(firstNavigateEvent.destination.key).toBe(secondPageEntry.key); + expect(locals.navigation.currentEntry).toBe(secondPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + const secondNavigateEvent = await locals.nextNavigateEvent(); + expect(secondNavigateEvent.destination.key).toBe(firstPageEntry.key); + expect(locals.navigation.currentEntry).toBe(firstPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(2); + expect(locals.popStateEvents.length).toBe(2); + }); + + it('go queues with forward', async () => { + const [, secondPageEntry, thirdPageEntry] = await setUpEntries(); + await locals.navigation.back().finished; + locals.navigateEvents.length = 0; + locals.navigationCurrentEntryChangeEvents.length = 0; + locals.popStateEvents.length = 0; + + locals.navigation.forward(); + locals.navigation.go(-1); + const firstNavigateEvent = await locals.nextNavigateEvent(); + expect(firstNavigateEvent.destination.key).toBe(thirdPageEntry.key); + expect(locals.navigation.currentEntry).toBe(thirdPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + const secondNavigateEvent = await locals.nextNavigateEvent(); + expect(secondNavigateEvent.destination.key).toBe(secondPageEntry.key); + expect(locals.navigation.currentEntry).toBe(secondPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(2); + expect(locals.popStateEvents.length).toBe(2); + }); + + it('go after synchronous navigate', async () => { + const [, secondPageEntry, thirdPageEntry] = await setUpEntries(); + + // Back to /page2 + locals.navigation.go(-1); + // Push /interrupt on top of current /page3 + locals.pendingInterceptOptions.push({}); + const interruptResult = locals.navigation.navigate('/interrupt'); + // Back from /interrupt to /page3. + locals.navigation.go(-1); + const interruptEntry = await interruptResult.finished; + expect(locals.navigation.currentEntry).toBe(interruptEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(1); + expect(locals.popStateEvents.length).toBe(1); + const firstNavigateEvent = await locals.nextNavigateEvent(); + expect(firstNavigateEvent.destination.key).toBe(secondPageEntry.key); + expect(locals.navigation.currentEntry).toBe(secondPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(2); + expect(locals.popStateEvents.length).toBe(2); + const secondNavigateEvent = await locals.nextNavigateEvent(); + expect(secondNavigateEvent.destination.key).toBe(thirdPageEntry.key); + expect(locals.navigation.currentEntry).toBe(thirdPageEntry); + expect(locals.navigationCurrentEntryChangeEvents.length).toBe(3); + expect(locals.popStateEvents.length).toBe(3); + }); + }); + }); +}); diff --git a/packages/common/testing/src/navigation/fake_navigation.ts b/packages/common/testing/src/navigation/fake_navigation.ts new file mode 100644 index 00000000000..d7fd4f394f0 --- /dev/null +++ b/packages/common/testing/src/navigation/fake_navigation.ts @@ -0,0 +1,969 @@ +/** + * @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 + */ + +// Prevents deletion of `Event` from `globalThis` during module loading. +const Event = globalThis.Event; + +/** + * Fake implementation of user agent history and navigation behavior. This is a + * high-fidelity implementation of browser behavior that attempts to emulate + * things like traversal delay. + */ +export class FakeNavigation implements Navigation { + /** + * The fake implementation of an entries array. Only same-document entries + * allowed. + */ + private readonly entriesArr: FakeNavigationHistoryEntry[] = []; + + /** + * The current active entry index into `entriesArr`. + */ + private currentEntryIndex = 0; + + /** + * The current navigate event. + */ + private navigateEvent: InternalFakeNavigateEvent|undefined = undefined; + + /** + * A Map of pending traversals, so that traversals to the same entry can be + * re-used. + */ + private readonly traversalQueue = new Map(); + + /** + * A Promise that resolves when the previous traversals have finished. Used to + * simulate the cross-process communication necessary for traversals. + */ + private nextTraversal = Promise.resolve(); + + /** + * A prospective current active entry index, which includes unresolved + * traversals. Used by `go` to determine where navigations are intended to go. + */ + private prospectiveEntryIndex = 0; + + /** + * A test-only option to make traversals synchronous, rather than emulate + * cross-process communication. + */ + private synchronousTraversals = false; + + /** Whether to allow a call to setInitialEntryForTesting. */ + private canSetInitialEntry = true; + + /** `EventTarget` to dispatch events. */ + private eventTarget: EventTarget = this.window.document.createElement('div'); + + /** The next unique id for created entries. Replace recreates this id. */ + private nextId = 0; + + /** The next unique key for created entries. Replace inherits this id. */ + private nextKey = 0; + + /** Whether this fake is disposed. */ + private disposed = false; + + /** Equivalent to `navigation.currentEntry`. */ + get currentEntry(): FakeNavigationHistoryEntry { + return this.entriesArr[this.currentEntryIndex]; + } + + get canGoBack(): boolean { + return this.currentEntryIndex > 0; + } + + get canGoForward(): boolean { + return this.currentEntryIndex < this.entriesArr.length - 1; + } + + constructor(private readonly window: Window, private readonly baseURI: string) { + // First entry. + this.setInitialEntryForTesting('.'); + } + + /** + * Sets the initial entry. + */ + setInitialEntryForTesting( + url: string, + options: { + historyState: unknown; + // Allows setting the URL without resolving it against the base. + absoluteUrl?: boolean; + state?: unknown; + } = {historyState: null}, + ) { + if (!this.canSetInitialEntry) { + throw new Error( + 'setInitialEntryForTesting can only be called before any ' + + 'navigation has occurred', + ); + } + const currentInitialEntry = this.entriesArr[0]; + this.entriesArr[0] = new FakeNavigationHistoryEntry( + options.absoluteUrl ? url : new URL(url, this.baseURI).toString(), + { + index: 0, + key: currentInitialEntry?.key ?? String(this.nextKey++), + id: currentInitialEntry?.id ?? String(this.nextId++), + sameDocument: true, + historyState: options?.historyState, + state: options.state, + }, + ); + } + + /** Returns whether the initial entry is still eligible to be set. */ + canSetInitialEntryForTesting(): boolean { + return this.canSetInitialEntry; + } + + /** + * Sets whether to emulate traversals as synchronous rather than + * asynchronous. + */ + setSynchronousTraversalsForTesting(synchronousTraversals: boolean) { + this.synchronousTraversals = synchronousTraversals; + } + + /** Equivalent to `navigation.entries()`. */ + entries(): FakeNavigationHistoryEntry[] { + return this.entriesArr.slice(); + } + + /** Equivalent to `navigation.navigate()`. */ + navigate( + url: string, + options?: NavigationNavigateOptions, + ): FakeNavigationResult { + const fromUrl = new URL(this.currentEntry.url!, this.baseURI); + const toUrl = new URL(url, this.baseURI); + + let navigationType: NavigationTypeString; + if (!options?.history || options.history === 'auto') { + // Auto defaults to push, but if the URLs are the same, is a replace. + if (fromUrl.toString() === toUrl.toString()) { + navigationType = 'replace'; + } else { + navigationType = 'push'; + } + } else { + navigationType = options.history; + } + + const hashChange = isHashChange(fromUrl, toUrl); + + const destination = new FakeNavigationDestination({ + url: toUrl.toString(), + state: options?.state, + sameDocument: hashChange, + historyState: null, + }); + const result = new InternalNavigationResult(); + + this.userAgentNavigate(destination, result, { + navigationType, + cancelable: true, + canIntercept: true, + // Always false for navigate(). + userInitiated: false, + hashChange, + info: options?.info, + }); + + return { + committed: result.committed, + finished: result.finished, + }; + } + + /** Equivalent to `history.pushState()`. */ + pushState(data: unknown, title: string, url?: string): void { + this.pushOrReplaceState('push', data, title, url); + } + + /** Equivalent to `history.replaceState()`. */ + replaceState(data: unknown, title: string, url?: string): void { + this.pushOrReplaceState('replace', data, title, url); + } + + private pushOrReplaceState( + navigationType: NavigationTypeString, + data: unknown, + _title: string, + url?: string, + ): void { + const fromUrl = new URL(this.currentEntry.url!, this.baseURI); + const toUrl = url ? new URL(url, this.baseURI) : fromUrl; + + const hashChange = isHashChange(fromUrl, toUrl); + + const destination = new FakeNavigationDestination({ + url: toUrl.toString(), + sameDocument: true, + historyState: data, + }); + const result = new InternalNavigationResult(); + + this.userAgentNavigate(destination, result, { + navigationType, + cancelable: true, + canIntercept: true, + // Always false for pushState() or replaceState(). + userInitiated: false, + hashChange, + skipPopState: true, + }); + } + + /** Equivalent to `navigation.traverseTo()`. */ + traverseTo(key: string, options?: NavigationOptions): FakeNavigationResult { + const fromUrl = new URL(this.currentEntry.url!, this.baseURI); + const entry = this.findEntry(key); + if (!entry) { + const domException = new DOMException( + 'Invalid key', + 'InvalidStateError', + ); + const committed = Promise.reject(domException); + const finished = Promise.reject(domException); + committed.catch(() => {}); + finished.catch(() => {}); + return { + committed, + finished, + }; + } + if (entry === this.currentEntry) { + return { + committed: Promise.resolve(this.currentEntry), + finished: Promise.resolve(this.currentEntry), + }; + } + if (this.traversalQueue.has(entry.key)) { + const existingResult = this.traversalQueue.get(entry.key)!; + return { + committed: existingResult.committed, + finished: existingResult.finished, + }; + } + + const hashChange = isHashChange(fromUrl, new URL(entry.url!, this.baseURI)); + const destination = new FakeNavigationDestination({ + url: entry.url!, + state: entry.getState(), + historyState: entry.getHistoryState(), + key: entry.key, + id: entry.id, + index: entry.index, + sameDocument: entry.sameDocument, + }); + this.prospectiveEntryIndex = entry.index; + const result = new InternalNavigationResult(); + this.traversalQueue.set(entry.key, result); + this.runTraversal(() => { + this.traversalQueue.delete(entry.key); + this.userAgentNavigate(destination, result, { + navigationType: 'traverse', + cancelable: true, + canIntercept: true, + // Always false for traverseTo(). + userInitiated: false, + hashChange, + info: options?.info, + }); + }); + return { + committed: result.committed, + finished: result.finished, + }; + } + + /** Equivalent to `navigation.back()`. */ + back(options?: NavigationOptions): FakeNavigationResult { + if (this.currentEntryIndex === 0) { + const domException = new DOMException( + 'Cannot go back', + 'InvalidStateError', + ); + const committed = Promise.reject(domException); + const finished = Promise.reject(domException); + committed.catch(() => {}); + finished.catch(() => {}); + return { + committed, + finished, + }; + } + const entry = this.entriesArr[this.currentEntryIndex - 1]; + return this.traverseTo(entry.key, options); + } + + /** Equivalent to `navigation.forward()`. */ + forward(options?: NavigationOptions): FakeNavigationResult { + if (this.currentEntryIndex === this.entriesArr.length - 1) { + const domException = new DOMException( + 'Cannot go forward', + 'InvalidStateError', + ); + const committed = Promise.reject(domException); + const finished = Promise.reject(domException); + committed.catch(() => {}); + finished.catch(() => {}); + return { + committed, + finished, + }; + } + const entry = this.entriesArr[this.currentEntryIndex + 1]; + return this.traverseTo(entry.key, options); + } + + /** + * Equivalent to `history.go()`. + * Note that this method does not actually work precisely to how Chrome + * does, instead choosing a simpler model with less unexpected behavior. + * Chrome has a few edge case optimizations, for instance with repeated + * `back(); forward()` chains it collapses certain traversals. + */ + go(direction: number): void { + const targetIndex = this.prospectiveEntryIndex + direction; + if (targetIndex >= this.entriesArr.length || targetIndex < 0) { + return; + } + this.prospectiveEntryIndex = targetIndex; + this.runTraversal(() => { + // Check again that destination is in the entries array. + if (targetIndex >= this.entriesArr.length || targetIndex < 0) { + return; + } + const fromUrl = new URL(this.currentEntry.url!, this.baseURI); + const entry = this.entriesArr[targetIndex]; + const hashChange = isHashChange(fromUrl, new URL(entry.url!, this.baseURI)); + const destination = new FakeNavigationDestination({ + url: entry.url!, + state: entry.getState(), + historyState: entry.getHistoryState(), + key: entry.key, + id: entry.id, + index: entry.index, + sameDocument: entry.sameDocument, + }); + const result = new InternalNavigationResult(); + this.userAgentNavigate(destination, result, { + navigationType: 'traverse', + cancelable: true, + canIntercept: true, + // Always false for go(). + userInitiated: false, + hashChange, + }); + }); + } + + /** Runs a traversal synchronously or asynchronously */ + private runTraversal(traversal: () => void) { + if (this.synchronousTraversals) { + traversal(); + return; + } + + // Each traversal occupies a single timeout resolution. + // This means that Promises added to commit and finish should resolve + // before the next traversal. + this.nextTraversal = this.nextTraversal.then(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + traversal(); + }); + }); + }); + } + + /** Equivalent to `navigation.addEventListener()`. */ + addEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions|boolean, + ) { + this.eventTarget.addEventListener(type, callback, options); + } + + /** Equivalent to `navigation.removeEventListener()`. */ + removeEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: EventListenerOptions|boolean, + ) { + this.eventTarget.removeEventListener(type, callback, options); + } + + /** Equivalent to `navigation.dispatchEvent()` */ + dispatchEvent(event: Event): boolean { + return this.eventTarget.dispatchEvent(event); + } + + /** Cleans up resources. */ + dispose() { + // Recreate eventTarget to release current listeners. + // `document.createElement` because NodeJS `EventTarget` is incompatible with Domino's `Event`. + this.eventTarget = this.window.document.createElement('div'); + this.disposed = true; + } + + /** Returns whether this fake is disposed. */ + isDisposed() { + return this.disposed; + } + + /** Implementation for all navigations and traversals. */ + private userAgentNavigate( + destination: FakeNavigationDestination, + result: InternalNavigationResult, + options: InternalNavigateOptions, + ) { + // The first navigation should disallow any future calls to set the initial + // entry. + this.canSetInitialEntry = false; + if (this.navigateEvent) { + this.navigateEvent.cancel( + new DOMException('Navigation was aborted', 'AbortError'), + ); + this.navigateEvent = undefined; + } + + const navigateEvent = createFakeNavigateEvent({ + navigationType: options.navigationType, + cancelable: options.cancelable, + canIntercept: options.canIntercept, + userInitiated: options.userInitiated, + hashChange: options.hashChange, + signal: result.signal, + destination, + info: options.info, + sameDocument: destination.sameDocument, + skipPopState: options.skipPopState, + result, + userAgentCommit: () => { + this.userAgentCommit(); + }, + }); + + this.navigateEvent = navigateEvent; + this.eventTarget.dispatchEvent(navigateEvent); + navigateEvent.dispatchedNavigateEvent(); + if (navigateEvent.commitOption === 'immediate') { + navigateEvent.commit(/* internal= */ true); + } + } + + /** Implementation to commit a navigation. */ + private userAgentCommit() { + if (!this.navigateEvent) { + return; + } + const from = this.currentEntry; + if (!this.navigateEvent.sameDocument) { + const error = new Error('Cannot navigate to a non-same-document URL.'); + this.navigateEvent.cancel(error); + throw error; + } + if (this.navigateEvent.navigationType === 'push' || + this.navigateEvent.navigationType === 'replace') { + this.userAgentPushOrReplace(this.navigateEvent.destination, { + navigationType: this.navigateEvent.navigationType, + }); + } else if (this.navigateEvent.navigationType === 'traverse') { + this.userAgentTraverse(this.navigateEvent.destination); + } + this.navigateEvent.userAgentNavigated(this.currentEntry); + const currentEntryChangeEvent = createFakeNavigationCurrentEntryChangeEvent( + {from, navigationType: this.navigateEvent.navigationType}, + ); + this.eventTarget.dispatchEvent(currentEntryChangeEvent); + if (!this.navigateEvent.skipPopState) { + const popStateEvent = createPopStateEvent({ + state: this.navigateEvent.destination.getHistoryState(), + }); + this.window.dispatchEvent(popStateEvent); + } + } + + /** Implementation for a push or replace navigation. */ + private userAgentPushOrReplace( + destination: FakeNavigationDestination, + {navigationType}: {navigationType: NavigationTypeString}, + ) { + if (navigationType === 'push') { + this.currentEntryIndex++; + this.prospectiveEntryIndex = this.currentEntryIndex; + } + const index = this.currentEntryIndex; + const key = navigationType === 'push' ? String(this.nextKey++) : this.currentEntry.key; + const entry = new FakeNavigationHistoryEntry(destination.url, { + id: String(this.nextId++), + key, + index, + sameDocument: true, + state: destination.getState(), + historyState: destination.getHistoryState(), + }); + if (navigationType === 'push') { + this.entriesArr.splice(index, Infinity, entry); + } else { + this.entriesArr[index] = entry; + } + } + + /** Implementation for a traverse navigation. */ + private userAgentTraverse(destination: FakeNavigationDestination) { + this.currentEntryIndex = destination.index; + } + + /** Utility method for finding entries with the given `key`. */ + private findEntry(key: string) { + for (const entry of this.entriesArr) { + if (entry.key === key) return entry; + } + return undefined; + } + + set onnavigate(_handler: ((this: Navigation, ev: NavigateEvent) => any)|null) { + throw new Error('unimplemented'); + } + + get onnavigate(): ((this: Navigation, ev: NavigateEvent) => any)|null { + throw new Error('unimplemented'); + } + + set oncurrententrychange(_handler: + ((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any)| + null) { + throw new Error('unimplemented'); + } + + get oncurrententrychange(): + ((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any)|null { + throw new Error('unimplemented'); + } + + set onnavigatesuccess(_handler: ((this: Navigation, ev: Event) => any)|null) { + throw new Error('unimplemented'); + } + + get onnavigatesuccess(): ((this: Navigation, ev: Event) => any)|null { + throw new Error('unimplemented'); + } + + set onnavigateerror(_handler: ((this: Navigation, ev: ErrorEvent) => any)|null) { + throw new Error('unimplemented'); + } + + get onnavigateerror(): ((this: Navigation, ev: ErrorEvent) => any)|null { + throw new Error('unimplemented'); + } + + get transition(): NavigationTransition|null { + throw new Error('unimplemented'); + } + + updateCurrentEntry(_options: NavigationUpdateCurrentEntryOptions): void { + throw new Error('unimplemented'); + } + + reload(_options?: NavigationReloadOptions): NavigationResult { + throw new Error('unimplemented'); + } +} + +/** + * Fake equivalent of the `NavigationResult` interface with + * `FakeNavigationHistoryEntry`. + */ +interface FakeNavigationResult extends NavigationResult { + readonly committed: Promise; + readonly finished: Promise; +} + +/** + * Fake equivalent of `NavigationHistoryEntry`. + */ +export class FakeNavigationHistoryEntry implements NavigationHistoryEntry { + readonly sameDocument; + + readonly id: string; + readonly key: string; + readonly index: number; + private readonly state: unknown; + private readonly historyState: unknown; + + // tslint:disable-next-line:no-any + ondispose: ((this: NavigationHistoryEntry, ev: Event) => any)|null = null; + + constructor( + readonly url: string|null, + { + id, + key, + index, + sameDocument, + state, + historyState, + }: { + id: string; key: string; index: number; sameDocument: boolean; historyState: unknown; + state?: unknown; + }, + ) { + this.id = id; + this.key = key; + this.index = index; + this.sameDocument = sameDocument; + this.state = state; + this.historyState = historyState; + } + + getState(): unknown { + // Budget copy. + return this.state ? JSON.parse(JSON.stringify(this.state)) : this.state; + } + + getHistoryState(): unknown { + // Budget copy. + return this.historyState ? JSON.parse(JSON.stringify(this.historyState)) : this.historyState; + } + + addEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions|boolean, + ) { + throw new Error('unimplemented'); + } + + removeEventListener( + type: string, + callback: EventListenerOrEventListenerObject, + options?: EventListenerOptions|boolean, + ) { + throw new Error('unimplemented'); + } + + dispatchEvent(event: Event): boolean { + throw new Error('unimplemented'); + } +} + +/** `NavigationInterceptOptions` with experimental commit option. */ +export interface ExperimentalNavigationInterceptOptions extends NavigationInterceptOptions { + commit?: 'immediate'|'after-transition'; +} + +/** `NavigateEvent` with experimental commit function. */ +export interface ExperimentalNavigateEvent extends NavigateEvent { + intercept(options?: ExperimentalNavigationInterceptOptions): void; + + commit(): void; +} + +/** + * Fake equivalent of `NavigateEvent`. + */ +export interface FakeNavigateEvent extends ExperimentalNavigateEvent { + readonly destination: FakeNavigationDestination; +} + +interface InternalFakeNavigateEvent extends FakeNavigateEvent { + readonly sameDocument: boolean; + readonly skipPopState?: boolean; + readonly commitOption: 'after-transition'|'immediate'; + readonly result: InternalNavigationResult; + + commit(internal?: boolean): void; + cancel(reason: Error): void; + dispatchedNavigateEvent(): void; + userAgentNavigated(entry: FakeNavigationHistoryEntry): void; +} + +/** + * Create a fake equivalent of `NavigateEvent`. This is not a class because ES5 + * transpiled JavaScript cannot extend native Event. + */ +function createFakeNavigateEvent({ + cancelable, + canIntercept, + userInitiated, + hashChange, + navigationType, + signal, + destination, + info, + sameDocument, + skipPopState, + result, + userAgentCommit, +}: { + cancelable: boolean; canIntercept: boolean; userInitiated: boolean; hashChange: boolean; + navigationType: NavigationTypeString; + signal: AbortSignal; + destination: FakeNavigationDestination; + info: unknown; + sameDocument: boolean; + skipPopState?: boolean; result: InternalNavigationResult; userAgentCommit: () => void; +}) { + const event = new Event('navigate', {bubbles: false, cancelable}) as { + -readonly[P in keyof InternalFakeNavigateEvent]: InternalFakeNavigateEvent[P]; + }; + event.canIntercept = canIntercept; + event.userInitiated = userInitiated; + event.hashChange = hashChange; + event.navigationType = navigationType; + event.signal = signal; + event.destination = destination; + event.info = info; + event.downloadRequest = null; + event.formData = null; + + event.sameDocument = sameDocument; + event.skipPopState = skipPopState; + event.commitOption = 'immediate'; + + let handlerFinished: Promise|undefined = undefined; + let interceptCalled = false; + let dispatchedNavigateEvent = false; + let commitCalled = false; + + event.intercept = function( + this: InternalFakeNavigateEvent, + options?: ExperimentalNavigationInterceptOptions, + ): void { + interceptCalled = true; + event.sameDocument = true; + const handler = options?.handler; + if (handler) { + handlerFinished = handler(); + } + if (options?.commit) { + event.commitOption = options.commit; + } + if (options?.focusReset !== undefined || options?.scroll !== undefined) { + throw new Error('unimplemented'); + } + }; + + event.scroll = function(this: InternalFakeNavigateEvent): void { + throw new Error('unimplemented'); + }; + + event.commit = function(this: InternalFakeNavigateEvent, internal = false) { + if (!internal && !interceptCalled) { + throw new DOMException( + `Failed to execute 'commit' on 'NavigateEvent': intercept() must be ` + + `called before commit().`, + 'InvalidStateError', + ); + } + if (!dispatchedNavigateEvent) { + throw new DOMException( + `Failed to execute 'commit' on 'NavigateEvent': commit() may not be ` + + `called during event dispatch.`, + 'InvalidStateError', + ); + } + if (commitCalled) { + throw new DOMException( + `Failed to execute 'commit' on 'NavigateEvent': commit() already ` + + `called.`, + 'InvalidStateError', + ); + } + commitCalled = true; + + userAgentCommit(); + }; + + // Internal only. + event.cancel = function(this: InternalFakeNavigateEvent, reason: Error) { + result.committedReject(reason); + result.finishedReject(reason); + }; + + // Internal only. + event.dispatchedNavigateEvent = function(this: InternalFakeNavigateEvent) { + dispatchedNavigateEvent = true; + if (event.commitOption === 'after-transition') { + // If handler finishes before commit, call commit. + handlerFinished?.then( + () => { + if (!commitCalled) { + event.commit(/* internal */ true); + } + }, + () => {}, + ); + } + Promise.all([result.committed, handlerFinished]) + .then( + ([entry]) => { + result.finishedResolve(entry); + }, + (reason) => { + result.finishedReject(reason); + }, + ); + }; + + // Internal only. + event.userAgentNavigated = function( + this: InternalFakeNavigateEvent, + entry: FakeNavigationHistoryEntry, + ) { + result.committedResolve(entry); + }; + + return event as InternalFakeNavigateEvent; +} + +/** Fake equivalent of `NavigationCurrentEntryChangeEvent`. */ +export interface FakeNavigationCurrentEntryChangeEvent extends NavigationCurrentEntryChangeEvent { + readonly from: FakeNavigationHistoryEntry; +} + +/** + * Create a fake equivalent of `NavigationCurrentEntryChange`. This does not use + * a class because ES5 transpiled JavaScript cannot extend native Event. + */ +function createFakeNavigationCurrentEntryChangeEvent({ + from, + navigationType, +}: {from: FakeNavigationHistoryEntry; navigationType: NavigationTypeString;}) { + const event = new Event('currententrychange', { + bubbles: false, + cancelable: false, + }) as { + -readonly[P in keyof NavigationCurrentEntryChangeEvent]: NavigationCurrentEntryChangeEvent[P]; + }; + event.from = from; + event.navigationType = navigationType; + return event as FakeNavigationCurrentEntryChangeEvent; +} + +/** + * Create a fake equivalent of `PopStateEvent`. This does not use a class + * because ES5 transpiled JavaScript cannot extend native Event. + */ +function createPopStateEvent({state}: {state: unknown}) { + const event = new Event('popstate', { + bubbles: false, + cancelable: false, + }) as {-readonly[P in keyof PopStateEvent]: PopStateEvent[P]}; + event.state = state; + return event as PopStateEvent; +} + +/** + * Fake equivalent of `NavigationDestination`. + */ +export class FakeNavigationDestination implements NavigationDestination { + readonly url: string; + readonly sameDocument: boolean; + readonly key: string|null; + readonly id: string|null; + readonly index: number; + + private readonly state?: unknown; + private readonly historyState: unknown; + + constructor({ + url, + sameDocument, + historyState, + state, + key = null, + id = null, + index = -1, + }: { + url: string; sameDocument: boolean; historyState: unknown; + state?: unknown; + key?: string | null; + id?: string | null; + index?: number; + }) { + this.url = url; + this.sameDocument = sameDocument; + this.state = state; + this.historyState = historyState; + this.key = key; + this.id = id; + this.index = index; + } + + getState(): unknown { + return this.state; + } + + getHistoryState(): unknown { + return this.historyState; + } +} + +/** Utility function to determine whether two UrlLike have the same hash. */ +function isHashChange(from: URL, to: URL): boolean { + return ( + to.hash !== from.hash && to.hostname === from.hostname && to.pathname === from.pathname && + to.search === from.search); +} + +/** Internal utility class for representing the result of a navigation. */ +class InternalNavigationResult { + committedResolve!: (entry: FakeNavigationHistoryEntry) => void; + committedReject!: (reason: Error) => void; + finishedResolve!: (entry: FakeNavigationHistoryEntry) => void; + finishedReject!: (reason: Error) => void; + readonly committed: Promise; + readonly finished: Promise; + get signal(): AbortSignal { + return this.abortController.signal; + } + private readonly abortController = new AbortController(); + + constructor() { + this.committed = new Promise( + (resolve, reject) => { + this.committedResolve = resolve; + this.committedReject = reject; + }, + ); + + this.finished = new Promise( + async (resolve, reject) => { + this.finishedResolve = resolve; + this.finishedReject = (reason: Error) => { + reject(reason); + this.abortController.abort(reason); + }; + }, + ); + // All rejections are handled. + this.committed.catch(() => {}); + this.finished.catch(() => {}); + } +} + +/** Internal options for performing a navigate. */ +interface InternalNavigateOptions { + navigationType: NavigationTypeString; + cancelable: boolean; + canIntercept: boolean; + userInitiated: boolean; + hashChange: boolean; + info?: unknown; + skipPopState?: boolean; +} diff --git a/packages/common/testing/src/navigation/provide_fake_platform_navigation.ts b/packages/common/testing/src/navigation/provide_fake_platform_navigation.ts new file mode 100644 index 00000000000..e6a71ac67ab --- /dev/null +++ b/packages/common/testing/src/navigation/provide_fake_platform_navigation.ts @@ -0,0 +1,30 @@ +/** + * @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 {Provider} from '@angular/core'; + +// @ng_package: ignore-cross-repo-import +import {PlatformNavigation} from '../../../src/navigation/platform_navigation'; + +import {FakeNavigation} from './fake_navigation'; + +/** + * Return a provider for the `FakeNavigation` in place of the real Navigation API. + * + * @internal + */ +export function provideFakePlatformNavigation(): Provider[] { + return [ + { + provide: PlatformNavigation, + useFactory: () => { + return new FakeNavigation(window, 'https://test.com'); + } + }, + ]; +}