qa-engineer 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/COMPATIBILITY.md +26 -12
  2. package/README.md +182 -84
  3. package/package.json +11 -20
  4. package/packages/engine/bin/qa-engine.mjs +560 -0
  5. package/packages/engine/lib/analysis/branding.mjs +187 -0
  6. package/packages/engine/lib/analysis/context.mjs +294 -0
  7. package/packages/engine/lib/analysis/contracts.mjs +149 -0
  8. package/packages/engine/lib/analysis/diff-guard.mjs +425 -0
  9. package/packages/engine/lib/analysis/discovery.mjs +220 -0
  10. package/packages/engine/lib/analysis/evidence.mjs +116 -0
  11. package/packages/engine/lib/analysis/har.mjs +124 -0
  12. package/packages/engine/lib/analysis/junit.mjs +126 -0
  13. package/packages/engine/lib/analysis/network.mjs +237 -0
  14. package/packages/engine/lib/analysis/redaction.mjs +127 -0
  15. package/packages/engine/lib/analysis/report-html.mjs +76 -0
  16. package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
  17. package/packages/engine/lib/analysis/xml.mjs +153 -0
  18. package/packages/engine/lib/analysis/zip.mjs +107 -0
  19. package/packages/engine/lib/artifacts/manager.mjs +453 -0
  20. package/packages/engine/lib/artifacts/mime.mjs +109 -0
  21. package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
  22. package/packages/engine/lib/diagnostics/engine.mjs +165 -0
  23. package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
  24. package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
  25. package/packages/engine/lib/diagnostics/repair.mjs +73 -0
  26. package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
  27. package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
  28. package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
  29. package/packages/engine/lib/frameworks/playwright.mjs +158 -0
  30. package/packages/engine/lib/report/components/charts.mjs +424 -0
  31. package/packages/engine/lib/report/components/evidence.mjs +207 -0
  32. package/packages/engine/lib/report/components/findings.mjs +258 -0
  33. package/packages/engine/lib/report/components/nav.mjs +99 -0
  34. package/packages/engine/lib/report/components/primitives.mjs +246 -0
  35. package/packages/engine/lib/report/components/runtime.mjs +246 -0
  36. package/packages/engine/lib/report/components/timeline.mjs +65 -0
  37. package/packages/engine/lib/report/core/model.mjs +270 -0
  38. package/packages/engine/lib/report/core/normalize.mjs +226 -0
  39. package/packages/engine/lib/report/core/sections.mjs +978 -0
  40. package/packages/engine/lib/report/export/bundle.mjs +293 -0
  41. package/packages/engine/lib/report/export/html.mjs +183 -0
  42. package/packages/engine/lib/report/export/machine.mjs +290 -0
  43. package/packages/engine/lib/report/export/markdown.mjs +323 -0
  44. package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
  45. package/packages/engine/lib/report/theme/css.mjs +529 -0
  46. package/packages/engine/lib/report/theme/tokens.mjs +137 -0
  47. package/packages/engine/lib/report/version.mjs +78 -0
  48. package/packages/engine/package.json +14 -0
  49. package/packages/installer/lib/agents/targets.mjs +90 -0
  50. package/packages/installer/lib/agents/user-level.mjs +80 -0
  51. package/packages/installer/lib/cli/flags.mjs +20 -2
  52. package/packages/installer/lib/commands/doctor.mjs +13 -20
  53. package/packages/installer/lib/commands/install.mjs +160 -91
  54. package/packages/installer/lib/commands/repair.mjs +10 -6
  55. package/packages/installer/lib/commands/self-test.mjs +4 -3
  56. package/packages/installer/lib/commands/uninstall.mjs +14 -8
  57. package/packages/installer/lib/commands/update.mjs +9 -4
  58. package/packages/installer/lib/commands/verify.mjs +13 -12
  59. package/packages/installer/lib/constants.mjs +13 -0
  60. package/packages/installer/lib/core/bundle.mjs +69 -92
  61. package/packages/installer/lib/core/conflict.mjs +5 -4
  62. package/packages/installer/lib/core/fs-safe.mjs +146 -6
  63. package/packages/installer/lib/core/integrity.mjs +59 -0
  64. package/packages/installer/lib/core/lockfile.mjs +19 -3
  65. package/packages/installer/lib/core/manifest.mjs +48 -57
  66. package/packages/installer/lib/core/plan.mjs +213 -0
  67. package/packages/installer/lib/core/qa-home.mjs +145 -0
  68. package/packages/installer/lib/core/scope.mjs +274 -0
  69. package/packages/installer/lib/core/validate-install.mjs +49 -31
  70. package/packages/installer/package.json +1 -1
  71. package/packages/installer/schemas/qa-lock.schema.json +119 -21
  72. package/shared/tooling/qa-tool.mjs +161 -0
  73. package/skills/qa-api/SKILL.md +5 -5
  74. package/skills/qa-api/references/deterministic-tooling.md +34 -32
  75. package/skills/qa-api/references/evidence-and-reporting.md +5 -5
  76. package/skills/qa-api/scripts/qa-tool.mjs +162 -0
  77. package/skills/qa-audit/SKILL.md +5 -5
  78. package/skills/qa-audit/references/deterministic-tooling.md +34 -32
  79. package/skills/qa-audit/references/evidence-and-reporting.md +5 -5
  80. package/skills/qa-audit/scripts/qa-tool.mjs +162 -0
  81. package/skills/qa-debug/SKILL.md +6 -6
  82. package/skills/qa-debug/references/deterministic-tooling.md +34 -32
  83. package/skills/qa-debug/references/diagnostic-engine.md +3 -3
  84. package/skills/qa-debug/references/evidence-and-reporting.md +5 -5
  85. package/skills/qa-debug/scripts/qa-tool.mjs +162 -0
  86. package/skills/qa-explore/SKILL.md +31 -15
  87. package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
  88. package/skills/qa-explore/references/api-replay.md +40 -1
  89. package/skills/qa-explore/references/deterministic-tooling.md +34 -32
  90. package/skills/qa-explore/references/evidence-and-reporting.md +5 -5
  91. package/skills/qa-explore/references/report-pipeline.md +266 -96
  92. package/skills/qa-explore/scripts/qa-tool.mjs +162 -0
  93. package/skills/qa-fix/SKILL.md +4 -4
  94. package/skills/qa-fix/references/deterministic-tooling.md +34 -32
  95. package/skills/qa-fix/references/diagnostic-engine.md +3 -3
  96. package/skills/qa-fix/references/evidence-and-reporting.md +5 -5
  97. package/skills/qa-fix/scripts/qa-tool.mjs +162 -0
  98. package/skills/qa-flaky/SKILL.md +4 -4
  99. package/skills/qa-flaky/references/deterministic-tooling.md +34 -32
  100. package/skills/qa-flaky/references/evidence-and-reporting.md +5 -5
  101. package/skills/qa-flaky/scripts/qa-tool.mjs +162 -0
  102. package/skills/qa-generate/references/evidence-and-reporting.md +5 -5
  103. package/skills/qa-init/SKILL.md +4 -4
  104. package/skills/qa-init/references/deterministic-tooling.md +34 -32
  105. package/skills/qa-init/references/evidence-and-reporting.md +5 -5
  106. package/skills/qa-init/scripts/qa-tool.mjs +162 -0
  107. package/skills/qa-report/SKILL.md +7 -7
  108. package/skills/qa-report/references/deterministic-tooling.md +34 -32
  109. package/skills/qa-report/references/diagnostic-engine.md +3 -3
  110. package/skills/qa-report/references/evidence-and-reporting.md +5 -5
  111. package/skills/qa-report/scripts/qa-tool.mjs +162 -0
  112. package/skills/qa-review/references/evidence-and-reporting.md +5 -5
  113. package/skills/qa-run/SKILL.md +6 -6
  114. package/skills/qa-run/references/deterministic-tooling.md +34 -32
  115. package/skills/qa-run/references/evidence-and-reporting.md +5 -5
  116. package/skills/qa-run/scripts/qa-tool.mjs +162 -0
  117. package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
  118. package/shared/analysis/lib/qa_analysis/branding.py +0 -175
  119. package/shared/analysis/lib/qa_analysis/cli.py +0 -144
  120. package/shared/analysis/lib/qa_analysis/context.py +0 -233
  121. package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
  122. package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
  123. package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
  124. package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
  125. package/shared/analysis/lib/qa_analysis/har.py +0 -87
  126. package/shared/analysis/lib/qa_analysis/junit.py +0 -104
  127. package/shared/analysis/lib/qa_analysis/redaction.py +0 -107
  128. package/shared/analysis/lib/qa_analysis/report_html.py +0 -781
  129. package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
  130. package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
  131. package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
  132. package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
  133. package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
  134. package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
  135. package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
  136. package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
  137. package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
  138. package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
  139. package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
  140. package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
  141. package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
  142. package/shared/tooling/qa_tool.py +0 -127
  143. /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
  144. /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
  145. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
  146. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
  147. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/execution-result-min.schema.json +0 -0
@@ -0,0 +1,978 @@
1
+ // The section registry: every block of the report, in reading order.
2
+ //
3
+ // A section is `{ id, title, navLabel, count, note, body }` and it is *omitted
4
+ // entirely when its body is empty*. That rule is what lets one renderer serve a
5
+ // two-finding smoke check and a twelve-page authenticated audit without either one
6
+ // looking padded: a run that never measured performance has no Performance section,
7
+ // rather than a Performance section reading "no data".
8
+ //
9
+ // Order is the argument the report makes:
10
+ //
11
+ // verdict → what it means → what was covered → what is wrong → the measurements
12
+ // behind it → the evidence → what to do → where everything came from
13
+ //
14
+ // Decision first, provenance last. A reader who stops after the second section still
15
+ // leaves with the right conclusion, and a reader who doubts it can walk all the way
16
+ // down to the file hashes.
17
+
18
+ import {
19
+ e, kpi, scoreGauge, chip, statusPill, section, table, plural,
20
+ formatDuration, formatBytes, compareNatural, vitalTile,
21
+ } from '../components/primitives.mjs';
22
+ import {
23
+ severityDonut, horizontalBars, waterfall, vitalsBullets, pageHealthBars, chartCard,
24
+ pathOf, truncate,
25
+ } from '../components/charts.mjs';
26
+ import { findingList, severityLegend, findingAnchor, dimensionLabel } from '../components/findings.mjs';
27
+ import { timelineList } from '../components/timeline.mjs';
28
+ import { artifactFigure, artifactTable, evidenceLabel } from '../components/evidence.mjs';
29
+ import { SEVERITY_ORDER, VITALS } from '../theme/tokens.mjs';
30
+
31
+ const ADAPTER_PLAIN = Object.freeze({
32
+ 'playwright-mcp': 'a real browser driven by Playwright',
33
+ 'cursor-browser': "the editor's built-in browser",
34
+ cdp: 'a real browser over the Chrome DevTools Protocol',
35
+ 'cli-playwright': 'a real browser driven by the Playwright CLI',
36
+ 'cli-other': 'a real browser driven from the command line',
37
+ unavailable: 'no browser automation — findings come from artifacts supplied to the run',
38
+ });
39
+
40
+ const DIMENSION_PLAIN = Object.freeze({
41
+ functional: 'Does the feature do what it is supposed to do, including when the input is wrong?',
42
+ api: 'The network requests the page makes — whether they are correct, and how the page handles a bad response',
43
+ performance: 'How much the page downloads and how quickly it becomes usable',
44
+ security: 'Client-side exposure: where credentials and tokens are stored, what leaks into URLs and error messages',
45
+ ui: 'Layout and visual states — empty, loading, error — including a narrow mobile screen',
46
+ ux: 'Whether the flow makes sense to a person using it, and whether the wording helps them',
47
+ data: 'Whether the numbers and text on screen match the system of record behind them',
48
+ accessibility: 'Whether the page can be operated with a keyboard and understood by a screen reader',
49
+ console: 'What the browser reported while the page ran — errors, warnings, unhandled rejections',
50
+ });
51
+
52
+ const AUTH_METHOD_LABEL = Object.freeze({
53
+ none: 'None', form: 'Username and password form', sso: 'Single sign-on', oauth: 'OAuth',
54
+ saml: 'SAML', 'azure-ad': 'Microsoft Entra ID', okta: 'Okta', auth0: 'Auth0',
55
+ google: 'Google sign-in', microsoft: 'Microsoft sign-in', 'magic-link': 'Magic link',
56
+ jwt: 'JWT', cookie: 'Cookie session', session: 'Server session', basic: 'HTTP basic',
57
+ 'api-key': 'API key', unknown: 'Unrecognised',
58
+ });
59
+
60
+ const SESSION_SOURCE_PLAIN = Object.freeze({
61
+ 'manual-login': 'a person signed in during the run and the session was captured afterwards',
62
+ 'reused-session': 'a stored session from an earlier run was reused',
63
+ 'not-required': 'no sign-in was needed',
64
+ failed: 'sign-in did not succeed, so authenticated areas were not reached',
65
+ });
66
+
67
+ /* ── Overview ─────────────────────────────────────────────────────────────── */
68
+
69
+ function overviewSection(model) {
70
+ const counts = model.severityCounts;
71
+ const tiles = SEVERITY_ORDER.map((key) =>
72
+ kpi({ value: counts[key] ?? 0, label: key, variant: `sev-${key}` }),
73
+ );
74
+
75
+ const tests = model.testCases;
76
+ if (tests) {
77
+ for (const key of ['passed', 'failed', 'blocked', 'skipped']) {
78
+ if (tests[key] === undefined) continue;
79
+ tiles.push(kpi({ value: tests[key], label: key, variant: `st-${key}` }));
80
+ }
81
+ }
82
+
83
+ if (model.pages.length > 0) {
84
+ tiles.push(kpi({ value: model.pages.length, label: 'Pages tested', zeroMuted: false }));
85
+ }
86
+ if (model.network?.totalRequests !== undefined) {
87
+ tiles.push(kpi({
88
+ value: model.network.totalRequests,
89
+ label: 'Requests',
90
+ sub: model.network.totalBytes ? formatBytes(model.network.totalBytes) : null,
91
+ zeroMuted: false,
92
+ }));
93
+ }
94
+ if (model.console?.errors !== undefined) {
95
+ tiles.push(kpi({ value: model.console.errors, label: 'Console errors' }));
96
+ }
97
+ if (model.durationMs !== null) {
98
+ tiles.push(kpi({ value: formatDuration(model.durationMs), label: 'Duration', zeroMuted: false }));
99
+ }
100
+
101
+ const scoreOrder = ['overall', 'functional', 'performance', 'accessibility', 'security', 'ux'];
102
+ const gauges = scoreOrder
103
+ .filter((key) => Number.isFinite(model.scores[key]))
104
+ .map((key) => scoreGauge(model.scores[key], key === 'overall' ? 'Overall' : dimensionLabel(key) || key))
105
+ .join('');
106
+
107
+ const charts = [];
108
+ const donut = severityDonut(counts);
109
+ charts.push(chartCard({
110
+ title: 'Findings by severity',
111
+ body: donut.body,
112
+ legend: donut.legend,
113
+ }));
114
+
115
+ if (model.pages.length > 1) {
116
+ const health = pageHealthBars(model.pages, model.findings);
117
+ charts.push(chartCard({ title: 'Findings by page', body: health.body, legend: health.legend }));
118
+ }
119
+
120
+ if (model.performance) {
121
+ const bullets = vitalsBullets(model.performance);
122
+ charts.push(chartCard({
123
+ title: 'Core Web Vitals',
124
+ body: bullets.body,
125
+ legend: bullets.legend,
126
+ note: 'Measured in this run, against Google’s published thresholds.',
127
+ }));
128
+ }
129
+
130
+ const body =
131
+ `<div class="kpis reveal">${tiles.join('')}</div>` +
132
+ (gauges
133
+ ? `<div class="scores reveal" style="margin-top:.75rem">${gauges}</div>` +
134
+ (model.overallDerived
135
+ ? '<p class="section-note" style="margin:.75rem 0 0">Overall is derived from the finding counts ' +
136
+ '(100 less 35 per critical, 12 per high, 4 per medium, 1 per low). Every other score was measured.</p>'
137
+ : '')
138
+ : '') +
139
+ (charts.filter(Boolean).length > 0
140
+ ? `<div class="chart-grid reveal" style="margin-top:.75rem">${charts.join('')}</div>`
141
+ : '');
142
+
143
+ return { id: 'overview', title: 'Overview', navLabel: 'Overview', count: null, body };
144
+ }
145
+
146
+ /* ── Executive summary ────────────────────────────────────────────────────── */
147
+
148
+ function executiveSection(model) {
149
+ const verdict = model.verdict;
150
+ const parts = [];
151
+
152
+ if (verdict.health) parts.push(`<p>${e(verdict.health)}</p>`);
153
+ else if (model.summary) parts.push(`<p>${e(model.summary)}</p>`);
154
+
155
+ if (verdict.risks.length > 0) {
156
+ parts.push(
157
+ '<h4 style="margin-top:1.25rem">Why</h4>' +
158
+ `<ul class="clean">${verdict.risks.map((risk) => `<li>${e(risk)}</li>`).join('')}</ul>`,
159
+ );
160
+ }
161
+
162
+ const panels = [];
163
+ if (verdict.recommendedAction) {
164
+ panels.push(
165
+ `<div class="panel"><h4>Recommended action</h4><p>${e(verdict.recommendedAction)}</p></div>`,
166
+ );
167
+ }
168
+ if (verdict.estimatedFixHours) {
169
+ const { low, high } = verdict.estimatedFixHours;
170
+ const range = low === high ? `${low}` : `${low}–${high}`;
171
+ panels.push(
172
+ `<div class="panel"><h4>Estimated fix time</h4><p><strong>${e(range)} hours</strong> of engineering ` +
173
+ 'time to clear the blocking findings.</p></div>',
174
+ );
175
+ }
176
+ if (verdict.confidence !== null) {
177
+ const percent = Math.round(verdict.confidence * 100);
178
+ panels.push(
179
+ `<div class="panel"><h4>Confidence</h4><p><strong>${percent}%</strong> — ` +
180
+ `${e(percent >= 80 ? 'the run reproduced what it reports and measured what it claims.'
181
+ : percent >= 60 ? 'most findings were reproduced; a few rest on a single observation.'
182
+ : 'treat this as a first look; several findings need confirming before they are acted on.')}</p></div>`,
183
+ );
184
+ }
185
+
186
+ if (parts.length === 0 && panels.length === 0) return { id: 'summary', body: '' };
187
+
188
+ const body =
189
+ '<div class="card reveal"><div class="card-body">' +
190
+ parts.join('') +
191
+ (panels.length > 0 ? `<div class="subgrid">${panels.join('')}</div>` : '') +
192
+ (verdict.inferred
193
+ ? '<p class="section-note" style="margin:1.25rem 0 0">This verdict was derived from the finding ' +
194
+ 'counts — the run did not state one of its own.</p>'
195
+ : '') +
196
+ '</div></div>';
197
+
198
+ return { id: 'summary', title: 'Executive summary', navLabel: 'Summary', count: null, body };
199
+ }
200
+
201
+ /* ── Failing tests (release rollup) ───────────────────────────────────────── */
202
+
203
+ function failuresSection(model) {
204
+ if (!model.failures || model.failures.length === 0) return { id: 'failures', body: '' };
205
+ const rows = model.failures.map((failure) => (
206
+ '<tr>' +
207
+ `<td><code>${e(failure.test)}</code></td>` +
208
+ `<td>${chip(failure.classification)}</td>` +
209
+ `<td>${e(failure.reason)}</td>` +
210
+ '</tr>'
211
+ ));
212
+ return {
213
+ id: 'failures',
214
+ title: 'Failing tests',
215
+ navLabel: 'Failures',
216
+ count: model.failures.length,
217
+ body: table(['Test', 'Classification', 'Why it failed'], rows),
218
+ };
219
+ }
220
+
221
+ /* ── Engineering summary (release rollup) ─────────────────────────────────── */
222
+
223
+ function engineeringSection(model) {
224
+ const text = model.summaries?.engineering;
225
+ if (!text) return { id: 'engineering', body: '' };
226
+ return {
227
+ id: 'engineering',
228
+ title: 'Engineering summary',
229
+ navLabel: 'Engineering',
230
+ count: null,
231
+ body: `<div class="card reveal"><div class="card-body"><p>${e(text)}</p></div></div>`,
232
+ };
233
+ }
234
+
235
+ /* ── Coverage ─────────────────────────────────────────────────────────────── */
236
+
237
+ function coverageSection(model) {
238
+ const blocks = [];
239
+ const scope = model.scope ?? {};
240
+
241
+ const intro = [];
242
+ const adapter = ADAPTER_PLAIN[model.browserAdapter];
243
+ const how = [];
244
+ if (adapter) how.push(`opened the application in ${adapter}`);
245
+ if (model.testCases?.total) {
246
+ how.push(`worked through ${plural(model.testCases.total, 'test case')}`);
247
+ }
248
+ if (model.pages.length > 1) how.push(`visited ${plural(model.pages.length, 'page')}`);
249
+ how.push('captured proof for every defect it reports');
250
+ intro.push(
251
+ '<p>This is an <strong>exploratory QA report</strong>. An AI QA engineer ' +
252
+ `${how.slice(0, -1).join(', ')}${how.length > 1 ? ', and ' : ''}${how[how.length - 1]}.</p>`,
253
+ );
254
+ intro.push(
255
+ '<p>Each finding says what happens today, what should happen instead, and how to see it ' +
256
+ 'for yourself — so nothing here has to be taken on trust.</p>',
257
+ );
258
+ if (scope.objective) intro.push(`<p>${e(scope.objective)}</p>`);
259
+
260
+ blocks.push(`<div class="card reveal"><div class="card-body">${intro.join('')}</div></div>`);
261
+
262
+ if (model.authentication) {
263
+ const auth = model.authentication;
264
+ const rows = [
265
+ auth.required !== undefined ? `<tr><td>Sign-in required</td><td>${auth.required ? 'Yes' : 'No'}</td></tr>` : '',
266
+ auth.method ? `<tr><td>Method</td><td>${e(AUTH_METHOD_LABEL[auth.method] ?? auth.method)}</td></tr>` : '',
267
+ auth.detectedBy ? `<tr><td>Detected by</td><td>${e(auth.detectedBy)}</td></tr>` : '',
268
+ auth.sessionSource
269
+ ? `<tr><td>Session</td><td>${e(SESSION_SOURCE_PLAIN[auth.sessionSource] ?? auth.sessionSource)}</td></tr>`
270
+ : '',
271
+ auth.profile ? `<tr><td>Role</td><td>${e(auth.profile)}</td></tr>` : '',
272
+ auth.note ? `<tr><td>Note</td><td>${e(auth.note)}</td></tr>` : '',
273
+ ].filter(Boolean);
274
+ if (rows.length > 0) {
275
+ blocks.push(
276
+ '<h4 style="margin-top:1.5rem">Authentication</h4>' +
277
+ `<div class="table-wrap"><table><tbody>${rows.join('')}</tbody></table></div>`,
278
+ );
279
+ }
280
+ }
281
+
282
+ if (scope.covered?.length > 0) {
283
+ blocks.push(
284
+ '<h4 style="margin-top:1.5rem">Exercised in this run</h4>' +
285
+ '<div class="card"><div class="card-body">' +
286
+ `<ul class="clean">${scope.covered.map((item) => `<li>${e(item)}</li>`).join('')}</ul>` +
287
+ '</div></div>',
288
+ );
289
+ }
290
+
291
+ if (model.dimensionsRun.length > 0) {
292
+ const rows = model.dimensionsRun.map(
293
+ (dimension) =>
294
+ `<tr><td><strong>${e(dimensionLabel(dimension))}</strong></td>` +
295
+ `<td>${e(DIMENSION_PLAIN[dimension] ?? '')}</td></tr>`,
296
+ );
297
+ blocks.push(
298
+ '<h4 style="margin-top:1.5rem">Areas checked</h4>' +
299
+ table(['Area', 'What that means'], rows),
300
+ );
301
+ }
302
+
303
+ if (model.pages.length > 0) {
304
+ const rows = model.pages.map((page) => {
305
+ const findings = model.findings.filter(
306
+ (finding) => finding.page === page.url || (page.findingIds ?? []).includes(finding.id),
307
+ );
308
+ return (
309
+ '<tr>' +
310
+ `<td><strong>${e(page.title || pathOf(page.url))}</strong>` +
311
+ `<div class="muted"><code>${e(page.url)}</code></div></td>` +
312
+ `<td>${statusPill(page.status)}</td>` +
313
+ `<td class="num">${e(page.httpStatus ?? '—')}</td>` +
314
+ `<td class="num">${e(page.loadMs !== undefined ? formatDuration(page.loadMs) : '—')}</td>` +
315
+ `<td class="num">${e(page.requests ?? '—')}</td>` +
316
+ `<td>${findings.length === 0
317
+ ? '<span class="muted">—</span>'
318
+ : findings
319
+ .map((finding) => `<a href="#${e(findingAnchor(finding.id))}"><code>${e(finding.id)}</code></a>`)
320
+ .join(' ')}</td>` +
321
+ '</tr>'
322
+ );
323
+ });
324
+ blocks.push(
325
+ '<h4 style="margin-top:1.5rem">Pages visited</h4>' +
326
+ table(
327
+ ['Page', 'State', { label: 'HTTP', numeric: true }, { label: 'Load', numeric: true },
328
+ { label: 'Requests', numeric: true }, 'Findings'],
329
+ rows,
330
+ ),
331
+ );
332
+ }
333
+
334
+ if (model.testCases?.cases?.length > 0) {
335
+ const order = { fail: 0, blocked: 1, skipped: 2, pass: 3 };
336
+ const rows = [...model.testCases.cases]
337
+ .sort((a, b) => (order[a.status] ?? 4) - (order[b.status] ?? 4) || compareNatural(a.id, b.id))
338
+ .map((testCase) => {
339
+ const link = testCase.findingId
340
+ ? `<a href="#${e(findingAnchor(testCase.findingId))}"><code>${e(testCase.findingId)}</code></a>`
341
+ : '<span class="muted">—</span>';
342
+ return (
343
+ '<tr>' +
344
+ `<td><code>${e(testCase.id)}</code></td>` +
345
+ `<td>${e(testCase.title)}${testCase.source === 'user-supplied' ? ' ' + chip('supplied') : ''}</td>` +
346
+ `<td>${statusPill(testCase.status)}</td>` +
347
+ `<td>${link}</td>` +
348
+ '</tr>'
349
+ );
350
+ });
351
+ blocks.push(
352
+ '<h4 style="margin-top:1.5rem">Test cases</h4>' +
353
+ table(['ID', 'Case', 'Result', 'Finding'], rows),
354
+ );
355
+ }
356
+
357
+ if (model.notCovered.length > 0) {
358
+ blocks.push(
359
+ '<h4 style="margin-top:1.5rem">Not covered in this run</h4>' +
360
+ '<div class="card"><div class="card-body">' +
361
+ `<ul class="clean">${model.notCovered.map((item) => `<li>${e(item)}</li>`).join('')}</ul>` +
362
+ '</div></div>',
363
+ );
364
+ }
365
+
366
+ return {
367
+ id: 'coverage',
368
+ title: 'Coverage',
369
+ navLabel: 'Coverage',
370
+ count: model.pages.length || null,
371
+ body: blocks.join(''),
372
+ };
373
+ }
374
+
375
+ /* ── Findings ─────────────────────────────────────────────────────────────── */
376
+
377
+ function findingsSection(model, { toolbarHtml }) {
378
+ // A release rollup has no findings by design — it has failing tests, which get their
379
+ // own section. Printing "No findings recorded" above that list reads as a
380
+ // contradiction.
381
+ if (model.findings.length === 0 && model.failures.length > 0) return { id: 'findings', body: '' };
382
+
383
+ const body =
384
+ toolbarHtml +
385
+ findingList(model.findings, model.registry) +
386
+ '<h4 style="margin-top:1.75rem">What the severities claim</h4>' +
387
+ severityLegend();
388
+
389
+ return {
390
+ id: 'findings',
391
+ title: 'Findings',
392
+ navLabel: 'Findings',
393
+ count: model.findings.length,
394
+ body,
395
+ };
396
+ }
397
+
398
+ /* ── Performance ──────────────────────────────────────────────────────────── */
399
+
400
+ function performanceSection(model) {
401
+ const perf = model.performance;
402
+ if (!perf) return { id: 'performance', body: '' };
403
+
404
+ const tiles = Object.keys(VITALS)
405
+ .filter((key) => perf[key] !== undefined && perf[key] !== null)
406
+ .map((key) => vitalTile(key, perf[key]))
407
+ .join('');
408
+
409
+ const weight = [
410
+ ['jsBytes', 'JavaScript'], ['cssBytes', 'CSS'], ['imageBytes', 'Images'],
411
+ ]
412
+ .filter(([key]) => Number.isFinite(perf[key]))
413
+ .map(([key, label]) => ({ label, value: perf[key], display: formatBytes(perf[key]) }));
414
+
415
+ const charts = [];
416
+ if (weight.length > 0) {
417
+ const bars = horizontalBars(weight, { unit: 'bytes' });
418
+ charts.push(chartCard({
419
+ title: 'Page weight by resource type',
420
+ body: bars.body,
421
+ note: Number.isFinite(perf.totalBytes) ? `Total transferred ${formatBytes(perf.totalBytes)}.` : null,
422
+ }));
423
+ }
424
+
425
+ const body =
426
+ (tiles ? `<div class="kpis reveal">${tiles}</div>` : '') +
427
+ (charts.length > 0 ? `<div class="chart-grid reveal" style="margin-top:.75rem">${charts.join('')}</div>` : '') +
428
+ (perf.note ? `<div class="card" style="margin-top:.75rem"><div class="card-body">${e(perf.note)}</div></div>` : '');
429
+
430
+ return {
431
+ id: 'performance',
432
+ title: 'Performance',
433
+ navLabel: 'Performance',
434
+ count: null,
435
+ note: 'Measured on this run, in this environment. A development build over a local network is not a '
436
+ + 'prediction of production.',
437
+ body,
438
+ };
439
+ }
440
+
441
+ /* ── API and network ──────────────────────────────────────────────────────── */
442
+
443
+ function apiSection(model) {
444
+ const network = model.network;
445
+ if (!network) return { id: 'api', body: '' };
446
+
447
+ const tiles = [
448
+ network.totalRequests !== undefined
449
+ ? kpi({ value: network.totalRequests, label: 'Requests', zeroMuted: false })
450
+ : '',
451
+ network.failedRequests !== undefined
452
+ ? kpi({ value: network.failedRequests, label: 'Failed', variant: 'st-failed' })
453
+ : '',
454
+ network.slowRequests !== undefined
455
+ ? kpi({
456
+ value: network.slowRequests,
457
+ label: 'Slow',
458
+ sub: network.slowThresholdMs ? `over ${formatDuration(network.slowThresholdMs)}` : null,
459
+ variant: 'st-blocked',
460
+ })
461
+ : '',
462
+ network.duplicateRequests !== undefined
463
+ ? kpi({ value: network.duplicateRequests, label: 'Duplicated', variant: 'st-blocked' })
464
+ : '',
465
+ network.totalBytes !== undefined
466
+ ? kpi({ value: formatBytes(network.totalBytes), label: 'Transferred', zeroMuted: false })
467
+ : '',
468
+ ].filter(Boolean);
469
+
470
+ const endpoints = network.endpoints ?? [];
471
+ const charts = [];
472
+
473
+ const slowest = endpoints
474
+ .filter((endpoint) => Number.isFinite(endpoint.durationMs))
475
+ .slice()
476
+ .sort((a, b) => b.durationMs - a.durationMs)
477
+ .slice(0, 8)
478
+ .map((endpoint) => ({
479
+ label: `${endpoint.method} ${pathOf(endpoint.url)}`,
480
+ value: endpoint.durationMs,
481
+ className: endpoint.issue === 'failed' || Number(endpoint.status) >= 400 ? 'sev-critical'
482
+ : endpoint.issue === 'slow' ? 'sev-high' : null,
483
+ }));
484
+ if (slowest.length > 0) {
485
+ const bars = horizontalBars(slowest, { unit: 'ms' });
486
+ charts.push(chartCard({ title: 'Slowest endpoints', body: bars.body }));
487
+ }
488
+
489
+ const heaviest = endpoints
490
+ .filter((endpoint) => Number.isFinite(endpoint.bytes) && endpoint.bytes > 0)
491
+ .slice()
492
+ .sort((a, b) => b.bytes - a.bytes)
493
+ .slice(0, 8)
494
+ .map((endpoint) => ({
495
+ label: `${endpoint.method} ${pathOf(endpoint.url)}`,
496
+ value: endpoint.bytes,
497
+ }));
498
+ if (heaviest.length > 0) {
499
+ const bars = horizontalBars(heaviest, { unit: 'bytes' });
500
+ charts.push(chartCard({ title: 'Largest payloads', body: bars.body }));
501
+ }
502
+
503
+ const duplicates = endpoints
504
+ .filter((endpoint) => Number(endpoint.count) > 1)
505
+ .slice()
506
+ .sort((a, b) => b.count - a.count)
507
+ .slice(0, 8)
508
+ .map((endpoint) => ({
509
+ label: `${endpoint.method} ${pathOf(endpoint.url)}`,
510
+ value: endpoint.count,
511
+ display: `${endpoint.count}×`,
512
+ className: 'sev-high',
513
+ }));
514
+ if (duplicates.length > 0) {
515
+ const bars = horizontalBars(duplicates, { unit: 'count' });
516
+ charts.push(chartCard({
517
+ title: 'Repeated requests',
518
+ body: bars.body,
519
+ note: 'The same request sent more than once for a single interaction.',
520
+ }));
521
+ }
522
+
523
+ const flow = waterfall(endpoints);
524
+ if (flow.body) {
525
+ charts.push(chartCard({
526
+ title: 'Request waterfall',
527
+ body: flow.body,
528
+ note: flow.truncated > 0 ? `Showing the first 24 of ${endpoints.length} requests.` : null,
529
+ }));
530
+ }
531
+
532
+ const rows = endpoints.slice(0, 40).map((endpoint) => {
533
+ const statusTone = Number(endpoint.status) >= 500 ? 'sev-critical'
534
+ : Number(endpoint.status) >= 400 ? 'sev-high' : null;
535
+ return (
536
+ '<tr>' +
537
+ `<td><code>${e(endpoint.method)}</code></td>` +
538
+ `<td><code title="${e(endpoint.url)}">${e(truncate(pathOf(endpoint.url), 44))}</code></td>` +
539
+ // One class attribute, not two: a second `class` is ignored by every browser, so
540
+ // the severity variable never resolved and a 500 rendered in body-text grey.
541
+ `<td class="${statusTone ? `num ${statusTone}` : 'num'}"` +
542
+ `${statusTone ? ' style="color:var(--sev-solid);font-weight:650"' : ''}>` +
543
+ `${e(endpoint.status ?? '—')}</td>` +
544
+ `<td class="num">${e(Number.isFinite(endpoint.durationMs) ? formatDuration(endpoint.durationMs) : '—')}</td>` +
545
+ `<td class="num">${e(Number.isFinite(endpoint.bytes) ? formatBytes(endpoint.bytes) : '—')}</td>` +
546
+ `<td class="num">${e(endpoint.count ?? 1)}</td>` +
547
+ `<td>${endpoint.issue ? chip(endpoint.issue) : ''}${
548
+ endpoint.findingId
549
+ ? ` <a href="#${e(findingAnchor(endpoint.findingId))}"><code>${e(endpoint.findingId)}</code></a>`
550
+ : ''
551
+ }</td>` +
552
+ '</tr>'
553
+ );
554
+ });
555
+
556
+ const body =
557
+ (tiles.length > 0 ? `<div class="kpis reveal">${tiles.join('')}</div>` : '') +
558
+ (charts.length > 0 ? `<div class="chart-grid reveal" style="margin-top:.75rem">${charts.join('')}</div>` : '') +
559
+ (rows.length > 0
560
+ ? '<h4 style="margin-top:1.5rem">Requests</h4>' +
561
+ table(
562
+ ['Method', 'Endpoint', { label: 'Status', numeric: true }, { label: 'Time', numeric: true },
563
+ { label: 'Size', numeric: true }, { label: 'Count', numeric: true }, 'Issue'],
564
+ rows,
565
+ ) +
566
+ (endpoints.length > 40
567
+ ? `<p class="section-note" style="margin-top:.5rem">Showing 40 of ${endpoints.length} requests, ` +
568
+ 'worst first.</p>'
569
+ : '')
570
+ : '');
571
+
572
+ return { id: 'api', title: 'API and network', navLabel: 'API', count: endpoints.length || null, body };
573
+ }
574
+
575
+ /* ── Security ─────────────────────────────────────────────────────────────── */
576
+
577
+ function securitySection(model) {
578
+ const security = model.security;
579
+ if (!security || (security.checked === false && !security.summary)) {
580
+ return { id: 'security', body: '' };
581
+ }
582
+ const rows = (security.checks ?? []).map((check) => {
583
+ const link = check.findingId
584
+ ? `<a href="#${e(findingAnchor(check.findingId))}"><code>${e(check.findingId)}</code></a>`
585
+ : '';
586
+ return (
587
+ '<tr>' +
588
+ `<td><strong>${e(check.check)}</strong></td>` +
589
+ `<td>${statusPill(check.status)}</td>` +
590
+ `<td>${e(check.detail ?? '')} ${link}</td>` +
591
+ '</tr>'
592
+ );
593
+ });
594
+
595
+ const failed = (security.checks ?? []).filter((check) => check.status === 'fail').length;
596
+ const body =
597
+ (security.summary
598
+ ? `<div class="card reveal"><div class="card-body">${e(security.summary)}</div></div>`
599
+ : '') +
600
+ (rows.length > 0
601
+ ? `<div style="margin-top:.75rem">${table(['Check', 'Result', 'Detail'], rows)}</div>`
602
+ : '');
603
+
604
+ return {
605
+ id: 'security',
606
+ title: 'Security',
607
+ navLabel: 'Security',
608
+ count: failed || null,
609
+ note: 'Client-side observations only. This is not a penetration test, and nothing destructive was attempted.',
610
+ body,
611
+ };
612
+ }
613
+
614
+ /* ── Accessibility ────────────────────────────────────────────────────────── */
615
+
616
+ function accessibilitySection(model) {
617
+ const a11y = model.accessibility;
618
+ if (!a11y || (a11y.checked === false && !a11y.summary)) return { id: 'accessibility', body: '' };
619
+
620
+ const impactRank = { critical: 0, serious: 1, moderate: 2, minor: 3 };
621
+ const rows = [...(a11y.violations ?? [])]
622
+ .sort((a, b) => (impactRank[a.impact] ?? 4) - (impactRank[b.impact] ?? 4) || b.count - a.count)
623
+ .map((violation) => {
624
+ const tone = { critical: 'critical', serious: 'high', moderate: 'medium', minor: 'low' }[violation.impact] ?? 'low';
625
+ return (
626
+ '<tr>' +
627
+ `<td><code>${e(violation.rule)}</code></td>` +
628
+ `<td><span class="badge sev-${tone}">${e(violation.impact)}</span></td>` +
629
+ `<td class="num">${e(violation.count)}</td>` +
630
+ `<td>${e(violation.description ?? '')}` +
631
+ (violation.selector ? `<div class="muted"><code>${e(violation.selector)}</code></div>` : '') +
632
+ (violation.findingId
633
+ ? ` <a href="#${e(findingAnchor(violation.findingId))}"><code>${e(violation.findingId)}</code></a>`
634
+ : '') +
635
+ '</td>' +
636
+ '</tr>'
637
+ );
638
+ });
639
+
640
+ const total = (a11y.violations ?? []).reduce((sum, violation) => sum + (violation.count ?? 0), 0);
641
+ const body =
642
+ (a11y.summary ? `<div class="card reveal"><div class="card-body">${e(a11y.summary)}</div></div>` : '') +
643
+ (rows.length > 0
644
+ ? `<div style="margin-top:.75rem">${table(
645
+ ['Rule', 'Impact', { label: 'Instances', numeric: true }, 'What it means'], rows,
646
+ )}</div>`
647
+ : '');
648
+
649
+ return {
650
+ id: 'accessibility',
651
+ title: 'Accessibility',
652
+ navLabel: 'Accessibility',
653
+ count: total || null,
654
+ body,
655
+ };
656
+ }
657
+
658
+ /* ── Console ──────────────────────────────────────────────────────────────── */
659
+
660
+ function consoleSection(model) {
661
+ const consoleData = model.console;
662
+ if (!consoleData) return { id: 'console', body: '' };
663
+
664
+ const tiles = [
665
+ consoleData.errors !== undefined ? kpi({ value: consoleData.errors, label: 'Errors', variant: 'st-failed' }) : '',
666
+ consoleData.warnings !== undefined ? kpi({ value: consoleData.warnings, label: 'Warnings', variant: 'st-blocked' }) : '',
667
+ consoleData.unhandledRejections !== undefined
668
+ ? kpi({ value: consoleData.unhandledRejections, label: 'Unhandled promises', variant: 'st-failed' })
669
+ : '',
670
+ consoleData.deprecations !== undefined
671
+ ? kpi({ value: consoleData.deprecations, label: 'Deprecations', variant: 'st-skipped' })
672
+ : '',
673
+ ].filter(Boolean);
674
+
675
+ const rows = (consoleData.entries ?? []).slice(0, 30).map((entry) => (
676
+ '<tr>' +
677
+ `<td>${statusPill(entry.level === 'error' ? 'failed' : entry.level === 'warning' ? 'blocked' : 'skipped', entry.level)}</td>` +
678
+ `<td><code>${e(truncate(entry.message, 160))}</code>` +
679
+ (entry.source ? `<div class="muted"><code>${e(entry.source)}</code></div>` : '') +
680
+ '</td>' +
681
+ `<td class="num">${e(entry.count ?? 1)}</td>` +
682
+ `<td>${entry.findingId
683
+ ? `<a href="#${e(findingAnchor(entry.findingId))}"><code>${e(entry.findingId)}</code></a>`
684
+ : ''}</td>` +
685
+ '</tr>'
686
+ ));
687
+
688
+ const body =
689
+ (tiles.length > 0 ? `<div class="kpis reveal">${tiles.join('')}</div>` : '') +
690
+ (rows.length > 0
691
+ ? `<div style="margin-top:.75rem">${table(
692
+ ['Level', 'Message', { label: 'Count', numeric: true }, 'Finding'], rows,
693
+ )}</div>`
694
+ : '');
695
+
696
+ return { id: 'console', title: 'Console', navLabel: 'Console', count: consoleData.errors ?? null, body };
697
+ }
698
+
699
+ /* ── Screenshots ──────────────────────────────────────────────────────────── */
700
+
701
+ function screenshotSection(model) {
702
+ const shots = model.registry
703
+ .list()
704
+ .filter((record) => record.kind === 'screenshot' || record.renderAs === 'image' || record.renderAs === 'video');
705
+ if (shots.length === 0) return { id: 'screenshots', body: '' };
706
+
707
+ const present = shots.filter((record) => record.exists);
708
+ const absent = shots.filter((record) => !record.exists);
709
+
710
+ const body =
711
+ (present.length > 0
712
+ ? `<div class="evidence reveal">${present.map((record) => artifactFigure(record)).join('')}</div>`
713
+ : '') +
714
+ (absent.length > 0
715
+ ? '<h4 style="margin-top:1.5rem">Captured but not found</h4>' +
716
+ `<div class="evidence">${absent.map((record) => artifactFigure(record)).join('')}</div>`
717
+ : '');
718
+
719
+ return {
720
+ id: 'screenshots',
721
+ title: 'Screenshots',
722
+ navLabel: 'Screenshots',
723
+ count: present.length,
724
+ note: 'Click any image to open it full size.',
725
+ body,
726
+ };
727
+ }
728
+
729
+ /* ── Timeline ─────────────────────────────────────────────────────────────── */
730
+
731
+ function timelineSection(model) {
732
+ const body = timelineList(model.timeline);
733
+ return { id: 'timeline', title: 'Execution timeline', navLabel: 'Timeline', count: null, body };
734
+ }
735
+
736
+ /* ── Data validation ──────────────────────────────────────────────────────── */
737
+
738
+ function dataSection(model) {
739
+ const db = model.dbValidation;
740
+ if (!db) return { id: 'data', body: '' };
741
+ const blocks = [];
742
+ const note = db.summary || (db.inScope ? 'In scope.' : 'Not in scope for this run.');
743
+ blocks.push(`<div class="card reveal"><div class="card-body">${e(note)}</div></div>`);
744
+
745
+ if (db.comparisons?.length > 0) {
746
+ const rows = db.comparisons.map((comparison) => (
747
+ '<tr>' +
748
+ `<td>${e(comparison.metric)}</td>` +
749
+ `<td><code>${e(comparison.uiValue)}</code></td>` +
750
+ `<td><code>${e(comparison.sourceValue)}</code></td>` +
751
+ `<td>${statusPill(comparison.match ? 'passed' : 'failed', comparison.match ? 'Match' : 'Mismatch')}</td>` +
752
+ '</tr>'
753
+ ));
754
+ blocks.push(
755
+ `<div style="margin-top:.75rem">${table(
756
+ ['Metric', 'Shown in the UI', 'Source of truth', 'Result'], rows,
757
+ )}</div>`,
758
+ );
759
+ }
760
+ return { id: 'data', title: 'Data validation', navLabel: 'Data', count: null, body: blocks.join('') };
761
+ }
762
+
763
+ /* ── Recommendations ──────────────────────────────────────────────────────── */
764
+
765
+ function recommendationsSection(model) {
766
+ const blocks = [];
767
+
768
+ if (model.fixOrder.length > 0) {
769
+ blocks.push(
770
+ '<h4>Suggested order</h4>' +
771
+ '<div class="card reveal"><div class="card-body">' +
772
+ `<ol class="chain">${model.fixOrder.map((item) => `<li>${e(item)}</li>`).join('')}</ol>` +
773
+ '</div></div>',
774
+ );
775
+ }
776
+
777
+ if (model.recommendations.length > 0) {
778
+ const rank = { high: 0, medium: 1, low: 2 };
779
+ const rows = [...model.recommendations]
780
+ .sort((a, b) => (rank[a.priority] ?? 3) - (rank[b.priority] ?? 3))
781
+ .map((recommendation) => {
782
+ const tone = { high: 'critical', medium: 'medium', low: 'low' }[recommendation.priority] ?? 'low';
783
+ const links = (recommendation.findingIds ?? [])
784
+ .map((id) => `<a href="#${e(findingAnchor(id))}"><code>${e(id)}</code></a>`)
785
+ .join(' ');
786
+ return (
787
+ '<tr>' +
788
+ `<td><strong>${e(recommendation.action)}</strong>` +
789
+ (recommendation.rationale ? `<div class="muted">${e(recommendation.rationale)}</div>` : '') +
790
+ (links ? `<div style="margin-top:.25rem">${links}</div>` : '') +
791
+ '</td>' +
792
+ `<td><span class="badge sev-${tone}">${e(recommendation.priority)}</span></td>` +
793
+ `<td>${recommendation.owner ? chip(recommendation.owner) : ''}</td>` +
794
+ `<td>${recommendation.effort ? chip(recommendation.effort) : ''}</td>` +
795
+ '</tr>'
796
+ );
797
+ });
798
+ blocks.push(
799
+ `<h4 style="margin-top:1.5rem">Actions</h4>${table(['Action', 'Priority', 'Owner', 'Effort'], rows)}`,
800
+ );
801
+ }
802
+
803
+ if (model.whatWorksWell.length > 0) {
804
+ blocks.push(
805
+ '<h4 style="margin-top:1.5rem">Verified working</h4>' +
806
+ '<div class="card"><div class="card-body">' +
807
+ `<ul class="clean">${model.whatWorksWell.map((item) => `<li>${e(item)}</li>`).join('')}</ul>` +
808
+ '</div></div>',
809
+ );
810
+ }
811
+
812
+ return {
813
+ id: 'recommendations',
814
+ title: 'Recommendations',
815
+ navLabel: 'Recommendations',
816
+ count: model.recommendations.length || null,
817
+ body: blocks.join(''),
818
+ };
819
+ }
820
+
821
+ /* ── Artifacts ────────────────────────────────────────────────────────────── */
822
+
823
+ function artifactSection(model) {
824
+ const records = model.registry.list().filter((record) => record.declaredPath);
825
+ if (records.length === 0 && model.evidence.length === 0) return { id: 'artifacts', body: '' };
826
+
827
+ const stats = model.artifactStats;
828
+ const tiles = [
829
+ kpi({ value: stats.total, label: 'Artifacts', zeroMuted: false }),
830
+ kpi({ value: stats.present, label: 'Present', variant: 'st-passed', zeroMuted: false }),
831
+ kpi({ value: stats.missing, label: 'Missing', variant: 'st-failed' }),
832
+ kpi({ value: formatBytes(stats.bytes), label: 'Total size', zeroMuted: false }),
833
+ ].join('');
834
+
835
+ const evidenceRows = model.evidence.map((entry) => {
836
+ const record = model.registry.forEvidence(entry);
837
+ return (
838
+ '<tr>' +
839
+ `<td>${e(evidenceLabel(entry.type))}</td>` +
840
+ `<td>${e(entry.description ?? '')}</td>` +
841
+ `<td>${record?.exists && record.href
842
+ ? `<a href="${e(record.href)}" target="_blank" rel="noopener noreferrer"><code>${e(entry.source)}</code></a>`
843
+ : `<code>${e(entry.source)}</code>`}</td>` +
844
+ `<td>${record?.exists
845
+ ? '<span class="pill st-passed">Present</span>'
846
+ : '<span class="pill st-failed">Missing</span>'}</td>` +
847
+ '</tr>'
848
+ );
849
+ });
850
+
851
+ const body =
852
+ `<div class="kpis reveal">${tiles}</div>` +
853
+ (stats.missing > 0
854
+ ? '<p class="section-note" style="margin-top:.75rem">Files listed as missing were referenced by the run ' +
855
+ 'but were not present when this report was rendered. The report shows a marker in their place rather ' +
856
+ 'than a broken image.</p>'
857
+ : '') +
858
+ (model.evidence.length > 0
859
+ ? `<h4 style="margin-top:1.5rem">Evidence index</h4>${table(['Kind', 'Shows', 'File', 'State'], evidenceRows)}`
860
+ : '') +
861
+ (records.length > 0
862
+ ? `<h4 style="margin-top:1.5rem">Registered artifacts</h4>${artifactTable(records)}`
863
+ : '');
864
+
865
+ return {
866
+ id: 'artifacts',
867
+ title: 'Artifacts',
868
+ navLabel: 'Artifacts',
869
+ count: stats.total,
870
+ body,
871
+ };
872
+ }
873
+
874
+ /* ── Appendix ─────────────────────────────────────────────────────────────── */
875
+
876
+ function appendixSection(model) {
877
+ const producer = model.producer;
878
+ // Named, but never acted on. Two agents handing the renderer the same JSON get
879
+ // byte-identical documents; this line is the only place the difference appears.
880
+ const producedBy = producer
881
+ ? [producer.agent, producer.model, producer.skill].filter(Boolean).join(' · ') +
882
+ (producer.version ? ` v${producer.version}` : '')
883
+ : model.skill
884
+ ? `${model.skill.name} v${model.skill.version}`
885
+ : null;
886
+
887
+ const rows = [
888
+ model.contract ? `<tr><td>Contract</td><td><code>${e(model.contract.name)} v${e(model.contract.version)}</code></td></tr>` : '',
889
+ producedBy ? `<tr><td>Produced by</td><td><code>${e(producedBy)}</code></td></tr>` : '',
890
+ `<tr><td>Schema version</td><td><code>${e(model.versions.schemaVersion)}</code></td></tr>`,
891
+ `<tr><td>Theme version</td><td><code>${e(model.versions.themeName)} v${e(model.versions.themeVersion)}</code></td></tr>`,
892
+ `<tr><td>Renderer version</td><td><code>${e(model.versions.rendererVersion)}</code></td></tr>`,
893
+ model.generatedAt ? `<tr><td>Generated</td><td>${e(model.generatedAt)}</td></tr>` : '',
894
+ model.reportVersion ? `<tr><td>Report version</td><td>v${e(model.reportVersion)}</td></tr>` : '',
895
+ model.browserAdapter
896
+ ? `<tr><td>Observed with</td><td>${e(ADAPTER_PLAIN[model.browserAdapter] ?? model.browserAdapter)}</td></tr>`
897
+ : '',
898
+ model.environment ? `<tr><td>Environment</td><td>${e(model.environment)}</td></tr>` : '',
899
+ model.durationMs !== null ? `<tr><td>Run duration</td><td>${e(formatDuration(model.durationMs))}</td></tr>` : '',
900
+ `<tr><td>Classification</td><td><code>${e(model.classification ?? '')}</code></td></tr>`,
901
+ ].filter(Boolean);
902
+
903
+ const body =
904
+ `<div class="table-wrap"><table><tbody>${rows.join('')}</tbody></table></div>` +
905
+ '<p class="section-note" style="margin-top:1rem">This report was rendered from a validated JSON artifact. ' +
906
+ 'Every number above came from that file, and the file is beside this one in the same folder. ' +
907
+ 'The layout, colours, and components come from the renderer — not from whichever agent produced the ' +
908
+ 'data — so a report from any agent is the same document.</p>';
909
+
910
+ return { id: 'appendix', title: 'Appendix', navLabel: 'Appendix', count: null, body };
911
+ }
912
+
913
+ /* ── Registry ─────────────────────────────────────────────────────────────── */
914
+
915
+ /**
916
+ * Which sections each audience gets.
917
+ *
918
+ * Not a different report — the *same* report, filtered. An executive rendering that
919
+ * reworded the findings would be a second document to keep in step, and the two would
920
+ * diverge the first time someone edited one of them. Filtering cannot diverge.
921
+ *
922
+ * `full` is the default and the canonical artifact. The other two exist because a
923
+ * fourteen-section report mailed to a CFO gets closed, and a two-section report handed
924
+ * to the engineer fixing it is useless.
925
+ */
926
+ export const MODES = Object.freeze({
927
+ full: null, // every section
928
+ executive: ['overview', 'summary', 'coverage', 'recommendations', 'appendix'],
929
+ developer: [
930
+ 'overview', 'findings', 'failures', 'performance', 'api', 'security',
931
+ 'accessibility', 'console', 'screenshots', 'artifacts', 'appendix',
932
+ ],
933
+ // The embedded rendering keeps every section; only the document wrapper differs.
934
+ artifact: null,
935
+ });
936
+
937
+ /** Filter sections to a mode. An unknown mode is a programming error, not a silent full render. */
938
+ export function filterSections(sections, mode = 'full') {
939
+ if (!(mode in MODES)) {
940
+ throw new Error(`unknown rendering mode '${mode}'; expected one of: ${Object.keys(MODES).join(', ')}`);
941
+ }
942
+ const allowed = MODES[mode];
943
+ if (allowed === null) return sections;
944
+ const order = new Map(allowed.map((id, index) => [id, index]));
945
+ return sections
946
+ .filter((section) => order.has(section.id))
947
+ .sort((a, b) => order.get(a.id) - order.get(b.id));
948
+ }
949
+
950
+ /** Every section, in reading order, with the empty ones dropped. */
951
+ export function buildSections(model, context) {
952
+ return [
953
+ overviewSection(model),
954
+ executiveSection(model),
955
+ engineeringSection(model),
956
+ coverageSection(model),
957
+ findingsSection(model, context),
958
+ failuresSection(model),
959
+ performanceSection(model),
960
+ apiSection(model),
961
+ securitySection(model),
962
+ accessibilitySection(model),
963
+ consoleSection(model),
964
+ screenshotSection(model),
965
+ dataSection(model),
966
+ timelineSection(model),
967
+ recommendationsSection(model),
968
+ artifactSection(model),
969
+ appendixSection(model),
970
+ ].filter((entry) => Boolean(entry.body));
971
+ }
972
+
973
+ /** Render a list of sections to HTML. */
974
+ export function renderSections(sections) {
975
+ return sections.map((entry) => section(entry)).join('');
976
+ }
977
+
978
+ export { ADAPTER_PLAIN, DIMENSION_PLAIN, AUTH_METHOD_LABEL };