Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions projects/testing-library/src/lib/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -80,10 +80,25 @@ interface BaseRenderResult<ComponentType, WrapperType = ComponentType> {
fixture: ComponentFixture<WrapperType>;
/**
* @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<boolean>;
navigate: {
(urlTree: UrlTree): Promise<boolean>;
(elementOrPath: Element | string, basePath?: string): Promise<boolean>;
};
/**
* @description
* Re-render the same component with different properties.
Expand Down Expand Up @@ -463,18 +478,29 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo

/**
* @description
* Specifies which route should be initially navigated to
* Specifies which route should be initially navigated to.
* Accepts a path, a `UrlTree`, or a function that receives Angular's `Router` and returns a `UrlTree`.
*
* A path is split into a path and query parameters by a simple parser: fragments are not supported and query parameter values are not decoded.
* Use a `UrlTree`, or a function returning one, to navigate with Angular's own URL handling.
*
* @example
* await render(AppComponent, {
* initialRoute: 'docs',
* routes: [
* { path: 'docs', component: DocsComponent },
* ]
* })
*
* @example
* await render(AppComponent, {
* initialRoute: 'myroute',
* initialRoute: (router) => 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
Expand Down
64 changes: 34 additions & 30 deletions projects/testing-library/src/lib/testing-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -139,34 +139,38 @@ export async function render<SutType, WrapperType = SutType, Q extends Queries =

const zone = TestBed.inject(NgZone);
const router = TestBed.inject(Router);
const _navigate = async (elementOrPath: Element | string, basePath = ''): Promise<boolean> => {
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<string, string | string[]>,
)
: undefined;

const navigateOptions: NavigationExtras | undefined = queryParams
? {
queryParams,
}
: undefined;

const _navigate = async (elementOrPath: Element | string | UrlTree, basePath = ''): Promise<boolean> => {
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<string, string | string[]>,
)
: undefined;

const navigateOptions: NavigationExtras | undefined = queryParams
? {
queryParams,
}
: undefined;

return navigateOptions ? router?.navigate([path], navigateOptions) : router?.navigate([path]);
};

Expand All @@ -182,7 +186,7 @@ export async function render<SutType, WrapperType = SutType, Q extends Queries =
return result ?? false;
};

if (initialRoute) await _navigate(initialRoute);
if (initialRoute) await _navigate(typeof initialRoute === 'function' ? initialRoute(router) : initialRoute);

if (typeof router?.initialNavigation === 'function') {
if (zone) {
Expand Down Expand Up @@ -340,7 +344,7 @@ export async function render<SutType, WrapperType = SutType, Q extends Queries =
}
};

const navigate = async (elementOrPath: Element | string, basePath = ''): Promise<boolean> => {
const navigate = async (elementOrPath: Element | string | UrlTree, basePath = ''): Promise<boolean> => {
const result = await _navigate(elementOrPath, basePath);
detectChanges();
return result;
Expand Down
38 changes: 38 additions & 0 deletions projects/testing-library/src/tests/navigate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
40 changes: 39 additions & 1 deletion projects/testing-library/src/tests/render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -531,6 +531,44 @@ describe('initialRoute', () => {

expect(screen.getByText(/present/i)).toBeVisible();
});

@Component({
selector: 'atl-url-tree-fixture',
template: `<div data-testid="langQueryParam">{{ langQueryParam }}</div>
<div data-testid="fragment">{{ fragment }}</div>`,
})
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', () => {
Expand Down
Loading