|
| 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 | +}); |
0 commit comments