diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index 19f9d36..742ccab 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -10,7 +10,7 @@ import { Type, } from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing'; -import { Routes } from '@angular/router'; +import { Router, Routes, UrlTree } from '@angular/router'; import { BoundFunctions, Config as dtlConfig, PrettyDOMOptions, Queries, queries } from '@testing-library/dom'; // TODO: import from Angular (is a breaking change) @@ -80,10 +80,25 @@ interface BaseRenderResult { fixture: ComponentFixture; /** * @description - * Navigates to the href of the element or to the path. + * Navigates to the href of the element, to the path, or to the provided `UrlTree`. * + * Paths and hrefs are prefixed with `basePath` and split into a path and query parameters by a simple parser: + * fragments are not supported and query parameter values are not decoded. + * + * Pass a `UrlTree` (e.g. from `router.createUrlTree` or `router.parseUrl`) to navigate with Angular's own URL handling. + * `basePath` is not supported with a `UrlTree`. + * + * @example + * await navigate(screen.getByRole('link', { name: /docs/i })); + * await navigate('docs?lang=en'); + * + * const router = TestBed.inject(Router); + * await navigate(router.createUrlTree(['docs'], { queryParams: { lang: 'en' }, fragment: 'getting-started' })); */ - navigate: (elementOrPath: Element | string, basePath?: string) => Promise; + navigate: { + (urlTree: UrlTree): Promise; + (elementOrPath: Element | string, basePath?: string): Promise; + }; /** * @description * Re-render the same component with different properties. @@ -463,18 +478,29 @@ export interface RenderComponentOptions router.createUrlTree(['docs'], { queryParams: { lang: 'en' }, fragment: 'getting-started' }), * routes: [ - * { path: '', component: HomeComponent }, - * { path: 'myroute', component: SecondaryComponent } + * { path: 'docs', component: DocsComponent }, * ] * }) */ - initialRoute?: string; + initialRoute?: string | UrlTree | ((router: Router) => UrlTree); /** * @description diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index f986beb..79ec630 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -15,7 +15,7 @@ import { isStandalone, } from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed, tick } from '@angular/core/testing'; -import { NavigationExtras, Router } from '@angular/router'; +import { NavigationExtras, Router, UrlTree } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import type { BoundFunctions, Queries } from '@testing-library/dom'; import { @@ -139,34 +139,38 @@ export async function render => { - const href = typeof elementOrPath === 'string' ? elementOrPath : elementOrPath.getAttribute('href'); - const [path, params] = (basePath + href).split('?'); - const queryParams = params - ? params.split('&').reduce( - (qp, q) => { - const [key, value] = q.split('='); - const currentValue = qp[key]; - if (typeof currentValue === 'undefined') { - qp[key] = value; - } else if (Array.isArray(currentValue)) { - qp[key] = [...currentValue, value]; - } else { - qp[key] = [currentValue, value]; - } - return qp; - }, - {} as Record, - ) - : undefined; - - const navigateOptions: NavigationExtras | undefined = queryParams - ? { - queryParams, - } - : undefined; - + const _navigate = async (elementOrPath: Element | string | UrlTree, basePath = ''): Promise => { const doNavigate = () => { + if (elementOrPath instanceof UrlTree) { + return router?.navigateByUrl(elementOrPath); + } + + const href = typeof elementOrPath === 'string' ? elementOrPath : elementOrPath.getAttribute('href'); + const [path, params] = (basePath + href).split('?'); + const queryParams = params + ? params.split('&').reduce( + (qp, q) => { + const [key, value] = q.split('='); + const currentValue = qp[key]; + if (typeof currentValue === 'undefined') { + qp[key] = value; + } else if (Array.isArray(currentValue)) { + qp[key] = [...currentValue, value]; + } else { + qp[key] = [currentValue, value]; + } + return qp; + }, + {} as Record, + ) + : undefined; + + const navigateOptions: NavigationExtras | undefined = queryParams + ? { + queryParams, + } + : undefined; + return navigateOptions ? router?.navigate([path], navigateOptions) : router?.navigate([path]); }; @@ -182,7 +186,7 @@ export async function render => { + const navigate = async (elementOrPath: Element | string | UrlTree, basePath = ''): Promise => { const result = await _navigate(elementOrPath, basePath); detectChanges(); return result; diff --git a/projects/testing-library/src/tests/navigate.spec.ts b/projects/testing-library/src/tests/navigate.spec.ts index caec61a..5d656b1 100644 --- a/projects/testing-library/src/tests/navigate.spec.ts +++ b/projects/testing-library/src/tests/navigate.spec.ts @@ -40,3 +40,41 @@ test('should pass queryParams if provided', async () => { }, }); }); + +test('should navigate from UrlTree', async () => { + const { navigate } = await render(FixtureComponent, { + routes: [{ path: 'docs', component: FixtureComponent }], + }); + + const router = TestBed.inject(Router); + + const result = await navigate(router.createUrlTree(['docs'])); + + expect(result).toBe(true); + expect(router.url).toBe('/docs'); +}); + +test('should use query params and fragment from UrlTree', async () => { + const { navigate } = await render(FixtureComponent, { + routes: [{ path: 'docs', component: FixtureComponent }], + }); + + const router = TestBed.inject(Router); + + await navigate(router.createUrlTree(['docs'], { queryParams: { lang: 'en' }, fragment: 'getting-started' })); + + expect(router.url).toBe('/docs?lang=en#getting-started'); +}); + +test('should not accept basePath when UrlTree is provided', async () => { + const { navigate } = await render(FixtureComponent, { + routes: [{ path: 'docs', component: FixtureComponent }], + }); + + const router = TestBed.inject(Router); + + // @ts-expect-error basePath is not part of the UrlTree overload + await navigate(router.createUrlTree(['docs']), 'base/'); + + expect(router.url).toBe('/docs'); +}); diff --git a/projects/testing-library/src/tests/render.spec.ts b/projects/testing-library/src/tests/render.spec.ts index 258d4eb..4d2e46b 100644 --- a/projects/testing-library/src/tests/render.spec.ts +++ b/projects/testing-library/src/tests/render.spec.ts @@ -20,7 +20,7 @@ import { outputFromObservable } from '@angular/core/rxjs-interop'; import { TestBed } from '@angular/core/testing'; import { vi, describe, test } from 'vitest'; import { render, fireEvent, screen, OutputRefKeysWithCallback, aliasedInput } from '../public_api'; -import { ActivatedRoute, Resolve, RouterModule } from '@angular/router'; +import { ActivatedRoute, DefaultUrlSerializer, Resolve, Router, RouterModule } from '@angular/router'; import { fromEvent, map } from 'rxjs'; import { AsyncPipe } from '@angular/common'; @@ -531,6 +531,44 @@ describe('initialRoute', () => { expect(screen.getByText(/present/i)).toBeVisible(); }); + + @Component({ + selector: 'atl-url-tree-fixture', + template: `
{{ langQueryParam }}
+
{{ fragment }}
`, + }) + class UrlTreeFixtureComponent { + readonly #route = inject(ActivatedRoute).snapshot; + readonly langQueryParam = this.#route.queryParamMap.get('lang'); + readonly fragment = this.#route.fragment; + } + + test('allows initial route rendering from UrlTree', async () => { + await render(RouterFixtureComponent, { + initialRoute: new DefaultUrlSerializer().parse('/docs?lang=en#getting-started'), + routes: [{ path: 'docs', component: UrlTreeFixtureComponent }], + }); + + const router = TestBed.inject(Router); + + expect(router.url).toBe('/docs?lang=en#getting-started'); + expect(screen.getByTestId('langQueryParam')).toHaveTextContent('en'); + expect(screen.getByTestId('fragment')).toHaveTextContent('getting-started'); + }); + + test('allows initial route rendering from UrlTree created with router', async () => { + await render(RouterFixtureComponent, { + initialRoute: (router) => + router.createUrlTree(['docs'], { queryParams: { lang: 'en' }, fragment: 'getting-started' }), + routes: [{ path: 'docs', component: UrlTreeFixtureComponent }], + }); + + const router = TestBed.inject(Router); + + expect(router.url).toBe('/docs?lang=en#getting-started'); + expect(screen.getByTestId('langQueryParam')).toHaveTextContent('en'); + expect(screen.getByTestId('fragment')).toHaveTextContent('getting-started'); + }); }); describe('configureTestBed', () => {