Skip to content

Commit 7568797

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/phase4-plugins
# Conflicts: # app/src/app.ts # apps/docs/src/content/agents/resources.md # apps/docs/src/content/agents/tools.md # apps/docs/src/content/getting-started/configuration.md # apps/docs/src/ngmd.config.ts # extension/ui/assets/browser-agent-rpc-BXhoSh1z-85BPnAAT.js # extension/ui/assets/browser-agent-rpc-BXhoSh1z-BsWRTvIZ.js # extension/ui/assets/browser-agent-rpc-BXhoSh1z-Cq6OpQlN.js # extension/ui/assets/index-CyKw4x-n.js # extension/ui/assets/index-DtaWkdLM.js # extension/ui/assets/index-IFTmL_6d.js # extension/ui/index.html # packages/devtools/src/__tests__/docs-tool-count.test.ts # packages/devtools/src/config.ts # packages/devtools/src/devframe.ts # packages/devtools/src/overlay.ts
2 parents d90c209 + 87c8ae4 commit 7568797

90 files changed

Lines changed: 10773 additions & 223 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,17 @@ Pangular Inspector inspects a running Angular app and serves what it finds to a
1414

1515
### Commands
1616

17-
| Command | What it runs |
18-
| ---------------------- | ------------------------------------------------------- |
19-
| `pnpm test:devtools` | Package tests |
20-
| `pnpm test:panel` | Panel tests |
21-
| `pnpm test:axe` | Builds a static report and runs axe on every panel view |
22-
| `pnpm typecheck` | TypeScript and template checks for every project |
23-
| `pnpm format:check` | Prettier, including the docs markdown |
24-
| `pnpm skills:check` | Checks the skills and roles in `.claude` |
25-
| `pnpm docs:build` | Builds the docs site with its link guards |
26-
| `pnpm extension:build` | Rebuilds `extension/ui` from `app` |
27-
| `pnpm commit:check` | Checks the commit messages on the branch |
17+
| Command | What it runs |
18+
| ---------------------- | ------------------------------------------------------------------------- |
19+
| `pnpm test:devtools` | Package tests |
20+
| `pnpm test:panel` | Panel tests |
21+
| `pnpm test:axe` | Builds a static report and runs axe on every panel view |
22+
| `pnpm typecheck` | TypeScript and template checks for every project |
23+
| `pnpm format:check` | Prettier, including the docs markdown |
24+
| `pnpm skills:check` | Checks the skills and roles in `.claude` and the skills the package ships |
25+
| `pnpm docs:build` | Builds the docs site with its link guards |
26+
| `pnpm extension:build` | Rebuilds `extension/ui` from `app` |
27+
| `pnpm commit:check` | Checks the commit messages on the branch |
2828

2929
The words this project uses for its own concepts (overlay, collector, hub, devframe, agent tool and so on) are defined in `docs/CONTEXT.md`. Use them the same way in code, docs and issues.
3030

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
2+
import type { DevframeRpcClient } from 'devframe/client';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { AUDIT_REFRESH_DELAY_MS, AuditInspector } from '../pages/audit-inspector';
5+
import type { AuditFinding, AuditResponse } from '../pages/audit-types';
6+
7+
const NOW = Date.UTC(2026, 9, 13, 10, 0, 0);
8+
9+
const finding = (extra: Partial<AuditFinding>): AuditFinding => ({
10+
key: `${extra.area ?? 'errors'}:${extra.rule ?? 'x'}:${extra.subject ?? ''}`,
11+
severity: 'error',
12+
area: 'errors',
13+
rule: 'NG0201',
14+
why: 'NullInjectorError: No provider for TripApi!',
15+
tool: 'pangular:explain-error',
16+
...extra,
17+
});
18+
19+
const findings: AuditFinding[] = [
20+
finding({
21+
area: 'accessibility',
22+
rule: 'image-alt',
23+
subject: 'TripPhoto',
24+
why: 'Images must have alternative text (critical).',
25+
tool: 'pangular:audit',
26+
args: { page: 'p1', area: 'accessibility' },
27+
count: 2,
28+
elementId: 'c1-9',
29+
}),
30+
finding({ subject: 'TripList', elementId: 'c1-2', args: { page: 'p1', id: 'e1' }, count: 3 }),
31+
finding({
32+
severity: 'warning',
33+
area: 'change-detection',
34+
rule: 'onpush-candidate',
35+
subject: 'TripList',
36+
why: 'TripList was refreshed 40 times and changed something in 2.',
37+
tool: 'pangular:diagnose-change-detection',
38+
}),
39+
];
40+
41+
function response(extra: Partial<AuditResponse> = {}): AuditResponse {
42+
return {
43+
pages: [{ pageId: 'p1', url: '/trips', title: 'Trips' }],
44+
report: {
45+
pageId: 'p1',
46+
url: '/trips',
47+
at: NOW,
48+
findings,
49+
metrics: { cdMeanMs: 4.5, errors: 3, findingsError: 2, findingsWarning: 1, findingsInfo: 0 },
50+
sources: [
51+
{ area: 'accessibility', state: 'ok' },
52+
{ area: 'router', state: 'off' },
53+
{ area: 'leaks', state: 'empty', note: 'No leak report yet.' },
54+
],
55+
},
56+
session: { before: null, after: null, diff: null },
57+
axe: null,
58+
...extra,
59+
};
60+
}
61+
62+
let fixture: ComponentFixture<AuditInspector>;
63+
let calls: { name: string; arg: unknown }[];
64+
let answers: Record<string, unknown>;
65+
let listeners: (() => void)[];
66+
const host = () => fixture.nativeElement as HTMLElement;
67+
const text = () => host().textContent!.replace(/\s+/g, ' ');
68+
const button = (label: string) =>
69+
[...host().querySelectorAll('button')].find((b) => b.textContent?.trim().startsWith(label))!;
70+
const settle = async () => {
71+
for (let i = 0; i < 4; i++) {
72+
await fixture.whenStable();
73+
await new Promise((resolve) => setTimeout(resolve, 0));
74+
}
75+
fixture.detectChanges();
76+
};
77+
78+
function start(backend = 'websocket') {
79+
const client = {
80+
connectionMeta: { backend },
81+
scope: () => ({
82+
rpc: {
83+
call: vi.fn(async (name: string, arg: unknown) => {
84+
calls.push({ name, arg });
85+
const answer = answers[name];
86+
return typeof answer === 'function' ? answer(arg) : answer;
87+
}),
88+
sharedState: async () => ({
89+
value: () => ({ pages: {} }),
90+
on: (_: string, listener: () => void) => {
91+
listeners.push(listener);
92+
return () => {};
93+
},
94+
}),
95+
},
96+
}),
97+
};
98+
fixture = TestBed.createComponent(AuditInspector);
99+
fixture.componentRef.setInput('rpc', client as unknown as DevframeRpcClient);
100+
}
101+
102+
describe('AuditInspector', () => {
103+
beforeEach(() => {
104+
calls = [];
105+
listeners = [];
106+
answers = { 'get-audit': response() };
107+
});
108+
109+
afterEach(() => {
110+
fixture?.destroy();
111+
TestBed.resetTestingModule();
112+
});
113+
114+
it('lists the findings ranked as the server sent them, with the agent tool for each', async () => {
115+
start();
116+
await settle();
117+
const rows = [...host().querySelectorAll('.finding')];
118+
expect(rows).toHaveLength(3);
119+
expect(rows[0]!.textContent).toContain('Accessibility');
120+
expect(rows[0]!.textContent).toContain('TripPhoto');
121+
expect(rows[0]!.textContent).toContain('2×');
122+
expect(rows[1]!.textContent).toContain('explain-error { id: "e1" }');
123+
expect(text()).toContain('3 of 3 findings');
124+
expect(text()).toContain('Mean cycle time4.5 ms');
125+
expect(text()).toContain('Router Its inspector is turned off.');
126+
expect(text()).toContain('Leaks No leak report yet.');
127+
expect(host().querySelector('h2')?.textContent).toContain('Before and after');
128+
expect(calls[0]).toEqual({ name: 'get-audit', arg: { pageId: undefined } });
129+
});
130+
131+
it('filters by text and shows Clear filters when nothing matches', async () => {
132+
start();
133+
await settle();
134+
const search = host().querySelector<HTMLInputElement>('input[type="search"]')!;
135+
search.value = 'onpush';
136+
search.dispatchEvent(new Event('input'));
137+
await settle();
138+
expect(host().querySelectorAll('.finding')).toHaveLength(1);
139+
expect(text()).toContain('1 of 3 findings');
140+
search.value = 'nothing like this';
141+
search.dispatchEvent(new Event('input'));
142+
await settle();
143+
expect(text()).toContain('No finding matches the filters.');
144+
button('Clear filters').click();
145+
await settle();
146+
expect(host().querySelectorAll('.finding')).toHaveLength(3);
147+
});
148+
149+
it('runs axe on the page and reads the audit again, or says why it did not run', async () => {
150+
answers['request-axe-run'] = { ok: true, violations: 1 };
151+
start();
152+
await settle();
153+
answers['get-audit'] = response({ axe: { at: NOW, ms: 30, violations: [{}] } });
154+
button('Run axe').click();
155+
await settle();
156+
expect(calls.map((c) => c.name)).toEqual(['get-audit', 'request-axe-run', 'get-audit']);
157+
expect(calls[1]!.arg).toEqual({ pageId: 'p1' });
158+
expect(text()).toContain('1 rule broken');
159+
160+
answers['request-axe-run'] = { ok: false, error: 'The page did not answer within 60s.' };
161+
button('Run axe').click();
162+
await settle();
163+
expect(host().querySelector('[role="alert"]')?.textContent).toContain('did not answer');
164+
});
165+
166+
it('takes a baseline and an after audit, and shows the comparison', async () => {
167+
answers['audit-session'] = { ok: true };
168+
start();
169+
await settle();
170+
expect(button('Take after audit').disabled).toBe(true);
171+
const before = { at: NOW, findings, metrics: {}, axe: true };
172+
answers['get-audit'] = response({ session: { before, after: null, diff: null } });
173+
button('Take baseline').click();
174+
await settle();
175+
expect(calls.find((c) => c.name === 'audit-session')?.arg).toEqual({
176+
pageId: 'p1',
177+
action: 'before',
178+
});
179+
expect(text()).toContain('Baseline at');
180+
expect(button('Take after audit').disabled).toBe(false);
181+
182+
answers['get-audit'] = response({
183+
session: {
184+
before,
185+
after: { ...before, findings: findings.slice(1) },
186+
diff: {
187+
resolved: [findings[0]!],
188+
added: [],
189+
remaining: [{ ...findings[1]!, count: 1, countBefore: 3 }],
190+
metrics: [
191+
{ metric: 'cdMeanMs', before: 6, after: 4.5, delta: -1.5 },
192+
{ metric: 'errors', before: 3, after: 4, delta: 1 },
193+
{ metric: 'retained', after: 0 },
194+
],
195+
notes: [],
196+
},
197+
},
198+
});
199+
button('Take after audit').click();
200+
await settle();
201+
const rows = [...host().querySelectorAll('tbody tr')].map((row) =>
202+
[...row.children].map((cell) => cell.textContent!.trim()).join(' | '),
203+
);
204+
expect(rows).toEqual([
205+
'Mean cycle time | 6 ms | 4.5 ms | −1.5 ms, better',
206+
'Retained components | Not measured | 0 | Not compared',
207+
'Error occurrences | 3 | 4 | +1, worse',
208+
]);
209+
expect(text()).toContain('Resolved (1)');
210+
expect(text()).toContain('image-alt in TripPhoto');
211+
expect(text()).toContain('NG0201 in TripList: 3× → 1×');
212+
expect([...host().querySelectorAll('h3')].map((h) => h.textContent?.trim())).toEqual([
213+
'Resolved (1)',
214+
'New (0)',
215+
'Changed count (1)',
216+
]);
217+
});
218+
219+
it('opens a component for errors and highlights an element for axe findings', async () => {
220+
start();
221+
await settle();
222+
const emitted: unknown[] = [];
223+
fixture.componentInstance.showComponent.subscribe((focus) => emitted.push(focus));
224+
button('Show component').click();
225+
expect(emitted).toEqual([{ id: 'c1-2', pageId: 'p1' }]);
226+
button('Show in page').click();
227+
await settle();
228+
expect(calls.find((c) => c.name === 'request-page-highlight')?.arg).toEqual({
229+
pageId: 'p1',
230+
id: 'c1-9',
231+
reveal: true,
232+
});
233+
});
234+
235+
it('reads the audit again once for a burst of session or axe pass changes', async () => {
236+
start();
237+
await settle();
238+
expect(listeners).toHaveLength(1);
239+
// An axe pass starting, its result and its end arrive together.
240+
listeners[0]!();
241+
listeners[0]!();
242+
listeners[0]!();
243+
await settle();
244+
expect(calls.filter((c) => c.name === 'get-audit')).toHaveLength(1);
245+
await new Promise((resolve) => setTimeout(resolve, AUDIT_REFRESH_DELAY_MS + 20));
246+
await settle();
247+
expect(calls.filter((c) => c.name === 'get-audit')).toHaveLength(2);
248+
});
249+
250+
it('offers no Show component, Show in page nor outline with the Components inspector off', async () => {
251+
start();
252+
fixture.componentRef.setInput('canShowComponent', false);
253+
await settle();
254+
expect(button('Show component')).toBeUndefined();
255+
expect(button('Show in page')).toBeUndefined();
256+
host().querySelector<HTMLElement>('.finding')!.dispatchEvent(new Event('mouseenter'));
257+
await settle();
258+
expect(calls.some((c) => c.name === 'request-page-highlight')).toBe(false);
259+
});
260+
261+
it('says what to do without a page, after an error, and in a static report', async () => {
262+
answers['get-audit'] = { pages: [], report: null };
263+
start();
264+
await settle();
265+
expect(text()).toContain('No page reports live data yet.');
266+
fixture.destroy();
267+
268+
answers['get-audit'] = () => Promise.reject(new Error('gone'));
269+
start();
270+
await settle();
271+
expect(text()).toContain("Can't read the audit");
272+
expect(button('Retry')).toBeTruthy();
273+
fixture.destroy();
274+
275+
start('static');
276+
await settle();
277+
expect(text()).toContain('a static report has none');
278+
});
279+
});

‎app/src/app.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { ComingSoon, type ComingSoonInfo } from './pages/coming-soon';
3232
import { CapacitorInspector } from './pages/capacitor-inspector';
3333
import { ErrorsInspector } from './pages/errors-inspector';
3434
import { PluginsInspector, pluginsReporting } from './pages/plugins-inspector';
35+
import { AuditInspector } from './pages/audit-inspector';
3536
import { Timeline } from './pages/timeline';
3637
import { capacitorPages } from './pages/capacitor-types';
3738
import { TabIcon } from './pages/tab-icon';
@@ -213,6 +214,7 @@ function readView(): View | null {
213214
CapacitorInspector,
214215
ErrorsInspector,
215216
PluginsInspector,
217+
AuditInspector,
216218
Timeline,
217219
ComingSoon,
218220
TabIcon,
@@ -508,6 +510,13 @@ function readView(): View | null {
508510
@case ('timeline') {
509511
<app-timeline [rpc]="rpc()" (open)="openLink($event)" />
510512
}
513+
@case ('audit') {
514+
<app-audit-inspector
515+
[rpc]="rpc()"
516+
[canShowComponent]="config().inspectors.components"
517+
(showComponent)="showComponent($event)"
518+
/>
519+
}
511520
@case ('components') {
512521
<app-component-tree
513522
[rpc]="rpc()"

0 commit comments

Comments
 (0)