qa-engineer 0.9.1 → 0.10.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 (109) hide show
  1. package/COMPATIBILITY.md +50 -26
  2. package/README.md +40 -32
  3. package/package.json +12 -20
  4. package/packages/engine/bin/qa-engine.mjs +336 -0
  5. package/packages/engine/lib/analysis/branding.mjs +187 -0
  6. package/packages/engine/lib/analysis/context.mjs +294 -0
  7. package/packages/{installer/lib/core/schema-validate.mjs → engine/lib/analysis/contracts.mjs} +11 -4
  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 +90 -0
  12. package/packages/engine/lib/analysis/junit.mjs +126 -0
  13. package/packages/engine/lib/analysis/redaction.mjs +127 -0
  14. package/packages/engine/lib/analysis/report-html.mjs +763 -0
  15. package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
  16. package/packages/engine/lib/analysis/xml.mjs +153 -0
  17. package/packages/engine/lib/analysis/zip.mjs +107 -0
  18. package/packages/engine/lib/diagnostics/engine.mjs +165 -0
  19. package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
  20. package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
  21. package/packages/engine/lib/diagnostics/repair.mjs +73 -0
  22. package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
  23. package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
  24. package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
  25. package/packages/engine/lib/frameworks/playwright.mjs +158 -0
  26. package/packages/engine/package.json +14 -0
  27. package/packages/installer/lib/agents/registry.mjs +46 -0
  28. package/packages/installer/lib/commands/doctor.mjs +7 -16
  29. package/packages/installer/lib/commands/install.mjs +59 -4
  30. package/packages/installer/lib/core/bundle.mjs +69 -92
  31. package/packages/installer/lib/core/config.mjs +1 -1
  32. package/packages/installer/lib/core/lockfile.mjs +1 -1
  33. package/packages/installer/lib/core/manifest.mjs +48 -54
  34. package/packages/installer/lib/core/validate-install.mjs +12 -19
  35. package/packages/installer/package.json +1 -1
  36. package/shared/tooling/qa-tool.mjs +125 -0
  37. package/skills/qa-api/SKILL.md +5 -5
  38. package/skills/qa-api/references/deterministic-tooling.md +34 -30
  39. package/skills/qa-api/references/evidence-and-reporting.md +29 -4
  40. package/skills/qa-api/scripts/qa-tool.mjs +126 -0
  41. package/skills/qa-audit/SKILL.md +5 -5
  42. package/skills/qa-audit/references/deterministic-tooling.md +34 -30
  43. package/skills/qa-audit/references/evidence-and-reporting.md +29 -4
  44. package/skills/qa-audit/scripts/qa-tool.mjs +126 -0
  45. package/skills/qa-debug/SKILL.md +6 -6
  46. package/skills/qa-debug/references/deterministic-tooling.md +34 -30
  47. package/skills/qa-debug/references/diagnostic-engine.md +3 -3
  48. package/skills/qa-debug/references/evidence-and-reporting.md +29 -4
  49. package/skills/qa-debug/scripts/qa-tool.mjs +126 -0
  50. package/skills/qa-explore/SKILL.md +22 -5
  51. package/skills/qa-explore/contracts/explore-result.schema.json +26 -2
  52. package/skills/qa-explore/references/deterministic-tooling.md +132 -0
  53. package/skills/qa-explore/references/evidence-and-reporting.md +29 -4
  54. package/skills/qa-explore/references/report-pipeline.md +89 -8
  55. package/skills/qa-explore/scripts/qa-tool.mjs +126 -0
  56. package/skills/qa-fix/SKILL.md +4 -4
  57. package/skills/qa-fix/references/deterministic-tooling.md +34 -30
  58. package/skills/qa-fix/references/diagnostic-engine.md +3 -3
  59. package/skills/qa-fix/references/evidence-and-reporting.md +29 -4
  60. package/skills/qa-fix/scripts/qa-tool.mjs +126 -0
  61. package/skills/qa-flaky/SKILL.md +4 -4
  62. package/skills/qa-flaky/references/deterministic-tooling.md +34 -30
  63. package/skills/qa-flaky/references/evidence-and-reporting.md +29 -4
  64. package/skills/qa-flaky/scripts/qa-tool.mjs +126 -0
  65. package/skills/qa-generate/references/evidence-and-reporting.md +29 -4
  66. package/skills/qa-init/SKILL.md +4 -4
  67. package/skills/qa-init/references/deterministic-tooling.md +34 -30
  68. package/skills/qa-init/references/evidence-and-reporting.md +29 -4
  69. package/skills/qa-init/scripts/qa-tool.mjs +126 -0
  70. package/skills/qa-report/SKILL.md +8 -7
  71. package/skills/qa-report/references/deterministic-tooling.md +34 -30
  72. package/skills/qa-report/references/diagnostic-engine.md +3 -3
  73. package/skills/qa-report/references/evidence-and-reporting.md +29 -4
  74. package/skills/qa-report/scripts/qa-tool.mjs +126 -0
  75. package/skills/qa-review/references/evidence-and-reporting.md +29 -4
  76. package/skills/qa-run/SKILL.md +6 -6
  77. package/skills/qa-run/references/deterministic-tooling.md +34 -30
  78. package/skills/qa-run/references/evidence-and-reporting.md +29 -4
  79. package/skills/qa-run/scripts/qa-tool.mjs +126 -0
  80. package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
  81. package/shared/analysis/lib/qa_analysis/branding.py +0 -175
  82. package/shared/analysis/lib/qa_analysis/cli.py +0 -129
  83. package/shared/analysis/lib/qa_analysis/context.py +0 -233
  84. package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
  85. package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
  86. package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
  87. package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
  88. package/shared/analysis/lib/qa_analysis/har.py +0 -58
  89. package/shared/analysis/lib/qa_analysis/junit.py +0 -80
  90. package/shared/analysis/lib/qa_analysis/redaction.py +0 -99
  91. package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
  92. package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
  93. package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
  94. package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
  95. package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
  96. package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
  97. package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
  98. package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
  99. package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
  100. package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
  101. package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
  102. package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
  103. package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
  104. package/shared/tooling/qa_tool.py +0 -127
  105. /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
  106. /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
  107. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
  108. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
  109. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/execution-result-min.schema.json +0 -0
@@ -0,0 +1,763 @@
1
+ // Render a contract artifact as a presentation-grade HTML report.
2
+ //
3
+ // ## Why this is code
4
+ //
5
+ // The first real `/qa-explore` run on a live application produced a valid artifact
6
+ // and a *lossy* report. Every finding in the contract carries `repro`, `actual`,
7
+ // `expected`, and `fixDirection` — all four required fields — and the hand-written
8
+ // HTML collapsed them into a single sentence:
9
+ //
10
+ // EXP-1 · high — Double-click Login fires two GraphQL auth requests
11
+ // Two identical POSTs to /graphql. Disable Login while in flight.
12
+ //
13
+ // The reader is left to infer what was expected, how to reproduce it, and what
14
+ // "correct" would look like. The data existed; the rendering discarded it. It also
15
+ // omitted the attribution footer entirely.
16
+ //
17
+ // Both failures share one cause: the report was *typed* rather than *rendered*. So
18
+ // it is rendered here. The contract is the input, every required field appears in
19
+ // the output, and the footer is not optional. A report cannot silently drop what a
20
+ // reader needs, because no one is retyping it.
21
+ //
22
+ // ## What it renders
23
+ //
24
+ // `explore-result` and `report-result` today — the two artifacts a human reads.
25
+ // Each finding becomes a card stating, in order: what is wrong, **what happens
26
+ // now**, **what should happen instead**, how to reproduce it, and the fix
27
+ // direction. That ordering is deliberate: a reader who stops after two lines still
28
+ // knows the defect and the gap.
29
+ //
30
+ // Ahead of the findings comes the orientation a forwarded report needs — what kind
31
+ // of document this is, how the application was observed, what each area checked
32
+ // means, what the severity labels claim, and what the run did *not* cover.
33
+ //
34
+ // No dependencies, single self-contained file, no external assets — a report must
35
+ // open from an email attachment on a plane.
36
+
37
+ import fs from 'node:fs';
38
+
39
+ import { footerHtml } from './branding.mjs';
40
+
41
+ export class ReportError extends Error {}
42
+
43
+ // Severity drives colour, order, and the summary bar. Kept here rather than in the
44
+ // template so a new severity cannot render as an unstyled surprise.
45
+ const SEVERITY = {
46
+ critical: { label: "Critical", colour: "#8b0018", tint: "#fdf0f2", rank: 0 },
47
+ high: { label: "High", colour: "#b3261e", tint: "#fdf1f0", rank: 1 },
48
+ medium: { label: "Medium", colour: "#a15c00", tint: "#fdf6ec", rank: 2 },
49
+ low: { label: "Low", colour: "#4a5568", tint: "#f4f5f7", rank: 3 },
50
+ };
51
+
52
+ // Summary tiles for non-severity counts. Test totals are not severities, and
53
+ // rendering "failed" in the same neutral grey as "passed" is how a reader misses
54
+ // the number that matters.
55
+ const COUNT_STYLE = {
56
+ "total": "#344054",
57
+ "passed": "#116149",
58
+ "failed": "#b3261e",
59
+ "blocked": "#a15c00",
60
+ "skipped": "#667085",
61
+ };
62
+
63
+ // What each evidence kind is called in the report. The contract guarantees `type`
64
+ // on every entry but `description` only on the top-level index, so the type is
65
+ // what a caption can always be built from.
66
+ const EVIDENCE_LABEL = {
67
+ "screenshot": "Screenshot",
68
+ "network": "Network capture",
69
+ "console": "Console output",
70
+ "dom": "DOM snapshot",
71
+ "har": "HAR archive",
72
+ "db": "Database query",
73
+ "file": "File",
74
+ "report": "Report",
75
+ "command": "Command output",
76
+ "trace": "Trace",
77
+ "log": "Log",
78
+ "diff": "Diff",
79
+ };
80
+
81
+ // What each QA dimension means to someone who has never read a QA report. The
82
+ // contract stores dimension *names*, which are jargon: "ux" tells a reader nothing.
83
+ const DIMENSION_LABEL = {
84
+ "functional": "Functionality",
85
+ "api": "API",
86
+ "performance": "Performance",
87
+ "security": "Security (client-side)",
88
+ "ui": "UI",
89
+ "ux": "UX",
90
+ "data": "Data",
91
+ };
92
+
93
+ const DIMENSION_PLAIN = {
94
+ "functional": "Does the feature do what it is supposed to do, including when the input is wrong?",
95
+ "api": "The network requests the page makes — whether they are correct, and how the page handles a bad response",
96
+ "performance": "How much the page downloads and how quickly it becomes usable",
97
+ "security": "Client-side exposure only: where credentials and tokens are stored, what leaks into URLs and error messages",
98
+ "ui": "Layout and visual states — empty, loading, error — including a narrow mobile screen",
99
+ "ux": "Whether the flow makes sense to a person using it, and whether the wording helps them",
100
+ "data": "Whether the numbers and text on screen match the system of record behind them",
101
+ };
102
+
103
+ // What each severity is claiming, so a reader can calibrate without asking.
104
+ const SEVERITY_MEANING = {
105
+ "critical": "Blocks release. Data loss, a security hole, or a core flow that cannot be completed.",
106
+ "high": "Fix before release. A user hits this on a normal path and the product does the wrong thing.",
107
+ "medium": "Fix soon. Real but survivable — a workaround exists, or the path is less common.",
108
+ "low": "Worth fixing. Polish, hygiene, or a measurement that needs confirming before it is acted on.",
109
+ };
110
+
111
+ // How each browser adapter observed the application, in one honest phrase. A
112
+ // reader deciding how much to trust a finding needs to know what watched the page.
113
+ const ADAPTER_PLAIN = {
114
+ "playwright-mcp": "a real browser driven by Playwright",
115
+ "cursor-browser": "the editor's built-in browser",
116
+ "cdp": "a real browser over the Chrome DevTools Protocol",
117
+ "cli-playwright": "a real browser driven by the Playwright CLI",
118
+ "cli-other": "a real browser driven from the command line",
119
+ "unavailable": "no browser automation — findings come from artifacts supplied to the run",
120
+ };
121
+
122
+ const STATUS_LABEL = {
123
+ "confirmed": "Confirmed",
124
+ "validated-user-report": "Validated user report",
125
+ "could-not-reproduce": "Could not reproduce",
126
+ "partial": "Partially reproduced",
127
+ "pass": "Passed",
128
+ "fail": "Failed",
129
+ "blocked": "Blocked",
130
+ "skipped": "Skipped",
131
+ };
132
+
133
+ const VERDICT = {
134
+ "pass": ["No defects found", "#116149", "#eaf7f1"],
135
+ "issues-found": ["Issues found", "#a15c00", "#fdf6ec"],
136
+ "blocked": ["Blocked", "#b3261e", "#fdf1f0"],
137
+ "insufficient-data": ["Insufficient data", "#4a5568", "#f4f5f7"],
138
+ "ready": ["Ready to ship", "#116149", "#eaf7f1"],
139
+ "ready-with-risks": ["Ready with risks", "#a15c00", "#fdf6ec"],
140
+ "not-ready": ["Not ready", "#b3261e", "#fdf1f0"],
141
+ };
142
+
143
+ const CSS = "\n*,*::before,*::after{box-sizing:border-box}\nbody{margin:0;background:#f7f8fa;color:#1a1d21;\n font:16px/1.6 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif;\n -webkit-font-smoothing:antialiased}\n.page{max-width:60rem;margin:0 auto;padding:2.5rem 1.5rem 3rem}\n.card{background:#fff;border:1px solid #e4e7ec;border-radius:12px;\n box-shadow:0 1px 2px rgba(16,24,40,.04);margin-bottom:1.25rem;overflow:hidden}\n.card-body{padding:1.5rem}\nheader.masthead{margin-bottom:1.75rem}\n.eyebrow{font-size:.6875rem;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:#667085}\nh1{margin:.35rem 0 .5rem;font-size:1.875rem;line-height:1.25;letter-spacing:-.02em}\nh2{margin:2rem 0 .875rem;font-size:1.125rem;letter-spacing:-.01em}\nh3{margin:0;font-size:1.0625rem;letter-spacing:-.01em}\n.meta{color:#667085;font-size:.8125rem}\n.meta strong{color:#344054;font-weight:600}\n.lead{margin:0 0 .25rem;font-size:1.0625rem;color:#475467;max-width:52rem}\n.intro p{margin:0 0 .75rem;max-width:52rem}\n.intro p:last-child{margin-bottom:0}\n.objective{margin:0 0 .875rem;max-width:52rem}\n.objective:last-child{margin-bottom:0}\n.verdict{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;\n padding:1rem 1.25rem;border-radius:10px;font-weight:600;margin:1.25rem 0}\n.counts{display:flex;flex-wrap:wrap;gap:.5rem;margin:1.25rem 0}\n.count{flex:1 1 7rem;background:#fff;border:1px solid #e4e7ec;border-radius:10px;\n padding:.75rem .875rem;text-align:center}\n.count .n{display:block;font-size:1.5rem;font-weight:700;line-height:1.2}\n.count .l{font-size:.6875rem;text-transform:uppercase;letter-spacing:.07em;color:#667085}\n.badge{display:inline-block;padding:.1875rem .5rem;border-radius:6px;\n font-size:.6875rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase;\n border:1px solid currentColor}\n.chip{display:inline-block;padding:.1875rem .5rem;border-radius:6px;background:#f2f4f7;\n color:#475467;font-size:.75rem;font-weight:500}\n.finding-head{display:flex;gap:.75rem;align-items:flex-start;\n padding:1.125rem 1.5rem;border-bottom:1px solid #eef0f3}\n.finding-head .grow{flex:1;min-width:0}\n.finding-id{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.75rem;color:#667085}\ndl.behaviour{margin:0;display:grid;grid-template-columns:minmax(8.5rem,auto) 1fr;gap:.625rem 1.25rem}\ndl.behaviour dt{font-size:.75rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#667085;padding-top:.15rem}\ndl.behaviour dd{margin:0}\n.now{border-left:3px solid #b3261e;padding-left:.75rem}\n.want{border-left:3px solid #116149;padding-left:.75rem}\n.fix{border-left:3px solid #3538cd;padding-left:.75rem}\ntable{border-collapse:collapse;width:100%;font-size:.875rem}\nth,td{text-align:left;padding:.5625rem .75rem;border-bottom:1px solid #eef0f3;vertical-align:top}\nth{font-size:.6875rem;text-transform:uppercase;letter-spacing:.06em;color:#667085;\n background:#fafbfc;font-weight:700}\ntbody tr:last-child td{border-bottom:0}\ncode{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.875em;\n background:#f2f4f7;padding:.1rem .3rem;border-radius:4px}\npre{margin:0;background:#1a1d21;color:#e4e7ec;padding:1rem;border-radius:8px;\n overflow-x:auto;font-size:.8125rem;line-height:1.55}\nfigure{margin:.875rem 0 0}\nfigure img{display:block;max-width:100%;height:auto;border:1px solid #e4e7ec;border-radius:8px}\nfigcaption{margin-top:.4rem;font-size:.75rem;color:#667085}\nul.clean{margin:0;padding-left:1.125rem}\nul.clean li{margin:.3rem 0}\nol.steps{margin:0;padding-left:1.25rem}\nol.steps li{margin:.15rem 0}\nol.order{margin:0;padding-left:1.25rem}\nol.order li{margin:.4rem 0}\n.empty{color:#667085;font-style:italic}\n@media print{body{background:#fff}.card{box-shadow:none;break-inside:avoid}.page{padding:0}}\n@media (max-width:34rem){dl.behaviour{grid-template-columns:1fr;gap:.25rem}\n dl.behaviour dt{padding-top:.5rem}}\n";
144
+
145
+ /** HTML-escape, matching Python's `html.escape(s, quote=True)`. */
146
+ function e(value) {
147
+ if (value === null || value === undefined) return '';
148
+ return String(value)
149
+ .replace(/&/g, '&')
150
+ .replace(/</g, '&lt;')
151
+ .replace(/>/g, '&gt;')
152
+ .replace(/"/g, '&quot;')
153
+ .replace(/'/g, '&#x27;');
154
+ }
155
+
156
+ function severityBadge(severity) {
157
+ const meta = SEVERITY[severity] ?? SEVERITY.low;
158
+ return (
159
+ `<span class="badge" style="color:${meta.colour};background:${meta.tint}">` +
160
+ `${e(meta.label)}</span>`
161
+ );
162
+ }
163
+
164
+ /**
165
+ * What the report is about, short enough to be a heading.
166
+ *
167
+ * The contract has no title field, and the summary is a paragraph — using it as an
168
+ * `<h1>` produced a five-line heading. The target under test is the honest short
169
+ * answer, the way Lighthouse titles a report by its URL.
170
+ */
171
+ function subject(result) {
172
+ const url = String(result.url ?? '').trim();
173
+ if (url) {
174
+ const withoutScheme = url.includes('://') ? url.slice(url.indexOf('://') + 3) : url;
175
+ return withoutScheme.replace(/\/+$/, '') || url;
176
+ }
177
+ return String(result.summary ?? 'QA report').split('.')[0].slice(0, 80);
178
+ }
179
+
180
+ /**
181
+ * Reproduction as steps when it is written as steps, as prose otherwise.
182
+ *
183
+ * Numbered steps are the part of a report a reader retypes into a browser, so they
184
+ * are rendered as a list rather than one dense line. The split is on the text's own
185
+ * numbering — nothing is invented, reordered, or dropped.
186
+ */
187
+ function reproSteps(repro) {
188
+ const text = String(repro ?? '').trim();
189
+ if (!text) return '<dd class="empty">Not recorded</dd>';
190
+ let lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
191
+ if (lines.length === 1) {
192
+ lines = text.split(/\s+(?=\d+[.)]\s)/).map((part) => part.trim()).filter(Boolean);
193
+ }
194
+ const steps = lines.map((line) => line.replace(/^\d+[.)]\s*/, ''));
195
+ if (steps.length < 2 || !lines.every((line) => /^\d+[.)]\s/.test(line))) {
196
+ return `<dd>${e(text)}</dd>`;
197
+ }
198
+ return `<dd><ol class="steps">${steps.map((step) => `<li>${e(step)}</li>`).join('')}</ol></dd>`;
199
+ }
200
+
201
+ /** Sort TC-2 before TC-10, the way a reader expects a case list to run. */
202
+ function naturalKey(identifier) {
203
+ return String(identifier ?? '')
204
+ .split(/(\d+)/)
205
+ .map((part) => (/^\d+$/.test(part) ? Number.parseInt(part, 10) : part.toLowerCase()));
206
+ }
207
+
208
+ function compareNatural(a, b) {
209
+ const left = naturalKey(a);
210
+ const right = naturalKey(b);
211
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
212
+ const x = left[i];
213
+ const y = right[i];
214
+ if (x === undefined) return -1;
215
+ if (y === undefined) return 1;
216
+ // Python compares int to int and str to str; a mixed pair cannot occur here
217
+ // because the split alternates text and digits from position 0 in both.
218
+ if (x === y) continue;
219
+ return x < y ? -1 : 1;
220
+ }
221
+ return 0;
222
+ }
223
+
224
+ /** A stable in-document id, so a failing test case can link to its finding. */
225
+ function anchor(findingId) {
226
+ const safe = [...String(findingId ?? '')]
227
+ .map((char) => (/[\p{L}\p{N}]/u.test(char) || char === '-' || char === '_' ? char : '-'))
228
+ .join('');
229
+ return safe ? `f-${safe}` : '';
230
+ }
231
+
232
+ function isImage(item) {
233
+ const source = String(item.source ?? '').toLowerCase();
234
+ return (
235
+ item.type === 'screenshot' ||
236
+ ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif'].some((ext) => source.endsWith(ext))
237
+ );
238
+ }
239
+
240
+ /**
241
+ * A caption that is always meaningful.
242
+ *
243
+ * Finding-level evidence carries only `type` and `source` — `description` is
244
+ * required on the top-level index, not here — so the type label is the fallback
245
+ * rather than repeating the filename twice.
246
+ */
247
+ function evidenceCaption(item) {
248
+ const described = item.description;
249
+ const label = EVIDENCE_LABEL[item.type] ?? item.type ?? 'Evidence';
250
+ return described ? `${e(described)} — ${e(label)}` : e(label);
251
+ }
252
+
253
+ function evidenceFigures(items, indent = ' ') {
254
+ const parts = [];
255
+ for (const item of items ?? []) {
256
+ const source = item.source ?? '';
257
+ const caption = evidenceCaption(item);
258
+ parts.push(`${indent}<figure>`);
259
+ if (isImage(item)) {
260
+ parts.push(`${indent} <img src="${e(source)}" alt="${caption}"/>`);
261
+ } else if (item.excerpt) {
262
+ parts.push(`${indent} <pre>${e(item.excerpt)}</pre>`);
263
+ }
264
+ parts.push(`${indent} <figcaption>${caption} · <code>${e(source)}</code></figcaption>`);
265
+ parts.push(`${indent}</figure>`);
266
+ }
267
+ return parts;
268
+ }
269
+
270
+ /**
271
+ * One finding: the defect, then what happens now, then what should happen.
272
+ *
273
+ * Ordering is the point. `actual` and `expected` are required by the contract and
274
+ * are the two things a reader cannot reconstruct for themselves, so they come
275
+ * before reproduction and fix direction.
276
+ */
277
+ function findingCard(finding) {
278
+ const parts = [
279
+ `<article class="card" id="${anchor(finding.id)}">`,
280
+ ' <div class="finding-head">',
281
+ ` ${severityBadge(finding.severity)}`,
282
+ ' <div class="grow">',
283
+ ` <h3>${e(finding.title)}</h3>`,
284
+ ` <div class="finding-id">${e(finding.id)}</div>`,
285
+ ' </div>',
286
+ ` <span class="chip">${e(finding.dimension ?? '')}</span>`,
287
+ ` <span class="chip">${e(STATUS_LABEL[finding.status] ?? finding.status ?? '')}</span>`,
288
+ ' </div>',
289
+ ' <div class="card-body">',
290
+ ' <dl class="behaviour">',
291
+ ' <dt>Current behaviour</dt>',
292
+ ` <dd class="now">${e(finding.actual)}</dd>`,
293
+ ' <dt>Expected behaviour</dt>',
294
+ ` <dd class="want">${e(finding.expected)}</dd>`,
295
+ ' <dt>Reproduction</dt>',
296
+ ` ${reproSteps(finding.repro)}`,
297
+ ' <dt>Fix direction</dt>',
298
+ ` <dd class="fix">${e(finding.fixDirection)}</dd>`,
299
+ ' </dl>',
300
+ ];
301
+ parts.push(...evidenceFigures(finding.evidence));
302
+ parts.push(' </div>', '</article>');
303
+ return parts.join('\n');
304
+ }
305
+
306
+ function titleCase(key) {
307
+ return key
308
+ .replace(/-/g, ' ')
309
+ .split(' ')
310
+ .map((word) => (word ? word[0].toUpperCase() + word.slice(1).toLowerCase() : word))
311
+ .join(' ');
312
+ }
313
+
314
+ function countsBlock(counts, order) {
315
+ const cells = [];
316
+ for (const key of order) {
317
+ if (!(key in counts)) continue;
318
+ const label = SEVERITY[key]?.label ?? titleCase(key);
319
+ const colour = SEVERITY[key]?.colour ?? COUNT_STYLE[key] ?? '#1a1d21';
320
+ cells.push(
321
+ `<div class="count"><span class="n" style="color:${colour}">${e(counts[key])}</span>` +
322
+ `<span class="l">${e(label)}</span></div>`,
323
+ );
324
+ }
325
+ return cells.length > 0 ? `<div class="counts">${cells.join('')}</div>` : '';
326
+ }
327
+
328
+ /**
329
+ * Executed cases, worst first, each failure linked to the finding it raised.
330
+ *
331
+ * A reader who starts from "which case failed?" should reach the explanation in one
332
+ * click, so `findingId` becomes a link into the finding card rather than a bare
333
+ * string they have to search for.
334
+ */
335
+ function testCaseTable(testCases, findingIds = new Set()) {
336
+ const cases = testCases.cases ?? [];
337
+ if (cases.length === 0) return '';
338
+ const order = { fail: 0, blocked: 1, skipped: 2, pass: 3 };
339
+ const rank = (status) => order[status] ?? 4;
340
+ const sorted = [...cases].sort(
341
+ (a, b) => rank(a.status) - rank(b.status) || compareNatural(a.id, b.id),
342
+ );
343
+ const rows = sorted.map((testCase) => {
344
+ const status = testCase.status ?? '';
345
+ const styleKey = { fail: 'failed', pass: 'passed' }[status] ?? status;
346
+ const colour = COUNT_STYLE[styleKey] ?? '#475467';
347
+ const findingId = testCase.findingId;
348
+ let link;
349
+ if (findingId && findingIds.has(findingId)) {
350
+ link = `<a href="#${anchor(findingId)}"><code>${e(findingId)}</code></a>`;
351
+ } else if (findingId) {
352
+ link = `<code>${e(findingId)}</code>`;
353
+ } else {
354
+ link = '<span class="empty">—</span>';
355
+ }
356
+ return (
357
+ '<tr>' +
358
+ `<td><code>${e(testCase.id)}</code></td>` +
359
+ `<td>${e(testCase.title)}</td>` +
360
+ `<td style="color:${colour};font-weight:600">` +
361
+ `${e(STATUS_LABEL[status] ?? status)}</td>` +
362
+ `<td>${link}</td>` +
363
+ '</tr>'
364
+ );
365
+ });
366
+ return (
367
+ '<div class="card"><table><thead><tr><th>ID</th><th>Test case</th>' +
368
+ '<th>Result</th><th>Finding</th></tr></thead><tbody>' +
369
+ rows.join('') +
370
+ '</tbody></table></div>'
371
+ );
372
+ }
373
+
374
+ /**
375
+ * Open the report for a reader who has never seen this product.
376
+ *
377
+ * A QA report is forwarded to people who were not in the room: a founder, a
378
+ * designer, the developer who owns one of the six findings. Landing straight on
379
+ * "EXP-1 · high" asks them to work out what kind of document this is, who produced
380
+ * it, how it was produced, and how much to trust it. So it says so. Everything here
381
+ * is derived from the artifact — nothing is asserted about work that was not
382
+ * recorded.
383
+ */
384
+ function orientation(result) {
385
+ const cases = result.testCases ?? {};
386
+ const executed = cases.total;
387
+ const adapter = ADAPTER_PLAIN[result.browserAdapter];
388
+
389
+ let how = ['opened the application'];
390
+ if (adapter) how = [`opened the application in ${adapter}`];
391
+ if (executed) how.push(`worked through ${executed} test case${executed !== 1 ? 's' : ''}`);
392
+ how.push('captured proof for every defect it reports');
393
+
394
+ const sentences = [
395
+ 'This is an <strong>exploratory QA report</strong>. An AI QA engineer ' +
396
+ how.slice(0, -1).join(', ') +
397
+ `, and ${how[how.length - 1]}.`,
398
+ 'Each finding below says what happens today, what should happen instead, ' +
399
+ 'and how to see it for yourself — so nothing here has to be taken on trust.',
400
+ ];
401
+ return (
402
+ '<div class="card intro"><div class="card-body">' +
403
+ sentences.map((sentence) => `<p>${sentence}</p>`).join('') +
404
+ '</div></div>'
405
+ );
406
+ }
407
+
408
+ /**
409
+ * What was tested and what was not, in plain language.
410
+ *
411
+ * Dimension names are jargon and coverage counts are not scope: "18 cases" does not
412
+ * tell a reader whether sign-up was looked at. The model supplies the feature-level
413
+ * prose in `scope`; the dimension table and the not-covered derivations are
414
+ * computed, so a run that omits `scope` still explains itself.
415
+ */
416
+ function scopeBlock(result) {
417
+ const scope = result.scope ?? {};
418
+ const dimensions = result.dimensionsRun ?? [];
419
+ const parts = [];
420
+
421
+ if (scope.objective) parts.push(`<p class="objective">${e(scope.objective)}</p>`);
422
+ if (scope.covered && scope.covered.length > 0) {
423
+ parts.push(`<ul class="clean">${scope.covered.map((x) => `<li>${e(x)}</li>`).join('')}</ul>`);
424
+ }
425
+ if (parts.length === 0 && dimensions.length === 0) return '';
426
+
427
+ let body = parts.length > 0
428
+ ? `<div class="card"><div class="card-body">${parts.join('')}</div></div>`
429
+ : '';
430
+
431
+ if (dimensions.length > 0) {
432
+ const rows = dimensions
433
+ .map(
434
+ (d) =>
435
+ `<tr><td><strong>${e(DIMENSION_LABEL[d] ?? d)}</strong></td>` +
436
+ `<td>${e(DIMENSION_PLAIN[d] ?? '')}</td></tr>`,
437
+ )
438
+ .join('');
439
+ body +=
440
+ '<div class="card"><table><thead><tr><th>Area checked</th>' +
441
+ `<th>What that means</th></tr></thead><tbody>${rows}</tbody></table></div>`;
442
+ }
443
+ return body;
444
+ }
445
+
446
+ /**
447
+ * The boundary of the run, stated rather than left to inference.
448
+ *
449
+ * An unstated boundary reads as "everything was checked". Blocked cases and skipped
450
+ * dimensions are the two boundaries the artifact already knows about, so they are
451
+ * added to whatever the run declared.
452
+ */
453
+ function notCoveredBlock(result) {
454
+ const scope = result.scope ?? {};
455
+ const items = (scope.notCovered ?? []).map((x) => String(x));
456
+
457
+ const ran = new Set(result.dimensionsRun ?? []);
458
+ if (ran.size > 0) {
459
+ for (const dimension of Object.keys(DIMENSION_PLAIN)) {
460
+ if (ran.has(dimension)) continue;
461
+ // Named with its plain-language meaning: "Data" alone tells a reader who does
462
+ // not work in QA nothing about what went unchecked.
463
+ const plain = DIMENSION_PLAIN[dimension];
464
+ items.push(
465
+ `${DIMENSION_LABEL[dimension] ?? dimension} was not examined — ` +
466
+ `${plain[0].toLowerCase()}${plain.slice(1)}`,
467
+ );
468
+ }
469
+ }
470
+
471
+ const db = result.dbValidation ?? {};
472
+ if (db.inScope === false && !db.summary) {
473
+ items.push('Data was not compared against the system of record — no access was provided.');
474
+ }
475
+
476
+ const blocked = (result.testCases?.cases ?? []).filter((c) => c.status === 'blocked');
477
+ if (blocked.length > 0) {
478
+ const listed = blocked.map((c) => `${c.id} (${c.title})`).join(', ');
479
+ items.push(`Could not be run: ${listed}`);
480
+ }
481
+
482
+ if (items.length === 0) return '';
483
+ const listing = items.map((x) => `<li>${e(x)}</li>`).join('');
484
+ return (
485
+ '<h2>Not covered in this run</h2>' +
486
+ `<div class="card"><div class="card-body"><ul class="clean">${listing}</ul></div></div>`
487
+ );
488
+ }
489
+
490
+ /** What the severity labels mean, so nobody has to ask. */
491
+ function legendBlock() {
492
+ const rows = Object.entries(SEVERITY_MEANING)
493
+ .map(([key, meaning]) => `<tr><td>${severityBadge(key)}</td><td>${e(meaning)}</td></tr>`)
494
+ .join('');
495
+ return (
496
+ '<div class="card"><table><thead><tr><th>Severity</th><th>What it means</th></tr>' +
497
+ `</thead><tbody>${rows}</tbody></table></div>`
498
+ );
499
+ }
500
+
501
+ /**
502
+ * The run's evidence, listed once with its descriptions.
503
+ *
504
+ * The contract requires this array and the hand-written report reduced it to a line
505
+ * of prose, so a reader could not tell what proof the run actually holds.
506
+ */
507
+ function evidenceIndex(evidence) {
508
+ if (!evidence || evidence.length === 0) return '';
509
+ const rows = evidence
510
+ .map(
511
+ (item) =>
512
+ '<tr>' +
513
+ `<td>${e(EVIDENCE_LABEL[item.type] ?? item.type ?? '')}</td>` +
514
+ `<td>${e(item.description ?? '')}</td>` +
515
+ `<td><code>${e(item.source)}</code></td>` +
516
+ '</tr>',
517
+ )
518
+ .join('');
519
+ return (
520
+ '<div class="card"><table><thead><tr><th>Kind</th><th>Shows</th>' +
521
+ `<th>File</th></tr></thead><tbody>${rows}</tbody></table></div>`
522
+ );
523
+ }
524
+
525
+ /** Render an explore-result artifact. */
526
+ export function renderExplore(result) {
527
+ const findings = [...(result.findings ?? [])].sort(
528
+ (a, b) =>
529
+ (SEVERITY[a.severity] ?? SEVERITY.low).rank - (SEVERITY[b.severity] ?? SEVERITY.low).rank,
530
+ );
531
+ const [verdictLabel, verdictColour, verdictTint] =
532
+ VERDICT[result.classification] ?? ['Reported', '#4a5568', '#f4f5f7'];
533
+
534
+ const metaBits = [];
535
+ if (result.url) metaBits.push(`<strong>Target</strong> <code>${e(result.url)}</code>`);
536
+ if (result.generatedAt) metaBits.push(`<strong>Generated</strong> ${e(result.generatedAt)}`);
537
+ if (result.browserAdapter) metaBits.push(`<strong>Browser</strong> ${e(result.browserAdapter)}`);
538
+ if (result.reportVersion) metaBits.push(`<strong>Report</strong> v${e(result.reportVersion)}`);
539
+
540
+ const body = [
541
+ '<header class="masthead">',
542
+ ' <div class="eyebrow">Exploratory QA report</div>',
543
+ ` <h1>${e(subject(result))}</h1>`,
544
+ ` <p class="meta">${metaBits.join(' &middot; ')}</p>`,
545
+ '</header>',
546
+ `<p class="lead">${e(result.summary ?? '')}</p>`,
547
+ `<div class="verdict" style="color:${verdictColour};background:${verdictTint}">` +
548
+ `${e(verdictLabel)}</div>`,
549
+ ];
550
+
551
+ body.push(countsBlock(result.severityCounts ?? {}, ['critical', 'high', 'medium', 'low']));
552
+
553
+ const tests = result.testCases ?? {};
554
+ if (Object.keys(tests).length > 0) {
555
+ const picked = {};
556
+ for (const key of ['total', 'passed', 'failed', 'blocked', 'skipped']) {
557
+ if (key in tests) picked[key] = tests[key];
558
+ }
559
+ body.push(countsBlock(picked, ['total', 'passed', 'failed', 'blocked', 'skipped']));
560
+ }
561
+
562
+ // Orientation before detail. A reader who has never seen this product needs to
563
+ // know what the document is, what was looked at, what was not, and what the
564
+ // labels mean — before the first finding, not in an appendix.
565
+ body.push('<h2>About this report</h2>');
566
+ body.push(orientation(result));
567
+
568
+ const scope = scopeBlock(result);
569
+ if (scope) {
570
+ body.push('<h2>What was tested</h2>');
571
+ body.push(scope);
572
+ }
573
+
574
+ const notCovered = notCoveredBlock(result);
575
+ if (notCovered) body.push(notCovered);
576
+
577
+ body.push('<h2>How to read the findings</h2>');
578
+ body.push(legendBlock());
579
+
580
+ body.push('<h2>Findings</h2>');
581
+ if (findings.length > 0) {
582
+ body.push(...findings.map((finding) => findingCard(finding)));
583
+ } else {
584
+ body.push('<div class="card"><div class="card-body empty">No findings recorded.</div></div>');
585
+ }
586
+
587
+ if ((tests.cases ?? []).length > 0) {
588
+ body.push('<h2>Test case coverage</h2>');
589
+ body.push(testCaseTable(tests, new Set(findings.map((f) => f.id))));
590
+ }
591
+
592
+ const db = result.dbValidation ?? {};
593
+ const dbHeaded = Boolean(db.summary) || db.inScope !== undefined;
594
+ if (dbHeaded) {
595
+ body.push('<h2>Data validation</h2>');
596
+ const note = db.summary || (db.inScope ? 'In scope.' : 'Not in scope for this run.');
597
+ body.push(`<div class="card"><div class="card-body">${e(note)}</div></div>`);
598
+ }
599
+ if (db.comparisons && db.comparisons.length > 0) {
600
+ if (!dbHeaded) body.push('<h2>Data validation</h2>');
601
+ const rows = db.comparisons
602
+ .map(
603
+ (c) =>
604
+ '<tr>' +
605
+ `<td>${e(c.metric)}</td><td><code>${e(c.uiValue)}</code></td>` +
606
+ `<td><code>${e(c.sourceValue)}</code></td>` +
607
+ `<td style="color:${c.match ? '#116149' : '#b3261e'};font-weight:600">` +
608
+ `${c.match ? 'Match' : 'Mismatch'}</td></tr>`,
609
+ )
610
+ .join('');
611
+ body.push(
612
+ '<div class="card"><table><thead><tr><th>Metric</th><th>Shown in UI</th>' +
613
+ `<th>Source of truth</th><th>Result</th></tr></thead><tbody>${rows}</tbody></table></div>`,
614
+ );
615
+ }
616
+
617
+ if (result.fixOrder && result.fixOrder.length > 0) {
618
+ body.push('<h2>Suggested fix order</h2>');
619
+ const items = result.fixOrder.map((x) => `<li>${e(x)}</li>`).join('');
620
+ body.push(`<div class="card"><div class="card-body"><ol class="order">${items}</ol></div></div>`);
621
+ }
622
+
623
+ if (result.recommendations && result.recommendations.length > 0) {
624
+ body.push('<h2>Recommendations</h2>');
625
+ const rows = result.recommendations
626
+ .map((r) => `<tr><td>${e(r.action)}</td><td>${e(r.priority)}</td></tr>`)
627
+ .join('');
628
+ body.push(
629
+ '<div class="card"><table><thead><tr><th>Action</th><th>Priority</th></tr>' +
630
+ `</thead><tbody>${rows}</tbody></table></div>`,
631
+ );
632
+ }
633
+
634
+ if (result.whatWorksWell && result.whatWorksWell.length > 0) {
635
+ body.push('<h2>Verified working</h2>');
636
+ const items = result.whatWorksWell.map((x) => `<li>${e(x)}</li>`).join('');
637
+ body.push(`<div class="card"><div class="card-body"><ul class="clean">${items}</ul></div></div>`);
638
+ }
639
+
640
+ if (result.evidence && result.evidence.length > 0) {
641
+ body.push('<h2>Evidence index</h2>');
642
+ body.push(evidenceIndex(result.evidence));
643
+ }
644
+
645
+ return body.join('\n');
646
+ }
647
+
648
+ /** Render a report-result artifact (qa-report's release rollup). */
649
+ export function renderReport(result) {
650
+ const readiness = result.releaseReadiness ?? {};
651
+ const verdict = 'verdict' in readiness ? readiness.verdict : result.classification;
652
+ const [label, colour, tint] = VERDICT[verdict] ?? ['Reported', '#4a5568', '#f4f5f7'];
653
+ const summaries = result.summaries ?? {};
654
+
655
+ const body = [
656
+ '<header class="masthead">',
657
+ ' <div class="eyebrow">Release readiness report</div>',
658
+ ` <h1>${e(subject(result))}</h1>`,
659
+ ` <p class="meta"><strong>Generated</strong> ${e(result.generatedAt ?? '')}</p>`,
660
+ '</header>',
661
+ `<p class="lead">${e(result.summary ?? '')}</p>`,
662
+ `<div class="verdict" style="color:${colour};background:${tint}">${e(label)}</div>`,
663
+ ];
664
+ if (readiness.rationale) {
665
+ body.push(`<div class="card"><div class="card-body">${e(readiness.rationale)}</div></div>`);
666
+ }
667
+
668
+ const tests = result.testSummary ?? {};
669
+ const picked = {};
670
+ for (const key of ['total', 'passed', 'failed', 'skipped']) {
671
+ if (key in tests) picked[key] = tests[key];
672
+ }
673
+ body.push(countsBlock(picked, ['total', 'passed', 'failed', 'skipped']));
674
+
675
+ for (const [key, heading] of [
676
+ ['executive', 'Executive summary'],
677
+ ['engineering', 'Engineering summary'],
678
+ ]) {
679
+ if (summaries[key]) {
680
+ body.push(`<h2>${heading}</h2>`);
681
+ body.push(`<div class="card"><div class="card-body">${e(summaries[key])}</div></div>`);
682
+ }
683
+ }
684
+
685
+ if (result.failureSummary && result.failureSummary.length > 0) {
686
+ body.push('<h2>Failures</h2>');
687
+ const rows = result.failureSummary
688
+ .map(
689
+ (f) =>
690
+ `<tr><td>${e(f.test)}</td><td>${e(f.classification)}</td>` +
691
+ `<td>${e(f.reason)}</td></tr>`,
692
+ )
693
+ .join('');
694
+ body.push(
695
+ '<div class="card"><table><thead><tr><th>Test</th><th>Classification</th>' +
696
+ `<th>Reason</th></tr></thead><tbody>${rows}</tbody></table></div>`,
697
+ );
698
+ }
699
+
700
+ if (result.recommendations && result.recommendations.length > 0) {
701
+ body.push('<h2>Recommendations</h2>');
702
+ const rows = result.recommendations
703
+ .map((r) => `<tr><td>${e(r.action)}</td><td>${e(r.priority)}</td></tr>`)
704
+ .join('');
705
+ body.push(
706
+ '<div class="card"><table><thead><tr><th>Action</th><th>Priority</th></tr>' +
707
+ `</thead><tbody>${rows}</tbody></table></div>`,
708
+ );
709
+ }
710
+
711
+ return body.join('\n');
712
+ }
713
+
714
+ const RENDERERS = {
715
+ 'qa-explore/explore-result': renderExplore,
716
+ 'qa-report/report-result': renderReport,
717
+ };
718
+
719
+ /** Render a full standalone HTML document for a supported artifact. */
720
+ export function render(result, { title = null } = {}) {
721
+ const contract = result.contract?.name;
722
+ const renderer = RENDERERS[contract];
723
+ if (!renderer) {
724
+ throw new ReportError(
725
+ `no HTML renderer for contract ${contract === undefined ? 'None' : `'${contract}'`}; supported: ` +
726
+ Object.keys(RENDERERS).sort().join(', '),
727
+ );
728
+ }
729
+
730
+ const heading = title ?? `QA report — ${subject(result)}`;
731
+ return (
732
+ '<!DOCTYPE html>\n' +
733
+ '<html lang="en">\n<head>\n' +
734
+ '<meta charset="utf-8"/>\n' +
735
+ '<meta name="viewport" content="width=device-width,initial-scale=1"/>\n' +
736
+ `<title>${e(heading)}</title>\n` +
737
+ `<style>${CSS}</style>\n` +
738
+ '</head>\n<body>\n' +
739
+ '<div class="page">\n' +
740
+ `${renderer(result)}\n` +
741
+ // Attribution is part of the document, not an optional flourish.
742
+ `${footerHtml()}` +
743
+ '</div>\n</body>\n</html>\n'
744
+ );
745
+ }
746
+
747
+ /** Read an artifact from disk and render it. */
748
+ export function renderFile(path, { title = null } = {}) {
749
+ let result;
750
+ try {
751
+ result = JSON.parse(fs.readFileSync(path, 'utf8'));
752
+ } catch (error) {
753
+ throw new ReportError(`could not read artifact at ${path}: ${error.message}`);
754
+ }
755
+ if (result === null || typeof result !== 'object' || Array.isArray(result)) {
756
+ throw new ReportError(`artifact at ${path} is not a JSON object`);
757
+ }
758
+ return render(result, { title });
759
+ }
760
+
761
+ export function supportedContracts() {
762
+ return Object.keys(RENDERERS).sort();
763
+ }