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,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
+ }
@@ -0,0 +1,270 @@
1
+ // Contract artifact → view model.
2
+ //
3
+ // Every renderer reads this shape, never the raw artifact. One place therefore owns
4
+ // the questions that would otherwise be answered slightly differently in eight
5
+ // sections: which verdict applies when `executive` is absent, what counts as a
6
+ // "covered" dimension, what the subject line of the report is, and — the one that
7
+ // matters most — what may be *derived* versus what must be *measured*.
8
+ //
9
+ // ## Derivation discipline
10
+ //
11
+ // A report is trusted because its numbers came from somewhere. So:
12
+ //
13
+ // derived — the overall score (a fixed function of severity counts), dimension
14
+ // filter counts, page/finding association, totals, the fallback verdict
15
+ // measured — every performance number, every score the producer supplied,
16
+ // artifact existence, request timings
17
+ //
18
+ // Nothing here invents a measurement. If a run never checked accessibility there is
19
+ // no accessibility score, and the report says the area was not examined rather than
20
+ // printing a plausible number. A fabricated 87 is worse than a blank, because the
21
+ // blank prompts a question and the 87 ends one.
22
+
23
+ import path from 'node:path';
24
+
25
+ import { createRegistry } from '../../artifacts/manager.mjs';
26
+ import { VERDICT, SEVERITY_ORDER } from '../theme/tokens.mjs';
27
+ import { DIMENSION_LABEL } from '../components/findings.mjs';
28
+ import { normalize } from './normalize.mjs';
29
+ import { versionStamp } from '../version.mjs';
30
+
31
+ /**
32
+ * What the report is *about*, short enough to be a heading.
33
+ *
34
+ * The contract has no title field on a 1.0 result and the summary is a paragraph —
35
+ * using it as an `<h1>` produced a five-line heading. The host is the honest short
36
+ * answer, the way Lighthouse titles a report by its URL.
37
+ */
38
+ export function subjectOf(result) {
39
+ if (result.title) return String(result.title);
40
+ const url = String(result.url ?? '').trim();
41
+ if (url) {
42
+ const withoutScheme = url.includes('://') ? url.slice(url.indexOf('://') + 3) : url;
43
+ return withoutScheme.replace(/\/+$/, '') || url;
44
+ }
45
+ return String(result.summary ?? 'QA report').split('.')[0].slice(0, 80);
46
+ }
47
+
48
+ /**
49
+ * The release decision.
50
+ *
51
+ * `executive.verdict` is authoritative when the run supplied one. Otherwise the
52
+ * classification maps onto the same surface, so a 1.0 result still renders a verdict
53
+ * banner instead of an empty strip. The severity counts are the last resort and the
54
+ * safest one: any critical means do-not-ship, whatever the prose says.
55
+ */
56
+ export function verdictOf(result) {
57
+ const executive = result.executive ?? {};
58
+ let key = executive.verdict;
59
+
60
+ if (!key) {
61
+ const counts = result.severityCounts ?? {};
62
+ if (result.classification === 'blocked') key = 'blocked';
63
+ else if (result.classification === 'insufficient-data') key = 'insufficient-data';
64
+ else if ((counts.critical ?? 0) > 0) key = 'do-not-ship';
65
+ else if ((counts.high ?? 0) > 0) key = 'ship-with-risks';
66
+ else key = result.classification === 'pass' ? 'pass' : 'issues-found';
67
+ }
68
+
69
+ const spec = VERDICT[key] ?? VERDICT['insufficient-data'];
70
+ return {
71
+ key,
72
+ label: spec.label,
73
+ tone: spec.tone,
74
+ blurb: executive.headline ?? spec.blurb,
75
+ headline: executive.headline ?? null,
76
+ health: executive.health ?? null,
77
+ risks: executive.risks ?? [],
78
+ recommendedAction: executive.recommendedAction ?? null,
79
+ estimatedFixHours: executive.estimatedFixHours ?? null,
80
+ confidence: Number.isFinite(executive.confidence) ? executive.confidence : null,
81
+ // True when nothing in the artifact stated a verdict and this one was inferred.
82
+ inferred: !executive.verdict,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * An overall score from severity counts.
88
+ *
89
+ * Deliberately a fixed, published function rather than a judgement: a critical costs
90
+ * 35 points, a high 12, a medium 4, a low 1, floored at zero. Two runs of the same
91
+ * application produce the same number, and a reader can check the arithmetic. It is
92
+ * shown as "derived" precisely so nobody mistakes it for a measurement.
93
+ */
94
+ export function deriveOverall(counts) {
95
+ const penalty =
96
+ (counts.critical ?? 0) * 35 + (counts.high ?? 0) * 12 + (counts.medium ?? 0) * 4 + (counts.low ?? 0) * 1;
97
+ return Math.max(0, 100 - penalty);
98
+ }
99
+
100
+ /** Dimension facets for the filter bar, counted from the findings themselves. */
101
+ function dimensionFacets(findings) {
102
+ const counts = new Map();
103
+ for (const finding of findings) {
104
+ const key = finding.dimension;
105
+ if (!key) continue;
106
+ counts.set(key, (counts.get(key) ?? 0) + 1);
107
+ }
108
+ return [...counts.entries()]
109
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
110
+ .map(([key, count]) => ({ key, label: DIMENSION_LABEL[key] ?? key, count }));
111
+ }
112
+
113
+ /**
114
+ * The boundary of the run, assembled from what was declared and what is derivable.
115
+ *
116
+ * An unstated boundary reads as "everything was checked". Blocked cases and unrun
117
+ * dimensions are boundaries the artifact already knows about, so they are added to
118
+ * whatever the run declared rather than left to the producer to remember.
119
+ */
120
+ function notCovered(result) {
121
+ const items = [...(result.scope?.notCovered ?? []).map(String)];
122
+ const ran = new Set(result.dimensionsRun ?? []);
123
+
124
+ if (ran.size > 0) {
125
+ for (const [key, label] of Object.entries(DIMENSION_LABEL)) {
126
+ if (ran.has(key)) continue;
127
+ items.push(`${label} was not examined in this run.`);
128
+ }
129
+ }
130
+
131
+ const db = result.dbValidation ?? {};
132
+ if (db.inScope === false && !db.summary) {
133
+ items.push('Data was not compared against the system of record — no access was provided.');
134
+ }
135
+
136
+ const blocked = (result.testCases?.cases ?? []).filter((testCase) => testCase.status === 'blocked');
137
+ if (blocked.length > 0) {
138
+ items.push(`Could not be run: ${blocked.map((c) => `${c.id} (${c.title})`).join(', ')}`);
139
+ }
140
+
141
+ return items;
142
+ }
143
+
144
+ /**
145
+ * Requests worth showing, ranked by how much trouble they represent.
146
+ *
147
+ * A raw endpoint list sorted by URL buries the 4-second call among ninety static
148
+ * assets. Failures first, then slow, then everything flagged with any issue.
149
+ */
150
+ function rankEndpoints(endpoints) {
151
+ const weight = (endpoint) => {
152
+ if (endpoint.issue === 'failed' || Number(endpoint.status) >= 500) return 0;
153
+ if (Number(endpoint.status) >= 400) return 1;
154
+ if (endpoint.issue === 'slow') return 2;
155
+ if (endpoint.issue) return 3;
156
+ return 4;
157
+ };
158
+ return [...endpoints].sort(
159
+ (a, b) => weight(a) - weight(b) || (Number(b.durationMs) || 0) - (Number(a.durationMs) || 0),
160
+ );
161
+ }
162
+
163
+ /**
164
+ * Build the view model.
165
+ *
166
+ * `resultPath` locates the artifact on disk so evidence paths resolve against the
167
+ * right directory; `outPath` is where the document will be written, which is what
168
+ * every href is made relative to. They are usually the same directory and must never
169
+ * be assumed to be.
170
+ */
171
+ export function buildModel(input, options = {}) {
172
+ // Whatever the producer's contract, it becomes one shape here — and it is refused by
173
+ // name if this renderer has never been tested against it.
174
+ const result = normalize(input);
175
+
176
+ const resultPath = options.resultPath ? path.resolve(options.resultPath) : null;
177
+ const baseDir = options.baseDir
178
+ ? path.resolve(options.baseDir)
179
+ : resultPath
180
+ ? path.dirname(resultPath)
181
+ : process.cwd();
182
+ const outDir = options.outPath ? path.dirname(path.resolve(options.outPath)) : baseDir;
183
+
184
+ const registry = createRegistry(result, {
185
+ baseDir,
186
+ outDir,
187
+ hash: options.hash !== false,
188
+ embed: options.embed === true,
189
+ embedLimit: options.embedLimit,
190
+ // Set by the bundler, which copies evidence into its own tree and needs every link
191
+ // pointed at the copy rather than at wherever the file was found.
192
+ hrefMap: options.hrefMap ?? null,
193
+ });
194
+
195
+ const findings = [...(result.findings ?? [])];
196
+ const counts = result.severityCounts ?? { critical: 0, high: 0, medium: 0, low: 0 };
197
+ const testCases = result.testCases ?? null;
198
+ const suppliedScores = result.scores ?? {};
199
+
200
+ const scores = { ...suppliedScores };
201
+ const overallDerived = !Number.isFinite(suppliedScores.overall);
202
+ if (overallDerived) scores.overall = deriveOverall(counts);
203
+
204
+ const network = result.network ?? null;
205
+ const endpoints = rankEndpoints(network?.endpoints ?? []);
206
+
207
+ return {
208
+ // Identity
209
+ title: result.title ?? null,
210
+ subject: subjectOf(result),
211
+ url: result.url ?? null,
212
+ summary: result.summary ?? '',
213
+ generatedAt: result.generatedAt ?? null,
214
+ environment: result.environment ?? null,
215
+ reportVersion: result.reportVersion ?? null,
216
+ browserAdapter: result.browserAdapter ?? null,
217
+ durationMs: Number.isFinite(result.durationMs) ? result.durationMs : null,
218
+ contract: result.contract ?? null,
219
+ skill: result.skill ?? null,
220
+
221
+ // Decision
222
+ classification: result.classification ?? null,
223
+ verdict: verdictOf(result),
224
+ scores,
225
+ overallDerived,
226
+
227
+ // Findings
228
+ findings,
229
+ severityCounts: counts,
230
+ totalFindings: SEVERITY_ORDER.reduce((sum, key) => sum + (counts[key] ?? 0), 0),
231
+ dimensions: dimensionFacets(findings),
232
+
233
+ // Coverage
234
+ scope: result.scope ?? null,
235
+ dimensionsRun: result.dimensionsRun ?? [],
236
+ notCovered: notCovered(result),
237
+ testCases,
238
+ pages: result.pages ?? [],
239
+ authentication: result.authentication ?? null,
240
+
241
+ // Analysis
242
+ timeline: result.timeline ?? [],
243
+ network: network ? { ...network, endpoints } : null,
244
+ performance: result.performance ?? null,
245
+ accessibility: result.accessibility ?? null,
246
+ security: result.security ?? null,
247
+ console: result.console ?? null,
248
+ dbValidation: result.dbValidation ?? null,
249
+
250
+ // Release rollup only: the per-test failure list and the two written summaries.
251
+ failures: result.failureSummary ?? [],
252
+ summaries: result.summaries ?? null,
253
+
254
+ // Guidance
255
+ whatWorksWell: result.whatWorksWell ?? [],
256
+ fixOrder: result.fixOrder ?? [],
257
+ recommendations: result.recommendations ?? [],
258
+
259
+ // Evidence
260
+ registry,
261
+ evidence: result.evidence ?? [],
262
+ artifactStats: registry.stats(),
263
+
264
+ // Provenance, for the appendix. `producer` is displayed and never branched on:
265
+ // the renderer must behave identically whoever made the report.
266
+ producer: result.producer ?? null,
267
+ versions: versionStamp(),
268
+ paths: { resultPath, baseDir, outDir },
269
+ };
270
+ }