From a8402192a30191f35abf23a0c6ff6160b430ac37 Mon Sep 17 00:00:00 2001 From: erKam Date: Tue, 13 Oct 2026 06:45:13 +0000 Subject: [PATCH 1/5] feat(mcp): add an audit entry point with axe and before/after sessions Agents and the panel get one ranked list of what the inspectors find, an accessibility pass over the app that loads axe only when asked, and a baseline/after comparison to verify a fix. Refs #343. --- app/src/__tests__/audit-inspector.test.ts | 262 +++++++ app/src/app.ts | 9 + app/src/pages/audit-diff.ts | 226 ++++++ app/src/pages/audit-inspector.ts | 737 ++++++++++++++++++ app/src/pages/audit-types.ts | 156 ++++ app/src/pages/tab-icon.ts | 4 + app/src/types/tab.types.ts | 1 + apps/docs/src/content/agents/resources.md | 1 + apps/docs/src/content/agents/tools.md | 48 +- .../src/content/guides/inspect-fix-verify.md | 120 +++ apps/docs/src/content/inspectors/audit.md | 145 ++++ apps/docs/src/ngmd.config.ts | 2 + packages/devtools/package.json | 1 + .../src/__tests__/audit-findings.test.ts | 513 ++++++++++++ .../src/__tests__/audit-tools.test.ts | 299 +++++++ .../src/__tests__/axe-runner-real.test.ts | 61 ++ .../devtools/src/__tests__/axe-runner.test.ts | 208 +++++ .../devtools/src/__tests__/config.test.ts | 2 + .../src/__tests__/docs-tool-count.test.ts | 1 + .../src/__tests__/hub-messages.test.ts | 9 + packages/devtools/src/axe-runner.ts | 336 ++++++++ packages/devtools/src/config.ts | 19 +- packages/devtools/src/devframe.ts | 26 + packages/devtools/src/hub-messages.ts | 2 + packages/devtools/src/overlay.ts | 4 + packages/devtools/src/page-highlight.ts | 2 + packages/devtools/src/rpc/audit-axe.ts | 169 ++++ packages/devtools/src/rpc/audit-findings.ts | 734 +++++++++++++++++ packages/devtools/src/rpc/audit-register.ts | 417 ++++++++++ packages/devtools/src/rpc/audit-sessions.ts | 214 +++++ packages/devtools/src/rpc/audit-text.ts | 175 +++++ packages/devtools/src/rpc/errors-register.ts | 3 + packages/devtools/src/rpc/leaks-register.ts | 3 + packages/devtools/src/rpc/open-in-panel.ts | 2 +- packages/devtools/vitest.compat.config.ts | 2 + pnpm-lock.yaml | 3 + 36 files changed, 4897 insertions(+), 19 deletions(-) create mode 100644 app/src/__tests__/audit-inspector.test.ts create mode 100644 app/src/pages/audit-diff.ts create mode 100644 app/src/pages/audit-inspector.ts create mode 100644 app/src/pages/audit-types.ts create mode 100644 apps/docs/src/content/guides/inspect-fix-verify.md create mode 100644 apps/docs/src/content/inspectors/audit.md create mode 100644 packages/devtools/src/__tests__/audit-findings.test.ts create mode 100644 packages/devtools/src/__tests__/audit-tools.test.ts create mode 100644 packages/devtools/src/__tests__/axe-runner-real.test.ts create mode 100644 packages/devtools/src/__tests__/axe-runner.test.ts create mode 100644 packages/devtools/src/axe-runner.ts create mode 100644 packages/devtools/src/rpc/audit-axe.ts create mode 100644 packages/devtools/src/rpc/audit-findings.ts create mode 100644 packages/devtools/src/rpc/audit-register.ts create mode 100644 packages/devtools/src/rpc/audit-sessions.ts create mode 100644 packages/devtools/src/rpc/audit-text.ts diff --git a/app/src/__tests__/audit-inspector.test.ts b/app/src/__tests__/audit-inspector.test.ts new file mode 100644 index 00000000..68c04f23 --- /dev/null +++ b/app/src/__tests__/audit-inspector.test.ts @@ -0,0 +1,262 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import type { DevframeRpcClient } from 'devframe/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AuditInspector } from '../pages/audit-inspector'; +import type { AuditFinding, AuditResponse } from '../pages/audit-types'; + +const NOW = Date.UTC(2026, 9, 13, 10, 0, 0); + +const finding = (extra: Partial): AuditFinding => ({ + key: `${extra.area ?? 'errors'}:${extra.rule ?? 'x'}:${extra.subject ?? ''}`, + severity: 'error', + area: 'errors', + rule: 'NG0201', + why: 'NullInjectorError: No provider for TripApi!', + tool: 'pangular:explain-error', + ...extra, +}); + +const findings: AuditFinding[] = [ + finding({ + area: 'accessibility', + rule: 'image-alt', + subject: 'TripPhoto', + why: 'Images must have alternative text (critical).', + tool: 'pangular:audit', + args: { page: 'p1', area: 'accessibility' }, + count: 2, + elementId: 'c1-9', + }), + finding({ subject: 'TripList', elementId: 'c1-2', args: { page: 'p1', id: 'e1' }, count: 3 }), + finding({ + severity: 'warning', + area: 'change-detection', + rule: 'onpush-candidate', + subject: 'TripList', + why: 'TripList was refreshed 40 times and changed something in 2.', + tool: 'pangular:diagnose-change-detection', + }), +]; + +function response(extra: Partial = {}): AuditResponse { + return { + pages: [{ pageId: 'p1', url: '/trips', title: 'Trips' }], + report: { + pageId: 'p1', + url: '/trips', + at: NOW, + findings, + metrics: { cdMeanMs: 4.5, errors: 3, findingsError: 2, findingsWarning: 1, findingsInfo: 0 }, + sources: [ + { area: 'accessibility', state: 'ok' }, + { area: 'router', state: 'off' }, + { area: 'leaks', state: 'empty', note: 'No leak report yet.' }, + ], + }, + session: { before: null, after: null, diff: null }, + axe: null, + ...extra, + }; +} + +let fixture: ComponentFixture; +let calls: { name: string; arg: unknown }[]; +let answers: Record; +let listeners: (() => void)[]; +const host = () => fixture.nativeElement as HTMLElement; +const text = () => host().textContent!.replace(/\s+/g, ' '); +const button = (label: string) => + [...host().querySelectorAll('button')].find((b) => b.textContent?.trim().startsWith(label))!; +const settle = async () => { + for (let i = 0; i < 4; i++) { + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + fixture.detectChanges(); +}; + +function start(backend = 'websocket') { + const client = { + connectionMeta: { backend }, + scope: () => ({ + rpc: { + call: vi.fn(async (name: string, arg: unknown) => { + calls.push({ name, arg }); + const answer = answers[name]; + return typeof answer === 'function' ? answer(arg) : answer; + }), + sharedState: async () => ({ + value: () => ({ pages: {} }), + on: (_: string, listener: () => void) => { + listeners.push(listener); + return () => {}; + }, + }), + }, + }), + }; + fixture = TestBed.createComponent(AuditInspector); + fixture.componentRef.setInput('rpc', client as unknown as DevframeRpcClient); +} + +describe('AuditInspector', () => { + beforeEach(() => { + calls = []; + listeners = []; + answers = { 'get-audit': response() }; + }); + + afterEach(() => { + fixture?.destroy(); + TestBed.resetTestingModule(); + }); + + it('lists the findings ranked as the server sent them, with the agent tool for each', async () => { + start(); + await settle(); + const rows = [...host().querySelectorAll('.finding')]; + expect(rows).toHaveLength(3); + expect(rows[0]!.textContent).toContain('Accessibility'); + expect(rows[0]!.textContent).toContain('TripPhoto'); + expect(rows[0]!.textContent).toContain('2×'); + expect(rows[1]!.textContent).toContain('explain-error { id: "e1" }'); + expect(text()).toContain('3 of 3 findings'); + expect(text()).toContain('Mean cycle time4.5 ms'); + expect(text()).toContain('Router Its inspector is turned off.'); + expect(text()).toContain('Leaks No leak report yet.'); + expect(host().querySelector('h2')?.textContent).toContain('Before and after'); + expect(calls[0]).toEqual({ name: 'get-audit', arg: { pageId: undefined } }); + }); + + it('filters by text and shows Clear filters when nothing matches', async () => { + start(); + await settle(); + const search = host().querySelector('input[type="search"]')!; + search.value = 'onpush'; + search.dispatchEvent(new Event('input')); + await settle(); + expect(host().querySelectorAll('.finding')).toHaveLength(1); + expect(text()).toContain('1 of 3 findings'); + search.value = 'nothing like this'; + search.dispatchEvent(new Event('input')); + await settle(); + expect(text()).toContain('No finding matches the filters.'); + button('Clear filters').click(); + await settle(); + expect(host().querySelectorAll('.finding')).toHaveLength(3); + }); + + it('runs axe on the page and reads the audit again, or says why it did not run', async () => { + answers['request-axe-run'] = { ok: true, violations: 1 }; + start(); + await settle(); + answers['get-audit'] = response({ axe: { at: NOW, ms: 30, violations: [{}] } }); + button('Run axe').click(); + await settle(); + expect(calls.map((c) => c.name)).toEqual(['get-audit', 'request-axe-run', 'get-audit']); + expect(calls[1]!.arg).toEqual({ pageId: 'p1' }); + expect(text()).toContain('1 rule broken'); + + answers['request-axe-run'] = { ok: false, error: 'The page did not answer within 60s.' }; + button('Run axe').click(); + await settle(); + expect(host().querySelector('[role="alert"]')?.textContent).toContain('did not answer'); + }); + + it('takes a baseline and an after audit, and shows the comparison', async () => { + answers['audit-session'] = { ok: true }; + start(); + await settle(); + expect(button('Take after audit').disabled).toBe(true); + const before = { at: NOW, findings, metrics: {}, axe: true }; + answers['get-audit'] = response({ session: { before, after: null, diff: null } }); + button('Take baseline').click(); + await settle(); + expect(calls.find((c) => c.name === 'audit-session')?.arg).toEqual({ + pageId: 'p1', + action: 'before', + }); + expect(text()).toContain('Baseline at'); + expect(button('Take after audit').disabled).toBe(false); + + answers['get-audit'] = response({ + session: { + before, + after: { ...before, findings: findings.slice(1) }, + diff: { + resolved: [findings[0]!], + added: [], + remaining: [{ ...findings[1]!, count: 1, countBefore: 3 }], + metrics: [ + { metric: 'cdMeanMs', before: 6, after: 4.5, delta: -1.5 }, + { metric: 'errors', before: 3, after: 4, delta: 1 }, + { metric: 'retained', after: 0 }, + ], + notes: [], + }, + }, + }); + button('Take after audit').click(); + await settle(); + const rows = [...host().querySelectorAll('tbody tr')].map((row) => + [...row.children].map((cell) => cell.textContent!.trim()).join(' | '), + ); + expect(rows).toEqual([ + 'Mean cycle time | 6 ms | 4.5 ms | −1.5 ms, better', + 'Retained components | Not measured | 0 | Not compared', + 'Error occurrences | 3 | 4 | +1, worse', + ]); + expect(text()).toContain('Resolved (1)'); + expect(text()).toContain('image-alt in TripPhoto'); + expect(text()).toContain('NG0201 in TripList: 3× → 1×'); + expect([...host().querySelectorAll('h3')].map((h) => h.textContent?.trim())).toEqual([ + 'Resolved (1)', + 'New (0)', + 'Changed count (1)', + ]); + }); + + it('opens a component for errors and highlights an element for axe findings', async () => { + start(); + await settle(); + const emitted: unknown[] = []; + fixture.componentInstance.showComponent.subscribe((focus) => emitted.push(focus)); + button('Show component').click(); + expect(emitted).toEqual([{ id: 'c1-2', pageId: 'p1' }]); + button('Show in page').click(); + await settle(); + expect(calls.find((c) => c.name === 'request-page-highlight')?.arg).toEqual({ + pageId: 'p1', + id: 'c1-9', + reveal: true, + }); + }); + + it('reads the audit again when a session or axe pass changes elsewhere', async () => { + start(); + await settle(); + expect(listeners).toHaveLength(1); + listeners[0]!(); + await settle(); + expect(calls.filter((c) => c.name === 'get-audit')).toHaveLength(2); + }); + + it('says what to do without a page, after an error, and in a static report', async () => { + answers['get-audit'] = { pages: [], report: null }; + start(); + await settle(); + expect(text()).toContain('No page reports live data yet.'); + fixture.destroy(); + + answers['get-audit'] = () => Promise.reject(new Error('gone')); + start(); + await settle(); + expect(text()).toContain("Can't read the audit"); + expect(button('Retry')).toBeTruthy(); + fixture.destroy(); + + start('static'); + await settle(); + expect(text()).toContain('a static report has none'); + }); +}); diff --git a/app/src/app.ts b/app/src/app.ts index 5ed2728b..ab9bda9e 100644 --- a/app/src/app.ts +++ b/app/src/app.ts @@ -31,6 +31,7 @@ import { NetworkInspector } from './pages/network-inspector'; import { ComingSoon, type ComingSoonInfo } from './pages/coming-soon'; import { CapacitorInspector } from './pages/capacitor-inspector'; import { ErrorsInspector } from './pages/errors-inspector'; +import { AuditInspector } from './pages/audit-inspector'; import { Timeline } from './pages/timeline'; import { capacitorPages } from './pages/capacitor-types'; import { TabIcon } from './pages/tab-icon'; @@ -211,6 +212,7 @@ function readView(): View | null { NetworkInspector, CapacitorInspector, ErrorsInspector, + AuditInspector, Timeline, ComingSoon, TabIcon, @@ -506,6 +508,13 @@ function readView(): View | null { @case ('timeline') { } + @case ('audit') { + + } @case ('components') { {{ note }}

+ } +
+ + + + + + + + + + + + @for (row of rows(); track row.label) { + + + + + + + } + +
+ Numbers before and after. Lower is better for each. +
NumberBeforeAfterChange
{{ row.label }}{{ row.before }}{{ row.after }} + {{ row.change }} +
+
+
+
+

Resolved ({{ diff().resolved.length }})

+ @if (diff().resolved.length) { +
    + @for (finding of diff().resolved; track finding.key) { +
  • + {{ areaLabel[finding.area] }} + {{ label(finding) }} +
  • + } +
+ } @else { +

None.

+ } +
+
+

New ({{ diff().added.length }})

+ @if (diff().added.length) { +
    + @for (finding of diff().added; track finding.key) { +
  • + {{ areaLabel[finding.area] }} + {{ label(finding) }} +
  • + } +
+ } @else { +

None.

+ } +
+
+

Changed count ({{ changed().length }})

+ @if (changed().length) { +
    + @for (finding of changed(); track finding.key) { +
  • + {{ areaLabel[finding.area] }} + {{ label(finding) }}: {{ finding.countBefore }}× → {{ finding.count ?? 1 }}× +
  • + } +
+ } @else { +

{{ diff().remaining.length }} still there with the same count.

+ } +
+
+ `, + styles: ` + @use 'mixins' as m; + :host { + display: grid; + gap: 12px; + } + .notice { + margin: 0; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-2); + } + .table-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + } + .table-wrap:focus-visible { + @include m.focus-ring(-2px); + } + table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + } + th, + td { + padding: 6px 10px; + border-bottom: 1px solid var(--border); + text-align: left; + white-space: nowrap; + } + thead th { + @include m.label; + background: var(--surface); + } + tbody th { + font-weight: 500; + color: var(--text); + } + td { + color: var(--text-2); + font-variant-numeric: tabular-nums; + } + tbody tr:last-child > * { + border-bottom: 0; + } + .chip { + display: inline-flex; + padding: 1px 8px; + border-radius: 99px; + font-size: 11px; + font-weight: 600; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text-2); + } + .chip[data-tone='ok'] { + border-color: transparent; + @include m.tint(ok); + } + .chip[data-tone='danger'] { + border-color: transparent; + @include m.tint(danger); + } + .lists { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + } + h3 { + @include m.label; + margin: 0 0 6px; + } + ul { + display: grid; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; + font-size: 12px; + overflow-wrap: anywhere; + } + .area { + color: var(--text-2); + font-weight: 600; + } + .muted { + margin: 0; + color: var(--text-2); + font-size: 12px; + } + `, +}) +export class AuditDiffView { + readonly diff = input.required(); + + protected readonly areaLabel = AREA_LABEL; + + readonly changed = computed(() => + this.diff().remaining.filter( + (finding) => finding.countBefore !== undefined && finding.countBefore !== finding.count, + ), + ); + + readonly rows = computed(() => { + const byKey = new Map(this.diff().metrics.map((change) => [change.metric, change])); + return METRICS.filter((metric) => byKey.has(metric.key)).map((metric) => { + const change = byKey.get(metric.key)!; + const unit = metric.unit ? ` ${metric.unit}` : ''; + const show = (value?: number) => (value === undefined ? 'Not measured' : `${value}${unit}`); + const delta = change.delta; + return { + label: metric.unit === '%' ? `${metric.label} (%)` : metric.label, + before: show(change.before), + after: show(change.after), + change: + delta === undefined + ? 'Not compared' + : delta === 0 + ? 'Same' + : `${delta > 0 ? '+' : '−'}${Math.abs(delta)}${unit}, ${delta < 0 ? 'better' : 'worse'}`, + tone: delta === undefined || delta === 0 ? 'plain' : delta < 0 ? 'ok' : 'danger', + }; + }); + }); + + label(finding: AuditFinding): string { + return `${finding.rule}${finding.subject ? ` in ${finding.subject}` : ''}`; + } +} diff --git a/app/src/pages/audit-inspector.ts b/app/src/pages/audit-inspector.ts new file mode 100644 index 00000000..1791c5a3 --- /dev/null +++ b/app/src/pages/audit-inspector.ts @@ -0,0 +1,737 @@ +import { + Component, + DestroyRef, + computed, + effect, + inject, + input, + linkedSignal, + output, + signal, + untracked, +} from '@angular/core'; +import type { DevframeRpcClient } from 'devframe/client'; +import { time } from '../format'; +import { hostPageId } from '../page-id'; +import { isStaticReport, rpcCall } from '../rpc'; +import { Select, type SelectOption } from '../ui/select'; +import type { ComponentFocus } from '../types/component-focus.types'; +import { AuditDiffView } from './audit-diff'; +import { + AREA_LABEL, + AUDIT_AREAS, + METRICS, + toAuditResponse, + toolCall, + type AuditArea, + type AuditFinding, + type AuditResponse, +} from './audit-types'; + +type SeverityFilter = 'all' | 'error' | 'warning'; + +const SEVERITY_TONE = { error: 'danger', warning: 'warn', info: 'accent' } as const; +const SEVERITY_RANK = { error: 0, warning: 1, info: 2 } as const; +/** Areas whose findings point at a component host the Components tab can select. */ +const COMPONENT_AREAS: readonly AuditArea[] = ['errors', 'hydration']; + +@Component({ + selector: 'app-audit-inspector', + imports: [Select, AuditDiffView], + template: ` + @if (staticReport()) { +
+

The audit reads a live page, and a static report has none.

+

Open the panel from the server that runs the app to audit it.

+
+ } @else if (!loaded()) { +

Loading the audit…

+ } @else if (failed()) { + + } @else if (!report()) { +
+

No page reports live data yet.

+

+ Open the app with the overlay loaded. The audit reads what the other inspectors collect + from it. +

+
+ } @else { +

+ One ranked list of what the inspectors found on the live page, with an axe pass over the app + when you run one. Take a baseline before a change and an after audit to compare them. Agents + get the same list from the audit tool. +

+ +
+ + + + @if (pageOptions().length > 1) { + + } + {{ countText() }} + + +
+ +

{{ axeStatus() }}

+ @if (axeError(); as error) { + + } + +
+
+

Before and after

+
+ + + @if (before()) { + + } +
+
+

{{ sessionText() }}

+ @if (sessionError(); as error) { + + } + @if (diff(); as diff) { + + } +
+ +
+

Findings

+ @if (!findings().length) { +

+ No finding on this page. Run axe, or record change detection, to check more. +

+ } @else if (!shown().length) { +
+

No finding matches the filters.

+ +
+ } @else { +
    + @for (finding of shown(); track finding.key) { +
  1. +
    + {{ + finding.severity + }} + {{ areaLabel[finding.area] }} + @if (finding.subject) { + {{ finding.subject }} + } + @if ((finding.count ?? 1) > 1) { + {{ finding.count }}× + } +
    +

    {{ finding.why }}

    +
    + Rule {{ finding.rule }} + Agent tool {{ toolCall(finding) }} + @if (finding.elementId) { + @if (canShowComponent() && isComponentArea(finding.area)) { + + } @else { + + } + } +
    +
  2. + } +
+ } +
+ +
+

Key numbers

+
+ @for (metric of numbers(); track metric.label) { +
+
{{ metric.label }}
+
{{ metric.value }}
+
+ } +
+
+ + @if (coverage().length) { +
+

Not checked

+
    + @for (source of coverage(); track source.area) { +
  • + {{ areaLabel[source.area] }} + {{ + source.state === 'off' + ? 'Its inspector is turned off.' + : (source.note ?? 'Nothing to read yet.') + }} +
  • + } +
+
+ } + } + `, + styles: ` + @use 'mixins' as m; + :host { + display: block; + font-size: 13px; + } + .intro { + margin: 0 0 12px; + color: var(--text-2); + line-height: 1.5; + } + code { + font-family: var(--font-mono); + font-size: 12px; + } + .muted { + color: var(--text-2); + } + .toolbar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; + margin: 0 0 8px; + padding: 8px; + background: color-mix(in srgb, var(--surface) 85%, transparent); + backdrop-filter: blur(10px); + border: 1px solid var(--border); + border-radius: var(--radius); + } + .search { + position: relative; + flex: 1 1 200px; + min-width: 0; + } + .search-icon { + position: absolute; + top: 50%; + left: 12px; + width: 14px; + height: 14px; + transform: translateY(-50%); + fill: none; + stroke: var(--text-3); + stroke-width: 2.2; + stroke-linecap: round; + pointer-events: none; + } + input[type='search'] { + width: 100%; + height: var(--control-h); + padding: 0 12px 0 34px; + background: var(--bg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 13px; + } + input[type='search']:focus { + @include m.field-focus; + } + .filter-select { + flex: 0 1 170px; + min-width: 0; + } + .count { + margin-left: auto; + color: var(--text-2); + font-size: 12px; + } + .small { + height: var(--control-h); + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; + cursor: pointer; + } + .small:hover:not(:disabled) { + background: var(--surface-3); + } + .small:disabled { + color: var(--text-2); + cursor: default; + } + .small:focus-visible { + @include m.focus-ring; + } + .axe-status { + min-height: 1.5em; + margin: 0 0 8px; + color: var(--text-2); + font-size: 12px; + } + .notice { + margin: 0 0 12px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-2); + line-height: 1.5; + } + .notice.error { + color: var(--danger-text); + border-color: color-mix(in srgb, var(--danger) 40%, transparent); + } + section { + margin: 0 0 16px; + } + .session { + @include m.panel; + display: grid; + gap: 8px; + padding: 10px 12px; + } + .session p { + margin: 0; + } + .session-head { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; + justify-content: space-between; + } + .actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + h2 { + margin: 0; + color: var(--text-strong); + font-size: 14px; + font-weight: 600; + } + .section-title { + margin: 0 0 8px; + } + .findings { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + } + .finding { + @include m.panel; + display: grid; + gap: 4px; + min-width: 0; + padding: 8px 12px; + } + .head, + .meta { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + align-items: center; + min-width: 0; + } + .meta { + font-size: 12px; + } + .meta code { + overflow-wrap: anywhere; + } + .subject { + min-width: 0; + color: var(--text-strong); + overflow-wrap: anywhere; + } + .times { + color: var(--text-2); + font-weight: 600; + font-variant-numeric: tabular-nums; + } + .why { + margin: 0; + color: var(--text); + line-height: 1.5; + overflow-wrap: anywhere; + } + .chip { + display: inline-flex; + align-items: center; + padding: 1px 8px; + border: 1px solid transparent; + border-radius: 99px; + font-size: 11px; + font-weight: 600; + @include m.tint(accent); + } + .chip.plain { + border-color: var(--border); + background: var(--surface-2); + color: var(--text-2); + } + .chip[data-tone='danger'] { + @include m.tint(danger); + } + .chip[data-tone='warn'] { + @include m.tint(warn); + } + .link { + padding: 0; + border: 0; + border-radius: 2px; + background: none; + color: var(--accent-text); + font: inherit; + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + } + .link:focus-visible { + @include m.focus-ring; + } + .numbers { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 6px; + margin: 0; + } + .numbers div { + @include m.panel; + padding: 6px 10px; + } + .numbers dt { + color: var(--text-2); + font-size: 12px; + } + .numbers dd { + margin: 0; + color: var(--text-strong); + font-size: 15px; + font-weight: 600; + font-variant-numeric: tabular-nums; + } + .coverage { + display: grid; + gap: 4px; + margin: 0; + padding: 0 0 0 18px; + color: var(--text-2); + font-size: 12px; + } + .coverage strong { + color: var(--text); + font-weight: 600; + } + .empty, + .state { + margin: 0 0 12px; + color: var(--text-2); + } + .empty p, + .state p { + margin: 0 0 8px; + } + `, +}) +export class AuditInspector { + readonly rpc = input(null); + /** Whether the Components tab is on, so "Show component" has somewhere to go. */ + readonly canShowComponent = input(true); + readonly showComponent = output(); + + private readonly destroyRef = inject(DestroyRef); + private readonly hostPageId = hostPageId(); + private unsubscribe?: () => void; + private seq = 0; + private highlighted = false; + + protected readonly time = time; + protected readonly toolCall = toolCall; + protected readonly areaLabel = AREA_LABEL; + protected readonly tone = SEVERITY_TONE; + protected readonly severityOptions: SelectOption[] = [ + { value: 'all', label: 'All severities' }, + { value: 'error', label: 'Errors' }, + { value: 'warning', label: 'Warnings and errors' }, + ]; + + readonly data = signal(null); + readonly loaded = signal(false); + readonly failed = signal(false); + readonly busy = signal(false); + readonly axeRunning = signal(false); + readonly axeError = signal(null); + readonly sessionError = signal(null); + readonly filter = signal(''); + readonly area = signal('all'); + readonly severity = signal('all'); + /** The page picked here, else the page the panel sits on, else the server's most recent. */ + readonly pageId = linkedSignal({ + source: () => this.data(), + computation: (data, previous) => + previous?.value && data?.pages.some((page) => page.pageId === previous.value) + ? previous.value + : (data?.report?.pageId ?? this.hostPageId ?? ''), + }); + + readonly staticReport = computed(() => isStaticReport(this.rpc())); + readonly report = computed(() => this.data()?.report ?? null); + readonly findings = computed(() => this.report()?.findings ?? []); + readonly before = computed(() => this.data()?.session?.before ?? null); + readonly after = computed(() => this.data()?.session?.after ?? null); + readonly diff = computed(() => this.data()?.session?.diff ?? null); + readonly pageOptions = computed(() => + (this.data()?.pages ?? []).map((page) => ({ + value: page.pageId, + label: page.title || page.url || page.pageId, + ...(page.url && page.url !== (page.title || page.url) ? { hint: page.url } : {}), + })), + ); + readonly areaOptions = computed(() => [ + { value: 'all', label: 'All areas' }, + ...AUDIT_AREAS.map((area) => ({ + value: area, + label: AREA_LABEL[area], + hint: String(this.findings().filter((finding) => finding.area === area).length), + })), + ]); + readonly shown = computed(() => { + const text = this.filter().trim().toLowerCase(); + const area = this.area(); + const floor = this.severity() === 'all' ? 2 : SEVERITY_RANK[this.severity() as 'error']; + return this.findings().filter( + (finding) => + (area === 'all' || finding.area === area) && + SEVERITY_RANK[finding.severity] <= floor && + (!text || + `${finding.subject ?? ''} ${finding.rule} ${finding.why}`.toLowerCase().includes(text)), + ); + }); + readonly countText = computed( + () => `${this.shown().length} of ${this.findings().length} findings`, + ); + readonly numbers = computed(() => { + const metrics = this.report()?.metrics ?? {}; + return METRICS.filter((metric) => metrics[metric.key] !== undefined).map((metric) => ({ + label: metric.unit === '%' ? `${metric.label} (%)` : metric.label, + value: `${metrics[metric.key]}${metric.unit === 'ms' ? ' ms' : ''}`, + })); + }); + readonly coverage = computed(() => + (this.report()?.sources ?? []).filter((source) => source.state !== 'ok'), + ); + readonly axeStatus = computed(() => { + if (this.axeRunning()) return 'Running axe on the page…'; + const axe = this.data()?.axe; + if (!axe) return 'No axe pass on this page yet. Run axe to check accessibility.'; + return `Last axe pass at ${time(axe.at)}: ${axe.violations.length} ${axe.violations.length === 1 ? 'rule' : 'rules'} broken.`; + }); + readonly sessionText = computed(() => { + const before = this.before(); + const after = this.after(); + if (!before) { + return 'Take a baseline, make your change, then take an after audit. Run axe and record change detection the same way both times, so the numbers compare.'; + } + const base = `Baseline at ${time(before.at)} with ${before.findings.length} findings.`; + return after + ? `${base} After audit at ${time(after.at)} with ${after.findings.length} findings.` + : `${base} Make your change, then take an after audit.`; + }); + + constructor() { + effect(() => { + const client = this.rpc(); + // Only the client is a dependency: the fetch reads the picked page, which must not re-run this. + if (client && !isStaticReport(client)) untracked(() => void this.subscribe(client)); + }); + this.destroyRef.onDestroy(() => { + this.unsubscribe?.(); + this.highlight(null); + }); + } + + isComponentArea(area: AuditArea): boolean { + return COMPONENT_AREAS.includes(area); + } + + clearFilters() { + this.filter.set(''); + this.area.set('all'); + this.severity.set('all'); + } + + pickPage(pageId: string | null) { + if (!pageId) return; + this.pageId.set(pageId); + void this.refresh(); + } + + async refresh(): Promise { + const client = this.rpc(); + if (!client) return; + const seq = ++this.seq; + this.failed.set(false); + try { + const value = await rpcCall(client, 'get-audit', { pageId: this.pageId() || undefined }); + if (seq !== this.seq || this.destroyRef.destroyed) return; + const data = toAuditResponse(value); + // The panel's own page may not report (yet): fall back to the server's most recent. + if (!data.report && data.pages.length && this.pageId()) { + this.pageId.set(''); + return this.refresh(); + } + this.data.set(data); + } catch { + if (seq === this.seq) this.failed.set(true); + } finally { + if (seq === this.seq) this.loaded.set(true); + } + } + + async runAxe() { + const client = this.rpc(); + if (!client || this.axeRunning()) return; + this.axeRunning.set(true); + this.axeError.set(null); + try { + const result = (await rpcCall(client, 'request-axe-run', { + pageId: this.pageId() || undefined, + })) as { ok?: boolean; error?: string } | null; + if (!result?.ok) this.axeError.set(result?.error ?? 'The axe pass did not run.'); + } catch { + this.axeError.set("Can't reach the devtools server to run axe."); + } finally { + this.axeRunning.set(false); + } + await this.refresh(); + } + + async mark(action: 'before' | 'after' | 'clear') { + const client = this.rpc(); + if (!client) return; + this.busy.set(true); + this.sessionError.set(null); + try { + const result = (await rpcCall(client, 'audit-session', { + pageId: this.pageId() || undefined, + action, + })) as { ok?: boolean; error?: string } | null; + if (!result?.ok) this.sessionError.set(result?.error ?? 'The session did not change.'); + } catch { + this.sessionError.set("Can't reach the devtools server."); + } finally { + this.busy.set(false); + } + await this.refresh(); + } + + highlight(finding: AuditFinding | null, reveal = false) { + const client = this.rpc(); + if (!client) return; + const pageId = this.pageId(); + const target = + finding?.elementId && pageId + ? { pageId, id: finding.elementId, ...(reveal ? { reveal: true } : {}) } + : null; + // Clear only a box this view drew. + if (!target && !this.highlighted) return; + this.highlighted = !!target; + void rpcCall(client, 'request-page-highlight', target).catch(() => {}); + } + + private async subscribe(client: DevframeRpcClient) { + await this.refresh(); + try { + // Sessions and axe passes change from agents too; fetch again when they do. + const state = await client.scope('pangular').rpc.sharedState('audit'); + if (this.destroyRef.destroyed) return; + this.unsubscribe?.(); + this.unsubscribe = state.on('updated', () => void this.refresh()); + } catch { + // Without the shared state the Refresh button still works. + } + } +} diff --git a/app/src/pages/audit-types.ts b/app/src/pages/audit-types.ts new file mode 100644 index 00000000..a7a0b7db --- /dev/null +++ b/app/src/pages/audit-types.ts @@ -0,0 +1,156 @@ +/** The audit as the `get-audit` RPC returns it (`rpc/audit-findings.ts` in the package). */ + +export type AuditSeverity = 'error' | 'warning' | 'info'; + +export type AuditArea = + | 'accessibility' + | 'change-detection' + | 'leaks' + | 'errors' + | 'router' + | 'forms' + | 'hydration' + | 'http' + | 'pipes'; + +export const AUDIT_AREAS: readonly AuditArea[] = [ + 'accessibility', + 'errors', + 'hydration', + 'change-detection', + 'leaks', + 'router', + 'forms', + 'http', + 'pipes', +]; + +export const AREA_LABEL: Record = { + accessibility: 'Accessibility', + 'change-detection': 'Change detection', + leaks: 'Leaks', + errors: 'Errors', + router: 'Router', + forms: 'Forms', + hydration: 'Hydration', + http: 'HTTP', + pipes: 'Pipes', +}; + +export interface AuditFinding { + key: string; + severity: AuditSeverity; + area: AuditArea; + rule: string; + subject?: string; + why: string; + tool: string; + args?: Record; + count?: number; + elementId?: string; +} + +export type AuditMetrics = Partial>; + +/** The numbers in the order the panel lists them, with their labels. */ +export const METRICS: readonly { key: string; label: string; unit?: string }[] = [ + { key: 'cdCycles', label: 'Change detection cycles' }, + { key: 'cdMeanMs', label: 'Mean cycle time', unit: 'ms' }, + { key: 'cdMaxMs', label: 'Slowest cycle', unit: 'ms' }, + { key: 'wastedChecks', label: 'Wasted checks' }, + { key: 'wastedPercent', label: 'Wasted checks', unit: '%' }, + { key: 'retained', label: 'Retained components' }, + { key: 'openSubscriptions', label: 'Open subscriptions after destroy' }, + { key: 'errorGroups', label: 'Error groups' }, + { key: 'errors', label: 'Error occurrences' }, + { key: 'axeViolations', label: 'axe rules broken' }, + { key: 'axeNodes', label: 'axe elements' }, + { key: 'httpFailures', label: 'Failed HTTP calls' }, + { key: 'findingsError', label: 'Errors found' }, + { key: 'findingsWarning', label: 'Warnings found' }, + { key: 'findingsInfo', label: 'Info found' }, +]; + +export interface AuditSource { + area: AuditArea; + state: 'ok' | 'empty' | 'off'; + note?: string; +} + +export interface AuditReport { + pageId?: string; + url?: string; + title?: string; + at: number; + findings: AuditFinding[]; + metrics: AuditMetrics; + sources: AuditSource[]; + axeAt?: number; +} + +export interface AuditSnapshot { + at: number; + url?: string; + findings: AuditFinding[]; + metrics: AuditMetrics; + axe: boolean; +} + +export interface MetricChange { + metric: string; + before?: number; + after?: number; + delta?: number; +} + +export interface AuditDiff { + resolved: AuditFinding[]; + added: AuditFinding[]; + remaining: (AuditFinding & { countBefore?: number })[]; + metrics: MetricChange[]; + notes: string[]; +} + +export interface AuditPage { + pageId: string; + url?: string; + title?: string; +} + +export interface AuditResponse { + pages: AuditPage[]; + report: AuditReport | null; + session?: { + before: AuditSnapshot | null; + after: AuditSnapshot | null; + diff: AuditDiff | null; + }; + axe?: { at: number; ms: number; violations: unknown[] } | null; +} + +/** The detail tool of a finding as an agent would call it, such as `explain-error { id: "e1" }`. */ +export function toolCall(finding: AuditFinding): string { + const args = Object.entries(finding.args ?? {}) + .filter(([key]) => key !== 'page') + .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) + .join(', '); + return `${finding.tool.replace(/^pangular:/, '')}${args ? ` { ${args} }` : ''}`; +} + +/** Reads a `get-audit` answer, dropping what does not fit. */ +export function toAuditResponse(value: unknown): AuditResponse { + const record = (value ?? {}) as Partial; + const pages = Array.isArray(record.pages) + ? record.pages.filter((page): page is AuditPage => typeof page?.pageId === 'string') + : []; + const report = + record.report && Array.isArray(record.report.findings) && Array.isArray(record.report.sources) + ? record.report + : null; + return { + pages, + report, + session: record.session ?? { before: null, after: null, diff: null }, + axe: record.axe ?? null, + }; +} diff --git a/app/src/pages/tab-icon.ts b/app/src/pages/tab-icon.ts index 1594a061..ec40cae2 100644 --- a/app/src/pages/tab-icon.ts +++ b/app/src/pages/tab-icon.ts @@ -27,6 +27,10 @@ import { Component, input } from '@angular/core'; } + @case ('audit') { + + + } @case ('components') { diff --git a/app/src/types/tab.types.ts b/app/src/types/tab.types.ts index 8f35e260..59858f74 100644 --- a/app/src/types/tab.types.ts +++ b/app/src/types/tab.types.ts @@ -1,6 +1,7 @@ export type Tab = | 'dashboard' | 'timeline' + | 'audit' | 'components' | 'pipes' | 'routes' diff --git a/apps/docs/src/content/agents/resources.md b/apps/docs/src/content/agents/resources.md index 9b444703..edc2f413 100644 --- a/apps/docs/src/content/agents/resources.md +++ b/apps/docs/src/content/agents/resources.md @@ -96,6 +96,7 @@ This table covers the data that has no resource of its own. | `pangular:pipe-usage` | Live pipe instances and recorded calls | | `pangular:errors` | Error groups and console calls, by page id | | `pangular:leaks` | Leak hints: retained instances, open subscriptions, effects, by page id | +| `pangular:audit` | Audit sessions and axe passes: when each was taken, by page id | | `pangular:analog` | Analog page data and the server call log | | `pangular:routes` | Declared but not filled. Use `get-routes` or `list-routes` instead. | diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index 609b3292..69f6da33 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -4,7 +4,7 @@ description: Every agent tool the devtools expose, grouped by inspector, with wh --- - Seventy-five tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel. + Seventy-six tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel. # Tools @@ -53,11 +53,11 @@ The URL comes from the HTTP or router report. A page that sends neither (no rout Gives the user a link that opens the panel on one tab with one item selected, and switches the panels that are open to it. Reads: page. Use it to point the user at what you found. -| Argument | Required | What it is | -| -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `tab` | Yes | The tab: `dashboard`, `timeline`, `components`, `routes`, `signals`, `injectors`, `store`, `forms`, `pipes`, `network` (SSR & HTTP), `errors`, `analog` or `capacitor`. Only tabs that are on count. | -| `id` | No | The item to select: a component id on `components`, the id of the component whose element injector to show on `injectors`, a form id on `forms`. Other tabs ignore it. | -| `page` | No | The page the item belongs to, from `list-pages`. | +| Argument | Required | What it is | +| -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tab` | Yes | The tab: `dashboard`, `timeline`, `audit`, `components`, `routes`, `signals`, `injectors`, `store`, `forms`, `pipes`, `network` (SSR & HTTP), `errors`, `analog` or `capacitor`. Only tabs that are on count. | +| `id` | No | The item to select: a component id on `components`, the id of the component whose element injector to show on `injectors`, a form id on `forms`. Other tabs ignore it. | +| `page` | No | The page the item belongs to, from `list-pages`. | The answer is a full URL, such as `http://localhost:4200/__devframes/pangular/#tab=components&id=12&page=k3x9`, when the hub serves the panel and a page has reported its address. Otherwise it is the hash to add to the panel's address. Each panel that is open and not in a background tab opens the link at once. The tool is `read`: it changes what the panel shows, not the app. See [Deep links](../getting-started/popup-and-hub.md#deep-links). @@ -65,21 +65,21 @@ The answer is a full URL, such as `http://localhost:4200/__devframes/pangular/#t Every tool has one of three safety tiers. The server sends the tier to your client as the tool's read-only and destructive hints. -| Tier | What the tool can do | Tools | -| ------------- | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `read` | Reads your source, the page or the server. `highlight` only shows and selects a component. | Every other tool, and every resource | -| `action` | Changes the running page, the server's fault rules or what the page records. A reload or the next call undoes it. | `navigate`, `dispatch-ngrx-action`, `form-action`, `fill-form`, `set-http-rule`, `remove-http-rule`, `set-component-property`, `set-signal`, `watch-signal`, `record-pipes`, `track-subscriptions`, `change-detection` and `hydration` | -| `destructive` | Can lose data: it erases recordings, or sends writes to your own API routes. | `clear-recordings` and `analog-call-api` | +| Tier | What the tool can do | Tools | +| ------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `read` | Reads your source, the page or the server. `highlight` only shows and selects a component. | Every other tool, and every resource | +| `action` | Changes the running page, the server's fault rules or what the page records. A reload or the next call undoes it. | `navigate`, `dispatch-ngrx-action`, `form-action`, `fill-form`, `set-http-rule`, `remove-http-rule`, `set-component-property`, `set-signal`, `watch-signal`, `record-pipes`, `track-subscriptions`, `change-detection`, `hydration` and `audit` | +| `destructive` | Can lose data: it erases recordings, or sends writes to your own API routes. | `clear-recordings` and `analog-call-api` | - The fifteen action and destructive tools act on the app. Your client can ask you before it runs them. + The sixteen action and destructive tools act on the app. Your client can ask you before it runs them. -When the hub is mounted, each finished call of an `action` or `destructive` tool posts an `Agent ran ` message to the hub, so you can see what the agent changed. `change-detection` posts only when it starts, stops or clears a recording, or turns a live view on or off, and `hydration` only when it turns the overlay on or off. See [Messages and badges](../getting-started/popup-and-hub.md#messages-and-badges). +When the hub is mounted, each finished call of an `action` or `destructive` tool posts an `Agent ran ` message to the hub, so you can see what the agent changed. `change-detection` posts only when it starts, stops or clears a recording, or turns a live view on or off, `hydration` only when it turns the overlay on or off, and `audit` only when it runs axe. See [Messages and badges](../getting-started/popup-and-hub.md#messages-and-badges). ### Turn tools off -The server decides which tools exist. Set `agent.readOnly` to keep only the `read` tools. `change-detection` stays as a `read` tool without its `record`, `flash`, `track` and `angularProfiling` arguments, so it reads a recording the panel started. `hydration` stays as a `read` tool without its `overlay` argument. Set `agent.tools.` to `false` to hide one inspector's tools and resources, and keep its tab. Turning an inspector off with `inspectors`, or blocking an action with `actions`, drops the matching tools too. `actions.router` is the exception: `navigate` stays for `instrument` and `resolve-lazy`, and refuses `navigate`, `abort`, `replay` and `probe`. See [Inspectors and agent tools](../getting-started/configuration.md#inspectors-and-agent-tools). +The server decides which tools exist. Set `agent.readOnly` to keep only the `read` tools. `change-detection` stays as a `read` tool without its `record`, `flash`, `track` and `angularProfiling` arguments, so it reads a recording the panel started. `hydration` stays as a `read` tool without its `overlay` argument, and `audit` without its `axe` argument, so it reads the last axe pass the panel ran. Set `agent.tools.` to `false` to hide one inspector's tools and resources, and keep its tab. Turning an inspector off with `inspectors`, or blocking an action with `actions`, drops the matching tools too. `actions.router` is the exception: `navigate` stays for `instrument` and `resolve-lazy`, and refuses `navigate`, `abort`, `replay` and `probe`. See [Inspectors and agent tools](../getting-started/configuration.md#inspectors-and-agent-tools). ## Source scan @@ -630,6 +630,26 @@ The explainers are the ones the [Errors](../inspectors/errors.md#explainers) tab `code` accepts `NG0100`, `ng100` or `NG01203`. `explain-error` says so when Angular's error reference has no page for a code, and gives no known cause then. Pass the owner's instance id to `inspect-component` for its inputs and state. `list-errors` takes `limit` up to 100 (default 30), and `list-console` up to 200 (default 50). Messages, stacks and console text are masked like other live values. +## Audit + +### audit Action + +One ranked list of what is wrong on the page, gathered from the other inspectors, with an axe pass over the app on request. See [Audit](../inspectors/audit.md). Reads: page. + +Each finding has a severity, an area, the component, route or URL it is about, one line on why, and the tool to call for detail, with its arguments. The answer also lists key numbers (mean and slowest change detection cycle, wasted checks, retained components, open subscriptions after destroy, errors, axe violations and failed HTTP calls) and the areas it could not read, under **Coverage**. + +| Argument | Required | Value | +| ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `axe` | no | `true` runs axe-core over the app first. Without it, the audit uses the last axe pass on the page. | +| `session` | no | `before` saves this audit as the page's baseline. `after` audits again and compares with the baseline. `clear` drops the baseline. | +| `area` | no | `accessibility`, `errors`, `hydration`, `change-detection`, `leaks`, `router`, `forms`, `http` or `pipes`. `accessibility` also lists the elements that break each axe rule. | +| `severity` | no | `error`, `warning` or `info`: only findings at that severity or above. | +| `limit` | no | Rows to list, 1 to 200. Default 30. | + +The audit reuses each inspector's own checks: `diagnose-change-detection` hints and checks slower than 16 ms, `find-leaks`, `explain-error`, `hydration`, `lint-routes`, `lint-forms`, `list-http-calls` and `lint-pipes`. Change detection numbers need a recording from `change-detection`. Retention is not judged on a page whose Angular keeps DI debug data, the same as `find-leaks`. An area whose inspector is off, or whose agent tools are hidden with `agent.tools.: false`, is left out. + +The axe pass loads axe-core into the page on demand and changes nothing in the app. It leaves out the overlay's own elements and frames, masks selectors, attributes and summaries, and leaves out text content. With `agent.readOnly`, the tool has no `axe` argument and is marked read-only. A session is kept per page for 30 minutes after its last use, so it survives a reload of the tab. Comparing only works within one page id. See [Inspect, fix and verify](../guides/inspect-fix-verify.md) for the loop. + ## Analog These tools cover *Analog apps. Most read your source. Some also read what the Vite plugin recorded. diff --git a/apps/docs/src/content/guides/inspect-fix-verify.md b/apps/docs/src/content/guides/inspect-fix-verify.md new file mode 100644 index 00000000..10ebdc7d --- /dev/null +++ b/apps/docs/src/content/guides/inspect-fix-verify.md @@ -0,0 +1,120 @@ +--- +title: Inspect, fix and verify +description: Let an agent audit the running app, fix what it finds and prove the fix with a before and after comparison. +--- + + + Audit the live page, change the code, audit again and compare. The agent sees what it fixed, and what it broke. + + +# Inspect, fix and verify + +This guide walks an agent through one loop with the `audit` tool: take a baseline of the running page, fix a finding in the code, and compare the page after the change with the baseline. You can run the same loop by hand on the [Audit](../inspectors/audit.md) tab. + +## Before you start + + + + The agent connects to the MCP endpoint of the server that runs the app. The stdio server has no page. + + + The app is open with the overlay loaded, in a development build. + + + agent.readOnly is off, so the agent can run axe and record change detection. + + + +## The loop + + + + Find the page id with list-pages. + + + Start a change detection recording and use the app the way the user does. + + + Call audit with session: "before" and axe: true. + + + Call the detail tool each finding names, then change the code. + + + Wait for the reload, record again and use the app the same way. + + + Call audit with session: "after" and axe: true. + + + +## Step 1: Pick the page + +Call `list-pages`. If more than one tab reports, pass its id as `page` to every call below. Without `page`, each tool uses the most recent page, which can change when you open another tab. + +## Step 2: Record what you measure + +The audit reads what the inspectors already hold. Errors, hydration, router, form and HTTP findings are there as soon as the page reports. Change detection numbers need a recording: + +```json +{"tool": "pangular:change-detection", "arguments": {"record": "start", "page": "k3x9"}} +``` + +Then use the app: open the screens and click what the change is about. Subscriptions after destroy need tracking, so call `track-subscriptions` with `action: "start"` too if leaks matter to the change. + +## Step 3: Take the baseline + +```json +{"tool": "pangular:audit", "arguments": {"session": "before", "axe": true, "page": "k3x9"}} +``` + +The answer is the ranked list of findings, the key numbers and what the audit could not check. It saves the audit as the page's baseline for 30 minutes after its last use. A reload keeps the page id, so the baseline survives the rebuild after your change. + +## Step 4: Read the detail and fix + +Each finding names the tool that explains it, with the arguments to pass, such as `explain-error { id: "e1" }` or `diagnose-change-detection`. For accessibility findings, call `audit` with `area: "accessibility"`: it lists the elements that break each axe rule, their component and instance id, and a link to the rule. Pass an instance id to `inspect-component`, or to `highlight` to show the user where it is. + +Fix one area at a time, so the comparison says what each change did. + +## Step 5: Repeat the same steps + +After the reload, the recording, the leak data and the error list start again. Start a change detection recording and use the app the same way as before the change. The numbers only compare when the page did the same work. + +## Step 6: Compare + +```json +{"tool": "pangular:audit", "arguments": {"session": "after", "axe": true, "page": "k3x9"}} +``` + +The answer adds a **Before and after** section: + +| Part | What it shows | +| ----------- | ----------------------------------------------------------------------------------------------------------- | +| Numbers | Each number before and after, with the difference marked better or worse. Lower is better for every number. | +| Resolved | Findings the baseline had and the after audit doesn't. | +| New | Findings only the after audit has. Look at these before you call the change done. | +| Still there | Findings in both. When a count moved, it lists those findings, otherwise the first five. | + +When only one of the two audits ran axe, accessibility findings are not compared, and a note says so. Call `audit` with `session: "after"` again after another fix: it compares with the same baseline. Call it with `session: "before"` to start over from the current state, or `session: "clear"` to drop the baseline. + +## Show the user + +Call `open-in-panel` with `tab: "audit"` to give the user a link to the same list and comparison in the panel. + +## Gotchas + + + With agent.readOnly, audit has no axe argument and change-detection can't record. Run axe and the recording from the panel, and the agent's audit reads them. + + +- Findings are matched by area, rule and subject, not by position. A renamed component or route counts as one finding resolved and one new. +- An area whose inspector or agent tools are off is left out, and **Coverage** names it. +- Pipe findings come from the source scan, so they change only when the source does. + +## Where to next + + + + + + diff --git a/apps/docs/src/content/inspectors/audit.md b/apps/docs/src/content/inspectors/audit.md new file mode 100644 index 00000000..aee059e3 --- /dev/null +++ b/apps/docs/src/content/inspectors/audit.md @@ -0,0 +1,145 @@ +--- +title: Audit +description: One ranked list of what the inspectors found on the running page, an axe pass over the app, and a before and after comparison of a change. +--- + + + Everything the inspectors found wrong on the page in one ranked list, with an accessibility pass and a way to measure a fix. + + +# Audit + +The Audit tab gathers the findings of the other inspectors into one list, ranked by severity. It adds an axe-core pass over the app when you run one. Take a baseline before a change and an after audit once it is in, and the tab compares the two: what was resolved, what is new, and how the key numbers moved. + +Agents get the same list from the `audit` tool. See [Agent tools](#agent-tools). + +## What it shows + +### Findings + +Each row is one finding: + +| Part | What it shows | +| -------------- | ------------------------------------------------------------------------------------------------------------------- | +| Severity | **error**, **warning** or **info**. | +| Area | The inspector it comes from, or **Accessibility** for the axe pass. | +| Subject | The component class, route, form, URL or pipe it is about. | +| Count | How many times or places it occurs, as **3×**. | +| Why | One line on why it is a problem. Values are masked like other live data. | +| Rule | The check's id, such as `onpush-candidate`, `wildcard-not-last` or `image-alt`. | +| Agent tool | The tool that explains the finding in detail, with its arguments, such as `explain-error { id: "e1" }`. | +| Show component | For errors and hydration mismatches, opens the [Components](./components.md) tab on the component. | +| Show in page | For other findings that map to an element, such as an axe violation, scrolls the page to it and draws a box around. | + +Errors come first, then warnings, then info. Within a severity, accessibility comes first, then errors, hydration, change detection, leaks, router, forms, HTTP and pipes, and then the finding that occurs most often. Filter by text, **Area** or **Severity**, and press `Escape` to clear the search. Hovering a row with an element outlines that element in the app. + +### Where the findings come from + +The audit runs no check of its own. It reads what each inspector already collected and turns its results into findings. + +| Area | Findings | Detail tool | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | +| Accessibility | Each axe rule the app breaks, per component, with the rule's impact as severity: critical and serious are errors, moderate a warning, minor info. | `audit` with `area` | +| Errors | Each error group, with its count and owning component. | `explain-error` | +| Hydration | Mismatches, server HTML that was not hydrated at all, and hosts that skip hydration. | `hydration` | +| Change detection | The hints of the [change detection diagnosis](./components.md#change-detection): OnPush candidates, inputs mutated in place, components that depend on zone.js. Also components whose slowest check took 16 ms or more. | `diagnose-change-detection` | +| Leaks | Components still retained after destroy, and subscriptions still open after their component went away. | `find-leaks` | +| Router | The findings of [Router Lint](./router.md), redirect loops included. | `lint-routes` | +| Forms | The findings of Forms Lint, and NG01xxx setup errors. | `lint-forms` | +| HTTP | Failed `HttpClient` calls by endpoint, calls a fault rule failed, and calls the browser fetched again because the transfer cache did not keep them. | `list-http-calls` | +| Pipes | The findings of the pipe lint, from the source scan. | `lint-pipes` | + +### Key numbers + +Under the list, the tab shows the numbers a before and after comparison reads: + +| Number | Source | +| -------------------------------- | ---------------------------------------------------------------------------- | +| Change detection cycles | The cycles of the current recording. | +| Mean cycle time, slowest cycle | The duration of those cycles, in ms. | +| Wasted checks | Refreshed checks that changed nothing, as a count and a share of all checks. | +| Retained components | Destroyed components still in memory after enough navigations. | +| Open subscriptions after destroy | Counted while subscription tracking is on. | +| Error groups, error occurrences | The errors the page hit since it loaded. | +| axe rules broken, axe elements | From the last axe pass. | +| Failed HTTP calls | Failed calls the page made, cancelled calls and fault rules left out. | +| Errors, warnings and info found | The findings by severity. | + +A number shows only when its area could be read. **Not checked** lists the areas the audit could not read and why, such as a missing change detection recording or an inspector that is turned off. + +### Run axe + +**Run axe** runs [axe-core](https://github.com/dequelabs/axe-core) over the app on the page. The overlay loads axe-core only then, so a page that never runs a pass doesn't download it. The pass: + +- Leaves out the overlay's own elements: the popup, the change detection flash layer, the hydration outline and the highlight box. +- Skips frames, and reports violations only. +- Reads the DOM and changes nothing in the app. +- Masks selectors, attributes and axe's summaries, and leaves out text content and `value` attributes. +- Names the component whose template holds each element. + +The tab keeps the last pass of each page, and the status line says when it ran. A page whose overlay has no axe pass, or that takes more than 60 seconds, gets an error that says so. + +### Before and after + + + + Run axe and record change detection if you want their numbers, then click Take baseline. + + + Edit the code. The page reloads with the same page id, so the baseline stays. + + + Run axe and record the same interactions again, then click Take after audit. + + + +The comparison shows each number before and after, with the change marked **better** or **worse** (lower is better for every number). Under it, **Resolved** lists findings the after audit no longer has, **New** the ones only it has, and **Changed count** the findings whose count moved. When only one of the two audits ran axe, accessibility findings are left out of the comparison, and a note says so. Taking another baseline drops the after audit. **Clear** drops both. + +### Pages + +When several tabs report, **Page** picks the one to audit. The panel of a tab starts on that tab. + +## Agent tools + +| Tool | What it does | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pangular:audit` | The ranked findings with the tool to call for each, the key numbers and what could not be checked. `axe: true` runs a fresh axe pass first. `session` takes a baseline (`before`), compares (`after`) or `clear`s it. | + +The tool is an `action` tool because of `axe`. With `agent.readOnly`, it has no `axe` argument, reads the last pass the panel ran, and is marked read-only. An area whose agent tools are hidden with `agent.tools.: false` is left out of the agent's answer, and the panel still shows it. See [Tools](../agents/tools.md#audit) and [Inspect, fix and verify](../guides/inspect-fix-verify.md). + +## Limits and gotchas + + + Messages, routes, URLs and axe selectors are masked like other live values: secret field names, JWTs, bearer tokens and secret key=value pairs. See Access and redaction. + + +### Caps + +| What | Kept | +| ------------------------ | ------------------------------------------------ | +| Findings per audit | 300, the most severe first. | +| axe rules per pass | 100, the ones with the most elements first. | +| Elements per axe rule | 20. The count still covers all of them. | +| Pages with a session | 10. The one unused the longest goes first. | +| Session and axe pass age | 30 minutes after their last use, then forgotten. | + +### Numbers depend on what you did + +Change detection numbers come from the current recording, and leak and error counts start again when the page reloads. To compare a change, do the same things on the page before each audit, and record change detection for both. + +### Retention with DI debug data + +On Angular 16.2 and later in a development build, Angular's DI debug data keeps every destroyed component in memory, so the audit, like [find-leaks](./components.md#leaks), doesn't judge retention there. Open subscriptions after destroy still count. + +### It needs a live page + +The audit reads what connected pages report. A [static report](../getting-started/cli.md#static-report) has no page, so the tab says so. + +## Where to next + + + + + + + diff --git a/apps/docs/src/ngmd.config.ts b/apps/docs/src/ngmd.config.ts index f5f526b7..da58148a 100644 --- a/apps/docs/src/ngmd.config.ts +++ b/apps/docs/src/ngmd.config.ts @@ -209,6 +209,7 @@ const config: NgmdConfig = { {label: 'Analog', href: '/inspectors/analog', status: 'new'}, {label: 'Capacitor', href: '/inspectors/capacitor', status: 'new'}, {label: 'Errors', href: '/inspectors/errors', status: 'new'}, + {label: 'Audit', href: '/inspectors/audit', status: 'new'}, ], }, { @@ -228,6 +229,7 @@ const config: NgmdConfig = { {label: 'Set up NativeScript', href: '/guides/nativescript'}, {label: 'Set up Angular Native', href: '/guides/angular-native'}, {label: 'Set up Capacitor', href: '/guides/capacitor', status: 'new'}, + {label: 'Inspect, fix and verify', href: '/guides/inspect-fix-verify', status: 'new'}, ], }, { diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 46d8463e..a66a9d87 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -132,6 +132,7 @@ "@devframes/plugin-terminals": "^1.2.3", "@devframes/plugin-terminals--assets": "^1.2.3", "@valibot/to-json-schema": "^1.8.0", + "axe-core": "^4.13.0", "cac": "^7.0.0", "devframe": "^1.2.3", "h3": "^1.15.11", diff --git a/packages/devtools/src/__tests__/audit-findings.test.ts b/packages/devtools/src/__tests__/audit-findings.test.ts new file mode 100644 index 00000000..204adfe5 --- /dev/null +++ b/packages/devtools/src/__tests__/audit-findings.test.ts @@ -0,0 +1,513 @@ +import { describe, expect, it } from 'vitest'; +import type { AxeReport } from '../axe-runner.ts'; +import type { ComponentPage, HttpState } from '../types.ts'; +import { toAxeReport, toAxeRunResult } from '../rpc/audit-axe.ts'; +import { buildAudit, rankFindings, type AuditFinding } from '../rpc/audit-findings.ts'; +import { + createAuditSessions, + diffAudits, + snapshotOf, + type AuditSnapshot, +} from '../rpc/audit-sessions.ts'; +import type { CdState } from '../rpc/cd-tools.ts'; +import type { ErrorsState } from '../rpc/errors-tools.ts'; +import type { FormsState } from '../rpc/forms-tools.ts'; +import { RETAINED_AFTER_MS, type LeaksPageView, type LeaksState } from '../rpc/leaks-tools.ts'; +import type { RouterState } from '../rpc/router-tools.ts'; + +const JWT = ['eyJhbGciOiJIUzI1NiJ9', 'eyJzdWIiOiIxMjM0NSJ9', 'c2lnbmF0dXJlc2ln'].join('.'); +const NOW = 1_000_000; + +const cd = (): CdState => ({ + pages: { + tab1: { + pageId: 'tab1', + supported: true, + recording: false, + startedAt: 1, + reportedAt: NOW, + dropped: 0, + hosts: {}, + cycles: [ + { id: 1, at: 1, ms: 4, passes: 1, checks: 3, components: [] }, + { id: 2, at: 2, ms: 20, passes: 1, checks: 3, components: [] }, + ], + components: [ + { name: 'TripList', checks: 40, ms: 60, maxMs: 25, cycles: 2 }, + { name: 'Banner', checks: 2, ms: 1, maxMs: 1, cycles: 2 }, + ], + diagnosis: { + triggers: true, + waste: true, + causes: { event: 2 }, + components: [ + { name: 'TripList', refreshed: 40, unchanged: 38, strategy: 'Eager' }, + { name: 'Banner', refreshed: 10, unchanged: 9, strategy: 'OnPush' }, + ], + inputs: [ + { name: 'TripCard', input: 'trip', parent: 'TripList', inPlace: 3, sameContents: 0 }, + ], + zone: [{ name: 'Clock', ticks: 4, tasks: ['setInterval'] }], + }, + }, + }, +}); + +const leaksPage = (retention: LeaksPageView['support']['retention']): LeaksPageView => ({ + pageId: 'tab1', + url: '/trips', + title: 'Trips', + at: NOW, + reportedAt: NOW, + support: { retention, effects: true, subscriptions: 'on' }, + navigations: 4, + totals: { components: 1, effects: 0, subscriptions: 2, destroyed: 1, collected: 0, dropped: 0 }, + components: [], + destroyed: [ + { + id: 'c9', + name: 'TripCard', + route: '/trips/7', + destroyedAt: NOW - RETAINED_AFTER_MS - 1000, + navigations: 3, + }, + ], + subscriptions: [ + { + name: 'TripCard', + count: 2, + route: '/trips/7', + openedAt: NOW - 5000, + reason: 'instance-destroyed', + source: 'TripCard.ngOnInit (trip-card.ts:12:7)', + }, + ], +}); + +const errors = (): ErrorsState => ({ + pages: { + tab1: { + pageId: 'tab1', + url: '/trips', + title: 'Trips', + reportedAt: NOW, + handler: 'wrapped', + dropped: { errors: 0, console: 0 }, + console: {}, + errors: { + e1: { + id: 'e1', + message: 'NullInjectorError: No provider for TripApi!', + code: 'NG0201', + count: 3, + firstAt: 1, + lastAt: 2, + sources: ['handler'], + owner: { id: 'c1', name: 'TripList' }, + explain: { kind: 'missing-provider', token: 'TripApi' }, + }, + }, + }, + }, +}); + +const router = (): RouterState => + ({ + pages: [ + { + pageId: 'tab1', + reportedAt: NOW, + changedAt: NOW, + snapshot: null, + navigations: [], + config: [ + { id: 'r1', path: '**', fullPath: '/**', kind: 'redirect', redirectTo: '' }, + { + id: 'r2', + path: 'trips', + fullPath: '/trips', + kind: 'component', + component: 'Trips', + title: 'Trips', + }, + ], + }, + ], + }) as unknown as RouterState; + +const forms = (): FormsState => ({ + forms: [], + events: [], + reportedAt: NOW, + setupErrors: [ + { pageId: 'tab1', message: 'NG01203: No value accessor for form control name: email' }, + { pageId: 'other', message: 'NG01050: formControlName must be used with a parent' }, + ], +}); + +const components = (): ComponentPage => + ({ + pageId: 'tab1', + reportedAt: NOW, + roots: [], + count: 0, + detail: null, + hydration: { + hydrated: true, + counts: { hydrated: 4, skipped: 2, mismatched: 1, client: 0, dehydratedBlocks: 0 }, + mismatches: [{ id: 'c5', name: 'TripMap', tag: 'app-trip-map' }], + }, + }) as ComponentPage; + +const call = (extra: Record) => ({ + id: String(Math.random()), + url: '/api/trips?id=1', + method: 'GET', + status: 200, + durationMs: 5, + side: 'client', + cacheHit: false, + faulted: false, + at: NOW, + ...extra, +}); + +const http = (): HttpState => + ({ + serverCalls: [call({ side: 'server', url: '/api/cities', cacheSkip: 'post' })], + requests: [], + rules: [], + pages: [ + { + pageId: 'tab1', + url: '/trips', + initialUrl: '/trips', + title: 'Trips', + hydration: null, + firstSeenAt: NOW, + reportedAt: NOW, + calls: [ + call({ status: 500, url: `/api/trips?token=${JWT}` }), + call({ status: 500, url: '/api/trips?id=2' }), + call({ status: 404, url: '/api/photos/1' }), + call({ status: 0, cancelled: true, url: '/api/search' }), + call({ status: 503, faulted: true, url: '/api/slow' }), + call({ url: '/api/cities' }), + ], + }, + ], + }) as unknown as HttpState; + +const axe = (): AxeReport => ({ + at: NOW, + ms: 120, + url: '/trips', + violations: [ + { + id: 'image-alt', + impact: 'critical', + help: 'Images must have alternative text', + tags: ['wcag2a'], + count: 3, + nodes: [ + { target: 'img.a', html: '', owner: { id: 'c2', name: 'TripPhoto' } }, + { target: 'img.b', html: '', owner: { id: 'c2', name: 'TripPhoto' } }, + ], + }, + { + id: 'region', + impact: 'moderate', + help: 'All page content should be contained by landmarks', + tags: ['best-practice'], + count: 1, + nodes: [{ target: 'p', html: '

' }], + }, + ], +}); + +const keys = (findings: AuditFinding[]) => findings.map((finding) => finding.key); + +describe('buildAudit', () => { + it('gathers every area into one list ranked by severity, area and count', () => { + const report = buildAudit( + { + cd: cd(), + leaks: { pages: { tab1: leaksPage(true) } }, + errors: errors(), + router: router(), + forms: forms(), + components: components(), + http: http(), + pipes: () => [ + { + rule: 'json-pipe', + severity: 'info', + pipe: 'json', + file: 'src/app/trips.ts', + line: 4, + message: '`| json` is a debugging aid.', + fix: 'Remove it.', + }, + ], + axe: axe(), + }, + 'tab1', + { url: '/trips', title: 'Trips' }, + NOW, + ); + const ranks = { error: 0, warning: 1, info: 2 }; + const severities = report.findings.map((finding) => ranks[finding.severity]); + expect(severities).toEqual([...severities].sort((a, b) => a - b)); + expect(report.findings[0]).toMatchObject({ + area: 'accessibility', + rule: 'image-alt', + subject: 'TripPhoto', + count: 2, + }); + expect(keys(report.findings)).toEqual( + expect.arrayContaining([ + 'accessibility:image-alt:(more elements)', + 'accessibility:region:p', + 'errors:NG0201:TripList', + 'hydration:hydration-mismatch:TripMap', + 'hydration:hydration-skipped:', + 'change-detection:onpush-candidate:TripList', + 'change-detection:onpush-marked-often:Banner', + 'change-detection:input-mutated:TripCard', + 'change-detection:zone-dependent:Clock', + 'change-detection:slow-component:TripList', + 'leaks:retained-after-destroy:TripCard', + 'leaks:subscription-after-destroy:TripCard', + 'router:wildcard-not-last:/**', + 'forms:setup-error:NG01203: No value accessor for form control name: email', + 'http:request-failed:GET /api/trips', + 'http:client-error:GET /api/photos/1', + 'http:faulted-by-rule:GET /api/slow', + 'http:fetched-twice:GET /api/cities', + 'pipes:json-pipe:json in src/app/trips.ts:4', + ]), + ); + // The other page's setup error and the cancelled call are not findings. + expect(JSON.stringify(report)).not.toContain('NG01050'); + expect(JSON.stringify(report)).not.toContain('/api/search'); + expect(JSON.stringify(report)).not.toContain(JWT); + const failed = report.findings.find((finding) => finding.rule === 'request-failed'); + expect(failed).toMatchObject({ count: 2, severity: 'error', tool: 'pangular:list-http-calls' }); + const error = report.findings.find((finding) => finding.area === 'errors'); + expect(error).toMatchObject({ + tool: 'pangular:explain-error', + args: { page: 'tab1', id: 'e1' }, + elementId: 'c1', + count: 3, + }); + expect(error!.why).toContain('No provider for TripApi'); + expect(report.metrics).toMatchObject({ + cdCycles: 2, + cdMeanMs: 12, + cdMaxMs: 20, + wastedChecks: 47, + wastedPercent: 94, + retained: 1, + openSubscriptions: 2, + errorGroups: 1, + errors: 3, + axeViolations: 2, + axeNodes: 4, + httpFailures: 3, + }); + expect(report.sources.every((source) => source.state === 'ok')).toBe(true); + expect(report.axeAt).toBe(NOW); + }); + + it('judges no retention when Angular keeps DI debug data, and says so', () => { + const report = buildAudit( + { leaks: { pages: { tab1: leaksPage('dev-di-debug') } } }, + 'tab1', + {}, + NOW, + ); + expect(keys(report.findings)).toEqual(['leaks:subscription-after-destroy:TripCard']); + expect(report.metrics.retained).toBeUndefined(); + expect(report.sources.find((source) => source.area === 'leaks')?.note).toMatch(/DI debug data/); + }); + + it('names the areas it could not read: off, empty and waiting for a recording or a pass', () => { + const report = buildAudit({ cd: { pages: {} }, components: null, axe: null }, 'tab1', {}, NOW); + const state = Object.fromEntries(report.sources.map((s) => [s.area, s.state])); + expect(state).toMatchObject({ + accessibility: 'empty', + 'change-detection': 'empty', + hydration: 'empty', + errors: 'off', + router: 'off', + forms: 'off', + http: 'off', + pipes: 'off', + leaks: 'off', + }); + expect(report.findings).toEqual([]); + expect(report.sources.find((s) => s.area === 'change-detection')?.note).toContain( + 'record: "start"', + ); + }); + + it('keeps finding keys the same across audits of a reloaded page', () => { + const first = buildAudit({ errors: errors() }, 'tab1', {}, NOW); + const reloaded = errors(); + reloaded.pages['tab1']!.errors = { e7: { ...reloaded.pages['tab1']!.errors['e1']!, id: 'e7' } }; + const second = buildAudit({ errors: reloaded }, 'tab1', {}, NOW + 1000); + expect(keys(second.findings)).toEqual(keys(first.findings)); + }); +}); + +describe('rankFindings', () => { + it('puts errors first, then by area, then the most frequent', () => { + const finding = (key: string, extra: Partial): AuditFinding => ({ + key, + severity: 'warning', + area: 'http', + rule: key, + why: '', + tool: 'x', + ...extra, + }); + const ranked = rankFindings([ + finding('a', { severity: 'info' }), + finding('b', { count: 2 }), + finding('c', { count: 9 }), + finding('d', { severity: 'error', area: 'pipes' }), + finding('e', { area: 'accessibility' }), + ]); + expect(keys(ranked)).toEqual(['d', 'e', 'c', 'b', 'a']); + }); +}); + +const snapshot = (findings: AuditFinding[], metrics: object, axeRan = true): AuditSnapshot => ({ + at: NOW, + findings, + axe: axeRan, + metrics: { findingsError: 0, findingsWarning: 0, findingsInfo: 0, ...metrics }, +}); + +describe('diffAudits', () => { + const f = (key: string, area: AuditFinding['area'] = 'errors', count?: number): AuditFinding => ({ + key, + severity: 'error', + area, + rule: key, + why: key, + tool: 'x', + ...(count ? { count } : {}), + }); + + it('lists resolved, new and remaining findings and each number before and after', () => { + const diff = diffAudits( + snapshot([f('a'), f('b', 'errors', 4)], { cdMeanMs: 10, errors: 4 }), + snapshot([f('b', 'errors', 1), f('c')], { cdMeanMs: 6.5, errors: 1, retained: 0 }), + ); + expect(keys(diff.resolved)).toEqual(['a']); + expect(keys(diff.added)).toEqual(['c']); + expect(diff.remaining).toEqual([ + expect.objectContaining({ key: 'b', count: 1, countBefore: 4 }), + ]); + expect(diff.metrics).toEqual( + expect.arrayContaining([ + { metric: 'cdMeanMs', before: 10, after: 6.5, delta: -3.5 }, + { metric: 'errors', before: 4, after: 1, delta: -3 }, + { metric: 'retained', after: 0 }, + ]), + ); + }); + + it('does not compare accessibility when only one side ran axe', () => { + const diff = diffAudits(snapshot([f('x', 'accessibility')], {}, true), snapshot([], {}, false)); + expect(diff.resolved).toEqual([]); + expect(diff.notes.join(' ')).toContain('Only the before audit ran axe'); + }); +}); + +describe('createAuditSessions', () => { + it('keeps a baseline per page, drops the after audit on a new baseline, and expires', () => { + let changes = 0; + const sessions = createAuditSessions({ ttlMs: 1000, onChange: () => changes++ }); + const base = snapshot([], {}); + sessions.mark('tab1', 'before', base, 0); + sessions.mark('tab1', 'after', base, 10); + expect(sessions.get('tab1')?.after).toBe(base); + sessions.mark('tab1', 'before', base, 20); + expect(sessions.get('tab1')?.after).toBeUndefined(); + sessions.mark('tab2', 'before', base, 900); + sessions.expire(1500); + expect(sessions.get('tab1')).toBeUndefined(); + expect(sessions.get('tab2')).toBeDefined(); + expect(changes).toBeGreaterThanOrEqual(5); + expect(sessions.summary().pages['tab2']).toMatchObject({ before: { at: NOW, findings: 0 } }); + }); + + it('keeps at most maxPages, dropping the least recently used', () => { + const sessions = createAuditSessions({ maxPages: 2 }); + const base = snapshot([], {}); + sessions.mark('a', 'before', base); + sessions.mark('b', 'before', base); + sessions.mark('a', 'after', base); + sessions.mark('c', 'before', base); + expect(Object.keys(sessions.summary().pages).sort()).toEqual(['a', 'c']); + }); + + it('marks a page as running while an axe pass is out', () => { + const sessions = createAuditSessions(); + sessions.setRunning('tab1', true); + expect(sessions.summary().pages['tab1']?.running).toBe(true); + sessions.setRunning('tab1', false); + expect(sessions.summary().pages['tab1']).toBeUndefined(); + }); + + it('snapshots an audit with whether axe ran', () => { + const report = buildAudit({ axe: axe() }, 'tab1', { url: '/trips' }, NOW); + expect(snapshotOf(report)).toMatchObject({ axe: true, url: '/trips', at: NOW }); + }); +}); + +describe('toAxeReport', () => { + it('masks again on the server and drops links and ids it does not trust', () => { + const report = toAxeReport({ + at: NOW, + ms: 50, + url: `/trips?token=${JWT}`, + violations: [ + { + id: 'image-alt', + impact: 'critical', + help: `Images must have alternative text ${JWT}`, + helpUrl: 'https://evil.example/phish', + tags: ['wcag2a', ' Pangular Inspector - + From 2b485dc08e60e8faf87d85eed1674174326c054a Mon Sep 17 00:00:00 2001 From: erKam Date: Tue, 13 Oct 2026 07:10:46 +0000 Subject: [PATCH 3/5] fix(mcp): address audit review findings Don't compare a stale axe pass, see pages only errors, forms or leaks report, cache the pipe scan, keep sessions alive on reads, store full snapshots, hide component owners when that inspector is off, shorten the axe timeout below the client's, and keep counts out of shared state. --- app/src/__tests__/audit-inspector.test.ts | 21 ++- app/src/pages/audit-inspector.ts | 32 ++++- app/src/pages/audit-types.ts | 4 +- apps/docs/src/content/agents/tools.md | 4 +- .../content/getting-started/popup-and-hub.md | 3 +- .../src/content/guides/inspect-fix-verify.md | 2 +- apps/docs/src/content/inspectors/audit.md | 34 ++--- .../src/__tests__/audit-findings.test.ts | 46 ++++++- .../src/__tests__/audit-tools.test.ts | 121 +++++++++++++++++- .../devtools/src/__tests__/axe-runner.test.ts | 32 ++++- .../src/__tests__/page-owners.test.ts | 12 ++ .../src/__tests__/pipe-lint-cache.test.ts | 20 +++ packages/devtools/src/axe-runner.ts | 12 ++ packages/devtools/src/devframe.ts | 24 +++- packages/devtools/src/overlay.ts | 5 +- packages/devtools/src/rpc/audit-audience.ts | 45 +++++++ packages/devtools/src/rpc/audit-axe.ts | 8 +- packages/devtools/src/rpc/audit-findings.ts | 7 +- packages/devtools/src/rpc/audit-register.ts | 93 ++++++++++++-- packages/devtools/src/rpc/audit-sessions.ts | 69 ++++++---- packages/devtools/src/rpc/audit-text.ts | 19 ++- packages/devtools/src/rpc/page-owners.ts | 10 ++ packages/devtools/src/rpc/pipe-lint-cache.ts | 25 ++++ 23 files changed, 558 insertions(+), 90 deletions(-) create mode 100644 packages/devtools/src/__tests__/pipe-lint-cache.test.ts create mode 100644 packages/devtools/src/rpc/audit-audience.ts create mode 100644 packages/devtools/src/rpc/pipe-lint-cache.ts diff --git a/app/src/__tests__/audit-inspector.test.ts b/app/src/__tests__/audit-inspector.test.ts index 68c04f23..fb04cca5 100644 --- a/app/src/__tests__/audit-inspector.test.ts +++ b/app/src/__tests__/audit-inspector.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 { AuditInspector } from '../pages/audit-inspector'; +import { AUDIT_REFRESH_DELAY_MS, AuditInspector } from '../pages/audit-inspector'; import type { AuditFinding, AuditResponse } from '../pages/audit-types'; const NOW = Date.UTC(2026, 9, 13, 10, 0, 0); @@ -232,15 +232,32 @@ describe('AuditInspector', () => { }); }); - it('reads the audit again when a session or axe pass changes elsewhere', async () => { + it('reads the audit again once for a burst of session or axe pass changes', async () => { start(); await settle(); expect(listeners).toHaveLength(1); + // An axe pass starting, its result and its end arrive together. + listeners[0]!(); + listeners[0]!(); listeners[0]!(); await settle(); + expect(calls.filter((c) => c.name === 'get-audit')).toHaveLength(1); + await new Promise((resolve) => setTimeout(resolve, AUDIT_REFRESH_DELAY_MS + 20)); + await settle(); expect(calls.filter((c) => c.name === 'get-audit')).toHaveLength(2); }); + it('offers no Show component, Show in page nor outline with the Components inspector off', async () => { + start(); + fixture.componentRef.setInput('canShowComponent', false); + await settle(); + expect(button('Show component')).toBeUndefined(); + expect(button('Show in page')).toBeUndefined(); + host().querySelector('.finding')!.dispatchEvent(new Event('mouseenter')); + await settle(); + expect(calls.some((c) => c.name === 'request-page-highlight')).toBe(false); + }); + it('says what to do without a page, after an error, and in a static report', async () => { answers['get-audit'] = { pages: [], report: null }; start(); diff --git a/app/src/pages/audit-inspector.ts b/app/src/pages/audit-inspector.ts index 1791c5a3..dd92566c 100644 --- a/app/src/pages/audit-inspector.ts +++ b/app/src/pages/audit-inspector.ts @@ -34,6 +34,12 @@ const SEVERITY_TONE = { error: 'danger', warning: 'warn', info: 'accent' } as co const SEVERITY_RANK = { error: 0, warning: 1, info: 2 } as const; /** Areas whose findings point at a component host the Components tab can select. */ const COMPONENT_AREAS: readonly AuditArea[] = ['errors', 'hydration']; +/** + * How long the panel waits after a change to the `audit` shared state before + * reading the audit again, so a burst (an axe pass starting, its result, it + * ending) costs one read. + */ +export const AUDIT_REFRESH_DELAY_MS = 150; @Component({ selector: 'app-audit-inspector', @@ -187,8 +193,9 @@ const COMPONENT_AREAS: readonly AuditArea[] = ['errors', 'hydration']; Agent tool {{ toolCall(finding) }} - @if (finding.elementId) { - @if (canShowComponent() && isComponentArea(finding.area)) { + + @if (finding.elementId && canShowComponent()) { + @if (isComponentArea(finding.area)) {