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,424 @@
1
+ // Charts, drawn as inline SVG.
2
+ //
3
+ // ## Why hand-rolled
4
+ //
5
+ // A charting library would be a remote script or a bundled megabyte, and the report
6
+ // has to open offline from a mail attachment. It would also draw to a `<canvas>`,
7
+ // which prints as a blur and is invisible to a screen reader.
8
+ //
9
+ // So these are SVG: a few hundred bytes each, sharp at any zoom, printable, and
10
+ // carrying a `<title>` plus a text legend so the numbers are readable without the
11
+ // picture. Every chart here degrades to a table of the same figures elsewhere in the
12
+ // report — the chart is the summary, never the only copy of the data.
13
+ //
14
+ // ## Colour
15
+ //
16
+ // Fills come from CSS custom properties (`var(--sev-solid)`) applied through a class
17
+ // rather than from hard-coded hex, so a segment changes with the theme exactly as the
18
+ // badge beside it does. Presentation attributes cannot resolve `var()`, so fills are
19
+ // set through `style=` where they need to be dynamic.
20
+
21
+ import { SEVERITY, SEVERITY_ORDER, CATEGORICAL, VITALS, vitalBand } from '../theme/tokens.mjs';
22
+ import { e, formatDuration, formatBytes } from './primitives.mjs';
23
+
24
+ /** Wrap a chart in its card, with a heading and an optional footnote. */
25
+ export function chartCard({ title, body, legend = '', note = null }) {
26
+ if (!body) return '';
27
+ return (
28
+ '<figure class="chart">' +
29
+ `<h4>${e(title)}</h4>` +
30
+ body +
31
+ legend +
32
+ (note ? `<figcaption class="muted" style="font-size:.75rem;margin-top:.5rem">${e(note)}</figcaption>` : '') +
33
+ '</figure>'
34
+ );
35
+ }
36
+
37
+ /**
38
+ * A donut of severity counts.
39
+ *
40
+ * Donut rather than pie because the hole holds the total, which is the number most
41
+ * readers want first. Segments are drawn as dashed arcs on one circle — cheaper than
42
+ * path arithmetic and immune to the rounding seams that `<path>` wedges produce.
43
+ */
44
+ export function severityDonut(counts, { size = 168 } = {}) {
45
+ const entries = SEVERITY_ORDER.map((key) => ({
46
+ key,
47
+ label: SEVERITY[key].label,
48
+ value: Number(counts?.[key] ?? 0),
49
+ })).filter((entry) => entry.value > 0);
50
+
51
+ const total = entries.reduce((sum, entry) => sum + entry.value, 0);
52
+ const radius = 56;
53
+ const circumference = 2 * Math.PI * radius;
54
+ const centre = size / 2;
55
+
56
+ let body;
57
+ if (total === 0) {
58
+ body =
59
+ `<svg viewBox="0 0 ${size} ${size}" role="img" aria-label="No findings recorded">` +
60
+ `<circle cx="${centre}" cy="${centre}" r="${radius}" fill="none" stroke="var(--border)" stroke-width="18"/>` +
61
+ `<text x="${centre}" y="${centre - 4}" text-anchor="middle" font-size="26" font-weight="750" ` +
62
+ 'fill="var(--text)">0</text>' +
63
+ `<text x="${centre}" y="${centre + 16}" text-anchor="middle" font-size="10" ` +
64
+ 'fill="var(--text-muted)" letter-spacing="1">FINDINGS</text></svg>';
65
+ return { body, legend: '', total };
66
+ }
67
+
68
+ let offset = 0;
69
+ const segments = entries
70
+ .map((entry) => {
71
+ const length = (entry.value / total) * circumference;
72
+ // A 2-unit gap between segments reads as separation without distorting the
73
+ // proportion at these sizes.
74
+ const gap = entries.length > 1 ? 2 : 0;
75
+ const arc = Math.max(0, length - gap);
76
+ const segment =
77
+ `<circle class="sev-${entry.key}" cx="${centre}" cy="${centre}" r="${radius}" fill="none" ` +
78
+ `style="stroke:var(--sev-solid)" stroke-width="18" stroke-linecap="butt" ` +
79
+ `stroke-dasharray="${arc.toFixed(2)} ${(circumference - arc).toFixed(2)}" ` +
80
+ `stroke-dashoffset="${(-offset).toFixed(2)}" ` +
81
+ `transform="rotate(-90 ${centre} ${centre})">` +
82
+ `<title>${e(entry.label)}: ${entry.value}</title></circle>`;
83
+ offset += length;
84
+ return segment;
85
+ })
86
+ .join('');
87
+
88
+ body =
89
+ `<svg viewBox="0 0 ${size} ${size}" role="img" ` +
90
+ `aria-label="Findings by severity: ${e(entries.map((x) => `${x.value} ${x.label.toLowerCase()}`).join(', '))}">` +
91
+ segments +
92
+ `<text x="${centre}" y="${centre - 4}" text-anchor="middle" font-size="26" font-weight="750" ` +
93
+ `fill="var(--text)">${total}</text>` +
94
+ `<text x="${centre}" y="${centre + 16}" text-anchor="middle" font-size="10" ` +
95
+ 'fill="var(--text-muted)" letter-spacing="1">FINDING' + (total === 1 ? '' : 'S') + '</text>' +
96
+ '</svg>';
97
+
98
+ const legend =
99
+ '<div class="legend">' +
100
+ entries
101
+ .map(
102
+ (entry) =>
103
+ `<span class="sev-${entry.key}"><i style="background:var(--sev-solid)"></i>` +
104
+ `${e(entry.label)} <b>${entry.value}</b></span>`,
105
+ )
106
+ .join('') +
107
+ '</div>';
108
+
109
+ return { body, legend, total };
110
+ }
111
+
112
+ /**
113
+ * Horizontal bars — the default for "which of these is worst".
114
+ *
115
+ * Horizontal because the labels are endpoint paths and page URLs, which do not fit
116
+ * under a vertical bar without rotating them 45 degrees and becoming unreadable.
117
+ */
118
+ export function horizontalBars(rows, { max = null, unit = 'ms', height = 26 } = {}) {
119
+ const data = rows.filter((row) => Number.isFinite(Number(row.value)));
120
+ if (data.length === 0) return { body: '', legend: '' };
121
+
122
+ const ceiling = max ?? Math.max(...data.map((row) => Number(row.value)));
123
+ const labelWidth = 128;
124
+ const valueWidth = 62;
125
+ const gap = 6;
126
+ const chartWidth = 420;
127
+ const barArea = chartWidth - labelWidth - valueWidth - gap * 2;
128
+ const totalHeight = data.length * height + 4;
129
+
130
+ const format = (value) =>
131
+ unit === 'bytes' ? formatBytes(value) : unit === 'ms' ? formatDuration(value) : String(value);
132
+
133
+ const bars = data
134
+ .map((row, index) => {
135
+ const y = index * height + 2;
136
+ const width = ceiling > 0 ? Math.max(2, (Number(row.value) / ceiling) * barArea) : 2;
137
+ const fill = row.className
138
+ ? 'var(--sev-solid)'
139
+ : row.colour ?? CATEGORICAL[index % CATEGORICAL.length];
140
+ const cls = row.className ? ` class="${e(row.className)}"` : '';
141
+ return (
142
+ `<g${cls}>` +
143
+ `<title>${e(row.label)} — ${e(row.display ?? format(row.value))}</title>` +
144
+ `<text x="0" y="${y + height / 2}" dominant-baseline="central" font-size="11" ` +
145
+ `fill="var(--text-soft)">${e(truncate(row.label, 22))}</text>` +
146
+ `<rect x="${labelWidth + gap}" y="${y + 4}" width="${barArea}" height="${height - 12}" ` +
147
+ 'rx="3" fill="var(--surface-sunken)"/>' +
148
+ `<rect x="${labelWidth + gap}" y="${y + 4}" width="${width.toFixed(1)}" height="${height - 12}" ` +
149
+ `rx="3" style="fill:${fill}"/>` +
150
+ `<text x="${chartWidth}" y="${y + height / 2}" text-anchor="end" dominant-baseline="central" ` +
151
+ `font-size="11" font-weight="600" fill="var(--text)">${e(row.display ?? format(row.value))}</text>` +
152
+ '</g>'
153
+ );
154
+ })
155
+ .join('');
156
+
157
+ return {
158
+ body:
159
+ `<svg viewBox="0 0 ${chartWidth} ${totalHeight}" role="img" ` +
160
+ `aria-label="${e(data.map((r) => `${r.label} ${r.display ?? format(r.value)}`).join('; '))}">` +
161
+ bars +
162
+ '</svg>',
163
+ legend: '',
164
+ };
165
+ }
166
+
167
+ /**
168
+ * A request waterfall: when each request started and how long it took.
169
+ *
170
+ * This is the one chart that shows a *shape* rather than a ranking — a staircase means
171
+ * serial requests, a solid block means a burst, and a repeated pair at the same offset
172
+ * is the duplicate-request bug that a table of totals hides completely.
173
+ */
174
+ export function waterfall(requests, { limit = 24 } = {}) {
175
+ const rows = requests
176
+ .filter((request) => Number.isFinite(Number(request.startedMs)) || Number.isFinite(Number(request.durationMs)))
177
+ .slice(0, limit);
178
+ if (rows.length === 0) return { body: '', legend: '', truncated: 0 };
179
+
180
+ const span = Math.max(
181
+ 1,
182
+ ...rows.map((row) => (Number(row.startedMs) || 0) + (Number(row.durationMs) || 0)),
183
+ );
184
+ const labelWidth = 150;
185
+ const chartWidth = 480;
186
+ const trackWidth = chartWidth - labelWidth - 52;
187
+ const rowHeight = 20;
188
+ const axisHeight = 16;
189
+ const totalHeight = rows.length * rowHeight + axisHeight + 6;
190
+
191
+ const ISSUE_COLOUR = {
192
+ failed: 'var(--sev-solid)',
193
+ slow: 'var(--sev-solid)',
194
+ duplicate: 'var(--sev-solid)',
195
+ };
196
+
197
+ const bars = rows
198
+ .map((row, index) => {
199
+ const y = index * rowHeight + axisHeight;
200
+ const start = ((Number(row.startedMs) || 0) / span) * trackWidth;
201
+ const width = Math.max(2, ((Number(row.durationMs) || 0) / span) * trackWidth);
202
+ const severity = issueSeverity(row);
203
+ const fill = severity ? ISSUE_COLOUR[row.issue] ?? 'var(--sev-solid)' : 'var(--tone,#0ba5ec)';
204
+ const cls = severity ? ` class="sev-${severity}"` : ' class="tone-accent"';
205
+ const label = `${row.method ?? ''} ${pathOf(row.url)}`.trim();
206
+ return (
207
+ `<g${cls}>` +
208
+ `<title>${e(label)} — ${e(formatDuration(row.durationMs))}` +
209
+ `${row.status ? ` · HTTP ${row.status}` : ''}${row.issue ? ` · ${row.issue}` : ''}</title>` +
210
+ `<text x="0" y="${y + rowHeight / 2}" dominant-baseline="central" font-size="10" ` +
211
+ `fill="var(--text-soft)" font-family="var(--mono)">${e(truncate(label, 26))}</text>` +
212
+ `<rect x="${(labelWidth + start).toFixed(1)}" y="${y + 4}" width="${width.toFixed(1)}" ` +
213
+ `height="${rowHeight - 9}" rx="2" style="fill:${fill}"/>` +
214
+ `<text x="${chartWidth}" y="${y + rowHeight / 2}" text-anchor="end" dominant-baseline="central" ` +
215
+ `font-size="10" fill="var(--text-muted)">${e(formatDuration(row.durationMs))}</text>` +
216
+ '</g>'
217
+ );
218
+ })
219
+ .join('');
220
+
221
+ // Four ticks: enough to read an offset, few enough not to fence in the bars.
222
+ const ticks = [0, 0.25, 0.5, 0.75, 1]
223
+ .map((fraction) => {
224
+ const x = labelWidth + fraction * trackWidth;
225
+ return (
226
+ `<line class="gridline" x1="${x.toFixed(1)}" y1="${axisHeight - 4}" x2="${x.toFixed(1)}" ` +
227
+ `y2="${totalHeight - 4}"/>` +
228
+ `<text class="axis" x="${x.toFixed(1)}" y="6" text-anchor="middle">` +
229
+ `${e(formatDuration(span * fraction))}</text>`
230
+ );
231
+ })
232
+ .join('');
233
+
234
+ return {
235
+ body:
236
+ `<svg viewBox="0 0 ${chartWidth} ${totalHeight}" role="img" ` +
237
+ `aria-label="Request waterfall over ${e(formatDuration(span))}, ${rows.length} requests">` +
238
+ ticks +
239
+ bars +
240
+ '</svg>',
241
+ legend: '',
242
+ truncated: Math.max(0, requests.length - rows.length),
243
+ };
244
+ }
245
+
246
+ function issueSeverity(request) {
247
+ if (request.issue === 'failed' || (Number(request.status) >= 500)) return 'critical';
248
+ if (Number(request.status) >= 400) return 'high';
249
+ if (request.issue === 'slow' || request.issue === 'n-plus-one') return 'high';
250
+ if (request.issue) return 'medium';
251
+ return null;
252
+ }
253
+
254
+ /**
255
+ * A bullet bar for one Core Web Vital: the measurement against its two thresholds.
256
+ *
257
+ * A raw "LCP 3200ms" means nothing to a product manager. The same number sitting past
258
+ * the green band and inside the amber one is instantly legible, which is the whole
259
+ * reason Lighthouse draws it this way.
260
+ */
261
+ export function vitalsBullets(performance) {
262
+ const keys = Object.keys(VITALS).filter(
263
+ (key) => performance?.[key] !== undefined && performance?.[key] !== null,
264
+ );
265
+ if (keys.length === 0) return { body: '', legend: '' };
266
+
267
+ const width = 420;
268
+ const rowHeight = 34;
269
+ const labelWidth = 96;
270
+ const valueWidth = 66;
271
+ const trackWidth = width - labelWidth - valueWidth - 12;
272
+
273
+ const rows = keys
274
+ .map((key, index) => {
275
+ const spec = VITALS[key];
276
+ const value = Number(performance[key]);
277
+ const band = vitalBand(key, value);
278
+ // The axis runs to 1.5× the poor threshold so a bad measurement still lands on
279
+ // the chart instead of pinning to the right edge with no sense of how bad.
280
+ const ceiling = Math.max(spec.poor * 1.5, value * 1.05);
281
+ const y = index * rowHeight + 4;
282
+ const x = labelWidth + 6;
283
+ const goodWidth = (spec.good / ceiling) * trackWidth;
284
+ const warnWidth = ((spec.poor - spec.good) / ceiling) * trackWidth;
285
+ const poorWidth = trackWidth - goodWidth - warnWidth;
286
+ const marker = Math.min(trackWidth, (value / ceiling) * trackWidth);
287
+ const shown =
288
+ spec.unit === 'bytes' ? formatBytes(value)
289
+ : spec.unit === 'ms' ? formatDuration(value)
290
+ : value.toFixed(key === 'cls' ? 3 : 0);
291
+ return (
292
+ `<g class="tone-${band}">` +
293
+ `<title>${e(spec.full)}: ${e(shown)} (good under ${e(thresholdLabel(spec, spec.good))}, ` +
294
+ `poor over ${e(thresholdLabel(spec, spec.poor))})</title>` +
295
+ `<text x="0" y="${y + 12}" dominant-baseline="central" font-size="11" font-weight="600" ` +
296
+ `fill="var(--text-soft)">${e(spec.label)}</text>` +
297
+ `<rect x="${x}" y="${y + 6}" width="${goodWidth.toFixed(1)}" height="12" rx="2" ` +
298
+ 'fill="#17b26a" opacity=".22"/>' +
299
+ `<rect x="${(x + goodWidth).toFixed(1)}" y="${y + 6}" width="${Math.max(0, warnWidth).toFixed(1)}" ` +
300
+ 'height="12" rx="2" fill="#f79009" opacity=".22"/>' +
301
+ `<rect x="${(x + goodWidth + warnWidth).toFixed(1)}" y="${y + 6}" ` +
302
+ `width="${Math.max(0, poorWidth).toFixed(1)}" height="12" rx="2" fill="#f04438" opacity=".18"/>` +
303
+ `<rect x="${(x + marker - 1.5).toFixed(1)}" y="${y + 2}" width="3" height="20" rx="1.5" ` +
304
+ 'style="fill:var(--tone)"/>' +
305
+ `<text x="${width}" y="${y + 12}" text-anchor="end" dominant-baseline="central" font-size="11" ` +
306
+ `font-weight="650" style="fill:var(--tone)">${e(shown)}</text>` +
307
+ '</g>'
308
+ );
309
+ })
310
+ .join('');
311
+
312
+ return {
313
+ body:
314
+ `<svg viewBox="0 0 ${width} ${keys.length * rowHeight + 8}" role="img" ` +
315
+ 'aria-label="Core Web Vitals against their thresholds">' +
316
+ rows +
317
+ '</svg>',
318
+ legend:
319
+ '<div class="legend">' +
320
+ '<span><i style="background:#17b26a"></i>Good</span>' +
321
+ '<span><i style="background:#f79009"></i>Needs improvement</span>' +
322
+ '<span><i style="background:#f04438"></i>Poor</span>' +
323
+ '</div>',
324
+ };
325
+ }
326
+
327
+ function thresholdLabel(spec, value) {
328
+ if (spec.unit === 'bytes') return formatBytes(value);
329
+ if (spec.unit === 'ms') return formatDuration(value);
330
+ return String(value);
331
+ }
332
+
333
+ /**
334
+ * Page health as a stacked bar per page: how many findings, at what severity.
335
+ *
336
+ * One row per page makes the worst page obvious at a glance, which is the question a
337
+ * multi-page run exists to answer.
338
+ */
339
+ export function pageHealthBars(pages, findings) {
340
+ const rows = pages
341
+ .map((page) => {
342
+ const own = findings.filter(
343
+ (finding) => finding.page === page.url || (page.findingIds ?? []).includes(finding.id),
344
+ );
345
+ const counts = {};
346
+ for (const key of SEVERITY_ORDER) {
347
+ counts[key] = own.filter((finding) => finding.severity === key).length;
348
+ }
349
+ return { page, counts, total: own.length };
350
+ })
351
+ .sort((a, b) => weight(b.counts) - weight(a.counts));
352
+
353
+ if (rows.length === 0) return { body: '', legend: '' };
354
+
355
+ const width = 460;
356
+ const rowHeight = 26;
357
+ const labelWidth = 190;
358
+ const trackWidth = width - labelWidth - 40;
359
+ const maxTotal = Math.max(1, ...rows.map((row) => row.total));
360
+
361
+ const bars = rows
362
+ .map((row, index) => {
363
+ const y = index * rowHeight;
364
+ let x = labelWidth;
365
+ const segments = SEVERITY_ORDER.filter((key) => row.counts[key] > 0)
366
+ .map((key) => {
367
+ const segmentWidth = (row.counts[key] / maxTotal) * trackWidth;
368
+ const rect =
369
+ `<rect class="sev-${key}" x="${x.toFixed(1)}" y="${y + 7}" ` +
370
+ `width="${Math.max(2, segmentWidth).toFixed(1)}" height="12" rx="2" ` +
371
+ `style="fill:var(--sev-solid)"><title>${e(SEVERITY[key].label)}: ${row.counts[key]}</title></rect>`;
372
+ x += segmentWidth + 1;
373
+ return rect;
374
+ })
375
+ .join('');
376
+ const label = row.page.title || pathOf(row.page.url) || row.page.url;
377
+ return (
378
+ `<g><text x="0" y="${y + 13}" dominant-baseline="central" font-size="11" ` +
379
+ `fill="var(--text-soft)">${e(truncate(label, 32))}</text>` +
380
+ (row.total === 0
381
+ ? `<rect x="${labelWidth}" y="${y + 7}" width="14" height="12" rx="2" fill="#17b26a" opacity=".35">` +
382
+ '<title>No findings</title></rect>'
383
+ : segments) +
384
+ `<text x="${width}" y="${y + 13}" text-anchor="end" dominant-baseline="central" font-size="11" ` +
385
+ `font-weight="600" fill="var(--text)">${row.total || '—'}</text></g>`
386
+ );
387
+ })
388
+ .join('');
389
+
390
+ return {
391
+ body:
392
+ `<svg viewBox="0 0 ${width} ${rows.length * rowHeight + 4}" role="img" ` +
393
+ 'aria-label="Findings per page by severity">' +
394
+ bars +
395
+ '</svg>',
396
+ legend:
397
+ '<div class="legend">' +
398
+ SEVERITY_ORDER.map(
399
+ (key) =>
400
+ `<span class="sev-${key}"><i style="background:var(--sev-solid)"></i>${e(SEVERITY[key].label)}</span>`,
401
+ ).join('') +
402
+ '</div>',
403
+ };
404
+ }
405
+
406
+ function weight(counts) {
407
+ return (counts.critical ?? 0) * 1000 + (counts.high ?? 0) * 100 + (counts.medium ?? 0) * 10 + (counts.low ?? 0);
408
+ }
409
+
410
+ /** The path portion of a URL, for a label that has to fit. */
411
+ export function pathOf(url) {
412
+ const text = String(url ?? '');
413
+ const withoutScheme = text.includes('://') ? text.slice(text.indexOf('://') + 3) : text;
414
+ const slash = withoutScheme.indexOf('/');
415
+ return slash === -1 ? withoutScheme : withoutScheme.slice(slash) || '/';
416
+ }
417
+
418
+ /** Shorten from the middle, so an endpoint keeps both its resource and its verb. */
419
+ export function truncate(text, max) {
420
+ const value = String(text ?? '');
421
+ if (value.length <= max) return value;
422
+ const keep = Math.floor((max - 1) / 2);
423
+ return `${value.slice(0, keep)}…${value.slice(value.length - (max - keep - 1))}`;
424
+ }
@@ -0,0 +1,207 @@
1
+ // Rendering evidence: screenshots, video, traces, logs — and honest absence.
2
+ //
3
+ // The contract for this module is one sentence: **it never emits a broken link.**
4
+ //
5
+ // Every path here has already been through the artifact registry, so each record
6
+ // carries `exists` as a measured fact. A record that is present renders as a preview
7
+ // with actions; a record that is not renders as a stated absence with the reason and
8
+ // the path that was searched. What it must never do is emit `<img src=…>` for a file
9
+ // nobody checked, because the browser's broken-image glyph tells the reader nothing
10
+ // except that the report is unreliable.
11
+
12
+ import { e, icon, chip, formatBytes } from './primitives.mjs';
13
+
14
+ // What each evidence kind is called in the report. The contract guarantees `type` on
15
+ // every entry but a caption only sometimes, so the type label is what a caption can
16
+ // always fall back to.
17
+ export const EVIDENCE_LABEL = Object.freeze({
18
+ screenshot: 'Screenshot',
19
+ video: 'Video',
20
+ trace: 'Playwright trace',
21
+ network: 'Network capture',
22
+ console: 'Console output',
23
+ dom: 'DOM snapshot',
24
+ har: 'HAR archive',
25
+ db: 'Database query',
26
+ performance: 'Performance metrics',
27
+ accessibility: 'Accessibility report',
28
+ security: 'Security report',
29
+ api: 'API report',
30
+ coverage: 'Coverage report',
31
+ log: 'Log',
32
+ diff: 'Diff',
33
+ file: 'File',
34
+ report: 'Report',
35
+ command: 'Command output',
36
+ });
37
+
38
+ export function evidenceLabel(kind) {
39
+ return EVIDENCE_LABEL[kind] ?? 'Evidence';
40
+ }
41
+
42
+ /**
43
+ * A stated absence.
44
+ *
45
+ * Named rather than silent: a missing screenshot is itself a finding about the run,
46
+ * and hiding it lets a reader assume the evidence exists and they simply cannot see
47
+ * it. The recorded path is shown because it is the one thing that makes the problem
48
+ * fixable — nine times in ten it is a path written relative to the wrong directory.
49
+ */
50
+ export function missingArtifact(record) {
51
+ const name = record.label ?? evidenceLabel(record.kind);
52
+ const reason = record.missingReason ?? 'File not found at the recorded path';
53
+ const path = record.declaredPath
54
+ ? `<div style="margin-top:.25rem"><code>${e(record.declaredPath)}</code></div>`
55
+ : '';
56
+ return (
57
+ '<div class="artifact-missing" role="note">' +
58
+ icon('warning', 18) +
59
+ `<div><span class="why">Artifact missing — ${e(name)}</span>` +
60
+ `<span>${e(reason)}.</span>${path}</div>` +
61
+ '</div>'
62
+ );
63
+ }
64
+
65
+ /** The caption strip under a preview: what it is, how big, and where to open it. */
66
+ function caption(record, { side = null } = {}) {
67
+ const name = record.label ?? evidenceLabel(record.kind);
68
+ const bits = [
69
+ `<span class="name"${side ? ` data-side="${e(side)}"` : ''}>${e(name)}</span>`,
70
+ record.sizeLabel ? `<span class="muted">${e(record.sizeLabel)}</span>` : '',
71
+ record.declaredPath ? `<code>${e(record.declaredPath)}</code>` : '',
72
+ ].filter(Boolean);
73
+
74
+ const actions = record.href
75
+ ? '<span class="shot-actions">' +
76
+ `<a href="${e(record.href)}" target="_blank" rel="noopener noreferrer">Open full size</a>` +
77
+ '</span>'
78
+ : '';
79
+ return `<figcaption>${bits.join('')}${actions}</figcaption>`;
80
+ }
81
+
82
+ /**
83
+ * One artifact, rendered the way its type allows.
84
+ *
85
+ * Images get a lazy-loaded preview that opens in the lightbox; video gets native
86
+ * controls; everything else gets a link and, when the run captured one, a text
87
+ * excerpt. `loading="lazy"` and explicit dimensions matter here — a report with forty
88
+ * screenshots that decodes all of them on open is a report nobody scrolls.
89
+ */
90
+ export function artifactFigure(record, { side = null, excerpt = null } = {}) {
91
+ if (!record) return '';
92
+ if (!record.exists) return missingArtifact(record);
93
+
94
+ if (record.renderAs === 'image') {
95
+ // An embedded copy wins: it is the one that still shows when the report travels
96
+ // alone. The full-size link stays pointed at the file on disk.
97
+ const src = record.dataUri ?? record.thumbnailHref ?? record.href;
98
+ const dimensions =
99
+ record.width && record.height ? ` width="${record.width}" height="${record.height}"` : '';
100
+ return (
101
+ '<figure class="shot">' +
102
+ `<img src="${e(src)}" alt="${e(record.label ?? evidenceLabel(record.kind))}" loading="lazy" ` +
103
+ `decoding="async"${dimensions} data-full="${e(record.dataUri ?? record.href)}" ` +
104
+ `data-name="${e(record.label ?? record.declaredPath)}" class="js-zoom">` +
105
+ caption(record, { side }) +
106
+ '</figure>'
107
+ );
108
+ }
109
+
110
+ if (record.renderAs === 'video') {
111
+ return (
112
+ '<figure class="shot">' +
113
+ `<video controls preload="metadata" src="${e(record.href)}"></video>` +
114
+ caption(record, { side }) +
115
+ '</figure>'
116
+ );
117
+ }
118
+
119
+ // A trace, a HAR, a log. The excerpt is what makes it readable without leaving the
120
+ // page; the link is what makes it verifiable.
121
+ return (
122
+ '<figure class="shot">' +
123
+ (excerpt ? `<pre><code>${e(excerpt)}</code></pre>` : '') +
124
+ caption(record, { side }) +
125
+ '</figure>'
126
+ );
127
+ }
128
+
129
+ /**
130
+ * A before/after pair, shown side by side.
131
+ *
132
+ * Two screenshots stacked vertically are two screenshots; side by side with a shared
133
+ * caption they are a comparison, and the reader sees the difference without being
134
+ * told what to look for.
135
+ */
136
+ export function comparePair(before, after) {
137
+ if (!before || !after) return '';
138
+ return (
139
+ '<div class="compare">' +
140
+ artifactFigure(before, { side: 'Before' }) +
141
+ artifactFigure(after, { side: 'After' }) +
142
+ '</div>'
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Every evidence entry on a finding, resolved and rendered.
148
+ *
149
+ * Before/after pairs are pulled out first so they render together rather than as two
150
+ * unrelated tiles; whatever is left renders in declaration order.
151
+ */
152
+ export function evidenceGrid(entries, registry) {
153
+ const records = (entries ?? [])
154
+ .map((entry) => ({ entry, record: registry.forEvidence(entry) }))
155
+ .filter((pair) => pair.record);
156
+ if (records.length === 0) return '';
157
+
158
+ const rendered = [];
159
+ const consumed = new Set();
160
+
161
+ for (const { entry, record } of records) {
162
+ if (consumed.has(record)) continue;
163
+ if (record.compares) {
164
+ const other = registry.get(record.compares);
165
+ if (other) {
166
+ consumed.add(record);
167
+ consumed.add(other);
168
+ // `compares` points at the earlier state, so the record declaring it is "after".
169
+ rendered.push(comparePair(other, record));
170
+ continue;
171
+ }
172
+ }
173
+ consumed.add(record);
174
+ rendered.push(artifactFigure(record, { excerpt: entry.excerpt ?? null }));
175
+ }
176
+
177
+ return `<div class="evidence">${rendered.join('')}</div>`;
178
+ }
179
+
180
+ /** The run-level evidence index: one table of every artifact and whether it is there. */
181
+ export function artifactTable(records) {
182
+ if (records.length === 0) return '';
183
+ const rows = records
184
+ .map((record) => {
185
+ const state = record.exists
186
+ ? '<span class="pill st-passed">Present</span>'
187
+ : '<span class="pill st-failed">Missing</span>';
188
+ const link = record.exists && record.href
189
+ ? `<a href="${e(record.href)}" target="_blank" rel="noopener noreferrer"><code>${e(record.declaredPath)}</code></a>`
190
+ : `<code>${e(record.declaredPath || '—')}</code>`;
191
+ return (
192
+ '<tr>' +
193
+ `<td>${e(evidenceLabel(record.kind))}</td>` +
194
+ `<td>${e(record.label ?? '')}</td>` +
195
+ `<td>${link}</td>` +
196
+ `<td class="num">${e(record.exists ? formatBytes(record.bytes) : '—')}</td>` +
197
+ `<td>${state}${record.hashMismatch ? chip('hash mismatch') : ''}</td>` +
198
+ '</tr>'
199
+ );
200
+ })
201
+ .join('');
202
+ return (
203
+ '<div class="table-wrap"><table><thead><tr>' +
204
+ '<th>Kind</th><th>Shows</th><th>File</th><th class="num">Size</th><th>State</th>' +
205
+ `</tr></thead><tbody>${rows}</tbody></table></div>`
206
+ );
207
+ }