qa-engineer 0.9.2 → 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.
- package/COMPATIBILITY.md +26 -12
- package/README.md +182 -84
- package/package.json +11 -20
- package/packages/engine/bin/qa-engine.mjs +560 -0
- package/packages/engine/lib/analysis/branding.mjs +187 -0
- package/packages/engine/lib/analysis/context.mjs +294 -0
- package/packages/engine/lib/analysis/contracts.mjs +149 -0
- package/packages/engine/lib/analysis/diff-guard.mjs +425 -0
- package/packages/engine/lib/analysis/discovery.mjs +220 -0
- package/packages/engine/lib/analysis/evidence.mjs +116 -0
- package/packages/engine/lib/analysis/har.mjs +124 -0
- package/packages/engine/lib/analysis/junit.mjs +126 -0
- package/packages/engine/lib/analysis/network.mjs +237 -0
- package/packages/engine/lib/analysis/redaction.mjs +127 -0
- package/packages/engine/lib/analysis/report-html.mjs +76 -0
- package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
- package/packages/engine/lib/analysis/xml.mjs +153 -0
- package/packages/engine/lib/analysis/zip.mjs +107 -0
- package/packages/engine/lib/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/diagnostics/engine.mjs +165 -0
- package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
- package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
- package/packages/engine/lib/diagnostics/repair.mjs +73 -0
- package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
- package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
- package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
- package/packages/engine/lib/frameworks/playwright.mjs +158 -0
- package/packages/engine/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 +14 -0
- 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 +13 -20
- package/packages/installer/lib/commands/install.mjs +160 -91
- 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/bundle.mjs +69 -92
- 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/manifest.mjs +48 -57
- 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 +49 -31
- package/packages/installer/package.json +1 -1
- package/packages/installer/schemas/qa-lock.schema.json +119 -21
- package/shared/tooling/qa-tool.mjs +161 -0
- package/skills/qa-api/SKILL.md +5 -5
- package/skills/qa-api/references/deterministic-tooling.md +34 -32
- package/skills/qa-api/references/evidence-and-reporting.md +5 -5
- package/skills/qa-api/scripts/qa-tool.mjs +162 -0
- package/skills/qa-audit/SKILL.md +5 -5
- package/skills/qa-audit/references/deterministic-tooling.md +34 -32
- package/skills/qa-audit/references/evidence-and-reporting.md +5 -5
- package/skills/qa-audit/scripts/qa-tool.mjs +162 -0
- package/skills/qa-debug/SKILL.md +6 -6
- package/skills/qa-debug/references/deterministic-tooling.md +34 -32
- package/skills/qa-debug/references/diagnostic-engine.md +3 -3
- package/skills/qa-debug/references/evidence-and-reporting.md +5 -5
- package/skills/qa-debug/scripts/qa-tool.mjs +162 -0
- package/skills/qa-explore/SKILL.md +31 -15
- 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/deterministic-tooling.md +34 -32
- package/skills/qa-explore/references/evidence-and-reporting.md +5 -5
- package/skills/qa-explore/references/report-pipeline.md +266 -96
- package/skills/qa-explore/scripts/qa-tool.mjs +162 -0
- package/skills/qa-fix/SKILL.md +4 -4
- package/skills/qa-fix/references/deterministic-tooling.md +34 -32
- package/skills/qa-fix/references/diagnostic-engine.md +3 -3
- package/skills/qa-fix/references/evidence-and-reporting.md +5 -5
- package/skills/qa-fix/scripts/qa-tool.mjs +162 -0
- package/skills/qa-flaky/SKILL.md +4 -4
- package/skills/qa-flaky/references/deterministic-tooling.md +34 -32
- package/skills/qa-flaky/references/evidence-and-reporting.md +5 -5
- package/skills/qa-flaky/scripts/qa-tool.mjs +162 -0
- package/skills/qa-generate/references/evidence-and-reporting.md +5 -5
- package/skills/qa-init/SKILL.md +4 -4
- package/skills/qa-init/references/deterministic-tooling.md +34 -32
- package/skills/qa-init/references/evidence-and-reporting.md +5 -5
- package/skills/qa-init/scripts/qa-tool.mjs +162 -0
- package/skills/qa-report/SKILL.md +7 -7
- package/skills/qa-report/references/deterministic-tooling.md +34 -32
- package/skills/qa-report/references/diagnostic-engine.md +3 -3
- package/skills/qa-report/references/evidence-and-reporting.md +5 -5
- package/skills/qa-report/scripts/qa-tool.mjs +162 -0
- package/skills/qa-review/references/evidence-and-reporting.md +5 -5
- package/skills/qa-run/SKILL.md +6 -6
- package/skills/qa-run/references/deterministic-tooling.md +34 -32
- package/skills/qa-run/references/evidence-and-reporting.md +5 -5
- package/skills/qa-run/scripts/qa-tool.mjs +162 -0
- package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
- package/shared/analysis/lib/qa_analysis/branding.py +0 -175
- package/shared/analysis/lib/qa_analysis/cli.py +0 -144
- package/shared/analysis/lib/qa_analysis/context.py +0 -233
- package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
- package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
- package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
- package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
- package/shared/analysis/lib/qa_analysis/har.py +0 -87
- package/shared/analysis/lib/qa_analysis/junit.py +0 -104
- package/shared/analysis/lib/qa_analysis/redaction.py +0 -107
- package/shared/analysis/lib/qa_analysis/report_html.py +0 -781
- package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
- package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
- package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
- package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
- package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
- package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
- package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
- package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
- package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
- package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
- package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
- package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
- package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
- package/shared/tooling/qa_tool.py +0 -127
- /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
- /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/execution-result-min.schema.json +0 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
// The artifact registry: one place that knows where every evidence file actually is.
|
|
2
|
+
//
|
|
3
|
+
// ## Why this is code
|
|
4
|
+
//
|
|
5
|
+
// The first live `/qa-explore` report rendered every screenshot as a broken image.
|
|
6
|
+
// The artifact was on disk, the path in the JSON was correct, and the `<img>` still
|
|
7
|
+
// 404ed — because the renderer emitted the declared path verbatim. The declared path
|
|
8
|
+
// was relative to the *project root* (`qa-artifacts/explore-R/screenshots/a.png`)
|
|
9
|
+
// while the report was written *inside that folder*, so the browser resolved
|
|
10
|
+
// `qa-artifacts/explore-R/qa-artifacts/explore-R/screenshots/a.png`.
|
|
11
|
+
//
|
|
12
|
+
// Two bugs, one cause: nothing owned the difference between "where the file is" and
|
|
13
|
+
// "how this document should link to it". This module owns it.
|
|
14
|
+
//
|
|
15
|
+
// declared path ─┐
|
|
16
|
+
// ├─► resolve on disk ─► verify ─► href relative to the output file
|
|
17
|
+
// output dir ─┘
|
|
18
|
+
//
|
|
19
|
+
// ## What it guarantees
|
|
20
|
+
//
|
|
21
|
+
// - A path is resolved against the result's own directory *and* every ancestor up to
|
|
22
|
+
// the search root, so a project-root-relative path finds its file instead of 404ing.
|
|
23
|
+
// - Every artifact is stat'ed. `exists` is a fact, never an assumption, so the
|
|
24
|
+
// renderer can show "Artifact missing" instead of a broken image icon.
|
|
25
|
+
// - Resolution cannot escape the search root. A `../../../etc/passwd` in an artifact
|
|
26
|
+
// path is refused, because the result JSON is attacker-influenced input the moment
|
|
27
|
+
// a report is forwarded.
|
|
28
|
+
// - Hash, size, and MIME type are computed from the file, not trusted from the JSON.
|
|
29
|
+
// A declared `sha256` that disagrees with the file is reported as a mismatch.
|
|
30
|
+
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import crypto from 'node:crypto';
|
|
34
|
+
|
|
35
|
+
import { mimeFor, kindFor, isInlineImage, isVideo, formatBytes } from './mime.mjs';
|
|
36
|
+
|
|
37
|
+
export class ArtifactError extends Error {
|
|
38
|
+
name = 'ArtifactError';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// How far up from the result directory a declared path may be resolved. Six levels
|
|
42
|
+
// reaches a project root from `<root>/qa-artifacts/explore-<id>/` with room to spare,
|
|
43
|
+
// and stops well short of scanning a whole filesystem.
|
|
44
|
+
const MAX_ASCENT = 6;
|
|
45
|
+
|
|
46
|
+
// Reasons an artifact is not renderable, in the words the report shows the reader.
|
|
47
|
+
const MISSING_REASON = Object.freeze({
|
|
48
|
+
'not-found': 'File not found at the recorded path',
|
|
49
|
+
'not-a-file': 'The recorded path is a directory, not a file',
|
|
50
|
+
'escapes-root': 'The recorded path points outside the report folder and was refused',
|
|
51
|
+
empty: 'File is present but empty',
|
|
52
|
+
unreadable: 'File could not be read',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** True for a reference the report should link to rather than resolve on disk. */
|
|
56
|
+
function isExternal(reference) {
|
|
57
|
+
return /^(https?:|data:|mailto:)/i.test(String(reference ?? ''));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Candidate absolute paths for a declared reference, nearest first.
|
|
62
|
+
*
|
|
63
|
+
* The order is the whole point: the result's own directory is what the contract
|
|
64
|
+
* documents, so a correct producer resolves on the first try. Ancestors follow, which
|
|
65
|
+
* is what rescues a project-root-relative path written by a producer that treated the
|
|
66
|
+
* repository root as the base.
|
|
67
|
+
*/
|
|
68
|
+
function candidatesFor(reference, baseDir) {
|
|
69
|
+
if (path.isAbsolute(reference)) return [path.resolve(reference)];
|
|
70
|
+
const candidates = [];
|
|
71
|
+
let dir = baseDir;
|
|
72
|
+
for (let level = 0; level <= MAX_ASCENT; level += 1) {
|
|
73
|
+
candidates.push(path.resolve(dir, reference));
|
|
74
|
+
const parent = path.dirname(dir);
|
|
75
|
+
if (parent === dir) break;
|
|
76
|
+
dir = parent;
|
|
77
|
+
}
|
|
78
|
+
return candidates;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** True when `child` is inside `root` (or is `root`), after both are resolved. */
|
|
82
|
+
function contains(root, child) {
|
|
83
|
+
const rel = path.relative(root, child);
|
|
84
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function statFile(absolute) {
|
|
88
|
+
try {
|
|
89
|
+
const stat = fs.statSync(absolute);
|
|
90
|
+
if (!stat.isFile()) return { ok: false, reason: 'not-a-file' };
|
|
91
|
+
return { ok: true, bytes: stat.size, mtime: stat.mtime };
|
|
92
|
+
} catch {
|
|
93
|
+
return { ok: false, reason: 'not-found' };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** SHA-256 of a file, streamed in chunks so a large trace does not land in memory. */
|
|
98
|
+
export function hashFile(absolute) {
|
|
99
|
+
const hash = crypto.createHash('sha256');
|
|
100
|
+
const fd = fs.openSync(absolute, 'r');
|
|
101
|
+
try {
|
|
102
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
103
|
+
for (;;) {
|
|
104
|
+
const read = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
105
|
+
if (read <= 0) break;
|
|
106
|
+
hash.update(buffer.subarray(0, read));
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
fs.closeSync(fd);
|
|
110
|
+
}
|
|
111
|
+
return hash.digest('hex');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Turn a declared reference into a resolved artifact record.
|
|
116
|
+
*
|
|
117
|
+
* `declared` carries whatever the producer wrote; everything else is measured. When
|
|
118
|
+
* the file is not found the record still comes back — with `exists: false` and a
|
|
119
|
+
* reason — because a report that silently omits missing evidence is how a reader
|
|
120
|
+
* concludes there was none.
|
|
121
|
+
*/
|
|
122
|
+
function resolveOne(declared, options) {
|
|
123
|
+
const { baseDir, outDir, searchRoot, hash, embed, embedLimit } = options;
|
|
124
|
+
const reference = String(declared.path ?? declared.source ?? '').trim();
|
|
125
|
+
|
|
126
|
+
const record = {
|
|
127
|
+
id: declared.id ?? null,
|
|
128
|
+
kind: declared.kind ?? kindFor(reference) ?? 'file',
|
|
129
|
+
declaredPath: reference,
|
|
130
|
+
label: declared.label ?? declared.caption ?? declared.description ?? null,
|
|
131
|
+
note: declared.note ?? null,
|
|
132
|
+
capturedAt: declared.capturedAt ?? null,
|
|
133
|
+
compares: declared.compares ?? null,
|
|
134
|
+
width: declared.width ?? null,
|
|
135
|
+
height: declared.height ?? null,
|
|
136
|
+
external: false,
|
|
137
|
+
exists: false,
|
|
138
|
+
missingReason: null,
|
|
139
|
+
absolutePath: null,
|
|
140
|
+
href: null,
|
|
141
|
+
thumbnailHref: null,
|
|
142
|
+
dataUri: null,
|
|
143
|
+
embedded: false,
|
|
144
|
+
bytes: declared.bytes ?? null,
|
|
145
|
+
sizeLabel: '',
|
|
146
|
+
mimeType: declared.mimeType ?? mimeFor(reference),
|
|
147
|
+
sha256: null,
|
|
148
|
+
declaredSha256: declared.sha256 ?? null,
|
|
149
|
+
hashMismatch: false,
|
|
150
|
+
registered: Boolean(declared.id),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (!reference) {
|
|
154
|
+
record.missingReason = MISSING_REASON['not-found'];
|
|
155
|
+
return record;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// An http(s) reference is somebody else's file. Link to it, do not claim to have
|
|
159
|
+
// verified it — a report that says "present" about a URL it never fetched is lying.
|
|
160
|
+
if (isExternal(reference)) {
|
|
161
|
+
record.external = true;
|
|
162
|
+
record.exists = true;
|
|
163
|
+
record.href = reference;
|
|
164
|
+
record.mimeType = record.mimeType ?? mimeFor(reference);
|
|
165
|
+
record.renderAs = renderModeFor(record);
|
|
166
|
+
return record;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let found = null;
|
|
170
|
+
for (const candidate of candidatesFor(reference, baseDir)) {
|
|
171
|
+
if (!contains(searchRoot, candidate)) continue;
|
|
172
|
+
const stat = statFile(candidate);
|
|
173
|
+
if (stat.ok) {
|
|
174
|
+
found = { absolute: candidate, ...stat };
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!found) {
|
|
180
|
+
// Distinguish "we refused to look" from "we looked and it was not there": the
|
|
181
|
+
// first is a producer writing a path outside the report, and the reader should
|
|
182
|
+
// be told which.
|
|
183
|
+
const anyInsideRoot = candidatesFor(reference, baseDir).some((c) => contains(searchRoot, c));
|
|
184
|
+
record.missingReason = MISSING_REASON[anyInsideRoot ? 'not-found' : 'escapes-root'];
|
|
185
|
+
record.renderAs = 'missing';
|
|
186
|
+
return record;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
record.exists = true;
|
|
190
|
+
record.absolutePath = found.absolute;
|
|
191
|
+
record.bytes = found.bytes;
|
|
192
|
+
record.sizeLabel = formatBytes(found.bytes);
|
|
193
|
+
record.mimeType = record.mimeType ?? mimeFor(found.absolute);
|
|
194
|
+
record.capturedAt = record.capturedAt ?? found.mtime.toISOString();
|
|
195
|
+
// A bundle copies evidence into its own `assets/` tree, so the href has to point at
|
|
196
|
+
// the copy rather than at wherever the file was found. Supplying the map is how the
|
|
197
|
+
// bundler redirects every link without the renderer knowing a bundle exists.
|
|
198
|
+
record.href = options.hrefMap?.get(found.absolute) ?? hrefFrom(outDir, found.absolute);
|
|
199
|
+
|
|
200
|
+
if (found.bytes === 0) {
|
|
201
|
+
// A zero-byte screenshot renders as a broken image just as reliably as a missing
|
|
202
|
+
// one. It is captured evidence that failed, and it is reported as such.
|
|
203
|
+
record.exists = false;
|
|
204
|
+
record.missingReason = MISSING_REASON.empty;
|
|
205
|
+
record.renderAs = 'missing';
|
|
206
|
+
return record;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (hash) {
|
|
210
|
+
try {
|
|
211
|
+
record.sha256 = hashFile(found.absolute);
|
|
212
|
+
record.hashMismatch = Boolean(
|
|
213
|
+
record.declaredSha256 && record.declaredSha256 !== record.sha256,
|
|
214
|
+
);
|
|
215
|
+
} catch {
|
|
216
|
+
record.missingReason = MISSING_REASON.unreadable;
|
|
217
|
+
record.exists = false;
|
|
218
|
+
record.renderAs = 'missing';
|
|
219
|
+
return record;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (declared.thumbnail) {
|
|
224
|
+
const thumb = resolveOne(
|
|
225
|
+
{ path: declared.thumbnail, kind: 'screenshot' },
|
|
226
|
+
{ ...options, hash: false, embed: false },
|
|
227
|
+
);
|
|
228
|
+
if (thumb.exists) record.thumbnailHref = thumb.href;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Embedding turns the report into one genuinely portable file: an image inlined as a
|
|
232
|
+
// data URI survives being forwarded as a lone attachment, uploaded to a wiki, or
|
|
233
|
+
// pasted into a ticket, none of which carry the sibling folder along. It is opt-in
|
|
234
|
+
// because base64 costs a third more bytes than the file, and a run with forty
|
|
235
|
+
// full-page screenshots produces a document too large to mail.
|
|
236
|
+
if (embed && isInlineImage(record.mimeType) && found.bytes <= embedLimit) {
|
|
237
|
+
try {
|
|
238
|
+
const encoded = fs.readFileSync(found.absolute).toString('base64');
|
|
239
|
+
record.dataUri = `data:${record.mimeType};base64,${encoded}`;
|
|
240
|
+
record.embedded = true;
|
|
241
|
+
} catch {
|
|
242
|
+
// Falls through to the file href, which is a working link on disk.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
record.renderAs = renderModeFor(record);
|
|
247
|
+
return record;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** How the report should present this artifact: inline, playable, or as a link. */
|
|
251
|
+
function renderModeFor(record) {
|
|
252
|
+
if (!record.exists) return 'missing';
|
|
253
|
+
if (isInlineImage(record.mimeType)) return 'image';
|
|
254
|
+
if (isVideo(record.mimeType)) return 'video';
|
|
255
|
+
return 'link';
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A relative, URL-encoded href from the output directory to a file. */
|
|
259
|
+
export function hrefFrom(outDir, absolute) {
|
|
260
|
+
const relative = path.relative(outDir, absolute);
|
|
261
|
+
const posix = relative.split(path.sep).join('/');
|
|
262
|
+
// Encode per segment so slashes survive and spaces, #, and ? do not break the link.
|
|
263
|
+
const encoded = posix
|
|
264
|
+
.split('/')
|
|
265
|
+
.map((segment) => (segment === '..' ? segment : encodeURIComponent(segment)))
|
|
266
|
+
.join('/');
|
|
267
|
+
// A sibling file must not read as a protocol-relative URL or a bare word the
|
|
268
|
+
// browser resolves against the wrong base.
|
|
269
|
+
return posix.startsWith('.') ? encoded : `./${encoded}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Build the registry for one result.
|
|
274
|
+
*
|
|
275
|
+
* `baseDir` is the directory the result JSON lives in — declared paths are relative
|
|
276
|
+
* to it. `outDir` is where the rendered document will be written, which is usually
|
|
277
|
+
* the same directory but must not be assumed to be: `--out ../summary.html` is legal
|
|
278
|
+
* and every href has to survive it.
|
|
279
|
+
*/
|
|
280
|
+
export function createRegistry(result, options = {}) {
|
|
281
|
+
const baseDir = path.resolve(options.baseDir ?? process.cwd());
|
|
282
|
+
const outDir = path.resolve(options.outDir ?? baseDir);
|
|
283
|
+
const searchRoot = path.resolve(
|
|
284
|
+
options.searchRoot ?? ascend(baseDir, MAX_ASCENT),
|
|
285
|
+
);
|
|
286
|
+
const hash = options.hash !== false;
|
|
287
|
+
// 2 MB per image: comfortably above a full-page PNG screenshot, below the point where
|
|
288
|
+
// one oversized capture makes the whole report undeliverable.
|
|
289
|
+
const resolveOptions = {
|
|
290
|
+
baseDir,
|
|
291
|
+
outDir,
|
|
292
|
+
searchRoot,
|
|
293
|
+
hash,
|
|
294
|
+
embed: options.embed === true,
|
|
295
|
+
embedLimit: Number.isFinite(options.embedLimit) ? options.embedLimit : 2 * 1024 * 1024,
|
|
296
|
+
hrefMap: options.hrefMap ?? null,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const byId = new Map();
|
|
300
|
+
const byPath = new Map();
|
|
301
|
+
const all = [];
|
|
302
|
+
|
|
303
|
+
const register = (declared) => {
|
|
304
|
+
const record = resolveOne(declared, resolveOptions);
|
|
305
|
+
all.push(record);
|
|
306
|
+
if (record.id) byId.set(record.id, record);
|
|
307
|
+
if (record.declaredPath) byPath.set(record.declaredPath, record);
|
|
308
|
+
return record;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
for (const declared of result?.artifacts ?? []) {
|
|
312
|
+
if (declared && typeof declared === 'object') register(declared);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Resolve an evidence entry to an artifact record.
|
|
317
|
+
*
|
|
318
|
+
* `artifactId` is the contract's preferred link and is honored first. A `source`
|
|
319
|
+
* that matches a registered path reuses that record rather than stat-ing the file
|
|
320
|
+
* twice. Anything else is resolved on the spot, so a 1.0 producer that never wrote
|
|
321
|
+
* an `artifacts[]` block still gets working images and honest missing markers.
|
|
322
|
+
*/
|
|
323
|
+
const forEvidence = (entry) => {
|
|
324
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
325
|
+
if (entry.artifactId) {
|
|
326
|
+
const known = byId.get(entry.artifactId);
|
|
327
|
+
if (known) return known;
|
|
328
|
+
// A dangling id is a producer error worth surfacing, not worth crashing on.
|
|
329
|
+
const stub = {
|
|
330
|
+
...resolveOne({ path: '', kind: entry.type }, resolveOptions),
|
|
331
|
+
id: entry.artifactId,
|
|
332
|
+
missingReason: `No artifact registered with id "${entry.artifactId}"`,
|
|
333
|
+
renderAs: 'missing',
|
|
334
|
+
label: entry.caption ?? entry.description ?? null,
|
|
335
|
+
};
|
|
336
|
+
all.push(stub);
|
|
337
|
+
return stub;
|
|
338
|
+
}
|
|
339
|
+
const source = String(entry.source ?? '').trim();
|
|
340
|
+
if (!source) return null;
|
|
341
|
+
const known = byPath.get(source);
|
|
342
|
+
if (known) return known;
|
|
343
|
+
return register({
|
|
344
|
+
path: source,
|
|
345
|
+
kind: entry.type,
|
|
346
|
+
label: entry.caption ?? entry.description ?? null,
|
|
347
|
+
});
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
baseDir,
|
|
352
|
+
outDir,
|
|
353
|
+
searchRoot,
|
|
354
|
+
get: (id) => byId.get(id) ?? null,
|
|
355
|
+
forEvidence,
|
|
356
|
+
list: () => [...all],
|
|
357
|
+
registered: () => all.filter((record) => record.registered),
|
|
358
|
+
missing: () => all.filter((record) => !record.exists),
|
|
359
|
+
stats() {
|
|
360
|
+
const present = all.filter((record) => record.exists);
|
|
361
|
+
return {
|
|
362
|
+
total: all.length,
|
|
363
|
+
present: present.length,
|
|
364
|
+
missing: all.length - present.length,
|
|
365
|
+
bytes: present.reduce((sum, record) => sum + (record.bytes ?? 0), 0),
|
|
366
|
+
hashMismatches: all.filter((record) => record.hashMismatch).length,
|
|
367
|
+
byKind: countBy(all, (record) => record.kind),
|
|
368
|
+
};
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function ascend(dir, levels) {
|
|
374
|
+
let current = dir;
|
|
375
|
+
for (let level = 0; level < levels; level += 1) {
|
|
376
|
+
const parent = path.dirname(current);
|
|
377
|
+
if (parent === current) break;
|
|
378
|
+
current = parent;
|
|
379
|
+
}
|
|
380
|
+
return current;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function countBy(items, key) {
|
|
384
|
+
const counts = {};
|
|
385
|
+
for (const item of items) {
|
|
386
|
+
const bucket = key(item);
|
|
387
|
+
counts[bucket] = (counts[bucket] ?? 0) + 1;
|
|
388
|
+
}
|
|
389
|
+
return counts;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Describe a directory's files as artifact records, for a producer that captured
|
|
394
|
+
* evidence but did not register it.
|
|
395
|
+
*
|
|
396
|
+
* This is the `artifacts verify` CLI's input and the honest answer to "did the run
|
|
397
|
+
* write what it said it wrote": it reads the directory rather than the JSON.
|
|
398
|
+
*/
|
|
399
|
+
export function scan(directory, { root = directory } = {}) {
|
|
400
|
+
const base = path.resolve(directory);
|
|
401
|
+
if (!fs.existsSync(base)) throw new ArtifactError(`no such directory: ${directory}`);
|
|
402
|
+
const found = [];
|
|
403
|
+
const walk = (dir) => {
|
|
404
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
405
|
+
const absolute = path.join(dir, entry.name);
|
|
406
|
+
if (entry.isDirectory()) {
|
|
407
|
+
walk(absolute);
|
|
408
|
+
} else if (entry.isFile()) {
|
|
409
|
+
const stat = fs.statSync(absolute);
|
|
410
|
+
found.push({
|
|
411
|
+
path: path.relative(path.resolve(root), absolute).split(path.sep).join('/'),
|
|
412
|
+
kind: kindFor(absolute) ?? 'file',
|
|
413
|
+
mimeType: mimeFor(absolute),
|
|
414
|
+
bytes: stat.size,
|
|
415
|
+
sizeLabel: formatBytes(stat.size),
|
|
416
|
+
capturedAt: stat.mtime.toISOString(),
|
|
417
|
+
empty: stat.size === 0,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
walk(base);
|
|
423
|
+
return found;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Check a result's artifacts against the filesystem.
|
|
428
|
+
*
|
|
429
|
+
* Returns the shape the CLI prints and a skill reads before it declares a run
|
|
430
|
+
* complete: `ok` is false when anything a finding points at is not there.
|
|
431
|
+
*/
|
|
432
|
+
export function verify(result, options = {}) {
|
|
433
|
+
const registry = createRegistry(result, options);
|
|
434
|
+
const records = registry.list();
|
|
435
|
+
const missing = registry.missing();
|
|
436
|
+
const mismatches = records.filter((record) => record.hashMismatch);
|
|
437
|
+
return {
|
|
438
|
+
ok: missing.length === 0 && mismatches.length === 0,
|
|
439
|
+
stats: registry.stats(),
|
|
440
|
+
missing: missing.map((record) => ({
|
|
441
|
+
id: record.id,
|
|
442
|
+
kind: record.kind,
|
|
443
|
+
declaredPath: record.declaredPath,
|
|
444
|
+
reason: record.missingReason,
|
|
445
|
+
})),
|
|
446
|
+
hashMismatches: mismatches.map((record) => ({
|
|
447
|
+
id: record.id,
|
|
448
|
+
declaredPath: record.declaredPath,
|
|
449
|
+
declared: record.declaredSha256,
|
|
450
|
+
actual: record.sha256,
|
|
451
|
+
})),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// File-type facts for artifacts: what a file is, and how a report may show it.
|
|
2
|
+
//
|
|
3
|
+
// Kept as a table rather than a dependency because the set is small, closed, and
|
|
4
|
+
// the report must render offline from a bundled skill. An unknown extension is
|
|
5
|
+
// reported as unknown rather than guessed — a report that labels a `.trace` file
|
|
6
|
+
// "image/png" and then renders a broken `<img>` is the failure this module exists
|
|
7
|
+
// to prevent.
|
|
8
|
+
|
|
9
|
+
const BY_EXTENSION = Object.freeze({
|
|
10
|
+
// Images — the only kinds that may be rendered inline as <img>.
|
|
11
|
+
'.png': 'image/png',
|
|
12
|
+
'.jpg': 'image/jpeg',
|
|
13
|
+
'.jpeg': 'image/jpeg',
|
|
14
|
+
'.webp': 'image/webp',
|
|
15
|
+
'.gif': 'image/gif',
|
|
16
|
+
'.avif': 'image/avif',
|
|
17
|
+
'.svg': 'image/svg+xml',
|
|
18
|
+
// Video — rendered as <video>, never as <img>.
|
|
19
|
+
'.webm': 'video/webm',
|
|
20
|
+
'.mp4': 'video/mp4',
|
|
21
|
+
'.ogv': 'video/ogg',
|
|
22
|
+
// Structured evidence.
|
|
23
|
+
'.json': 'application/json',
|
|
24
|
+
'.har': 'application/json',
|
|
25
|
+
'.xml': 'application/xml',
|
|
26
|
+
'.csv': 'text/csv',
|
|
27
|
+
'.sarif': 'application/json',
|
|
28
|
+
// Text evidence.
|
|
29
|
+
'.txt': 'text/plain',
|
|
30
|
+
'.log': 'text/plain',
|
|
31
|
+
'.md': 'text/markdown',
|
|
32
|
+
'.html': 'text/html',
|
|
33
|
+
'.htm': 'text/html',
|
|
34
|
+
'.diff': 'text/x-diff',
|
|
35
|
+
'.patch': 'text/x-diff',
|
|
36
|
+
// Archives — Playwright traces are zips.
|
|
37
|
+
'.zip': 'application/zip',
|
|
38
|
+
'.gz': 'application/gzip',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// An SVG is an image the browser will render, and also a document that can carry
|
|
42
|
+
// script. A report embeds artifacts it did not author, so SVG is shown as a link
|
|
43
|
+
// rather than inlined into the page.
|
|
44
|
+
const INLINE_IMAGE = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/avif']);
|
|
45
|
+
|
|
46
|
+
const VIDEO = new Set(['video/webm', 'video/mp4', 'video/ogg']);
|
|
47
|
+
|
|
48
|
+
// When the declared kind is absent, the extension decides. Ordered most specific
|
|
49
|
+
// first: `.har` is JSON, but it is a HAR before it is a document.
|
|
50
|
+
const KIND_BY_EXTENSION = Object.freeze({
|
|
51
|
+
'.har': 'har',
|
|
52
|
+
'.zip': 'trace',
|
|
53
|
+
'.webm': 'video',
|
|
54
|
+
'.mp4': 'video',
|
|
55
|
+
'.ogv': 'video',
|
|
56
|
+
'.png': 'screenshot',
|
|
57
|
+
'.jpg': 'screenshot',
|
|
58
|
+
'.jpeg': 'screenshot',
|
|
59
|
+
'.webp': 'screenshot',
|
|
60
|
+
'.gif': 'screenshot',
|
|
61
|
+
'.avif': 'screenshot',
|
|
62
|
+
'.html': 'dom',
|
|
63
|
+
'.htm': 'dom',
|
|
64
|
+
'.log': 'log',
|
|
65
|
+
'.diff': 'diff',
|
|
66
|
+
'.patch': 'diff',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** The lower-cased extension of a path, including the dot, or '' when there is none. */
|
|
70
|
+
export function extensionOf(filePath) {
|
|
71
|
+
const name = String(filePath ?? '').split(/[\\/]/).pop() ?? '';
|
|
72
|
+
const dot = name.lastIndexOf('.');
|
|
73
|
+
return dot > 0 ? name.slice(dot).toLowerCase() : '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The MIME type for a path, or null when the extension is not one we know. */
|
|
77
|
+
export function mimeFor(filePath) {
|
|
78
|
+
return BY_EXTENSION[extensionOf(filePath)] ?? null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The artifact kind a path implies, or null. Used only when `kind` is absent. */
|
|
82
|
+
export function kindFor(filePath) {
|
|
83
|
+
return KIND_BY_EXTENSION[extensionOf(filePath)] ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** True when the report may render this MIME type inline with `<img>`. */
|
|
87
|
+
export function isInlineImage(mimeType) {
|
|
88
|
+
return INLINE_IMAGE.has(String(mimeType ?? ''));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** True when the report may render this MIME type with `<video>`. */
|
|
92
|
+
export function isVideo(mimeType) {
|
|
93
|
+
return VIDEO.has(String(mimeType ?? ''));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Bytes as a short human string: 812 B, 44.1 KB, 3.2 MB. */
|
|
97
|
+
export function formatBytes(bytes) {
|
|
98
|
+
const value = Number(bytes);
|
|
99
|
+
if (!Number.isFinite(value) || value < 0) return '';
|
|
100
|
+
if (value < 1024) return `${value} B`;
|
|
101
|
+
const units = ['KB', 'MB', 'GB'];
|
|
102
|
+
let scaled = value / 1024;
|
|
103
|
+
let unit = 0;
|
|
104
|
+
while (scaled >= 1024 && unit < units.length - 1) {
|
|
105
|
+
scaled /= 1024;
|
|
106
|
+
unit += 1;
|
|
107
|
+
}
|
|
108
|
+
return `${scaled >= 10 ? Math.round(scaled) : scaled.toFixed(1)} ${units[unit]}`;
|
|
109
|
+
}
|