From 329449403638c7c16f2af7054b61b34ecb622039 Mon Sep 17 00:00:00 2001 From: erKam Date: Tue, 13 Oct 2026 02:17:04 +0000 Subject: [PATCH 1/2] feat(overlay): support Angular 12.2 and later CI now runs the page-side tests on Angular 12.2, 15.2, 17.3 and 19.2, so the peer range can go down to 12.2. This fixes what broke on those versions (router events, forms, the transfer cache key, webpack builds of the overlay) and documents the setup per version. Refs #386. --- .github/workflows/compat.yml | 33 ++- .../src/content/contributing/development.md | 12 +- .../content/getting-started/installation.md | 44 ++- .../content/getting-started/introduction.md | 4 +- apps/docs/src/content/inspectors/errors.md | 2 +- apps/docs/src/content/inspectors/forms.md | 2 +- apps/docs/src/content/inspectors/signals.md | 2 +- docs/angular-compat.md | 89 ++++-- packages/devtools/package.json | 47 +++- .../src/__tests__/analog-load-inherit.test.ts | 4 +- .../src/__tests__/angular-real-app.test.ts | 265 ++++++++++++++++++ .../devtools/src/__tests__/angular-version.ts | 216 +++++++++++++- .../src/__tests__/cd-recorder-real.test.ts | 56 ++-- .../__tests__/change-detection-real.test.ts | 33 ++- .../src/__tests__/defer-blocks-real.test.ts | 33 ++- .../src/__tests__/errors-real.test.ts | 99 ++++--- .../forms-actions-fill-submit.test.ts | 8 +- .../src/__tests__/forms-actions.test.ts | 8 +- .../src/__tests__/forms-audit.test.ts | 8 +- .../src/__tests__/forms-collector.test.ts | 8 +- .../src/__tests__/forms-dotted-keys.test.ts | 8 +- .../src/__tests__/forms-instrument.test.ts | 8 +- .../devtools/src/__tests__/forms-real.test.ts | 4 +- .../devtools/src/__tests__/forms-scan.test.ts | 8 +- packages/devtools/src/__tests__/forms.test.ts | 234 +++++++++------- .../src/__tests__/http-cache-key.test.ts | 27 +- packages/devtools/src/__tests__/http.test.ts | 60 ++-- .../src/__tests__/injector-tree-real.test.ts | 62 ++-- .../src/__tests__/leased-collectors.test.ts | 8 +- .../src/__tests__/network-inspector.test.ts | 8 +- .../src/__tests__/ngrx-collector.test.ts | 32 ++- .../src/__tests__/pipes-collector.test.ts | 148 ++++++---- .../src/__tests__/pipes-runtime.test.ts | 52 ++-- .../src/__tests__/private-api-canary.test.ts | 119 +++++--- .../router-actions-probe-timeout.test.ts | 4 +- .../src/__tests__/router-actions-wait.test.ts | 66 +++-- .../src/__tests__/router-audit.test.ts | 44 +-- .../src/__tests__/router-features.test.ts | 93 +++--- .../src/__tests__/router-forroot.test.ts | 92 ++++-- .../__tests__/router-guard-results.test.ts | 125 +++++---- .../src/__tests__/router-loops.test.ts | 22 +- .../src/__tests__/router-real.test.ts | 11 +- .../src/__tests__/router-setup-legacy.test.ts | 98 ++++--- .../src/__tests__/router-setup-real.test.ts | 65 +++-- .../__tests__/router-shared-guards.test.ts | 96 ++++++- .../devtools/src/__tests__/router-testing.ts | 43 +++ .../src/__tests__/signal-graph-real.test.ts | 3 +- .../src/__tests__/signal-history.test.ts | 6 + .../src/__tests__/vite-signals-stub.test.ts | 49 ++++ packages/devtools/src/forms.ts | 3 +- packages/devtools/src/http-cache-key.ts | 3 + packages/devtools/src/ngrx-collector.ts | 12 +- packages/devtools/src/router-actions.ts | 31 +- packages/devtools/src/router-setup.ts | 8 +- packages/devtools/src/router.ts | 56 +++- packages/devtools/src/signal-history.ts | 18 +- packages/devtools/src/signals-stub.ts | 4 + packages/devtools/src/vite-signals-stub.ts | 38 +++ packages/devtools/src/vite.ts | 5 + packages/devtools/tsdown.config.ts | 1 + packages/devtools/vitest.compat.config.ts | 5 + 61 files changed, 2010 insertions(+), 742 deletions(-) create mode 100644 packages/devtools/src/__tests__/angular-real-app.test.ts create mode 100644 packages/devtools/src/__tests__/router-testing.ts create mode 100644 packages/devtools/src/__tests__/vite-signals-stub.test.ts create mode 100644 packages/devtools/src/signals-stub.ts create mode 100644 packages/devtools/src/vite-signals-stub.ts diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index 8aebcb0d..21286579 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -24,7 +24,19 @@ jobs: strategy: fail-fast: false matrix: + # The oldest supported minor of each tested major, then each current major. The tests run + # on Node.js 22 for every version: they load Angular in jsdom, not through its CLI. include: + - angular: '12.2' + range: '~12.2.0' + # Angular 12 ships View Engine bundles that its CLI compiles for Ivy with ngcc. + ngcc: true + - angular: '15.2' + range: '~15.2.0' + - angular: '17.3' + range: '~17.3.0' + - angular: '19.2' + range: '~19.2.0' - angular: '20.0' range: '~20.0.0' - angular: '21' @@ -46,6 +58,9 @@ jobs: - name: Install Angular ${{ matrix.angular }} env: RANGE: ${{ matrix.range }} + # ngcc rewrites the installed files in place, so they are copied rather than linked to + # the pnpm store. + IMPORT_METHOD: ${{ matrix.ngcc && 'copy' || 'auto' }} run: | { echo 'overrides:' @@ -53,7 +68,23 @@ jobs: echo " '@angular/$pkg': '$RANGE'" done } >> pnpm-workspace.yaml - pnpm install --no-frozen-lockfile + pnpm install --no-frozen-lockfile --config.package-import-method="$IMPORT_METHOD" + + - name: Compile Angular ${{ matrix.angular }} for Ivy + if: matrix.ngcc + run: | + version=$(node -p "require('@angular/core/package.json').version") + npm install --prefix "$RUNNER_TEMP/ngcc" --no-audit --no-fund --no-save \ + "@angular/compiler-cli@$version" "@angular/compiler@$version" "@angular/core@$version" \ + typescript@~4.3.5 rxjs@~7.8.0 zone.js@~0.11.4 + # Each package's folder in the pnpm store links its dependencies. A package is compiled + # after the ones it depends on, so core and common go first. + for pkg in core common compiler platform-browser platform-browser-dynamic platform-server forms router elements animations; do + for dir in node_modules/.pnpm/@angular+"$pkg"@"$version"*/node_modules; do + [ -d "$dir" ] || continue + "$RUNNER_TEMP/ngcc/node_modules/.bin/ngcc" --source "$PWD/$dir" --properties main es2015 module --first-only + done + done - name: List the versions under test run: | diff --git a/apps/docs/src/content/contributing/development.md b/apps/docs/src/content/contributing/development.md index 2cedb4c2..e3a658cc 100644 --- a/apps/docs/src/content/contributing/development.md +++ b/apps/docs/src/content/contributing/development.md @@ -184,10 +184,14 @@ A separate `axe` job in the same workflow installs Chromium and runs `pnpm test: ### Angular compatibility -`.github/workflows/compat.yml` runs the page-side tests on the oldest and newest Angular versions the peer range allows, and on the `next` prerelease. 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. +`.github/workflows/compat.yml` runs the page-side tests on the oldest Angular version the peer range allows, the last minor of 15, 17 and 19, each current major, and the `next` prerelease. 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 12.2 | `~12.2.0` | 22 | +| Angular 15.2 | `~15.2.0` | 22 | +| Angular 17.3 | `~17.3.0` | 22 | +| Angular 19.2 | `~19.2.0` | 22 | | Angular 20.0 | `~20.0.0` | 22 | | Angular 21 | `^21.0.0` | 22 | | Angular 22 | `^22.0.0` | 22 | @@ -195,9 +199,11 @@ A separate `axe` job in the same workflow installs Chromium and runs `pnpm test: The `next` job installs the `next` dist-tag, so a renamed `ɵ` API shows up before the next major ships. It runs with `continue-on-error`: a failure marks the job red but not the workflow. -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 or read Angular's private debug APIs, including the `*-real` tests that boot a real app where the main tests use fakes, and leaves out the panel tests and most 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. +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 or read Angular's private debug APIs, including the `*-real` tests that boot a real app where the main tests use fakes, and leaves out the panel tests and most 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. Before Angular 17 it also points `@angular/core/primitives/signals` at the stub the Vite plugin uses. The Angular 12 job then compiles the View Engine packages for Ivy with ngcc from `@angular/compiler-cli` 12, after a copying install, since ngcc rewrites the installed files. -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. +`src/__tests__/angular-version.ts` has the helpers that make a test run on every version: `initAngularTestBed()`, `bootstrapRoot()` (an NgModule before 14.1), `zonelessProviders()`, `STANDALONE`, `writableSignal()` and `testModuleFor()`. `router-testing.ts` gives `provideRouter` or `RouterModule.forRoot`. A test of a feature that a version's debug API lacks checks the unavailable result there with `angularAtLeast()`. It is skipped only when the Angular API the test builds on doesn't exist yet, such as `@defer` before 17.0. + +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`. For Angular 12, run the ngcc step from the workflow before the tests. Do this in a copy of the repository, and don't commit the changed lockfile. ### Pull request checks diff --git a/apps/docs/src/content/getting-started/installation.md b/apps/docs/src/content/getting-started/installation.md index 5bcc0172..b942c453 100644 --- a/apps/docs/src/content/getting-started/installation.md +++ b/apps/docs/src/content/getting-started/installation.md @@ -17,8 +17,8 @@ The devtools ship as one npm package, `@pangular-inspector/devtools`. It contain 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, with no upper bound. See Supported Angular versions for older versions. + + @angular/core and @angular/common 12.2 and newer are supported, with no upper bound. Older versions show less data. See Supported Angular versions. pnpm, npm, yarn or bun. Any of the four. @@ -31,7 +31,7 @@ The devtools ship as one npm package, `@pangular-inspector/devtools`. It contain ## Supported Angular versions -The peer range covers Angular 20 and later, and CI tests it (see [Tested versions](#tested-versions)). The overlay checks each debug API on the page and sends what it finds with the page's Angular version, so each tab shows the data that version provides. Only the profiler also looks at the version: the overlay uses `ɵsetProfiler` from Angular 20.0, because before that it holds a single profiler. +The peer range covers Angular 12.2 and later, and CI tests it (see [Tested versions](#tested-versions)). The overlay checks each debug API on the page and sends what it finds with the page's Angular version, so each tab shows the data that version provides. Only the profiler also looks at the version: the overlay uses `ɵsetProfiler` from Angular 20.0, because before that it holds a single profiler. When a tab needs a debug API the page doesn't have, it says which Angular version the feature needs and which one the page runs, for example "The live signal graph needs Angular 20.1 or later; this page runs Angular 15.2." The agent tools answer **Unsupported on this Angular version** with the same sentence, and `list-pages` shows each page's Angular version next to its platform. @@ -41,6 +41,8 @@ When a tab needs a debug API the page doesn't have, it says which Angular versio | Injected services, providers and environment injectors | 16.2 | | Full injector tree with lookup paths | 17.0 | | Signal values in the component detail (`ng.isSignal`) | 17.1 | +| Form events from `AbstractControl.events` | 18.0 | +| The `@pangular-inspector/devtools/http` entry | 19.0 | | **Defer blocks** list | 19.1 | | Change detection recording and Angular profiling | 20.0 | | Live signal graph | 20.1 | @@ -50,27 +52,51 @@ All of them need a development build, since production builds leave out `window. ### Angular 12 to 19 -Angular 12 to 19 are outside the peer range and CI doesn't test them, so your package manager warns about the peer dependency. On those versions the overlay degrades as the table above shows: +On Angular 12 to 19 the overlay degrades as the table above shows: - The **Injectors** tab shows the element injectors with their components and directives. From 16.2 it adds providers, dependencies and environment injectors. -- Before 16.2, the overlay finds the `Router` through a router link, the component an outlet renders or the root component, whichever holds it. A page where none of them injects the `Router` shows no router data. +- Before 16.2, the overlay finds the `Router` through a router link, the component an outlet renders or the root component, whichever holds it. A page where none of them injects the `Router` shows no router data. The router setup is then read in `events-only` mode, so the setup kind shows `unknown` and features found through providers (preloading, the scroller, `withDebugTracing()`) show `off`. - Before 16.2, the **NgRx** tab can't find the `@ngrx/store` `Store` or signal stores that components provide. +- Before 16.2, the errors collector can't wrap the app's `ErrorHandler`. It counts the errors Angular's default handler logs instead, so errors a custom handler swallows don't show. +- Before 18.0, the **Forms** tab builds its event timeline by comparing snapshots, since controls have no `events` stream. -The overlay loads `@angular/core/primitives/signals` for exact signal history. That entry exists from Angular 17, so a build that resolves it ahead of time can fail on Angular 12 to 16. +### Setup on Angular 12 to 19 + +| Setup | Angular 12 to 16 | Angular 17 to 19 | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| Overlay import in `main.ts` | Works with the webpack-based `@angular-devkit/build-angular:browser` builder. Signal history uses samples, since `@angular/core/primitives/signals` exists from 17.0. | Works with `browser` and `application`. Under webpack, signal history uses samples. | +| Vite plugin (Analog) | Works. The plugin points `@angular/core/primitives/signals` at a stub before 17.0. | Works. | +| `initPangularHub()` in an Express server | Works when the server runs on Node.js 22 or later. On an older Node.js, use the standalone CLI. | Works. | +| Standalone CLI next to `ng serve` | Works. Run `npx pangular dev` with Node.js 22 or later, and the app's own CLI with the Node.js version it supports. | Works. | +| Chrome extension | Works on top of any of the setups above. | Works. | +| `@pangular-inspector/devtools/http` | Not available. It imports `REQUEST` and `provideEnvironmentInitializer`, so the build fails. Leave it out; the HTTP timeline and fault rules need it. | Not available before 19.0, for the same reason. | + +With the webpack-based `browser` builder, use the standalone CLI next to `ng serve`, as in [Client-only Angular CLI app](#client-only-angular-cli-app), or the Express hub for an SSR app. + +TypeScript before 4.7 (Angular 12 and 13) can't read the package's `.d.mts` types, so `import('@pangular-inspector/devtools/overlay')` fails the type check. Declare the module in a typings file: + +```ts +// src/typings.d.ts +declare module '@pangular-inspector/devtools/overlay'; +``` ### Tested versions -The peer range is `@angular/* >=20` with no upper bound. CI tests these versions: +The peer range is `@angular/core` and `@angular/common` `>=12.2` 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 | +| 12.2 (lowest supported) | 22 | The page-side tests against a real Angular app, after ngcc | Every push to `main`, and weekly | +| 15.2 | 22 | The page-side tests against a real Angular app | Every push to `main`, and weekly | +| 17.3 | 22 | The page-side tests against a real Angular app | Every push to `main`, and weekly | +| 19.2 | 22 | The page-side tests against a real Angular app | Every push to `main`, and weekly | +| 20.0 | 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. +The page-side tests run in Node.js 22 for every Angular version, since they load Angular in jsdom rather than through its CLI. Tests of a feature a version lacks check the unavailable state on that version instead. Angular 13, 14, 16 and 18 are in the peer range but not tested. 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 diff --git a/apps/docs/src/content/getting-started/introduction.md b/apps/docs/src/content/getting-started/introduction.md index fb8c17b3..7858533c 100644 --- a/apps/docs/src/content/getting-started/introduction.md +++ b/apps/docs/src/content/getting-started/introduction.md @@ -112,8 +112,8 @@ The devtools are a - - The package declares Angular 20 and later as its peer range. + + The package declares Angular 12.2 and later as its peer range. Older versions show less data. The package runs on Node.js 22 and newer. diff --git a/apps/docs/src/content/inspectors/errors.md b/apps/docs/src/content/inspectors/errors.md index ffe86b8f..7da07e9e 100644 --- a/apps/docs/src/content/inspectors/errors.md +++ b/apps/docs/src/content/inspectors/errors.md @@ -43,7 +43,7 @@ When several tabs report, **Page** picks one, or **All pages** to see them toget - The overlay wraps the handleError method of the app's ErrorHandler, your own or Angular's. Your handler still runs, with the same arguments, after the overlay records the error. + The overlay wraps the handleError method of the app's ErrorHandler, your own or Angular's. Your handler still runs, with the same arguments, after the overlay records the error. Before Angular 16.2 the overlay can't find the handler, so it records what Angular's default handler logs. Listeners for error and unhandledrejection on window. They only listen, so the browser and Angular handle the event as before. diff --git a/apps/docs/src/content/inspectors/forms.md b/apps/docs/src/content/inspectors/forms.md index 07b06133..bb6b6daf 100644 --- a/apps/docs/src/content/inspectors/forms.md +++ b/apps/docs/src/content/inspectors/forms.md @@ -118,7 +118,7 @@ The actions bar works on the selected form: ### When the page reports -The overlay reads the forms after change detection and pushes them when they change. It also pushes shortly after each `input`, `change`, `focusout`, `submit` or `reset` event. Reactive and template-driven forms also report each change through `control.events`. +The overlay reads the forms after change detection and pushes them when they change. It also pushes shortly after each `input`, `change`, `focusout`, `submit` or `reset` event. Reactive and template-driven forms also report each change through `control.events`, which Angular has from 18.0. On older versions the timeline comes from comparing the forms between reads. ### Validators run only when needed diff --git a/apps/docs/src/content/inspectors/signals.md b/apps/docs/src/content/inspectors/signals.md index d0f5f5ea..6aa63951 100644 --- a/apps/docs/src/content/inspectors/signals.md +++ b/apps/docs/src/content/inspectors/signals.md @@ -146,7 +146,7 @@ Angular doesn't send a value for `linkedSignal` nodes. The overlay takes it from ### Exact and sampled values -Exact **set** entries come from a hook on signal writes. The overlay matches a write to a node by its name, kind, version and value. It samples everything else each time it reads the page, as **sampled** entries. +Exact **set** entries come from a hook on signal writes, which the overlay loads from `@angular/core/primitives/signals`. A webpack build (the `browser` builder) leaves that import to run time, where it fails, so there every entry is **sampled**. The overlay matches a write to a node by its name, kind, version and value. It samples everything else each time it reads the page, as **sampled** entries. In a development build, the Angular compiler names most signals for you. It adds a `debugName` to `signal()`, `computed()`, `linkedSignal()`, `input()`, `model()`, queries, `effect()`, `resource()` and `httpResource()` when the call initializes a class field, a `this.name =` assignment or a variable. The name is the field or variable name. diff --git a/docs/angular-compat.md b/docs/angular-compat.md index 23e4b692..466f68f2 100644 --- a/docs/angular-compat.md +++ b/docs/angular-compat.md @@ -39,41 +39,72 @@ The `@angular/core` and `@angular/router` packages of these versions were packed ## Framework objects -| API | Used by | First version | Shape changes up to 22 | Fallback | -| --------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `ɵcmp.selectors`, `ɵdir.selectors` | `directive-hosts.ts`, `forms.ts`, `overlay-*-views.ts` | 12.0 (every Ivy version) | None. | None needed. | -| `ɵcmp.inputs` | `router-config.ts`, `router-links.ts` | 12.0 | Keys are public input names in every version; values gain a `[name, flags]` form for signal inputs in 17.1. Only the keys are read. | None needed. | -| `ɵcmp.debugInfo` (`ɵsetClassDebugInfo`) | `component-source.ts`, `router.ts` | 17.0 | None. | No source link; the class name comes from the constructor. | -| `ɵpipe`, `PipeDef` in `tView.data` | `pipes-runtime.ts` | 12.0 | `name`, `type` and `pure` in every version. | None needed. | -| `__ngContext__` on DOM nodes | `pipes-runtime.ts` | 12.0 | 12 and 13 patch the LView array (or an `LContext`) onto the node. From 14.0 Angular patches a numeric view id, which `getComponent` upgrades to an `LContext` with `lView`. | `lViewOf()` reads all three forms; a bare id is resolved after `getComponent` ran on the node. | -| LView slots (`TVIEW` 1, `PARENT` 3, `CONTEXT` 8) | `pipes-runtime.ts` | 12.0 | Unchanged. | None needed. | -| LView `DECLARATION_COMPONENT_VIEW` | `pipes-runtime.ts` | 12.0 | Slot 16 up to 15.2, slot 15 from 16.0 (slot 16 then holds the declaration `LContainer`). | `declaringViewOf()` uses slot 16 when it holds a component view, else slot 15. | -| `R3Injector.records`, `.scopes`, `.source` | `injector-tree.ts`, `ngrx-collector.ts`, `zone-mode.ts` | 12.0 | Unchanged. | None needed. | -| `__ngDebugHydrationInfo__` | `hydration.ts`, `http-hydration.ts` | 17.1 | Hydration exists from 16.0, but dev builds only record this per node from 17.1. | Hosts are not marked hydrated or mismatched; the hydration section stays empty. | -| `SIGNAL` symbol on signal functions | `signal-resources.ts`, `ngrx-collector.ts` | 16.0 | Unchanged. | None needed: before 16.0 there are no signals. | -| `setPostSignalSetFn` (`@angular/core/primitives/signals`) | `signal-history.ts` | 17.0 as a package entry (16.x has it internally only) | Unchanged. | History falls back to sampled values (`writeHook: false`). See the build note below. | -| `ng-version` attribute on root elements | `ng-capabilities.ts`, `change-detection.ts`, `router-setup.ts`, `host-tree.ts` and others | 12.0 | Unchanged. | Version unknown: messages say so, and `profiler` stays off. | +| API | Used by | First version | Shape changes up to 22 | Fallback | +| --------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ɵcmp.selectors`, `ɵdir.selectors` | `directive-hosts.ts`, `forms.ts`, `overlay-*-views.ts` | 12.0 (every Ivy version) | None. | None needed. | +| `ɵcmp.inputs` | `router-config.ts`, `router-links.ts` | 12.0 | Keys are public input names in every version; values gain a `[name, flags]` form for signal inputs in 17.1. Only the keys are read. | None needed. | +| `ɵcmp.debugInfo` (`ɵsetClassDebugInfo`) | `component-source.ts`, `router.ts` | 17.0 | None. | No source link; the class name comes from the constructor. | +| `ɵpipe`, `PipeDef` in `tView.data` | `pipes-runtime.ts` | 12.0 | `name`, `type` and `pure` in every version. | None needed. | +| `__ngContext__` on DOM nodes | `pipes-runtime.ts` | 12.0 | 12 and 13 patch the LView array (or an `LContext`) onto the node. From 14.0 Angular patches a numeric view id, which `getComponent` upgrades to an `LContext` with `lView`. | `lViewOf()` reads all three forms; a bare id is resolved after `getComponent` ran on the node. | +| LView slots (`TVIEW` 1, `PARENT` 3, `CONTEXT` 8) | `pipes-runtime.ts` | 12.0 | Unchanged. | None needed. | +| LView `DECLARATION_COMPONENT_VIEW` | `pipes-runtime.ts` | 12.0 | Slot 16 up to 15.2, slot 15 from 16.0 (slot 16 then holds the declaration `LContainer`). | `declaringViewOf()` uses slot 16 when it holds a component view, else slot 15. | +| `R3Injector.records`, `.scopes`, `.source` | `injector-tree.ts`, `ngrx-collector.ts`, `zone-mode.ts` | 12.0 | Unchanged. | None needed. | +| `__ngDebugHydrationInfo__` | `hydration.ts`, `http-hydration.ts` | 17.1 | Hydration exists from 16.0, but dev builds only record this per node from 17.1. | Hosts are not marked hydrated or mismatched; the hydration section stays empty. | +| `SIGNAL` symbol on signal functions | `signal-resources.ts`, `ngrx-collector.ts` | 16.0 | Unchanged. | None needed: before 16.0 there are no signals. | +| `setPostSignalSetFn` (`@angular/core/primitives/signals`) | `signal-history.ts` | 17.0 as a package entry (16.x has it internally only) | Unchanged. | History falls back to sampled values (`writeHook: false`). See "Builds on older toolchains" below. | +| `AbstractControl.events` | `forms.ts`, `forms-collector.ts` | 18.0 | None. | `watchControlEvents()` returns null and the collector diffs snapshots for the form timeline. | +| `AbstractControl.getRawValue` on `FormControl` | `forms.ts`, `forms-actions.ts` | 14.0 (`FormGroup` and `FormArray` have it from 12.0) | None. | `isAbstractControl()` checks `setValue`, which every control has; raw values fall back to `value`. | +| `untracked` | `ngrx-collector.ts` | 16.0 | None. | Read at runtime from the `@angular/core` namespace; before 16.0 the value is read directly. A named import would fail webpack builds before 16.0. | +| `ng-version` attribute on root elements | `ng-capabilities.ts`, `change-detection.ts`, `router-setup.ts`, `host-tree.ts` and others | 12.0 | Unchanged. | Version unknown: messages say so, and `profiler` stays off. | ## Router internals -`router.ts` reads the Router through `read()` guards, so a field an older router lacks is left out of the report. `navigationTransitions` (whose events the overlay prefers) exists from 15.0; before that the overlay subscribes to `router.events`. The overlay calls `currentNavigation` when it is a function and falls back to `getCurrentNavigation()`, which every version has. `withComponentInputBinding` exists from 16.0 and `ROUTER_OUTLET_DATA` by 19.2 (not in 18.2). +`router.ts` reads the Router through `read()` guards, so a field an older router lacks is left out of the report. `navigationTransitions` (whose events the overlay prefers) exists from 15.0; before that the overlay subscribes to `router.events`. The overlay calls `currentNavigation` when it is a function and falls back to `getCurrentNavigation()`, which every version has. `withComponentInputBinding` exists from 16.0 and `routerOutletData` from 19.0. + +| Field or behaviour | First version | Before it | +| -------------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type` (`EventType`) on router events | 14.0 | `routerEventType()` reads the class name from `toString()` (`NavigationStart(id: 1, url: '/')`), which every event has and minification keeps. | +| `Router.options` (the `forRoot` options) | 15.0 | Only the options the Router keeps as fields are read (`onSameUrlNavigation`, `paramsInheritanceStrategy`, `urlUpdateStrategy`), and none shows as set. | +| `Router.lastSuccessfulNavigation` | 12.0, not on the Router in 15.0 to 16.x | 15.0 to 16.x keep it on `navigationTransitions` only, which `lastNavigationOf()` reads as well. | +| `Location._locationStrategy` | 14.0 | Named `_platformStrategy`; the setup reads either. | +| `Router.pendingTasks.hasPendingTasksObservable` | 20.0 | 16.0 to 19.x hold a `hasPendingTasks` subject, which `waitForStable()` reads; before 16.0 the Router has no pending tasks and the stability is unknown (`null`). | +| `navigationTransitions.currentTransition` | 16.2 | No `checked` (the routes whose guards Angular checks) on navigation records. | +| One matching pass (`applyRedirects` merged) | 16.0 | Angular runs `canMatch` guards in both passes, so the overlay records each run twice. | +| Redirect request events | 18.0 | A guard redirect is linked to its target from the cancel reason (`Redirecting to "/login"`) and the navigation that starts next. | +| `navigationErrorHandler` on the transitions, `RedirectCommand` | 18.0 | The handler is an environment initializer; the setup reports it as `off` and it can't redirect. | +| NG04016 (redirect loop) | 17.0 | The loop fails with NG04002 and is not reported as a loop. | +| Functional guards and resolvers, `provideRouter` | 14.2 | Class guards and `RouterModule.forRoot`; the overlay instruments both. | ## What each inspector shows -| Inspector | Full data from | On older versions | -| ---------------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Components (tree, detail, values) | 12.0 | Signal values are left out of the detail before 17.1 (no `ng.isSignal`). Injected services are listed from 16.2. | -| Components, `@defer` blocks | 19.1 | Unavailable note on 17.0 to 19.0; nothing on 12 to 16, which have no `@defer`. | -| Components, `@for` blocks | 22.0 | Unavailable note on 17.0 to 21.x; nothing on 12 to 16. | -| Components, change detection recording | 20.0 | Unavailable note. Angular profiling also needs 20.0. | -| Injectors | 17.0 | 16.2 to 16.x: element injectors with providers and dependencies, and environment injectors, with element injectors left out of lookup paths. 12.0 to 16.1: element injectors with their components and directives only. | -| Signals | 20.1 | The source scan and the unavailable note. 19.1 to 20.0 have `ɵgetSignalGraph` without node ids. | -| Router | 16.2 | 12.0 to 16.1: live data when a router link, an outlet's component or the root component holds the Router; otherwise none. | -| NgRx (`@ngrx/store`, signal stores provided in components) | 16.2 | Not found before 16.2; the view shows the unavailable note when it finds no store. | -| Pipes, Forms, HTTP, Errors | 12.0 | No version gate was found in the APIs they read. Signal Forms and WebMCP tools need Angular 22 on their own. | +| Inspector | Full data from | On older versions | +| ---------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Components (tree, detail, values) | 12.0 | Signal values are left out of the detail before 17.1 (no `ng.isSignal`). Injected services are listed from 16.2. | +| Components, `@defer` blocks | 19.1 | Unavailable note on 17.0 to 19.0; nothing on 12 to 16, which have no `@defer`. | +| Components, `@for` blocks | 22.0 | Unavailable note on 17.0 to 21.x; nothing on 12 to 16. | +| Components, change detection recording | 20.0 | Unavailable note. Angular profiling also needs 20.0. | +| Injectors | 17.0 | 16.2 to 16.x: element injectors with providers and dependencies, and environment injectors, with element injectors left out of lookup paths. 12.0 to 16.1: element injectors with their components and directives only. | +| Signals | 20.1 | The source scan and the unavailable note. 19.1 to 20.0 have `ɵgetSignalGraph` without node ids. | +| Router | 16.2 | 12.0 to 16.1: live data when a router link, an outlet's component or the root component holds the Router; otherwise none. | +| NgRx (`@ngrx/store`, signal stores provided in components) | 16.2 | Not found before 16.2; the view shows the unavailable note when it finds no store. | +| Pipes, Forms, HTTP, Errors | 12.0 | Forms: the event timeline comes from snapshot diffs before 18.0 (no `AbstractControl.events`). Errors: before 16.2 the `ErrorHandler` can't be found to wrap, so the collector counts what Angular's default handler logs. HTTP: the `/http` entry builds from 19.0. Signal Forms and WebMCP tools need Angular 22 on their own. | + +## How it is tested + +`.github/workflows/compat.yml` runs the page-side tests (`packages/devtools/vitest.compat.config.ts`) on Angular 12.2, 15.2, 17.3, 19.2, 20.0, 21 and 22, all on Node.js 22: the tests load Angular in jsdom with JIT, so they need no Angular CLI. Angular 12 ships View Engine bundles, so that job first compiles them for Ivy with ngcc from `@angular/compiler-cli` 12. The test helpers in `packages/devtools/src/__tests__/angular-version.ts` start TestBed, bootstrap an app (an NgModule before 14.1) and give zoneless change detection where each version has it. + +`angular-real-app.test.ts` bootstraps a small app with a pipe, a directive and the router on each version and checks that `window.ng` has exactly the utils `ng-shapes.ts` lists for that version, that `detectAngular()` reports the capabilities in the first table, and that each collector gives data or its documented fallback. `private-api-canary.test.ts` checks each util and framework field by the versions above. A test of a feature the version lacks checks the unavailable result on that version instead of being skipped; tests are skipped only where the Angular API the test builds on doesn't exist, such as functional guards before 14.2 or `@defer` before 17.0. The real `@ngrx` tests run from 19.0, because the workspace installs `@ngrx` 22. + +## Builds on older toolchains + +These were checked by building an Angular 12.2 and an Angular 15.2 app with `@angular-devkit/build-angular:browser` (webpack) and `import('@pangular-inspector/devtools/overlay')` in `main.ts`; the Angular 12 build was also loaded in jsdom, where the overlay started and looked for the server. + +- `signal-history.ts` imports `@angular/core/primitives/signals` with `/* webpackIgnore: true */`. webpack leaves the import to run time, where it fails and signal history falls back to samples (on 17 and 18 webpack builds too). Vite and esbuild resolve it; the Vite plugin aliases it to `signals-stub.ts` when the app's `@angular/core` is older than 17 (`vite-signals-stub.ts`). +- The dist files are `.mjs`, so webpack treats a named import of a missing export as an error. The overlay imports nothing by name that Angular 12 lacks; `untracked` is read at run time. `@pangular-inspector/devtools/http` imports `REQUEST` and `provideEnvironmentInitializer`, so it builds from Angular 19.0 only. +- `typesVersions` in `package.json` maps each entry to its `.d.mts` for `moduleResolution: node`, which Angular 12 to 16 apps use. TypeScript before 4.7 (Angular 12 and 13) can't read `.d.mts`, so those apps declare the module themselves (see the installation page). ## Not covered yet -- The inventory comes from the packed bundles. CI runs the page-side tests against real Angular 20, 21 and 22 apps, but not yet against 12 to 19; that is the follow-up (issue #386, step 5). The fake `ng` objects in `packages/devtools/src/__tests__/ng-shapes.ts` follow the key lists above. -- The overlay imports `@angular/core/primitives/signals` dynamically for the write hook. That entry exists from 17.0, so a bundler that resolves dynamic imports at build time (webpack in `@angular-devkit/build-angular`) may fail to build an Angular 12 to 16 app with the overlay. Setup on older builders is issue #386, step 6. -- `package.json` still declares `@angular/core >=20` and `@angular/common >=20` as peers. The floor moves once CI tests the older versions. +- Angular 13, 14, 16 and 18 are in the peer range but not run in CI. +- The `application` (esbuild) builder of Angular 17 to 19 was not built against; it resolves the signal primitives like Vite. +- `@pangular-inspector/devtools/http` needs Angular 19.0. Reading `REQUEST` and `provideEnvironmentInitializer` without a named import would let it build from 15.0 (`withInterceptors`). diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 0b9175f3..46d8463e 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -52,6 +52,49 @@ "./vite": "./dist/vite.mjs" } }, + "typesVersions": { + "*": { + "devframe": [ + "./dist/devframe.d.mts" + ], + "config": [ + "./dist/config.d.mts" + ], + "cd-data": [ + "./dist/cd-data.d.mts" + ], + "overlay": [ + "./dist/overlay.d.mts" + ], + "overlay-manual": [ + "./dist/overlay-manual.d.mts" + ], + "capacitor-hook": [ + "./dist/capacitor-hook.d.mts" + ], + "overlay-angular-native": [ + "./dist/overlay-angular-native.d.mts" + ], + "popup": [ + "./dist/popup.d.mts" + ], + "overlay-nativescript": [ + "./dist/overlay-nativescript.d.mts" + ], + "cli": [ + "./dist/cli.d.mts" + ], + "http": [ + "./dist/http.d.mts" + ], + "hub": [ + "./dist/hub.d.mts" + ], + "vite": [ + "./dist/vite.d.mts" + ] + } + }, "scripts": { "build": "tsdown && vite build --config ../../app/vite.config.ts --outDir ../packages/devtools/dist/public --emptyOutDir", "prepack": "pnpm build" @@ -96,8 +139,8 @@ }, "peerDependencies": { "@nativescript/core": ">=8", - "@angular/common": ">=20", - "@angular/core": ">=20", + "@angular/common": ">=12.2", + "@angular/core": ">=12.2", "rxjs": ">=7", "vite": ">=5" }, diff --git a/packages/devtools/src/__tests__/analog-load-inherit.test.ts b/packages/devtools/src/__tests__/analog-load-inherit.test.ts index 4ef2f292..eb418cae 100644 --- a/packages/devtools/src/__tests__/analog-load-inherit.test.ts +++ b/packages/devtools/src/__tests__/analog-load-inherit.test.ts @@ -2,15 +2,15 @@ import '@angular/compiler'; import { Component } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { Router, RouterOutlet, provideRouter, type Route, type Routes } from '@angular/router'; import { describe, expect, it } from 'vitest'; import { ANALOG_META_DESCRIPTION, chainOf, collectAnalog } from '../analog-runtime.ts'; import { scanAnalog } from '../rpc/analog-scan.ts'; import { resolveAnalogReport } from '../rpc/analog-tools.ts'; import { BASE_FILES, makeProject } from './analog-fixture.ts'; +import { initAngularTestBed } from './angular-version.ts'; -TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); +initAngularTestBed(); const META = Symbol(ANALOG_META_DESCRIPTION); diff --git a/packages/devtools/src/__tests__/angular-real-app.test.ts b/packages/devtools/src/__tests__/angular-real-app.test.ts new file mode 100644 index 00000000..1956a87c --- /dev/null +++ b/packages/devtools/src/__tests__/angular-real-app.test.ts @@ -0,0 +1,265 @@ +// @vitest-environment jsdom +// A small real app on whichever Angular is installed (the compat workflow runs +// this file on 12.2, 15.2, 17.3, 19.2, 20.0, 21 and 22): an NgModule app before +// 14.0, which has no standalone components, and a standalone app from 14.0. It +// checks that the page reports the debug API docs/angular-compat.md lists for +// that version, and that each collector gives data or its documented fallback. +import '@angular/compiler'; +import * as core from '@angular/core'; +import { + Component, + Directive, + Injectable, + NgModule, + Pipe, + type ApplicationRef, +} from '@angular/core'; +import { APP_BASE_HREF, CommonModule } from '@angular/common'; +import { BrowserModule, platformBrowser } from '@angular/platform-browser'; +import * as browser from '@angular/platform-browser'; +import { RouterModule, type Routes } from '@angular/router'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { addProfilerListener } from '../change-detection.ts'; +import { collectComponentTree, selectedComponentDetail } from '../component-tree.ts'; +import { ANGULAR_FEATURE_NEEDS, angularVersionAtLeast, type AngularCapability } from '../config.ts'; +import { createDeferTracker } from '../defer-blocks.ts'; +import { elementId } from '../element-id.ts'; +import { createForTracker } from '../for-blocks.ts'; +import { collectInjectorTree } from '../injector-tree.ts'; +import { detectAngular } from '../ng-capabilities.ts'; +import { scanPipeViews } from '../pipes-runtime.ts'; +import { findRouters, snapshotRouter, type RouterDebugApi } from '../router.ts'; +import { collectSignalGraph } from '../signal-graph.ts'; +import { angularAtLeast, STANDALONE, zonelessProviders } from './angular-version.ts'; +import { NG_UTILS, ngUtilsOf } from './ng-shapes.ts'; + +const version = core.VERSION.full; +const minor = `${core.VERSION.major}.${core.VERSION.minor}`; +const standalone = angularAtLeast('14.0'); + +class TripService { + readonly trips = ['lisbon', 'porto']; +} +Injectable({ providedIn: 'root' })(TripService); + +class TripTitlePipe { + transform(value: string): string { + return value.toUpperCase(); + } +} +Pipe({ ...STANDALONE, name: 'tripTitle' })(TripTitlePipe); + +class Tooltip {} +Directive({ ...STANDALONE, selector: '[appTooltip]' })(Tooltip); + +const signal = (core as unknown as { signal?: (value: T) => () => T }).signal; + +class TripCard { + trip = ''; + // A signal from 16.0, so the signal graph has a node to report; a getter before. + readonly seats = signal ? signal(2) : () => 2; +} +Component({ + ...STANDALONE, + selector: 'app-trip-card', + inputs: ['trip'], + ...(standalone ? { imports: [TripTitlePipe] } : {}), + template: '

{{ trip | tripTitle }}

{{ seats() }} seats

', +})(TripCard); + +class TripsPage {} +Component({ ...STANDALONE, selector: 'app-trips', template: '

Trips

' })(TripsPage); + +const routes: Routes = [ + { path: '', pathMatch: 'full', redirectTo: 'trips' }, + { path: 'trips', component: TripsPage }, +]; + +class App { + title = 'Trips'; + // An injected service, so the injector tree has a dependency to list. + constructor(readonly trips: TripService) {} +} +Component({ + ...STANDALONE, + selector: 'app-root', + ...(standalone ? { imports: [CommonModule, RouterModule, TripCard, Tooltip] } : {}), + template: ` +
+ + Trips + +
+ `, +})(App); +// What the CLI emits for constructor injection, which JIT cannot read from types. +(App as unknown as { ctorParameters: () => unknown[] }).ctorParameters = () => [ + { type: TripService }, +]; + +class AppModule {} + +async function bootstrap(): Promise<{ app: ApplicationRef; destroy: () => void }> { + if (standalone) { + const bootstrapApplication = ( + browser as unknown as { + bootstrapApplication: (type: unknown, config: unknown) => Promise; + } + ).bootstrapApplication; + const importProvidersFrom = ( + core as unknown as { importProvidersFrom: (m: unknown) => unknown } + ).importProvidersFrom; + const app = await bootstrapApplication(App, { + providers: [ + ...zonelessProviders(), + { provide: APP_BASE_HREF, useValue: '/' }, + importProvidersFrom(RouterModule.forRoot(routes, { initialNavigation: 'enabledBlocking' })), + ], + }); + return { app, destroy: () => app.destroy() }; + } + NgModule({ + imports: [ + BrowserModule, + RouterModule.forRoot(routes, { initialNavigation: 'enabledBlocking' } as never), + ], + declarations: [App, TripCard, Tooltip, TripTitlePipe, TripsPage], + providers: [{ provide: APP_BASE_HREF, useValue: '/' }], + bootstrap: [App], + })(AppModule); + const ref = await platformBrowser().bootstrapModule(AppModule); + // `ApplicationRef.destroy()` exists from 14.1; the module ref tears the app down before. + return { app: ref.injector.get(core.ApplicationRef), destroy: () => ref.destroy() }; +} + +type Ng = Record; + +describe(`a real Angular ${version} app`, () => { + let app: ApplicationRef; + let destroy: () => void; + let ng: Ng; + let root: Element; + + beforeAll(async () => { + document.body.innerHTML = ''; + ({ app, destroy } = await bootstrap()); + app.tick(); + await new Promise((resolve) => setTimeout(resolve, 0)); + app.tick(); + ng = (globalThis as { ng?: Ng }).ng!; + root = document.querySelector('app-root')!; + }); + + afterAll(() => { + destroy(); + document.body.innerHTML = ''; + }); + + it('publishes the window.ng utils docs/angular-compat.md lists for this version', () => { + const expected = ngUtilsOf(minor); + // `ɵgetRouterInstance` comes from `provideRouter`, which this app does not use. + const checked = Object.values(NG_UTILS) + .flat() + .filter((name) => name !== 'ɵgetRouterInstance'); + const have = checked.filter((name) => typeof ng[name] === 'function'); + expect(have.sort()).toEqual(expected.filter((name) => checked.includes(name)).sort()); + }); + + it('reports the capabilities config.ts names for this version', () => { + const info = detectAngular(ng, document); + expect(info.version).toBe(version); + const expected: Partial> = { + debugApi: true, + injectorProviders: angularAtLeast('16.2'), + injectorMetadata: angularAtLeast('17.0'), + isSignal: angularAtLeast('17.1'), + // The util exists from 19.1; its nodes carry ids from 20.1 (see below). + signalGraph: angularAtLeast('19.1'), + deferBlocks: angularAtLeast('19.1'), + forBlocks: angularAtLeast('22.0'), + profiler: angularAtLeast('20.0'), + angularProfiling: angularAtLeast('20.0'), + routerInstance: false, + }; + expect(info.capabilities).toEqual(expected); + // The panel and the agent tools name the same first versions. + for (const name of [ + 'injectorProviders', + 'injectorMetadata', + 'isSignal', + 'deferBlocks', + ] as const) { + expect(angularVersionAtLeast(version, ANGULAR_FEATURE_NEEDS[name].since), name).toBe( + expected[name], + ); + } + }); + + it('lists the component tree and reads the selected component', () => { + const tree = collectComponentTree(ng as never, {}); + expect(tree.roots.map((node) => node.name)).toEqual(['App']); + const names = (nodes: typeof tree.roots): string[] => + nodes.flatMap((node) => [node.name, ...names(node.children)]); + expect(names(tree.roots)).toEqual(['App', 'TripCard', 'TripsPage']); + const card = document.querySelector('app-trip-card')!; + const detail = selectedComponentDetail(ng as never, { selectedId: elementId(card) }); + expect(detail?.name).toBe('TripCard'); + expect(detail?.inputs.map((input) => input.name)).toEqual(['trip']); + expect(detail?.directives.map((directive) => directive.name)).toContain('Tooltip'); + }); + + it('lists the injector tree as far as this version allows', () => { + const report = collectInjectorTree(ng as never); + const [appNode] = report.roots; + expect(appNode?.injector).toMatchObject({ type: 'element', component: 'App' }); + const services = appNode!.providers.map((provider) => provider.token); + if (angularAtLeast('17.0')) { + expect(report.environment.length).toBeGreaterThan(0); + expect(appNode!.injector.path?.length).toBeGreaterThan(0); + } else if (angularAtLeast('16.2')) { + expect(report.environment.length).toBeGreaterThan(0); + } else { + // 12.0 to 16.1: element injectors with their components and directives only. + expect(report.environment).toEqual([]); + expect(appNode!.injector.path).toEqual([]); + expect(services).toEqual([]); + } + const card = appNode!.children.find((child) => child.injector.component === 'TripCard'); + expect(card?.injector.directives).toEqual(['TripCard', 'Tooltip']); + }); + + it('finds the Router and reads the active route on every version', () => { + const routers = findRouters(ng as RouterDebugApi, [root]); + expect(routers).toHaveLength(1); + const snapshot = snapshotRouter(routers[0]!); + expect(snapshot?.url).toBe('/trips'); + }); + + it('attributes pipe usages to the component that declares them', () => { + const { usages } = scanPipeViews(ng as never, document.querySelectorAll('*')); + const usage = usages.find((each) => each.name === 'tripTitle'); + expect(usage?.component).toBeInstanceOf(TripCard); + }); + + it('gives no signal graph, defer or @for data where the version lacks the util', () => { + const card = document.querySelector('app-trip-card')!; + const graph = collectSignalGraph(ng as never, { id: elementId(card) }, document); + if (angularAtLeast('20.1')) { + expect(graph?.unsupported).toBeFalsy(); + expect(graph?.component?.name).toBe('TripCard'); + expect(graph?.nodes.length).toBeGreaterThan(0); + } else if (angularAtLeast('19.1')) expect(graph?.unsupported).toBe(true); + else expect(graph).toBeNull(); + + const defer = createDeferTracker().collect(ng as never, document); + expect(defer).toEqual(angularAtLeast('19.1') ? [] : undefined); + const forBlocks = createForTracker().collect(ng as never, document); + expect(forBlocks).toEqual(angularAtLeast('22.0') ? { forBlocks: [] } : undefined); + }); + + it('leaves the profiler slot alone before 20.0', () => { + const remove = addProfilerListener(ng, () => {}, document); + expect(typeof remove).toBe(angularAtLeast('20.0') ? 'function' : 'object'); + remove?.(); + }); +}); diff --git a/packages/devtools/src/__tests__/angular-version.ts b/packages/devtools/src/__tests__/angular-version.ts index 6b1b09a2..376302a8 100644 --- a/packages/devtools/src/__tests__/angular-version.ts +++ b/packages/devtools/src/__tests__/angular-version.ts @@ -1,4 +1,10 @@ -import { VERSION } from '@angular/core'; +import '@angular/compiler'; +import * as core from '@angular/core'; +import { CommonModule as commonModule, DOCUMENT } from '@angular/common'; +import { TestBed, TestComponentRenderer } from '@angular/core/testing'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; + +const { VERSION } = core; export function angularAtLeast(version: string): boolean { const have = VERSION.full.split(/[.-]/).map((part) => parseInt(part, 10)); @@ -8,3 +14,211 @@ export function angularAtLeast(version: string): boolean { } return true; } + +/** The installed Angular major, for tests that compare against a version table. */ +export const angularMajor = parseInt(VERSION.major, 10); + +/** What `DOMTestComponentRenderer` of `@angular/platform-browser-dynamic/testing` does. */ +class DomTestComponentRenderer extends TestComponentRenderer { + private readonly roots: Element[] = []; + override insertRootElement(rootElId: string): void { + this.removeAllRootElements(); + const root = document.createElement('div'); + root.setAttribute('id', rootElId); + document.body.appendChild(root); + this.roots.push(root); + } + override removeAllRootElements(): void { + for (const root of this.roots.splice(0)) root.remove(); + } +} + +/** + * Starts TestBed on any Ivy version. Before 19.0 the browser testing platform + * is built on `platformCore`, so it has no JIT compiler options, no `DOCUMENT` + * and no component renderer: apps on those versions used + * `platformBrowserDynamicTesting`. They are added here instead of installing + * `@angular/platform-browser-dynamic`. + */ +export function initAngularTestBed(): void { + let testingModule: unknown = BrowserTestingModule; + const extra: core.StaticProvider[] = []; + if (!angularAtLeast('19.0')) { + extra.push( + { provide: core.COMPILER_OPTIONS, useValue: {}, multi: true }, + { provide: DOCUMENT, useFactory: () => document, deps: [] }, + ); + class LegacyBrowserTestingModule {} + core.NgModule({ + exports: [BrowserTestingModule], + providers: [{ provide: TestComponentRenderer, useClass: DomTestComponentRenderer }], + })(LegacyBrowserTestingModule); + testingModule = LegacyBrowserTestingModule; + } + try { + TestBed.initTestEnvironment( + testingModule as typeof BrowserTestingModule, + platformBrowserTesting(extra), + ); + } catch { + // already initialized in this worker + } +} + +type AnyCore = Record; + +/** + * Zoneless change detection where Angular has it (18.0 experimental, 20.0 + * stable). Older versions run on zone.js, which `zone-setup.ts` loads for them. + */ +export function zonelessProviders(): core.EnvironmentProviders[] { + const api = core as unknown as AnyCore; + const provide = (api['provideZonelessChangeDetection'] ?? + api['provideExperimentalZonelessChangeDetection']) as + (() => core.EnvironmentProviders) | undefined; + return provide ? [provide()] : []; +} + +/** + * Decorator options that make a JIT component or directive standalone. From + * 19.0 that is the default; 14.0 to 18.x need the flag. + */ +export const STANDALONE: { standalone?: true } = angularAtLeast('19.0') ? {} : { standalone: true }; + +/** A bootstrapped test app: its root injector and how to tear it down. */ +export interface BootedApp { + injector: core.Injector; + appRef: core.ApplicationRef; + destroy(): void; +} + +/** + * Bootstraps `root` the way an app on the installed version would: + * `bootstrapApplication` from 14.1 (with zoneless change detection where + * Angular has it), an NgModule before. `modules` are NgModules such as + * `RouterModule.forRoot(...)`, imported into the app either way. + */ +export async function bootstrapRoot( + root: core.Type, + options: { + providers?: (core.Provider | core.EnvironmentProviders)[]; + modules?: unknown[]; + /** Components, directives and pipes `root` uses, declared with it before 14.0. */ + declarations?: core.Type[]; + } = {}, +): Promise { + const browser = (await import('@angular/platform-browser')) as unknown as Record; + const api = core as unknown as AnyCore; + const bootstrapApplication = browser['bootstrapApplication'] as + ((type: unknown, config: unknown) => Promise) | undefined; + const importProvidersFrom = api['importProvidersFrom'] as + ((...modules: unknown[]) => core.EnvironmentProviders) | undefined; + if (bootstrapApplication && importProvidersFrom && angularAtLeast('14.1')) { + const appRef = await bootstrapApplication(root, { + providers: [ + ...zonelessProviders(), + ...(options.modules?.length ? [importProvidersFrom(...options.modules)] : []), + ...(options.providers ?? []), + ], + }); + return { injector: appRef.injector, appRef, destroy: () => appRef.destroy() }; + } + // A component belongs to one NgModule, so each root gets its own, reused by every boot. + let declaring = rootModules.get(root); + if (!declaring) { + declaring = class RootModule {}; + const declarations = [root, ...(options.declarations ?? [])]; + core.NgModule({ + imports: [commonModule], + declarations, + exports: declarations, + })(declaring); + rootModules.set(root, declaring); + } + class AppModule {} + core.NgModule({ + imports: [browser['BrowserModule'], declaring, ...(options.modules ?? [])] as never[], + providers: options.providers as core.Provider[], + bootstrap: [root], + })(AppModule); + const platformBrowser = browser['platformBrowser'] as () => core.PlatformRef; + const moduleRef = await platformBrowser().bootstrapModule(AppModule); + let destroyed = false; + return { + injector: moduleRef.injector, + appRef: moduleRef.injector.get(core.ApplicationRef), + destroy: () => { + if (!destroyed) moduleRef.destroy(); + destroyed = true; + }, + }; +} + +const rootModules = new WeakMap>(); + +/** + * A writable signal, or on Angular before 16.0 (no signals) a function with the + * same `set` and `update`, so a template that calls it renders on every version. + */ +export function writableSignal(initial: T): core.WritableSignal { + const create = (core as unknown as AnyCore)['signal'] as + ((value: T) => core.WritableSignal) | undefined; + if (create) return create(initial); + let value = initial; + const read = (() => value) as core.WritableSignal; + return Object.assign(read, { + set: (next: T) => void (value = next), + update: (fn: (current: T) => T) => void (value = fn(value)), + }); +} + +/** Registers `callback` after each render: `afterEveryRender` from 20.0, `afterRender` from 16.2. */ +export function afterEachRender(callback: () => void): void { + const api = core as unknown as AnyCore; + const register = (api['afterEveryRender'] ?? api['afterRender']) as + ((callback: () => void) => unknown) | undefined; + register?.(callback); +} + +/** `@if`, `@for` and the other control flow blocks exist from 17.0. */ +export const controlFlow = angularAtLeast('17.0'); + +type Annotated = { __annotations__?: { ngMetadataName?: string; imports?: unknown[] }[] }; + +/** + * What TestBed needs to create `types` on any version. From 14.0 the test's + * components are standalone and need nothing. Before, each is declared in + * the testing module with the components, directives and pipes its + * decorator lists in `imports` (read back from the JIT annotations), and + * anything else, such as `CurrencyPipe` or `NgIf`, comes from `CommonModule`. + */ +export function testModuleFor(...types: core.Type[]): { + declarations?: core.Type[]; + imports?: unknown[]; +} { + if (angularAtLeast('14.0')) return {}; + const declarations = new Set>(); + const visit = (each: unknown) => { + const annotations = Object.hasOwn(each as object, '__annotations__') + ? (each as Annotated).__annotations__ + : undefined; + if (!annotations?.length || declarations.has(each as core.Type)) return; + declarations.add(each as core.Type); + for (const annotation of annotations) for (const dep of annotation.imports ?? []) visit(dep); + }; + types.forEach(visit); + return { declarations: [...declarations], imports: [commonModule] }; +} + +/** + * `inject(token)` for a component field. Before 14.0 a component's field + * initializers can't call `inject()`, so the directive injector the compiled + * factory uses is asked instead. + */ +export function injectHere(token: core.ProviderToken): T { + if (angularAtLeast('14.0')) return core.inject(token); + const directiveInject = (core as unknown as AnyCore)['ɵɵdirectiveInject'] as ( + token: core.ProviderToken, + ) => T; + return directiveInject(token); +} diff --git a/packages/devtools/src/__tests__/cd-recorder-real.test.ts b/packages/devtools/src/__tests__/cd-recorder-real.test.ts index 78d602af..79700288 100644 --- a/packages/devtools/src/__tests__/cd-recorder-real.test.ts +++ b/packages/devtools/src/__tests__/cd-recorder-real.test.ts @@ -1,33 +1,39 @@ // @vitest-environment jsdom import '@angular/compiler'; -import { - Component, - afterEveryRender, - provideZonelessChangeDetection, - signal, - type ApplicationRef, -} from '@angular/core'; -import { bootstrapApplication } from '@angular/platform-browser'; +import { Component } from '@angular/core'; import { afterEach, describe, expect, it } from 'vitest'; import { attachChangeDetection, type CdReport } from '../cd-overlay.ts'; import { PROFILER_EVENT as E } from '../cd-recorder.ts'; import { addProfilerListener } from '../change-detection.ts'; import { elementId } from '../element-id.ts'; -import { angularAtLeast } from './angular-version.ts'; +import { + afterEachRender, + angularAtLeast, + bootstrapRoot, + writableSignal, + STANDALONE, + type BootedApp, +} from './angular-version.ts'; class TripCart { - readonly items = signal(0); + readonly items = writableSignal(0); add() { this.items.update((n) => n + 1); } } Component({ + ...STANDALONE, selector: 'app-trip-cart', template: ' {{ items() }} items', })(TripCart); class TripShell {} -Component({ selector: 'app-root', imports: [TripCart], template: '' })(TripShell); +Component({ + ...STANDALONE, + selector: 'app-root', + imports: [TripCart], + template: '', +})(TripShell); // Plain inputs: JIT without the CLI transform cannot see signal inputs. class HookBadge { @@ -37,23 +43,29 @@ class HookBadge { ngDoCheck() {} ngAfterViewChecked() {} } -Component({ selector: 'app-hook-badge', inputs: ['count'], template: 'n={{ count }}' })(HookBadge); +Component({ + ...STANDALONE, + selector: 'app-hook-badge', + inputs: ['count'], + template: 'n={{ count }}', +})(HookBadge); class HookShell { - readonly count = signal(0); + readonly count = writableSignal(0); rendered = 0; constructor() { - afterEveryRender(() => this.rendered++); + afterEachRender(() => this.rendered++); } bump() { this.count.update((n) => n + 1); } } Component({ + ...STANDALONE, selector: 'app-root', imports: [HookBadge], template: - '', + '', })(HookShell); const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -77,7 +89,7 @@ function fakeRpc() { } describe('change detection recorder with a real app', () => { - let app: ApplicationRef | null = null; + let app: BootedApp | null = null; let cleanup: (() => void) | null = null; afterEach(() => { cleanup?.(); @@ -89,10 +101,8 @@ describe('change detection recorder with a real app', () => { async function boot() { document.body.innerHTML = ''; - app = await bootstrapApplication(TripShell, { - providers: [provideZonelessChangeDetection()], - }); - app.tick(); + app = await bootstrapRoot(TripShell, { declarations: [TripCart] }); + app.appRef.tick(); } it('gets the profiler events the recorder reads, with the numbers it expects', async () => { @@ -174,10 +184,8 @@ describe('change detection recorder with a real app', () => { it('times real lifecycle hooks, outputs and afterRender callbacks', async () => { document.body.innerHTML = ''; - app = await bootstrapApplication(HookShell, { - providers: [provideZonelessChangeDetection()], - }); - app.tick(); + app = await bootstrapRoot(HookShell, { declarations: [HookBadge] }); + app.appRef.tick(); const events: number[] = []; const removeSpy = addProfilerListener(ngOf(), (event) => events.push(event)); const { my, handlers, reports } = fakeRpc(); diff --git a/packages/devtools/src/__tests__/change-detection-real.test.ts b/packages/devtools/src/__tests__/change-detection-real.test.ts index 405332ce..ee7d1602 100644 --- a/packages/devtools/src/__tests__/change-detection-real.test.ts +++ b/packages/devtools/src/__tests__/change-detection-real.test.ts @@ -1,27 +1,28 @@ // @vitest-environment jsdom import '@angular/compiler'; -import { - Component, - VERSION, - provideZonelessChangeDetection, - signal, - type ApplicationRef, -} from '@angular/core'; -import { bootstrapApplication } from '@angular/platform-browser'; +import { Component, VERSION } from '@angular/core'; import { afterEach, describe, expect, it } from 'vitest'; import { addProfilerListener, angularMajor, watchChangeDetection } from '../change-detection.ts'; -import { angularAtLeast } from './angular-version.ts'; +import { + angularAtLeast, + bootstrapRoot, + writableSignal, + STANDALONE, + type BootedApp, +} from './angular-version.ts'; class TripCounter { - readonly count = signal(0); + readonly count = writableSignal(0); } -Component({ selector: 'app-root', template: '

{{ count() }} trips

' })(TripCounter); +Component({ ...STANDALONE, selector: 'app-root', template: '

{{ count() }} trips

' })( + TripCounter, +); const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const ngOf = () => (globalThis as { ng?: unknown }).ng; describe('watchChangeDetection with a real app', () => { - let app: ApplicationRef | null = null; + let app: BootedApp | null = null; let stop: (() => void) | null = null; afterEach(() => { stop?.(); @@ -33,11 +34,9 @@ describe('watchChangeDetection with a real app', () => { async function boot() { document.body.innerHTML = ''; - app = await bootstrapApplication(TripCounter, { - providers: [provideZonelessChangeDetection()], - }); - app.tick(); - return app.components[0].instance as TripCounter; + app = await bootstrapRoot(TripCounter); + app.appRef.tick(); + return app.appRef.components[0]!.instance as TripCounter; } it('reads the major version from the ng-version attribute', async () => { diff --git a/packages/devtools/src/__tests__/defer-blocks-real.test.ts b/packages/devtools/src/__tests__/defer-blocks-real.test.ts index 165d1839..851e6ef5 100644 --- a/packages/devtools/src/__tests__/defer-blocks-real.test.ts +++ b/packages/devtools/src/__tests__/defer-blocks-real.test.ts @@ -1,20 +1,17 @@ // @vitest-environment jsdom import '@angular/compiler'; -import { - Component, - provideZonelessChangeDetection, - signal, - type ApplicationRef, -} from '@angular/core'; +import { Component, signal, type ApplicationRef } from '@angular/core'; import { bootstrapApplication } from '@angular/platform-browser'; import { afterEach, describe, expect, it } from 'vitest'; import { elementById } from '../element-id.ts'; import { createDeferTracker, type DeferDebugNg } from '../defer-blocks.ts'; +import { angularAtLeast, zonelessProviders, STANDALONE } from './angular-version.ts'; class TripReviews { readonly ready = signal(false); } Component({ + ...STANDALONE, selector: 'app-root', template: ` @defer (when ready()) { @@ -29,12 +26,14 @@ Component({ class ExamplePanel {} Component({ + ...STANDALONE, selector: 'app-example-panel', template: `
`, })(ExamplePanel); class LazyExample {} Component({ + ...STANDALONE, selector: 'app-root', imports: [ExamplePanel], template: ` @@ -55,7 +54,11 @@ const settle = async (app: ApplicationRef) => { } }; -describe('createDeferTracker with a real app', () => { +/** `ɵgetDeferBlocks` exists from 19.1 (`ɵgetControlFlowBlocks` from 22.0). */ +const deferUtil = angularAtLeast('19.1'); + +// @defer exists from 17.0; angular-real-app.test.ts covers the versions before it. +describe.runIf(angularAtLeast('17.0'))('createDeferTracker with a real app', () => { let app: ApplicationRef | null = null; afterEach(() => { app?.destroy(); @@ -66,13 +69,19 @@ describe('createDeferTracker with a real app', () => { it('reads a real @defer block through the private defer block util', async () => { document.body.innerHTML = ''; app = await bootstrapApplication(TripReviews, { - providers: [provideZonelessChangeDetection()], + providers: [...zonelessProviders()], }); await settle(app); const ng = (globalThis as { ng?: DeferDebugNg }).ng!; - expect(typeof (ng.ɵgetControlFlowBlocks ?? ng.ɵgetDeferBlocks)).toBe('function'); const tracker = createDeferTracker(); + if (!deferUtil) { + // 17.0 to 19.0 render @defer but publish no util: the page reports none. + expect(ng.ɵgetControlFlowBlocks ?? ng.ɵgetDeferBlocks).toBeUndefined(); + expect(tracker.collect(ng)).toBeUndefined(); + return; + } + expect(typeof (ng.ɵgetControlFlowBlocks ?? ng.ɵgetDeferBlocks)).toBe('function'); const [block] = tracker.collect(ng) ?? []; expect(block).toMatchObject({ state: 'placeholder', @@ -93,12 +102,16 @@ describe('createDeferTracker with a real app', () => { it('names the template owner of a @defer block projected into another component', async () => { document.body.innerHTML = ''; app = await bootstrapApplication(LazyExample, { - providers: [provideZonelessChangeDetection()], + providers: [...zonelessProviders()], }); await settle(app); const ng = (globalThis as { ng?: DeferDebugNg }).ng!; const tracker = createDeferTracker(); + if (!deferUtil) { + expect(tracker.collect(ng)).toBeUndefined(); + return; + } const [block] = tracker.collect(ng) ?? []; expect(block).toMatchObject({ owner: { name: 'LazyExample', tag: 'app-root' } }); expect(elementById(block.owner!.id)).toBe(document.querySelector('app-root')); diff --git a/packages/devtools/src/__tests__/errors-real.test.ts b/packages/devtools/src/__tests__/errors-real.test.ts index db00e1f1..d4d46c58 100644 --- a/packages/devtools/src/__tests__/errors-real.test.ts +++ b/packages/devtools/src/__tests__/errors-real.test.ts @@ -1,31 +1,40 @@ // @vitest-environment jsdom import '@angular/compiler'; -import { - Component, - ErrorHandler, - Injectable, - computed, - provideZonelessChangeDetection, - signal, - type ApplicationRef, -} from '@angular/core'; -import { bootstrapApplication } from '@angular/platform-browser'; +import * as core from '@angular/core'; +import { Component, ErrorHandler, Injectable } from '@angular/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { elementById } from '../element-id.ts'; import { attachErrors, type ErrorsDebugNg } from '../errors-collector.ts'; import type { ErrorsReport } from '../rpc/errors-tools.ts'; +import { + angularAtLeast, + bootstrapRoot, + writableSignal, + STANDALONE, + type BootedApp, +} from './angular-version.ts'; + +/** + * The collector wraps the app's ErrorHandler, found through its provider + * (ɵgetInjectorProviders, 16.2+). Before, it counts what Angular's default + * handler logs (`console.error('ERROR', error)`) instead. + */ +const wrapsHandler = angularAtLeast('16.2'); class TripCard { - readonly seats = signal(3); + readonly seats = writableSignal(3); book(): void { throw new Error('No seats left on trip 42'); } /** Writes a signal inside a computed, which Angular refuses with NG0600. */ recount(): void { - computed(() => this.seats.set(2))(); + (core as unknown as { computed: (fn: () => void) => () => void }).computed(() => + this.seats.set(2), + )(); } } Component({ + ...STANDALONE, selector: 'app-trip-card', template: ` `, @@ -33,9 +42,10 @@ Component({ class TripsApp {} Component({ + ...STANDALONE, selector: 'app-root', imports: [TripCard], - template: `

Trips

`, + template: `

Trips

`, })(TripsApp); const handled: unknown[] = []; @@ -46,10 +56,10 @@ class RecordingHandler extends ErrorHandler { } Injectable()(RecordingHandler); -const settle = async (app: ApplicationRef) => { +const settle = async (app: BootedApp) => { for (let i = 0; i < 3; i++) { await new Promise((resolve) => setTimeout(resolve, 0)); - app.tick(); + app.appRef.tick(); } }; @@ -68,7 +78,7 @@ function harness() { } describe('the errors collector with a real app', () => { - let app: ApplicationRef | null = null; + let app: BootedApp | null = null; let stop = () => {}; afterEach(() => { stop(); @@ -82,12 +92,12 @@ describe('the errors collector with a real app', () => { it("wraps the default ErrorHandler, keeps it logging, and links the template's component", async () => { const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); document.body.innerHTML = ''; - app = await bootstrapApplication(TripsApp, { providers: [provideZonelessChangeDetection()] }); + app = await bootstrapRoot(TripsApp, { declarations: [TripCard] }); await settle(app); const { reports, collector } = harness(); stop = collector.stop; await collector.push(); - expect(reports.at(-1)?.handler).toBe('wrapped'); + expect(reports.at(-1)?.handler).toBe(wrapsHandler ? 'wrapped' : 'none'); document.querySelector('button')!.click(); await collector.push(); @@ -106,36 +116,47 @@ describe('the errors collector with a real app', () => { expect(reports.at(-1)!.console.map((entry) => entry.level)).toContain('error'); }); - it('reads the code of a real RuntimeError and links its reference page', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - document.body.innerHTML = ''; - app = await bootstrapApplication(TripsApp, { providers: [provideZonelessChangeDetection()] }); - await settle(app); - const { reports, collector } = harness(); - stop = collector.stop; - document.querySelector('button.recount')!.click(); - await collector.push(); - expect(reports.at(-1)!.errors[0]).toMatchObject({ - code: 'NG0600', - docUrl: 'https://angular.dev/errors/NG0600', - owner: { name: 'TripCard' }, - }); - }); + // NG0600 (a signal written inside a computed) exists with signals, from 16.0. + it.runIf(angularAtLeast('16.0'))( + 'reads the code of a real RuntimeError and links its reference page', + async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + document.body.innerHTML = ''; + app = await bootstrapRoot(TripsApp, { declarations: [TripCard] }); + await settle(app); + const { reports, collector } = harness(); + stop = collector.stop; + document.querySelector('button.recount')!.click(); + await collector.push(); + expect(reports.at(-1)!.errors[0]).toMatchObject({ + code: 'NG0600', + docUrl: 'https://angular.dev/errors/NG0600', + owner: { name: 'TripCard' }, + }); + }, + ); it('keeps a custom ErrorHandler running and restores it on stop', async () => { document.body.innerHTML = ''; - app = await bootstrapApplication(TripsApp, { - providers: [ - provideZonelessChangeDetection(), - { provide: ErrorHandler, useClass: RecordingHandler }, - ], + app = await bootstrapRoot(TripsApp, { + declarations: [TripCard], + providers: [{ provide: ErrorHandler, useClass: RecordingHandler }], }); await settle(app); const { reports, collector } = harness(); await collector.push(); + stop = collector.stop; + if (!wrapsHandler) { + // A custom handler that does not log can't be seen before 16.2; it still runs. + expect(reports.at(-1)?.handler).toBe('none'); + document.querySelector('button')!.click(); + await collector.push(); + expect(handled).toHaveLength(1); + expect(reports.at(-1)!.errors).toEqual([]); + return; + } expect(reports.at(-1)?.handler).toBe('wrapped'); - stop = collector.stop; document.querySelector('button')!.click(); document.querySelector('button')!.click(); await collector.push(); diff --git a/packages/devtools/src/__tests__/forms-actions-fill-submit.test.ts b/packages/devtools/src/__tests__/forms-actions-fill-submit.test.ts index 14f57a95..21df82c7 100644 --- a/packages/devtools/src/__tests__/forms-actions-fill-submit.test.ts +++ b/packages/devtools/src/__tests__/forms-actions-fill-submit.test.ts @@ -3,16 +3,12 @@ import '@angular/compiler'; import { Component } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { afterEach, describe, expect, it } from 'vitest'; import { findForms } from '../forms.ts'; import { runFormAction, type ActionContext } from '../forms-actions.ts'; +import { initAngularTestBed } from './angular-version.ts'; -try { - TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); -} catch { - // already initialized in this worker -} +initAngularTestBed(); afterEach(() => TestBed.resetTestingModule()); diff --git a/packages/devtools/src/__tests__/forms-actions.test.ts b/packages/devtools/src/__tests__/forms-actions.test.ts index 5c6902a4..14a8b448 100644 --- a/packages/devtools/src/__tests__/forms-actions.test.ts +++ b/packages/devtools/src/__tests__/forms-actions.test.ts @@ -10,7 +10,6 @@ import { Validators, } from '@angular/forms'; import { FormField, FormRoot, form, hidden, required } from '@angular/forms/signals'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { afterEach, describe, expect, it } from 'vitest'; import { findForms } from '../forms.ts'; import { @@ -20,12 +19,9 @@ import { secretInside, type ActionContext, } from '../forms-actions.ts'; +import { initAngularTestBed } from './angular-version.ts'; -try { - TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); -} catch { - // already initialized in this worker -} +initAngularTestBed(); afterEach(() => TestBed.resetTestingModule()); diff --git a/packages/devtools/src/__tests__/forms-audit.test.ts b/packages/devtools/src/__tests__/forms-audit.test.ts index 98a5bb94..a5132e55 100644 --- a/packages/devtools/src/__tests__/forms-audit.test.ts +++ b/packages/devtools/src/__tests__/forms-audit.test.ts @@ -13,7 +13,6 @@ import { submit, validate, } from '@angular/forms/signals'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { attachForms } from '../forms-collector.ts'; import { coerceToCurrent } from '../forms-actions.ts'; @@ -28,12 +27,9 @@ import { } from '../forms.ts'; import { controlFacts } from '../forms-read.ts'; import { lintForm } from '../rpc/forms-lint.ts'; +import { initAngularTestBed } from './angular-version.ts'; -try { - TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); -} catch { - // already initialized in this worker -} +initAngularTestBed(); const stops: (() => void)[] = []; afterEach(() => { diff --git a/packages/devtools/src/__tests__/forms-collector.test.ts b/packages/devtools/src/__tests__/forms-collector.test.ts index 145eaf12..2ee76a05 100644 --- a/packages/devtools/src/__tests__/forms-collector.test.ts +++ b/packages/devtools/src/__tests__/forms-collector.test.ts @@ -10,19 +10,15 @@ import { provideExperimentalWebMcpForms, required, } from '@angular/forms/signals'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { setFlagsFromString } from 'node:v8'; import { runInNewContext } from 'node:vm'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { attachForms, setupErrorOf } from '../forms-collector.ts'; import type { FormEvent } from '../forms.ts'; import { LEASED_PUSH_MS } from '../page-lease.ts'; +import { initAngularTestBed } from './angular-version.ts'; -try { - TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); -} catch { - // already initialized in this worker -} +initAngularTestBed(); const stops: (() => void)[] = []; afterEach(() => { diff --git a/packages/devtools/src/__tests__/forms-dotted-keys.test.ts b/packages/devtools/src/__tests__/forms-dotted-keys.test.ts index df939ecd..7062e06f 100644 --- a/packages/devtools/src/__tests__/forms-dotted-keys.test.ts +++ b/packages/devtools/src/__tests__/forms-dotted-keys.test.ts @@ -4,19 +4,15 @@ import { Component, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { FormField, form } from '@angular/forms/signals'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { afterEach, describe, expect, it } from 'vitest'; import { collectForms, controlPathOf, diffForms, findForms, nodeAt } from '../forms.ts'; import { runFormAction, type ActionContext } from '../forms-actions.ts'; import { fieldPath } from '../forms-read.ts'; import { formSourceIn } from '../rpc/forms-source.ts'; import { inferShape, requiredNow, schemaInputs, schemaRequired } from '../forms-webmcp.ts'; +import { initAngularTestBed } from './angular-version.ts'; -try { - TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); -} catch { - // already initialized in this worker -} +initAngularTestBed(); afterEach(() => TestBed.resetTestingModule()); diff --git a/packages/devtools/src/__tests__/forms-instrument.test.ts b/packages/devtools/src/__tests__/forms-instrument.test.ts index 7c80688d..bf77842b 100644 --- a/packages/devtools/src/__tests__/forms-instrument.test.ts +++ b/packages/devtools/src/__tests__/forms-instrument.test.ts @@ -4,7 +4,6 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms'; import { form } from '@angular/forms/signals'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { describe, expect, it } from 'vitest'; import { collectForms, diffForms } from '../forms.ts'; import { @@ -14,12 +13,9 @@ import { instrumentForms, type InstrumentCall, } from '../forms-instrument.ts'; +import { initAngularTestBed } from './angular-version.ts'; -try { - TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); -} catch { - // already initialized in this worker -} +initAngularTestBed(); describe('caller frames', () => { it('skips framework and devtools frames and trims URLs', () => { diff --git a/packages/devtools/src/__tests__/forms-real.test.ts b/packages/devtools/src/__tests__/forms-real.test.ts index d3f65aba..cce2b56d 100644 --- a/packages/devtools/src/__tests__/forms-real.test.ts +++ b/packages/devtools/src/__tests__/forms-real.test.ts @@ -15,11 +15,11 @@ import { validate, } from '@angular/forms/signals'; import { SignalFormControl, compatForm } from '@angular/forms/signals/compat'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { describe, expect, it } from 'vitest'; import { isFieldTree, serializeControl, serializeField } from '../forms.ts'; +import { initAngularTestBed } from './angular-version.ts'; -TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); +initAngularTestBed(); function signalForm(model: T, schema?: Parameters>[1]) { return TestBed.runInInjectionContext(() => diff --git a/packages/devtools/src/__tests__/forms-scan.test.ts b/packages/devtools/src/__tests__/forms-scan.test.ts index c4588906..ce599b38 100644 --- a/packages/devtools/src/__tests__/forms-scan.test.ts +++ b/packages/devtools/src/__tests__/forms-scan.test.ts @@ -4,16 +4,12 @@ import { Component, ViewEncapsulation, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { FormField, FormRoot, form } from '@angular/forms/signals'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { attachForms } from '../forms-collector.ts'; import { createFormIds, pageElements, type FoundForm } from '../forms.ts'; +import { initAngularTestBed } from './angular-version.ts'; -try { - TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); -} catch { - // already initialized in this worker -} +initAngularTestBed(); const stops: (() => void)[] = []; afterEach(() => { diff --git a/packages/devtools/src/__tests__/forms.test.ts b/packages/devtools/src/__tests__/forms.test.ts index b7a08b9e..2afc5b40 100644 --- a/packages/devtools/src/__tests__/forms.test.ts +++ b/packages/devtools/src/__tests__/forms.test.ts @@ -20,6 +20,10 @@ import { type FormFieldNode, type FormsDebugApi, } from '../forms.ts'; +import { angularAtLeast } from './angular-version.ts'; + +/** `AbstractControl.events` exists from Angular 18.0. */ +const controlEvents = angularAtLeast('18.0'); function directive(selectors: unknown[][], props: Record) { class Directive {} @@ -522,22 +526,34 @@ describe('diffForms', () => { }); describe('control events', () => { - it('turns Angular control events into timeline entries with the changed path', () => { - const form = new FormGroup({ address: new FormGroup({ city: new FormControl('') }) }); - const seen: FormEvent[] = []; - const stop = watchControlEvents(form, 'form-9', (e) => seen.push(e))!; - form.controls.address.controls.city.setValue('Izmir'); - form.controls.address.controls.city.markAsTouched(); - stop(); - form.controls.address.controls.city.setValue('Ankara'); - expect(seen.map((e) => [e.path, e.type, e.detail])).toEqual([ - ['address.city', 'value', '"Izmir"'], - ['address.city', 'status', 'VALID'], - ['address.city', 'touched', 'true'], - ]); - }); - - it('maps submit and reset events by their class name', () => { + it.skipIf(controlEvents)('has none before Angular 18.0, so the collector diffs snapshots', () => { + const form = new FormGroup({ city: new FormControl('') }); + expect(watchControlEvents(form, 'form-9', () => {})).toBeNull(); + const before = snapshot(serializeControl(form), { kind: 'reactive' }); + form.controls.city.setValue('Izmir'); + const events = diffForms([before], [snapshot(serializeControl(form), { kind: 'reactive' })]); + expect(events.map((e) => [e.path, e.type])).toContainEqual(['city', 'value']); + }); + + it.runIf(controlEvents)( + 'turns Angular control events into timeline entries with the changed path', + () => { + const form = new FormGroup({ address: new FormGroup({ city: new FormControl('') }) }); + const seen: FormEvent[] = []; + const stop = watchControlEvents(form, 'form-9', (e) => seen.push(e))!; + form.controls.address.controls.city.setValue('Izmir'); + form.controls.address.controls.city.markAsTouched(); + stop(); + form.controls.address.controls.city.setValue('Ankara'); + expect(seen.map((e) => [e.path, e.type, e.detail])).toEqual([ + ['address.city', 'value', '"Izmir"'], + ['address.city', 'status', 'VALID'], + ['address.city', 'touched', 'true'], + ]); + }, + ); + + it.runIf(controlEvents)('maps submit and reset events by their class name', () => { class FormSubmittedEvent { source = null; } @@ -629,7 +645,7 @@ describe('redaction', () => { expect(form.value.confirm).toBe('hunter2'); }); - it('redacts secret values in the event timeline', () => { + it.runIf(controlEvents)('redacts secret values in the event timeline', () => { document.body.innerHTML = ''; const form = new FormGroup({ pw: new FormControl('') }); const elements = new WeakMap([ @@ -662,25 +678,28 @@ describe('redaction', () => { expect(node.errors[0].message).toBe('needs at least 8 characters'); }); - it('redacts a standalone control by the property that holds it, in snapshots and events', () => { - document.body.innerHTML = ''; - const host = document.querySelector('app-settings')!; - const apiKey = new FormControl(''); - class Settings { - apiKey = apiKey; - } - const ng = fakeNg(new Map([[host, new Settings()]]), new Map(), new Map()); - apiKey.setValue('sk-live-123'); - const [form] = collectForms(findForms(ng, [host])); - expect(form.label).toBe('Settings.apiKey'); - expect(form.root.value).toBe('[redacted]'); - - const seen: FormEvent[] = []; - const stop = watchControlEvents(apiKey, form.id, (e) => seen.push(e), { rootKey: 'apiKey' })!; - apiKey.setValue('sk-live-456'); - stop(); - expect(JSON.stringify(seen)).not.toContain('sk-live'); - }); + it.runIf(controlEvents)( + 'redacts a standalone control by the property that holds it, in snapshots and events', + () => { + document.body.innerHTML = ''; + const host = document.querySelector('app-settings')!; + const apiKey = new FormControl(''); + class Settings { + apiKey = apiKey; + } + const ng = fakeNg(new Map([[host, new Settings()]]), new Map(), new Map()); + apiKey.setValue('sk-live-123'); + const [form] = collectForms(findForms(ng, [host])); + expect(form.label).toBe('Settings.apiKey'); + expect(form.root.value).toBe('[redacted]'); + + const seen: FormEvent[] = []; + const stop = watchControlEvents(apiKey, form.id, (e) => seen.push(e), { rootKey: 'apiKey' })!; + apiKey.setValue('sk-live-456'); + stop(); + expect(JSON.stringify(seen)).not.toContain('sk-live'); + }, + ); it('redacts nested object values by key', () => { expect(serializeFormValue({ user: 'kam', password: 'x' })).toEqual({ @@ -720,32 +739,36 @@ describe('extra reactive state', () => { const [nameNode, noteNode] = collected.root.children!; expect(nameNode).toMatchObject({ validators: { sync: true, async: true }, - defaultValue: 'kam', accessor: 'DefaultValueAccessor', }); + // `nonNullable` and `defaultValue` exist from 14.0. + expect(nameNode.defaultValue).toBe(angularAtLeast('14.0') ? 'kam' : undefined); expect(noteNode.validators).toBeUndefined(); - expect(noteNode.defaultValue).toBeNull(); + expect(noteNode.defaultValue).toBe(angularAtLeast('14.0') ? null : undefined); }); }); describe('secret parents and disabled children', () => { - it('redacts every field under a secret-named group or array, in snapshots and events', () => { - const form = new FormGroup({ - passwords: new FormGroup({ first: new FormControl('a1'), second: new FormControl('a2') }), - apiKeys: new FormArray([new FormControl('sk-1')]), - name: new FormControl('kam'), - }); - const text = JSON.stringify(serializeControl(form)); - expect(text).not.toMatch(/a1|a2|sk-1/); - expect(text).toContain('kam'); - - const seen: FormEvent[] = []; - const stop = watchControlEvents(form, 'form-1', (e) => seen.push(e))!; - form.controls.passwords.controls.first.setValue('new-secret'); - form.controls.apiKeys.at(0).setValue('sk-2'); - stop(); - expect(JSON.stringify(seen)).not.toMatch(/new-secret|sk-2/); - }); + it.runIf(controlEvents)( + 'redacts every field under a secret-named group or array, in snapshots and events', + () => { + const form = new FormGroup({ + passwords: new FormGroup({ first: new FormControl('a1'), second: new FormControl('a2') }), + apiKeys: new FormArray([new FormControl('sk-1')]), + name: new FormControl('kam'), + }); + const text = JSON.stringify(serializeControl(form)); + expect(text).not.toMatch(/a1|a2|sk-1/); + expect(text).toContain('kam'); + + const seen: FormEvent[] = []; + const stop = watchControlEvents(form, 'form-1', (e) => seen.push(e))!; + form.controls.passwords.controls.first.setValue('new-secret'); + form.controls.apiKeys.at(0).setValue('sk-2'); + stop(); + expect(JSON.stringify(seen)).not.toMatch(/new-secret|sk-2/); + }, + ); it('flags children of a secret-named group as redacted by their parent', () => { const form = new FormGroup({ @@ -774,32 +797,38 @@ describe('secret parents and disabled children', () => { expect(token).toMatchObject({ materialized: false, value: '[redacted]', redacted: 'key' }); }); - it('leaves disabled children out of a group value event, like form.value', () => { - const form = new FormGroup({ a: new FormControl('x'), b: new FormControl('y') }); - form.controls.b.disable(); - const seen: FormEvent[] = []; - const stop = watchControlEvents(form, 'form-1', (e) => seen.push(e))!; - form.setValue({ a: 'z', b: 'w' }); - stop(); - expect(seen.find((e) => e.type === 'value')?.detail).toBe(JSON.stringify(form.value)); - }); - - it('reads the submitted flag after Angular updates it, so a minified reset is not a submit', async () => { - class E { - source: unknown = null; - } - const form = new FormGroup({}); - let submitted = true; - const seen: FormEvent[] = []; - const stop = watchControlEvents(form, 'form-1', (e) => seen.push(e), { - submitted: () => submitted, - })!; - (form as unknown as { _events: { next(e: unknown): void } })._events.next(new E()); - submitted = false; - await Promise.resolve(); - stop(); - expect(seen.map((e) => e.type)).toEqual(['reset']); - }); + it.runIf(controlEvents)( + 'leaves disabled children out of a group value event, like form.value', + () => { + const form = new FormGroup({ a: new FormControl('x'), b: new FormControl('y') }); + form.controls.b.disable(); + const seen: FormEvent[] = []; + const stop = watchControlEvents(form, 'form-1', (e) => seen.push(e))!; + form.setValue({ a: 'z', b: 'w' }); + stop(); + expect(seen.find((e) => e.type === 'value')?.detail).toBe(JSON.stringify(form.value)); + }, + ); + + it.runIf(controlEvents)( + 'reads the submitted flag after Angular updates it, so a minified reset is not a submit', + async () => { + class E { + source: unknown = null; + } + const form = new FormGroup({}); + let submitted = true; + const seen: FormEvent[] = []; + const stop = watchControlEvents(form, 'form-1', (e) => seen.push(e), { + submitted: () => submitted, + })!; + (form as unknown as { _events: { next(e: unknown): void } })._events.next(new E()); + submitted = false; + await Promise.resolve(); + stop(); + expect(seen.map((e) => e.type)).toEqual(['reset']); + }, + ); it('tells submit from reset by the submitted flag when class names are minified', () => { class E { @@ -905,26 +934,29 @@ describe('findFieldElement for Signal Forms', () => { }); describe('group value events', () => { - it('redact password inputs under non-secret keys when the whole group changes', () => { - document.body.innerHTML = ''; - const form = new FormGroup({ - user: new FormControl('kam'), - confirm: new FormControl(''), - }); - const elements = new WeakMap([ - [form.controls.confirm, document.querySelector('#confirm')!], - ]); - const seen: FormEvent[] = []; - const stop = watchControlEvents(form, 'form-1', (e) => seen.push(e), { - elements: () => elements, - })!; - form.setValue({ user: 'kam', confirm: 'hunter2' }); - form.reset(); - stop(); - const details = seen.filter((e) => e.type === 'value').map((e) => e.detail); - expect(details.join(' ')).not.toContain('hunter2'); - expect(details[0]).toBe('{"user":"kam","confirm":"[redacted]"}'); - }); + it.runIf(controlEvents)( + 'redact password inputs under non-secret keys when the whole group changes', + () => { + document.body.innerHTML = ''; + const form = new FormGroup({ + user: new FormControl('kam'), + confirm: new FormControl(''), + }); + const elements = new WeakMap([ + [form.controls.confirm, document.querySelector('#confirm')!], + ]); + const seen: FormEvent[] = []; + const stop = watchControlEvents(form, 'form-1', (e) => seen.push(e), { + elements: () => elements, + })!; + form.setValue({ user: 'kam', confirm: 'hunter2' }); + form.reset(); + stop(); + const details = seen.filter((e) => e.type === 'value').map((e) => e.detail); + expect(details.join(' ')).not.toContain('hunter2'); + expect(details[0]).toBe('{"user":"kam","confirm":"[redacted]"}'); + }, + ); }); describe('Angular spec edge cases', () => { diff --git a/packages/devtools/src/__tests__/http-cache-key.test.ts b/packages/devtools/src/__tests__/http-cache-key.test.ts index 4c839a18..06617175 100644 --- a/packages/devtools/src/__tests__/http-cache-key.test.ts +++ b/packages/devtools/src/__tests__/http-cache-key.test.ts @@ -11,16 +11,12 @@ import { ɵwithHttpTransferCache as withHttpTransferCache, } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { firstValueFrom } from 'rxjs'; import { afterEach, describe, expect, it } from 'vitest'; import { sha256Hex, transferCacheKeys } from '../http-cache-key.ts'; +import { angularAtLeast, initAngularTestBed } from './angular-version.ts'; -try { - TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); -} catch { - // already initialized in this worker -} +initAngularTestBed(); afterEach(() => TestBed.resetTestingModule()); @@ -32,7 +28,24 @@ describe('sha256Hex', () => { }); }); -describe('transferCacheKeys', () => { +/** The DJB2 variant Angular 16.0 to 17.0 hash the key with (makeCacheKey in @angular/common/http). */ +function angular16Hash(value: string): string { + let hash = 0; + for (const char of value) hash = (Math.imul(31, hash) + char.charCodeAt(0)) << 0; + return String(hash + 2147483647 + 1); +} + +describe('transferCacheKeys for each Angular key format', () => { + it('includes the 16.0 to 17.0 key, which leaves the body out', () => { + const req = new HttpRequest('GET', '/api/feed', null, { + params: new HttpParams({ fromObject: { page: 1, tag: 'b' } }), + }); + expect(transferCacheKeys(req)).toContain(angular16Hash('GET.json./api/feed?page=1&tag=b')); + }); +}); + +// Angular caches HTTP responses in TransferState from 16.0 (ɵwithHttpTransferCache). +describe.runIf(angularAtLeast('16.0'))('transferCacheKeys', () => { const cachedBy = async (stored: HttpRequest, sent: { page: number }) => { TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting(), withHttpTransferCache({})], diff --git a/packages/devtools/src/__tests__/http.test.ts b/packages/devtools/src/__tests__/http.test.ts index ce9fddaa..98dcf5d8 100644 --- a/packages/devtools/src/__tests__/http.test.ts +++ b/packages/devtools/src/__tests__/http.test.ts @@ -20,6 +20,10 @@ import { } from '@angular/common/http'; import { Observable } from 'rxjs'; import { transferCacheKeys } from '../http-cache-key.ts'; +import { angularAtLeast } from './angular-version.ts'; + +/** `@pangular-inspector/devtools/http` imports `REQUEST` and `provideEnvironmentInitializer`, which arrived in 19.0. */ +const httpEntry = angularAtLeast('19.0'); import { pangularHttpInterceptor, parseBody, @@ -368,7 +372,8 @@ describe('hydration helpers', () => { }); }); -describe('interceptor', () => { +// The interceptor runs through runInInjectionContext (16.0) here; the entry itself builds from 19.0. +describe.runIf(angularAtLeast('16.0'))('interceptor', () => { const g = globalThis as { ngDevMode?: unknown }; let saved: unknown; beforeEach(() => { @@ -820,7 +825,7 @@ describe('hydration scanner', () => { }); }); -describe('hydration warning capture', () => { +describe.runIf(httpEntry)('hydration warning capture', () => { const originals = { warn: console.warn, error: console.error }; afterEach(() => { @@ -935,28 +940,31 @@ describe('http overlay edge cases', () => { expect(sent[0]['title']).toContain('[redacted]'); }); - it('masks a token that straddles the warning clip and the mismatch detail clip', () => { - const original = console.warn; - console.warn = () => {}; - delete httpRegistry().warnings; - try { - createEnvironmentInjector([providePangularHttp()], Injector.NULL as EnvironmentInjector); - console.warn(`NG0500: ${'x'.repeat(980)} ${jwt}`); - expect(httpRegistry().warnings).toHaveLength(1); - expect(httpRegistry().warnings![0]).not.toContain('eyJ'); - } finally { - console.warn = original; - } - document.body.innerHTML = ''; - (document.querySelector('app-card') as unknown as Record)[ - '__ngDebugHydrationInfo__' - ] = { - status: 'mismatched', - expectedNodeDetails: `${'y'.repeat(470)} ${jwt}`, - actualNodeDetails: 'Bearer abc123def456', - }; - const [mismatch] = scanHydration(document).mismatches; - expect(JSON.stringify(mismatch)).not.toMatch(/eyJ|abc123def456/); - document.body.innerHTML = ''; - }); + it.runIf(httpEntry)( + 'masks a token that straddles the warning clip and the mismatch detail clip', + () => { + const original = console.warn; + console.warn = () => {}; + delete httpRegistry().warnings; + try { + createEnvironmentInjector([providePangularHttp()], Injector.NULL as EnvironmentInjector); + console.warn(`NG0500: ${'x'.repeat(980)} ${jwt}`); + expect(httpRegistry().warnings).toHaveLength(1); + expect(httpRegistry().warnings![0]).not.toContain('eyJ'); + } finally { + console.warn = original; + } + document.body.innerHTML = ''; + (document.querySelector('app-card') as unknown as Record)[ + '__ngDebugHydrationInfo__' + ] = { + status: 'mismatched', + expectedNodeDetails: `${'y'.repeat(470)} ${jwt}`, + actualNodeDetails: 'Bearer abc123def456', + }; + const [mismatch] = scanHydration(document).mismatches; + expect(JSON.stringify(mismatch)).not.toMatch(/eyJ|abc123def456/); + document.body.innerHTML = ''; + }, + ); }); diff --git a/packages/devtools/src/__tests__/injector-tree-real.test.ts b/packages/devtools/src/__tests__/injector-tree-real.test.ts index 30249a8f..4e069d05 100644 --- a/packages/devtools/src/__tests__/injector-tree-real.test.ts +++ b/packages/devtools/src/__tests__/injector-tree-real.test.ts @@ -1,46 +1,52 @@ // @vitest-environment jsdom import '@angular/compiler'; -import { - Component, - Injectable, - InjectionToken, - inject, - provideZonelessChangeDetection, - type ApplicationRef, -} from '@angular/core'; -import { bootstrapApplication } from '@angular/platform-browser'; +import { Component, Inject, Injectable, InjectionToken } from '@angular/core'; import { afterEach, describe, expect, it } from 'vitest'; import { collectInjectorTree, type DebugNg } from '../injector-tree.ts'; import type { InjectorTreeNode } from '../types.ts'; +import { angularAtLeast, bootstrapRoot, STANDALONE, type BootedApp } from './angular-version.ts'; const API_URL = new InjectionToken('API_URL'); +// Constructor injection, as the CLI compiles it, so the classes work before inject() (14.0). class TripStore { - readonly api = inject(API_URL); + constructor(readonly api: string) {} } Injectable({ providedIn: 'root' })(TripStore); +(TripStore as unknown as { ctorParameters: () => unknown[] }).ctorParameters = () => [ + { type: String, decorators: [{ type: Inject, args: [API_URL] }] }, +]; class CardState {} Injectable()(CardState); class TripCard { - readonly state = inject(CardState); - readonly store = inject(TripStore); + constructor( + readonly state: CardState, + readonly store: TripStore, + ) {} } -Component({ selector: 'app-trip-card', template: 'card', providers: [CardState] })(TripCard); +(TripCard as unknown as { ctorParameters: () => unknown[] }).ctorParameters = () => [ + { type: CardState }, + { type: TripStore }, +]; +Component({ ...STANDALONE, selector: 'app-trip-card', template: 'card', providers: [CardState] })( + TripCard, +); class TripPage {} Component({ + ...STANDALONE, selector: 'app-root', imports: [TripCard], - template: '', + template: '', })(TripPage); const flatten = (nodes: InjectorTreeNode[]): InjectorTreeNode[] => nodes.flatMap((node) => [node, ...flatten(node.children)]); describe('collectInjectorTree with a real app', () => { - let app: ApplicationRef | null = null; + let app: BootedApp | null = null; afterEach(() => { app?.destroy(); app = null; @@ -49,25 +55,35 @@ describe('collectInjectorTree with a real app', () => { it('reads element and environment injectors through the private injector utils', async () => { document.body.innerHTML = ''; - app = await bootstrapApplication(TripPage, { - providers: [provideZonelessChangeDetection(), { provide: API_URL, useValue: '/api' }], + app = await bootstrapRoot(TripPage, { + declarations: [TripCard], + providers: [{ provide: API_URL, useValue: '/api' }], }); - app.tick(); + app.appRef.tick(); const ng = (globalThis as { ng?: DebugNg }).ng!; - expect(typeof ng.ɵgetInjectorProviders).toBe('function'); - expect(typeof ng.ɵgetInjectorResolutionPath).toBe('function'); const report = collectInjectorTree(ng); - const elements = flatten(report.roots); const card = elements.find((n) => n.injector.component === 'TripCard'); + const page = elements.find((n) => n.injector.component === 'TripPage'); expect(card?.injector.type).toBe('element'); + expect(page?.children).toContain(card); + if (!angularAtLeast('16.2')) { + // Before 16.2 there are no provider or resolution path utils: element + // injectors with their components only. + expect(ng.ɵgetInjectorProviders).toBeUndefined(); + expect(card?.providers).toEqual([]); + expect(card?.injector.path).toEqual([]); + expect(card?.dependencies ?? []).toEqual([]); + expect(report.environment).toEqual([]); + return; + } + expect(typeof ng.ɵgetInjectorProviders).toBe('function'); + expect(typeof ng.ɵgetInjectorResolutionPath).toBe('function'); expect(card?.providers).toContainEqual( expect.objectContaining({ token: 'CardState', type: 'class' }), ); expect(card?.providers.map((p) => p.token)).not.toContain('ElementRef'); - const page = elements.find((n) => n.injector.component === 'TripPage'); - expect(page?.children).toContain(card); expect(card?.injector.path?.slice(0, 2)).toEqual([card?.injector.id, page?.injector.id]); expect(card?.dependencies).toEqual( expect.arrayContaining([ diff --git a/packages/devtools/src/__tests__/leased-collectors.test.ts b/packages/devtools/src/__tests__/leased-collectors.test.ts index 227bdeca..d426309d 100644 --- a/packages/devtools/src/__tests__/leased-collectors.test.ts +++ b/packages/devtools/src/__tests__/leased-collectors.test.ts @@ -3,18 +3,14 @@ import '@angular/compiler'; import { Component } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { attachChangeDetection } from '../cd-overlay.ts'; import { attachForms } from '../forms-collector.ts'; import { CHANGE_DETECTION_SHAPE, FORMS_SHAPE } from '../push-delta.ts'; import { createReportDeltas, deltaPush } from '../rpc/report-deltas.ts'; +import { initAngularTestBed } from './angular-version.ts'; -try { - TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); -} catch { - // already initialized in this worker -} +initAngularTestBed(); /** An RPC scope whose push answers like the server, rebuilding each report. */ function server(name: string, shape: typeof FORMS_SHAPE) { diff --git a/packages/devtools/src/__tests__/network-inspector.test.ts b/packages/devtools/src/__tests__/network-inspector.test.ts index 43183830..2c962fd5 100644 --- a/packages/devtools/src/__tests__/network-inspector.test.ts +++ b/packages/devtools/src/__tests__/network-inspector.test.ts @@ -2,16 +2,12 @@ import '@angular/compiler'; import { Component, EventEmitter } from '@angular/core'; import { TestBed, type ComponentFixture } from '@angular/core/testing'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { afterEach, describe, expect, it } from 'vitest'; import { NetworkInspector } from '../../../../app/src/pages/network-inspector.ts'; import { Select } from '../../../../app/src/ui/select.ts'; +import { initAngularTestBed } from './angular-version.ts'; -try { - TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); -} catch { - // already initialized in this worker -} +initAngularTestBed(); class StubSelect { valueChange = new EventEmitter(); diff --git a/packages/devtools/src/__tests__/ngrx-collector.test.ts b/packages/devtools/src/__tests__/ngrx-collector.test.ts index d68c9db2..c932f964 100644 --- a/packages/devtools/src/__tests__/ngrx-collector.test.ts +++ b/packages/devtools/src/__tests__/ngrx-collector.test.ts @@ -12,6 +12,14 @@ import { type NgrxPageReport, } from '../ngrx-shared.ts'; import { mergeNgrxReport, nameStore, ngrxStateOf, type NgrxPages } from '../rpc/ngrx-tools.ts'; +import { angularAtLeast, STANDALONE } from './angular-version.ts'; + +/** + * The workspace installs @ngrx 22, which loads on Angular 19.0 and later (it + * imports `linkedSignal`). The compat workflow keeps it, so on older versions + * the NgRx coverage is the fake-ng tests here and the capability checks. + */ +const realNgrx = angularAtLeast('19.0'); const SIGNAL = Symbol('SIGNAL'); const STATE_SOURCE = Symbol('STATE_SOURCE'); @@ -1488,7 +1496,7 @@ describe('ngrx tools', () => { }); }); -describe('ngrx collector with @ngrx/signals', () => { +describe.runIf(realNgrx)('ngrx collector with @ngrx/signals', () => { it('reads, logs and restores a real signal store from an injector', async () => { await import('@angular/compiler'); const { Injector, computed } = await import('@angular/core'); @@ -1678,7 +1686,9 @@ describe('ngrx collector with @ngrx/signals', () => { store = inject(PackingStore); view = signalState({ hidePacked: false }); } - Component({ selector: 'app-packing', template: '', providers: [PackingStore] })(Packing); + Component({ ...STANDALONE, selector: 'app-packing', template: '', providers: [PackingStore] })( + Packing, + ); const fixture = TestBed.createComponent(Packing); document.body.replaceChildren(fixture.nativeElement); const collector = createNgrxCollector( @@ -1877,7 +1887,12 @@ describe('ngrx collector with @ngrx/signals', () => { class Panel { dispatcher = inject(Dispatcher); } - Component({ selector: 'app-panel', template: '', providers: [provideDispatcher()] })(Panel); + Component({ + ...STANDALONE, + selector: 'app-panel', + template: '', + providers: [provideDispatcher()], + })(Panel); const fixture = TestBed.createComponent(Panel); document.body.replaceChildren(fixture.nativeElement); const collector = createNgrxCollector( @@ -1957,16 +1972,15 @@ async function testBed() { await import('@angular/compiler'); const { TestBed } = await import('@angular/core/testing'); if (!testBedReady) { - const { BrowserTestingModule, platformBrowserTesting } = - await import('@angular/platform-browser/testing'); - TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); + const { initAngularTestBed } = await import('./angular-version.ts'); + initAngularTestBed(); testBedReady = true; } TestBed.resetTestingModule(); return TestBed; } -describe('ngrx collector with @ngrx/store', () => { +describe.runIf(realNgrx)('ngrx collector with @ngrx/store', () => { async function realStore( options: { devtools?: { maxAge?: number; actionsBlocklist?: string[] }; @@ -2034,7 +2048,9 @@ describe('ngrx collector with @ngrx/store', () => { store.next(add({ by: 3 })); const by = signal(4); store.dispatch(() => add({ by: by() })); - TestBed.tick(); + // `TestBed.tick()` from 20.0; `flushEffects()` runs the reactive dispatch before. + const testBed = TestBed as unknown as { tick?: () => void; flushEffects?: () => void }; + (testBed.tick ?? testBed.flushEffects)!.call(TestBed); expect(collector.logSince(0).map((e) => [e.type, e.origin, e.diff])).toEqual([ ['[Counter] Add', 'dispatch', [{ path: 'count', op: 'change', before: 0, after: 2 }]], ['[Counter] Add', 'effect', [{ path: 'count', op: 'change', before: 2, after: 5 }]], diff --git a/packages/devtools/src/__tests__/pipes-collector.test.ts b/packages/devtools/src/__tests__/pipes-collector.test.ts index a116433a..c410b4f8 100644 --- a/packages/devtools/src/__tests__/pipes-collector.test.ts +++ b/packages/devtools/src/__tests__/pipes-collector.test.ts @@ -1,16 +1,16 @@ // @vitest-environment jsdom import '@angular/compiler'; -import { - ChangeDetectorRef, - Component, - Pipe, - inject, - signal, - type PipeTransform, -} from '@angular/core'; +import { ChangeDetectorRef, Component, Pipe, type PipeTransform } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { AsyncPipe, CurrencyPipe, DatePipe, JsonPipe, UpperCasePipe } from '@angular/common'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { + AsyncPipe, + CurrencyPipe, + DatePipe, + JsonPipe, + NgForOf, + NgIf, + UpperCasePipe, +} from '@angular/common'; import { BehaviorSubject, of, type Observable } from 'rxjs'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { attachPipes } from '../pipes-collector.ts'; @@ -19,22 +19,32 @@ import { setRedaction } from '../forms-privacy.ts'; import { explainPipeText } from '../rpc/pipe-explain.ts'; import { mergePipePageReport } from '../rpc/pipes-tools.ts'; import type { PipePageReport } from '../rpc/pipes-tools.ts'; +import { + controlFlow, + initAngularTestBed, + injectHere, + testModuleFor, + writableSignal, + STANDALONE, +} from './angular-version.ts'; -try { - TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); -} catch { - // already initialized in this worker -} +initAngularTestBed(); const stops: (() => void)[] = []; +let configured = false; afterEach(() => { + configured = false; stops.splice(0).forEach((stop) => stop()); setRedaction(); TestBed.resetTestingModule(); document.body.innerHTML = ''; }); -async function mount(type: new () => T) { +/** Creates `type`; before 14.0, components a test creates later are declared up front in `also`. */ +async function mount(type: new () => T, also: (new () => unknown)[] = []) { + const declared = testModuleFor(type, ...also); + if (declared.declarations && !configured) TestBed.configureTestingModule(declared); + configured = true; const fixture = TestBed.createComponent(type); document.body.appendChild(fixture.nativeElement); fixture.detectChanges(); @@ -47,10 +57,11 @@ function ng(): any { } class Receipt { - a = signal(1); - b = signal(2); + a = writableSignal(1); + b = writableSignal(2); } Component({ + ...STANDALONE, selector: 'app-receipt', imports: [CurrencyPipe], template: `

{{ a() | currency }}

{{ b() | currency }}

`, @@ -63,21 +74,21 @@ class JoinPipe implements PipeTransform { return value.join(','); } } -Pipe({ name: 'join' })(JoinPipe); +Pipe({ ...STANDALONE, name: 'join' })(JoinPipe); class KeysPipe implements PipeTransform { transform(value: Map | Set): string { return [...value.keys()].join(','); } } -Pipe({ name: 'keys' })(KeysPipe); +Pipe({ ...STANDALONE, name: 'keys' })(KeysPipe); class FilterPipe implements PipeTransform { transform(value: string[], term: string): string { return value.filter((v) => v.includes(term)).join(','); } } -Pipe({ name: 'filter' })(FilterPipe); +Pipe({ ...STANDALONE, name: 'filter' })(FilterPipe); class ExplodePipe implements PipeTransform { transform(value: string): string { @@ -85,12 +96,13 @@ class ExplodePipe implements PipeTransform { return value; } } -Pipe({ name: 'explode' })(ExplodePipe); +Pipe({ ...STANDALONE, name: 'explode' })(ExplodePipe); class Explodes { - value = signal('start'); + value = writableSignal('start'); } Component({ + ...STANDALONE, selector: 'app-explodes', imports: [ExplodePipe], template: `

{{ value() | explode }}

`, @@ -100,9 +112,12 @@ class Receipts { rows = Array.from({ length: 12 }, (_, i) => i); } Component({ + ...STANDALONE, selector: 'app-receipts', - imports: [Receipt], - template: `@for (row of rows; track row) {}`, + imports: [Receipt, ...(controlFlow ? [] : [NgForOf])], + template: controlFlow + ? `@for (row of rows; track row) {}` + : ``, })(Receipts); function harness(pageId = 'pg', tree?: HostTree) { @@ -127,6 +142,7 @@ function harness(pageId = 'pg', tree?: HostTree) { describe('pipes collector', () => { it('scans a host tree in place of the document', async () => { + TestBed.configureTestingModule(testModuleFor(Receipt)); const fixture = TestBed.createComponent(Receipt); fixture.detectChanges(); await fixture.whenStable(); @@ -267,6 +283,7 @@ describe('pipes collector', () => { solo$ = new BehaviorSubject('solo'); } Component({ + ...STANDALONE, selector: 'app-feed', imports: [AsyncPipe], template: `

{{ shared$ | async }}

{{ shared$ | async }}

{{ solo$ | async }}

`, @@ -292,10 +309,11 @@ describe('pipes collector', () => { it('flags a pure pipe fed a mutated-in-place argument (experimental stale check)', async () => { class ListView { - items = signal(['a', 'b']); - cdr = inject(ChangeDetectorRef); + items = writableSignal(['a', 'b']); + cdr = injectHere(ChangeDetectorRef); } Component({ + ...STANDALONE, selector: 'app-list', // Bound directly to the array reference (not a derived primitive), so // a mutation in place is genuinely invisible to Angular's `Object.is` @@ -333,9 +351,10 @@ describe('pipes collector', () => { it('does not flag a normal, immutable update as stale', async () => { class ListView { - items = signal(['a', 'b']); + items = writableSignal(['a', 'b']); } Component({ + ...STANDALONE, selector: 'app-list2', imports: [JoinPipe], template: `

{{ items() | join }}

`, @@ -363,10 +382,11 @@ describe('pipes collector', () => { it('does not run the stale check while not instrumented', async () => { class ListView { - items = signal(['a', 'b']); - cdr = inject(ChangeDetectorRef); + items = writableSignal(['a', 'b']); + cdr = injectHere(ChangeDetectorRef); } Component({ + ...STANDALONE, selector: 'app-list3', imports: [JoinPipe], template: `

{{ items() | join }}

`, @@ -394,10 +414,11 @@ describe('pipes collector', () => { it('keeps a stale finding, with its first detection time, until the argument changes', async () => { class ListView { - items = signal(['a', 'b']); - cdr = inject(ChangeDetectorRef); + items = writableSignal(['a', 'b']); + cdr = injectHere(ChangeDetectorRef); } Component({ + ...STANDALONE, selector: 'app-list4', imports: [JoinPipe], template: `

{{ items() | join }}

`, @@ -444,7 +465,16 @@ describe('pipes collector', () => { }); it('walks the whole DOM once, then only again when it changes', async () => { - await mount(Receipt); + class Extra { + label = 'late'; + } + Component({ + ...STANDALONE, + selector: 'app-extra', + imports: [UpperCasePipe], + template: `{{ label | uppercase }}`, + })(Extra); + await mount(Receipt, [Extra]); const h = harness(); const spy = vi.spyOn(document, 'querySelectorAll'); const fullScans = () => spy.mock.calls.filter(([selector]) => selector === '*').length; @@ -456,14 +486,6 @@ describe('pipes collector', () => { await Promise.resolve(); expect(fullScans()).toBe(1); - class Extra { - label = 'late'; - } - Component({ - selector: 'app-extra', - imports: [UpperCasePipe], - template: `{{ label | uppercase }}`, - })(Extra); await mount(Extra); await new Promise((resolve) => setTimeout(resolve)); h.collector.resume(); @@ -480,13 +502,17 @@ describe('pipes collector', () => { it('picks up a text-only view that appears later without walking the whole DOM', async () => { class Toggle { - show = signal(false); + show = writableSignal(false); label = 'late'; } Component({ + ...STANDALONE, selector: 'app-toggle', - imports: [UpperCasePipe], - template: `

@if (show()) {{{ label | uppercase }}}

`, + imports: [UpperCasePipe, ...(controlFlow ? [] : [NgIf])], + // A text-only view: an @if block from 17.0, an *ngIf container before. + template: controlFlow + ? `

@if (show()) {{{ label | uppercase }}}

` + : `

{{ label | uppercase }}

`, })(Toggle); const fixture = await mount(Toggle); const h = harness(); @@ -515,6 +541,7 @@ describe('pipes collector', () => { total = 3; } Component({ + ...STANDALONE, selector: 'app-invoice', imports: [CurrencyPipe], template: `

{{ total | currency }}

`, @@ -535,6 +562,7 @@ describe('pipes collector', () => { source$: BehaviorSubject | null = null; } Component({ + ...STANDALONE, selector: 'app-empty', imports: [AsyncPipe], template: `

{{ source$ | async }}

`, @@ -556,6 +584,7 @@ describe('pipes collector', () => { header$ = new BehaviorSubject(`Bearer ${jwt}`); } Component({ + ...STANDALONE, selector: 'app-session', imports: [AsyncPipe], template: `

{{ (session$ | async)?.user }}

{{ header$ | async }}

`, @@ -576,6 +605,7 @@ describe('pipes collector', () => { token$ = new BehaviorSubject(`token ${jwt}`); } Component({ + ...STANDALONE, selector: 'app-long-token', imports: [AsyncPipe], template: `

{{ token$ | async }}

`, @@ -593,9 +623,10 @@ describe('pipes collector', () => { it('redacts instrumented inputs and outputs, including configured secret names', async () => { setRedaction({ secretNames: ['voucher'] }); class Checkout { - form = signal({ email: 'ada@example.com', password: 'hunter2', voucher: 'ABC123' }); + form = writableSignal({ email: 'ada@example.com', password: 'hunter2', voucher: 'ABC123' }); } Component({ + ...STANDALONE, selector: 'app-checkout', imports: [JsonPipe], template: `
{{ form() | json }}
`, @@ -675,10 +706,11 @@ describe('pipes collector', () => { it('starts each recording with a fresh stale baseline', async () => { class ListView { - items = signal(['a', 'b']); - cdr = inject(ChangeDetectorRef); + items = writableSignal(['a', 'b']); + cdr = injectHere(ChangeDetectorRef); } Component({ + ...STANDALONE, selector: 'app-list-baseline', imports: [JoinPipe], template: `

{{ items() | join }}

`, @@ -698,9 +730,10 @@ describe('pipes collector', () => { it('flags a Date mutated in place and shown with the real DatePipe', async () => { class When { when = new Date(2020, 0, 1); - cdr = inject(ChangeDetectorRef); + cdr = injectHere(ChangeDetectorRef); } Component({ + ...STANDALONE, selector: 'app-when', imports: [DatePipe], template: `

{{ when | date: 'yyyy' }}

`, @@ -730,9 +763,10 @@ describe('pipes collector', () => { ])('flags a %s mutated in place', async (_kind, create, mutate) => { class Bag { bag = create(); - cdr = inject(ChangeDetectorRef); + cdr = injectHere(ChangeDetectorRef); } Component({ + ...STANDALONE, selector: `app-bag-${_kind.toLowerCase()}`, imports: [KeysPipe], template: `

{{ bag | keys }}

`, @@ -750,10 +784,11 @@ describe('pipes collector', () => { it('does not flag a pipe that reran because another argument changed', async () => { class Search { items = ['apple', 'banana']; - term = signal('a'); - cdr = inject(ChangeDetectorRef); + term = writableSignal('a'); + cdr = injectHere(ChangeDetectorRef); } Component({ + ...STANDALONE, selector: 'app-search', imports: [FilterPipe], template: `

{{ items | filter: term() }}

`, @@ -773,10 +808,11 @@ describe('pipes collector', () => { it('clears a stale finding once the pipe reruns', async () => { class Search { items = ['apple', 'banana']; - term = signal('a'); - cdr = inject(ChangeDetectorRef); + term = writableSignal('a'); + cdr = injectHere(ChangeDetectorRef); } Component({ + ...STANDALONE, selector: 'app-search2', imports: [FilterPipe], template: `

{{ items | filter: term() }}

`, @@ -800,7 +836,7 @@ describe('pipes collector', () => { const cached = of('cached'); class Feed { stable$ = new BehaviorSubject('stable'); - cdr = inject(ChangeDetectorRef); + cdr = injectHere(ChangeDetectorRef); getData(): Observable { return of('fresh'); } @@ -809,6 +845,7 @@ describe('pipes collector', () => { } } Component({ + ...STANDALONE, selector: 'app-resubscribe', imports: [AsyncPipe], template: `

{{ getData() | async }}

{{ getCached() | async }}

{{ stable$ | async }}

`, @@ -837,9 +874,10 @@ describe('pipes collector', () => { it('does not mark an async pipe whose source changed once', async () => { class Feed { source$ = new BehaviorSubject('one'); - cdr = inject(ChangeDetectorRef); + cdr = injectHere(ChangeDetectorRef); } Component({ + ...STANDALONE, selector: 'app-switch', imports: [AsyncPipe], template: `

{{ source$ | async }}

`, diff --git a/packages/devtools/src/__tests__/pipes-runtime.test.ts b/packages/devtools/src/__tests__/pipes-runtime.test.ts index 6d6f97ef..ceb5c49c 100644 --- a/packages/devtools/src/__tests__/pipes-runtime.test.ts +++ b/packages/devtools/src/__tests__/pipes-runtime.test.ts @@ -1,9 +1,8 @@ // @vitest-environment jsdom import '@angular/compiler'; -import { Component, Pipe, TemplateRef, signal } from '@angular/core'; +import { Component, Pipe, TemplateRef } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { CurrencyPipe, NgTemplateOutlet, UpperCasePipe } from '@angular/common'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { CurrencyPipe, NgForOf, NgIf, NgTemplateOutlet, UpperCasePipe } from '@angular/common'; import { afterEach, describe, expect, it } from 'vitest'; import { findPipeUsages, @@ -14,12 +13,15 @@ import { staleCheckFor, type PipeCall, } from '../pipes-runtime.ts'; +import { + controlFlow, + initAngularTestBed, + testModuleFor, + writableSignal, + STANDALONE, +} from './angular-version.ts'; -try { - TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); -} catch { - // already initialized in this worker -} +initAngularTestBed(); afterEach(() => { TestBed.resetTestingModule(); @@ -27,6 +29,8 @@ afterEach(() => { }); async function mount(type: new () => T) { + const declared = testModuleFor(type); + if (declared.declarations) TestBed.configureTestingModule(declared); const fixture = TestBed.createComponent(type); document.body.appendChild(fixture.nativeElement); fixture.detectChanges(); @@ -43,6 +47,7 @@ class Price { name = 'widget'; } Component({ + ...STANDALONE, selector: 'app-price', imports: [CurrencyPipe, UpperCasePipe], template: `

{{ amount | currency }}

{{ name | uppercase }}

`, @@ -100,9 +105,10 @@ describe('pipe runtime discovery (real Angular render)', () => { }); class Cart { - items = signal(['a', 'b']); + items = writableSignal(['a', 'b']); } Component({ + ...STANDALONE, selector: 'app-cart', imports: [UpperCasePipe], // json isn't imported here; slice the array to a single-string binding so @@ -115,6 +121,7 @@ class Labelled { value = 'a'; } Component({ + ...STANDALONE, selector: 'app-labelled', imports: [UpperCasePipe], template: `uppercase

{{ value | uppercase }}

`, @@ -162,17 +169,21 @@ describe('stale pure-pipe check (experimental, real Angular render)', () => { }); class Badge {} -Component({ selector: 'app-badge', template: `badge` })(Badge); +Component({ ...STANDALONE, selector: 'app-badge', template: `badge` })(Badge); class Board { - show = signal(true); - items = signal(['x', 'y']); + show = writableSignal(true); + items = writableSignal(['x', 'y']); title = 'board'; } Component({ + ...STANDALONE, selector: 'app-board', - imports: [Badge, UpperCasePipe], - template: `@if (show()) {{{ title | uppercase }}}@for (item of items(); track item) {{{ item | uppercase }}}`, + imports: [Badge, UpperCasePipe, ...(controlFlow ? [] : [NgIf, NgForOf])], + // Views that hold only text: control flow blocks from 17.0, containers before. + template: controlFlow + ? `@if (show()) {{{ title | uppercase }}}@for (item of items(); track item) {{{ item | uppercase }}}` + : `{{ title | uppercase }}{{ item | uppercase }}`, })(Board); class _ShoutPipe { @@ -180,13 +191,14 @@ class _ShoutPipe { return `${value}!`; } } -Pipe({ name: 'shout' })(_ShoutPipe); +Pipe({ ...STANDALONE, name: 'shout' })(_ShoutPipe); class _Shell { - a = signal('one'); - b = signal('two'); + a = writableSignal('one'); + b = writableSignal('two'); } Component({ + ...STANDALONE, selector: 'app-shell', imports: [_ShoutPipe], template: `

{{ a() | shout }}

{{ b() | shout }}

`, @@ -196,19 +208,21 @@ class _Frame { tpl: TemplateRef | null = null; } Component({ + ...STANDALONE, selector: 'app-frame', inputs: ['tpl'], imports: [NgTemplateOutlet], - template: `
`, + template: `
`, })(_Frame); class _Declarer { label = 'row'; } Component({ + ...STANDALONE, selector: 'app-declarer', imports: [_Frame, UpperCasePipe], - template: `{{ label | uppercase }}`, + template: `{{ label | uppercase }}`, })(_Declarer); describe('pipe discovery through the view tree', () => { diff --git a/packages/devtools/src/__tests__/private-api-canary.test.ts b/packages/devtools/src/__tests__/private-api-canary.test.ts index b2e11161..db9fad04 100644 --- a/packages/devtools/src/__tests__/private-api-canary.test.ts +++ b/packages/devtools/src/__tests__/private-api-canary.test.ts @@ -1,17 +1,18 @@ // @vitest-environment jsdom import '@angular/compiler'; -import { - Component, - Directive, - provideZonelessChangeDetection, - signal, - ɵsetClassDebugInfo, - type ApplicationRef, -} from '@angular/core'; -import { bootstrapApplication } from '@angular/platform-browser'; -import { provideRouter } from '@angular/router'; +import { APP_BASE_HREF } from '@angular/common'; +import * as core from '@angular/core'; +import { Component, Directive } from '@angular/core'; +import { RouterModule } from '@angular/router'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { angularAtLeast } from './angular-version.ts'; +import { + angularAtLeast, + bootstrapRoot, + writableSignal, + STANDALONE, + type BootedApp, +} from './angular-version.ts'; +import { routerApi } from './router-testing.ts'; /** * Every private (`ɵ`) and public debug symbol the collectors read from the @@ -20,7 +21,7 @@ import { angularAtLeast } from './angular-version.ts'; * the collectors fall back or report that the page has no data. */ -/** `window.ng` utils every supported version (Angular 20.0+) publishes. */ +/** `window.ng` utils every supported version (Angular 12.0+) publishes. */ const REQUIRED_NG_UTILS = [ // component-tree, forms, pipes-runtime, signal-graph, cd-overlay, router 'getComponent', @@ -32,45 +33,54 @@ const REQUIRED_NG_UTILS = [ 'getDirectiveMetadata', 'getRootComponents', 'applyChanges', - 'isSignal', - // injector-tree, router, router-setup, ngrx-collector, forms-webmcp - 'ɵgetInjectorProviders', - 'ɵgetInjectorResolutionPath', - 'ɵgetInjectorMetadata', - 'ɵgetDependenciesFromInjectable', // change-detection, cd-overlay 'ɵsetProfiler', - // signal-graph - 'ɵgetSignalGraph', ] as const; /** * Utils that exist only from a given version, or only up to one. Each entry - * names the version that brought it in (`since`) or took it out (`until`). + * names the version that brought it in (`since`) or took it out (`until`), as + * docs/angular-compat.md lists them. */ const VERSIONED_NG_UTILS = [ + // injector-tree, router, router-setup, ngrx-collector, forms-webmcp + { name: 'ɵgetInjectorProviders', since: '16.2.0' }, + { name: 'ɵgetInjectorResolutionPath', since: '16.2.0' }, + { name: 'ɵgetDependenciesFromInjectable', since: '16.2.0' }, + { name: 'ɵgetInjectorMetadata', since: '17.0.0' }, + // component-tree, component-values, signal-graph + { name: 'isSignal', since: '17.1.0' }, + // signal-graph + { name: 'ɵgetSignalGraph', since: '19.1.0' }, + // cd-live + { name: 'enableProfiling', since: '20.0.0' }, // defer-blocks: renamed from `ɵgetDeferBlocks` in Angular 22.0. { name: 'ɵgetControlFlowBlocks', since: '22.0.0' }, - { name: 'ɵgetDeferBlocks', until: '22.0.0' }, + { name: 'ɵgetDeferBlocks', since: '19.1.0', until: '22.0.0' }, // router: provideRouter publishes it since Angular 20.3.5; before that the // overlay finds the Router through `ɵgetInjectorProviders`. { name: 'ɵgetRouterInstance', since: '20.3.5' }, ] as const; class TripBadge {} -Directive({ selector: '[appTripBadge]' })(TripBadge); +Directive({ ...STANDALONE, selector: '[appTripBadge]' })(TripBadge); class TripCard { tripId = ''; - readonly selected = signal(false); + readonly selected = writableSignal(false); } Component({ + ...STANDALONE, selector: 'app-trip-card', imports: [TripBadge], - inputs: [{ name: 'tripId', alias: 'trip' }], + // The string form; the object form of inputs exists from 16.0. + inputs: ['tripId: trip'], template: 'Trip', })(TripCard); -ɵsetClassDebugInfo(TripCard, { +// Dev builds record where a component is declared from 17.0. +const setClassDebugInfo = (core as unknown as Record)['ɵsetClassDebugInfo'] as + ((type: unknown, info: unknown) => void) | undefined; +setClassDebugInfo?.(TripCard, { className: 'TripCard', filePath: 'src/app/trip-card.ts', lineNumber: 12, @@ -78,24 +88,33 @@ Component({ class TripPage {} Component({ + ...STANDALONE, selector: 'app-root', imports: [TripCard], - template: '', + template: '', })(TripPage); type Ng = Record; type Def = Record; describe('private API canary', () => { - let app: ApplicationRef; + let app: BootedApp; let ng: Ng; beforeAll(async () => { document.body.innerHTML = ''; - app = await bootstrapApplication(TripPage, { - providers: [provideZonelessChangeDetection(), provideRouter([])], + // provideRouter publishes ɵgetRouterInstance; before 14.2 there is only forRoot. + const provideRouter = + routerApi<(routes: unknown[]) => core.EnvironmentProviders>('provideRouter'); + app = await bootstrapRoot(TripPage, { + declarations: [TripCard, TripBadge], + providers: [ + { provide: APP_BASE_HREF, useValue: '/' }, + ...(provideRouter ? [provideRouter([])] : []), + ], + modules: provideRouter ? [] : [RouterModule.forRoot([])], }); - app.tick(); + app.appRef.tick(); ng = (globalThis as { ng?: Ng }).ng!; }); @@ -115,7 +134,8 @@ describe('private API canary', () => { expect(typeof ng[entry.name] === 'function', `ng.${entry.name}`).toBe(expected); }); - it('ɵsetProfiler hands back a remover (Angular 20.0+)', () => { + // Before 20.0 setting a profiler evicts the one in place, so the overlay never calls it. + it.runIf(angularAtLeast('20.0'))('ɵsetProfiler hands back a remover (Angular 20.0+)', () => { const remove = (ng['ɵsetProfiler'] as (fn: () => void) => unknown)(() => {}); expect(typeof remove).toBe('function'); (remove as () => void)(); @@ -128,12 +148,16 @@ describe('private API canary', () => { expect((cmp!['selectors'] as unknown[][])[0][0]).toBe('app-trip-card'); // router-config, router-links: inputs keyed by public name expect(Object.keys(cmp!['inputs'] as object)).toContain('trip'); - // component-source, router: debugInfo set by ɵsetClassDebugInfo in dev builds - expect(cmp!['debugInfo']).toMatchObject({ - className: 'TripCard', - filePath: 'src/app/trip-card.ts', - lineNumber: 12, - }); + // component-source, router: debugInfo set by ɵsetClassDebugInfo in dev builds (17.0+) + if (setClassDebugInfo) { + expect(cmp!['debugInfo']).toMatchObject({ + className: 'TripCard', + filePath: 'src/app/trip-card.ts', + lineNumber: 12, + }); + } else { + expect(cmp!['debugInfo']).toBeUndefined(); + } // forms: ɵdir.selectors const dir = (TripBadge as unknown as { ɵdir?: Def }).ɵdir; expect((dir!['selectors'] as unknown[][])[0]).toContain('appTripBadge'); @@ -149,7 +173,20 @@ describe('private API canary', () => { const span = host.querySelector('span')!; expect((ng['getDirectives'] as (el: Element) => unknown[])(span)[0]).toBeInstanceOf(TripBadge); expect((ng['getOwningComponent'] as (el: Element) => unknown)(span)).toBe(card); - expect((ng['isSignal'] as (v: unknown) => boolean)((card as TripCard).selected)).toBe(true); + if (angularAtLeast('17.1')) { + expect((ng['isSignal'] as (v: unknown) => boolean)((card as TripCard).selected)).toBe(true); + } + + // pipes-runtime: 12.x and 13.x patch the LView array onto the node; from 14.0 + // it is a view id that getComponent upgrades to an LContext with an lView. + const context = (host as unknown as { __ngContext__?: unknown }).__ngContext__; + if (angularAtLeast('14.0')) { + expect(Array.isArray((context as { lView?: unknown })?.lView)).toBe(true); + } else { + const lView = Array.isArray(context) ? context : (context as { lView?: unknown })?.lView; + expect(Array.isArray(lView)).toBe(true); + } + if (!angularAtLeast('17.0')) return; const injector = (ng['getInjector'] as (el: Element) => unknown)(host); const meta = (ng['ɵgetInjectorMetadata'] as (i: unknown) => { type: string } | null)(injector); @@ -165,9 +202,5 @@ describe('private API canary', () => { ); expect(providers.length).toBeGreaterThan(0); expect(providers[0]).toHaveProperty('token'); - - // pipes-runtime: after getComponent, __ngContext__ is an LContext with an lView array. - const context = (host as unknown as { __ngContext__?: { lView?: unknown } }).__ngContext__; - expect(Array.isArray(context?.lView)).toBe(true); }); }); diff --git a/packages/devtools/src/__tests__/router-actions-probe-timeout.test.ts b/packages/devtools/src/__tests__/router-actions-probe-timeout.test.ts index 68c407c5..ebddf0f3 100644 --- a/packages/devtools/src/__tests__/router-actions-probe-timeout.test.ts +++ b/packages/devtools/src/__tests__/router-actions-probe-timeout.test.ts @@ -2,13 +2,13 @@ import '@angular/compiler'; import { Component } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { Router, provideRouter, type Routes } from '@angular/router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { watchRouter, type NavigationRecord } from '../router.ts'; import { runAction } from '../router-actions.ts'; +import { initAngularTestBed } from './angular-version.ts'; -TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); +initAngularTestBed(); class Page {} Component({ selector: 'app-page', template: '' })(Page); diff --git a/packages/devtools/src/__tests__/router-actions-wait.test.ts b/packages/devtools/src/__tests__/router-actions-wait.test.ts index 0110dc6c..b5381452 100644 --- a/packages/devtools/src/__tests__/router-actions-wait.test.ts +++ b/packages/devtools/src/__tests__/router-actions-wait.test.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom import '@angular/compiler'; -import { Component, PendingTasks, inject } from '@angular/core'; +import * as core from '@angular/core'; +import { Component, inject } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { RedirectCommand, Router, @@ -13,12 +13,13 @@ 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'; +import { angularAtLeast, initAngularTestBed, STANDALONE } from './angular-version.ts'; +import { functionalRouter } from './router-testing.ts'; -TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); +initAngularTestBed(); class Page {} -Component({ selector: 'app-page', template: '' })(Page); +Component({ ...STANDALONE, selector: 'app-page', template: '' })(Page); let landingGuardRuns = 0; let landingResolverRuns = 0; @@ -61,7 +62,26 @@ const routes: Routes = [ }, ]; -describe('router actions that wait on the app', () => { +/** The Router holds the root pending tasks from 16.0. */ +const routerTasks = angularAtLeast('16.0'); + +/** + * Adds a pending task and returns its remover: through the public + * `PendingTasks` from 19.0, through the root tasks the Router holds before. + */ +function addPendingTask(router: Router): () => void { + const publicTasks = (core as unknown as Record)['PendingTasks']; + if (publicTasks && angularAtLeast('19.0')) { + return TestBed.inject(publicTasks as core.Type<{ add(): () => void }>).add(); + } + const tasks = (router as unknown as { pendingTasks: { add(): number; remove(id: number): void } }) + .pendingTasks; + const id = tasks.add(); + return () => tasks.remove(id); +} + +// provideRouter and functional guards exist from 14.2. +describe.runIf(functionalRouter)('router actions that wait on the app', () => { let router: Router; let navigations: NavigationRecord[]; let stop: (() => void) | null; @@ -90,16 +110,19 @@ describe('router actions that wait on the app', () => { afterEach(() => stop?.()); - it('waitFor "navigation" returns when the navigation ends, without waiting for pending tasks', async () => { - const done = TestBed.inject(PendingTasks).add(); - const result = await act({ action: 'navigate', url: '/plain', waitFor: 'navigation' }); - expect(result).toMatchObject({ outcome: 'succeeded', url: '/plain' }); - expect(result['stable']).toBeUndefined(); - done(); - }); + it.runIf(routerTasks)( + 'waitFor "navigation" returns when the navigation ends, without waiting for pending tasks', + async () => { + const done = addPendingTask(router); + const result = await act({ action: 'navigate', url: '/plain', waitFor: 'navigation' }); + expect(result).toMatchObject({ outcome: 'succeeded', url: '/plain' }); + expect(result['stable']).toBeUndefined(); + done(); + }, + ); - it('waitFor "stable" waits until the app has no pending tasks', async () => { - const done = TestBed.inject(PendingTasks).add(); + it.runIf(routerTasks)('waitFor "stable" waits until the app has no pending tasks', async () => { + const done = addPendingTask(router); setTimeout(done, 400); const started = Date.now(); const result = await act({ action: 'navigate', url: '/plain', waitFor: 'stable' }); @@ -107,8 +130,8 @@ describe('router actions that wait on the app', () => { expect(result).toMatchObject({ outcome: 'succeeded', stable: true }); }); - it('says when the app did not become stable in time', async () => { - const done = TestBed.inject(PendingTasks).add(); + it.runIf(routerTasks)('says when the app did not become stable in time', async () => { + const done = addPendingTask(router); expect(await waitForStable(router as never, 30)).toBe(false); done(); expect(await waitForStable(router as never, 30)).toBe(true); @@ -118,6 +141,15 @@ describe('router actions that wait on the app', () => { expect(await waitForStable({} as never, 30)).toBeNull(); }); + it.skipIf(routerTasks)( + 'reports an unknown stability before 16.0, whose Router has no pending tasks', + async () => { + expect(await waitForStable(router as never, 30)).toBeNull(); + const result = await act({ action: 'navigate', url: '/plain', waitFor: 'stable' }); + expect(result).toMatchObject({ outcome: 'succeeded', url: '/plain' }); + }, + ); + it.skipIf(!angularAtLeast('20.2.0')).each([ ['a UrlTree', '/members'], ['a RedirectCommand', '/vip'], diff --git a/packages/devtools/src/__tests__/router-audit.test.ts b/packages/devtools/src/__tests__/router-audit.test.ts index 3f45b3ad..49602578 100644 --- a/packages/devtools/src/__tests__/router-audit.test.ts +++ b/packages/devtools/src/__tests__/router-audit.test.ts @@ -2,7 +2,6 @@ import '@angular/compiler'; import { Component, inject } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { Router, RouterLink, RouterLinkActive, provideRouter, type Routes } from '@angular/router'; import { RouterTestingHarness } from '@angular/router/testing'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -24,9 +23,9 @@ import { storedInstrumented, } from '../router-actions.ts'; import { touchRouterPage } from '../rpc/router-tools.ts'; -import { angularAtLeast } from './angular-version.ts'; +import { angularAtLeast, initAngularTestBed, STANDALONE } from './angular-version.ts'; -TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); +initAngularTestBed(); class Nav { readonly router = inject(Router); @@ -36,6 +35,7 @@ class Nav { } } Component({ + ...STANDALONE, selector: 'app-nav', imports: [RouterLink, RouterLinkActive], template: ` @@ -59,8 +59,13 @@ Component({ - @if (nullOptions) { + ${ + // Control flow blocks exist from 17.0; the link only matters on 22.0+. + angularAtLeast('17.0') + ? `@if (nullOptions) { Never + }` + : '' } `, })(Nav); @@ -86,7 +91,8 @@ const routes: Routes = [ }, ]; -describe('router audit fixes on a real Router', () => { +// RouterTestingHarness exists from 15.2. +describe.runIf(angularAtLeast('15.2'))('router audit fixes on a real Router', () => { let router: Router; let navigations: NavigationRecord[]; let cleanup: (() => void)[]; @@ -237,18 +243,22 @@ describe('route-config secrets before any navigation event', () => { expect(adopted?.url).toBe('/reset/[redacted]'); }); - it('masks a short path-param token in a rendered routerLink href', async () => { - TestBed.resetTestingModule(); - TestBed.configureTestingModule({ - providers: [provideRouter([{ path: 'invite/:code', component: Nav }, ...routes])], - }); - const real = TestBed.inject(Router); - const harness = await RouterTestingHarness.create(); - await harness.navigateByUrl('/a'); - harness.detectChanges(); - const links = linksOf((globalThis as { ng?: RouterDebugApi }).ng!, real as never); - expect(links.find((l) => l.text === 'Invite')?.href).toBe('/invite/[redacted]'); - }); + // RouterTestingHarness exists from 15.2. + it.runIf(angularAtLeast('15.2'))( + 'masks a short path-param token in a rendered routerLink href', + async () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideRouter([{ path: 'invite/:code', component: Nav }, ...routes])], + }); + const real = TestBed.inject(Router); + const harness = await RouterTestingHarness.create(); + await harness.navigateByUrl('/a'); + harness.detectChanges(); + const links = linksOf((globalThis as { ng?: RouterDebugApi }).ng!, real as never); + expect(links.find((l) => l.text === 'Invite')?.href).toBe('/invite/[redacted]'); + }, + ); }); describe('router heartbeat', () => { diff --git a/packages/devtools/src/__tests__/router-features.test.ts b/packages/devtools/src/__tests__/router-features.test.ts index 23949361..0f05a9f7 100644 --- a/packages/devtools/src/__tests__/router-features.test.ts +++ b/packages/devtools/src/__tests__/router-features.test.ts @@ -2,7 +2,6 @@ import '@angular/compiler'; import { Component, inject } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { PreloadAllModules, RedirectCommand, @@ -30,18 +29,22 @@ import { type PreloadRecord, } from '../router-actions.ts'; import { matchUrl } from '../rpc/router-config-tools.ts'; -import { angularAtLeast } from './angular-version.ts'; +import { angularAtLeast, initAngularTestBed, STANDALONE } from './angular-version.ts'; +import { functionalRouter, routerTestModule } from './router-testing.ts'; -TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); +/** The navigation error handler can redirect (`RedirectCommand`) from 18.0. */ +const handlerRedirects = angularAtLeast('18.0'); + +initAngularTestBed(); class Page {} -Component({ selector: 'app-page', template: '' })(Page); +Component({ ...STANDALONE, selector: 'app-page', template: '' })(Page); class UserPage { id?: string; user?: unknown; } -Component({ selector: 'app-user', template: '', inputs: ['id', 'user'] })(UserPage); +Component({ ...STANDALONE, selector: 'app-user', template: '', inputs: ['id', 'user'] })(UserPage); const authGuard = () => inject(Router).parseUrl('/login'); const slowResolver = () => new Promise((resolve) => setTimeout(() => resolve('late'), 40)); @@ -81,7 +84,8 @@ const routes: Routes = [ { path: '**', component: Page }, ]; -describe('router features on a real Router', () => { +// provideRouter and functional guards exist from 14.2. +describe.runIf(functionalRouter)('router features on a real Router', () => { let router: Router; let navigations: NavigationRecord[]; let cleanup: (() => void)[]; @@ -157,7 +161,9 @@ describe('router features on a real Router', () => { total: expect.any(Number), recognize: expect.any(Number), }); - expect(last().checked?.activate).toEqual(['/users/:id']); + // The guard checks are read from the router's current transition, exposed from 16.2. + if (angularAtLeast('16.2')) expect(last().checked?.activate).toEqual(['/users/:id']); + else expect(last().checked).toBeUndefined(); }); it('links a guard redirect to the navigation it starts', async () => { @@ -172,7 +178,7 @@ describe('router features on a real Router', () => { expect(login).toMatchObject({ url: '/login', redirectedFrom: admin.id }); }); - it('keeps what the navigation error handler did', async () => { + it.runIf(handlerRedirects)('keeps what the navigation error handler did', async () => { await router.navigateByUrl('/broken').catch(() => {}); const broken = navigations.find((n) => n.url === '/broken')!; expect(broken.errorHandler).toMatch(/redirected to \/login/); @@ -187,16 +193,19 @@ describe('router features on a real Router', () => { expect(last()).toMatchObject({ url: '/login', redirectedFrom: broken.id }); }); - it('keeps the error code of an error the handler redirected away from', async () => { - await router.navigateByUrl('/coded').catch(() => {}); - const coded = navigations.find((n) => n.url === '/coded')!; - expect(coded).toMatchObject({ - outcome: 'redirected', - redirectKind: 'error handler', - reason: 'Error: NG04002: db down', - errorCode: 'NG04002', - }); - }); + it.runIf(handlerRedirects)( + 'keeps the error code of an error the handler redirected away from', + async () => { + await router.navigateByUrl('/coded').catch(() => {}); + const coded = navigations.find((n) => n.url === '/coded')!; + expect(coded).toMatchObject({ + outcome: 'redirected', + redirectKind: 'error handler', + reason: 'Error: NG04002: db down', + errorCode: 'NG04002', + }); + }, + ); it('records calls that throw before a navigation starts', () => { expect(() => @@ -326,29 +335,35 @@ describe('router features on a real Router', () => { }); }); - it('maps outlets to routed components and their router-bound inputs', async () => { - TestBed.resetTestingModule(); - TestBed.configureTestingModule({ - providers: [ - provideRouter(routes, (await import('@angular/router')).withComponentInputBinding()), - ], - }); - const harness = await RouterTestingHarness.create(); - await harness.navigateByUrl('/users/9'); - const outlets = outletsOf(TestBed.inject(Router) as never); - expect(outlets[0]).toMatchObject({ outlet: 'primary', activated: true }); - const leaf = outlets[0].children?.[0] ?? outlets[0]; - expect(leaf).toMatchObject({ component: 'UserPage', route: '/users/:id' }); - expect(leaf.inputs).toEqual([ - { input: 'id', source: 'param' }, - { input: 'user', source: 'data' }, - ]); - }); + // withComponentInputBinding exists from 16.0. + it.runIf(angularAtLeast('16.0'))( + 'maps outlets to routed components and their router-bound inputs', + async () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideRouter(routes, (await import('@angular/router')).withComponentInputBinding()), + ], + }); + const harness = await RouterTestingHarness.create(); + await harness.navigateByUrl('/users/9'); + const outlets = outletsOf(TestBed.inject(Router) as never); + expect(outlets[0]).toMatchObject({ outlet: 'primary', activated: true }); + const leaf = outlets[0].children?.[0] ?? outlets[0]; + expect(leaf).toMatchObject({ component: 'UserPage', route: '/users/:id' }); + expect(leaf.inputs).toEqual([ + { input: 'id', source: 'param' }, + { input: 'user', source: 'data' }, + ]); + }, + ); }); -describe('routerOutletData', () => { +// routerOutletData exists from 19.0. +describe.runIf(angularAtLeast('19.0'))('routerOutletData', () => { class Shell {} Component({ + ...STANDALONE, selector: 'app-shell', imports: [RouterOutlet], template: ` @@ -403,7 +418,7 @@ describe('routerOutletData', () => { }); }); -describe('preloads', () => { +describe.runIf(functionalRouter)('preloads', () => { it('records preloads that run between navigations', async () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ @@ -452,7 +467,7 @@ describe('helpers', () => { it('predicts matches like the default matcher', () => { TestBed.resetTestingModule(); - TestBed.configureTestingModule({ providers: [provideRouter(routes)] }); + TestBed.configureTestingModule(routerTestModule(routes)); const tree = walkConfig(TestBed.inject(Router) as never); expect(matchUrl(tree, '/users/3').chain.map((n) => n.fullPath)).toEqual([ '/users', diff --git a/packages/devtools/src/__tests__/router-forroot.test.ts b/packages/devtools/src/__tests__/router-forroot.test.ts index e0161854..54822610 100644 --- a/packages/devtools/src/__tests__/router-forroot.test.ts +++ b/packages/devtools/src/__tests__/router-forroot.test.ts @@ -1,26 +1,26 @@ // @vitest-environment jsdom import '@angular/compiler'; -import { Component, importProvidersFrom, provideZonelessChangeDetection } from '@angular/core'; -import { bootstrapApplication } from '@angular/platform-browser'; +import { APP_BASE_HREF } from '@angular/common'; +import { Component } from '@angular/core'; import { PreloadAllModules, Router, RouterModule, type ExtraOptions } from '@angular/router'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { routerOf } from '../analog-runtime.ts'; import { findRouter, type RouterDebugApi } from '../router.ts'; import { detectSetup, publishesRouterUtil } from '../router-setup.ts'; +import { angularAtLeast, bootstrapRoot, STANDALONE, type BootedApp } from './angular-version.ts'; +import { injectorProviders } from './router-testing.ts'; class Root {} -Component({ selector: 'app-root', template: '' })(Root); +Component({ ...STANDALONE, selector: 'app-root', template: '' })(Root); -let app: Awaited>; +let app: BootedApp; let ng: RouterDebugApi; async function boot(options?: ExtraOptions) { document.body.innerHTML = ''; - app = await bootstrapApplication(Root, { - providers: [ - provideZonelessChangeDetection(), - importProvidersFrom(RouterModule.forRoot([{ path: '', component: Root }], options)), - ], + app = await bootstrapRoot(Root, { + providers: [{ provide: APP_BASE_HREF, useValue: '/' }], + modules: [RouterModule.forRoot([{ path: '', component: Root }], options)], }); const published = (globalThis as { ng?: RouterDebugApi }).ng!; ng = { @@ -34,13 +34,37 @@ beforeEach(() => boot()); afterEach(() => app.destroy()); +// Before 16.2 the Router is only found when a component or router link holds +// it (router-fallback.ts); this root holds nothing, so there is no router data. +const found = () => (injectorProviders ? app.injector.get(Router) : null); + it('finds the router of a RouterModule.forRoot app through the injector chain', () => { expect((globalThis as { ng?: RouterDebugApi }).ng!.ɵgetRouterInstance).toBeUndefined(); - expect(findRouter(ng, [document.querySelector('app-root')!])).toBe(app.injector.get(Router)); + expect(findRouter(ng, [document.querySelector('app-root')!])).toBe(found()); }); it('finds the router for the Analog report without ng.ɵgetRouterInstance', () => { - expect(routerOf(ng as never)).toBe(app.injector.get(Router)); + expect(routerOf(ng as never)).toBe(found()); +}); + +it('finds the router a root component holds on every version', async () => { + app.destroy(); + class Holder { + constructor(readonly router: Router) {} + } + Component({ ...STANDALONE, selector: 'app-root', template: '' })(Holder); + (Holder as unknown as { ctorParameters: () => unknown[] }).ctorParameters = () => [ + { type: Router }, + ]; + document.body.innerHTML = ''; + app = await bootstrapRoot(Holder, { + providers: [{ provide: APP_BASE_HREF, useValue: '/' }], + modules: [RouterModule.forRoot([])], + }); + const published = (globalThis as { ng?: RouterDebugApi }).ng!; + expect(findRouter(published, [document.querySelector('app-root')!])).toBe( + app.injector.get(Router), + ); }); it('reports forRoot from its guard token on every version, with or without the router util', () => { @@ -49,10 +73,13 @@ it('reports forRoot from its guard token on every version, with or without the r root.setAttribute('ng-version', version); return detectSetup(ng, app.injector.get(Router) as never, 1, root).setupKind; }; + // The guard token is a provider, read through ɵgetInjectorProviders (16.2+). + // Without it, a version that publishes the router util tells forRoot apart + // by the util's absence; an older one can't tell. expect(setupOn('22.1.7')).toBe('forRoot or other'); expect(setupOn('20.3.5')).toBe('forRoot or other'); - expect(setupOn('20.3.4')).toBe('forRoot or other'); - expect(setupOn('20.0.0')).toBe('forRoot or other'); + expect(setupOn('20.3.4')).toBe(injectorProviders ? 'forRoot or other' : 'unknown'); + expect(setupOn('20.0.0')).toBe(injectorProviders ? 'forRoot or other' : 'unknown'); }); it('knows which versions publish the router util', () => { @@ -83,19 +110,34 @@ it('reads the RouterModule.forRoot options on a real Router', async () => { const setup = detectSetup(ng, app.injector.get(Router) as never, 1, root); vi.restoreAllMocks(); const values = Object.fromEntries(setup.options.map((o) => [o.name, [o.value, o.set]])); - expect(values).toMatchObject({ - initialNavigation: ['enabledBlocking', true], - scrollPositionRestoration: ['top', true], - anchorScrolling: ['enabled', true], - onSameUrlNavigation: ['reload', true], - paramsInheritanceStrategy: ['always', true], - }); + if (angularAtLeast('15.0')) { + expect(values).toMatchObject({ + initialNavigation: ['enabledBlocking', true], + scrollPositionRestoration: ['top', true], + anchorScrolling: ['enabled', true], + onSameUrlNavigation: ['reload', true], + paramsInheritanceStrategy: ['always', true], + }); + } else { + // Router.options holds the forRoot options from 15.0. Before, only the + // options the Router keeps as fields are read, and none shows as set. + expect(values).toMatchObject({ + initialNavigation: ['enabledNonBlocking', false], + scrollPositionRestoration: ['disabled', false], + anchorScrolling: ['disabled', false], + onSameUrlNavigation: ['reload', false], + paramsInheritanceStrategy: ['always', false], + }); + } + // Each option exists from the version named; preloading and the scroller are + // found through their providers (16.2). + const on = (since: string, value = 'on') => (angularAtLeast(since) ? value : 'off'); expect(setup.features).toMatchObject({ - componentInputBinding: 'on', - viewTransitions: 'on', - preloading: 'PreloadAllModules', - scroller: 'on', - debugTracing: 'on', + componentInputBinding: on('16.0'), + viewTransitions: on('17.0'), + preloading: on('16.2', 'PreloadAllModules'), + scroller: on('16.2'), + debugTracing: on('15.0'), platformNavigation: 'off', }); expect(setup.strategies['locationStrategy']).toBe('HashLocationStrategy'); diff --git a/packages/devtools/src/__tests__/router-guard-results.test.ts b/packages/devtools/src/__tests__/router-guard-results.test.ts index 6480d76b..c4659454 100644 --- a/packages/devtools/src/__tests__/router-guard-results.test.ts +++ b/packages/devtools/src/__tests__/router-guard-results.test.ts @@ -2,7 +2,6 @@ import '@angular/compiler'; import { Component } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { Router, provideRouter, type Routes } from '@angular/router'; import { BehaviorSubject, @@ -16,8 +15,10 @@ import { import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { watchRouter, type NavigationRecord } from '../router.ts'; import { instrument } from '../router-actions.ts'; +import { initAngularTestBed, STANDALONE } from './angular-version.ts'; +import { functionalRouter } from './router-testing.ts'; -TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); +initAngularTestBed(); class StateObservable extends Observable { constructor(state: Observable) { @@ -39,7 +40,7 @@ class FakeStore extends Observable { } class Page {} -Component({ selector: 'app-page', template: 'page' })(Page); +Component({ ...STANDALONE, selector: 'app-page', template: 'page' })(Page); const state = new BehaviorSubject({ authed: true }); const behavior = new BehaviorSubject(true); @@ -80,63 +81,67 @@ function wrapGuard(value: Observable) { return { wrapped, nav }; } -describe('instrumented guards keep the observable they return working', () => { - let router: Router; - let navigations: NavigationRecord[]; - let cleanup: (() => void)[]; - const last = () => navigations[navigations.length - 1]; - - beforeEach(() => { - TestBed.resetTestingModule(); - TestBed.configureTestingModule({ providers: [provideRouter(routes)] }); - router = TestBed.inject(Router); - navigations = []; - cleanup = []; - const stop = watchRouter(router as never, navigations, () => {}); - if (stop) cleanup.push(stop); - cleanup.push(instrument(router as never, navigations)); - subject = new Subject(); - asyncValue = later(true); - }); - - afterEach(() => { - for (const fn of cleanup) fn(); - }); - - it('passes a Store-like observable with its own lift and constructor', async () => { - expect(storeGuard()).toBeInstanceOf(FakeStore); - await expect(router.navigateByUrl('/store')).resolves.toBe(true); - expect(router.url).toBe('/store'); - expect(last().runs).toEqual([ - expect.objectContaining({ guard: 'storeGuard', kind: 'canActivate', result: 'true' }), - ]); - }); - - it('passes the current value of a BehaviorSubject, not a function', async () => { - behavior.next(false); - await expect(router.navigateByUrl('/behavior')).resolves.toBe(false); - expect(last().runs?.[0]).toMatchObject({ guard: 'behaviorGuard', result: 'false' }); - behavior.next(true); - await expect(router.navigateByUrl('/behavior')).resolves.toBe(true); - expect(last().runs?.[0]).toMatchObject({ result: 'true' }); - }); - - it('waits for a Subject to emit', async () => { - const navigation = router.navigateByUrl('/subject'); - await new Promise((resolve) => setTimeout(resolve, 5)); - subject.next(true); - await expect(navigation).resolves.toBe(true); - expect(last().runs?.[0]).toMatchObject({ guard: 'subjectGuard', result: 'true' }); - }); - - it('records an async guard and resolver on their first value', async () => { - await expect(router.navigateByUrl('/async')).resolves.toBe(true); - expect(last().runs?.map((run) => [run.kind, run.guard, run.result])).toEqual([ - ['canActivate', 'asyncGuard', 'true'], - ['resolve', 'v: asyncResolver', 'string'], - ]); - }); -}); +// provideRouter and functional guards exist from 14.2. +describe.runIf(functionalRouter)( + 'instrumented guards keep the observable they return working', + () => { + let router: Router; + let navigations: NavigationRecord[]; + let cleanup: (() => void)[]; + const last = () => navigations[navigations.length - 1]; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ providers: [provideRouter(routes)] }); + router = TestBed.inject(Router); + navigations = []; + cleanup = []; + const stop = watchRouter(router as never, navigations, () => {}); + if (stop) cleanup.push(stop); + cleanup.push(instrument(router as never, navigations)); + subject = new Subject(); + asyncValue = later(true); + }); + + afterEach(() => { + for (const fn of cleanup) fn(); + }); + + it('passes a Store-like observable with its own lift and constructor', async () => { + expect(storeGuard()).toBeInstanceOf(FakeStore); + await expect(router.navigateByUrl('/store')).resolves.toBe(true); + expect(router.url).toBe('/store'); + expect(last().runs).toEqual([ + expect.objectContaining({ guard: 'storeGuard', kind: 'canActivate', result: 'true' }), + ]); + }); + + it('passes the current value of a BehaviorSubject, not a function', async () => { + behavior.next(false); + await expect(router.navigateByUrl('/behavior')).resolves.toBe(false); + expect(last().runs?.[0]).toMatchObject({ guard: 'behaviorGuard', result: 'false' }); + behavior.next(true); + await expect(router.navigateByUrl('/behavior')).resolves.toBe(true); + expect(last().runs?.[0]).toMatchObject({ result: 'true' }); + }); + + it('waits for a Subject to emit', async () => { + const navigation = router.navigateByUrl('/subject'); + await new Promise((resolve) => setTimeout(resolve, 5)); + subject.next(true); + await expect(navigation).resolves.toBe(true); + expect(last().runs?.[0]).toMatchObject({ guard: 'subjectGuard', result: 'true' }); + }); + + it('records an async guard and resolver on their first value', async () => { + await expect(router.navigateByUrl('/async')).resolves.toBe(true); + expect(last().runs?.map((run) => [run.kind, run.guard, run.result])).toEqual([ + ['canActivate', 'asyncGuard', 'true'], + ['resolve', 'v: asyncResolver', 'string'], + ]); + }); + }, +); describe('tracked guard observables', () => { it('records the first value of an async source read through first()', async () => { diff --git a/packages/devtools/src/__tests__/router-loops.test.ts b/packages/devtools/src/__tests__/router-loops.test.ts index 9ec1c243..3a57ad8f 100644 --- a/packages/devtools/src/__tests__/router-loops.test.ts +++ b/packages/devtools/src/__tests__/router-loops.test.ts @@ -2,7 +2,6 @@ import '@angular/compiler'; import { Component, inject } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { RedirectCommand, Router, @@ -17,8 +16,10 @@ import type { RouteNode } from '../router-config.ts'; import { lintRoutes } from '../rpc/router-config-tools.ts'; import { detectLoops, redirectCycles } from '../rpc/router-loops.ts'; import { describeNavigation, type RouterPage } from '../rpc/router-tools.ts'; +import { angularAtLeast, initAngularTestBed, STANDALONE } from './angular-version.ts'; +import { functionalRouter } from './router-testing.ts'; -TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); +initAngularTestBed(); function nav(id: number, extra: Partial = {}): NavigationRecord { return { @@ -208,7 +209,7 @@ describe('detectLoops', () => { }); class Page {} -Component({ selector: 'app-page', template: '' })(Page); +Component({ ...STANDALONE, selector: 'app-page', template: '' })(Page); let signedIn = false; let bounces = 0; @@ -231,7 +232,8 @@ const routes: Routes = [ { path: 'step/4', component: Page }, ]; -describe('detectLoops on a real Router', () => { +// provideRouter and functional guards exist from 14.2. +describe.runIf(functionalRouter)('detectLoops on a real Router', () => { let router: Router; let navigations: NavigationRecord[]; let cleanup: (() => void)[]; @@ -275,11 +277,18 @@ describe('detectLoops on a real Router', () => { it('flags a redirectTo loop that Angular stops with NG04016', async () => { await router.navigateByUrl('/x').catch(() => {}); const failed = navigations[navigations.length - 1]; - expect(failed).toMatchObject({ url: '/x', outcome: 'failed', errorCode: 'NG04016' }); const config: RouteNode[] = [ { id: '3', path: 'x', fullPath: '/x', kind: 'redirect', redirectTo: '/y' }, { id: '4', path: 'y', fullPath: '/y', kind: 'redirect', redirectTo: '/x' }, ]; + if (!angularAtLeast('17.0')) { + // Angular names the loop (NG04016) from 17.0. Before, the navigation fails + // with "Cannot match any routes" (NG04002) and is not reported as a loop. + expect(failed).toMatchObject({ url: '/x', outcome: 'failed', errorCode: 'NG04002' }); + expect(detectLoops(navigations, config)).toEqual([]); + return; + } + expect(failed).toMatchObject({ url: '/x', outcome: 'failed', errorCode: 'NG04016' }); expect(detectLoops(navigations, config)).toEqual([ expect.objectContaining({ kind: 'config', ids: [failed.id], cycle: ['/x', '/y', '/x'] }), ]); @@ -300,7 +309,8 @@ function flakyResolver() { } const backToReport = () => inject(Router).parseUrl('/report'); -describe('detectLoops through the navigation error handler', () => { +// The navigation error handler can redirect (`RedirectCommand`) from 18.0. +describe.runIf(angularAtLeast('18.0'))('detectLoops through the navigation error handler', () => { let router: Router; let navigations: NavigationRecord[]; let cleanup: (() => void)[]; diff --git a/packages/devtools/src/__tests__/router-real.test.ts b/packages/devtools/src/__tests__/router-real.test.ts index c1d19395..ed4c4d77 100644 --- a/packages/devtools/src/__tests__/router-real.test.ts +++ b/packages/devtools/src/__tests__/router-real.test.ts @@ -2,7 +2,6 @@ import '@angular/compiler'; import { Component, Injector, inject } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { PreloadAllModules, Router, @@ -20,12 +19,13 @@ import { type NavigationRecord, type RouterDebugApi, } from '../router.ts'; -import { angularAtLeast } from './angular-version.ts'; +import { angularAtLeast, initAngularTestBed, STANDALONE } from './angular-version.ts'; +import { functionalRouter } from './router-testing.ts'; -TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); +initAngularTestBed(); class Page {} -Component({ selector: 'app-page', template: '' })(Page); +Component({ ...STANDALONE, selector: 'app-page', template: '' })(Page); const authGuard = () => inject(Router).parseUrl('/login'); const denyGuard = () => false; @@ -75,7 +75,8 @@ const routes: Routes = [ { path: 'lazy', loadChildren: () => Promise.resolve([{ path: '', component: Page }]) }, ]; -describe('real Router', () => { +// The routes use functional guards and resolvers, which exist from 14.2 with provideRouter. +describe.runIf(functionalRouter)('real Router', () => { let router: Router; let navigations: NavigationRecord[]; let stop: (() => void) | null; diff --git a/packages/devtools/src/__tests__/router-setup-legacy.test.ts b/packages/devtools/src/__tests__/router-setup-legacy.test.ts index c5301101..728785dc 100644 --- a/packages/devtools/src/__tests__/router-setup-legacy.test.ts +++ b/packages/devtools/src/__tests__/router-setup-legacy.test.ts @@ -1,60 +1,72 @@ // @vitest-environment jsdom import '@angular/compiler'; -import { Component, provideZonelessChangeDetection } from '@angular/core'; +import { Component } from '@angular/core'; import { bootstrapApplication } from '@angular/platform-browser'; import { Router, provideRouter } from '@angular/router'; -import { afterEach, beforeEach, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { routerOf } from '../analog-runtime.ts'; import { findRouter, type RouterDebugApi } from '../router.ts'; import { detectSetup } from '../router-setup.ts'; +import { zonelessProviders, STANDALONE } from './angular-version.ts'; +import { functionalRouter, injectorProviders } from './router-testing.ts'; class Root {} -Component({ selector: 'app-root', template: '' })(Root); +Component({ ...STANDALONE, selector: 'app-root', template: '' })(Root); -let app: Awaited>; -let ng: RouterDebugApi; -let util: unknown; +// provideRouter exists from 14.2. +describe.runIf(functionalRouter)('a provideRouter app without the router util', () => { + let app: Awaited>; + let ng: RouterDebugApi; + let util: unknown; -beforeEach(async () => { - document.body.innerHTML = ''; - app = await bootstrapApplication(Root, { - providers: [provideZonelessChangeDetection(), provideRouter([{ path: '', component: Root }])], + beforeEach(async () => { + document.body.innerHTML = ''; + app = await bootstrapApplication(Root, { + providers: [...zonelessProviders(), provideRouter([{ path: '', component: Root }])], + }); + const published = (globalThis as { ng?: RouterDebugApi & Record }).ng!; + util = published['ɵgetRouterInstance']; + delete published['ɵgetRouterInstance']; + ng = { + getInjector: published.getInjector, + ɵgetInjectorResolutionPath: published.ɵgetInjectorResolutionPath, + ɵgetInjectorProviders: published.ɵgetInjectorProviders, + }; }); - const published = (globalThis as { ng?: RouterDebugApi & Record }).ng!; - util = published['ɵgetRouterInstance']; - delete published['ɵgetRouterInstance']; - ng = { - getInjector: published.getInjector, - ɵgetInjectorResolutionPath: published.ɵgetInjectorResolutionPath, - ɵgetInjectorProviders: published.ɵgetInjectorProviders, - }; -}); -afterEach(() => { - (globalThis as { ng?: Record }).ng!['ɵgetRouterInstance'] = util; - app.destroy(); -}); + afterEach(() => { + (globalThis as { ng?: Record }).ng!['ɵgetRouterInstance'] = util; + app.destroy(); + }); -it('finds the router of a provideRouter app without ng.ɵgetRouterInstance', () => { - const roots = [document.querySelector('app-root')!]; - expect(findRouter(ng, roots)).toBe(app.injector.get(Router)); - expect(routerOf(ng as never)).toBe(app.injector.get(Router)); -}); + it('finds the router of a provideRouter app without ng.ɵgetRouterInstance', () => { + const roots = [document.querySelector('app-root')!]; + // Before 16.2 only a component or link holding the Router finds it; this root holds none. + const expected = injectorProviders ? app.injector.get(Router) : null; + expect(findRouter(ng, roots)).toBe(expected); + expect(routerOf(ng as never)).toBe(expected); + }); -it.each(['20.3.4', '20.3.5'])( - 'reports provideRouter from the root ROUTES when the router util is missing on %s', - (version) => { - const root = document.querySelector('app-root')!; - root.setAttribute('ng-version', version); - expect(detectSetup(ng, app.injector.get(Router) as never, 1, root).setupKind).toBe( - 'provideRouter', - ); - }, -); + it.each(['20.3.4', '20.3.5'])( + 'reports provideRouter from the root ROUTES when the router util is missing on %s', + (version) => { + const root = document.querySelector('app-root')!; + root.setAttribute('ng-version', version); + // ROUTES is a provider (16.2+). Before, 20.3.5 and later tell the setup + // apart by the router util, and an older version can't. + const expected = injectorProviders + ? 'provideRouter' + : version === '20.3.5' + ? 'forRoot or other' + : 'unknown'; + expect(detectSetup(ng, app.injector.get(Router) as never, 1, root).setupKind).toBe(expected); + }, + ); -it('reports unknown when neither the util nor the providers tell the setup apart', () => { - const root = document.querySelector('app-root')!; - root.setAttribute('ng-version', '20.3.4'); - const bare: RouterDebugApi = { ...ng, ɵgetInjectorProviders: () => [] }; - expect(detectSetup(bare, app.injector.get(Router) as never, 1, root).setupKind).toBe('unknown'); + it('reports unknown when neither the util nor the providers tell the setup apart', () => { + const root = document.querySelector('app-root')!; + root.setAttribute('ng-version', '20.3.4'); + const bare: RouterDebugApi = { ...ng, ɵgetInjectorProviders: () => [] }; + expect(detectSetup(bare, app.injector.get(Router) as never, 1, root).setupKind).toBe('unknown'); + }); }); diff --git a/packages/devtools/src/__tests__/router-setup-real.test.ts b/packages/devtools/src/__tests__/router-setup-real.test.ts index b9eb234e..55de0da3 100644 --- a/packages/devtools/src/__tests__/router-setup-real.test.ts +++ b/packages/devtools/src/__tests__/router-setup-real.test.ts @@ -1,12 +1,7 @@ // @vitest-environment jsdom import '@angular/compiler'; import { HashLocationStrategy, PlatformNavigation } from '@angular/common'; -import { - Component, - provideZonelessChangeDetection, - type EnvironmentProviders, - type Provider, -} from '@angular/core'; +import { Component, type EnvironmentProviders, type Provider } from '@angular/core'; import { bootstrapApplication } from '@angular/platform-browser'; import { PreloadAllModules, @@ -28,6 +23,8 @@ 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'; +import { angularAtLeast, zonelessProviders, STANDALONE } from './angular-version.ts'; +import { functionalRouter, injectorProviders } from './router-testing.ts'; const optionalRouter: Partial = router; const withExperimentalAutoCleanupInjectors = optionalRouter.withExperimentalAutoCleanupInjectors; @@ -35,7 +32,7 @@ const withExperimentalPlatformNavigation = optionalRouter.withExperimentalPlatfo const withRouterResources = optionalRouter.ɵwithRouterResources; class Root {} -Component({ selector: 'app-root', template: '' })(Root); +Component({ ...STANDALONE, selector: 'app-root', template: '' })(Root); const routes = [{ path: '', component: Root }]; let destroy: (() => void) | null = null; @@ -51,7 +48,7 @@ async function setupWith( ): Promise { document.body.innerHTML = ''; const app = await bootstrapApplication(Root, { - providers: [provideZonelessChangeDetection(), provideRouter(routes, ...features), ...extra], + providers: [...zonelessProviders(), provideRouter(routes, ...features), ...extra], }); destroy = () => app.destroy(); const ng = (globalThis as { ng?: RouterDebugApi }).ng!; @@ -61,11 +58,29 @@ async function setupWith( const option = (setup: RouterSetup, name: string) => setup.options.find((o) => o.name === name); -describe('detectSetup on a real provideRouter app', () => { +/** + * What the overlay reads for each feature, and the first version it can: from + * the Router's own fields once the feature stores one there, else from the + * providers (`ɵgetInjectorProviders`, 16.2). Before that the feature reads `off`. + */ +const READABLE_FROM: Record = { + componentInputBinding: '16.0', + viewTransitions: '17.0', + // Before 18.0 the handler is an environment initializer the Router does not hold. + navigationErrorHandler: '18.0', + routerResources: '20.0', + injectorCleanup: '20.0', + preloading: '16.2', + scroller: '16.2', +}; + +// `provideRouter` and its features exist from 14.2. +describe.runIf(functionalRouter)('detectSetup on a real provideRouter app', () => { it('reports defaults when no feature is used', async () => { const setup = await setupWith([]); - expect(setup.setupKind).toBe('provideRouter'); - expect(setup.mode).toBe('full'); + // Before 16.2 nothing tells provideRouter apart, and only router events are read. + expect(setup.setupKind).toBe(injectorProviders ? 'provideRouter' : 'unknown'); + expect(setup.mode).toBe(injectorProviders ? 'full' : 'events-only'); expect(option(setup, 'initialNavigation')).toEqual({ name: 'initialNavigation', value: 'enabledNonBlocking', @@ -87,15 +102,21 @@ describe('detectSetup on a real provideRouter app', () => { it('reads the initial navigation mode of withEnabledBlockingInitialNavigation()', async () => { const setup = await setupWith([withEnabledBlockingInitialNavigation()]); - expect(option(setup, 'initialNavigation')).toMatchObject({ - value: 'enabledBlocking', - set: true, - }); + // The mode is read from a provider, so before 16.2 the default shows. + expect(option(setup, 'initialNavigation')).toMatchObject( + injectorProviders + ? { value: 'enabledBlocking', set: true } + : { value: 'enabledNonBlocking', set: false }, + ); }); it('reads the initial navigation mode of withDisabledInitialNavigation()', async () => { const setup = await setupWith([withDisabledInitialNavigation()]); - expect(option(setup, 'initialNavigation')).toMatchObject({ value: 'disabled', set: true }); + expect(option(setup, 'initialNavigation')).toMatchObject( + injectorProviders + ? { value: 'disabled', set: true } + : { value: 'enabledNonBlocking', set: false }, + ); }); it('detects withDebugTracing()', async () => { @@ -103,7 +124,8 @@ describe('detectSetup on a real provideRouter app', () => { vi.spyOn(console, 'group').mockImplementation(() => {}); vi.spyOn(console, 'groupEnd').mockImplementation(() => {}); const setup = await setupWith([withDebugTracing()]); - expect(setup.features['debugTracing']).toBe('on'); + // withDebugTracing() is found in the injector records on the resolution path (16.2). + expect(setup.features['debugTracing']).toBe(injectorProviders ? 'on' : 'off'); vi.restoreAllMocks(); }); @@ -121,7 +143,7 @@ describe('detectSetup on a real provideRouter app', () => { ).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); + expect(setup.features[name]).toBe(angularAtLeast(READABLE_FROM[name]!) ? value : 'off'); }); it.skipIf(typeof withExperimentalPlatformNavigation !== 'function')( @@ -160,6 +182,9 @@ describe('detectSetup on a real provideRouter app', () => { }), ]); const values = Object.fromEntries(setup.options.map((o) => [o.name, [o.value, o.set]])); + // The scroll options come from the RouterScroller, found through its provider (16.2). + const scroll = (value: string, fallback: string) => + injectorProviders ? [value, false] : [fallback, false]; expect(values).toMatchObject({ onSameUrlNavigation: ['reload', true], paramsInheritanceStrategy: ['always', true], @@ -167,8 +192,8 @@ describe('detectSetup on a real provideRouter app', () => { canceledNavigationResolution: ['computed', true], defaultQueryParamsHandling: ['merge', true], resolveNavigationPromiseOnError: ['true', true], - scrollPositionRestoration: ['top', false], - anchorScrolling: ['enabled', false], + scrollPositionRestoration: scroll('top', 'disabled'), + anchorScrolling: scroll('enabled', 'disabled'), }); }); diff --git a/packages/devtools/src/__tests__/router-shared-guards.test.ts b/packages/devtools/src/__tests__/router-shared-guards.test.ts index ca48f61d..8e932128 100644 --- a/packages/devtools/src/__tests__/router-shared-guards.test.ts +++ b/packages/devtools/src/__tests__/router-shared-guards.test.ts @@ -2,16 +2,17 @@ import '@angular/compiler'; import { Component, Injectable } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; -import { Router, provideRouter, type Routes } from '@angular/router'; +import { Router, type Routes } from '@angular/router'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { watchRouter, type NavigationRecord } from '../router.ts'; import { instrument } from '../router-actions.ts'; +import { angularAtLeast, initAngularTestBed, STANDALONE } from './angular-version.ts'; +import { routerTestModule } from './router-testing.ts'; -TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); +initAngularTestBed(); class Page {} -Component({ selector: 'app-page', template: '' })(Page); +Component({ ...STANDALONE, selector: 'app-page', template: '' })(Page); class AuthGuard { canActivate() { @@ -59,7 +60,7 @@ describe('instrumented class guards and resolvers shared by several routes', () beforeEach(async () => { TestBed.resetTestingModule(); - TestBed.configureTestingModule({ providers: [provideRouter(routes)] }); + TestBed.configureTestingModule(routerTestModule(routes)); router = TestBed.inject(Router); navigations = []; cleanup = []; @@ -86,12 +87,83 @@ describe('instrumented class guards and resolvers shared by several routes', () ]); }); - it('names the route for canMatch and canActivateChild runs', async () => { - await router.navigateByUrl('/m2'); - expect(last().runs).toEqual([expect.objectContaining({ kind: 'canMatch', route: '/m2' })]); - await router.navigateByUrl('/p2/c'); - expect(last().runs).toEqual([ - expect.objectContaining({ kind: 'canActivateChild', route: '/p2' }), - ]); + // canMatch exists from 14.1. + it.runIf(angularAtLeast('14.1'))( + 'names the route for canMatch and canActivateChild runs', + async () => { + await router.navigateByUrl('/m2'); + // Before 16.0 Angular matches routes twice (redirects, then recognition), running canMatch in each pass. + const run = expect.objectContaining({ kind: 'canMatch', route: '/m2' }); + expect(last().runs).toEqual(angularAtLeast('16.0') ? [run] : [run, run]); + await router.navigateByUrl('/p2/c'); + expect(last().runs).toEqual([ + expect.objectContaining({ kind: 'canActivateChild', route: '/p2' }), + ]); + }, + ); +}); + +class DenyGuard { + canActivate() { + return false; + } +} +Injectable({ providedIn: 'root' })(DenyGuard); + +class FailingResolver { + resolve(): never { + throw new Error('user service is down'); + } +} +Injectable({ providedIn: 'root' })(FailingResolver); + +// Class guards work on every version, so this is the router coverage for 12.0 to +// 14.1, which have neither functional guards nor provideRouter. +describe('navigation records from class guards and resolvers', () => { + let router: Router; + let navigations: NavigationRecord[]; + let stop: (() => void) | null; + const last = () => navigations[navigations.length - 1]!; + + beforeEach(async () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule( + routerTestModule([ + { path: '', component: Page }, + { path: 'a', component: Page, canActivate: [AuthGuard], resolve: { user: UserResolver } }, + { path: 'blocked', component: Page, canActivate: [DenyGuard] }, + { path: 'broken', component: Page, resolve: { user: FailingResolver } }, + { path: 'old', redirectTo: 'a' }, + ]), + ); + router = TestBed.inject(Router); + navigations = []; + stop = watchRouter(router as never, navigations, () => {}); + await router.navigateByUrl('/'); + }); + + afterEach(() => stop?.()); + + it('records a navigation with its guards and resolvers', async () => { + await router.navigateByUrl('/old'); + expect(last()).toMatchObject({ + url: '/old', + finalUrl: '/a', + outcome: 'succeeded', + guards: { names: ['AuthGuard'], passed: true }, + resolvers: { names: ['user: UserResolver'] }, + }); + }); + + it('records a guard that blocks and a resolver that fails', async () => { + await router.navigateByUrl('/blocked'); + expect(last()).toMatchObject({ + url: '/blocked', + outcome: 'cancelled', + guards: { names: ['DenyGuard'], passed: false }, + }); + await router.navigateByUrl('/broken').catch(() => {}); + expect(last()).toMatchObject({ url: '/broken', outcome: 'failed' }); + expect(last().reason).toContain('user service is down'); }); }); diff --git a/packages/devtools/src/__tests__/router-testing.ts b/packages/devtools/src/__tests__/router-testing.ts new file mode 100644 index 00000000..0cb1bd07 --- /dev/null +++ b/packages/devtools/src/__tests__/router-testing.ts @@ -0,0 +1,43 @@ +import { APP_BASE_HREF } from '@angular/common'; +import type { TestModuleMetadata } from '@angular/core/testing'; +import * as router from '@angular/router'; +import type { Routes } from '@angular/router'; +import { angularAtLeast } from './angular-version.ts'; + +type Feature = (...args: never[]) => unknown; + +const api = router as unknown as Record; + +/** + * A router function by name, or undefined on a version without it, for tests + * that run on every version the compat workflow installs. + */ +export function routerApi(name: string): T | undefined { + const value = api[name]; + return typeof value === 'function' ? (value as T) : undefined; +} + +/** + * The TestBed setup for `routes`: `provideRouter` with `features` from 14.2, + * `RouterModule.forRoot` before (where `features` must be empty). + */ +export function routerTestModule(routes: Routes, ...features: unknown[]): TestModuleMetadata { + const provideRouter = + routerApi<(routes: Routes, ...features: unknown[]) => unknown>('provideRouter'); + if (provideRouter) return { providers: [provideRouter(routes, ...features) as never] }; + if (features.length) throw new Error('Router features need provideRouter (Angular 14.2+)'); + return { + imports: [router.RouterModule.forRoot(routes)], + providers: [{ provide: APP_BASE_HREF, useValue: '/' }], + }; +} + +/** Functional guards and resolvers, and `provideRouter`, exist from 14.2. */ +export const functionalRouter = angularAtLeast('14.2'); + +/** + * The overlay reads providers and environment injectors through + * `ɵgetInjectorProviders`, which Angular publishes from 16.2. Before it the + * router setup is reported in `events-only` mode. + */ +export const injectorProviders = angularAtLeast('16.2'); diff --git a/packages/devtools/src/__tests__/signal-graph-real.test.ts b/packages/devtools/src/__tests__/signal-graph-real.test.ts index d4c6abab..7a28c195 100644 --- a/packages/devtools/src/__tests__/signal-graph-real.test.ts +++ b/packages/devtools/src/__tests__/signal-graph-real.test.ts @@ -18,7 +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'; +import { angularAtLeast, STANDALONE } from './angular-version.ts'; class TripStore { readonly trips = signal(['Lisbon'], { debugName: 'trips' }); @@ -69,6 +69,7 @@ class TripPage { } Component({ + ...STANDALONE, selector: 'app-root', template: `{{ city() }} {{ weather.value() }} {{ weather.status() }} {{ forecast.error() }} {{ unnamed.value() }} {{ airport.value() }} {{ airport.statusCode() }} {{ selected() }}`, diff --git a/packages/devtools/src/__tests__/signal-history.test.ts b/packages/devtools/src/__tests__/signal-history.test.ts index 3de3d88a..334afa24 100644 --- a/packages/devtools/src/__tests__/signal-history.test.ts +++ b/packages/devtools/src/__tests__/signal-history.test.ts @@ -361,6 +361,12 @@ describe('installSignalWriteHook', () => { expect(await installSignalWriteHook(vi.fn(), async () => ({}) as never)).toBeNull(); }); + it('returns null with the stub the Vite plugin uses before Angular 17', async () => { + expect( + await installSignalWriteHook(vi.fn(), () => import('../signals-stub.ts') as never), + ).toBeNull(); + }); + it('returns a restore function when the hook installs', async () => { expect(await installSignalWriteHook(vi.fn(), async () => fakeCore())).toBeTypeOf('function'); }); diff --git a/packages/devtools/src/__tests__/vite-signals-stub.test.ts b/packages/devtools/src/__tests__/vite-signals-stub.test.ts new file mode 100644 index 00000000..5e718a19 --- /dev/null +++ b/packages/devtools/src/__tests__/vite-signals-stub.test.ts @@ -0,0 +1,49 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import pangularVite from '../vite.ts'; +import { + SIGNAL_PRIMITIVES, + angularCoreMajor, + signalPrimitivesAlias, +} from '../vite-signals-stub.ts'; + +/** An app folder whose node_modules holds `@angular/core` at `version`, or nothing. */ +function app(version?: string): string { + const root = mkdtempSync(join(tmpdir(), 'pangular-signals-')); + writeFileSync(join(root, 'package.json'), '{"name":"app","private":true}'); + if (version) { + const core = join(root, 'node_modules', '@angular', 'core'); + mkdirSync(core, { recursive: true }); + writeFileSync(join(core, 'package.json'), JSON.stringify({ name: '@angular/core', version })); + } + return root; +} + +describe('the signal primitives stub for Angular before 17', () => { + it('reads the installed @angular/core major', () => { + expect(angularCoreMajor(app('15.2.10'))).toBe(15); + expect(angularCoreMajor(app('17.3.12'))).toBe(17); + expect(angularCoreMajor(app())).toBeUndefined(); + }); + + it('points the primitives entry at the stub only before 17.0', () => { + const [alias] = signalPrimitivesAlias(app('16.2.12')); + expect(alias?.find).toBe(SIGNAL_PRIMITIVES); + expect(String(alias?.replacement)).toMatch(/signals-stub\.(ts|mjs)$/); + expect(signalPrimitivesAlias(app('12.2.17'))).toHaveLength(1); + expect(signalPrimitivesAlias(app('17.0.0'))).toEqual([]); + expect(signalPrimitivesAlias(app('22.1.7'))).toEqual([]); + expect(signalPrimitivesAlias(app())).toEqual([]); + }); + + it('adds the alias through the Vite plugin config hook', () => { + const plugin = pangularVite(); + const config = plugin.config as (user: { root?: string }) => unknown; + expect(config({ root: app('15.2.10') })).toMatchObject({ + resolve: { alias: [{ find: SIGNAL_PRIMITIVES }] }, + }); + expect(config({ root: app('20.0.7') })).toBeUndefined(); + }); +}); diff --git a/packages/devtools/src/forms.ts b/packages/devtools/src/forms.ts index 2240acb5..66cab960 100644 --- a/packages/devtools/src/forms.ts +++ b/packages/devtools/src/forms.ts @@ -293,9 +293,10 @@ function read(fn: () => T, fallback: T): T { export function isAbstractControl(value: unknown): value is AnyRecord { if (!value || typeof value !== 'object') return false; const v = value as AnyRecord; + // `FormControl.getRawValue` only exists from 14.0, so it can't tell controls apart. return read( () => - typeof v['getRawValue'] === 'function' && + typeof v['setValue'] === 'function' && typeof v['setParent'] === 'function' && typeof v['updateValueAndValidity'] === 'function' && 'status' in v, diff --git a/packages/devtools/src/http-cache-key.ts b/packages/devtools/src/http-cache-key.ts index e6c6a42f..7337c9d5 100644 --- a/packages/devtools/src/http-cache-key.ts +++ b/packages/devtools/src/http-cache-key.ts @@ -103,5 +103,8 @@ export function transferCacheKeys(req: HttpRequest): string[] { keys.add(sha256Hex(parts.join('|'))); keys.add(legacyHash(parts.join('|'))); } + // 16.0 to 17.0 leave the body out: `method.responseType.url?params`. + const params = legacyParams(req.params as ParamsLike); + keys.add(legacyHash(`${req.method}.${req.responseType}.${req.url}?${params}`)); return [...keys]; } diff --git a/packages/devtools/src/ngrx-collector.ts b/packages/devtools/src/ngrx-collector.ts index d49e65bd..f980846d 100644 --- a/packages/devtools/src/ngrx-collector.ts +++ b/packages/devtools/src/ngrx-collector.ts @@ -1,4 +1,4 @@ -import { untracked } from '@angular/core'; +import * as angularCore from '@angular/core'; import { isRedactedKey, REDACTED } from './forms-privacy.ts'; import { documentTree, type HostTree } from './host-tree.ts'; import { className, tokenName } from './injector-tree.ts'; @@ -21,6 +21,16 @@ import { } from './ngrx-shared.ts'; import { registeredPatchState, registeredWatchState } from './ngrx-register.ts'; +/** + * Angular's `untracked` (16.0+), read at runtime: a named import of it fails + * the build of an app on Angular 12 to 15 under webpack's strict ESM rules. + * Signals don't exist before 16.0, so reading the value directly is the same. + */ +function untracked(body: () => T): T { + const fn = Reflect.get(angularCore, 'untracked') as ((body: () => T) => T) | undefined; + return typeof fn === 'function' ? fn(body) : body(); +} + type AnyRecord = Record; export interface NgrxDebugNg { diff --git a/packages/devtools/src/router-actions.ts b/packages/devtools/src/router-actions.ts index be4effeb..7997e2ad 100644 --- a/packages/devtools/src/router-actions.ts +++ b/packages/devtools/src/router-actions.ts @@ -2,6 +2,7 @@ import { isRouterLink } from './router-links.ts'; import { CANCEL_CODES, EventType, + routerEventType, MAX_DEPTH, clip, componentName, @@ -112,7 +113,7 @@ export function onGuardsCheckStart(router: AnyRecord, run: () => void): () => vo const subscription = read( () => events?.['subscribe']((event: AnyRecord) => { - if (read(() => event['type'] === EventType.GuardsCheckStart, false)) run(); + if (routerEventType(event) === EventType.GuardsCheckStart) run(); }) as { unsubscribe(): void } | undefined, undefined, ); @@ -600,17 +601,33 @@ function waitForNavigation( }); } +/** Whether `value` can be subscribed to, like an RxJS subject. */ +function subscribable(value: unknown): value is AnyRecord { + return !!value && typeof (value as AnyRecord)['subscribe'] === 'function'; +} + +/** + * The stream of the root pending tasks the Router holds as `pendingTasks`: + * `hasPendingTasksObservable` from 20.0, the `hasPendingTasks` subject from + * 16.0 to 19.x (in 20.0 that name is a boolean). Before 16.0 the Router has none. + */ +function pendingTasksOf(router: AnyRecord): AnyRecord | null { + const tasks = read(() => router['pendingTasks'] as AnyRecord | undefined, undefined); + if (!tasks) return null; + const current = read(() => tasks['hasPendingTasksObservable'], null); + if (subscribable(current)) return current; + const legacy = read(() => tasks['hasPendingTasks'], null); + return subscribable(legacy) ? legacy : null; +} + /** * Waits until the app has no pending tasks, the same signal as * `ApplicationRef.whenStable()`: both read the root `PendingTasksInternal`, - * which the Router holds as `pendingTasks`. + * which the Router holds as `pendingTasks`. Null when the Router has none. */ export function waitForStable(router: AnyRecord, ms: number): Promise { - const pending = read( - () => router['pendingTasks']['hasPendingTasksObservable'] as AnyRecord, - null, - ); - if (typeof pending?.['subscribe'] !== 'function') return Promise.resolve(null); + const pending = pendingTasksOf(router); + if (!pending) return Promise.resolve(null); return new Promise((resolve) => { let done = false; let subscription: { unsubscribe(): void } | null = null; diff --git a/packages/devtools/src/router-setup.ts b/packages/devtools/src/router-setup.ts index c033b6e1..95757423 100644 --- a/packages/devtools/src/router-setup.ts +++ b/packages/devtools/src/router-setup.ts @@ -210,7 +210,13 @@ export function detectSetup( : 'off'; const strategies: Record = { - locationStrategy: strategyName(read(() => router['location']['_locationStrategy'], null)), + // `Location` holds it as `_platformStrategy` before 14.0 and `_locationStrategy` from it. + locationStrategy: strategyName( + read( + () => router['location']['_locationStrategy'] ?? router['location']['_platformStrategy'], + null, + ), + ), titleStrategy: strategyName(read(() => transitions?.['titleStrategy'], null)), routeReuseStrategy: strategyName(read(() => router['routeReuseStrategy'], null)), urlHandlingStrategy: strategyName(read(() => router['urlHandlingStrategy'], null)), diff --git a/packages/devtools/src/router.ts b/packages/devtools/src/router.ts index 85d6f2e6..40405b2a 100644 --- a/packages/devtools/src/router.ts +++ b/packages/devtools/src/router.ts @@ -137,6 +137,27 @@ export const EventType = { NavigationSkipped: 16, } as const; +/** Angular's `EventType` numbers by event class, for routers whose events have no `type`. */ +const EVENT_TYPES: Record = { + ...EventType, + ChildActivationEnd: 12, + ActivationEnd: 14, +}; + +/** + * The `EventType` of a router event. Events carry it as `type` from 14.0; + * before, every public event class names itself in `toString()`, such as + * `NavigationStart(id: 1, url: '/')`, which survives minification. + */ +export function routerEventType(event: AnyRecord): number | undefined { + const type = read(() => event['type'], undefined); + if (typeof type === 'number') return type; + if (type !== undefined) return undefined; + const text = read(() => String(event['toString']?.() ?? ''), ''); + const name = /^([A-Za-z]+)\(/.exec(text)?.[1]; + return name ? EVENT_TYPES[name] : undefined; +} + interface NavState { secrets: string[]; guardsAt?: number; @@ -574,6 +595,27 @@ function isBeforeActivate(event: AnyRecord, nav: NavigationRecord | undefined): return !!nav && stateOf(nav).recognizedAt !== undefined; } +/** + * Before 18.0 the router emits no redirect request, so a guard or resolver + * redirect shows only as a cancel whose reason names the target + * (`Redirecting to "/login"`), followed at once by the navigation to it. + */ +function linkCancelledRedirect(navigations: NavigationRecord[], nav: NavigationRecord): void { + const source = navigations[navigations.length - 1]; + if ( + !source || + source.outcome !== 'redirected' || + source.redirectTo !== undefined || + source.id !== nav.id - 1 || + !/^Redirecting to "/.test(source.reason ?? '') + ) { + return; + } + source.redirectTo = nav.url; + source.redirectKind = redirectKindOf(source); + nav.redirectedFrom = source.id; +} + function redirectKindOf(nav: NavigationRecord): string { const state = stateOf(nav); if (state.resolveAt !== undefined) return 'resolver'; @@ -807,7 +849,7 @@ export function applyRouterEvent( router?: AnyRecord, onLateChange?: () => void, ): boolean { - const type = read(() => event['type'] as number | undefined, undefined); + const type = routerEventType(event); const id = read(() => event['id'] as number, -1); const current = findPending(navigations); if (type === undefined) { @@ -872,6 +914,8 @@ export function applyRouterEvent( if (pendingRedirect) { nav.redirectedFrom = pendingRedirect.from; pendingRedirect = null; + } else { + linkCancelledRedirect(navigations, nav); } navigations.push(nav); return true; @@ -1055,10 +1099,16 @@ function urlOf(router: AnyRecord, tree: unknown): string | undefined { /** * The navigation that finished before the overlay subscribed, from * `router.lastSuccessfulNavigation` (a signal since Angular 20, a property - * before). It has no timing or guard details. + * before). 15.0 to 16.x keep it on `navigationTransitions` only. It has no timing or + * guard details. */ export function lastNavigationOf(router: AnyRecord, at: number): NavigationRecord | null { - const raw = read(() => router['lastSuccessfulNavigation'], null); + const raw = read( + () => + router['lastSuccessfulNavigation'] ?? + router['navigationTransitions']?.['lastSuccessfulNavigation'], + null, + ); const last = read(() => (typeof raw === 'function' ? raw() : raw) as AnyRecord | null, null); if (!last) return null; const rawUrl = urlOf(router, last['extractedUrl']) ?? urlOf(router, last['initialUrl']); diff --git a/packages/devtools/src/signal-history.ts b/packages/devtools/src/signal-history.ts index 515e638d..04a55f71 100644 --- a/packages/devtools/src/signal-history.ts +++ b/packages/devtools/src/signal-history.ts @@ -277,10 +277,24 @@ export function createSignalHistory( type SignalSetHook = ((node: RawSignalNode) => void) | null; +/** + * Loads Angular's signal primitives, a package entry from 17.0. webpack is + * told to leave the import alone, so an Angular 12 to 16 app (which has no + * such entry) still builds; in a webpack bundle the import then fails at run + * time and history falls back to samples. Vite and esbuild resolve it as + * usual, and the Vite plugin stubs it out before 17.0 (`signals-stub.ts`). + */ +function loadSignalPrimitives(): Promise<{ + setPostSignalSetFn: (fn: SignalSetHook) => SignalSetHook; +}> { + return import(/* webpackIgnore: true */ '@angular/core/primitives/signals') as never; +} + export async function installSignalWriteHook( onWrite: (node: RawSignalNode) => void, - load: () => Promise<{ setPostSignalSetFn: (fn: SignalSetHook) => SignalSetHook }> = () => - import('@angular/core/primitives/signals') as never, + load: () => Promise<{ + setPostSignalSetFn: (fn: SignalSetHook) => SignalSetHook; + }> = loadSignalPrimitives, ): Promise<(() => void) | null> { let setHook: (fn: SignalSetHook) => SignalSetHook; try { diff --git a/packages/devtools/src/signals-stub.ts b/packages/devtools/src/signals-stub.ts new file mode 100644 index 00000000..35e29aa9 --- /dev/null +++ b/packages/devtools/src/signals-stub.ts @@ -0,0 +1,4 @@ +// Stands in for `@angular/core/primitives/signals` on Angular before 17.0, +// which has no such entry: the Vite plugin points the overlay's import here, +// and the signal write hook (signal-history.ts) then falls back to samples. +export const setPostSignalSetFn = undefined; diff --git a/packages/devtools/src/vite-signals-stub.ts b/packages/devtools/src/vite-signals-stub.ts new file mode 100644 index 00000000..7b839db4 --- /dev/null +++ b/packages/devtools/src/vite-signals-stub.ts @@ -0,0 +1,38 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Alias } from 'vite'; + +/** The entry the overlay's signal write hook imports (signal-history.ts). */ +export const SIGNAL_PRIMITIVES = '@angular/core/primitives/signals'; + +/** The `@angular/core` major the app at `root` installs, or undefined when there is none. */ +export function angularCoreMajor(root: string): number | undefined { + try { + const require = createRequire(join(root, 'package.json')); + const file = require.resolve('@angular/core/package.json'); + const version = (JSON.parse(readFileSync(file, 'utf8')) as { version?: unknown }).version; + const major = parseInt(String(version), 10); + return Number.isFinite(major) ? major : undefined; + } catch { + return undefined; + } +} + +/** The stub module next to this one: `.ts` in the workspace, `.mjs` in the published package. */ +function stubPath(): string { + const file = import.meta.url.endsWith('.ts') ? './signals-stub.ts' : './signals-stub.mjs'; + return fileURLToPath(new URL(file, import.meta.url)); +} + +/** + * Angular ships `@angular/core/primitives/signals` from 17.0. On an older app + * Vite could not resolve the overlay's import of it, so the plugin points it + * at a stub, and signal history falls back to samples. Nothing changes from 17.0. + */ +export function signalPrimitivesAlias(root: string): Alias[] { + const major = angularCoreMajor(root); + if (major === undefined || major >= 17) return []; + return [{ find: SIGNAL_PRIMITIVES, replacement: stubPath() }]; +} diff --git a/packages/devtools/src/vite.ts b/packages/devtools/src/vite.ts index 36da776c..61a8d480 100644 --- a/packages/devtools/src/vite.ts +++ b/packages/devtools/src/vite.ts @@ -22,6 +22,7 @@ import { } from './hub-gate.ts'; import { pickPangularConfig, resolvePangularConfig, type PangularConfig } from './config.ts'; import { overlayTags } from './vite-overlay.ts'; +import { signalPrimitivesAlias } from './vite-signals-stub.ts'; import { terminalsEnabled } from './hub-terminals.ts'; export type { PangularConfig } from './config.ts'; @@ -123,6 +124,10 @@ export default function pangularVite(options: PangularViteOptions = {}): Plugin name: 'pangular', apply: 'serve', enforce: 'pre', + config(user) { + const alias = signalPrimitivesAlias(user.root ?? process.cwd()); + return alias.length ? { resolve: { alias } } : undefined; + }, // `pre`, so Vite's own HTML pass resolves the injected import like any other. transformIndexHtml: { order: 'pre', diff --git a/packages/devtools/tsdown.config.ts b/packages/devtools/tsdown.config.ts index 1a57cc46..2a9a2967 100644 --- a/packages/devtools/tsdown.config.ts +++ b/packages/devtools/tsdown.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ 'overlay-nativescript': 'src/overlay-nativescript.ts', 'overlay-angular-native': 'src/overlay-angular-native.ts', vite: 'src/vite.ts', + 'signals-stub': 'src/signals-stub.ts', http: 'src/http.ts', hub: 'src/hub.ts', cli: 'src/cli.ts', diff --git a/packages/devtools/vitest.compat.config.ts b/packages/devtools/vitest.compat.config.ts index cfbea077..d46b1b97 100644 --- a/packages/devtools/vitest.compat.config.ts +++ b/packages/devtools/vitest.compat.config.ts @@ -1,12 +1,14 @@ import { createRequire } from 'node:module'; import { defineConfig, mergeConfig } from 'vitest/config'; import base from './vitest.config.ts'; +import { signalPrimitivesAlias } from './src/vite-signals-stub.ts'; const require = createRequire(import.meta.url); const angular = Number(require('@angular/core/package.json').version.split('.')[0]); const pageSide = [ 'analog-scan', + 'angular-real-app', 'cd-recorder', 'cd-recorder-real', 'change-detection', @@ -64,5 +66,8 @@ export default mergeConfig( ), setupFiles: angular >= 21 ? [] : ['src/__tests__/zone-setup.ts'], }, + // What the Vite plugin does for an app before Angular 17, which has no + // `@angular/core/primitives/signals` for the signal write hook to import. + resolve: { alias: signalPrimitivesAlias(import.meta.dirname) }, }), ); From 522f0d2f9a0632115fe333d465f5cb299353df2d Mon Sep 17 00:00:00 2001 From: erKam Date: Tue, 13 Oct 2026 02:37:22 +0000 Subject: [PATCH 2/2] fix(router): link code-less redirect cancels and resolve a relative Vite root Angular 12 to 14 send NavigationCancel without a code, so a guard redirect was recorded as cancelled and never linked to its target. The Vite plugin also passed a relative root to createRequire, which throws, so the signals stub alias was dropped. --- .../content/getting-started/installation.md | 2 +- .../src/__tests__/router-regressions.test.ts | 26 +++++++++++++++++++ packages/devtools/src/router.ts | 14 ++++++---- packages/devtools/src/vite.ts | 4 ++- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/docs/src/content/getting-started/installation.md b/apps/docs/src/content/getting-started/installation.md index b942c453..219c5073 100644 --- a/apps/docs/src/content/getting-started/installation.md +++ b/apps/docs/src/content/getting-started/installation.md @@ -66,7 +66,7 @@ On Angular 12 to 19 the overlay degrades as the table above shows: | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | Overlay import in `main.ts` | Works with the webpack-based `@angular-devkit/build-angular:browser` builder. Signal history uses samples, since `@angular/core/primitives/signals` exists from 17.0. | Works with `browser` and `application`. Under webpack, signal history uses samples. | | Vite plugin (Analog) | Works. The plugin points `@angular/core/primitives/signals` at a stub before 17.0. | Works. | -| `initPangularHub()` in an Express server | Works when the server runs on Node.js 22 or later. On an older Node.js, use the standalone CLI. | Works. | +| `initPangularHub()` in an Express server | Works when the server runs on Node.js 22 or later. If the server must stay on an older Node.js, run the standalone CLI on Node.js 22 or later beside it instead. | Works. | | Standalone CLI next to `ng serve` | Works. Run `npx pangular dev` with Node.js 22 or later, and the app's own CLI with the Node.js version it supports. | Works. | | Chrome extension | Works on top of any of the setups above. | Works. | | `@pangular-inspector/devtools/http` | Not available. It imports `REQUEST` and `provideEnvironmentInitializer`, so the build fails. Leave it out; the HTTP timeline and fault rules need it. | Not available before 19.0, for the same reason. | diff --git a/packages/devtools/src/__tests__/router-regressions.test.ts b/packages/devtools/src/__tests__/router-regressions.test.ts index aa78bebd..777b2ae3 100644 --- a/packages/devtools/src/__tests__/router-regressions.test.ts +++ b/packages/devtools/src/__tests__/router-regressions.test.ts @@ -112,6 +112,32 @@ describe('router events', () => { expect(navigations.map((nav) => nav.trigger)).toEqual(['popstate', 'unknown']); }); + it('links a guard redirect from a cancel with no code, as Angular 12 to 14 send it', () => { + const navigations: NavigationRecord[] = []; + const router = { url: '/', routerState: { snapshot: null } }; + applyRouterEvent( + navigations, + { type: EventType.NavigationStart, id: 1, url: '/admin' }, + 0, + router, + ); + applyRouterEvent( + navigations, + { type: EventType.NavigationCancel, id: 1, url: '/admin', reason: 'Redirecting to "/login"' }, + 1, + router, + ); + applyRouterEvent( + navigations, + { type: EventType.NavigationStart, id: 2, url: '/login' }, + 2, + router, + ); + const [admin, login] = navigations; + expect(admin).toMatchObject({ outcome: 'redirected', code: 'Redirect', redirectTo: '/login' }); + expect(login).toMatchObject({ url: '/login', redirectedFrom: admin.id }); + }); + it('reports the title set after NavigationEnd', async () => { const navigations: NavigationRecord[] = []; const router = { url: '/', routerState: { snapshot: null } }; diff --git a/packages/devtools/src/router.ts b/packages/devtools/src/router.ts index 40405b2a..3736b769 100644 --- a/packages/devtools/src/router.ts +++ b/packages/devtools/src/router.ts @@ -1048,7 +1048,14 @@ export function applyRouterEvent( } case EventType.NavigationCancel: { const code = read(() => event['code'] as number | undefined, undefined); - nav.code = code === undefined ? undefined : (CANCEL_CODES[code] ?? String(code)); + const rawReason = String(event['reason'] ?? '').replace(/^NavigationCancelingError: /, ''); + // Before the cancel codes (15.x), a guard redirect shows only in its reason. + const legacyRedirect = code === undefined && /^Redirecting to "/.test(rawReason); + nav.code = legacyRedirect + ? 'Redirect' + : code === undefined + ? undefined + : (CANCEL_CODES[code] ?? String(code)); nav.outcome = nav.code === 'Redirect' ? 'redirected' : 'cancelled'; const byGuard = nav.code === 'Redirect' || nav.code === 'GuardRejected'; if (byGuard && nav.guards && nav.guards.passed === undefined) { @@ -1062,10 +1069,7 @@ export function applyRouterEvent( nav.reason = nav.code === 'Redirect' && state.handled?.redirected ? state.handled.reason - : errorText( - String(event['reason'] ?? '').replace(/^NavigationCancelingError: /, ''), - state.secrets, - ); + : errorText(rawReason, state.secrets); finish(nav, at, router); return true; } diff --git a/packages/devtools/src/vite.ts b/packages/devtools/src/vite.ts index 61a8d480..a0282062 100644 --- a/packages/devtools/src/vite.ts +++ b/packages/devtools/src/vite.ts @@ -1,3 +1,4 @@ +import { resolve } from 'node:path'; import { isAuthTokens, type PangularAuthTokens } from './auth-tokens.ts'; import type { Plugin } from 'vite'; import { normalizeHubBase } from '@devframes/hub/constants'; @@ -125,7 +126,8 @@ export default function pangularVite(options: PangularViteOptions = {}): Plugin apply: 'serve', enforce: 'pre', config(user) { - const alias = signalPrimitivesAlias(user.root ?? process.cwd()); + // Vite allows a relative `root`, and `createRequire` needs an absolute path. + const alias = signalPrimitivesAlias(resolve(process.cwd(), user.root ?? '')); return alias.length ? { resolve: { alias } } : undefined; }, // `pre`, so Vite's own HTML pass resolves the injected import like any other.