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,246 @@
1
+ // The small pieces every section is built from: escaping, formatting, badges, icons.
2
+ //
3
+ // Escaping lives here and is used everywhere, because a report renders text the run
4
+ // did not author — page titles, console messages, response bodies, DOM excerpts. That
5
+ // content is untrusted the same way a log line is untrusted, and a `<script>` in a
6
+ // console error must reach the reader as characters, never as markup.
7
+
8
+ import { SEVERITY, STATUS, VITALS, vitalBand, scoreBand } from '../theme/tokens.mjs';
9
+ import { formatBytes } from '../../artifacts/mime.mjs';
10
+
11
+ /** HTML-escape text for element content and quoted attribute values alike. */
12
+ export function e(value) {
13
+ if (value === null || value === undefined) return '';
14
+ return String(value)
15
+ .replace(/&/g, '&amp;')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;')
19
+ .replace(/'/g, '&#x27;');
20
+ }
21
+
22
+ /**
23
+ * Escape for a JSON literal embedded in a `<script>` block.
24
+ *
25
+ * `JSON.stringify` alone is not enough: the string `</script>` inside any value ends
26
+ * the block early and drops the rest of the page into the document as markup. The
27
+ * `<` and `
`/`
` escapes close that off.
28
+ */
29
+ export function jsonScript(value) {
30
+ return JSON.stringify(value)
31
+ .replace(/</g, '\\u003c')
32
+ .replace(/\u2028/g, '\\u2028')
33
+ .replace(/\u2029/g, '\\u2029');
34
+ }
35
+
36
+ /** Join class names, dropping the falsy ones. */
37
+ export function cx(...names) {
38
+ return names.filter(Boolean).join(' ');
39
+ }
40
+
41
+ /** An id safe to use as an anchor, derived from a finding or section key. */
42
+ export function slug(value, prefix = '') {
43
+ const safe = [...String(value ?? '')]
44
+ .map((ch) => (/[\p{L}\p{N}]/u.test(ch) || ch === '-' || ch === '_' ? ch : '-'))
45
+ .join('')
46
+ .replace(/-+/g, '-')
47
+ .replace(/^-|-$/g, '');
48
+ return safe ? `${prefix}${safe}` : '';
49
+ }
50
+
51
+ /** Sort TC-2 before TC-10, the way a reader expects a case list to run. */
52
+ export function compareNatural(a, b) {
53
+ const key = (v) =>
54
+ String(v ?? '')
55
+ .split(/(\d+)/)
56
+ .map((part) => (/^\d+$/.test(part) ? Number.parseInt(part, 10) : part.toLowerCase()));
57
+ const left = key(a);
58
+ const right = key(b);
59
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
60
+ const x = left[i];
61
+ const y = right[i];
62
+ if (x === undefined) return -1;
63
+ if (y === undefined) return 1;
64
+ if (x === y) continue;
65
+ return x < y ? -1 : 1;
66
+ }
67
+ return 0;
68
+ }
69
+
70
+ /** A severity badge. */
71
+ export function severityBadge(severity) {
72
+ const key = SEVERITY[severity] ? severity : 'low';
73
+ return `<span class="badge sev-${key}">${e(SEVERITY[key].label)}</span>`;
74
+ }
75
+
76
+ /** A status pill — pass, fail, blocked, skipped, or a free-form label. */
77
+ export function statusPill(status, label = null) {
78
+ const map = {
79
+ pass: 'passed', passed: 'passed', ok: 'passed', confirmed: 'failed',
80
+ fail: 'failed', failed: 'failed', issues: 'failed',
81
+ blocked: 'blocked', warn: 'blocked', warned: 'blocked',
82
+ skipped: 'skipped', 'not-checked': 'skipped',
83
+ };
84
+ const key = map[status] ?? 'neutral';
85
+ const text = label ?? STATUS[key]?.label ?? status;
86
+ return `<span class="pill st-${key}">${e(text)}</span>`;
87
+ }
88
+
89
+ /** A neutral chip for dimensions, tags, owners. */
90
+ export function chip(text, title = null) {
91
+ if (!text) return '';
92
+ const attr = title ? ` title="${e(title)}"` : '';
93
+ return `<span class="chip"${attr}>${e(text)}</span>`;
94
+ }
95
+
96
+ /**
97
+ * A KPI tile.
98
+ *
99
+ * `variant` picks the accent: `sev-<key>` colours it by severity, `st-<key>` by test
100
+ * status. A zero gets muted deliberately — a wall of tiles where "0 critical" shouts
101
+ * as loudly as "3 critical" trains the reader to skip all of them.
102
+ */
103
+ export function kpi({ value, label, sub = null, variant = null, zeroMuted = true }) {
104
+ const numeric = Number(value);
105
+ const isZero = zeroMuted && Number.isFinite(numeric) && numeric === 0;
106
+ const kind = variant?.startsWith('sev-') ? 'sev' : variant?.startsWith('st-') ? 'st' : '';
107
+ return (
108
+ `<div class="${cx('kpi', kind, variant, isZero && 'kpi-zero')}">` +
109
+ `<span class="kpi-n">${e(value)}</span>` +
110
+ `<span class="kpi-l">${e(label)}</span>` +
111
+ (sub ? `<span class="kpi-sub">${e(sub)}</span>` : '') +
112
+ '</div>'
113
+ );
114
+ }
115
+
116
+ /** A ring gauge for a 0–100 score, drawn as SVG so it survives print and PDF. */
117
+ export function scoreGauge(score, label) {
118
+ const value = Math.max(0, Math.min(100, Math.round(Number(score))));
119
+ const band = scoreBand(value);
120
+ const radius = 20;
121
+ const circumference = 2 * Math.PI * radius;
122
+ const filled = (value / 100) * circumference;
123
+ return (
124
+ `<div class="score tone-${band}">` +
125
+ `<svg width="48" height="48" viewBox="0 0 48 48" role="img" ` +
126
+ `aria-label="${e(label)} score ${value} out of 100">` +
127
+ `<circle cx="24" cy="24" r="${radius}" fill="none" stroke="var(--border)" stroke-width="4"/>` +
128
+ `<circle cx="24" cy="24" r="${radius}" fill="none" stroke="var(--tone)" stroke-width="4" ` +
129
+ `stroke-linecap="round" stroke-dasharray="${filled.toFixed(2)} ${circumference.toFixed(2)}" ` +
130
+ 'transform="rotate(-90 24 24)"/>' +
131
+ `<text x="24" y="24" text-anchor="middle" dominant-baseline="central" ` +
132
+ `font-size="14" font-weight="700" fill="var(--tone)">${value}</text>` +
133
+ '</svg>' +
134
+ `<div><div class="score-l">${e(label)}</div>` +
135
+ `<div class="score-b">${band === 'good' ? 'Good' : band === 'warn' ? 'Needs work' : 'Poor'}</div></div>` +
136
+ '</div>'
137
+ );
138
+ }
139
+
140
+ /** A duration in the largest unit that stays readable. */
141
+ export function formatDuration(ms) {
142
+ const value = Number(ms);
143
+ if (!Number.isFinite(value) || value < 0) return '';
144
+ if (value < 1000) return `${Math.round(value)} ms`;
145
+ if (value < 60_000) return `${(value / 1000).toFixed(value < 10_000 ? 2 : 1)} s`;
146
+ const minutes = Math.floor(value / 60_000);
147
+ const seconds = Math.round((value % 60_000) / 1000);
148
+ return seconds ? `${minutes} m ${seconds} s` : `${minutes} m`;
149
+ }
150
+
151
+ /** A count with its noun, pluralised. */
152
+ export function plural(count, singular, pluralForm = null) {
153
+ const n = Number(count) || 0;
154
+ return `${n} ${n === 1 ? singular : pluralForm ?? `${singular}s`}`;
155
+ }
156
+
157
+ export { formatBytes };
158
+
159
+ /** Render one Core Web Vital as a tile whose colour states whether it is acceptable. */
160
+ export function vitalTile(key, value) {
161
+ const spec = VITALS[key];
162
+ if (!spec || value === null || value === undefined) return '';
163
+ const band = vitalBand(key, value);
164
+ const shown =
165
+ spec.unit === 'bytes'
166
+ ? formatBytes(value)
167
+ : spec.unit === 'ms'
168
+ ? formatDuration(value)
169
+ : String(Number(value).toFixed(key === 'cls' ? 3 : 0));
170
+ return (
171
+ `<div class="kpi tone-${band}" style="--kpi-accent:var(--tone)" title="${e(spec.full)} — ${e(spec.plain)}">` +
172
+ `<span class="kpi-n" style="color:var(--tone)">${e(shown)}</span>` +
173
+ `<span class="kpi-l">${e(spec.label)}</span>` +
174
+ `<span class="kpi-sub">${e(spec.plain)}</span>` +
175
+ '</div>'
176
+ );
177
+ }
178
+
179
+ /**
180
+ * Inline SVG icons.
181
+ *
182
+ * Inline because the report has no second file to fetch, and an icon font would be a
183
+ * remote asset. Every one is 16×16 on a 24-unit grid, stroked with `currentColor` so
184
+ * it inherits the surrounding text colour in both themes.
185
+ */
186
+ const ICON_PATHS = {
187
+ search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
188
+ chevron: '<path d="m9 6 6 6-6 6"/>',
189
+ sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/>',
190
+ moon: '<path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z"/>',
191
+ expand: '<path d="M4 9V4h5M20 15v5h-5M15 4h5v5M9 20H4v-5"/>',
192
+ print: '<path d="M6 9V3h12v6M6 18H4v-6h16v6h-2M8 14h8v7H8z"/>',
193
+ warning: '<path d="M12 3 2 20h20L12 3Z"/><path d="M12 9v5m0 3v.5"/>',
194
+ file: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"/><path d="M14 3v5h5"/>',
195
+ close: '<path d="M6 6 18 18M18 6 6 18"/>',
196
+ zoom: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5M11 8v6M8 11h6"/>',
197
+ };
198
+
199
+ export function icon(name, size = 16) {
200
+ const body = ICON_PATHS[name];
201
+ if (!body) return '';
202
+ return (
203
+ `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" ` +
204
+ 'stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" ' +
205
+ `aria-hidden="true" focusable="false">${body}</svg>`
206
+ );
207
+ }
208
+
209
+ /** A `<section>` with a heading, an anchor, and an optional count badge. */
210
+ export function section({ id, title, count = null, note = null, body }) {
211
+ if (!body) return '';
212
+ const badge = count === null ? '' : `<span class="count">${e(count)}</span>`;
213
+ return (
214
+ `<section class="section" id="${e(id)}" aria-labelledby="${e(id)}-h">` +
215
+ `<h2 id="${e(id)}-h">${e(title)}${badge}</h2>` +
216
+ (note ? `<p class="section-note">${e(note)}</p>` : '') +
217
+ body +
218
+ '</section>'
219
+ );
220
+ }
221
+
222
+ /** A table wrapped so wide content scrolls inside its own box, never the page. */
223
+ export function table(headers, rows, { className = '' } = {}) {
224
+ if (rows.length === 0) return '';
225
+ const head = headers
226
+ .map((header) => {
227
+ const label = typeof header === 'string' ? header : header.label;
228
+ const numeric = typeof header === 'object' && header.numeric;
229
+ return `<th${numeric ? ' class="num"' : ''}>${e(label)}</th>`;
230
+ })
231
+ .join('');
232
+ return (
233
+ `<div class="table-wrap ${e(className)}"><table><thead><tr>${head}</tr></thead>` +
234
+ `<tbody>${rows.join('')}</tbody></table></div>`
235
+ );
236
+ }
237
+
238
+ /** A proportional bar for a table cell — payload size, duration, request share. */
239
+ export function barCell(value, max, { colour = null, label = null } = {}) {
240
+ const ratio = max > 0 ? Math.max(0, Math.min(1, Number(value) / max)) : 0;
241
+ const style = colour ? ` style="--bar:${colour}"` : '';
242
+ return (
243
+ `<td class="bar-cell">${label === null ? '' : `<div class="tabular" style="margin-bottom:.25rem">${e(label)}</div>`}` +
244
+ `<div class="bar"${style}><span style="width:${(ratio * 100).toFixed(1)}%"></span></div></td>`
245
+ );
246
+ }
@@ -0,0 +1,246 @@
1
+ // The report's client-side behaviour, emitted as one inline <script>.
2
+ //
3
+ // ## Rules this script lives by
4
+ //
5
+ // 1. **The report works without it.** Findings are real `<details>`, navigation is
6
+ // real anchors, every image is a real `<img>`. This adds search, filtering, the
7
+ // lightbox, and the theme toggle — it is not what makes the document readable.
8
+ // 2. **It never throws.** A report that logs an error to the console is a report the
9
+ // reader stops trusting, and one uncaught exception stops every later listener from
10
+ // binding. Every entry point is guarded and every optional element is null-checked.
11
+ // 3. **No storage assumptions.** A report opened from `file://` may have `localStorage`
12
+ // blocked outright, so persistence is best-effort and its absence changes nothing.
13
+ //
14
+ // It is written as plain ES5-compatible ES2017 — no modules, no optional chaining in
15
+ // the emitted source — because a report gets opened in whatever browser the recipient
16
+ // has, including the embedded one in a mail client.
17
+
18
+ /** The inline script, as source text. */
19
+ export function runtimeScript() {
20
+ return `
21
+ (function () {
22
+ 'use strict';
23
+
24
+ function $(id) { return document.getElementById(id); }
25
+ function all(selector, root) { return Array.prototype.slice.call((root || document).querySelectorAll(selector)); }
26
+
27
+ /* ── Theme ──────────────────────────────────────────────────────────────
28
+ The OS preference is the default; an explicit choice overrides it and is
29
+ remembered when storage allows. */
30
+ var THEME_KEY = 'qa-report-theme';
31
+ function storedTheme() {
32
+ try { return window.localStorage.getItem(THEME_KEY); } catch (err) { return null; }
33
+ }
34
+ function storeTheme(value) {
35
+ try { window.localStorage.setItem(THEME_KEY, value); } catch (err) { /* private mode, file:// */ }
36
+ }
37
+ function systemTheme() {
38
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
39
+ }
40
+ function applyTheme(value) {
41
+ document.documentElement.setAttribute('data-theme', value);
42
+ var button = $('toggle-theme');
43
+ if (button) {
44
+ button.setAttribute('aria-label', value === 'dark' ? 'Switch to light theme' : 'Switch to dark theme');
45
+ button.setAttribute('title', value === 'dark' ? 'Switch to light theme' : 'Switch to dark theme');
46
+ }
47
+ }
48
+ var initial = storedTheme();
49
+ if (initial === 'dark' || initial === 'light') applyTheme(initial);
50
+ var themeButton = $('toggle-theme');
51
+ if (themeButton) {
52
+ themeButton.addEventListener('click', function () {
53
+ var current = document.documentElement.getAttribute('data-theme') || systemTheme();
54
+ var next = current === 'dark' ? 'light' : 'dark';
55
+ applyTheme(next);
56
+ storeTheme(next);
57
+ });
58
+ }
59
+
60
+ var printButton = $('print-report');
61
+ if (printButton) printButton.addEventListener('click', function () { window.print(); });
62
+
63
+ /* ── Search and filtering ───────────────────────────────────────────────
64
+ The DOM is the index: every finding carries its searchable text and its facets
65
+ as data attributes, so there is no second copy to fall out of step. */
66
+ var findings = all('.finding');
67
+ var searchBox = $('finding-search');
68
+ var matchCount = $('match-count');
69
+ var noResults = $('no-results');
70
+ var filterButtons = all('.filter');
71
+ var query = '';
72
+
73
+ function activeValues(kind) {
74
+ var pressed = [];
75
+ var any = false;
76
+ filterButtons.forEach(function (button) {
77
+ if (button.getAttribute('data-filter') !== kind) return;
78
+ any = true;
79
+ if (button.getAttribute('aria-pressed') === 'true') pressed.push(button.getAttribute('data-value'));
80
+ });
81
+ return any ? pressed : null;
82
+ }
83
+
84
+ function apply() {
85
+ var severities = activeValues('severity');
86
+ var dimensions = activeValues('dimension');
87
+ var shown = 0;
88
+
89
+ findings.forEach(function (finding) {
90
+ var text = finding.getAttribute('data-search') || '';
91
+ var matchesQuery = !query || text.indexOf(query) !== -1;
92
+ var matchesSeverity = !severities || severities.indexOf(finding.getAttribute('data-severity')) !== -1;
93
+ var matchesDimension = !dimensions || dimensions.indexOf(finding.getAttribute('data-dimension')) !== -1;
94
+ var visible = matchesQuery && matchesSeverity && matchesDimension;
95
+ finding.hidden = !visible;
96
+ if (visible) shown++;
97
+ /* A search that matches text inside a collapsed card should show that text. */
98
+ if (visible && query && !finding.open) finding.open = true;
99
+ });
100
+
101
+ if (matchCount) {
102
+ matchCount.textContent = shown === findings.length
103
+ ? ''
104
+ : shown + ' of ' + findings.length + ' shown';
105
+ }
106
+ if (noResults) noResults.className = shown === 0 && findings.length > 0 ? 'no-results on' : 'no-results';
107
+ }
108
+
109
+ if (searchBox) {
110
+ var debounce = null;
111
+ searchBox.addEventListener('input', function () {
112
+ window.clearTimeout(debounce);
113
+ debounce = window.setTimeout(function () {
114
+ query = searchBox.value.trim().toLowerCase();
115
+ apply();
116
+ }, 120);
117
+ });
118
+ /* Escape clears, which is what every search box a reader has ever used does. */
119
+ searchBox.addEventListener('keydown', function (event) {
120
+ if (event.key === 'Escape') { searchBox.value = ''; query = ''; apply(); }
121
+ });
122
+ }
123
+
124
+ filterButtons.forEach(function (button) {
125
+ button.addEventListener('click', function () {
126
+ var pressed = button.getAttribute('aria-pressed') === 'true';
127
+ button.setAttribute('aria-pressed', pressed ? 'false' : 'true');
128
+ apply();
129
+ });
130
+ });
131
+
132
+ var toggleAll = $('toggle-all');
133
+ if (toggleAll && findings.length) {
134
+ toggleAll.addEventListener('click', function () {
135
+ var anyClosed = findings.some(function (finding) { return !finding.open && !finding.hidden; });
136
+ findings.forEach(function (finding) { if (!finding.hidden) finding.open = anyClosed; });
137
+ });
138
+ }
139
+
140
+ /* ── Lightbox ───────────────────────────────────────────────────────────
141
+ Screenshots are the evidence most often examined closely, and a report that
142
+ makes the reader open a file manager to see one at full size is not evidence
143
+ they will check. */
144
+ var box = $('lightbox');
145
+ var boxImage = $('lightbox-img');
146
+ var boxName = $('lightbox-name');
147
+ var boxOpen = $('lightbox-open');
148
+ var boxZoom = $('lightbox-zoom');
149
+ var lastFocus = null;
150
+
151
+ function closeBox() {
152
+ if (!box) return;
153
+ box.classList.remove('on', 'zoomed');
154
+ box.hidden = true;
155
+ if (boxImage) boxImage.removeAttribute('src');
156
+ if (lastFocus && lastFocus.focus) lastFocus.focus();
157
+ }
158
+
159
+ function openBox(image) {
160
+ if (!box || !boxImage) return;
161
+ lastFocus = image;
162
+ boxImage.src = image.getAttribute('data-full') || image.src;
163
+ boxImage.alt = image.alt || '';
164
+ if (boxName) boxName.textContent = image.getAttribute('data-name') || image.alt || '';
165
+ if (boxOpen) boxOpen.href = image.getAttribute('data-full') || image.src;
166
+ box.hidden = false;
167
+ box.classList.add('on');
168
+ box.classList.remove('zoomed');
169
+ var close = $('lightbox-close');
170
+ if (close && close.focus) close.focus();
171
+ }
172
+
173
+ all('img.js-zoom').forEach(function (image) {
174
+ image.addEventListener('click', function () { openBox(image); });
175
+ /* Keyboard parity: an image that only opens on click is evidence a keyboard user
176
+ cannot examine. */
177
+ image.setAttribute('tabindex', '0');
178
+ image.setAttribute('role', 'button');
179
+ image.addEventListener('keydown', function (event) {
180
+ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openBox(image); }
181
+ });
182
+ });
183
+
184
+ if (box) {
185
+ box.addEventListener('click', function (event) {
186
+ if (event.target === box || event.target === boxImage) {
187
+ if (event.target === boxImage && !box.classList.contains('zoomed')) return closeBox();
188
+ closeBox();
189
+ }
190
+ });
191
+ var closeButton = $('lightbox-close');
192
+ if (closeButton) closeButton.addEventListener('click', closeBox);
193
+ if (boxZoom) {
194
+ boxZoom.addEventListener('click', function (event) {
195
+ event.stopPropagation();
196
+ box.classList.toggle('zoomed');
197
+ boxZoom.textContent = box.classList.contains('zoomed') ? 'Fit' : 'Zoom';
198
+ });
199
+ }
200
+ document.addEventListener('keydown', function (event) {
201
+ if (event.key === 'Escape' && !box.hidden) closeBox();
202
+ });
203
+ }
204
+
205
+ /* ── Scroll spy ─────────────────────────────────────────────────────────
206
+ Which section the reader is in, reflected in the sidebar. IntersectionObserver
207
+ rather than a scroll listener: no layout thrash, and it degrades to "no active
208
+ highlight" on a browser that lacks it rather than to a broken page. */
209
+ var links = all('nav.toc a');
210
+ if (links.length && 'IntersectionObserver' in window) {
211
+ var byId = {};
212
+ links.forEach(function (link) { byId[link.getAttribute('data-target')] = link; });
213
+ var visible = {};
214
+ var spy = new IntersectionObserver(function (entries) {
215
+ entries.forEach(function (entry) { visible[entry.target.id] = entry.isIntersecting; });
216
+ var current = null;
217
+ all('section.section').forEach(function (section) {
218
+ if (!current && visible[section.id]) current = section.id;
219
+ });
220
+ links.forEach(function (link) {
221
+ link.classList.toggle('active', link.getAttribute('data-target') === current);
222
+ });
223
+ }, { rootMargin: '-72px 0px -60% 0px', threshold: 0 });
224
+ all('section.section').forEach(function (section) { spy.observe(section); });
225
+ }
226
+
227
+ /* ── Entrance ───────────────────────────────────────────────────────────
228
+ Staggered only on first paint, capped so a long report does not spend two
229
+ seconds animating content the reader is already scrolling past. */
230
+ if (window.matchMedia && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
231
+ all('.reveal').forEach(function (element, index) {
232
+ element.style.animationDelay = Math.min(index * 40, 320) + 'ms';
233
+ });
234
+ }
235
+
236
+ /* A deep link to a finding must land on it open, not on a collapsed row. */
237
+ function openHashTarget() {
238
+ if (!window.location.hash) return;
239
+ var target = document.getElementById(window.location.hash.slice(1));
240
+ if (target && target.tagName === 'DETAILS') target.open = true;
241
+ }
242
+ openHashTarget();
243
+ window.addEventListener('hashchange', openHashTarget);
244
+ })();
245
+ `.trim();
246
+ }
@@ -0,0 +1,65 @@
1
+ // The execution timeline: what the run did, in order, and how long each phase took.
2
+ //
3
+ // This answers a question every reader of an automated QA report eventually asks —
4
+ // "what did this thing actually do?" — and it answers a sharper one for whoever has
5
+ // to trust the result: a run that spent four seconds on "security" did not check
6
+ // security, and the timeline is where that becomes visible instead of inferable.
7
+
8
+ import { e, formatDuration, icon } from './primitives.mjs';
9
+
10
+ const PHASE_LABEL = Object.freeze({
11
+ launch: 'Browser launched',
12
+ authentication: 'Authentication',
13
+ navigation: 'Navigation',
14
+ functional: 'Functional testing',
15
+ api: 'API analysis',
16
+ performance: 'Performance measurement',
17
+ security: 'Security checks',
18
+ accessibility: 'Accessibility checks',
19
+ ui: 'UI and layout',
20
+ data: 'Data validation',
21
+ analysis: 'Analysis',
22
+ reporting: 'Report generation',
23
+ });
24
+
25
+ export function phaseLabel(phase) {
26
+ return PHASE_LABEL[phase] ?? phase ?? '';
27
+ }
28
+
29
+ /** The timeline as an ordered list, with a rail and a state dot per phase. */
30
+ export function timelineList(entries) {
31
+ if (!entries || entries.length === 0) return '';
32
+ const total = entries.reduce((sum, entry) => sum + (Number(entry.durationMs) || 0), 0);
33
+
34
+ const items = entries
35
+ .map((entry) => {
36
+ const status = entry.status ?? 'ok';
37
+ const duration = Number(entry.durationMs);
38
+ const share =
39
+ total > 0 && Number.isFinite(duration)
40
+ ? ` · ${Math.round((duration / total) * 100)}% of the run`
41
+ : '';
42
+ return (
43
+ `<li class="tl-${e(status)}">` +
44
+ '<div class="tl-head">' +
45
+ `<span class="tl-label">${e(entry.label || phaseLabel(entry.phase))}</span>` +
46
+ (Number.isFinite(duration)
47
+ ? `<span class="tl-dur">${e(formatDuration(duration))}${e(share)}</span>`
48
+ : '') +
49
+ '</div>' +
50
+ (entry.detail ? `<div class="tl-detail">${e(entry.detail)}</div>` : '') +
51
+ '</li>'
52
+ );
53
+ })
54
+ .join('');
55
+
56
+ return (
57
+ '<div class="card"><div class="card-body">' +
58
+ `<ol class="timeline">${items}</ol>` +
59
+ (total > 0
60
+ ? `<p class="muted" style="margin-top:1rem;font-size:.8125rem">${icon('file', 14)} ` +
61
+ `Total measured time ${e(formatDuration(total))}.</p>`
62
+ : '') +
63
+ '</div></div>'
64
+ );
65
+ }