qa-engineer 0.10.0 → 0.11.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 (63) 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/scripts/qa-tool.mjs +41 -5
  54. package/skills/qa-explore/SKILL.md +26 -10
  55. package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
  56. package/skills/qa-explore/references/api-replay.md +40 -1
  57. package/skills/qa-explore/references/report-pipeline.md +265 -95
  58. package/skills/qa-explore/scripts/qa-tool.mjs +41 -5
  59. package/skills/qa-fix/scripts/qa-tool.mjs +41 -5
  60. package/skills/qa-flaky/scripts/qa-tool.mjs +41 -5
  61. package/skills/qa-init/scripts/qa-tool.mjs +41 -5
  62. package/skills/qa-report/scripts/qa-tool.mjs +41 -5
  63. package/skills/qa-run/scripts/qa-tool.mjs +41 -5
@@ -0,0 +1,293 @@
1
+ // The portable report bundle: a folder that is the whole report, and nothing outside it.
2
+ //
3
+ // ## Why a folder and not just the single file
4
+ //
5
+ // `report-html --embed` already produces one self-contained page, and that is the right
6
+ // artifact for an email attachment. It is the wrong artifact for a run with forty
7
+ // screenshots and a video: base64 inflates by a third, and a 60 MB HTML file is not a
8
+ // thing anyone opens twice.
9
+ //
10
+ // So the bundle is the canonical *local* output. It copies every piece of evidence into
11
+ // its own `assets/` tree, rewrites every link to point at the copy, and then verifies
12
+ // that no link escapes the folder. What comes out is a directory — or one `.zip` — that
13
+ // opens in any browser, on any machine, with no network, no CDN, and no AI platform
14
+ // involved. Six months later it still opens, because there is nothing left outside it
15
+ // to go missing.
16
+ //
17
+ // qa-report/
18
+ // index.html the report
19
+ // report.json the artifact it was rendered from
20
+ // manifest.json every file, with its SHA-256
21
+ // assets/
22
+ // css/report.css the theme
23
+ // js/report.js the behaviour
24
+ // screenshots/ videos/ traces/ network/ dom/ console/ logs/ other/
25
+ //
26
+ // ## The guarantee, and how it is kept
27
+ //
28
+ // Nothing in `index.html` may point outside the bundle. That is checked *after* writing
29
+ // by re-reading the emitted HTML, extracting every `src` and `href`, and resolving each
30
+ // one against the bundle root. A link that resolves to a file that is not there fails
31
+ // the write rather than shipping a report with a hole in it.
32
+
33
+ import fs from 'node:fs';
34
+ import path from 'node:path';
35
+
36
+ import { createRegistry, hashFile } from '../../artifacts/manager.mjs';
37
+ import { createZip } from '../../artifacts/zip-write.mjs';
38
+ import { stylesheet } from '../theme/css.mjs';
39
+ import { runtimeScript } from '../components/runtime.mjs';
40
+ import { render } from './html.mjs';
41
+ import { renderMarkdown } from './markdown.mjs';
42
+ import { versionStamp } from '../version.mjs';
43
+
44
+ export class BundleError extends Error {
45
+ name = 'BundleError';
46
+ }
47
+
48
+ // Where each artifact kind lands. Grouping by kind rather than dumping everything in
49
+ // one directory is what makes a 200-file bundle navigable when someone opens the folder
50
+ // instead of the report.
51
+ const BUCKET = Object.freeze({
52
+ screenshot: 'screenshots',
53
+ video: 'videos',
54
+ trace: 'traces',
55
+ har: 'network',
56
+ network: 'network',
57
+ dom: 'dom',
58
+ console: 'console',
59
+ log: 'logs',
60
+ performance: 'metrics',
61
+ accessibility: 'metrics',
62
+ security: 'metrics',
63
+ api: 'metrics',
64
+ coverage: 'metrics',
65
+ diff: 'diffs',
66
+ report: 'reports',
67
+ file: 'other',
68
+ });
69
+
70
+ /** A filename that is safe on every filesystem and cannot escape its directory. */
71
+ function safeName(input, fallback) {
72
+ const base = path.basename(String(input ?? '')).replace(/[^A-Za-z0-9._-]/g, '-').replace(/^-+/, '');
73
+ return base && base !== '.' && base !== '..' ? base.slice(0, 120) : fallback;
74
+ }
75
+
76
+ /** Plan where every present artifact is copied to, keeping names unique. */
77
+ function planAssets(records) {
78
+ const plan = new Map();
79
+ const taken = new Set();
80
+ let counter = 0;
81
+
82
+ for (const record of records) {
83
+ if (!record.exists || record.external || !record.absolutePath) continue;
84
+ if (plan.has(record.absolutePath)) continue;
85
+
86
+ const bucket = BUCKET[record.kind] ?? 'other';
87
+ counter += 1;
88
+ let name = safeName(record.declaredPath, `artifact-${counter}`);
89
+ let target = `assets/${bucket}/${name}`;
90
+ // Two runs can capture `screenshot.png` in different folders; the bundle flattens
91
+ // them into one directory, so a collision has to be resolved rather than silently
92
+ // overwriting the earlier file.
93
+ if (taken.has(target)) {
94
+ const dot = name.lastIndexOf('.');
95
+ const stem = dot > 0 ? name.slice(0, dot) : name;
96
+ const extension = dot > 0 ? name.slice(dot) : '';
97
+ name = `${stem}-${counter}${extension}`;
98
+ target = `assets/${bucket}/${name}`;
99
+ }
100
+ taken.add(target);
101
+ plan.set(record.absolutePath, target);
102
+ }
103
+ return plan;
104
+ }
105
+
106
+ /**
107
+ * Every evidence entry in a result, wherever it lives.
108
+ *
109
+ * Both contract families keep evidence in two places: a run-level index and one array
110
+ * per finding. Missing either leaves files uncopied and links dangling, so both are
111
+ * walked here rather than at each call site.
112
+ */
113
+ function collectEvidence(result) {
114
+ const entries = [...(result?.evidence ?? [])];
115
+ for (const finding of result?.findings ?? result?.issues ?? []) {
116
+ entries.push(...(finding?.evidence ?? []));
117
+ }
118
+ return entries.filter((entry) => entry && typeof entry === 'object');
119
+ }
120
+
121
+ /** Every `src=` and `href=` in the document, for the completeness check. */
122
+ function referencedPaths(html) {
123
+ const found = new Set();
124
+ const pattern = /(?:src|href)="([^"]+)"/g;
125
+ let match = pattern.exec(html);
126
+ while (match !== null) {
127
+ const reference = match[1];
128
+ // External links, in-page anchors, and inlined data are not bundle files.
129
+ if (!/^(https?:|mailto:|data:|#)/i.test(reference)) found.add(reference);
130
+ match = pattern.exec(html);
131
+ }
132
+ return [...found];
133
+ }
134
+
135
+ function writeFile(root, relative, contents) {
136
+ const target = path.join(root, relative);
137
+ fs.mkdirSync(path.dirname(target), { recursive: true });
138
+ fs.writeFileSync(target, contents);
139
+ }
140
+
141
+ /**
142
+ * Write the bundle.
143
+ *
144
+ * Returns the manifest: what was written, what it hashes to, and whether every link
145
+ * resolves. `zip` additionally emits `<outDir>.zip` beside the folder.
146
+ */
147
+ export function writeBundle(result, options = {}) {
148
+ const resultPath = options.resultPath ? path.resolve(options.resultPath) : null;
149
+ const outDir = path.resolve(
150
+ options.outDir ?? (resultPath ? path.join(path.dirname(resultPath), 'report') : 'qa-report'),
151
+ );
152
+ const baseDir = resultPath ? path.dirname(resultPath) : process.cwd();
153
+ const mode = options.mode ?? 'full';
154
+
155
+ if (fs.existsSync(outDir) && !options.force) {
156
+ const entries = fs.readdirSync(outDir);
157
+ // Refuse to write into a directory holding something else. A bundle owns its
158
+ // folder — it deletes and rewrites `assets/` — and doing that to a directory the
159
+ // user chose for another reason is not recoverable.
160
+ const foreign = entries.filter((name) => !['index.html', 'report.json', 'manifest.json', 'assets', 'report.md'].includes(name));
161
+ if (foreign.length > 0) {
162
+ throw new BundleError(
163
+ `refusing to write a bundle into ${outDir}: it holds ${foreign.length} unrelated ` +
164
+ `file(s) (${foreign.slice(0, 3).join(', ')}). Choose an empty directory or pass force.`,
165
+ );
166
+ }
167
+ }
168
+
169
+ // Pass one: resolve everything against the filesystem so `exists` is known.
170
+ //
171
+ // The registry resolves `artifacts[]` eagerly but evidence entries only when the
172
+ // renderer asks for them, so surveying `list()` straight away sees an empty set for
173
+ // any result that never registered an artifacts block — and the bundle then copies
174
+ // nothing while the page still links at the originals. Touching every evidence entry
175
+ // first is what makes the survey complete.
176
+ const survey = createRegistry(result, { baseDir, outDir, hash: false });
177
+ for (const entry of collectEvidence(result)) survey.forEvidence(entry);
178
+ const plan = planAssets(survey.list());
179
+
180
+ // Pass two: render with every href redirected at the bundle's own copy.
181
+ const hrefMap = new Map([...plan].map(([absolute, target]) => [absolute, `./${target}`]));
182
+ const renderOptions = {
183
+ ...options,
184
+ resultPath,
185
+ baseDir,
186
+ outDir,
187
+ mode,
188
+ embed: false,
189
+ hrefMap,
190
+ assets: { css: './assets/css/report.css', js: './assets/js/report.js' },
191
+ };
192
+
193
+ const html = render(result, renderOptions);
194
+
195
+ fs.mkdirSync(outDir, { recursive: true });
196
+ // A rewritten bundle must not keep a screenshot the new run no longer references.
197
+ fs.rmSync(path.join(outDir, 'assets'), { recursive: true, force: true });
198
+
199
+ const written = [];
200
+ const record = (relative, bytes) => written.push({ path: relative, bytes });
201
+
202
+ writeFile(outDir, 'assets/css/report.css', stylesheet());
203
+ record('assets/css/report.css', Buffer.byteLength(stylesheet()));
204
+ writeFile(outDir, 'assets/js/report.js', runtimeScript());
205
+ record('assets/js/report.js', Buffer.byteLength(runtimeScript()));
206
+
207
+ for (const [absolute, target] of plan) {
208
+ fs.mkdirSync(path.dirname(path.join(outDir, target)), { recursive: true });
209
+ fs.copyFileSync(absolute, path.join(outDir, target));
210
+ record(target, fs.statSync(path.join(outDir, target)).size);
211
+ }
212
+
213
+ writeFile(outDir, 'index.html', html);
214
+ record('index.html', Buffer.byteLength(html));
215
+
216
+ // The source artifact travels with the report: a bundle that cannot be re-rendered is
217
+ // a dead end, and the JSON is what every other format is produced from.
218
+ const sourceJson = `${JSON.stringify(result, null, 2)}\n`;
219
+ writeFile(outDir, 'report.json', sourceJson);
220
+ record('report.json', Buffer.byteLength(sourceJson));
221
+
222
+ if (options.markdown !== false) {
223
+ const markdown = renderMarkdown(result, { ...renderOptions });
224
+ writeFile(outDir, 'report.md', markdown);
225
+ record('report.md', Buffer.byteLength(markdown));
226
+ }
227
+
228
+ // The completeness check: re-read what was actually emitted rather than trusting the
229
+ // plan, and resolve every link against the bundle root.
230
+ const emitted = fs.readFileSync(path.join(outDir, 'index.html'), 'utf8');
231
+ const broken = [];
232
+ for (const reference of referencedPaths(emitted)) {
233
+ const decoded = decodeURI(reference.split('?')[0].split('#')[0]);
234
+ const resolved = path.resolve(outDir, decoded);
235
+ const relative = path.relative(outDir, resolved);
236
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
237
+ broken.push({ reference, reason: 'points outside the bundle' });
238
+ } else if (!fs.existsSync(resolved)) {
239
+ broken.push({ reference, reason: 'file not present in the bundle' });
240
+ }
241
+ }
242
+
243
+ const manifest = {
244
+ ...versionStamp(),
245
+ generatedAt: result?.generatedAt ?? result?.metadata?.generatedAt ?? null,
246
+ mode,
247
+ complete: broken.length === 0,
248
+ brokenReferences: broken,
249
+ missingArtifacts: survey.missing().map((entry) => ({
250
+ id: entry.id,
251
+ declaredPath: entry.declaredPath,
252
+ reason: entry.missingReason,
253
+ })),
254
+ files: written
255
+ .sort((a, b) => (a.path < b.path ? -1 : 1))
256
+ .map((entry) => ({
257
+ ...entry,
258
+ sha256: hashFile(path.join(outDir, entry.path)),
259
+ })),
260
+ totalBytes: written.reduce((sum, entry) => sum + entry.bytes, 0),
261
+ };
262
+
263
+ const manifestJson = `${JSON.stringify(manifest, null, 2)}\n`;
264
+ writeFile(outDir, 'manifest.json', manifestJson);
265
+
266
+ if (broken.length > 0) {
267
+ throw new BundleError(
268
+ `bundle written to ${outDir} but ${broken.length} reference(s) do not resolve inside it: ` +
269
+ broken.slice(0, 3).map((entry) => `${entry.reference} (${entry.reason})`).join('; '),
270
+ );
271
+ }
272
+
273
+ let zipPath = null;
274
+ if (options.zip) {
275
+ zipPath = typeof options.zip === 'string' ? path.resolve(options.zip) : `${outDir}.zip`;
276
+ const stamp = manifest.generatedAt ? new Date(manifest.generatedAt) : new Date(0);
277
+ const entries = [
278
+ ...manifest.files.map((entry) => ({
279
+ name: `${path.basename(outDir)}/${entry.path}`,
280
+ data: fs.readFileSync(path.join(outDir, entry.path)),
281
+ })),
282
+ { name: `${path.basename(outDir)}/manifest.json`, data: manifestJson },
283
+ ];
284
+ fs.writeFileSync(zipPath, createZip(entries, { modifiedAt: stamp }));
285
+ }
286
+
287
+ return {
288
+ outDir,
289
+ zipPath,
290
+ entry: path.join(outDir, 'index.html'),
291
+ ...manifest,
292
+ };
293
+ }
@@ -0,0 +1,183 @@
1
+ // Assembling the HTML document.
2
+ //
3
+ // One file, no external request. The stylesheet, the script, and the attribution are
4
+ // inlined; images are the only thing that stays on disk, and they sit beside the
5
+ // report in the same run folder so the whole directory moves as a unit.
6
+ //
7
+ // The order below is the document's argument, and it is deliberately front-loaded:
8
+ // masthead, verdict, then the numbers. Everything a reader needs to make a decision is
9
+ // above the fold; everything they need to *check* the decision is below it.
10
+
11
+ import { footerHtml } from '../../analysis/branding.mjs';
12
+ import { stylesheet } from '../theme/css.mjs';
13
+ import { runtimeScript } from '../components/runtime.mjs';
14
+ import { sidebar, toolbar, lightbox } from '../components/nav.mjs';
15
+ import { buildModel } from '../core/model.mjs';
16
+ import { buildSections, renderSections, filterSections, MODES } from '../core/sections.mjs';
17
+ import { supportedContracts as normalizerContracts, SchemaError } from '../core/normalize.mjs';
18
+ import { e, formatDuration } from '../components/primitives.mjs';
19
+ import { versionStamp, versionLine } from '../version.mjs';
20
+
21
+ // `name` is set explicitly: without it every one of these serializes and prints as
22
+ // plain "Error", which is exactly the wrong thing to read in a CI log.
23
+ export class ReportError extends Error {
24
+ name = 'ReportError';
25
+ }
26
+
27
+ /**
28
+ * The masthead: what this is, what it is about, and when it was produced.
29
+ *
30
+ * The URL is a link because the first thing a reader does with a QA report is open the
31
+ * thing being reported on. `rel="noopener noreferrer"` because a report is opened from
32
+ * anywhere and a tab that can reach back into `window.opener` is a real hazard.
33
+ */
34
+ function masthead(model) {
35
+ const facts = [
36
+ model.generatedAt ? { label: 'Generated', value: model.generatedAt.slice(0, 10) } : null,
37
+ model.environment ? { label: 'Environment', value: model.environment } : null,
38
+ model.pages.length > 0 ? { label: 'Pages', value: String(model.pages.length) } : null,
39
+ model.testCases?.total ? { label: 'Test cases', value: String(model.testCases.total) } : null,
40
+ { label: 'Findings', value: String(model.totalFindings) },
41
+ model.durationMs !== null ? { label: 'Duration', value: formatDuration(model.durationMs) } : null,
42
+ model.reportVersion ? { label: 'Report', value: `v${model.reportVersion}` } : null,
43
+ ].filter(Boolean);
44
+
45
+ const target = model.url
46
+ ? `<a href="${e(model.url)}" target="_blank" rel="noopener noreferrer">${e(model.url)}</a>`
47
+ : '';
48
+
49
+ const verdict = model.verdict;
50
+ const confidence =
51
+ verdict.confidence !== null
52
+ ? `<span class="verdict-confidence">${Math.round(verdict.confidence * 100)}% confidence</span>`
53
+ : '';
54
+
55
+ return (
56
+ '<header class="masthead">' +
57
+ '<div class="eyebrow">Exploratory QA report</div>' +
58
+ `<h1>${e(model.subject)}</h1>` +
59
+ (target || model.title ? `<p class="subject">${target}</p>` : '') +
60
+ `<dl class="factbar">${facts
61
+ .map((fact) => `<div class="fact"><dt>${e(fact.label)}</dt><dd>${e(fact.value)}</dd></div>`)
62
+ .join('')}</dl>` +
63
+ `<div class="verdict tone-${e(verdict.tone)}">` +
64
+ `<span class="verdict-label"><span class="verdict-dot"></span>${e(verdict.label)}</span>` +
65
+ `<span class="verdict-blurb">${e(verdict.blurb)}</span>` +
66
+ confidence +
67
+ '</div>' +
68
+ '</header>'
69
+ );
70
+ }
71
+
72
+ /** Render the body of a report from a model, filtered to a mode. */
73
+ export function renderBody(model, { mode = 'full' } = {}) {
74
+ // The search-and-filter bar is only meaningful over a list worth filtering, and the
75
+ // executive rendering has no findings list at all.
76
+ const toolbarHtml =
77
+ model.findings.length >= 2 && mode !== 'executive'
78
+ ? toolbar(model.severityCounts, { dimensions: model.dimensions })
79
+ : '';
80
+
81
+ const sections = filterSections(buildSections(model, { toolbarHtml }), mode);
82
+
83
+ return (
84
+ '<a class="skip" href="#findings">Skip to findings</a>' +
85
+ '<div class="shell">' +
86
+ sidebar(sections, {
87
+ productName: model.subject,
88
+ subtitle: model.generatedAt ? model.generatedAt.slice(0, 10) : 'QA report',
89
+ }) +
90
+ '<main class="main" id="main">' +
91
+ masthead(model) +
92
+ renderSections(sections) +
93
+ footerHtml() +
94
+ '</main>' +
95
+ '</div>' +
96
+ lightbox()
97
+ );
98
+ }
99
+
100
+ /**
101
+ * A full standalone HTML document.
102
+ *
103
+ * `resultPath` and `outPath` are what make evidence links work: the first says where
104
+ * declared paths are relative to, the second says what the hrefs must be relative to.
105
+ * Passing neither still renders — every artifact simply reports as missing, which is
106
+ * the honest outcome for a result rendered with no idea where its files are.
107
+ *
108
+ * `mode` selects the audience. `artifact` returns the page *without* the document
109
+ * wrapper, for a host that supplies its own `<head>` — Claude Artifacts, a wiki, a
110
+ * dashboard iframe. It is the same markup and the same stylesheet, so an embedded
111
+ * report and a standalone one are visually identical.
112
+ */
113
+ export function render(result, options = {}) {
114
+ const mode = options.mode ?? 'full';
115
+ if (!(mode in MODES)) {
116
+ throw new ReportError(
117
+ `unknown rendering mode '${mode}'; expected one of: ${Object.keys(MODES).join(', ')}`,
118
+ );
119
+ }
120
+
121
+ let model;
122
+ try {
123
+ model = buildModel(result, options);
124
+ } catch (error) {
125
+ // The normalizer's refusals are report errors from a caller's point of view; the
126
+ // CLI maps ReportError to a clean exit code and a readable message.
127
+ if (error instanceof SchemaError) throw new ReportError(error.message);
128
+ throw error;
129
+ }
130
+
131
+ const heading = options.title ?? `QA report — ${model.subject}`;
132
+ const versions = versionStamp();
133
+
134
+ // `assets` moves the stylesheet and the script into sibling files, for the bundle
135
+ // layout. Everything else about the document is identical, and both remain
136
+ // dependency-free — a local file reference is not an external dependency.
137
+ const assets = options.assets ?? null;
138
+ const styleTag = assets
139
+ ? `<link rel="stylesheet" href="${e(assets.css)}"/>\n`
140
+ : `<style>${stylesheet()}</style>\n`;
141
+ const scriptTag = assets
142
+ ? `<script src="${e(assets.js)}"></script>\n`
143
+ : `<script>${runtimeScript()}</script>\n`;
144
+
145
+ const body = `${renderBody(model, { mode })}\n${scriptTag}`;
146
+
147
+ if (mode === 'artifact') {
148
+ // No <html>/<head>/<body>: the host provides them. The <title> and the styling are
149
+ // still emitted, because both are legal in a body and both are needed for the
150
+ // page to be recognisably the same report.
151
+ return `<title>${e(heading)}</title>\n${styleTag}${body}`;
152
+ }
153
+
154
+ return (
155
+ '<!DOCTYPE html>\n' +
156
+ '<html lang="en">\n<head>\n' +
157
+ '<meta charset="utf-8"/>\n' +
158
+ '<meta name="viewport" content="width=device-width,initial-scale=1"/>\n' +
159
+ '<meta name="color-scheme" content="light dark"/>\n' +
160
+ `<meta name="description" content="${e(String(model.summary).slice(0, 200))}"/>\n` +
161
+ '<meta name="generator" content="qa-engineer"/>\n' +
162
+ `<meta name="qa-schema-version" content="${e(versions.schemaVersion)}"/>\n` +
163
+ `<meta name="qa-theme-version" content="${e(versions.themeName)} v${e(versions.themeVersion)}"/>\n` +
164
+ `<meta name="qa-renderer-version" content="${e(versions.rendererVersion)}"/>\n` +
165
+ `<title>${e(heading)}</title>\n` +
166
+ styleTag +
167
+ '</head>\n<body>\n' +
168
+ body +
169
+ '</body>\n</html>\n'
170
+ );
171
+ }
172
+
173
+ /** Contract names the renderer accepts, from the normalizer registry. */
174
+ export function supportedContracts() {
175
+ return normalizerContracts();
176
+ }
177
+
178
+ /** The rendering modes, for `--help` and for validation. */
179
+ export function supportedModes() {
180
+ return Object.keys(MODES);
181
+ }
182
+
183
+ export { versionStamp, versionLine };