qa-engineer 0.10.0 → 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 (63) hide show
  1. package/README.md +167 -59
  2. package/package.json +1 -1
  3. package/packages/engine/bin/qa-engine.mjs +232 -8
  4. package/packages/engine/lib/analysis/har.mjs +34 -0
  5. package/packages/engine/lib/analysis/network.mjs +237 -0
  6. package/packages/engine/lib/analysis/report-html.mjs +47 -734
  7. package/packages/engine/lib/artifacts/manager.mjs +453 -0
  8. package/packages/engine/lib/artifacts/mime.mjs +109 -0
  9. package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
  10. package/packages/engine/lib/report/components/charts.mjs +424 -0
  11. package/packages/engine/lib/report/components/evidence.mjs +207 -0
  12. package/packages/engine/lib/report/components/findings.mjs +258 -0
  13. package/packages/engine/lib/report/components/nav.mjs +99 -0
  14. package/packages/engine/lib/report/components/primitives.mjs +246 -0
  15. package/packages/engine/lib/report/components/runtime.mjs +246 -0
  16. package/packages/engine/lib/report/components/timeline.mjs +65 -0
  17. package/packages/engine/lib/report/core/model.mjs +270 -0
  18. package/packages/engine/lib/report/core/normalize.mjs +226 -0
  19. package/packages/engine/lib/report/core/sections.mjs +978 -0
  20. package/packages/engine/lib/report/export/bundle.mjs +293 -0
  21. package/packages/engine/lib/report/export/html.mjs +183 -0
  22. package/packages/engine/lib/report/export/machine.mjs +290 -0
  23. package/packages/engine/lib/report/export/markdown.mjs +323 -0
  24. package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
  25. package/packages/engine/lib/report/theme/css.mjs +529 -0
  26. package/packages/engine/lib/report/theme/tokens.mjs +137 -0
  27. package/packages/engine/lib/report/version.mjs +78 -0
  28. package/packages/engine/package.json +2 -2
  29. package/packages/installer/lib/agents/targets.mjs +90 -0
  30. package/packages/installer/lib/agents/user-level.mjs +80 -0
  31. package/packages/installer/lib/cli/flags.mjs +20 -2
  32. package/packages/installer/lib/commands/doctor.mjs +6 -4
  33. package/packages/installer/lib/commands/install.mjs +134 -93
  34. package/packages/installer/lib/commands/repair.mjs +10 -6
  35. package/packages/installer/lib/commands/self-test.mjs +4 -3
  36. package/packages/installer/lib/commands/uninstall.mjs +14 -8
  37. package/packages/installer/lib/commands/update.mjs +9 -4
  38. package/packages/installer/lib/commands/verify.mjs +13 -12
  39. package/packages/installer/lib/constants.mjs +13 -0
  40. package/packages/installer/lib/core/conflict.mjs +5 -4
  41. package/packages/installer/lib/core/fs-safe.mjs +146 -6
  42. package/packages/installer/lib/core/integrity.mjs +59 -0
  43. package/packages/installer/lib/core/lockfile.mjs +19 -3
  44. package/packages/installer/lib/core/plan.mjs +213 -0
  45. package/packages/installer/lib/core/qa-home.mjs +145 -0
  46. package/packages/installer/lib/core/scope.mjs +274 -0
  47. package/packages/installer/lib/core/validate-install.mjs +37 -12
  48. package/packages/installer/package.json +1 -1
  49. package/packages/installer/schemas/qa-lock.schema.json +119 -21
  50. package/shared/tooling/qa-tool.mjs +41 -5
  51. package/skills/qa-api/scripts/qa-tool.mjs +41 -5
  52. package/skills/qa-audit/scripts/qa-tool.mjs +41 -5
  53. package/skills/qa-debug/scripts/qa-tool.mjs +41 -5
  54. package/skills/qa-explore/SKILL.md +26 -10
  55. package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
  56. package/skills/qa-explore/references/api-replay.md +40 -1
  57. package/skills/qa-explore/references/report-pipeline.md +265 -95
  58. package/skills/qa-explore/scripts/qa-tool.mjs +41 -5
  59. package/skills/qa-fix/scripts/qa-tool.mjs +41 -5
  60. package/skills/qa-flaky/scripts/qa-tool.mjs +41 -5
  61. package/skills/qa-init/scripts/qa-tool.mjs +41 -5
  62. package/skills/qa-report/scripts/qa-tool.mjs +41 -5
  63. package/skills/qa-run/scripts/qa-tool.mjs +41 -5
@@ -0,0 +1,143 @@
1
+ // Writing a ZIP, with no dependencies.
2
+ //
3
+ // `lib/analysis/zip.mjs` reads them (Playwright traces); this writes them, so a report
4
+ // folder can leave as one file a stakeholder can actually receive. Every mail system,
5
+ // ticket tracker, and chat tool accepts a `.zip`; none of them accept a directory.
6
+ //
7
+ // ## Scope
8
+ //
9
+ // The minimum format that every extractor understands: local headers, deflate or
10
+ // store, a central directory, and an end-of-central-directory record. No ZIP64, no
11
+ // encryption, no multi-disk — a QA report is megabytes, not gigabytes, and the
12
+ // simplest file that opens everywhere is the right one.
13
+ //
14
+ // ## Reproducibility
15
+ //
16
+ // Timestamps come from the caller, defaulting to the report's own `generatedAt`.
17
+ // Re-zipping the same report therefore produces the same bytes, so a bundle can be
18
+ // checksummed and a diff between two runs is a diff of the content.
19
+
20
+ import zlib from 'node:zlib';
21
+
22
+ const LOCAL_SIGNATURE = 0x04034b50;
23
+ const CENTRAL_SIGNATURE = 0x02014b50;
24
+ const EOCD_SIGNATURE = 0x06054b50;
25
+
26
+ // Deflate is only worth its CPU on compressible bytes. A PNG or an MP4 is already
27
+ // compressed, and re-deflating it costs time to make the file marginally larger.
28
+ const ALREADY_COMPRESSED = new Set([
29
+ '.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.ogv', '.zip', '.gz',
30
+ ]);
31
+
32
+ const CRC_TABLE = (() => {
33
+ const table = new Uint32Array(256);
34
+ for (let n = 0; n < 256; n += 1) {
35
+ let c = n;
36
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
37
+ table[n] = c >>> 0;
38
+ }
39
+ return table;
40
+ })();
41
+
42
+ export function crc32(buffer) {
43
+ let crc = 0xffffffff;
44
+ for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
45
+ return (crc ^ 0xffffffff) >>> 0;
46
+ }
47
+
48
+ /** MS-DOS date and time, the only clock a ZIP header understands. */
49
+ function dosStamp(date) {
50
+ // The format cannot represent anything before 1980; clamp rather than wrap, so a
51
+ // missing or nonsense timestamp produces a valid archive.
52
+ const year = Math.max(1980, date.getFullYear());
53
+ const time =
54
+ (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2);
55
+ const day = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
56
+ return { time: time & 0xffff, date: day & 0xffff };
57
+ }
58
+
59
+ function shouldStore(name) {
60
+ const dot = name.lastIndexOf('.');
61
+ return dot > 0 && ALREADY_COMPRESSED.has(name.slice(dot).toLowerCase());
62
+ }
63
+
64
+ /**
65
+ * Build a ZIP from `[{ name, data }]`.
66
+ *
67
+ * `name` is the path inside the archive and always uses forward slashes — a backslash
68
+ * here produces an archive that extracts into one long filename on Unix.
69
+ */
70
+ export function createZip(entries, { modifiedAt = new Date(0) } = {}) {
71
+ const stamp = dosStamp(modifiedAt.getFullYear() >= 1980 ? modifiedAt : new Date('1980-01-01T00:00:00Z'));
72
+ const locals = [];
73
+ const centrals = [];
74
+ let offset = 0;
75
+
76
+ for (const entry of entries) {
77
+ const name = Buffer.from(String(entry.name).split('\\').join('/'), 'utf8');
78
+ const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data, 'utf8');
79
+ const store = shouldStore(entry.name);
80
+ const compressed = store ? data : zlib.deflateRawSync(data, { level: 9 });
81
+ // Deflate can inflate small or incompressible input; storing it is then both
82
+ // smaller and faster to read back.
83
+ const useStore = store || compressed.length >= data.length;
84
+ const body = useStore ? data : compressed;
85
+ const method = useStore ? 0 : 8;
86
+ const checksum = crc32(data);
87
+
88
+ const local = Buffer.alloc(30);
89
+ local.writeUInt32LE(LOCAL_SIGNATURE, 0);
90
+ local.writeUInt16LE(20, 4);
91
+ local.writeUInt16LE(0, 6);
92
+ local.writeUInt16LE(method, 8);
93
+ local.writeUInt16LE(stamp.time, 10);
94
+ local.writeUInt16LE(stamp.date, 12);
95
+ local.writeUInt32LE(checksum, 14);
96
+ local.writeUInt32LE(body.length, 18);
97
+ local.writeUInt32LE(data.length, 22);
98
+ local.writeUInt16LE(name.length, 26);
99
+ local.writeUInt16LE(0, 28);
100
+ locals.push(local, name, body);
101
+
102
+ const central = Buffer.alloc(46);
103
+ central.writeUInt32LE(CENTRAL_SIGNATURE, 0);
104
+ central.writeUInt16LE(20, 4);
105
+ central.writeUInt16LE(20, 6);
106
+ central.writeUInt16LE(0, 8);
107
+ central.writeUInt16LE(method, 10);
108
+ central.writeUInt16LE(stamp.time, 12);
109
+ central.writeUInt16LE(stamp.date, 14);
110
+ central.writeUInt32LE(checksum, 16);
111
+ central.writeUInt32LE(body.length, 20);
112
+ central.writeUInt32LE(data.length, 24);
113
+ central.writeUInt16LE(name.length, 28);
114
+ central.writeUInt16LE(0, 30);
115
+ central.writeUInt16LE(0, 32);
116
+ central.writeUInt16LE(0, 34);
117
+ central.writeUInt16LE(0, 36);
118
+ // 0o644 in the high 16 bits, so extraction on Unix produces readable files rather
119
+ // than mode 000.
120
+ central.writeUInt32LE((0o100644 << 16) >>> 0, 38);
121
+ central.writeUInt32LE(offset, 42);
122
+ centrals.push(central, name);
123
+
124
+ offset += local.length + name.length + body.length;
125
+ }
126
+
127
+ const centralBuffer = Buffer.concat(centrals);
128
+ // End of central directory. The field offsets are 4/6/8/10/12/16/20 — writing the
129
+ // directory size and its offset two bytes late produces a file that every extractor
130
+ // reads as truncated by roughly a gigabyte, because it takes the tail of one field
131
+ // and the head of the next as a length.
132
+ const eocd = Buffer.alloc(22);
133
+ eocd.writeUInt32LE(EOCD_SIGNATURE, 0);
134
+ eocd.writeUInt16LE(0, 4);
135
+ eocd.writeUInt16LE(0, 6);
136
+ eocd.writeUInt16LE(entries.length, 8);
137
+ eocd.writeUInt16LE(entries.length, 10);
138
+ eocd.writeUInt32LE(centralBuffer.length, 12);
139
+ eocd.writeUInt32LE(offset, 16);
140
+ eocd.writeUInt16LE(0, 20);
141
+
142
+ return Buffer.concat([...locals, centralBuffer, eocd]);
143
+ }
@@ -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
+ }