/**
* Angular Router: screens from `Routes` arrays, navigation from command
* arrays and `routerLink`, the component tree from templates.
*
* Validated on angular-realworld-example-app (standalone components, lazy
* `loadComponent`, a `loadChildren` routes file), ngx-admin (NgModule
* `loadChildren` through a routing module, layouts, redirects) and Ghostfolio
* (route constants with `$localize` paths, destructured locals, function
* constants, a `**` redirect home).
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { commandsHref, localizeDefault, parseAngularRoutes, staticString } from '../src/resolution/frameworks/angular-router';
import { readMicrosyntax, templateMemberUses } from '../src/resolution/angular-template-synthesizer';
import { buildDeadCodeReport } from '../src/graph/dead-code';
import { buildScreens } from '../src/ui-server/api/screens';
import { buildSteps } from '../src/ui-server/api/steps';
describe('parseAngularRoutes', () => {
const file = (body: string) => `import { Routes } from '@angular/router';\n${body}`;
it('reads screens, joining children to their parents, and skips what is not a screen', () => {
const { routes, mounts, redirects } = parseAngularRoutes(
file(`export const routes: Routes = [
{ path: '', loadComponent: () => import('./home/home.component') },
{ path: 'login', component: AuthComponent, canActivate: [guard] },
{ path: 'editor', children: [
{ path: '', loadComponent: () => import('./editor.component').then((m) => m.EditorComponent) },
{ path: ':slug', loadComponent: () => import('./editor.component').then((m) => m.EditorComponent) },
] },
{ path: 'profile', loadChildren: () => import('./profile/profile.routes') },
{ path: 'users', matcher: usersMatcher, component: UsersComponent },
{ path: 'old', redirectTo: 'login' },
{ path: '**', component: NotFoundComponent },
];`)
);
expect(routes.map((r) => [r.path, r.component])).toEqual([
['/', { name: null, spec: './home/home.component' }],
['/login', { name: 'AuthComponent', spec: null }],
['/editor', { name: 'EditorComponent', spec: './editor.component' }],
['/editor/:slug', { name: 'EditorComponent', spec: './editor.component' }],
]);
expect(mounts.map((m) => [m.prefix, m.spec])).toEqual([['/profile', './profile/profile.routes']]);
expect(redirects).toEqual([{ from: '/old', to: '/login', absolute: false }]);
});
it('treats a route with children as a layout: a screen only where no child claims its address', () => {
const { routes } = parseAngularRoutes(
file(`const routes: Routes = [
{ path: ':username', component: ProfileComponent, children: [
{ path: '', loadComponent: () => import('./articles.component') },
{ path: 'favorites', loadComponent: () => import('./favorites.component') },
] },
{ path: 'pages', component: PagesComponent, children: [{ path: 'dashboard', component: DashboardComponent }] },
];
export default routes;`)
);
expect(routes.map((r) => [r.path, r.component?.spec ?? r.component?.name, r.layouts.map((l) => l.name)])).toEqual([
['/:username', './articles.component', ['ProfileComponent']],
['/:username/favorites', './favorites.component', ['ProfileComponent']],
['/pages/dashboard', 'DashboardComponent', ['PagesComponent']],
['/pages', 'PagesComponent', []],
]);
});
it('finds the arrays forRoot, forChild, provideRouter and a typed default export hold', () => {
for (const body of [
`RouterModule.forRoot([{ path: 'a', component: A }])`,
`RouterModule.forChild([{ path: 'a', component: A }])`,
`bootstrapApplication(App, { providers: [provideRouter([{ path: 'a', component: A }])] })`,
`export default [{ path: 'a', component: A }] satisfies Routes;`,
`const table = [{ path: 'a', component: A }] as Routes;`,
]) {
expect(parseAngularRoutes(file(body)).routes.map((r) => r.path)).toEqual(['/a']);
}
// An array that is not a routes array, in a file that imports the router.
expect(parseAngularRoutes(file(`const tabs = [{ path: 'a', component: A }];`)).routes).toEqual([]);
// No router import, no routes.
expect(parseAngularRoutes(`export const routes = [{ path: 'a', component: A }];`).routes).toEqual([]);
});
it('keeps a constant path as a placeholder for the cross-file pass, and reads $localize defaults', () => {
const { routes, redirects } = parseAngularRoutes(
file(`export const routes: Routes = [
{ path: internalRoutes.account.path, component: AccountComponent },
{ path: $localize\`:kebab-case@@routes.about:about\`, component: AboutComponent },
{ path: '**', redirectTo: 'home', pathMatch: 'full' },
];`)
);
expect(routes.map((r) => r.path)).toEqual(['/{internalRoutes.account.path}', '/about']);
expect(redirects).toEqual([{ from: '/**', to: '/home', absolute: false }]);
});
});
describe('Angular destinations', () => {
it('reads a static string: literals, $localize defaults and + between them', () => {
expect(staticString(`'/' + $localize\`:kebab-case@@routes.about:about\``)).toBe('/about');
expect(staticString(`'/editor/' + slug`)).toBeNull();
expect(localizeDefault('$localize`Access`')).toBe('Access');
});
it('reads an absolute command array, holes for what is computed, and nothing for a relative or all-hole one', () => {
expect(commandsHref(`['/article', article.slug]`)?.display).toBe('/article/${…}');
expect(commandsHref(`['/profile', p.username, 'favorites']`)?.display).toBe('/profile/${…}/favorites');
expect(commandsHref(`['/']`)?.display).toBe('/');
expect(commandsHref(`['../', id]`)).toBeNull();
expect(commandsHref(`['edit']`)).toBeNull();
// Nothing static: it would match any route of its length.
expect(commandsHref('[`/${a}`, b, c]')).toBeNull();
});
});
describe('what a template reads at render', () => {
const uses = (template: string) => templateMemberUses(template).map((u) => `${u.name}${u.call ? '()' : ''} ${u.via}`);
it('reads property bindings, interpolations, structural directives, control flow and @let', () => {
expect(uses(``)).toEqual([
'label() [attr.aria-label]',
'icon() [name]',
]);
expect(uses(`
{{ count() }} {{ total }}
`)).toEqual([
'isOpen() *ngIf',
'count() {{ }}',
'total {{ }}',
]);
expect(uses(`@if (loading()) { x } @else if (failed()) { y } @switch (mode()) { @case (Mode.A) { a } }`)).toEqual([
'loading() @if',
'failed() @else if',
'mode() @switch',
'Mode @case',
]);
// `on timer(5s)` is a trigger, not a call.
expect(uses(`@defer (on viewport; when ready(); prefetch on timer(5s)) { x }`)).toEqual(['ready() @defer']);
expect(uses(`@let total = price() * quantity; `)).toEqual(['price() @let', 'quantity @let', 'title [(ngModel)]']);
});
it("leaves out a pipe, an object key, another object's member, a string, a comment and an event binding", () => {
expect(uses(`{{ total | currency: code() }} `)).toEqual([
'total {{ }}',
'code() {{ }}',
'isActive() [ngClass]',
'form [x]',
'own() [x]',
]);
expect(uses(` {{ 'quoted()' }} `)).toEqual([]);
// A template literal's text is a string; its holes are expressions.
expect(uses('
')).toEqual([
'isToday() [class.today]',
'key [class.today]',
'formatDay() [class.today]',
'day [class.today]',
]);
});
it('leaves out the names the template declares, where they are visible', () => {
// A microsyntax's locals, keys and context names belong to the directive.
expect(uses(`
{{ i }}
`)).toEqual([
'items() *ngFor',
'trackById *ngFor',
]);
// An alias belongs to the body, not to the condition that names it.
expect(uses(`@if (user(); as user) { {{ user.name }} }`)).toEqual(['user() @if']);
expect(uses(`
{{ user.name }}
`)).toEqual(['user() *ngIf']);
// `track` sees the loop's own names.
expect(uses(`@for (item of items(); track item.id; let odd = $odd) { {{ item.name }} {{ odd }} }`)).toEqual(['items() @for']);
expect(uses(`@let total = price(); {{ total }}`)).toEqual(['price() @let']);
// A reference variable is seen across the template, even before it is declared.
expect(uses(``)).toEqual([]);
expect(uses(`{{ row.name }} {{ i }}`)).toEqual([]);
});
it('splits a microsyntax into its expressions and its locals', () => {
expect(readMicrosyntax('let item of items | async; index as i; trackBy: trackFn')).toEqual({
expressions: [
{ at: 12, text: 'items | async' },
{ at: 48, text: 'trackFn' },
],
locals: ['item', 'i'],
});
expect(readMicrosyntax('user$ | async as user; else loading')).toEqual({
expressions: [
{ at: 0, text: 'user$ | async ' },
{ at: 28, text: 'loading' },
],
locals: ['user'],
});
});
});
// ---------------------------------------------------------------------------
// End to end
// ---------------------------------------------------------------------------
const projects: string[] = [];
afterAll(() => {
for (const p of projects.splice(0)) fs.rmSync(p, { recursive: true, force: true });
});
const component = (name: string, selector: string, template: string, body = '') => `import { Component } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: '${selector}',
${template.startsWith('./') ? `templateUrl: '${template}'` : `template: \`${template}\``},
})
export class ${name} {
constructor(private readonly router: Router) {}
${body}
}
`;
describe('an Angular app, indexed', () => {
let cg: CodeGraph;
let root: string;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-angular-'));
projects.push(root);
const files: Record = {
'package.json': JSON.stringify({ dependencies: { '@angular/core': '^19.0.0', '@angular/router': '^19.0.0' } }),
'src/app/routes.constants.ts': `export const internalRoutes = {
account: { path: 'account', routerLink: ['/account'], subRoutes: { access: { path: 'access', routerLink: (id: string) => ['/account', 'access', id] } } },
about: { path: $localize\`:kebab-case@@routes.about:about\`, routerLink: ['/' + $localize\`:kebab-case@@routes.about:about\`] },
};
`,
'src/app/app.routes.ts': `import { Routes } from '@angular/router';
import { AuthComponent } from './auth/auth.component';
import { internalRoutes } from './routes.constants';
const { about } = internalRoutes;
export const routes: Routes = [
{ path: 'home', loadComponent: () => import('./home/home.component') },
{ path: 'login', component: AuthComponent },
{ path: about.path, loadComponent: () => import('./about/about.component').then((m) => m.AboutComponent) },
{ path: internalRoutes.account.path, loadChildren: () => import('./account/account.routes') },
{ path: 'admin', loadChildren: () => import('./admin/admin.module').then((m) => m.AdminModule) },
{ path: '**', redirectTo: 'home' },
];
`,
'src/app/account/account.routes.ts': `import { Routes } from '@angular/router';
import { AccountComponent } from './account.component';
import { AccessComponent } from './access.component';
import { internalRoutes } from '../routes.constants';
export default [
{ path: '', component: AccountComponent, children: [
{ path: '', loadComponent: () => import('./overview.component') },
{ path: internalRoutes.account.subRoutes.access.path + '/:id', component: AccessComponent },
] },
] satisfies Routes;
`,
// An NgModule's routes live in the routing module it imports.
'src/app/admin/admin.module.ts': `import { NgModule } from '@angular/core';
import { AdminRoutingModule } from './admin-routing.module';
@NgModule({ imports: [AdminRoutingModule] })
export class AdminModule {}
`,
'src/app/admin/admin-routing.module.ts': `import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AdminUsersComponent } from './admin-users.component';
const routes: Routes = [{ path: '', redirectTo: 'users', pathMatch: 'full' }, { path: 'users', component: AdminUsersComponent }];
@NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule] })
export class AdminRoutingModule {}
`,
'src/app/home/home.component.ts': component('HomeComponent', 'app-home', './home.component.html', ` open(slug: string) { this.router.navigate(['/login']); }
more() { this.router.navigate([], { queryParams: { page: 2 } }); }`).replace('export class', 'export default class'),
'src/app/home/home.component.html': `Sign inAboutAccessRelative
`,
'src/app/shared/card.component.ts': component(
'CardComponent',
'app-card',
`x`,
` name = '';
save() { this.router.navigate(internalRoutes.account.subRoutes.access.routerLink('me')); }`
).replace(
"import { Router } from '@angular/router';",
"import { Router } from '@angular/router';\nimport { internalRoutes } from '../routes.constants';"
),
'src/app/auth/auth.component.ts': component('AuthComponent', 'app-auth', `Home`, ` done() { this.router.navigate(['/']); }`),
'src/app/about/about.component.ts': component('AboutComponent', 'app-about', `
About
`),
// A tab bar built in the class, handed to a tabs component that binds each `tab.routerLink`.
'src/app/account/account.component.ts': component(
'AccountComponent',
'app-account',
`Log out`,
` tabs = [{ label: 'Access', routerLink: internalRoutes.account.subRoutes.access.routerLink('me') }];`
).replace("import { Router } from '@angular/router';", "import { Router } from '@angular/router';\nimport { internalRoutes } from '../routes.constants';"),
'src/app/account/overview.component.ts': component('OverviewComponent', 'app-overview', `