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..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); @@ -104,32 +107,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'], + }, + }), +);