diff --git a/.claude/skills/devtools-docs/SKILL.md b/.claude/skills/devtools-docs/SKILL.md index 02535d98..55d3a350 100644 --- a/.claude/skills/devtools-docs/SKILL.md +++ b/.claude/skills/devtools-docs/SKILL.md @@ -94,7 +94,7 @@ Raw HTML in markdown. Always write explicit closing tags; never self-close custo | `ngmd-workflow` > `ngmd-step` | Ordered steps. | step `title` | | `ngmd-accordion` > `ngmd-accordion-item` | FAQ. | item `title`, `open` | | `ngmd-pill-row` > `ngmd-pill` | Related links at the end of a page. | pill `href`, `title` | -| `ngmd-badge` | Status chip next to a heading. | `variant`: new, updated, alpha, beta, stable, deprecated | +| `ngmd-badge` | Status chip next to a heading. | `variant`: new, updated, alpha, beta, stable, deprecated, action, destructive | | `ngmd-tabs` > `ngmd-tab` | Alternatives a code group can't express. | tab `title`, `icon`, `image` | | `ngmd-image`, `ngmd-video` | Screenshots, YouTube or Vimeo. | image `src`, `alt`, `caption`, `width`. video `src`, `title` | @@ -114,6 +114,7 @@ Rules: ## 6. Code samples - Always set the language. Put the file path in a comment on the first line: `// src/app/app.config.ts`. +- JSON samples get no path comment: JSON has no comments and the copy button copies the whole block. Name the file in the sentence above the sample instead. - Highlight lines with `{3}` or `{2,5-7}` after the language. Count the path comment as line 1. - Install commands use a code group: ` ```bash group="install" name="pnpm" active ` then npm, yarn, bun. - `file="path#L5-L20"` imports a real file (relative to `apps/docs`, nothing outside it). It is not a title. diff --git a/.gitattributes b/.gitattributes index dbf6ce47..a84ef1a6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,4 @@ *.zip binary extension/ui/** linguist-generated=true pnpm-lock.yaml linguist-generated=true +extension/ui/THIRD_PARTY_LICENSES.md -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfad349f..7776f178 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,6 @@ jobs: - uses: nrwl/nx-set-shas@v4 - - uses: nrwl/nx-set-shas@v4 - - name: Check formatting run: pnpm format:check @@ -66,7 +64,7 @@ jobs: node-22: name: Package tests on Node.js 22 - # `engines.node` allows 22, while the build job runs the `.nvmrc` version. + # `engines.node` allows 22.12 and later, while the build job runs the `.nvmrc` version. runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -96,3 +94,91 @@ jobs: - name: Check the panel with axe run: pnpm test:axe + + # The e2e jobs below are slow, so they run only when the package, the panel, the SSR demo, the + # tools docs (e2e:mcp reads them) or the scripts and fixtures they use change. A diff that cannot + # be computed (a new branch, a force push) runs them. + changes: + name: Changed paths + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + e2e: ${{ steps.diff.outputs.e2e }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - id: diff + env: + BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + run: | + paths=(packages/devtools app e2e src apps/docs/src/content/agents angular.json package.json + scripts/e2e-security.mjs scripts/e2e-mcp.mjs pnpm-lock.yaml .github/workflows/ci.yml) + if git diff --quiet "$BASE" HEAD -- "${paths[@]}"; then + echo "e2e=false" >> "$GITHUB_OUTPUT" + else + echo "e2e=true" >> "$GITHUB_OUTPUT" + fi + + e2e-security: + name: Security e2e + needs: changes + if: needs.changes.outputs.e2e == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + + # Playwright's own Chromium, as the axe job installs it. + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Build the package + run: pnpm devtools:build-pkg + + # Leak corpus, access gates and a production build, on ports 5231 to 5236. + - name: Check redaction and access + run: pnpm e2e:security + + - name: Keep the captured channels + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-security + path: dist/e2e-security + if-no-files-found: ignore + + e2e-mcp: + name: MCP e2e + needs: changes + if: needs.changes.outputs.e2e == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + # Builds the package and the SSR demo itself, then serves the demo on port 5150 (the variant + # hubs on 5151 to 5154) and checks every tool, resource and prompt, and agents/tools.md. + - name: Check the agent surface + run: pnpm e2e:mcp --report dist/e2e-mcp/report.json + + - name: Keep the report + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-mcp + path: dist/e2e-mcp + if-no-files-found: ignore diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..ebcd2b73 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,169 @@ +name: E2E + +# The slow end-to-end checks that do not fit a pull request: every panel view in a real browser, +# multi-tab and lifecycle resilience, and the install matrix from a packed tarball. Pull requests +# run the security and MCP checks (ci.yml), and the release runs a slice of the install matrix on +# the tarball it publishes (release.yml). See apps/docs/src/content/contributing/development.md. +on: + schedule: + # Mondays at 07:00 UTC, an hour after latest.yml and compat.yml. + - cron: '0 7 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + panel: + name: Panel views + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + # The SSR demo serves the panel built into packages/devtools/dist/public. + - name: Build the package and the demo + run: | + pnpm devtools:build-pkg + pnpm build --configuration development + + # The demo keeps the one-time code on unless PANGULAR_AUTH is false, and the panel check + # needs a connected panel. + - name: Start the SSR demo on port 4000 + run: | + PANGULAR_AUTH=false PORT=4000 node dist/pangular-inspector/server/server.mjs > "$RUNNER_TEMP/demo.log" 2>&1 & + for _ in $(seq 60); do + curl -sf http://localhost:4000/ > /dev/null && exit 0 + sleep 1 + done + cat "$RUNNER_TEMP/demo.log" + exit 1 + + # Every tab and sub-view, dark and light, at 1280 and 360 px: axe, overflow and page errors. + - name: Check every panel view + run: pnpm e2e:panel --base http://localhost:4000 --out dist/e2e-panel + + - name: Keep the screenshots and results + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-panel + path: dist/e2e-panel + if-no-files-found: ignore + + resilience: + name: Resilience + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Build the package and the demo + run: | + pnpm devtools:build-pkg + pnpm build --configuration development + + # Starts the demo on port 5401 itself. Two checks fail today on open issues and are marked + # as known failures in the script, so they print XFAIL and do not fail the job: + # - expiry: a crashed tab stays listed for about 150 s (#422); + # - stress: the forms collector keeps 100 children per group and does not say so (#423). + # The soak check (`soak --minutes 30`) is manual only. + - name: Check multitab, expiry, lifecycle and stress + run: pnpm e2e:resilience multitab expiry lifecycle stress --json dist/e2e-resilience.json + + - name: Keep the results + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-resilience + path: dist/e2e-resilience.json + if-no-files-found: ignore + + install: + name: Install ${{ matrix.name }} + runs-on: ubuntu-latest + timeout-minutes: 90 + strategy: + # Every cell reports, so one week's failures are all visible at once. + fail-fast: false + matrix: + include: + - name: angular-cli + args: --scenarios=angular-cli --pm=npm,pnpm --angular=20.3,21,22 --no-audit + - name: angular-cli-serve + args: --scenarios=angular-cli-serve --pm=npm,pnpm --angular=20.3,21,22 --no-audit + - name: angular-cli-nossr + args: --scenarios=angular-cli-nossr --pm=npm,pnpm --angular=20.3,21,22 --no-audit + - name: analog + args: --scenarios=analog --pm=pnpm --no-audit + - name: cli + args: --scenarios=cli --pm=npm,pnpm --no-audit + - name: package-audit + args: --audit-only + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + # Packs the package, publishes it to a throwaway local registry (port 4873, the apps on the + # ports after it) and installs it into fresh apps. A failed run keeps only the logs. + - name: Run the cells + run: >- + pnpm e2e:install ${{ matrix.args }} + --report=dist/e2e-install/report.json --logs=dist/e2e-install/logs + + - name: Keep the report and the logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-install-${{ matrix.name }} + path: dist/e2e-install + if-no-files-found: ignore + + report: + name: Report the failure + needs: [panel, resilience, install] + if: failure() && github.event_name == 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + # One open issue for the weekly run, found by its title. + issues: write + steps: + - env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + title="ci: the weekly e2e run fails" + body="A job of the weekly E2E workflow failed: $RUN. The run's artifacts hold the reports and logs." + number=$(gh issue list --state open --search "\"$title\" in:title" --json number,title \ + | jq -r --arg title "$title" 'map(select(.title == $title)) | .[0].number // empty') + if [ -n "$number" ]; then + gh issue comment "$number" --body "Still failing: $RUN" + else + gh issue create --title "$title" --body "$body" --label bug --label 'area: ci' + fi diff --git a/.github/workflows/latest.yml b/.github/workflows/latest.yml index 4ea98c1a..36dea9e4 100644 --- a/.github/workflows/latest.yml +++ b/.github/workflows/latest.yml @@ -1,10 +1,12 @@ name: Latest versions -# The package declares `@angular/core >=20` and `vite >=5`, and this repository pins the versions -# it tests on, so a new Angular, Analog or Vite release can break users between our releases -# without CI noticing (#100). Each week this makes fresh apps the way users do, on whatever those -# ranges resolve to that day, installs the package from a local registry, wires the documented -# setup, builds it and checks the hub answers. See apps/docs/src/content/contributing/publishing.md. +# The package declares `@angular/core >=12.2.0 <23.0.0` and `vite >=5`, and this repository pins +# the versions it tests on, so a new Angular, Analog or Vite release can break users between our +# releases without CI noticing (#100). Each week this makes fresh apps the way users do, on whatever +# those ranges resolve to that day, installs the package from a local registry, wires the +# documented setup, builds it and checks the hub answers (`pnpm verify:publish`). The release +# runs the same check on the tarball it publishes. See +# apps/docs/src/content/contributing/publishing.md#releasing. on: schedule: # Mondays at 06:00 UTC. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..8ba5f35a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,150 @@ +name: Release + +# Publishes @pangular-inspector/devtools to npm from a version tag, with provenance, after checking +# the exact tarball it publishes. Push a tag such as v1.0.0 on a commit of main whose CI passed, or +# run it from the Actions tab with that tag picked as the ref. See +# apps/docs/src/content/contributing/publishing.md#releasing. +on: + push: + tags: ['v*'] + # Run with "Use workflow from" set to the tag; a branch is refused. + workflow_dispatch: + +permissions: + contents: read + +concurrency: + # One release at a time, and never cancelled halfway through a publish. + group: release + cancel-in-progress: false + +jobs: + release: + name: Publish ${{ github.ref_name }} + runs-on: ubuntu-latest + timeout-minutes: 120 + # Protect this environment (required reviewers, tags only) in the repository settings. It holds + # the NPM_TOKEN secret. + environment: npm + permissions: + # gh release create + contents: write + # npm publish --provenance signs the attestation with the workflow's OIDC token. + id-token: write + # gh run list reads the CI runs of the tagged commit. + actions: read + env: + # Both triggers run on the tag itself: a tag push, or a manual run with the tag as the ref. + TAG: ${{ github.ref_name }} + steps: + - name: Check the run is for a tag + if: github.ref_type != 'tag' + run: | + echo "::error::Release runs only on a version tag, not on ${GITHUB_REF}. Pick the tag in \"Use workflow from\"." + exit 1 + + - uses: actions/checkout@v7 + with: + ref: ${{ github.ref }} + # The whole history, so the tagged commit can be checked against main. + fetch-depth: 0 + persist-credentials: false + + - name: Check the tag is on main + run: | + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "::error::$TAG points at $(git rev-parse --short HEAD), which is not on main. Release from main only." + exit 1 + fi + + # The full CI (ci.yml) must have passed on main for this exact commit, so a commit whose CI + # failed, or is still running, is never published. + - name: Check CI passed on the tagged commit + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + sha=$(git rev-parse HEAD) + passed=$(gh run list --workflow ci.yml --commit "$sha" --branch main --event push \ + --status success --json databaseId -q 'length > 0') + if [ "$passed" != "true" ]; then + echo "::error::CI has no successful run on main for $sha. Wait for it to pass, or fix main and tag again." + exit 1 + fi + + # The tag, the package version and the changelog section must agree. The section becomes the + # GitHub release notes. + - name: Check the version and the changelog + run: node scripts/release-notes.mjs --tag="$TAG" --out="$RUNNER_TEMP/release-notes.md" + + - uses: ./.github/actions/setup + with: + registry-url: https://registry.npmjs.org + + - name: Type-check + run: pnpm typecheck + + - name: Test devtools package + run: pnpm test:devtools + + - name: Test devtools panel + run: pnpm test:panel + + # `prepack` builds the library and the panel. The root .pnpmfile.cjs drops the workspace-only + # fields from the packed package.json. Everything after this step uses this one file. + - name: Pack the tarball + id: pack + run: | + mkdir -p dist/release + pnpm --filter @pangular-inspector/devtools pack --pack-destination "$PWD/dist/release" + tarball=$(ls "$PWD"/dist/release/*.tgz) + echo "tarball=$tarball" >> "$GITHUB_OUTPUT" + echo "Packed $(basename "$tarball")" >> "$GITHUB_STEP_SUMMARY" + + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + # Both publish the tarball to a throwaway local registry, never to npm. + - name: Verify the tarball (Angular CLI) + run: pnpm verify:publish --scenario=angular-cli --tarball="${{ steps.pack.outputs.tarball }}" + + - name: Verify the tarball (Analog) + run: pnpm verify:publish --scenario=analog --tarball="${{ steps.pack.outputs.tarball }}" + + # A small slice of the weekly install matrix (e2e.yml), plus the package audit. + - name: Install matrix on the tarball + run: >- + pnpm e2e:install --tarball="${{ steps.pack.outputs.tarball }}" + --cells=angular-cli:npm:22,angular-cli:pnpm:20.3,angular-cli-nossr:npm:22,analog:pnpm,cli:npm + --report=dist/release/e2e-install.json --logs=dist/release/e2e-install-logs + + - name: Keep the tarball and the reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-${{ env.TAG }} + path: dist/release + if-no-files-found: ignore + + # A version with a prerelease part (1.1.0-rc.1) goes to the `next` dist-tag, so `latest` + # only ever moves to a stable release. + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + TARBALL: ${{ steps.pack.outputs.tarball }} + run: | + case "$TAG" in + *-*) dist_tag=next ;; + *) dist_tag=latest ;; + esac + npm publish "$TARBALL" --provenance --access public --tag "$dist_tag" + + - name: Create the GitHub release + env: + GH_TOKEN: ${{ github.token }} + TARBALL: ${{ steps.pack.outputs.tarball }} + run: | + prerelease=() + case "$TAG" in *-*) prerelease=(--prerelease) ;; esac + gh release create "$TAG" "$TARBALL" --verify-tag --title "$TAG" \ + --notes-file "$RUNNER_TEMP/release-notes.md" "${prerelease[@]}" diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs new file mode 100644 index 00000000..940f9c1c --- /dev/null +++ b/.pnpmfile.cjs @@ -0,0 +1,14 @@ +// pnpm hooks. Only packing is changed: nothing here affects how dependencies resolve. + +/** + * Drops the fields that only matter inside this workspace from the package.json that ships in a + * tarball (`pnpm pack`, `pnpm publish`): dev dependencies, the Nx target settings and the build + * scripts. The published manifest then holds only what an installer and a bundler read. + */ +function beforePacking(pkg) { + if (pkg.name !== '@pangular-inspector/devtools') return pkg; + const { devDependencies: _dev, nx: _nx, scripts: _scripts, ...published } = pkg; + return published; +} + +module.exports = { hooks: { beforePacking } }; diff --git a/AGENTS.md b/AGENTS.md index 54f0b278..3347808b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ Roles for delegating work live in `.claude/agents/`: `ui-engineer`, `inspector-e ## Serving Locally -- **Demo app (SSR):** `pnpm build --configuration development && node dist/pangular-inspector/server/server.mjs` → http://localhost:4000 +- **Demo app (SSR):** `pnpm build --configuration development && node dist/pangular-inspector/server/server.mjs` → http://localhost:4000 (the panel asks for the one-time code the server prints; `PANGULAR_AUTH=false` turns it off for a local check. A production build serves no devtools) - **Devtools SPA (hot reload):** `pnpm devtools:dev` → http://localhost:5173 (serves its own RPC, so source-scan data works; live tabs need an app page connected, so use the SSR server on 4000 for those) - **Demo app (SPA, no SSR):** `pnpm start` → http://localhost:4200 (runs `ng serve` with SSR and hot reload; devtools popup + RPC work without a separate server) - The devtools popup appears on the demo app page; click it to open the inspector panel diff --git a/SECURITY.md b/SECURITY.md index ce21e06f..904ab05f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,14 @@ ## Supported versions -Pangular Inspector is in early development. Only the latest published release of -`@pangular-inspector/devtools` is supported. There are no older release lines to backport a fix to. +Security fixes go to the 1.x release line of `@pangular-inspector/devtools`, in its latest +minor release. Versions before 1.0 are not supported: upgrade to the latest 1.x release to get a +fix. + +| Version | Supported | +| ------- | --------- | +| 1.x | Yes | +| < 1.0 | No | ## Reporting a vulnerability diff --git a/app/src/__tests__/app-shell.test.ts b/app/src/__tests__/app-shell.test.ts index ea0dee1c..171ecc21 100644 --- a/app/src/__tests__/app-shell.test.ts +++ b/app/src/__tests__/app-shell.test.ts @@ -244,6 +244,14 @@ describe('App before the server trusts it', () => { const input = form.querySelector('input')!; expect(document.activeElement).toBe(input); + input.value = '123'; + input.dispatchEvent(new Event('input')); + form.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); + await settle(fixture); + expect(form.querySelector('[role="alert"]')?.textContent).toContain( + 'Enter the 6-digit code from the terminal.', + ); + input.value = '000000'; input.dispatchEvent(new Event('input')); form.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); @@ -258,6 +266,11 @@ describe('App before the server trusts it', () => { expect(host(fixture).querySelector('app-auth-code')).toBeNull(); expect(status(fixture)).toBe('Live'); expect(host(fixture).querySelector('app-dashboard')).not.toBeNull(); + expect(document.activeElement).toBe(host(fixture).querySelector('nav button.active')); + const statuses = [...host(fixture).querySelectorAll('main [role="status"]')].map((el) => + el.textContent?.trim(), + ); + expect(statuses).toContain('Connected.'); fixture.destroy(); }); }); diff --git a/app/src/__tests__/cd-diagnosis.test.ts b/app/src/__tests__/cd-diagnosis.test.ts index ae7358ea..778668f0 100644 --- a/app/src/__tests__/cd-diagnosis.test.ts +++ b/app/src/__tests__/cd-diagnosis.test.ts @@ -1,5 +1,5 @@ import { TestBed, type ComponentFixture } from '@angular/core/testing'; -import type { CdDiagnosis as Diagnosis } from '@pangular-inspector/devtools/config'; +import type { CdDiagnosis as Diagnosis } from '@pangular-inspector/devtools/internal/config'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { CdDiagnosis } from '../pages/cd-diagnosis'; import { CdRecording, type CdPage } from '../pages/cd-recording'; diff --git a/app/src/__tests__/component-tree-expand.test.ts b/app/src/__tests__/component-tree-expand.test.ts index 1eb4d9cc..e9d0cbc8 100644 --- a/app/src/__tests__/component-tree-expand.test.ts +++ b/app/src/__tests__/component-tree-expand.test.ts @@ -197,7 +197,7 @@ describe('ComponentTree Clear filter focus', () => { const fixture = await setup(); fixture.componentInstance.setFilter('zzz'); await settle(fixture); - const clear = button(fixture, 'Clear filter'); + const clear = button(fixture, 'Clear filters'); clear.focus(); clear.click(); await settle(fixture); @@ -236,7 +236,7 @@ describe('ComponentTree Clear filter focus', () => { await settle(fixture); fixture.componentInstance.filter.set('zzz'); await settle(fixture); - const clear = button(fixture, 'Clear filter'); + const clear = button(fixture, 'Clear filters'); clear.focus(); clear.click(); await settle(fixture); diff --git a/app/src/__tests__/component-value-tree.test.ts b/app/src/__tests__/component-value-tree.test.ts index 678821d5..49891b18 100644 --- a/app/src/__tests__/component-value-tree.test.ts +++ b/app/src/__tests__/component-value-tree.test.ts @@ -409,7 +409,7 @@ describe('ComponentTree value expansion', () => { type(filter, 'zzz'); await settle(fixture); expect(el(fixture).textContent).toContain('No inputs, outputs or properties match “zzz”'); - button(fixture, 'Clear filter').click(); + button(fixture, 'Clear filters').click(); await settle(fixture); expect(filter.value).toBe(''); expect(document.activeElement).toBe(filter); diff --git a/app/src/__tests__/dashboard-cards.test.ts b/app/src/__tests__/dashboard-cards.test.ts index 73982138..2df0a11c 100644 --- a/app/src/__tests__/dashboard-cards.test.ts +++ b/app/src/__tests__/dashboard-cards.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { resolvePangularConfig } from '@pangular-inspector/devtools/config'; +import { resolvePangularConfig } from '@pangular-inspector/devtools/internal/config'; import { componentsCard, dashboardStats, diff --git a/app/src/__tests__/di-inspector-pages.test.ts b/app/src/__tests__/di-inspector-pages.test.ts index 46db795f..308dd7b8 100644 --- a/app/src/__tests__/di-inspector-pages.test.ts +++ b/app/src/__tests__/di-inspector-pages.test.ts @@ -310,7 +310,7 @@ describe('injector search box focus', () => { button.click(); } - it('keeps focus on the search box after Clear search in the live tree', async () => { + it('keeps focus on the search box after Clear filters in the live tree', async () => { const fixture = await render({ roots: [ { @@ -325,7 +325,7 @@ describe('injector search box focus', () => { document.body.append(host); fixture.componentInstance.query.set('zzz'); await fixture.whenStable(); - clickNamed(host, 'Clear search'); + clickNamed(host, 'Clear filters'); await fixture.whenStable(); expect(fixture.componentInstance.query()).toBe(''); expect(document.activeElement).toBe( @@ -348,7 +348,7 @@ describe('injector search box focus', () => { ]); fixture.componentInstance.query.set('zzz'); await fixture.whenStable(); - clickNamed(host, 'Clear filter'); + clickNamed(host, 'Clear filters'); await fixture.whenStable(); expect(host.textContent).toContain('CartService'); expect(document.activeElement).toBe( diff --git a/app/src/__tests__/errors-view.test.ts b/app/src/__tests__/errors-view.test.ts index 40af8e51..d4ad578b 100644 --- a/app/src/__tests__/errors-view.test.ts +++ b/app/src/__tests__/errors-view.test.ts @@ -168,7 +168,7 @@ describe('the Errors tab', () => { search.dispatchEvent(new Event('input')); await settle(fixture); expect(text(fixture)).toContain('No error matches “zzz”.'); - button(fixture, 'Clear filter').click(); + button(fixture, 'Clear filters').click(); await settle(fixture); expect(host(fixture).querySelectorAll('ol.errors > li')).toHaveLength(1); expect(document.activeElement).toBe(search); diff --git a/app/src/__tests__/filter-status.test.ts b/app/src/__tests__/filter-status.test.ts new file mode 100644 index 00000000..f4785e34 --- /dev/null +++ b/app/src/__tests__/filter-status.test.ts @@ -0,0 +1,45 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { filterAnnouncement } from '../ui/filter-status'; + +describe('filterAnnouncement', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + function setup() { + const query = signal(''); + const total = signal(10); + const text = () => `${total()} of ${total()} events`; + const status = TestBed.runInInjectionContext(() => filterAnnouncement(() => query(), text)); + TestBed.tick(); + return { query, total, status }; + } + + it('stays quiet when only the data changes', () => { + const { total, status } = setup(); + total.set(11); + TestBed.tick(); + vi.advanceTimersByTime(2000); + total.set(12); + TestBed.tick(); + vi.advanceTimersByTime(2000); + expect(status()).toBe(''); + }); + + it('speaks the count once after the user stops typing', () => { + const { query, total, status } = setup(); + for (const value of ['t', 'tr', 'tri']) { + query.set(value); + TestBed.tick(); + vi.advanceTimersByTime(100); + } + expect(status()).toBe(''); + vi.advanceTimersByTime(1000); + expect(status()).toBe('10 of 10 events'); + total.set(20); + TestBed.tick(); + vi.advanceTimersByTime(2000); + expect(status()).toBe('10 of 10 events'); + }); +}); diff --git a/app/src/__tests__/format.test.ts b/app/src/__tests__/format.test.ts index c2f6635c..1dc001ef 100644 --- a/app/src/__tests__/format.test.ts +++ b/app/src/__tests__/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { prettyJson, time } from '../format'; +import { plural, prettyJson, time } from '../format'; describe('time', () => { it('formats a timestamp as the local time of day', () => { @@ -28,3 +28,11 @@ describe('prettyJson', () => { expect(prettyJson('"text"')).toBeNull(); }); }); + +describe('plural', () => { + it('uses the singular for one and the plural otherwise', () => { + expect(plural(1, 'route')).toBe('1 route'); + expect(plural(0, 'route')).toBe('0 routes'); + expect(plural(2, 'navigation is', 'navigations are')).toBe('2 navigations are'); + }); +}); diff --git a/app/src/__tests__/network-ssr-requests.test.ts b/app/src/__tests__/network-ssr-requests.test.ts index 9391e18c..3d53386b 100644 --- a/app/src/__tests__/network-ssr-requests.test.ts +++ b/app/src/__tests__/network-ssr-requests.test.ts @@ -100,6 +100,14 @@ describe('NetworkInspector SSR requests', () => { ); expect(host.querySelector('tr.selected .tag.server')!.textContent).toBe('this page'); + // The requests are one Tab stop, named by method, path, status and render mode. + const rows = [...host.querySelectorAll('button.ssr-row')]; + expect(rows.length).toBeGreaterThan(0); + expect(rows.filter((row) => row.tabIndex === 0)).toHaveLength(1); + expect(rows[0].textContent!.replace(/\s+/g, ' ').trim()).toMatch( + /^GET \/examples\/ssr , 200, \S/, + ); + inspector.selectedRequestId.set('r2'); inspector.pages.update((pages) => pages.map((p) => ({ ...p, reportedAt: 2000 }))); await fixture.whenStable(); diff --git a/app/src/__tests__/plugins-view.test.ts b/app/src/__tests__/plugins-view.test.ts index 320b2273..40dba76b 100644 --- a/app/src/__tests__/plugins-view.test.ts +++ b/app/src/__tests__/plugins-view.test.ts @@ -198,7 +198,7 @@ describe('the Plugins tab', () => { search.dispatchEvent(new Event('input')); await settle(fixture); expect(text(fixture)).toContain('No row or item matches “zzz”.'); - button(fixture, 'Clear filter').click(); + button(fixture, 'Clear filters').click(); await settle(fixture); expect(host(fixture).querySelectorAll('tbody tr')).toHaveLength(2); }); diff --git a/app/src/__tests__/query-inspector.test.ts b/app/src/__tests__/query-inspector.test.ts index ea055d19..da5425db 100644 --- a/app/src/__tests__/query-inspector.test.ts +++ b/app/src/__tests__/query-inspector.test.ts @@ -154,7 +154,8 @@ describe('QueryInspector', () => { } as unknown as DevframeRpcClient); await settle(fixture); const text = root(fixture).textContent!.replace(/\s+/g, ' '); - expect(text).toContain('The connected page has no QueryClient.'); + expect(text).toContain('The connected page has no QueryClient yet.'); + expect(text).not.toContain('No page reports a QueryClient'); expect(text).toContain('provideTanStackQuery(new QueryClient())'); expect(text).not.toContain('Open the app with the overlay loaded'); }); diff --git a/app/src/__tests__/router-panels.test.ts b/app/src/__tests__/router-panels.test.ts index 92b93f64..7db1c0ae 100644 --- a/app/src/__tests__/router-panels.test.ts +++ b/app/src/__tests__/router-panels.test.ts @@ -289,7 +289,7 @@ describe('RouteTree filter count', () => { it('shows "N of M routes" while a search filters the tree and says it once', async () => { const fixture = mount(RouteTree, page({ config: routes }), offline); await settle(fixture); - expect(summary(fixture)).toContain('3 route(s).'); + expect(summary(fixture)).toContain('3 routes.'); expect(el(fixture).querySelector('.summary .count')).toBeNull(); type(fixture, 'users'); @@ -336,11 +336,11 @@ describe('RouteTree filter count', () => { await settle(fixture); type(fixture, 'zzz'); await settle(fixture); - button(fixture, 'Clear filter').click(); + button(fixture, 'Clear filters').click(); await settle(fixture); expect(filterInput(fixture).value).toBe(''); expect(document.activeElement).toBe(filterInput(fixture)); - expect(summary(fixture)).toContain('3 route(s).'); + expect(summary(fixture)).toContain('3 routes.'); expect(filterStatus(fixture)).toBe(''); }); }); diff --git a/app/src/__tests__/shell-deep-link.test.ts b/app/src/__tests__/shell-deep-link.test.ts index 3fca0458..47758340 100644 --- a/app/src/__tests__/shell-deep-link.test.ts +++ b/app/src/__tests__/shell-deep-link.test.ts @@ -1,7 +1,7 @@ import { TestBed, type ComponentFixture } from '@angular/core/testing'; import type { DevframeRpcClient } from 'devframe/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { parsePanelLink } from '@pangular-inspector/devtools/config'; +import { parsePanelLink } from '@pangular-inspector/devtools/internal/config'; const server = vi.hoisted(() => ({ /** Events the panel registered, by name, to call as the server would broadcast. */ diff --git a/app/src/__tests__/signal-inspector.test.ts b/app/src/__tests__/signal-inspector.test.ts index 44e3faf8..8e7313da 100644 --- a/app/src/__tests__/signal-inspector.test.ts +++ b/app/src/__tests__/signal-inspector.test.ts @@ -332,6 +332,18 @@ describe('SignalInspector', () => { expect(host.textContent).toContain('user'); }); + it('offers Clear filters when a filter is set on an empty graph', async () => { + const fixture = await render({ ...graph, nodes: [], edges: [], resources: [] }); + const host = fixture.nativeElement as HTMLElement; + fixture.componentInstance.filter.set('zzz'); + await fixture.whenStable(); + expect(host.textContent).toContain('No signals in this graph.'); + const clear = Array.from(host.querySelectorAll('button')).find( + (b) => b.textContent?.trim() === 'Clear filters', + ); + expect(clear).toBeDefined(); + }); + describe('filter box focus and Escape', () => { afterEach(() => { document.body.innerHTML = ''; diff --git a/app/src/__tests__/timeline.test.ts b/app/src/__tests__/timeline.test.ts index 8258d0e3..dc235ab7 100644 --- a/app/src/__tests__/timeline.test.ts +++ b/app/src/__tests__/timeline.test.ts @@ -376,6 +376,8 @@ describe('the Timeline tab', () => { expect(titles[0]).toBe('Navigation to /admin'); expect(titles.at(-1)).toBe('GET /api/trips'); expect(text(fixture)).toContain('9 of 9 events'); + // The visible count is not a live region, so pushes never announce it. + expect(host(fixture).querySelector('.count')!.hasAttribute('role')).toBe(false); const chart = host(fixture).querySelector('[role="img"]')!; expect(chart.getAttribute('aria-label')).toContain('Router 2'); expect(host(fixture).querySelectorAll('h2')[0].textContent).toBe('Time axis'); diff --git a/app/src/__tests__/tint-contrast.test.ts b/app/src/__tests__/tint-contrast.test.ts index 9c8f9f7c..64dab59c 100644 --- a/app/src/__tests__/tint-contrast.test.ts +++ b/app/src/__tests__/tint-contrast.test.ts @@ -159,9 +159,72 @@ describe('router timeline phase colours', () => { } }); +// Control outlines and signal graph edges are the only boundary of what they +// draw, so they need 3:1 on every surface (WCAG 1.4.11). +describe('control outlines and graph edges', () => { + const base = theme('amber'); + for (const mode of ['dark', 'light'] as const) { + for (const token of ['control-border', 'graph-edge']) { + it(`${mode}: --${token} keeps 3:1 on every surface`, () => { + const tokens = base[mode]; + expect(tokens[token], `--${token}`).toBeDefined(); + for (const surface of ['bg', 'surface', 'surface-2', 'surface-3']) { + expect( + ratio(rgb(tokens[token]), rgb(tokens[surface])), + `on --${surface}`, + ).toBeGreaterThanOrEqual(3); + } + }); + } + } +}); + +// Pressed and selected controls draw their border in --accent-line, a +// percentage of the accent over the surface, so it needs 3:1 too. +describe('accent borders', () => { + for (const accent of ACCENTS) { + const { css } = compileString(`@use 'theme' with ($accent: ${accent});`, { + loadPaths: [STYLES], + }); + const themes = theme(accent); + const alphas = [...css.matchAll(/--accent-line-amount:\s*(\d+)%/g)]; + for (const [index, mode] of (['dark', 'light'] as const).entries()) { + it(`${accent} ${mode}: --accent-line keeps 3:1 on every surface`, () => { + const amount = Number(alphas[index]?.[1]) / 100; + expect(amount).toBeGreaterThan(0); + const tokens = themes[mode]; + for (const surface of ['bg', 'surface', 'surface-2', 'surface-3']) { + const under = rgb(tokens[surface]); + const line = mix(rgb(tokens['accent']), amount, under); + expect(ratio(line, under), `on --${surface}`).toBeGreaterThanOrEqual(3); + } + }); + } + } +}); + +// Signal kind badges put --kind-ink on a solid --kind- fill. +describe('signal kind badges', () => { + const tokens = theme('amber').dark; + const kinds = Object.keys(tokens).filter( + (name) => name.startsWith('kind-') && name !== 'kind-ink', + ); + it('has a fill for every kind', () => expect(kinds.length).toBeGreaterThan(10)); + for (const kind of kinds) { + it(`--kind-ink keeps 4.5:1 on --${kind}`, () => { + expect(ratio(rgb(tokens['kind-ink']), rgb(tokens[kind]))).toBeGreaterThanOrEqual(4.5); + }); + } +}); + // Pages that used hard-coded hex colors now take them from the tokens above, so // both themes get a value these tests check. -const TOKEN_ONLY_PAGES = ['analog-inspector.ts', 'route-timeline.ts']; +const TOKEN_ONLY_PAGES = [ + 'analog-inspector.ts', + 'route-timeline.ts', + 'signal-graph-view.ts', + 'signal-inspector.ts', +]; describe('pages that use theme tokens only', () => { for (const file of TOKEN_ONLY_PAGES) { diff --git a/app/src/angular-pages.ts b/app/src/angular-pages.ts index b58955d8..b7ded26f 100644 --- a/app/src/angular-pages.ts +++ b/app/src/angular-pages.ts @@ -1,6 +1,6 @@ import { DestroyRef, effect, inject, signal, untracked, type Signal } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; -import type { AngularPageInfo } from '@pangular-inspector/devtools/config'; +import type { AngularPageInfo } from '@pangular-inspector/devtools/internal/config'; export interface AngularPage extends AngularPageInfo { pageId: string; diff --git a/app/src/app.ts b/app/src/app.ts index 3a64439f..01fcf99b 100644 --- a/app/src/app.ts +++ b/app/src/app.ts @@ -64,7 +64,7 @@ import { parsePanelLink, type HubView, type PanelLink, -} from '@pangular-inspector/devtools/config'; +} from '@pangular-inspector/devtools/internal/config'; import { Settings } from './pages/settings'; import { CommandPalette } from './ui/command-palette'; import type { PaletteCommand } from './palette-filter'; @@ -480,6 +480,7 @@ function readView(): View | null {

{{ backgroundNote() }}

+

{{ connectedNote() }}

@if (tab() === 'settings') { (null); + /** Says "Connected." once the one-time code is accepted. */ + readonly connectedNote = signal(''); private readonly hiddenPages = signal([]); private readonly pageId = hostPageId(); readonly backgroundNote = computed(() => { const hidden = this.hiddenPages(); if (this.pageId) { - return hidden.includes(this.pageId) ? 'Tab in background, showing the last data.' : ''; + return hidden.includes(this.pageId) ? 'Page in background, showing the last data.' : ''; } if (!hidden.length) return ''; return hidden.length === 1 - ? 'A tab is in the background, showing its last data.' - : `${hidden.length} tabs are in the background, showing their last data.`; + ? 'A page is in the background, showing its last data.' + : `${hidden.length} pages are in the background, showing their last data.`; }); private stopVisibility = () => {}; private stopNative = () => {}; @@ -1324,11 +1327,19 @@ export class App implements OnInit, OnDestroy { } if (this.pendingClient === client && status !== 'connecting') this.pendingClient = null; if (status === 'connected') { + const paired = this.pairing() === client; this.pairing.set(null); // devframe says it is trusted before it stores the token it got for the code. setTimeout(() => auth.remember(client.connection?.authToken)); this.ready(client); this.connected.set(this.rpc() === client); + // The code prompt that held focus is gone: say so and move to the tab bar. + if (paired) { + this.connectedNote.update((note) => + note === 'Connected.' ? 'Connected.\u00a0' : 'Connected.', + ); + this.focusCurrentTab(); + } } else if (status === 'unauthorized') { this.connected.set(false); if (this.rpc() === client) this.rpc.set(null); diff --git a/app/src/devtools-config.ts b/app/src/devtools-config.ts index 56041ae7..31a47f1d 100644 --- a/app/src/devtools-config.ts +++ b/app/src/devtools-config.ts @@ -5,7 +5,7 @@ import { type PangularAction, type PangularInspector, type ResolvedPangularConfig, -} from '@pangular-inspector/devtools/config'; +} from '@pangular-inspector/devtools/internal/config'; import type { Tab } from './types/tab.types'; export { actionBlockedMessage }; diff --git a/app/src/format.ts b/app/src/format.ts index 322adc3a..1f2a0a9a 100644 --- a/app/src/format.ts +++ b/app/src/format.ts @@ -52,3 +52,8 @@ export function prettyJson(text: string): string | null { } return out; } + +/** `1 route`, `3 routes`: the count and the noun in the matching number. */ +export function plural(count: number, one: string, many = `${one}s`): string { + return `${count} ${count === 1 ? one : many}`; +} diff --git a/app/src/hub-dock-sync.ts b/app/src/hub-dock-sync.ts index 280fb3f0..9473d42a 100644 --- a/app/src/hub-dock-sync.ts +++ b/app/src/hub-dock-sync.ts @@ -1,4 +1,4 @@ -import { hubDockId } from '@pangular-inspector/devtools/config'; +import { hubDockId } from '@pangular-inspector/devtools/internal/config'; const CLIENT_CONTEXT_KEY = '__DEVFRAME_HUB_CLIENT_CONTEXT__'; diff --git a/app/src/pages/analog-inspector.ts b/app/src/pages/analog-inspector.ts index 358b6457..456ea53f 100644 --- a/app/src/pages/analog-inspector.ts +++ b/app/src/pages/analog-inspector.ts @@ -10,7 +10,7 @@ import { untracked, } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; -import { time } from '../format'; +import { plural, time } from '../format'; import { hostPageId } from '../page-id'; import { rpcCall, rpcTry as call } from '../rpc'; import { actionAllowed, actionBlockedMessage } from '../devtools-config'; @@ -1029,7 +1029,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth
Build output
@if (p.built.length) { - {{ p.built.length }} page(s) in dist/analog/public + {{ plural(p.built.length, 'page') }} in dist/analog/public @if (p.notBuilt.length) { · missing @for (r of p.notBuilt; track r) { @@ -1102,8 +1102,9 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth @case ('lint') { @if (findings().length) {

- {{ findings().length }} issue(s) in {{ lintCards().length }} group(s). Each card - says what is wrong, where, and how to fix it. + {{ plural(findings().length, 'issue') }} in + {{ plural(lintCards().length, 'group') }}. Each card says what is wrong, where, and + how to fix it.