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,258 @@
1
+ // The finding card — the unit of the report a developer actually works from.
2
+ //
3
+ // ## Collapsed by default, and why the summary has to carry weight
4
+ //
5
+ // A report with twenty findings expanded is a wall nobody reads. Collapsed, the
6
+ // summary row is all most readers ever see, so it carries the four things needed to
7
+ // triage without opening anything: severity, what is wrong, where, and whether it was
8
+ // confirmed. Everything else lives inside.
9
+ //
10
+ // `<details>` rather than a JavaScript accordion: it opens with no script, it is
11
+ // keyboard-operable and screen-reader-announced for free, and Ctrl-F finds text inside
12
+ // a closed one in Chrome. The print stylesheet forces every one open, because a
13
+ // printed report of collapsed headings is a printed table of contents.
14
+ //
15
+ // ## The order of the body
16
+ //
17
+ // what happens now → what should happen → why it matters → how to see it
18
+ // → why it happens → how to fix it → what to re-test → the proof
19
+ //
20
+ // A reader who stops one third of the way down still has the defect, the gap, and the
21
+ // business consequence. Root cause and fix direction come after that, because they are
22
+ // what an engineer needs and the first three are what everyone needs.
23
+
24
+ import {
25
+ e, slug, severityBadge, statusPill, chip, icon, plural,
26
+ } from './primitives.mjs';
27
+ import { evidenceGrid } from './evidence.mjs';
28
+ import { SEVERITY } from '../theme/tokens.mjs';
29
+
30
+ const DIMENSION_LABEL = Object.freeze({
31
+ functional: 'Functionality',
32
+ api: 'API',
33
+ performance: 'Performance',
34
+ security: 'Security',
35
+ ui: 'UI',
36
+ ux: 'UX',
37
+ data: 'Data',
38
+ accessibility: 'Accessibility',
39
+ console: 'Console',
40
+ });
41
+
42
+ const STATUS_LABEL = Object.freeze({
43
+ confirmed: 'Confirmed',
44
+ 'validated-user-report': 'Validated user report',
45
+ 'could-not-reproduce': 'Could not reproduce',
46
+ partial: 'Partially reproduced',
47
+ 'fixed-in-run': 'Fixed during the run',
48
+ });
49
+
50
+ const LAYER_LABEL = Object.freeze({
51
+ frontend: 'Frontend',
52
+ backend: 'Backend',
53
+ network: 'Network',
54
+ data: 'Data',
55
+ infrastructure: 'Infrastructure',
56
+ 'third-party': 'Third party',
57
+ unknown: 'Unknown',
58
+ });
59
+
60
+ export function dimensionLabel(dimension) {
61
+ return DIMENSION_LABEL[dimension] ?? dimension ?? '';
62
+ }
63
+
64
+ export function findingAnchor(id) {
65
+ return slug(id, 'f-');
66
+ }
67
+
68
+ /**
69
+ * Reproduction as steps when the run recorded steps, as prose otherwise.
70
+ *
71
+ * `steps[]` is the contract's structured form and is used when present. Failing that,
72
+ * numbered prose is split on its own numbering — nothing invented, nothing reordered.
73
+ * Unnumbered prose is left exactly as written, because guessing sentence boundaries in
74
+ * a repro turns "click Save. Wait 3s." into two steps that are really one.
75
+ */
76
+ function reproBlock(finding) {
77
+ if (Array.isArray(finding.steps) && finding.steps.length > 0) {
78
+ return `<ol class="steps">${finding.steps.map((step) => `<li>${e(step)}</li>`).join('')}</ol>`;
79
+ }
80
+ const text = String(finding.repro ?? '').trim();
81
+ if (!text) return '<span class="empty">Not recorded</span>';
82
+
83
+ let lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
84
+ if (lines.length === 1) {
85
+ lines = text.split(/\s+(?=\d+[.)]\s)/).map((part) => part.trim()).filter(Boolean);
86
+ }
87
+ if (lines.length < 2 || !lines.every((line) => /^\d+[.)]\s/.test(line))) {
88
+ return e(text);
89
+ }
90
+ const steps = lines.map((line) => line.replace(/^\d+[.)]\s*/, ''));
91
+ return `<ol class="steps">${steps.map((step) => `<li>${e(step)}</li>`).join('')}</ol>`;
92
+ }
93
+
94
+ /** A labelled row in the behaviour list. */
95
+ function row(term, body, railClass = null) {
96
+ if (!body) return '';
97
+ return `<dt>${e(term)}</dt><dd${railClass ? ` class="rail ${railClass}"` : ''}>${body}</dd>`;
98
+ }
99
+
100
+ /** The metrics table that turns "slow" into a number with a budget beside it. */
101
+ function metricsPanel(metrics) {
102
+ if (!metrics || metrics.length === 0) return '';
103
+ const rows = metrics
104
+ .map(
105
+ (metric) =>
106
+ '<tr>' +
107
+ `<td>${e(metric.label)}</td>` +
108
+ `<td class="num"${metric.breached ? ' style="color:var(--sev-solid);font-weight:650"' : ''}>` +
109
+ `${e(metric.value)}</td>` +
110
+ `<td class="num muted">${e(metric.budget ?? '—')}</td>` +
111
+ '</tr>',
112
+ )
113
+ .join('');
114
+ return (
115
+ '<div class="table-wrap" style="margin-top:1rem"><table>' +
116
+ '<thead><tr><th>Measurement</th><th class="num">Observed</th><th class="num">Budget</th></tr></thead>' +
117
+ `<tbody>${rows}</tbody></table></div>`
118
+ );
119
+ }
120
+
121
+ /** Root cause: the investigation, rendered as a causal chain rather than a paragraph. */
122
+ function rootCausePanel(rootCause) {
123
+ if (!rootCause?.summary) return '';
124
+ const chain =
125
+ Array.isArray(rootCause.chain) && rootCause.chain.length > 0
126
+ ? `<ol class="chain" style="margin-top:.5rem">${rootCause.chain.map((step) => `<li>${e(step)}</li>`).join('')}</ol>`
127
+ : '';
128
+ const meta = [
129
+ rootCause.layer ? chip(LAYER_LABEL[rootCause.layer] ?? rootCause.layer) : '',
130
+ Number.isFinite(rootCause.confidence)
131
+ ? chip(`${Math.round(rootCause.confidence * 100)}% confidence`)
132
+ : '',
133
+ ]
134
+ .filter(Boolean)
135
+ .join('');
136
+ return (
137
+ `<div>${e(rootCause.summary)}${chain}` +
138
+ (meta ? `<div style="margin-top:.5rem;display:flex;gap:.375rem;flex-wrap:wrap">${meta}</div>` : '') +
139
+ '</div>'
140
+ );
141
+ }
142
+
143
+ /** What QA re-runs once the fix lands — the half of a bug report that usually goes missing. */
144
+ function regressionPanel(regression) {
145
+ if (!regression?.level) return '';
146
+ const tone = { high: 'critical', medium: 'medium', low: 'low' }[regression.level] ?? 'low';
147
+ const retest =
148
+ Array.isArray(regression.retest) && regression.retest.length > 0
149
+ ? `<ul class="clean" style="margin-top:.5rem">${regression.retest.map((item) => `<li>${e(item)}</li>`).join('')}</ul>`
150
+ : '';
151
+ return (
152
+ `<div><span class="badge sev-${tone}">${e(regression.level)} risk</span>` +
153
+ (regression.note ? `<div style="margin-top:.375rem">${e(regression.note)}</div>` : '') +
154
+ retest +
155
+ '</div>'
156
+ );
157
+ }
158
+
159
+ /**
160
+ * One finding, collapsed.
161
+ *
162
+ * `data-*` attributes on the element carry everything the client-side filter needs, so
163
+ * search and severity filtering run without a second copy of the findings in a script
164
+ * block — the DOM is the index.
165
+ */
166
+ export function findingCard(finding, registry, { open = false } = {}) {
167
+ const severity = SEVERITY[finding.severity] ? finding.severity : 'low';
168
+ const anchor = findingAnchor(finding.id);
169
+ const tags = Array.isArray(finding.tags) ? finding.tags : [];
170
+
171
+ // Everything a reader might type into the search box, flattened once at render time.
172
+ const haystack = [
173
+ finding.id, finding.title, finding.actual, finding.expected, finding.fixDirection,
174
+ finding.businessImpact, finding.developerNotes, finding.page,
175
+ finding.rootCause?.summary, dimensionLabel(finding.dimension),
176
+ ...tags, ...(finding.affectedApis ?? []),
177
+ ]
178
+ .filter(Boolean)
179
+ .join(' ')
180
+ .toLowerCase();
181
+
182
+ const meta = [
183
+ `<span class="finding-id">${e(finding.id)}</span>`,
184
+ chip(dimensionLabel(finding.dimension)),
185
+ statusPill(finding.status, STATUS_LABEL[finding.status] ?? finding.status),
186
+ finding.page ? chip(finding.page) : '',
187
+ ...tags.map((tag) => chip(tag)),
188
+ ]
189
+ .filter(Boolean)
190
+ .join('');
191
+
192
+ const body = [
193
+ '<dl class="behaviour">',
194
+ row('Current behaviour', e(finding.actual), 'rail-now'),
195
+ row('Expected behaviour', e(finding.expected), 'rail-want'),
196
+ row('Business impact', finding.businessImpact ? e(finding.businessImpact) : '', 'rail-impact'),
197
+ row('Reproduction', reproBlock(finding)),
198
+ row('Root cause', rootCausePanel(finding.rootCause), 'rail-cause'),
199
+ row('Suggested fix', e(finding.fixDirection), 'rail-fix'),
200
+ row('Regression risk', regressionPanel(finding.regressionRisk)),
201
+ row(
202
+ 'Affected APIs',
203
+ (finding.affectedApis ?? []).map((api) => `<code>${e(api)}</code>`).join(' '),
204
+ ),
205
+ row('Developer notes', finding.developerNotes ? e(finding.developerNotes) : ''),
206
+ '</dl>',
207
+ metricsPanel(finding.metrics),
208
+ evidenceGrid(finding.evidence, registry),
209
+ ].join('');
210
+
211
+ return (
212
+ `<details class="finding sev-${severity}" id="${e(anchor)}"${open ? ' open' : ''} ` +
213
+ `data-severity="${e(severity)}" data-dimension="${e(finding.dimension ?? '')}" ` +
214
+ `data-status="${e(finding.status ?? '')}" data-page="${e(finding.page ?? '')}" ` +
215
+ `data-tags="${e(tags.join(' '))}" data-search="${e(haystack)}">` +
216
+ '<summary>' +
217
+ severityBadge(severity) +
218
+ '<span class="finding-grow">' +
219
+ `<span class="finding-title">${e(finding.title)}</span>` +
220
+ `<span class="finding-meta">${meta}</span>` +
221
+ '</span>' +
222
+ `<span class="caret">${icon('chevron', 18)}</span>` +
223
+ '</summary>' +
224
+ `<div class="finding-body">${body}</div>` +
225
+ '</details>'
226
+ );
227
+ }
228
+
229
+ /** Every finding, worst first, with a stable tie-break so reruns diff cleanly. */
230
+ export function findingList(findings, registry) {
231
+ const sorted = [...findings].sort((a, b) => {
232
+ const rank = (SEVERITY[a.severity] ?? SEVERITY.low).rank - (SEVERITY[b.severity] ?? SEVERITY.low).rank;
233
+ return rank !== 0 ? rank : String(a.id).localeCompare(String(b.id), 'en', { numeric: true });
234
+ });
235
+ if (sorted.length === 0) {
236
+ return '<div class="card"><div class="card-body empty">No findings recorded.</div></div>';
237
+ }
238
+ return (
239
+ sorted.map((finding) => findingCard(finding, registry)).join('') +
240
+ '<div class="no-results" id="no-results">' +
241
+ `<p><strong>Nothing matches those filters.</strong></p><p>${e(plural(sorted.length, 'finding'))} in total — ` +
242
+ 'clear the search box or reset the severity filters to see them.</p></div>'
243
+ );
244
+ }
245
+
246
+ /** The severity legend, so a reader can calibrate without asking what "high" means. */
247
+ export function severityLegend() {
248
+ const rows = Object.entries(SEVERITY)
249
+ .sort((a, b) => a[1].rank - b[1].rank)
250
+ .map(([key, value]) => `<tr><td>${severityBadge(key)}</td><td>${e(value.meaning)}</td></tr>`)
251
+ .join('');
252
+ return (
253
+ '<div class="table-wrap"><table><thead><tr><th>Severity</th><th>What it claims</th></tr></thead>' +
254
+ `<tbody>${rows}</tbody></table></div>`
255
+ );
256
+ }
257
+
258
+ export { STATUS_LABEL, DIMENSION_LABEL };
@@ -0,0 +1,99 @@
1
+ // The sticky sidebar and the findings toolbar.
2
+ //
3
+ // A twelve-section report needs a map, and a report shared with six audiences needs a
4
+ // way for each of them to reach their part of it: an engineering manager wants
5
+ // Findings, a CTO wants Overview and Security, a designer wants Screenshots.
6
+ //
7
+ // Both controls degrade honestly. The sidebar is a list of in-page anchors, so it
8
+ // works with scripting disabled and it prints as nothing (deliberately hidden). The
9
+ // toolbar's search and filters need script, so they are rendered only when the report
10
+ // has enough findings to be worth filtering, and the findings themselves are always
11
+ // present in the document whether or not the toolbar ever runs.
12
+
13
+ import { e, icon } from './primitives.mjs';
14
+ import { SEVERITY, SEVERITY_ORDER } from '../theme/tokens.mjs';
15
+
16
+ /** The sidebar: brand, then one anchor per section that has content. */
17
+ export function sidebar(sections, { productName, subtitle }) {
18
+ const links = sections
19
+ .filter((section) => section.body)
20
+ .map(
21
+ (section) =>
22
+ `<a href="#${e(section.id)}" data-target="${e(section.id)}">` +
23
+ `<span>${e(section.navLabel ?? section.title)}</span>` +
24
+ (section.count === null || section.count === undefined
25
+ ? ''
26
+ : `<span class="n">${e(section.count)}</span>`) +
27
+ '</a>',
28
+ )
29
+ .join('');
30
+
31
+ return (
32
+ '<aside class="sidebar" aria-label="Report sections">' +
33
+ '<div class="sidebar-brand">' +
34
+ '<span class="sidebar-mark" aria-hidden="true">QA</span>' +
35
+ `<span><span class="sidebar-name">${e(productName)}</span>` +
36
+ `<br><span class="sidebar-sub">${e(subtitle)}</span></span>` +
37
+ '</div>' +
38
+ `<nav class="toc" aria-label="Sections">${links}</nav>` +
39
+ '</aside>'
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Search, severity filters, expand/collapse, theme, print.
45
+ *
46
+ * The severity buttons start pressed — the default view is everything, and a filter
47
+ * that hides findings before the reader asks is how a report loses a critical.
48
+ */
49
+ export function toolbar(counts, { dimensions = [] } = {}) {
50
+ const severityFilters = SEVERITY_ORDER.filter((key) => (counts[key] ?? 0) > 0)
51
+ .map(
52
+ (key) =>
53
+ `<button type="button" class="filter sev-${key}" data-filter="severity" data-value="${key}" ` +
54
+ `aria-pressed="true"><span class="dot"></span>${e(SEVERITY[key].label)} ` +
55
+ `<span class="tabular">${e(counts[key] ?? 0)}</span></button>`,
56
+ )
57
+ .join('');
58
+
59
+ const dimensionFilters = dimensions
60
+ .map(
61
+ (dimension) =>
62
+ `<button type="button" class="filter" data-filter="dimension" data-value="${e(dimension.key)}" ` +
63
+ `aria-pressed="true">${e(dimension.label)} <span class="tabular">${e(dimension.count)}</span></button>`,
64
+ )
65
+ .join('');
66
+
67
+ return (
68
+ '<div class="toolbar" role="search">' +
69
+ '<label class="search">' +
70
+ icon('search', 16) +
71
+ '<input type="search" id="finding-search" placeholder="Search findings, APIs, pages, root causes…" ' +
72
+ 'autocomplete="off" aria-label="Search findings">' +
73
+ '</label>' +
74
+ `<div class="filters">${severityFilters}${dimensionFilters}</div>` +
75
+ '<span class="match-count" id="match-count" aria-live="polite"></span>' +
76
+ '<button type="button" class="iconbtn" id="toggle-all" title="Expand or collapse every finding" ' +
77
+ `aria-label="Expand or collapse every finding">${icon('expand', 16)}</button>` +
78
+ '<button type="button" class="iconbtn" id="toggle-theme" title="Switch between light and dark" ' +
79
+ `aria-label="Switch between light and dark">${icon('moon', 16)}</button>` +
80
+ '<button type="button" class="iconbtn" id="print-report" title="Print or save as PDF" ' +
81
+ `aria-label="Print or save as PDF">${icon('print', 16)}</button>` +
82
+ '</div>'
83
+ );
84
+ }
85
+
86
+ /** The lightbox shell. Empty until a screenshot is clicked. */
87
+ export function lightbox() {
88
+ return (
89
+ '<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-label="Screenshot viewer" hidden>' +
90
+ '<div class="lightbox-bar">' +
91
+ '<span class="grow" id="lightbox-name"></span>' +
92
+ '<button type="button" id="lightbox-zoom">Zoom</button>' +
93
+ '<a id="lightbox-open" href="#" target="_blank" rel="noopener noreferrer">Open file</a>' +
94
+ `<button type="button" id="lightbox-close" aria-label="Close viewer">${icon('close', 16)}</button>` +
95
+ '</div>' +
96
+ '<img id="lightbox-img" alt="">' +
97
+ '</div>'
98
+ );
99
+ }
@@ -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
+ }