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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ inputs:
description: Passed to setup-node, which writes an .npmrc for it. Only the release sets it.
required: false
default: ''
node-version:
description: A Node.js version to use in place of the one in .nvmrc.
required: false
default: ''

runs:
using: composite
Expand All @@ -16,7 +20,8 @@ runs:

- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
node-version: ${{ inputs.node-version }}
node-version-file: ${{ inputs.node-version == '' && '.nvmrc' || '' }}
cache: pnpm
registry-url: ${{ inputs.registry-url }}

Expand Down
63 changes: 63 additions & 0 deletions .github/workflows/compat.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Angular compatibility

on:
push:
branches: [main]
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
page-side:
name: Angular ${{ matrix.angular }} on Node.js 22
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- angular: '20.0'
range: '~20.0.0'
- angular: '21'
range: '^21.0.0'
- angular: '22'
range: '^22.0.0'
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false

- uses: ./.github/actions/setup
with:
node-version: 22

- name: Install Angular ${{ matrix.angular }}
env:
RANGE: ${{ matrix.range }}
run: |
{
echo 'overrides:'
for pkg in animations common compiler core elements forms platform-browser platform-browser-dynamic platform-server router; do
echo " '@angular/$pkg': '$RANGE'"
done
} >> pnpm-workspace.yaml
pnpm install --no-frozen-lockfile

- name: List the versions under test
run: |
{
echo "- Node.js $(node --version)"
for pkg in core common router forms platform-browser; do
echo "- @angular/$pkg $(node -p "require('@angular/$pkg/package.json').version")"
done
} | tee -a "$GITHUB_STEP_SUMMARY"

- name: Test the page side
run: pnpm exec vitest run --root packages/devtools --config vitest.compat.config.ts
14 changes: 14 additions & 0 deletions apps/docs/src/content/contributing/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,20 @@ pnpm commit:check # Commit messages on your branch

A separate `axe` job in the same workflow installs Chromium and runs `pnpm test:axe`.

### Angular compatibility

`.github/workflows/compat.yml` runs the page-side tests on the oldest and newest Angular versions the peer range allows. It runs on pushes to `main`, every Monday at 06:00 UTC, and on demand from the **Actions** tab. It doesn't run on pull requests.

| Job | Angular range | Node.js |
| ------------ | ------------- | ------- |
| Angular 20.0 | `~20.0.0` | 22 |
| Angular 21 | `^21.0.0` | 22 |
| Angular 22 | `^22.0.0` | 22 |

Each job appends `overrides` for the `@angular/*` runtime packages to `pnpm-workspace.yaml`, reinstalls, and runs `vitest` with `packages/devtools/vitest.compat.config.ts`. That config picks the tests that render a real Angular app and leaves out the panel and server tests. It adds the Signal Forms tests on Angular 22 and later, and loads `zone.js` before Angular 21, where `TestBed` still expects it. A test that needs a feature from a later release checks the version with `angularAtLeast()` from `src/__tests__/angular-version.ts` and skips on older ones.

To reproduce a job, add the same `overrides` block to `pnpm-workspace.yaml`, run `pnpm install --no-frozen-lockfile`, then `pnpm exec vitest run --root packages/devtools --config vitest.compat.config.ts`. Don't commit the changed lockfile.

### Pull request checks

Two more workflows run on pull requests. Both only warn. They never fail the pull request.
Expand Down
18 changes: 16 additions & 2 deletions apps/docs/src/content/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ The devtools ship as one npm package, `@pangular-inspector/devtools`. It contain

<ngmd-card-grid columns="3">
<ngmd-card icon="terminal" title="Node.js 22 or later">
The package declares <code>node &gt;=22</code> in its <code>engines</code> field. CI runs on Node.js 24.
The package declares <code>node &gt;=22</code> in its <code>engines</code> field. CI tests Node.js 22 and 24.
</ngmd-card>
<ngmd-card icon="code" title="Angular 20 or later">
<code>&#64;angular/core</code> and <code>&#64;angular/common</code> 20 and newer are supported. CI runs the tests on Angular 22.
<code>&#64;angular/core</code> and <code>&#64;angular/common</code> 20 and newer are supported, with no upper bound. See <a href="#tested-versions">Tested versions</a>.
</ngmd-card>
<ngmd-card icon="box" title="Package manager">
pnpm, npm, yarn or bun. Any of the four.
Expand All @@ -29,6 +29,20 @@ The devtools ship as one npm package, `@pangular-inspector/devtools`. It contain
Live data comes from Angular's debug API (<code>window.ng</code>). Production builds remove it, so the live tabs stay empty there. Run your app in development mode while you inspect it.
</ngmd-callout>

### Tested versions

The peer range is `@angular/* >=20` with no upper bound. CI tests these versions:

| Angular | Node.js | What runs | When |
| ----------------------- | ------- | ------------------------------------------------------------------------- | ------------------------------------- |
| 20.0 (lowest supported) | 22 | The page-side tests against a real Angular app | Every push to `main`, and weekly |
| 21 (latest 21.x) | 22 | The page-side tests against a real Angular app | Every push to `main`, and weekly |
| 22 (latest 22.x) | 22 | The page-side tests, including Signal Forms | Every push to `main`, and weekly |
| 22 (repository version) | 24 | Every package and panel test, the type checks and axe | Every pull request and push to `main` |
| Newest the ranges allow | 24 | A fresh Angular CLI app and a fresh Analog app with the package installed | Weekly |

Some features need a later Angular release than 20.0. The inspector pages list them, for example [abort and probe](../inspectors/router.md#abort-and-probe-need-angular-20-2) (20.2) and the live [signal graph](../inspectors/signals.md#debug-apis) (20.1). The Signal Forms tests run on Angular 22 and later, where Signal Forms are stable.

## Install the package

```bash group="install" name="pnpm" image="https://cdn.simpleicons.org/pnpm/F69220" active
Expand Down
10 changes: 10 additions & 0 deletions packages/devtools/src/__tests__/angular-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { VERSION } from '@angular/core';

export function angularAtLeast(version: string): boolean {
const have = VERSION.full.split(/[.-]/).map((part) => parseInt(part, 10));
const want = version.split('.').map((part) => parseInt(part, 10));
for (let i = 0; i < want.length; i++) {
if ((have[i] || 0) !== want[i]) return (have[i] || 0) > want[i];
}
return true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { watchRouter, type NavigationRecord } from '../router.ts';
import { runAction, waitForStable } from '../router-actions.ts';
import { angularAtLeast } from './angular-version.ts';

TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());

Expand Down Expand Up @@ -117,7 +118,7 @@ describe('router actions that wait on the app', () => {
expect(await waitForStable({} as never, 30)).toBeNull();
});

it.each([
it.skipIf(!angularAtLeast('20.2.0')).each([
['a UrlTree', '/members'],
['a RedirectCommand', '/vip'],
['an error the error handler redirects', '/crash'],
Expand Down
68 changes: 68 additions & 0 deletions packages/devtools/src/__tests__/router-angular20.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { applyRouterEvent, type NavigationRecord, type RouterDebugApi } from '../router.ts';
import { detectSetup } from '../router-setup.ts';

class RouterScroller {
constructor(readonly options: Record<string, unknown>) {}
}

class InjectionToken {
constructor(readonly _desc: string) {}
}

function startWith(currentNavigation: unknown): NavigationRecord | undefined {
const navigations: NavigationRecord[] = [];
const router = { url: '/', navigationTransitions: { currentNavigation } };
applyRouterEvent(
navigations,
{ type: 0, id: 1, url: '/users/2', navigationTrigger: 'imperative' },
0,
router,
);
return navigations[0];
}

describe('router collector on Angular 20.0 internals', () => {
const extras = { replaceUrl: true, state: { from: 'list' } };

it('lists the extras when currentNavigation is a plain field, as on Angular 20.0 and 20.1', () => {
expect(startWith({ extras })?.extras).toEqual(['replaceUrl', 'state: from']);
});

it('lists the extras when currentNavigation is a signal', () => {
expect(startWith(() => ({ extras }))?.extras).toEqual(['replaceUrl', 'state: from']);
});

it('finds the forRoot scroller behind a token without a description', () => {
const root = document.createElement('app-root');
const token = new InjectionToken('');
const scroller = new RouterScroller({ anchorScrolling: 'enabled' });
const injector = { get: (wanted: unknown) => (wanted === token ? scroller : null) };
const provider = {
provide: token,
useFactory: function () {
return new RouterScroller({});
},
};
const ng: RouterDebugApi = {
getInjector: () => injector,
ɵgetInjectorResolutionPath: () => [injector],
ɵgetInjectorProviders: () => [{ token, provider }] as never,
};
const setup = detectSetup(ng, { options: {} }, 1, root);
expect(setup.features['scroller']).toBe('on');
});

it('leaves the scroller off when no provider creates one', () => {
const root = document.createElement('app-root');
const token = new InjectionToken('');
const injector = { get: () => ({ options: {} }) };
const ng: RouterDebugApi = {
getInjector: () => injector,
ɵgetInjectorResolutionPath: () => [injector],
ɵgetInjectorProviders: () => [{ token, provider: { provide: token, useValue: 1 } }] as never,
};
expect(detectSetup(ng, { options: {} }, 1, root).features['scroller']).toBe('off');
});
});
31 changes: 19 additions & 12 deletions packages/devtools/src/__tests__/router-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ import {
storedInstrumented,
} from '../router-actions.ts';
import { touchRouterPage } from '../rpc/router-tools.ts';
import { angularAtLeast } from './angular-version.ts';

TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());

class Nav {
readonly router = inject(Router);
readonly nullOptions = angularAtLeast('22.0.0');
go() {
void this.router.navigateByUrl('/b');
}
Expand All @@ -48,7 +50,9 @@ Component({
<a routerLink="/b" routerLinkActive="on" [routerLinkActiveOptions]="{ queryParams: 'exact' }"
>Partial</a
>
<a routerLink="/b" routerLinkActive="on" [routerLinkActiveOptions]="$any(null)">Never</a>
@if (nullOptions) {
<a routerLink="/b" routerLinkActive="on" [routerLinkActiveOptions]="$any(null)">Never</a>
}
`,
})(Nav);

Expand Down Expand Up @@ -118,16 +122,19 @@ describe('router audit fixes on a real Router', () => {
expect(matchOptionsOf(null)).toBeNull();
});

it('agrees with RouterLinkActive for partial match options and null options', async () => {
const harness = await RouterTestingHarness.create();
await harness.navigateByUrl('/b?tab=1');
harness.detectChanges();
const links = linksOf(ng(), router as never);
const partial = links.find((l) => l.text === 'Partial')!;
const never = links.find((l) => l.text === 'Never')!;
expect(partial).toMatchObject({ active: false, linkActive: false });
expect(never).toMatchObject({ active: false, linkActive: false });
});
it.skipIf(!angularAtLeast('22.0.0'))(
'agrees with RouterLinkActive for partial match options and null options',
async () => {
const harness = await RouterTestingHarness.create();
await harness.navigateByUrl('/b?tab=1');
harness.detectChanges();
const links = linksOf(ng(), router as never);
const partial = links.find((l) => l.text === 'Partial')!;
const never = links.find((l) => l.text === 'Never')!;
expect(partial).toMatchObject({ active: false, linkActive: false });
expect(never).toMatchObject({ active: false, linkActive: false });
},
);

it('only credits a click to RouterLink when the element carries the directive', async () => {
cleanup.push(captureCallers(router as never, navigations, ng()));
Expand Down Expand Up @@ -173,7 +180,7 @@ describe('router audit fixes on a real Router', () => {
]);
});

it('says what a probe did and did not run', async () => {
it.skipIf(!angularAtLeast('20.2.0'))('says what a probe did and did not run', async () => {
const probe = (await runAction(
router as never,
navigations,
Expand Down
Loading
Loading