qa-engineer 0.10.0 → 0.12.0

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