From 66018486862738156b2bf74538be88ce76553e06 Mon Sep 17 00:00:00 2001 From: Kam Date: Wed, 7 Oct 2026 22:37:22 +0300 Subject: [PATCH 1/2] ci: test the page side on Angular 20.0, 21 and 22 The package declares @angular/* >=20 and Node >=22, but CI only ran on the repository's Angular 22 and Node 24. Add a compatibility workflow that runs the page-side tests against Angular 20.0, 21 and 22 on Node 22, on pushes to main, weekly and on demand. The job overrides the Angular versions at install time, and vitest.compat.config.ts picks the tests that render a real app. Tests for features from later releases check the Angular version and skip. The first run on 20.0 found two collector gaps, fixed here: navigation extras were missing because currentNavigation is a plain field before 20.2, and the forRoot scroller showed as off because its token has no description on 20.0. List the tested versions on the Installation page. Closes #100 --- .github/actions/setup/action.yml | 7 +- .github/workflows/compat.yml | 63 +++++++++++ .../src/content/contributing/development.md | 14 +++ .../content/getting-started/installation.md | 18 +++- .../devtools/src/__tests__/angular-version.ts | 10 ++ .../src/__tests__/router-actions-wait.test.ts | 3 +- .../src/__tests__/router-angular20.test.ts | 68 ++++++++++++ .../src/__tests__/router-audit.test.ts | 31 +++--- .../src/__tests__/router-features.test.ts | 100 +++++++++--------- .../src/__tests__/router-real.test.ts | 24 +++-- .../src/__tests__/router-setup-real.test.ts | 59 +++++++---- .../src/__tests__/signal-graph-real.test.ts | 3 +- packages/devtools/src/__tests__/zone-setup.ts | 1 + packages/devtools/src/router-setup.ts | 9 +- packages/devtools/src/router.ts | 8 +- packages/devtools/vitest.compat.config.ts | 51 +++++++++ 16 files changed, 366 insertions(+), 103 deletions(-) create mode 100644 .github/workflows/compat.yml create mode 100644 packages/devtools/src/__tests__/angular-version.ts create mode 100644 packages/devtools/src/__tests__/router-angular20.test.ts create mode 100644 packages/devtools/src/__tests__/zone-setup.ts create mode 100644 packages/devtools/vitest.compat.config.ts diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 3314e8a0..b25fd966 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -6,6 +6,10 @@ inputs: description: Passed to setup-node, which writes an .npmrc for it. Only the release sets it. required: false default: '' + node-version: + description: A Node.js version to use in place of the one in .nvmrc. + required: false + default: '' runs: using: composite @@ -16,7 +20,8 @@ runs: - uses: actions/setup-node@v7 with: - node-version-file: .nvmrc + node-version: ${{ inputs.node-version }} + node-version-file: ${{ inputs.node-version == '' && '.nvmrc' || '' }} cache: pnpm registry-url: ${{ inputs.registry-url }} diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml new file mode 100644 index 00000000..40918a26 --- /dev/null +++ b/.github/workflows/compat.yml @@ -0,0 +1,63 @@ +name: Angular compatibility + +on: + push: + branches: [main] + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + page-side: + name: Angular ${{ matrix.angular }} on Node.js 22 + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - angular: '20.0' + range: '~20.0.0' + - angular: '21' + range: '^21.0.0' + - angular: '22' + range: '^22.0.0' + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + with: + node-version: 22 + + - name: Install Angular ${{ matrix.angular }} + env: + RANGE: ${{ matrix.range }} + run: | + { + echo 'overrides:' + for pkg in animations common compiler core elements forms platform-browser platform-browser-dynamic platform-server router; do + echo " '@angular/$pkg': '$RANGE'" + done + } >> pnpm-workspace.yaml + pnpm install --no-frozen-lockfile + + - name: List the versions under test + run: | + { + echo "- Node.js $(node --version)" + for pkg in core common router forms platform-browser; do + echo "- @angular/$pkg $(node -p "require('@angular/$pkg/package.json').version")" + done + } | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Test the page side + run: pnpm exec vitest run --root packages/devtools --config vitest.compat.config.ts diff --git a/apps/docs/src/content/contributing/development.md b/apps/docs/src/content/contributing/development.md index 52515ab3..74f95322 100644 --- a/apps/docs/src/content/contributing/development.md +++ b/apps/docs/src/content/contributing/development.md @@ -175,6 +175,20 @@ pnpm commit:check # Commit messages on your branch A separate `axe` job in the same workflow installs Chromium and runs `pnpm test:axe`. +### Angular compatibility + +`.github/workflows/compat.yml` runs the page-side tests on the oldest and newest Angular versions the peer range allows. It runs on pushes to `main`, every Monday at 06:00 UTC, and on demand from the **Actions** tab. It doesn't run on pull requests. + +| Job | Angular range | Node.js | +| ------------ | ------------- | ------- | +| Angular 20.0 | `~20.0.0` | 22 | +| Angular 21 | `^21.0.0` | 22 | +| Angular 22 | `^22.0.0` | 22 | + +Each job appends `overrides` for the `@angular/*` runtime packages to `pnpm-workspace.yaml`, reinstalls, and runs `vitest` with `packages/devtools/vitest.compat.config.ts`. That config picks the tests that render a real Angular app and leaves out the panel and server tests. It adds the Signal Forms tests on Angular 22 and later, and loads `zone.js` before Angular 21, where `TestBed` still expects it. A test that needs a feature from a later release checks the version with `angularAtLeast()` from `src/__tests__/angular-version.ts` and skips on older ones. + +To reproduce a job, add the same `overrides` block to `pnpm-workspace.yaml`, run `pnpm install --no-frozen-lockfile`, then `pnpm exec vitest run --root packages/devtools --config vitest.compat.config.ts`. Don't commit the changed lockfile. + ### Pull request checks Two more workflows run on pull requests. Both only warn. They never fail the pull request. diff --git a/apps/docs/src/content/getting-started/installation.md b/apps/docs/src/content/getting-started/installation.md index 836309ba..f73f5d15 100644 --- a/apps/docs/src/content/getting-started/installation.md +++ b/apps/docs/src/content/getting-started/installation.md @@ -15,10 +15,10 @@ The devtools ship as one npm package, `@pangular-inspector/devtools`. It contain - The package declares node >=22 in its engines field. CI runs on Node.js 24. + The package declares node >=22 in its engines field. CI tests Node.js 22 and 24. - @angular/core and @angular/common 20 and newer are supported. CI runs the tests on Angular 22. + @angular/core and @angular/common 20 and newer are supported, with no upper bound. See Tested versions. pnpm, npm, yarn or bun. Any of the four. @@ -29,6 +29,20 @@ The devtools ship as one npm package, `@pangular-inspector/devtools`. It contain Live data comes from Angular's debug API (window.ng). Production builds remove it, so the live tabs stay empty there. Run your app in development mode while you inspect it. +### Tested versions + +The peer range is `@angular/* >=20` with no upper bound. CI tests these versions: + +| Angular | Node.js | What runs | When | +| ----------------------- | ------- | ------------------------------------------------------------------------- | ------------------------------------- | +| 20.0 (lowest supported) | 22 | The page-side tests against a real Angular app | Every push to `main`, and weekly | +| 21 (latest 21.x) | 22 | The page-side tests against a real Angular app | Every push to `main`, and weekly | +| 22 (latest 22.x) | 22 | The page-side tests, including Signal Forms | Every push to `main`, and weekly | +| 22 (repository version) | 24 | Every package and panel test, the type checks and axe | Every pull request and push to `main` | +| Newest the ranges allow | 24 | A fresh Angular CLI app and a fresh Analog app with the package installed | Weekly | + +Some features need a later Angular release than 20.0. The inspector pages list them, for example [abort and probe](../inspectors/router.md#abort-and-probe-need-angular-20-2) (20.2) and the live [signal graph](../inspectors/signals.md#debug-apis) (20.1). The Signal Forms tests run on Angular 22 and later, where Signal Forms are stable. + ## Install the package ```bash group="install" name="pnpm" image="https://cdn.simpleicons.org/pnpm/F69220" active diff --git a/packages/devtools/src/__tests__/angular-version.ts b/packages/devtools/src/__tests__/angular-version.ts new file mode 100644 index 00000000..6b1b09a2 --- /dev/null +++ b/packages/devtools/src/__tests__/angular-version.ts @@ -0,0 +1,10 @@ +import { VERSION } from '@angular/core'; + +export function angularAtLeast(version: string): boolean { + const have = VERSION.full.split(/[.-]/).map((part) => parseInt(part, 10)); + const want = version.split('.').map((part) => parseInt(part, 10)); + for (let i = 0; i < want.length; i++) { + if ((have[i] || 0) !== want[i]) return (have[i] || 0) > want[i]; + } + return true; +} diff --git a/packages/devtools/src/__tests__/router-actions-wait.test.ts b/packages/devtools/src/__tests__/router-actions-wait.test.ts index 14a9cdde..0110dc6c 100644 --- a/packages/devtools/src/__tests__/router-actions-wait.test.ts +++ b/packages/devtools/src/__tests__/router-actions-wait.test.ts @@ -13,6 +13,7 @@ import { import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { watchRouter, type NavigationRecord } from '../router.ts'; import { runAction, waitForStable } from '../router-actions.ts'; +import { angularAtLeast } from './angular-version.ts'; TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); @@ -117,7 +118,7 @@ describe('router actions that wait on the app', () => { expect(await waitForStable({} as never, 30)).toBeNull(); }); - it.each([ + it.skipIf(!angularAtLeast('20.2.0')).each([ ['a UrlTree', '/members'], ['a RedirectCommand', '/vip'], ['an error the error handler redirects', '/crash'], diff --git a/packages/devtools/src/__tests__/router-angular20.test.ts b/packages/devtools/src/__tests__/router-angular20.test.ts new file mode 100644 index 00000000..b37a09fe --- /dev/null +++ b/packages/devtools/src/__tests__/router-angular20.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { applyRouterEvent, type NavigationRecord, type RouterDebugApi } from '../router.ts'; +import { detectSetup } from '../router-setup.ts'; + +class RouterScroller { + constructor(readonly options: Record) {} +} + +class InjectionToken { + constructor(readonly _desc: string) {} +} + +function startWith(currentNavigation: unknown): NavigationRecord | undefined { + const navigations: NavigationRecord[] = []; + const router = { url: '/', navigationTransitions: { currentNavigation } }; + applyRouterEvent( + navigations, + { type: 0, id: 1, url: '/users/2', navigationTrigger: 'imperative' }, + 0, + router, + ); + return navigations[0]; +} + +describe('router collector on Angular 20.0 internals', () => { + const extras = { replaceUrl: true, state: { from: 'list' } }; + + it('lists the extras when currentNavigation is a plain field, as on Angular 20.0 and 20.1', () => { + expect(startWith({ extras })?.extras).toEqual(['replaceUrl', 'state: from']); + }); + + it('lists the extras when currentNavigation is a signal', () => { + expect(startWith(() => ({ extras }))?.extras).toEqual(['replaceUrl', 'state: from']); + }); + + it('finds the forRoot scroller behind a token without a description', () => { + const root = document.createElement('app-root'); + const token = new InjectionToken(''); + const scroller = new RouterScroller({ anchorScrolling: 'enabled' }); + const injector = { get: (wanted: unknown) => (wanted === token ? scroller : null) }; + const provider = { + provide: token, + useFactory: function () { + return new RouterScroller({}); + }, + }; + const ng: RouterDebugApi = { + getInjector: () => injector, + ɵgetInjectorResolutionPath: () => [injector], + ɵgetInjectorProviders: () => [{ token, provider }] as never, + }; + const setup = detectSetup(ng, { options: {} }, 1, root); + expect(setup.features['scroller']).toBe('on'); + }); + + it('leaves the scroller off when no provider creates one', () => { + const root = document.createElement('app-root'); + const token = new InjectionToken(''); + const injector = { get: () => ({ options: {} }) }; + const ng: RouterDebugApi = { + getInjector: () => injector, + ɵgetInjectorResolutionPath: () => [injector], + ɵgetInjectorProviders: () => [{ token, provider: { provide: token, useValue: 1 } }] as never, + }; + expect(detectSetup(ng, { options: {} }, 1, root).features['scroller']).toBe('off'); + }); +}); diff --git a/packages/devtools/src/__tests__/router-audit.test.ts b/packages/devtools/src/__tests__/router-audit.test.ts index 8312c920..cea4fea7 100644 --- a/packages/devtools/src/__tests__/router-audit.test.ts +++ b/packages/devtools/src/__tests__/router-audit.test.ts @@ -19,11 +19,13 @@ import { storedInstrumented, } from '../router-actions.ts'; import { touchRouterPage } from '../rpc/router-tools.ts'; +import { angularAtLeast } from './angular-version.ts'; TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); class Nav { readonly router = inject(Router); + readonly nullOptions = angularAtLeast('22.0.0'); go() { void this.router.navigateByUrl('/b'); } @@ -48,7 +50,9 @@ Component({ Partial - Never + @if (nullOptions) { + Never + } `, })(Nav); @@ -118,16 +122,19 @@ describe('router audit fixes on a real Router', () => { expect(matchOptionsOf(null)).toBeNull(); }); - it('agrees with RouterLinkActive for partial match options and null options', async () => { - const harness = await RouterTestingHarness.create(); - await harness.navigateByUrl('/b?tab=1'); - harness.detectChanges(); - const links = linksOf(ng(), router as never); - const partial = links.find((l) => l.text === 'Partial')!; - const never = links.find((l) => l.text === 'Never')!; - expect(partial).toMatchObject({ active: false, linkActive: false }); - expect(never).toMatchObject({ active: false, linkActive: false }); - }); + it.skipIf(!angularAtLeast('22.0.0'))( + 'agrees with RouterLinkActive for partial match options and null options', + async () => { + const harness = await RouterTestingHarness.create(); + await harness.navigateByUrl('/b?tab=1'); + harness.detectChanges(); + const links = linksOf(ng(), router as never); + const partial = links.find((l) => l.text === 'Partial')!; + const never = links.find((l) => l.text === 'Never')!; + expect(partial).toMatchObject({ active: false, linkActive: false }); + expect(never).toMatchObject({ active: false, linkActive: false }); + }, + ); it('only credits a click to RouterLink when the element carries the directive', async () => { cleanup.push(captureCallers(router as never, navigations, ng())); @@ -173,7 +180,7 @@ describe('router audit fixes on a real Router', () => { ]); }); - it('says what a probe did and did not run', async () => { + it.skipIf(!angularAtLeast('20.2.0'))('says what a probe did and did not run', async () => { const probe = (await runAction( router as never, navigations, diff --git a/packages/devtools/src/__tests__/router-features.test.ts b/packages/devtools/src/__tests__/router-features.test.ts index 925d91cf..f2dbcb3a 100644 --- a/packages/devtools/src/__tests__/router-features.test.ts +++ b/packages/devtools/src/__tests__/router-features.test.ts @@ -30,6 +30,7 @@ import { type PreloadRecord, } from '../router-actions.ts'; import { matchUrl } from '../rpc/router-config-tools.ts'; +import { angularAtLeast } from './angular-version.ts'; TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); @@ -229,60 +230,63 @@ describe('router features on a real Router', () => { expect(router.config.find((r) => r.path === 'admin')!.canActivate![0]).toBe(authGuard); }); - it('navigates, replays, probes and resolves lazy routes on request', async () => { - let instrumented = false; - const set = (on: boolean) => (instrumented = on); - const nav = (await runAction( - router as never, - navigations, - { action: 'navigate', pattern: '/users/:id', params: { id: '5' } }, - set, - )) as Record; - expect(nav).toMatchObject({ outcome: 'succeeded', url: '/users/5' }); - expect(last().caller).toBe('navigate from DevTools'); + it.skipIf(!angularAtLeast('20.2.0'))( + 'navigates, replays, probes and resolves lazy routes on request', + async () => { + let instrumented = false; + const set = (on: boolean) => (instrumented = on); + const nav = (await runAction( + router as never, + navigations, + { action: 'navigate', pattern: '/users/:id', params: { id: '5' } }, + set, + )) as Record; + expect(nav).toMatchObject({ outcome: 'succeeded', url: '/users/5' }); + expect(last().caller).toBe('navigate from DevTools'); - const unsafe = (await runAction( - router as never, - navigations, - { action: 'navigate', url: 'https://evil.test/' }, - set, - )) as Record; - expect(unsafe['error']).toMatch(/same-origin/); + const unsafe = (await runAction( + router as never, + navigations, + { action: 'navigate', url: 'https://evil.test/' }, + set, + )) as Record; + expect(unsafe['error']).toMatch(/same-origin/); - await router.navigateByUrl('/admin'); - const redirected = navigations.find((n) => n.url === '/admin')!; - const replay = (await runAction( - router as never, - navigations, - { action: 'replay', id: redirected.id }, - set, - )) as Record; - expect(replay).toMatchObject({ same: true, replay: { outcome: 'redirected' } }); + await router.navigateByUrl('/admin'); + const redirected = navigations.find((n) => n.url === '/admin')!; + const replay = (await runAction( + router as never, + navigations, + { action: 'replay', id: redirected.id }, + set, + )) as Record; + expect(replay).toMatchObject({ same: true, replay: { outcome: 'redirected' } }); - const probe = (await runAction( - router as never, - navigations, - { action: 'probe', url: '/users/77' }, - set, - )) as Record; - expect(probe).toMatchObject({ matched: true }); - expect(router.url).not.toBe('/users/77'); - expect(navigations.some((n) => n.probe && n.url === '/users/77')).toBe(true); + const probe = (await runAction( + router as never, + navigations, + { action: 'probe', url: '/users/77' }, + set, + )) as Record; + expect(probe).toMatchObject({ matched: true }); + expect(router.url).not.toBe('/users/77'); + expect(navigations.some((n) => n.probe && n.url === '/users/77')).toBe(true); - const lazy = (await runAction( - router as never, - navigations, - { action: 'resolve-lazy', id: '6' }, - set, - )) as Record; - expect(lazy).toMatchObject({ routes: [{ path: '' }, { path: 'deep' }] }); - expect((router.config[6] as { _loadedRoutes?: unknown })._loadedRoutes).toBeUndefined(); + const lazy = (await runAction( + router as never, + navigations, + { action: 'resolve-lazy', id: '6' }, + set, + )) as Record; + expect(lazy).toMatchObject({ routes: [{ path: '' }, { path: 'deep' }] }); + expect((router.config[6] as { _loadedRoutes?: unknown })._loadedRoutes).toBeUndefined(); - await runAction(router as never, navigations, { action: 'instrument', on: true }, set); - expect(instrumented).toBe(true); - }); + await runAction(router as never, navigations, { action: 'instrument', on: true }, set); + expect(instrumented).toBe(true); + }, + ); - it('aborts the navigation in flight', async () => { + it.skipIf(!angularAtLeast('20.2.0'))('aborts the navigation in flight', async () => { const pending = router.navigateByUrl('/slow'); await new Promise((resolve) => setTimeout(resolve, 5)); const result = (await runAction( diff --git a/packages/devtools/src/__tests__/router-real.test.ts b/packages/devtools/src/__tests__/router-real.test.ts index 072c9c06..1042b621 100644 --- a/packages/devtools/src/__tests__/router-real.test.ts +++ b/packages/devtools/src/__tests__/router-real.test.ts @@ -19,6 +19,7 @@ import { type NavigationRecord, type RouterDebugApi, } from '../router.ts'; +import { angularAtLeast } from './angular-version.ts'; TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); @@ -91,16 +92,19 @@ describe('real Router', () => { const last = () => navigations[navigations.length - 1]; - it('finds the router through the debug util that provideRouter publishes', () => { - const published = (globalThis as { ng?: RouterDebugApi }).ng; - expect(typeof published?.ɵgetRouterInstance).toBe('function'); - const injector = TestBed.inject(Injector); - const ng: RouterDebugApi = { - getInjector: () => injector, - ɵgetRouterInstance: published!.ɵgetRouterInstance, - }; - expect(findRouter(ng, [document.createElement('div')])).toBe(router); - }); + it.skipIf(!angularAtLeast('20.3.5'))( + 'finds the router through the debug util that provideRouter publishes', + () => { + const published = (globalThis as { ng?: RouterDebugApi }).ng; + expect(typeof published?.ɵgetRouterInstance).toBe('function'); + const injector = TestBed.inject(Injector); + const ng: RouterDebugApi = { + getInjector: () => injector, + ɵgetRouterInstance: published!.ɵgetRouterInstance, + }; + expect(findRouter(ng, [document.createElement('div')])).toBe(router); + }, + ); it('reports the active route with params, resolved data, guards and redacted secrets', async () => { await router.navigateByUrl('/users/42?tab=posts&token=xyz#bio'); diff --git a/packages/devtools/src/__tests__/router-setup-real.test.ts b/packages/devtools/src/__tests__/router-setup-real.test.ts index 29f4dbf4..f9b3bafd 100644 --- a/packages/devtools/src/__tests__/router-setup-real.test.ts +++ b/packages/devtools/src/__tests__/router-setup-real.test.ts @@ -104,32 +104,45 @@ describe('detectSetup on a real provideRouter app', () => { vi.restoreAllMocks(); }); - it.each([ - ['componentInputBinding', withComponentInputBinding(), 'on'], - ['viewTransitions', withViewTransitions(), 'on'], - ['navigationErrorHandler', withNavigationErrorHandler(() => {}), 'on'], - ['routerResources', withRouterResources(), 'on'], - ['injectorCleanup', withExperimentalAutoCleanupInjectors(), 'on'], - ['preloading', withPreloading(PreloadAllModules), 'PreloadAllModules'], - ['scroller', withInMemoryScrolling({ anchorScrolling: 'enabled' }), 'on'], - ] as const)('detects the %s feature', async (name, feature, value) => { - const setup = await setupWith([feature as RouterFeatures]); + it.each( + ( + [ + ['componentInputBinding', withComponentInputBinding, 'on'], + ['viewTransitions', withViewTransitions, 'on'], + ['navigationErrorHandler', () => withNavigationErrorHandler(() => {}), 'on'], + ['routerResources', withRouterResources, 'on'], + ['injectorCleanup', withExperimentalAutoCleanupInjectors, 'on'], + ['preloading', () => withPreloading(PreloadAllModules), 'PreloadAllModules'], + ['scroller', () => withInMemoryScrolling({ anchorScrolling: 'enabled' }), 'on'], + ] as const + ).filter(([, feature]) => typeof feature === 'function'), + )('detects the %s feature', async (name, feature, value) => { + const setup = await setupWith([(feature as () => unknown)() as RouterFeatures]); expect(setup.features[name]).toBe(value); }); - it('detects withExperimentalPlatformNavigation()', async () => { - const navigation = Object.assign(new EventTarget(), { - currentEntry: { url: 'http://localhost/', key: '0', id: '0', index: 0, getState: () => null }, - entries: () => [], - transition: null, - navigate: () => ({ committed: Promise.resolve(), finished: Promise.resolve() }), - }); - const setup = await setupWith( - [withExperimentalPlatformNavigation()], - [{ provide: PlatformNavigation, useValue: navigation }], - ); - expect(setup.features['platformNavigation']).toBe('on'); - }); + it.skipIf(typeof withExperimentalPlatformNavigation !== 'function')( + 'detects withExperimentalPlatformNavigation()', + async () => { + const navigation = Object.assign(new EventTarget(), { + currentEntry: { + url: 'http://localhost/', + key: '0', + id: '0', + index: 0, + getState: () => null, + }, + entries: () => [], + transition: null, + navigate: () => ({ committed: Promise.resolve(), finished: Promise.resolve() }), + }); + const setup = await setupWith( + [withExperimentalPlatformNavigation()], + [{ provide: PlatformNavigation, useValue: navigation }], + ); + expect(setup.features['platformNavigation']).toBe('on'); + }, + ); it('reads scrolling options and withRouterConfig() options', async () => { const setup = await setupWith([ diff --git a/packages/devtools/src/__tests__/signal-graph-real.test.ts b/packages/devtools/src/__tests__/signal-graph-real.test.ts index f7f66c4e..d4c6abab 100644 --- a/packages/devtools/src/__tests__/signal-graph-real.test.ts +++ b/packages/devtools/src/__tests__/signal-graph-real.test.ts @@ -18,6 +18,7 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common import { bootstrapApplication } from '@angular/platform-browser'; import { afterEach, describe, expect, it } from 'vitest'; import { collectSignalGraph, type SignalDebugNg } from '../signal-graph.ts'; +import { angularAtLeast } from './angular-version.ts'; class TripStore { readonly trips = signal(['Lisbon'], { debugName: 'trips' }); @@ -80,7 +81,7 @@ const settle = async (app: ApplicationRef) => { } }; -describe('collectSignalGraph with a real app', () => { +describe.skipIf(!angularAtLeast('20.1.0'))('collectSignalGraph with a real app', () => { let app: ApplicationRef | null = null; afterEach(() => { app?.destroy(); diff --git a/packages/devtools/src/__tests__/zone-setup.ts b/packages/devtools/src/__tests__/zone-setup.ts new file mode 100644 index 00000000..aa09a9ff --- /dev/null +++ b/packages/devtools/src/__tests__/zone-setup.ts @@ -0,0 +1 @@ +import 'zone.js'; diff --git a/packages/devtools/src/router-setup.ts b/packages/devtools/src/router-setup.ts index fa3214f2..34c0d9a7 100644 --- a/packages/devtools/src/router-setup.ts +++ b/packages/devtools/src/router-setup.ts @@ -48,7 +48,14 @@ function scrollerOf(ng: RouterDebugApi, root: Element | null): AnyRecord | null const accept = (value: unknown) => read(() => !!(value as AnyRecord)?.['options'], false); const byClass = providerOf(ng, injector, 'RouterScroller', accept); if (byClass) return byClass; - const record = providersOf(ng, root).find((p) => tokenDescription(p.token) === 'Router Scroller'); + const record = providersOf(ng, root).find( + (p) => + tokenDescription(p.token) === 'Router Scroller' || + read( + () => /new\s+_*RouterScroller\b/.test(String((p.provider as AnyRecord)['useFactory'])), + false, + ), + ); const value = record ? read(() => (injector as AnyRecord)['get'](record.token, null) as unknown, null) : null; diff --git a/packages/devtools/src/router.ts b/packages/devtools/src/router.ts index e7ce0f18..e2f81a08 100644 --- a/packages/devtools/src/router.ts +++ b/packages/devtools/src/router.ts @@ -895,10 +895,10 @@ export function applyRouterEvent( nav.url = redactUrl(url, state.secrets); const from = read(() => String(router?.['url'] ?? ''), ''); if (router && from) nav.from = redactUrl(from); - const extras = read( - () => router?.['navigationTransitions']?.['currentNavigation']?.()?.['extras'] as AnyRecord, - null, - ); + const extras = read(() => { + const current = router?.['navigationTransitions']?.['currentNavigation']; + return (typeof current === 'function' ? current() : current)?.['extras'] as AnyRecord; + }, null); const listed = extrasOf(extras, event['restoredState']); if (listed) nav.extras = listed; if (pendingCaller && at - pendingCaller.at < 2000 && nav.trigger === 'imperative') { diff --git a/packages/devtools/vitest.compat.config.ts b/packages/devtools/vitest.compat.config.ts new file mode 100644 index 00000000..9193dd44 --- /dev/null +++ b/packages/devtools/vitest.compat.config.ts @@ -0,0 +1,51 @@ +import { createRequire } from 'node:module'; +import { defineConfig, mergeConfig } from 'vitest/config'; +import base from './vitest.config.ts'; + +const require = createRequire(import.meta.url); +const angular = Number(require('@angular/core/package.json').version.split('.')[0]); + +const pageSide = [ + 'analog-scan', + 'forms', + 'forms-tools', + 'http', + 'http-cache-key', + 'ngrx-collector', + 'pipes-collector', + 'pipes-runtime', + 'router-actions-wait', + 'router-angular20', + 'router-audit', + 'router-features', + 'router-forroot', + 'router-guard-results', + 'router-loops', + 'router-real', + 'router-setup-legacy', + 'router-setup-real', + 'router-shared-guards', + 'signal-graph-real', +]; + +const signalForms = [ + 'forms-actions', + 'forms-audit', + 'forms-collector', + 'forms-instrument', + 'forms-read', + 'forms-real', + 'forms-source', +]; + +export default mergeConfig( + base, + defineConfig({ + test: { + include: [...pageSide, ...(angular >= 22 ? signalForms : [])].map( + (name) => `src/__tests__/${name}.test.ts`, + ), + setupFiles: angular >= 21 ? [] : ['src/__tests__/zone-setup.ts'], + }, + }), +); From bb781418a28906b203eb389f6c9e83028ceafbd0 Mon Sep 17 00:00:00 2001 From: Kam Date: Wed, 7 Oct 2026 22:53:38 +0300 Subject: [PATCH 2/2] test(router): look up optional router features through the namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Named imports of withExperimentalAutoCleanupInjectors, withExperimentalPlatformNavigation and ɵwithRouterResources fail to link under native ESM on Angular versions that lack them, so read them from the module namespace instead. --- .../devtools/src/__tests__/router-setup-real.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/devtools/src/__tests__/router-setup-real.test.ts b/packages/devtools/src/__tests__/router-setup-real.test.ts index f9b3bafd..b9eb234e 100644 --- a/packages/devtools/src/__tests__/router-setup-real.test.ts +++ b/packages/devtools/src/__tests__/router-setup-real.test.ts @@ -16,21 +16,24 @@ import { withDebugTracing, withDisabledInitialNavigation, withEnabledBlockingInitialNavigation, - withExperimentalAutoCleanupInjectors, - withExperimentalPlatformNavigation, withHashLocation, withInMemoryScrolling, withNavigationErrorHandler, withPreloading, withRouterConfig, - ɵwithRouterResources as withRouterResources, withViewTransitions, type RouterFeatures, } from '@angular/router'; +import * as router from '@angular/router'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { RouterDebugApi } from '../router.ts'; import { detectSetup, type RouterSetup } from '../router-setup.ts'; +const optionalRouter: Partial = router; +const withExperimentalAutoCleanupInjectors = optionalRouter.withExperimentalAutoCleanupInjectors; +const withExperimentalPlatformNavigation = optionalRouter.withExperimentalPlatformNavigation; +const withRouterResources = optionalRouter.ɵwithRouterResources; + class Root {} Component({ selector: 'app-root', template: '' })(Root); @@ -137,7 +140,7 @@ describe('detectSetup on a real provideRouter app', () => { navigate: () => ({ committed: Promise.resolve(), finished: Promise.resolve() }), }); const setup = await setupWith( - [withExperimentalPlatformNavigation()], + [withExperimentalPlatformNavigation!()], [{ provide: PlatformNavigation, useValue: navigation }], ); expect(setup.features['platformNavigation']).toBe('on');