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.
- package/COMPATIBILITY.md +26 -12
- package/README.md +182 -84
- package/package.json +11 -20
- package/packages/engine/bin/qa-engine.mjs +560 -0
- package/packages/engine/lib/analysis/branding.mjs +187 -0
- package/packages/engine/lib/analysis/context.mjs +294 -0
- package/packages/engine/lib/analysis/contracts.mjs +149 -0
- package/packages/engine/lib/analysis/diff-guard.mjs +425 -0
- package/packages/engine/lib/analysis/discovery.mjs +220 -0
- package/packages/engine/lib/analysis/evidence.mjs +116 -0
- package/packages/engine/lib/analysis/har.mjs +124 -0
- package/packages/engine/lib/analysis/junit.mjs +126 -0
- package/packages/engine/lib/analysis/network.mjs +237 -0
- package/packages/engine/lib/analysis/redaction.mjs +127 -0
- package/packages/engine/lib/analysis/report-html.mjs +76 -0
- package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
- package/packages/engine/lib/analysis/xml.mjs +153 -0
- package/packages/engine/lib/analysis/zip.mjs +107 -0
- package/packages/engine/lib/artifacts/manager.mjs +453 -0
- package/packages/engine/lib/artifacts/mime.mjs +109 -0
- package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
- package/packages/engine/lib/diagnostics/engine.mjs +165 -0
- package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
- package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
- package/packages/engine/lib/diagnostics/repair.mjs +73 -0
- package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
- package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
- package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
- package/packages/engine/lib/frameworks/playwright.mjs +158 -0
- package/packages/engine/lib/report/components/charts.mjs +424 -0
- package/packages/engine/lib/report/components/evidence.mjs +207 -0
- package/packages/engine/lib/report/components/findings.mjs +258 -0
- package/packages/engine/lib/report/components/nav.mjs +99 -0
- package/packages/engine/lib/report/components/primitives.mjs +246 -0
- package/packages/engine/lib/report/components/runtime.mjs +246 -0
- package/packages/engine/lib/report/components/timeline.mjs +65 -0
- package/packages/engine/lib/report/core/model.mjs +270 -0
- package/packages/engine/lib/report/core/normalize.mjs +226 -0
- package/packages/engine/lib/report/core/sections.mjs +978 -0
- package/packages/engine/lib/report/export/bundle.mjs +293 -0
- package/packages/engine/lib/report/export/html.mjs +183 -0
- package/packages/engine/lib/report/export/machine.mjs +290 -0
- package/packages/engine/lib/report/export/markdown.mjs +323 -0
- package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
- package/packages/engine/lib/report/theme/css.mjs +529 -0
- package/packages/engine/lib/report/theme/tokens.mjs +137 -0
- package/packages/engine/lib/report/version.mjs +78 -0
- package/packages/engine/package.json +14 -0
- package/packages/installer/lib/agents/targets.mjs +90 -0
- package/packages/installer/lib/agents/user-level.mjs +80 -0
- package/packages/installer/lib/cli/flags.mjs +20 -2
- package/packages/installer/lib/commands/doctor.mjs +13 -20
- package/packages/installer/lib/commands/install.mjs +160 -91
- package/packages/installer/lib/commands/repair.mjs +10 -6
- package/packages/installer/lib/commands/self-test.mjs +4 -3
- package/packages/installer/lib/commands/uninstall.mjs +14 -8
- package/packages/installer/lib/commands/update.mjs +9 -4
- package/packages/installer/lib/commands/verify.mjs +13 -12
- package/packages/installer/lib/constants.mjs +13 -0
- package/packages/installer/lib/core/bundle.mjs +69 -92
- package/packages/installer/lib/core/conflict.mjs +5 -4
- package/packages/installer/lib/core/fs-safe.mjs +146 -6
- package/packages/installer/lib/core/integrity.mjs +59 -0
- package/packages/installer/lib/core/lockfile.mjs +19 -3
- package/packages/installer/lib/core/manifest.mjs +48 -57
- package/packages/installer/lib/core/plan.mjs +213 -0
- package/packages/installer/lib/core/qa-home.mjs +145 -0
- package/packages/installer/lib/core/scope.mjs +274 -0
- package/packages/installer/lib/core/validate-install.mjs +49 -31
- package/packages/installer/package.json +1 -1
- package/packages/installer/schemas/qa-lock.schema.json +119 -21
- package/shared/tooling/qa-tool.mjs +161 -0
- package/skills/qa-api/SKILL.md +5 -5
- package/skills/qa-api/references/deterministic-tooling.md +34 -32
- package/skills/qa-api/references/evidence-and-reporting.md +5 -5
- package/skills/qa-api/scripts/qa-tool.mjs +162 -0
- package/skills/qa-audit/SKILL.md +5 -5
- package/skills/qa-audit/references/deterministic-tooling.md +34 -32
- package/skills/qa-audit/references/evidence-and-reporting.md +5 -5
- package/skills/qa-audit/scripts/qa-tool.mjs +162 -0
- package/skills/qa-debug/SKILL.md +6 -6
- package/skills/qa-debug/references/deterministic-tooling.md +34 -32
- package/skills/qa-debug/references/diagnostic-engine.md +3 -3
- package/skills/qa-debug/references/evidence-and-reporting.md +5 -5
- package/skills/qa-debug/scripts/qa-tool.mjs +162 -0
- package/skills/qa-explore/SKILL.md +31 -15
- package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
- package/skills/qa-explore/references/api-replay.md +40 -1
- package/skills/qa-explore/references/deterministic-tooling.md +34 -32
- package/skills/qa-explore/references/evidence-and-reporting.md +5 -5
- package/skills/qa-explore/references/report-pipeline.md +266 -96
- package/skills/qa-explore/scripts/qa-tool.mjs +162 -0
- package/skills/qa-fix/SKILL.md +4 -4
- package/skills/qa-fix/references/deterministic-tooling.md +34 -32
- package/skills/qa-fix/references/diagnostic-engine.md +3 -3
- package/skills/qa-fix/references/evidence-and-reporting.md +5 -5
- package/skills/qa-fix/scripts/qa-tool.mjs +162 -0
- package/skills/qa-flaky/SKILL.md +4 -4
- package/skills/qa-flaky/references/deterministic-tooling.md +34 -32
- package/skills/qa-flaky/references/evidence-and-reporting.md +5 -5
- package/skills/qa-flaky/scripts/qa-tool.mjs +162 -0
- package/skills/qa-generate/references/evidence-and-reporting.md +5 -5
- package/skills/qa-init/SKILL.md +4 -4
- package/skills/qa-init/references/deterministic-tooling.md +34 -32
- package/skills/qa-init/references/evidence-and-reporting.md +5 -5
- package/skills/qa-init/scripts/qa-tool.mjs +162 -0
- package/skills/qa-report/SKILL.md +7 -7
- package/skills/qa-report/references/deterministic-tooling.md +34 -32
- package/skills/qa-report/references/diagnostic-engine.md +3 -3
- package/skills/qa-report/references/evidence-and-reporting.md +5 -5
- package/skills/qa-report/scripts/qa-tool.mjs +162 -0
- package/skills/qa-review/references/evidence-and-reporting.md +5 -5
- package/skills/qa-run/SKILL.md +6 -6
- package/skills/qa-run/references/deterministic-tooling.md +34 -32
- package/skills/qa-run/references/evidence-and-reporting.md +5 -5
- package/skills/qa-run/scripts/qa-tool.mjs +162 -0
- package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
- package/shared/analysis/lib/qa_analysis/branding.py +0 -175
- package/shared/analysis/lib/qa_analysis/cli.py +0 -144
- package/shared/analysis/lib/qa_analysis/context.py +0 -233
- package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
- package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
- package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
- package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
- package/shared/analysis/lib/qa_analysis/har.py +0 -87
- package/shared/analysis/lib/qa_analysis/junit.py +0 -104
- package/shared/analysis/lib/qa_analysis/redaction.py +0 -107
- package/shared/analysis/lib/qa_analysis/report_html.py +0 -781
- package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
- package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
- package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
- package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
- package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
- package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
- package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
- package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
- package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
- package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
- package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
- package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
- package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
- package/shared/tooling/qa_tool.py +0 -127
- /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
- /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
- /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 };
|