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