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.
- package/COMPATIBILITY.md +25 -15
- package/LICENSE +1 -1
- package/README.md +22 -4
- package/package.json +4 -3
- package/packages/installer/README.md +1 -1
- package/packages/installer/lib/agents/registry.mjs +46 -0
- package/packages/installer/lib/commands/doctor.mjs +6 -6
- package/packages/installer/lib/commands/install.mjs +32 -5
- package/packages/installer/lib/commands/onboard.mjs +2 -2
- package/packages/installer/lib/core/config.mjs +1 -1
- package/packages/installer/lib/core/lockfile.mjs +1 -1
- package/packages/installer/lib/core/manifest.mjs +3 -0
- package/packages/installer/lib/core/wrappers.mjs +4 -4
- package/packages/installer/lib/ui/theme.mjs +1 -1
- package/packages/installer/package.json +1 -1
- package/packages/installer/schemas/qa-lock.schema.json +1 -1
- package/packages/installer/schemas/qa.config.schema.json +1 -1
- package/shared/analysis/lib/qa_analysis/branding.json +1 -1
- package/shared/analysis/lib/qa_analysis/cli.py +15 -0
- package/shared/analysis/lib/qa_analysis/har.py +32 -3
- package/shared/analysis/lib/qa_analysis/junit.py +25 -1
- package/shared/analysis/lib/qa_analysis/redaction.py +10 -2
- package/shared/analysis/lib/qa_analysis/report_html.py +781 -0
- package/skills/qa/README.md +1 -1
- package/skills/qa/SKILL.md +1 -1
- package/skills/qa-api/references/deterministic-tooling.md +2 -0
- package/skills/qa-api/references/evidence-and-reporting.md +29 -4
- package/skills/qa-audit/references/deterministic-tooling.md +2 -0
- package/skills/qa-audit/references/evidence-and-reporting.md +29 -4
- package/skills/qa-debug/references/deterministic-tooling.md +2 -0
- package/skills/qa-debug/references/evidence-and-reporting.md +29 -4
- package/skills/qa-example/SKILL.md +1 -1
- package/skills/qa-example/contracts/self-check-report.schema.json +1 -1
- package/skills/qa-explore/SKILL.md +22 -5
- package/skills/qa-explore/contracts/explore-result.schema.json +26 -2
- package/skills/qa-explore/references/deterministic-tooling.md +130 -0
- package/skills/qa-explore/references/evidence-and-reporting.md +29 -4
- package/skills/qa-explore/references/report-pipeline.md +89 -8
- package/skills/qa-fix/references/deterministic-tooling.md +2 -0
- package/skills/qa-fix/references/evidence-and-reporting.md +29 -4
- package/skills/qa-flaky/references/deterministic-tooling.md +2 -0
- package/skills/qa-flaky/references/evidence-and-reporting.md +29 -4
- package/skills/qa-generate/references/evidence-and-reporting.md +29 -4
- package/skills/qa-init/references/deterministic-tooling.md +2 -0
- package/skills/qa-init/references/evidence-and-reporting.md +29 -4
- package/skills/qa-report/SKILL.md +3 -2
- package/skills/qa-report/references/deterministic-tooling.md +2 -0
- package/skills/qa-report/references/evidence-and-reporting.md +29 -4
- package/skills/qa-review/references/evidence-and-reporting.md +29 -4
- package/skills/qa-run/references/deterministic-tooling.md +2 -0
- package/skills/qa-run/references/evidence-and-reporting.md +29 -4
- 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">{" · ".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
|
+
]
|