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.
- package/COMPATIBILITY.md +50 -26
- package/README.md +40 -32
- package/package.json +12 -20
- package/packages/engine/bin/qa-engine.mjs +336 -0
- package/packages/engine/lib/analysis/branding.mjs +187 -0
- package/packages/engine/lib/analysis/context.mjs +294 -0
- package/packages/{installer/lib/core/schema-validate.mjs → engine/lib/analysis/contracts.mjs} +11 -4
- package/packages/engine/lib/analysis/diff-guard.mjs +425 -0
- package/packages/engine/lib/analysis/discovery.mjs +220 -0
- package/packages/engine/lib/analysis/evidence.mjs +116 -0
- package/packages/engine/lib/analysis/har.mjs +90 -0
- package/packages/engine/lib/analysis/junit.mjs +126 -0
- package/packages/engine/lib/analysis/redaction.mjs +127 -0
- package/packages/engine/lib/analysis/report-html.mjs +763 -0
- package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
- package/packages/engine/lib/analysis/xml.mjs +153 -0
- package/packages/engine/lib/analysis/zip.mjs +107 -0
- package/packages/engine/lib/diagnostics/engine.mjs +165 -0
- package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
- package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
- package/packages/engine/lib/diagnostics/repair.mjs +73 -0
- package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
- package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
- package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
- package/packages/engine/lib/frameworks/playwright.mjs +158 -0
- package/packages/engine/package.json +14 -0
- package/packages/installer/lib/agents/registry.mjs +46 -0
- package/packages/installer/lib/commands/doctor.mjs +7 -16
- package/packages/installer/lib/commands/install.mjs +59 -4
- package/packages/installer/lib/core/bundle.mjs +69 -92
- 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 +48 -54
- package/packages/installer/lib/core/validate-install.mjs +12 -19
- package/packages/installer/package.json +1 -1
- package/shared/tooling/qa-tool.mjs +125 -0
- package/skills/qa-api/SKILL.md +5 -5
- package/skills/qa-api/references/deterministic-tooling.md +34 -30
- package/skills/qa-api/references/evidence-and-reporting.md +29 -4
- package/skills/qa-api/scripts/qa-tool.mjs +126 -0
- package/skills/qa-audit/SKILL.md +5 -5
- package/skills/qa-audit/references/deterministic-tooling.md +34 -30
- package/skills/qa-audit/references/evidence-and-reporting.md +29 -4
- package/skills/qa-audit/scripts/qa-tool.mjs +126 -0
- package/skills/qa-debug/SKILL.md +6 -6
- package/skills/qa-debug/references/deterministic-tooling.md +34 -30
- package/skills/qa-debug/references/diagnostic-engine.md +3 -3
- package/skills/qa-debug/references/evidence-and-reporting.md +29 -4
- package/skills/qa-debug/scripts/qa-tool.mjs +126 -0
- 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 +132 -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-explore/scripts/qa-tool.mjs +126 -0
- package/skills/qa-fix/SKILL.md +4 -4
- package/skills/qa-fix/references/deterministic-tooling.md +34 -30
- package/skills/qa-fix/references/diagnostic-engine.md +3 -3
- package/skills/qa-fix/references/evidence-and-reporting.md +29 -4
- package/skills/qa-fix/scripts/qa-tool.mjs +126 -0
- package/skills/qa-flaky/SKILL.md +4 -4
- package/skills/qa-flaky/references/deterministic-tooling.md +34 -30
- package/skills/qa-flaky/references/evidence-and-reporting.md +29 -4
- package/skills/qa-flaky/scripts/qa-tool.mjs +126 -0
- package/skills/qa-generate/references/evidence-and-reporting.md +29 -4
- package/skills/qa-init/SKILL.md +4 -4
- package/skills/qa-init/references/deterministic-tooling.md +34 -30
- package/skills/qa-init/references/evidence-and-reporting.md +29 -4
- package/skills/qa-init/scripts/qa-tool.mjs +126 -0
- package/skills/qa-report/SKILL.md +8 -7
- package/skills/qa-report/references/deterministic-tooling.md +34 -30
- package/skills/qa-report/references/diagnostic-engine.md +3 -3
- package/skills/qa-report/references/evidence-and-reporting.md +29 -4
- package/skills/qa-report/scripts/qa-tool.mjs +126 -0
- package/skills/qa-review/references/evidence-and-reporting.md +29 -4
- package/skills/qa-run/SKILL.md +6 -6
- package/skills/qa-run/references/deterministic-tooling.md +34 -30
- package/skills/qa-run/references/evidence-and-reporting.md +29 -4
- package/skills/qa-run/scripts/qa-tool.mjs +126 -0
- package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
- package/shared/analysis/lib/qa_analysis/branding.py +0 -175
- package/shared/analysis/lib/qa_analysis/cli.py +0 -129
- package/shared/analysis/lib/qa_analysis/context.py +0 -233
- package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
- package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
- package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
- package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
- package/shared/analysis/lib/qa_analysis/har.py +0 -58
- package/shared/analysis/lib/qa_analysis/junit.py +0 -80
- package/shared/analysis/lib/qa_analysis/redaction.py +0 -99
- package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
- package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
- package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
- package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
- package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
- package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
- package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
- package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
- package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
- package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
- package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
- package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
- package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
- package/shared/tooling/qa_tool.py +0 -127
- /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
- /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/execution-result-min.schema.json +0 -0
|
@@ -0,0 +1,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, '<')
|
|
151
|
+
.replace(/>/g, '>')
|
|
152
|
+
.replace(/"/g, '"')
|
|
153
|
+
.replace(/'/g, ''');
|
|
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(' · ')}</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
|
+
}
|