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.
- package/README.md +167 -59
- package/package.json +1 -1
- package/packages/engine/bin/qa-engine.mjs +232 -8
- package/packages/engine/lib/analysis/har.mjs +34 -0
- package/packages/engine/lib/analysis/network.mjs +237 -0
- package/packages/engine/lib/analysis/report-html.mjs +47 -734
- package/packages/engine/lib/artifacts/manager.mjs +453 -0
- package/packages/engine/lib/artifacts/mime.mjs +109 -0
- package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
- package/packages/engine/lib/report/components/charts.mjs +424 -0
- package/packages/engine/lib/report/components/evidence.mjs +207 -0
- package/packages/engine/lib/report/components/findings.mjs +258 -0
- package/packages/engine/lib/report/components/nav.mjs +99 -0
- package/packages/engine/lib/report/components/primitives.mjs +246 -0
- package/packages/engine/lib/report/components/runtime.mjs +246 -0
- package/packages/engine/lib/report/components/timeline.mjs +65 -0
- package/packages/engine/lib/report/core/model.mjs +270 -0
- package/packages/engine/lib/report/core/normalize.mjs +226 -0
- package/packages/engine/lib/report/core/sections.mjs +978 -0
- package/packages/engine/lib/report/export/bundle.mjs +293 -0
- package/packages/engine/lib/report/export/html.mjs +183 -0
- package/packages/engine/lib/report/export/machine.mjs +290 -0
- package/packages/engine/lib/report/export/markdown.mjs +323 -0
- package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
- package/packages/engine/lib/report/theme/css.mjs +529 -0
- package/packages/engine/lib/report/theme/tokens.mjs +137 -0
- package/packages/engine/lib/report/version.mjs +78 -0
- package/packages/engine/package.json +2 -2
- package/packages/installer/lib/agents/targets.mjs +90 -0
- package/packages/installer/lib/agents/user-level.mjs +80 -0
- package/packages/installer/lib/cli/flags.mjs +20 -2
- package/packages/installer/lib/commands/doctor.mjs +6 -4
- package/packages/installer/lib/commands/install.mjs +134 -93
- package/packages/installer/lib/commands/repair.mjs +10 -6
- package/packages/installer/lib/commands/self-test.mjs +4 -3
- package/packages/installer/lib/commands/uninstall.mjs +14 -8
- package/packages/installer/lib/commands/update.mjs +9 -4
- package/packages/installer/lib/commands/verify.mjs +13 -12
- package/packages/installer/lib/constants.mjs +13 -0
- package/packages/installer/lib/core/conflict.mjs +5 -4
- package/packages/installer/lib/core/fs-safe.mjs +146 -6
- package/packages/installer/lib/core/integrity.mjs +59 -0
- package/packages/installer/lib/core/lockfile.mjs +19 -3
- package/packages/installer/lib/core/plan.mjs +213 -0
- package/packages/installer/lib/core/qa-home.mjs +145 -0
- package/packages/installer/lib/core/scope.mjs +274 -0
- package/packages/installer/lib/core/validate-install.mjs +37 -12
- package/packages/installer/package.json +1 -1
- package/packages/installer/schemas/qa-lock.schema.json +119 -21
- package/shared/tooling/qa-tool.mjs +41 -5
- package/skills/qa-api/scripts/qa-tool.mjs +41 -5
- package/skills/qa-audit/scripts/qa-tool.mjs +41 -5
- package/skills/qa-debug/SKILL.md +3 -0
- package/skills/qa-debug/references/failure-handoff.md +44 -0
- package/skills/qa-debug/scripts/qa-tool.mjs +41 -5
- package/skills/qa-explore/SKILL.md +26 -10
- package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
- package/skills/qa-explore/references/api-replay.md +40 -1
- package/skills/qa-explore/references/report-pipeline.md +265 -95
- package/skills/qa-explore/scripts/qa-tool.mjs +41 -5
- package/skills/qa-fix/scripts/qa-tool.mjs +41 -5
- package/skills/qa-flaky/scripts/qa-tool.mjs +41 -5
- package/skills/qa-init/scripts/qa-tool.mjs +41 -5
- package/skills/qa-report/scripts/qa-tool.mjs +41 -5
- package/skills/qa-run/README.md +7 -0
- package/skills/qa-run/SKILL.md +12 -7
- package/skills/qa-run/contracts/execution-result.schema.json +77 -0
- package/skills/qa-run/examples/execute-playwright.md +42 -10
- package/skills/qa-run/examples/plan-a-run.md +5 -3
- package/skills/qa-run/references/artifact-collector.md +2 -1
- package/skills/qa-run/references/command-builder.md +18 -1
- package/skills/qa-run/references/execution-strategy.md +2 -0
- package/skills/qa-run/references/failure-handoff.md +44 -0
- package/skills/qa-run/references/playwright-artifacts.md +3 -1
- package/skills/qa-run/references/playwright-execution.md +14 -1
- package/skills/qa-run/scripts/qa-tool.mjs +41 -5
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Contract artifact → view model.
|
|
2
|
+
//
|
|
3
|
+
// Every renderer reads this shape, never the raw artifact. One place therefore owns
|
|
4
|
+
// the questions that would otherwise be answered slightly differently in eight
|
|
5
|
+
// sections: which verdict applies when `executive` is absent, what counts as a
|
|
6
|
+
// "covered" dimension, what the subject line of the report is, and — the one that
|
|
7
|
+
// matters most — what may be *derived* versus what must be *measured*.
|
|
8
|
+
//
|
|
9
|
+
// ## Derivation discipline
|
|
10
|
+
//
|
|
11
|
+
// A report is trusted because its numbers came from somewhere. So:
|
|
12
|
+
//
|
|
13
|
+
// derived — the overall score (a fixed function of severity counts), dimension
|
|
14
|
+
// filter counts, page/finding association, totals, the fallback verdict
|
|
15
|
+
// measured — every performance number, every score the producer supplied,
|
|
16
|
+
// artifact existence, request timings
|
|
17
|
+
//
|
|
18
|
+
// Nothing here invents a measurement. If a run never checked accessibility there is
|
|
19
|
+
// no accessibility score, and the report says the area was not examined rather than
|
|
20
|
+
// printing a plausible number. A fabricated 87 is worse than a blank, because the
|
|
21
|
+
// blank prompts a question and the 87 ends one.
|
|
22
|
+
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { createRegistry } from '../../artifacts/manager.mjs';
|
|
26
|
+
import { VERDICT, SEVERITY_ORDER } from '../theme/tokens.mjs';
|
|
27
|
+
import { DIMENSION_LABEL } from '../components/findings.mjs';
|
|
28
|
+
import { normalize } from './normalize.mjs';
|
|
29
|
+
import { versionStamp } from '../version.mjs';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What the report is *about*, short enough to be a heading.
|
|
33
|
+
*
|
|
34
|
+
* The contract has no title field on a 1.0 result and the summary is a paragraph —
|
|
35
|
+
* using it as an `<h1>` produced a five-line heading. The host is the honest short
|
|
36
|
+
* answer, the way Lighthouse titles a report by its URL.
|
|
37
|
+
*/
|
|
38
|
+
export function subjectOf(result) {
|
|
39
|
+
if (result.title) return String(result.title);
|
|
40
|
+
const url = String(result.url ?? '').trim();
|
|
41
|
+
if (url) {
|
|
42
|
+
const withoutScheme = url.includes('://') ? url.slice(url.indexOf('://') + 3) : url;
|
|
43
|
+
return withoutScheme.replace(/\/+$/, '') || url;
|
|
44
|
+
}
|
|
45
|
+
return String(result.summary ?? 'QA report').split('.')[0].slice(0, 80);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The release decision.
|
|
50
|
+
*
|
|
51
|
+
* `executive.verdict` is authoritative when the run supplied one. Otherwise the
|
|
52
|
+
* classification maps onto the same surface, so a 1.0 result still renders a verdict
|
|
53
|
+
* banner instead of an empty strip. The severity counts are the last resort and the
|
|
54
|
+
* safest one: any critical means do-not-ship, whatever the prose says.
|
|
55
|
+
*/
|
|
56
|
+
export function verdictOf(result) {
|
|
57
|
+
const executive = result.executive ?? {};
|
|
58
|
+
let key = executive.verdict;
|
|
59
|
+
|
|
60
|
+
if (!key) {
|
|
61
|
+
const counts = result.severityCounts ?? {};
|
|
62
|
+
if (result.classification === 'blocked') key = 'blocked';
|
|
63
|
+
else if (result.classification === 'insufficient-data') key = 'insufficient-data';
|
|
64
|
+
else if ((counts.critical ?? 0) > 0) key = 'do-not-ship';
|
|
65
|
+
else if ((counts.high ?? 0) > 0) key = 'ship-with-risks';
|
|
66
|
+
else key = result.classification === 'pass' ? 'pass' : 'issues-found';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const spec = VERDICT[key] ?? VERDICT['insufficient-data'];
|
|
70
|
+
return {
|
|
71
|
+
key,
|
|
72
|
+
label: spec.label,
|
|
73
|
+
tone: spec.tone,
|
|
74
|
+
blurb: executive.headline ?? spec.blurb,
|
|
75
|
+
headline: executive.headline ?? null,
|
|
76
|
+
health: executive.health ?? null,
|
|
77
|
+
risks: executive.risks ?? [],
|
|
78
|
+
recommendedAction: executive.recommendedAction ?? null,
|
|
79
|
+
estimatedFixHours: executive.estimatedFixHours ?? null,
|
|
80
|
+
confidence: Number.isFinite(executive.confidence) ? executive.confidence : null,
|
|
81
|
+
// True when nothing in the artifact stated a verdict and this one was inferred.
|
|
82
|
+
inferred: !executive.verdict,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* An overall score from severity counts.
|
|
88
|
+
*
|
|
89
|
+
* Deliberately a fixed, published function rather than a judgement: a critical costs
|
|
90
|
+
* 35 points, a high 12, a medium 4, a low 1, floored at zero. Two runs of the same
|
|
91
|
+
* application produce the same number, and a reader can check the arithmetic. It is
|
|
92
|
+
* shown as "derived" precisely so nobody mistakes it for a measurement.
|
|
93
|
+
*/
|
|
94
|
+
export function deriveOverall(counts) {
|
|
95
|
+
const penalty =
|
|
96
|
+
(counts.critical ?? 0) * 35 + (counts.high ?? 0) * 12 + (counts.medium ?? 0) * 4 + (counts.low ?? 0) * 1;
|
|
97
|
+
return Math.max(0, 100 - penalty);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Dimension facets for the filter bar, counted from the findings themselves. */
|
|
101
|
+
function dimensionFacets(findings) {
|
|
102
|
+
const counts = new Map();
|
|
103
|
+
for (const finding of findings) {
|
|
104
|
+
const key = finding.dimension;
|
|
105
|
+
if (!key) continue;
|
|
106
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
107
|
+
}
|
|
108
|
+
return [...counts.entries()]
|
|
109
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
110
|
+
.map(([key, count]) => ({ key, label: DIMENSION_LABEL[key] ?? key, count }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The boundary of the run, assembled from what was declared and what is derivable.
|
|
115
|
+
*
|
|
116
|
+
* An unstated boundary reads as "everything was checked". Blocked cases and unrun
|
|
117
|
+
* dimensions are boundaries the artifact already knows about, so they are added to
|
|
118
|
+
* whatever the run declared rather than left to the producer to remember.
|
|
119
|
+
*/
|
|
120
|
+
function notCovered(result) {
|
|
121
|
+
const items = [...(result.scope?.notCovered ?? []).map(String)];
|
|
122
|
+
const ran = new Set(result.dimensionsRun ?? []);
|
|
123
|
+
|
|
124
|
+
if (ran.size > 0) {
|
|
125
|
+
for (const [key, label] of Object.entries(DIMENSION_LABEL)) {
|
|
126
|
+
if (ran.has(key)) continue;
|
|
127
|
+
items.push(`${label} was not examined in this run.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const db = result.dbValidation ?? {};
|
|
132
|
+
if (db.inScope === false && !db.summary) {
|
|
133
|
+
items.push('Data was not compared against the system of record — no access was provided.');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const blocked = (result.testCases?.cases ?? []).filter((testCase) => testCase.status === 'blocked');
|
|
137
|
+
if (blocked.length > 0) {
|
|
138
|
+
items.push(`Could not be run: ${blocked.map((c) => `${c.id} (${c.title})`).join(', ')}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return items;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Requests worth showing, ranked by how much trouble they represent.
|
|
146
|
+
*
|
|
147
|
+
* A raw endpoint list sorted by URL buries the 4-second call among ninety static
|
|
148
|
+
* assets. Failures first, then slow, then everything flagged with any issue.
|
|
149
|
+
*/
|
|
150
|
+
function rankEndpoints(endpoints) {
|
|
151
|
+
const weight = (endpoint) => {
|
|
152
|
+
if (endpoint.issue === 'failed' || Number(endpoint.status) >= 500) return 0;
|
|
153
|
+
if (Number(endpoint.status) >= 400) return 1;
|
|
154
|
+
if (endpoint.issue === 'slow') return 2;
|
|
155
|
+
if (endpoint.issue) return 3;
|
|
156
|
+
return 4;
|
|
157
|
+
};
|
|
158
|
+
return [...endpoints].sort(
|
|
159
|
+
(a, b) => weight(a) - weight(b) || (Number(b.durationMs) || 0) - (Number(a.durationMs) || 0),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Build the view model.
|
|
165
|
+
*
|
|
166
|
+
* `resultPath` locates the artifact on disk so evidence paths resolve against the
|
|
167
|
+
* right directory; `outPath` is where the document will be written, which is what
|
|
168
|
+
* every href is made relative to. They are usually the same directory and must never
|
|
169
|
+
* be assumed to be.
|
|
170
|
+
*/
|
|
171
|
+
export function buildModel(input, options = {}) {
|
|
172
|
+
// Whatever the producer's contract, it becomes one shape here — and it is refused by
|
|
173
|
+
// name if this renderer has never been tested against it.
|
|
174
|
+
const result = normalize(input);
|
|
175
|
+
|
|
176
|
+
const resultPath = options.resultPath ? path.resolve(options.resultPath) : null;
|
|
177
|
+
const baseDir = options.baseDir
|
|
178
|
+
? path.resolve(options.baseDir)
|
|
179
|
+
: resultPath
|
|
180
|
+
? path.dirname(resultPath)
|
|
181
|
+
: process.cwd();
|
|
182
|
+
const outDir = options.outPath ? path.dirname(path.resolve(options.outPath)) : baseDir;
|
|
183
|
+
|
|
184
|
+
const registry = createRegistry(result, {
|
|
185
|
+
baseDir,
|
|
186
|
+
outDir,
|
|
187
|
+
hash: options.hash !== false,
|
|
188
|
+
embed: options.embed === true,
|
|
189
|
+
embedLimit: options.embedLimit,
|
|
190
|
+
// Set by the bundler, which copies evidence into its own tree and needs every link
|
|
191
|
+
// pointed at the copy rather than at wherever the file was found.
|
|
192
|
+
hrefMap: options.hrefMap ?? null,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const findings = [...(result.findings ?? [])];
|
|
196
|
+
const counts = result.severityCounts ?? { critical: 0, high: 0, medium: 0, low: 0 };
|
|
197
|
+
const testCases = result.testCases ?? null;
|
|
198
|
+
const suppliedScores = result.scores ?? {};
|
|
199
|
+
|
|
200
|
+
const scores = { ...suppliedScores };
|
|
201
|
+
const overallDerived = !Number.isFinite(suppliedScores.overall);
|
|
202
|
+
if (overallDerived) scores.overall = deriveOverall(counts);
|
|
203
|
+
|
|
204
|
+
const network = result.network ?? null;
|
|
205
|
+
const endpoints = rankEndpoints(network?.endpoints ?? []);
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
// Identity
|
|
209
|
+
title: result.title ?? null,
|
|
210
|
+
subject: subjectOf(result),
|
|
211
|
+
url: result.url ?? null,
|
|
212
|
+
summary: result.summary ?? '',
|
|
213
|
+
generatedAt: result.generatedAt ?? null,
|
|
214
|
+
environment: result.environment ?? null,
|
|
215
|
+
reportVersion: result.reportVersion ?? null,
|
|
216
|
+
browserAdapter: result.browserAdapter ?? null,
|
|
217
|
+
durationMs: Number.isFinite(result.durationMs) ? result.durationMs : null,
|
|
218
|
+
contract: result.contract ?? null,
|
|
219
|
+
skill: result.skill ?? null,
|
|
220
|
+
|
|
221
|
+
// Decision
|
|
222
|
+
classification: result.classification ?? null,
|
|
223
|
+
verdict: verdictOf(result),
|
|
224
|
+
scores,
|
|
225
|
+
overallDerived,
|
|
226
|
+
|
|
227
|
+
// Findings
|
|
228
|
+
findings,
|
|
229
|
+
severityCounts: counts,
|
|
230
|
+
totalFindings: SEVERITY_ORDER.reduce((sum, key) => sum + (counts[key] ?? 0), 0),
|
|
231
|
+
dimensions: dimensionFacets(findings),
|
|
232
|
+
|
|
233
|
+
// Coverage
|
|
234
|
+
scope: result.scope ?? null,
|
|
235
|
+
dimensionsRun: result.dimensionsRun ?? [],
|
|
236
|
+
notCovered: notCovered(result),
|
|
237
|
+
testCases,
|
|
238
|
+
pages: result.pages ?? [],
|
|
239
|
+
authentication: result.authentication ?? null,
|
|
240
|
+
|
|
241
|
+
// Analysis
|
|
242
|
+
timeline: result.timeline ?? [],
|
|
243
|
+
network: network ? { ...network, endpoints } : null,
|
|
244
|
+
performance: result.performance ?? null,
|
|
245
|
+
accessibility: result.accessibility ?? null,
|
|
246
|
+
security: result.security ?? null,
|
|
247
|
+
console: result.console ?? null,
|
|
248
|
+
dbValidation: result.dbValidation ?? null,
|
|
249
|
+
|
|
250
|
+
// Release rollup only: the per-test failure list and the two written summaries.
|
|
251
|
+
failures: result.failureSummary ?? [],
|
|
252
|
+
summaries: result.summaries ?? null,
|
|
253
|
+
|
|
254
|
+
// Guidance
|
|
255
|
+
whatWorksWell: result.whatWorksWell ?? [],
|
|
256
|
+
fixOrder: result.fixOrder ?? [],
|
|
257
|
+
recommendations: result.recommendations ?? [],
|
|
258
|
+
|
|
259
|
+
// Evidence
|
|
260
|
+
registry,
|
|
261
|
+
evidence: result.evidence ?? [],
|
|
262
|
+
artifactStats: registry.stats(),
|
|
263
|
+
|
|
264
|
+
// Provenance, for the appendix. `producer` is displayed and never branched on:
|
|
265
|
+
// the renderer must behave identically whoever made the report.
|
|
266
|
+
producer: result.producer ?? null,
|
|
267
|
+
versions: versionStamp(),
|
|
268
|
+
paths: { resultPath, baseDir, outDir },
|
|
269
|
+
};
|
|
270
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Every input shape the renderer accepts, folded into one internal shape.
|
|
2
|
+
//
|
|
3
|
+
// ## Why this seam exists
|
|
4
|
+
//
|
|
5
|
+
// The promise is that a report looks the same whoever produced it — Claude Code,
|
|
6
|
+
// Cursor, Codex, a future agent nobody has written yet. That promise is only keepable
|
|
7
|
+
// if *no producer ever touches presentation*, which in turn is only enforceable if
|
|
8
|
+
// every producer hands over the same kind of thing: structured data, validated, and
|
|
9
|
+
// then rendered by code none of them can influence.
|
|
10
|
+
//
|
|
11
|
+
// This module is where "the same kind of thing" is defined. It accepts:
|
|
12
|
+
//
|
|
13
|
+
// qa-engineer/qa-report the canonical, producer-neutral report (schema 2.0)
|
|
14
|
+
// qa-explore/explore-result the exploratory contract (schema 1.x)
|
|
15
|
+
// qa-report/report-result the release rollup (schema 1.x)
|
|
16
|
+
//
|
|
17
|
+
// and returns one internal shape. Adding a fourth producer means adding a normalizer
|
|
18
|
+
// here — not a stylesheet, not a template, and certainly not a second renderer.
|
|
19
|
+
//
|
|
20
|
+
// ## Why the older contracts are not simply migrated
|
|
21
|
+
//
|
|
22
|
+
// Reports are archived. A result written a year ago must still render, and an
|
|
23
|
+
// installed skill emitting schema 1.1 must not break when the pack updates. So the
|
|
24
|
+
// older shapes are translated on read rather than deprecated on write, and the
|
|
25
|
+
// canonical shape is what new producers are pointed at.
|
|
26
|
+
//
|
|
27
|
+
// ## What a producer may not do
|
|
28
|
+
//
|
|
29
|
+
// Nothing here reads a colour, a class name, a font, or a layout hint, and the
|
|
30
|
+
// canonical schema has no field to carry one. An agent that wants its report to look
|
|
31
|
+
// different has no mechanism to make it so — which is the point.
|
|
32
|
+
|
|
33
|
+
import { schemaVersionOf, SUPPORTED_SCHEMA_VERSIONS, isSupportedSchema } from '../version.mjs';
|
|
34
|
+
|
|
35
|
+
export class SchemaError extends Error {
|
|
36
|
+
name = 'SchemaError';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The canonical report, unwrapped into the internal shape.
|
|
41
|
+
*
|
|
42
|
+
* The canonical form groups fields by audience — `summary` for the decision,
|
|
43
|
+
* `coverage` for what was looked at, `issues` for the detail — because that is how a
|
|
44
|
+
* producer thinks about filling it in. The internal shape is flat because that is how
|
|
45
|
+
* fifteen sections read from it. Neither is wrong; this function is the join.
|
|
46
|
+
*/
|
|
47
|
+
function fromCanonical(report) {
|
|
48
|
+
const metadata = report.metadata ?? {};
|
|
49
|
+
const summary = report.summary ?? {};
|
|
50
|
+
const coverage = report.coverage ?? {};
|
|
51
|
+
const recommendations = report.recommendations ?? {};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
contract: { name: 'qa-engineer/qa-report', version: report.schemaVersion ?? '2.0' },
|
|
55
|
+
skill: metadata.producer
|
|
56
|
+
? { name: metadata.producer.skill ?? metadata.producer.agent ?? 'unknown', version: metadata.producer.version ?? '' }
|
|
57
|
+
: undefined,
|
|
58
|
+
producer: metadata.producer ?? null,
|
|
59
|
+
|
|
60
|
+
title: metadata.title,
|
|
61
|
+
url: metadata.url,
|
|
62
|
+
environment: metadata.environment,
|
|
63
|
+
generatedAt: metadata.generatedAt,
|
|
64
|
+
durationMs: metadata.durationMs,
|
|
65
|
+
reportVersion: metadata.reportVersion,
|
|
66
|
+
browserAdapter: metadata.browserAdapter,
|
|
67
|
+
authentication: metadata.authentication,
|
|
68
|
+
|
|
69
|
+
summary: summary.text ?? '',
|
|
70
|
+
classification: summary.classification ?? null,
|
|
71
|
+
executive: {
|
|
72
|
+
verdict: summary.verdict,
|
|
73
|
+
headline: summary.headline,
|
|
74
|
+
health: summary.health,
|
|
75
|
+
risks: summary.risks,
|
|
76
|
+
recommendedAction: summary.recommendedAction,
|
|
77
|
+
estimatedFixHours: summary.estimatedFixHours,
|
|
78
|
+
confidence: summary.confidence,
|
|
79
|
+
},
|
|
80
|
+
scores: summary.scores,
|
|
81
|
+
severityCounts: summary.severityCounts ?? countSeverities(report.issues ?? []),
|
|
82
|
+
|
|
83
|
+
scope: {
|
|
84
|
+
objective: coverage.objective,
|
|
85
|
+
covered: coverage.covered,
|
|
86
|
+
notCovered: coverage.notCovered,
|
|
87
|
+
},
|
|
88
|
+
dimensionsRun: coverage.dimensionsRun,
|
|
89
|
+
pages: coverage.pages,
|
|
90
|
+
testCases: coverage.testCases,
|
|
91
|
+
timeline: coverage.timeline,
|
|
92
|
+
|
|
93
|
+
// `issues` is the canonical name; `findings` is what fifteen sections already call
|
|
94
|
+
// them. Renaming the sections to match would be churn with no reader benefit.
|
|
95
|
+
findings: report.issues ?? [],
|
|
96
|
+
artifacts: report.artifacts,
|
|
97
|
+
evidence: report.evidence ?? [],
|
|
98
|
+
|
|
99
|
+
performance: report.performance,
|
|
100
|
+
security: report.security,
|
|
101
|
+
accessibility: report.accessibility,
|
|
102
|
+
console: report.console,
|
|
103
|
+
network: report.network,
|
|
104
|
+
dbValidation: report.dataValidation,
|
|
105
|
+
|
|
106
|
+
fixOrder: recommendations.fixOrder,
|
|
107
|
+
recommendations: recommendations.actions,
|
|
108
|
+
whatWorksWell: recommendations.whatWorksWell,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Severity totals, when the producer did not count them itself. */
|
|
113
|
+
function countSeverities(issues) {
|
|
114
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
115
|
+
for (const issue of issues) {
|
|
116
|
+
if (counts[issue.severity] !== undefined) counts[issue.severity] += 1;
|
|
117
|
+
}
|
|
118
|
+
return counts;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Fold `qa-report/report-result` — the release rollup — into the same shape.
|
|
123
|
+
*
|
|
124
|
+
* The rollup describes the same event from further away: one summary of many runs
|
|
125
|
+
* rather than one run of a product. Its readiness verdict becomes the executive
|
|
126
|
+
* verdict and its test summary becomes the test counts. Its failure list stays a list
|
|
127
|
+
* of failures rather than being forced into `findings` with an invented severity for
|
|
128
|
+
* each row — a failing assertion is not a severity-ranked defect, and pretending
|
|
129
|
+
* otherwise would put fabricated numbers in the summary tiles.
|
|
130
|
+
*/
|
|
131
|
+
function fromRollup(result) {
|
|
132
|
+
const readiness = result.releaseReadiness ?? {};
|
|
133
|
+
const tests = result.testSummary ?? {};
|
|
134
|
+
const summaries = result.summaries ?? {};
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
...result,
|
|
138
|
+
executive: result.executive ?? {
|
|
139
|
+
verdict: readiness.verdict ?? result.classification,
|
|
140
|
+
headline: readiness.rationale ?? result.summary,
|
|
141
|
+
health: summaries.executive ?? null,
|
|
142
|
+
},
|
|
143
|
+
severityCounts: result.severityCounts ?? { critical: 0, high: 0, medium: 0, low: 0 },
|
|
144
|
+
testCases: result.testCases ?? (Object.keys(tests).length > 0
|
|
145
|
+
? {
|
|
146
|
+
total: tests.total ?? 0,
|
|
147
|
+
passed: tests.passed ?? 0,
|
|
148
|
+
failed: tests.failed ?? 0,
|
|
149
|
+
blocked: tests.blocked ?? 0,
|
|
150
|
+
skipped: tests.skipped ?? 0,
|
|
151
|
+
cases: [],
|
|
152
|
+
}
|
|
153
|
+
: null),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The exploratory contract is already the internal shape; nothing to translate. */
|
|
158
|
+
function fromExplore(result) {
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const NORMALIZERS = Object.freeze({
|
|
163
|
+
'qa-engineer/qa-report': fromCanonical,
|
|
164
|
+
'qa-explore/explore-result': fromExplore,
|
|
165
|
+
'qa-report/report-result': fromRollup,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/** Contract names this renderer accepts, for an error message and for `--help`. */
|
|
169
|
+
export function supportedContracts() {
|
|
170
|
+
return Object.keys(NORMALIZERS).sort();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Which producer made this report, for the provenance block.
|
|
175
|
+
*
|
|
176
|
+
* Recorded and displayed, never acted on: the renderer's behaviour must not vary by
|
|
177
|
+
* producer, or the guarantee that two agents produce identical output is only true
|
|
178
|
+
* until someone adds a special case.
|
|
179
|
+
*/
|
|
180
|
+
export function producerOf(result) {
|
|
181
|
+
const producer = result?.metadata?.producer ?? result?.producer;
|
|
182
|
+
if (producer) {
|
|
183
|
+
return {
|
|
184
|
+
agent: producer.agent ?? null,
|
|
185
|
+
model: producer.model ?? null,
|
|
186
|
+
skill: producer.skill ?? null,
|
|
187
|
+
version: producer.version ?? null,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (result?.skill) {
|
|
191
|
+
return { agent: null, model: null, skill: result.skill.name, version: result.skill.version };
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Normalize any accepted contract into the internal shape.
|
|
198
|
+
*
|
|
199
|
+
* Throws rather than guessing. A result whose contract is unknown, or whose schema
|
|
200
|
+
* version this renderer has never been tested against, is refused by name — rendering
|
|
201
|
+
* it half-understood would produce a document that looks authoritative and is missing
|
|
202
|
+
* whatever the unknown version added.
|
|
203
|
+
*/
|
|
204
|
+
export function normalize(result) {
|
|
205
|
+
const name = result?.contract?.name;
|
|
206
|
+
const normalizer = NORMALIZERS[name];
|
|
207
|
+
|
|
208
|
+
if (!normalizer) {
|
|
209
|
+
throw new SchemaError(
|
|
210
|
+
`no renderer for contract ${name === undefined ? 'None' : `'${name}'`}; supported: ` +
|
|
211
|
+
`${supportedContracts().join(', ')}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!isSupportedSchema(result)) {
|
|
216
|
+
throw new SchemaError(
|
|
217
|
+
`report declares schema version ${schemaVersionOf(result)}, which this renderer ` +
|
|
218
|
+
`does not support; supported: ${SUPPORTED_SCHEMA_VERSIONS.join(', ')}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const normalized = normalizer(result);
|
|
223
|
+
// Provenance survives normalization so the appendix can state who produced the
|
|
224
|
+
// report even when the contract carried it somewhere else.
|
|
225
|
+
return { ...normalized, producer: producerOf(result) };
|
|
226
|
+
}
|