qa-engineer 0.9.0 → 0.9.2

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 (52) hide show
  1. package/COMPATIBILITY.md +25 -15
  2. package/LICENSE +1 -1
  3. package/README.md +22 -4
  4. package/package.json +4 -3
  5. package/packages/installer/README.md +1 -1
  6. package/packages/installer/lib/agents/registry.mjs +46 -0
  7. package/packages/installer/lib/commands/doctor.mjs +6 -6
  8. package/packages/installer/lib/commands/install.mjs +32 -5
  9. package/packages/installer/lib/commands/onboard.mjs +2 -2
  10. package/packages/installer/lib/core/config.mjs +1 -1
  11. package/packages/installer/lib/core/lockfile.mjs +1 -1
  12. package/packages/installer/lib/core/manifest.mjs +3 -0
  13. package/packages/installer/lib/core/wrappers.mjs +4 -4
  14. package/packages/installer/lib/ui/theme.mjs +1 -1
  15. package/packages/installer/package.json +1 -1
  16. package/packages/installer/schemas/qa-lock.schema.json +1 -1
  17. package/packages/installer/schemas/qa.config.schema.json +1 -1
  18. package/shared/analysis/lib/qa_analysis/branding.json +1 -1
  19. package/shared/analysis/lib/qa_analysis/cli.py +15 -0
  20. package/shared/analysis/lib/qa_analysis/har.py +32 -3
  21. package/shared/analysis/lib/qa_analysis/junit.py +25 -1
  22. package/shared/analysis/lib/qa_analysis/redaction.py +10 -2
  23. package/shared/analysis/lib/qa_analysis/report_html.py +781 -0
  24. package/skills/qa/README.md +1 -1
  25. package/skills/qa/SKILL.md +1 -1
  26. package/skills/qa-api/references/deterministic-tooling.md +2 -0
  27. package/skills/qa-api/references/evidence-and-reporting.md +29 -4
  28. package/skills/qa-audit/references/deterministic-tooling.md +2 -0
  29. package/skills/qa-audit/references/evidence-and-reporting.md +29 -4
  30. package/skills/qa-debug/references/deterministic-tooling.md +2 -0
  31. package/skills/qa-debug/references/evidence-and-reporting.md +29 -4
  32. package/skills/qa-example/SKILL.md +1 -1
  33. package/skills/qa-example/contracts/self-check-report.schema.json +1 -1
  34. package/skills/qa-explore/SKILL.md +22 -5
  35. package/skills/qa-explore/contracts/explore-result.schema.json +26 -2
  36. package/skills/qa-explore/references/deterministic-tooling.md +130 -0
  37. package/skills/qa-explore/references/evidence-and-reporting.md +29 -4
  38. package/skills/qa-explore/references/report-pipeline.md +89 -8
  39. package/skills/qa-fix/references/deterministic-tooling.md +2 -0
  40. package/skills/qa-fix/references/evidence-and-reporting.md +29 -4
  41. package/skills/qa-flaky/references/deterministic-tooling.md +2 -0
  42. package/skills/qa-flaky/references/evidence-and-reporting.md +29 -4
  43. package/skills/qa-generate/references/evidence-and-reporting.md +29 -4
  44. package/skills/qa-init/references/deterministic-tooling.md +2 -0
  45. package/skills/qa-init/references/evidence-and-reporting.md +29 -4
  46. package/skills/qa-report/SKILL.md +3 -2
  47. package/skills/qa-report/references/deterministic-tooling.md +2 -0
  48. package/skills/qa-report/references/evidence-and-reporting.md +29 -4
  49. package/skills/qa-review/references/evidence-and-reporting.md +29 -4
  50. package/skills/qa-run/references/deterministic-tooling.md +2 -0
  51. package/skills/qa-run/references/evidence-and-reporting.md +29 -4
  52. package/packages/installer/lib/core/schema-validate.mjs +0 -142
@@ -0,0 +1,781 @@
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. Each
25
+ finding becomes a card stating, in order: what is wrong, **what happens now**,
26
+ **what should happen instead**, how to reproduce it, and the fix direction. That
27
+ ordering is deliberate: a reader who stops after two lines still knows the defect
28
+ and the gap.
29
+
30
+ Standard library only, single self-contained file, no external assets — a report
31
+ must open from an email attachment on a plane.
32
+ """
33
+
34
+ import html
35
+ import json
36
+ import re
37
+
38
+ from . import branding
39
+
40
+ # Severity drives colour, order, and the summary bar. Kept here rather than in the
41
+ # template so a new severity cannot render as an unstyled surprise.
42
+ _SEVERITY = {
43
+ "critical": {"label": "Critical", "colour": "#8b0018", "tint": "#fdf0f2", "rank": 0},
44
+ "high": {"label": "High", "colour": "#b3261e", "tint": "#fdf1f0", "rank": 1},
45
+ "medium": {"label": "Medium", "colour": "#a15c00", "tint": "#fdf6ec", "rank": 2},
46
+ "low": {"label": "Low", "colour": "#4a5568", "tint": "#f4f5f7", "rank": 3},
47
+ }
48
+
49
+ # Summary tiles for non-severity counts. Test totals are not severities, and
50
+ # rendering "failed" in the same neutral grey as "passed" is how a reader misses
51
+ # the number that matters.
52
+ _COUNT_STYLE = {
53
+ "total": "#344054",
54
+ "passed": "#116149",
55
+ "failed": "#b3261e",
56
+ "blocked": "#a15c00",
57
+ "skipped": "#667085",
58
+ }
59
+
60
+ # What each evidence kind is called in the report. The contract guarantees `type`
61
+ # on every entry but `description` only on the top-level index, so the type is
62
+ # what a caption can always be built from.
63
+ _EVIDENCE_LABEL = {
64
+ "screenshot": "Screenshot",
65
+ "network": "Network capture",
66
+ "console": "Console output",
67
+ "dom": "DOM snapshot",
68
+ "har": "HAR archive",
69
+ "db": "Database query",
70
+ "file": "File",
71
+ "report": "Report",
72
+ "command": "Command output",
73
+ "trace": "Trace",
74
+ "log": "Log",
75
+ "diff": "Diff",
76
+ }
77
+
78
+ # What each QA dimension means to someone who has never read a QA report. The
79
+ # contract stores dimension *names*, which are jargon: "ux" tells a reader nothing.
80
+ # The report is read by founders, designers, and support staff, not only by the
81
+ # engineer who will fix the defect.
82
+ _DIMENSION_LABEL = {
83
+ "functional": "Functionality",
84
+ "api": "API",
85
+ "performance": "Performance",
86
+ "security": "Security (client-side)",
87
+ "ui": "UI",
88
+ "ux": "UX",
89
+ "data": "Data",
90
+ }
91
+
92
+ _DIMENSION_PLAIN = {
93
+ "functional": "Does the feature do what it is supposed to do, including when the input is wrong?",
94
+ "api": "The network requests the page makes — whether they are correct, and how the page handles a bad response",
95
+ "performance": "How much the page downloads and how quickly it becomes usable",
96
+ "security": "Client-side exposure only: where credentials and tokens are stored, what leaks into URLs and error messages",
97
+ "ui": "Layout and visual states — empty, loading, error — including a narrow mobile screen",
98
+ "ux": "Whether the flow makes sense to a person using it, and whether the wording helps them",
99
+ "data": "Whether the numbers and text on screen match the system of record behind them",
100
+ }
101
+
102
+ # What each severity is claiming, so a reader can calibrate without asking. These
103
+ # are definitions, not opinions: they say what the label means in this report.
104
+ _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
+ _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
+ _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
+ _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
+
144
+ class ReportError(ValueError):
145
+ """Raised when an artifact cannot be rendered as a report."""
146
+
147
+
148
+ def _e(value):
149
+ """Escape for HTML text and attribute contexts."""
150
+ return html.escape("" if value is None else str(value), quote=True)
151
+
152
+
153
+ _CSS = """
154
+ *,*::before,*::after{box-sizing:border-box}
155
+ body{margin:0;background:#f7f8fa;color:#1a1d21;
156
+ font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
157
+ -webkit-font-smoothing:antialiased}
158
+ .page{max-width:60rem;margin:0 auto;padding:2.5rem 1.5rem 3rem}
159
+ .card{background:#fff;border:1px solid #e4e7ec;border-radius:12px;
160
+ box-shadow:0 1px 2px rgba(16,24,40,.04);margin-bottom:1.25rem;overflow:hidden}
161
+ .card-body{padding:1.5rem}
162
+ header.masthead{margin-bottom:1.75rem}
163
+ .eyebrow{font-size:.6875rem;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:#667085}
164
+ h1{margin:.35rem 0 .5rem;font-size:1.875rem;line-height:1.25;letter-spacing:-.02em}
165
+ h2{margin:2rem 0 .875rem;font-size:1.125rem;letter-spacing:-.01em}
166
+ h3{margin:0;font-size:1.0625rem;letter-spacing:-.01em}
167
+ .meta{color:#667085;font-size:.8125rem}
168
+ .meta strong{color:#344054;font-weight:600}
169
+ .lead{margin:0 0 .25rem;font-size:1.0625rem;color:#475467;max-width:52rem}
170
+ .intro p{margin:0 0 .75rem;max-width:52rem}
171
+ .intro p:last-child{margin-bottom:0}
172
+ .objective{margin:0 0 .875rem;max-width:52rem}
173
+ .objective:last-child{margin-bottom:0}
174
+ .verdict{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;
175
+ padding:1rem 1.25rem;border-radius:10px;font-weight:600;margin:1.25rem 0}
176
+ .counts{display:flex;flex-wrap:wrap;gap:.5rem;margin:1.25rem 0}
177
+ .count{flex:1 1 7rem;background:#fff;border:1px solid #e4e7ec;border-radius:10px;
178
+ padding:.75rem .875rem;text-align:center}
179
+ .count .n{display:block;font-size:1.5rem;font-weight:700;line-height:1.2}
180
+ .count .l{font-size:.6875rem;text-transform:uppercase;letter-spacing:.07em;color:#667085}
181
+ .badge{display:inline-block;padding:.1875rem .5rem;border-radius:6px;
182
+ font-size:.6875rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase;
183
+ border:1px solid currentColor}
184
+ .chip{display:inline-block;padding:.1875rem .5rem;border-radius:6px;background:#f2f4f7;
185
+ color:#475467;font-size:.75rem;font-weight:500}
186
+ .finding-head{display:flex;gap:.75rem;align-items:flex-start;
187
+ padding:1.125rem 1.5rem;border-bottom:1px solid #eef0f3}
188
+ .finding-head .grow{flex:1;min-width:0}
189
+ .finding-id{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.75rem;color:#667085}
190
+ dl.behaviour{margin:0;display:grid;grid-template-columns:minmax(8.5rem,auto) 1fr;gap:.625rem 1.25rem}
191
+ dl.behaviour dt{font-size:.75rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#667085;padding-top:.15rem}
192
+ dl.behaviour dd{margin:0}
193
+ .now{border-left:3px solid #b3261e;padding-left:.75rem}
194
+ .want{border-left:3px solid #116149;padding-left:.75rem}
195
+ .fix{border-left:3px solid #3538cd;padding-left:.75rem}
196
+ table{border-collapse:collapse;width:100%;font-size:.875rem}
197
+ th,td{text-align:left;padding:.5625rem .75rem;border-bottom:1px solid #eef0f3;vertical-align:top}
198
+ th{font-size:.6875rem;text-transform:uppercase;letter-spacing:.06em;color:#667085;
199
+ background:#fafbfc;font-weight:700}
200
+ tbody tr:last-child td{border-bottom:0}
201
+ code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.875em;
202
+ background:#f2f4f7;padding:.1rem .3rem;border-radius:4px}
203
+ pre{margin:0;background:#1a1d21;color:#e4e7ec;padding:1rem;border-radius:8px;
204
+ overflow-x:auto;font-size:.8125rem;line-height:1.55}
205
+ figure{margin:.875rem 0 0}
206
+ figure img{display:block;max-width:100%;height:auto;border:1px solid #e4e7ec;border-radius:8px}
207
+ figcaption{margin-top:.4rem;font-size:.75rem;color:#667085}
208
+ ul.clean{margin:0;padding-left:1.125rem}
209
+ ul.clean li{margin:.3rem 0}
210
+ ol.steps{margin:0;padding-left:1.25rem}
211
+ ol.steps li{margin:.15rem 0}
212
+ ol.order{margin:0;padding-left:1.25rem}
213
+ ol.order li{margin:.4rem 0}
214
+ .empty{color:#667085;font-style:italic}
215
+ @media print{body{background:#fff}.card{box-shadow:none;break-inside:avoid}.page{padding:0}}
216
+ @media (max-width:34rem){dl.behaviour{grid-template-columns:1fr;gap:.25rem}
217
+ dl.behaviour dt{padding-top:.5rem}}
218
+ """
219
+
220
+
221
+ def _severity_badge(severity):
222
+ meta = _SEVERITY.get(severity, _SEVERITY["low"])
223
+ return (
224
+ f'<span class="badge" style="color:{meta["colour"]};background:{meta["tint"]}">'
225
+ f'{_e(meta["label"])}</span>'
226
+ )
227
+
228
+
229
+ def _subject(result):
230
+ """What the report is about, short enough to be a heading.
231
+
232
+ The contract has no title field, and the summary is a paragraph — using it as
233
+ an `<h1>` produced a five-line heading. The target under test is the honest
234
+ short answer, the way Lighthouse titles a report by its URL.
235
+ """
236
+ url = str(result.get("url") or "").strip()
237
+ if url:
238
+ return url.split("://", 1)[-1].rstrip("/") or url
239
+ return str(result.get("summary") or "QA report").split(".")[0][:80]
240
+
241
+
242
+ def _repro_steps(repro):
243
+ """Reproduction as steps when it is written as steps, as prose otherwise.
244
+
245
+ Numbered steps are the part of a report a reader retypes into a browser, so
246
+ they are rendered as a list rather than one dense line. The split is on the
247
+ text's own numbering — nothing is invented, reordered, or dropped.
248
+ """
249
+ text = str(repro or "").strip()
250
+ if not text:
251
+ return '<dd class="empty">Not recorded</dd>'
252
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
253
+ if len(lines) == 1:
254
+ lines = [part.strip() for part in re.split(r"\s+(?=\d+[.)]\s)", text) if part.strip()]
255
+ steps = [re.sub(r"^\d+[.)]\s*", "", line) for line in lines]
256
+ if len(steps) < 2 or not all(re.match(r"^\d+[.)]\s", line) for line in lines):
257
+ return f"<dd>{_e(text)}</dd>"
258
+ items = "".join(f"<li>{_e(step)}</li>" for step in steps)
259
+ return f'<dd><ol class="steps">{items}</ol></dd>'
260
+
261
+
262
+ def _natural(identifier):
263
+ """Sort TC-2 before TC-10, the way a reader expects a case list to run."""
264
+ return [
265
+ int(part) if part.isdigit() else part.lower()
266
+ for part in re.split(r"(\d+)", str(identifier or ""))
267
+ ]
268
+
269
+
270
+ def _anchor(finding_id):
271
+ """A stable in-document id, so a failing test case can link to its finding."""
272
+ safe = "".join(c if (c.isalnum() or c in "-_") else "-" for c in str(finding_id or ""))
273
+ return f"f-{safe}" if safe else ""
274
+
275
+
276
+ def _is_image(item):
277
+ source = str(item.get("source", "")).lower()
278
+ return item.get("type") == "screenshot" or source.endswith(
279
+ (".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif")
280
+ )
281
+
282
+
283
+ def _evidence_caption(item):
284
+ """A caption that is always meaningful.
285
+
286
+ Finding-level evidence carries only `type` and `source` — `description` is
287
+ required on the top-level index, not here — so the type label is the fallback
288
+ rather than repeating the filename twice.
289
+ """
290
+ described = item.get("description")
291
+ label = _EVIDENCE_LABEL.get(item.get("type"), item.get("type") or "Evidence")
292
+ return f"{_e(described)} — {_e(label)}" if described else _e(label)
293
+
294
+
295
+ def _evidence_figures(items, indent=" "):
296
+ parts = []
297
+ for item in items or []:
298
+ source = item.get("source", "")
299
+ caption = _evidence_caption(item)
300
+ parts.append(f"{indent}<figure>")
301
+ if _is_image(item):
302
+ parts.append(f'{indent} <img src="{_e(source)}" alt="{caption}"/>')
303
+ elif item.get("excerpt"):
304
+ parts.append(f'{indent} <pre>{_e(item["excerpt"])}</pre>')
305
+ parts.append(f'{indent} <figcaption>{caption} · <code>{_e(source)}</code></figcaption>')
306
+ parts.append(f"{indent}</figure>")
307
+ return parts
308
+
309
+
310
+ def _finding_card(finding):
311
+ """One finding: the defect, then what happens now, then what should happen.
312
+
313
+ Ordering is the point. `actual` and `expected` are required by the contract and
314
+ are the two things a reader cannot reconstruct for themselves, so they come
315
+ before reproduction and fix direction.
316
+ """
317
+ parts = [
318
+ f'<article class="card" id="{_anchor(finding.get("id"))}">',
319
+ ' <div class="finding-head">',
320
+ f' {_severity_badge(finding.get("severity"))}',
321
+ ' <div class="grow">',
322
+ f' <h3>{_e(finding.get("title"))}</h3>',
323
+ f' <div class="finding-id">{_e(finding.get("id"))}</div>',
324
+ ' </div>',
325
+ f' <span class="chip">{_e(finding.get("dimension", ""))}</span>',
326
+ f' <span class="chip">{_e(_STATUS_LABEL.get(finding.get("status"), finding.get("status", "")))}</span>',
327
+ ' </div>',
328
+ ' <div class="card-body">',
329
+ ' <dl class="behaviour">',
330
+ ' <dt>Current behaviour</dt>',
331
+ f' <dd class="now">{_e(finding.get("actual"))}</dd>',
332
+ ' <dt>Expected behaviour</dt>',
333
+ f' <dd class="want">{_e(finding.get("expected"))}</dd>',
334
+ ' <dt>Reproduction</dt>',
335
+ f' {_repro_steps(finding.get("repro"))}',
336
+ ' <dt>Fix direction</dt>',
337
+ f' <dd class="fix">{_e(finding.get("fixDirection"))}</dd>',
338
+ ' </dl>',
339
+ ]
340
+ parts += _evidence_figures(finding.get("evidence"))
341
+ parts += [' </div>', '</article>']
342
+ return "\n".join(parts)
343
+
344
+
345
+ def _counts_block(counts, order):
346
+ cells = []
347
+ for key in order:
348
+ if key not in counts:
349
+ continue
350
+ label = _SEVERITY.get(key, {}).get("label", key.replace("-", " ").title())
351
+ colour = _SEVERITY.get(key, {}).get("colour") or _COUNT_STYLE.get(key, "#1a1d21")
352
+ value = counts[key]
353
+ cells.append(
354
+ f'<div class="count"><span class="n" style="color:{colour}">{_e(value)}</span>'
355
+ f'<span class="l">{_e(label)}</span></div>'
356
+ )
357
+ return f'<div class="counts">{"".join(cells)}</div>' if cells else ""
358
+
359
+
360
+ def _test_case_table(test_cases, finding_ids=()):
361
+ """Executed cases, worst first, each failure linked to the finding it raised.
362
+
363
+ A reader who starts from "which case failed?" should reach the explanation in
364
+ one click, so `findingId` becomes a link into the finding card rather than a
365
+ bare string they have to search for.
366
+ """
367
+ cases = test_cases.get("cases") or []
368
+ if not cases:
369
+ return ""
370
+ order = {"fail": 0, "blocked": 1, "skipped": 2, "pass": 3}
371
+ rows = []
372
+ for case in sorted(cases, key=lambda c: (order.get(c.get("status"), 4), _natural(c.get("id")))):
373
+ status = case.get("status", "")
374
+ colour = _COUNT_STYLE.get({"fail": "failed", "pass": "passed"}.get(status, status), "#475467")
375
+ finding_id = case.get("findingId")
376
+ if finding_id and finding_id in finding_ids:
377
+ link = f'<a href="#{_anchor(finding_id)}"><code>{_e(finding_id)}</code></a>'
378
+ elif finding_id:
379
+ link = f"<code>{_e(finding_id)}</code>"
380
+ else:
381
+ link = '<span class="empty">—</span>'
382
+ rows.append(
383
+ "<tr>"
384
+ f'<td><code>{_e(case.get("id"))}</code></td>'
385
+ f'<td>{_e(case.get("title"))}</td>'
386
+ f'<td style="color:{colour};font-weight:600">'
387
+ f'{_e(_STATUS_LABEL.get(status, status))}</td>'
388
+ f"<td>{link}</td>"
389
+ "</tr>"
390
+ )
391
+ return (
392
+ '<div class="card"><table><thead><tr><th>ID</th><th>Test case</th>'
393
+ "<th>Result</th><th>Finding</th></tr></thead><tbody>"
394
+ + "".join(rows)
395
+ + "</tbody></table></div>"
396
+ )
397
+
398
+
399
+ def _orientation(result):
400
+ """Open the report for a reader who has never seen this product.
401
+
402
+ A QA report is forwarded to people who were not in the room: a founder, a
403
+ designer, the developer who owns one of the six findings. Landing straight on
404
+ "EXP-1 · high" asks them to work out what kind of document this is, who
405
+ produced it, how it was produced, and how much to trust it. So it says so.
406
+ Everything here is derived from the artifact — nothing is asserted about work
407
+ that was not recorded.
408
+ """
409
+ cases = result.get("testCases") or {}
410
+ executed = cases.get("total")
411
+ adapter = _ADAPTER_PLAIN.get(result.get("browserAdapter"))
412
+
413
+ how = ["opened the application"]
414
+ if adapter:
415
+ how = [f"opened the application in {adapter}"]
416
+ if executed:
417
+ how.append(f"worked through {executed} test case{'s' if executed != 1 else ''}")
418
+ how.append("captured proof for every defect it reports")
419
+
420
+ sentences = [
421
+ "This is an <strong>exploratory QA report</strong>. An AI QA engineer "
422
+ + ", ".join(how[:-1])
423
+ + f", and {how[-1]}.",
424
+ "Each finding below says what happens today, what should happen instead, "
425
+ "and how to see it for yourself — so nothing here has to be taken on trust.",
426
+ ]
427
+ return (
428
+ '<div class="card intro"><div class="card-body">'
429
+ + "".join(f"<p>{s}</p>" for s in sentences)
430
+ + "</div></div>"
431
+ )
432
+
433
+
434
+ def _scope_block(result):
435
+ """What was tested and what was not, in plain language.
436
+
437
+ Dimension names are jargon and coverage counts are not scope: "18 cases" does
438
+ not tell a reader whether sign-up was looked at. The model supplies the
439
+ feature-level prose in `scope`; the dimension table and the not-covered
440
+ derivations are computed, so a run that omits `scope` still explains itself.
441
+ """
442
+ scope = result.get("scope") or {}
443
+ dimensions = result.get("dimensionsRun") or []
444
+ parts = []
445
+
446
+ if scope.get("objective"):
447
+ parts.append(f'<p class="objective">{_e(scope["objective"])}</p>')
448
+
449
+ if scope.get("covered"):
450
+ items = "".join(f"<li>{_e(x)}</li>" for x in scope["covered"])
451
+ parts.append(f'<ul class="clean">{items}</ul>')
452
+
453
+ if not parts and not dimensions:
454
+ return ""
455
+
456
+ body = (
457
+ f'<div class="card"><div class="card-body">{"".join(parts)}</div></div>'
458
+ if parts
459
+ else ""
460
+ )
461
+
462
+ if dimensions:
463
+ rows = "".join(
464
+ f'<tr><td><strong>{_e(_DIMENSION_LABEL.get(d, d))}</strong></td>'
465
+ f'<td>{_e(_DIMENSION_PLAIN.get(d, ""))}</td></tr>'
466
+ for d in dimensions
467
+ )
468
+ body += (
469
+ '<div class="card"><table><thead><tr><th>Area checked</th>'
470
+ f"<th>What that means</th></tr></thead><tbody>{rows}</tbody></table></div>"
471
+ )
472
+ return body
473
+
474
+
475
+ def _not_covered_block(result):
476
+ """The boundary of the run, stated rather than left to inference.
477
+
478
+ An unstated boundary reads as "everything was checked". Blocked cases and
479
+ skipped dimensions are the two boundaries the artifact already knows about, so
480
+ they are added to whatever the run declared.
481
+ """
482
+ scope = result.get("scope") or {}
483
+ items = [str(x) for x in (scope.get("notCovered") or [])]
484
+
485
+ ran = set(result.get("dimensionsRun") or [])
486
+ if ran:
487
+ missing = [d for d in _DIMENSION_PLAIN if d not in ran]
488
+ for dimension in missing:
489
+ # Named with its plain-language meaning: "Data" alone tells a reader
490
+ # who does not work in QA nothing about what went unchecked.
491
+ items.append(
492
+ f"{_DIMENSION_LABEL.get(dimension, dimension)} was not examined — "
493
+ f"{_DIMENSION_PLAIN[dimension][0].lower()}{_DIMENSION_PLAIN[dimension][1:]}"
494
+ )
495
+
496
+ db = result.get("dbValidation") or {}
497
+ if db.get("inScope") is False and not db.get("summary"):
498
+ items.append("Data was not compared against the system of record — no access was provided.")
499
+
500
+ cases = result.get("testCases") or {}
501
+ blocked = [c for c in (cases.get("cases") or []) if c.get("status") == "blocked"]
502
+ if blocked:
503
+ listed = ", ".join(f'{c.get("id")} ({c.get("title")})' for c in blocked)
504
+ items.append(f"Could not be run: {listed}")
505
+
506
+ if not items:
507
+ return ""
508
+ listing = "".join(f"<li>{_e(x)}</li>" for x in items)
509
+ return (
510
+ '<h2>Not covered in this run</h2>'
511
+ f'<div class="card"><div class="card-body"><ul class="clean">{listing}</ul></div></div>'
512
+ )
513
+
514
+
515
+ def _legend_block():
516
+ """What the severity labels mean, so nobody has to ask."""
517
+ rows = "".join(
518
+ f"<tr><td>{_severity_badge(key)}</td><td>{_e(meaning)}</td></tr>"
519
+ for key, meaning in _SEVERITY_MEANING.items()
520
+ )
521
+ return (
522
+ '<div class="card"><table><thead><tr><th>Severity</th><th>What it means</th></tr>'
523
+ f"</thead><tbody>{rows}</tbody></table></div>"
524
+ )
525
+
526
+
527
+ def _evidence_index(evidence):
528
+ """The run's evidence, listed once with its descriptions.
529
+
530
+ The contract requires this array and the hand-written report reduced it to a
531
+ line of prose, so a reader could not tell what proof the run actually holds.
532
+ """
533
+ if not evidence:
534
+ return ""
535
+ rows = "".join(
536
+ "<tr>"
537
+ f'<td>{_e(_EVIDENCE_LABEL.get(item.get("type"), item.get("type", "")))}</td>'
538
+ f'<td>{_e(item.get("description", ""))}</td>'
539
+ f'<td><code>{_e(item.get("source"))}</code></td>'
540
+ "</tr>"
541
+ for item in evidence
542
+ )
543
+ return (
544
+ '<div class="card"><table><thead><tr><th>Kind</th><th>Shows</th>'
545
+ f"<th>File</th></tr></thead><tbody>{rows}</tbody></table></div>"
546
+ )
547
+
548
+
549
+ def render_explore(result):
550
+ """Render an explore-result artifact."""
551
+ findings = sorted(
552
+ result.get("findings") or [],
553
+ key=lambda f: _SEVERITY.get(f.get("severity"), _SEVERITY["low"])["rank"],
554
+ )
555
+ verdict_label, verdict_colour, verdict_tint = _VERDICT.get(
556
+ result.get("classification"), ("Reported", "#4a5568", "#f4f5f7")
557
+ )
558
+
559
+ meta_bits = []
560
+ if result.get("url"):
561
+ meta_bits.append(f'<strong>Target</strong> <code>{_e(result["url"])}</code>')
562
+ if result.get("generatedAt"):
563
+ meta_bits.append(f'<strong>Generated</strong> {_e(result["generatedAt"])}')
564
+ if result.get("browserAdapter"):
565
+ meta_bits.append(f'<strong>Browser</strong> {_e(result["browserAdapter"])}')
566
+ if result.get("reportVersion"):
567
+ meta_bits.append(f'<strong>Report</strong> v{_e(result["reportVersion"])}')
568
+
569
+ body = [
570
+ '<header class="masthead">',
571
+ ' <div class="eyebrow">Exploratory QA report</div>',
572
+ f' <h1>{_e(_subject(result))}</h1>',
573
+ f' <p class="meta">{" &middot; ".join(meta_bits)}</p>',
574
+ '</header>',
575
+ f'<p class="lead">{_e(result.get("summary", ""))}</p>',
576
+ f'<div class="verdict" style="color:{verdict_colour};background:{verdict_tint}">'
577
+ f'{_e(verdict_label)}</div>',
578
+ ]
579
+
580
+ counts = result.get("severityCounts") or {}
581
+ body.append(_counts_block(counts, ["critical", "high", "medium", "low"]))
582
+
583
+ tests = result.get("testCases") or {}
584
+ if tests:
585
+ body.append(
586
+ _counts_block(
587
+ {k: tests[k] for k in ("total", "passed", "failed", "blocked", "skipped") if k in tests},
588
+ ["total", "passed", "failed", "blocked", "skipped"],
589
+ )
590
+ )
591
+
592
+ # Orientation before detail. A reader who has never seen this product needs to
593
+ # know what the document is, what was looked at, what was not, and what the
594
+ # labels mean — before the first finding, not in an appendix.
595
+ body.append("<h2>About this report</h2>")
596
+ body.append(_orientation(result))
597
+
598
+ scope = _scope_block(result)
599
+ if scope:
600
+ body.append("<h2>What was tested</h2>")
601
+ body.append(scope)
602
+
603
+ not_covered = _not_covered_block(result)
604
+ if not_covered:
605
+ body.append(not_covered)
606
+
607
+ body.append("<h2>How to read the findings</h2>")
608
+ body.append(_legend_block())
609
+
610
+ body.append("<h2>Findings</h2>")
611
+ if findings:
612
+ body.extend(_finding_card(f) for f in findings)
613
+ else:
614
+ body.append('<div class="card"><div class="card-body empty">No findings recorded.</div></div>')
615
+
616
+ if tests.get("cases"):
617
+ body.append("<h2>Test case coverage</h2>")
618
+ body.append(_test_case_table(tests, {f.get("id") for f in findings}))
619
+
620
+ db = result.get("dbValidation") or {}
621
+ if db.get("summary") or db.get("inScope") is not None:
622
+ body.append("<h2>Data validation</h2>")
623
+ note = db.get("summary") or (
624
+ "In scope." if db.get("inScope") else "Not in scope for this run."
625
+ )
626
+ body.append(f'<div class="card"><div class="card-body">{_e(note)}</div></div>')
627
+ if db.get("comparisons"):
628
+ if not (db.get("summary") or db.get("inScope") is not None):
629
+ body.append("<h2>Data validation</h2>")
630
+ rows = "".join(
631
+ "<tr>"
632
+ f'<td>{_e(c.get("metric"))}</td><td><code>{_e(c.get("uiValue"))}</code></td>'
633
+ f'<td><code>{_e(c.get("sourceValue"))}</code></td>'
634
+ f'<td style="color:{"#116149" if c.get("match") else "#b3261e"};font-weight:600">'
635
+ f'{"Match" if c.get("match") else "Mismatch"}</td></tr>'
636
+ for c in db["comparisons"]
637
+ )
638
+ body.append(
639
+ '<div class="card"><table><thead><tr><th>Metric</th><th>Shown in UI</th>'
640
+ f"<th>Source of truth</th><th>Result</th></tr></thead><tbody>{rows}</tbody></table></div>"
641
+ )
642
+
643
+ if result.get("fixOrder"):
644
+ body.append("<h2>Suggested fix order</h2>")
645
+ items = "".join(f"<li>{_e(x)}</li>" for x in result["fixOrder"])
646
+ body.append(f'<div class="card"><div class="card-body"><ol class="order">{items}</ol></div></div>')
647
+
648
+ if result.get("recommendations"):
649
+ body.append("<h2>Recommendations</h2>")
650
+ rows = "".join(
651
+ f'<tr><td>{_e(r.get("action"))}</td><td>{_e(r.get("priority"))}</td></tr>'
652
+ for r in result["recommendations"]
653
+ )
654
+ body.append(
655
+ '<div class="card"><table><thead><tr><th>Action</th><th>Priority</th></tr>'
656
+ f"</thead><tbody>{rows}</tbody></table></div>"
657
+ )
658
+
659
+ if result.get("whatWorksWell"):
660
+ body.append("<h2>Verified working</h2>")
661
+ items = "".join(f"<li>{_e(x)}</li>" for x in result["whatWorksWell"])
662
+ body.append(f'<div class="card"><div class="card-body"><ul class="clean">{items}</ul></div></div>')
663
+
664
+ if result.get("evidence"):
665
+ body.append("<h2>Evidence index</h2>")
666
+ body.append(_evidence_index(result["evidence"]))
667
+
668
+ return "\n".join(body)
669
+
670
+
671
+ def render_report(result):
672
+ """Render a report-result artifact (qa-report's release rollup)."""
673
+ verdict = (result.get("releaseReadiness") or {}).get("verdict", result.get("classification"))
674
+ label, colour, tint = _VERDICT.get(verdict, ("Reported", "#4a5568", "#f4f5f7"))
675
+ summaries = result.get("summaries") or {}
676
+
677
+ body = [
678
+ '<header class="masthead">',
679
+ ' <div class="eyebrow">Release readiness report</div>',
680
+ f' <h1>{_e(_subject(result))}</h1>',
681
+ f' <p class="meta"><strong>Generated</strong> {_e(result.get("generatedAt", ""))}</p>',
682
+ '</header>',
683
+ f'<p class="lead">{_e(result.get("summary", ""))}</p>',
684
+ f'<div class="verdict" style="color:{colour};background:{tint}">{_e(label)}</div>',
685
+ ]
686
+ rationale = (result.get("releaseReadiness") or {}).get("rationale")
687
+ if rationale:
688
+ body.append(f'<div class="card"><div class="card-body">{_e(rationale)}</div></div>')
689
+
690
+ tests = result.get("testSummary") or {}
691
+ body.append(
692
+ _counts_block(
693
+ {k: tests[k] for k in ("total", "passed", "failed", "skipped") if k in tests},
694
+ ["total", "passed", "failed", "skipped"],
695
+ )
696
+ )
697
+
698
+ for key, heading in (("executive", "Executive summary"), ("engineering", "Engineering summary")):
699
+ if summaries.get(key):
700
+ body.append(f"<h2>{heading}</h2>")
701
+ body.append(f'<div class="card"><div class="card-body">{_e(summaries[key])}</div></div>')
702
+
703
+ if result.get("failureSummary"):
704
+ body.append("<h2>Failures</h2>")
705
+ rows = "".join(
706
+ f'<tr><td>{_e(f.get("test"))}</td><td>{_e(f.get("classification"))}</td>'
707
+ f'<td>{_e(f.get("reason"))}</td></tr>'
708
+ for f in result["failureSummary"]
709
+ )
710
+ body.append(
711
+ '<div class="card"><table><thead><tr><th>Test</th><th>Classification</th>'
712
+ f"<th>Reason</th></tr></thead><tbody>{rows}</tbody></table></div>"
713
+ )
714
+
715
+ if result.get("recommendations"):
716
+ body.append("<h2>Recommendations</h2>")
717
+ rows = "".join(
718
+ f'<tr><td>{_e(r.get("action"))}</td><td>{_e(r.get("priority"))}</td></tr>'
719
+ for r in result["recommendations"]
720
+ )
721
+ body.append(
722
+ '<div class="card"><table><thead><tr><th>Action</th><th>Priority</th></tr>'
723
+ f"</thead><tbody>{rows}</tbody></table></div>"
724
+ )
725
+
726
+ return "\n".join(body)
727
+
728
+
729
+ _RENDERERS = {
730
+ "qa-explore/explore-result": render_explore,
731
+ "qa-report/report-result": render_report,
732
+ }
733
+
734
+
735
+ def render(result, title=None):
736
+ """Render a full standalone HTML document for a supported artifact."""
737
+ contract = (result.get("contract") or {}).get("name")
738
+ renderer = _RENDERERS.get(contract)
739
+ if renderer is None:
740
+ raise ReportError(
741
+ f"no HTML renderer for contract {contract!r}; supported: "
742
+ + ", ".join(sorted(_RENDERERS))
743
+ )
744
+
745
+ heading = title or f"QA report — {_subject(result)}"
746
+ return (
747
+ "<!DOCTYPE html>\n"
748
+ '<html lang="en">\n<head>\n'
749
+ '<meta charset="utf-8"/>\n'
750
+ '<meta name="viewport" content="width=device-width,initial-scale=1"/>\n'
751
+ f"<title>{_e(heading)}</title>\n"
752
+ f"<style>{_CSS}</style>\n"
753
+ "</head>\n<body>\n"
754
+ '<div class="page">\n'
755
+ f"{renderer(result)}\n"
756
+ # Attribution is part of the document, not an optional flourish.
757
+ f"{branding.footer_html()}"
758
+ "</div>\n</body>\n</html>\n"
759
+ )
760
+
761
+
762
+ def render_file(path, title=None):
763
+ """Read an artifact from disk and render it."""
764
+ try:
765
+ with open(path, "r", encoding="utf-8") as handle:
766
+ result = json.load(handle)
767
+ except (OSError, json.JSONDecodeError) as exc:
768
+ raise ReportError(f"could not read artifact at {path}: {exc}") from exc
769
+ if not isinstance(result, dict):
770
+ raise ReportError(f"artifact at {path} is not a JSON object")
771
+ return render(result, title=title)
772
+
773
+
774
+ def supported_contracts():
775
+ return sorted(_RENDERERS)
776
+
777
+
778
+ __all__ = [
779
+ "render", "render_file", "supported_contracts", "ReportError",
780
+ "render_explore", "render_report",
781
+ ]