agent-device-lab 0.0.0-stage → 0.3.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/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +142 -2
- package/assets/dashboard/app.css +238 -0
- package/assets/dashboard/app.js +976 -0
- package/assets/dashboard/index.html +120 -0
- package/bin/agentlab.js +8 -0
- package/dist/cli/ci.js +443 -0
- package/dist/cli/flow.js +127 -0
- package/dist/cli/main.js +855 -0
- package/dist/cli/signals.js +38 -0
- package/dist/core/action-log.js +125 -0
- package/dist/core/auth.js +175 -0
- package/dist/core/bundle.js +432 -0
- package/dist/core/checks.js +137 -0
- package/dist/core/ci.js +389 -0
- package/dist/core/commands.js +366 -0
- package/dist/core/control.js +217 -0
- package/dist/core/detectors.js +392 -0
- package/dist/core/devices.js +83 -0
- package/dist/core/doctor.js +203 -0
- package/dist/core/explore-safety.js +110 -0
- package/dist/core/extract.js +1445 -0
- package/dist/core/feed.js +493 -0
- package/dist/core/findings.js +179 -0
- package/dist/core/format.js +311 -0
- package/dist/core/init.js +607 -0
- package/dist/core/lab.js +1979 -0
- package/dist/core/migrate.js +35 -0
- package/dist/core/observation.js +161 -0
- package/dist/core/ownership.js +142 -0
- package/dist/core/process-group.js +206 -0
- package/dist/core/process-identity.js +56 -0
- package/dist/core/profile.js +816 -0
- package/dist/core/project-runner.js +359 -0
- package/dist/core/replay.js +303 -0
- package/dist/core/reproduction.js +36 -0
- package/dist/core/scan-policy.js +156 -0
- package/dist/core/scan-report.js +145 -0
- package/dist/core/scan.js +455 -0
- package/dist/core/schema.js +39 -0
- package/dist/core/services.js +134 -0
- package/dist/core/steps.js +80 -0
- package/dist/core/sweep.js +138 -0
- package/dist/core/trace-sanitize.js +641 -0
- package/dist/core/url-redact.js +25 -0
- package/dist/core/versions.js +29 -0
- package/dist/core/zip.js +264 -0
- package/dist/daemon/main.js +160 -0
- package/dist/daemon/state.js +93 -0
- package/dist/dashboard/open.js +25 -0
- package/dist/dashboard/server.js +366 -0
- package/dist/mcp/server.js +89 -0
- package/package.json +58 -4
package/dist/core/ci.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { isAbsolute, join } from 'node:path';
|
|
2
|
+
import { getDevice } from './devices.js';
|
|
3
|
+
import { esc, CSS, groupHtml, findingsTable } from './scan-report.js';
|
|
4
|
+
import { evaluatePolicy, groupFindings, matchSuppressions } from './scan-policy.js';
|
|
5
|
+
import { LabError } from './schema.js';
|
|
6
|
+
import { CONTRACT_VERSIONS, productVersion } from './versions.js';
|
|
7
|
+
// Pure parts of CI mode (`agentlab test`, `sweep --project`, `scenario --project`, `report`): resolving what to
|
|
8
|
+
// run from the profile's `ci` section and the flags, the pass/fail policy, the result document and its text,
|
|
9
|
+
// JUnit and HTML renderings. Everything here is browser-free and deterministic in its input, so `agentlab
|
|
10
|
+
// report` reproduces the files of a run from ci-result.json alone. The orchestration is in src/cli/ci.ts.
|
|
11
|
+
export const CI_SCHEMA = 'agentlab.ci-result';
|
|
12
|
+
export const CI_FORMATS = ['text', 'json', 'html', 'junit'];
|
|
13
|
+
/** Exit codes: the process exits with `verdict.exitCode` (signals: 130, 143, 129 by the signal handler). */
|
|
14
|
+
export const EXIT = { pass: 0, failed: 1, couldNotRun: 2, timedOut: 3 };
|
|
15
|
+
export const DEFAULT_TIMEOUT_MS = 30 * 60_000;
|
|
16
|
+
const list = (v) => v === undefined ? undefined : v.split(',').map((x) => x.trim()).filter(Boolean);
|
|
17
|
+
/** "90s", "15m", "2h", "1500ms" or a bare number of seconds → milliseconds; undefined when malformed. */
|
|
18
|
+
export function parseDuration(text) {
|
|
19
|
+
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(text.trim());
|
|
20
|
+
if (!m)
|
|
21
|
+
return undefined;
|
|
22
|
+
const n = Number(m[1]);
|
|
23
|
+
const ms = n * { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[m[2] ?? 's'];
|
|
24
|
+
return ms >= 1 ? Math.round(ms) : undefined;
|
|
25
|
+
}
|
|
26
|
+
export function formatDuration(ms) {
|
|
27
|
+
if (ms < 1000)
|
|
28
|
+
return `${ms}ms`;
|
|
29
|
+
const s = ms / 1000;
|
|
30
|
+
if (s < 60)
|
|
31
|
+
return `${Number.isInteger(s) ? s : s.toFixed(1)}s`;
|
|
32
|
+
const m = Math.floor(s / 60);
|
|
33
|
+
return `${m}m${Math.round(s % 60)}s`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* What to run and how to decide, from flags over the profile's `ci` section over the defaults. Returns every
|
|
37
|
+
* problem found (unknown scenarios and devices, malformed options), never just the first.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveCi(profile, input, env = process.env) {
|
|
40
|
+
const problems = [];
|
|
41
|
+
const ci = profile.ci;
|
|
42
|
+
const declared = profile.scan.scenarios.map((s) => s.name);
|
|
43
|
+
const oneOf = (name, value, fallback, allowed, last) => {
|
|
44
|
+
if (value === undefined)
|
|
45
|
+
return fallback ?? last;
|
|
46
|
+
if (!allowed.includes(value)) {
|
|
47
|
+
problems.push(`--${name} must be one of ${allowed.join(', ')}`);
|
|
48
|
+
return fallback ?? last;
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
};
|
|
52
|
+
const flows = input.command === 'test' ? list(input.flows) ?? ci.flows ?? [] : [];
|
|
53
|
+
const flagScenarios = list(input.scenarios);
|
|
54
|
+
let scenarios = [];
|
|
55
|
+
if (input.command === 'scenario') {
|
|
56
|
+
const named = input.positionals.length ? input.positionals : flagScenarios;
|
|
57
|
+
scenarios = named ?? (Array.isArray(ci.scenarios) ? ci.scenarios : declared);
|
|
58
|
+
if (!declared.length)
|
|
59
|
+
problems.push('the profile declares no scan.scenarios to run');
|
|
60
|
+
}
|
|
61
|
+
else if (input.command === 'test') {
|
|
62
|
+
const chosen = flagScenarios ?? ci.scenarios ?? (declared.length ? 'all' : undefined);
|
|
63
|
+
scenarios = chosen === 'all' ? declared : chosen ?? [];
|
|
64
|
+
}
|
|
65
|
+
for (const name of scenarios) {
|
|
66
|
+
if (!declared.includes(name))
|
|
67
|
+
problems.push(`unknown scenario "${name}"${declared.length ? ` (declared: ${declared.join(', ')})` : ' (the profile declares none)'}`);
|
|
68
|
+
}
|
|
69
|
+
let routes = [];
|
|
70
|
+
if (input.command === 'sweep')
|
|
71
|
+
routes = input.positionals.length ? input.positionals : list(input.routes) ?? ci.routes ?? [profile.startPath];
|
|
72
|
+
else if (input.command === 'test')
|
|
73
|
+
routes = list(input.routes) ?? ci.routes ?? (!flows.length && !scenarios.length ? [profile.startPath] : []);
|
|
74
|
+
for (const r of routes)
|
|
75
|
+
if (!r.startsWith('/'))
|
|
76
|
+
problems.push(`route "${r}" must start with "/"`);
|
|
77
|
+
const devices = list(input.devices) ?? ci.devices;
|
|
78
|
+
const sweepDevices = devices ?? profile.scan.devices;
|
|
79
|
+
for (const d of new Set([...(devices ?? []), ...(routes.length ? sweepDevices : [])])) {
|
|
80
|
+
try {
|
|
81
|
+
getDevice(d, profile.devices);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
problems.push(LabError.from(err).message);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
let timeoutMs = ci.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
88
|
+
if (input.timeout !== undefined) {
|
|
89
|
+
const t = parseDuration(input.timeout);
|
|
90
|
+
if (t === undefined)
|
|
91
|
+
problems.push('--timeout must be a duration such as 90s, 15m or 1h');
|
|
92
|
+
else
|
|
93
|
+
timeoutMs = t;
|
|
94
|
+
}
|
|
95
|
+
const formats = [];
|
|
96
|
+
for (const f of list(input.format) ?? [...CI_FORMATS]) {
|
|
97
|
+
if (!CI_FORMATS.includes(f))
|
|
98
|
+
problems.push(`--format takes ${CI_FORMATS.join(', ')}, not "${f}"`);
|
|
99
|
+
else if (!formats.includes(f))
|
|
100
|
+
formats.push(f);
|
|
101
|
+
}
|
|
102
|
+
const failOnHeuristic = input.failOnHeuristic ?? ci.failOnHeuristic ?? profile.scan.policy.failOnHeuristic;
|
|
103
|
+
const selections = {
|
|
104
|
+
flows, routes, scenarios, ...(devices ? { devices } : {}), sweepDevices,
|
|
105
|
+
failOn: oneOf('fail-on', input.failOn, ci.failOn ?? profile.scan.policy.failOn, ['high', 'medium', 'low', 'none'], 'high'),
|
|
106
|
+
failOnHeuristic,
|
|
107
|
+
scenarioErrors: oneOf('scenario-errors', input.scenarioErrors, ci.scenarioErrors, ['fail', 'report'], 'fail'),
|
|
108
|
+
trace: oneOf('trace', input.trace, ci.trace, ['off', 'on-failure', 'always'], 'off'),
|
|
109
|
+
evidence: oneOf('evidence', input.evidence, ci.evidence, ['off', 'on-failure', 'always'], 'on-failure'),
|
|
110
|
+
timeoutMs,
|
|
111
|
+
auth: oneOf('auth', input.auth, ci.auth ?? (env.AGENTLAB_AUTH_STATE ? 'env' : 'fresh'), ['fresh', 'saved', 'env'], 'fresh'),
|
|
112
|
+
formats,
|
|
113
|
+
out: input.out ?? ci.out ?? './agentlab-results',
|
|
114
|
+
};
|
|
115
|
+
return { selections, problems };
|
|
116
|
+
}
|
|
117
|
+
const RANK = { high: 3, medium: 2, low: 1 };
|
|
118
|
+
/** Unsuppressed findings that fail the policy: confirmed (or any with failOnHeuristic) at or above `failOn`. */
|
|
119
|
+
export function failingFindings(findings, p) {
|
|
120
|
+
if (p.failOn === 'none')
|
|
121
|
+
return [];
|
|
122
|
+
const min = RANK[p.failOn];
|
|
123
|
+
return findings.filter((f) => !f.suppressed && (p.failOnHeuristic || f.confidence === 'confirmed') && RANK[f.severity] >= min);
|
|
124
|
+
}
|
|
125
|
+
/** The project's scan suppressions applied to every finding of the run (sweeps and scans alike), on copies. */
|
|
126
|
+
export function applySuppressions(findings, profile, ctx) {
|
|
127
|
+
const copies = structuredClone(findings);
|
|
128
|
+
const { statuses, applied } = matchSuppressions(copies, profile.scan.suppressions, { today: ctx.today ?? new Date().toISOString().slice(0, 10), scenarios: ctx.scenarios, devices: ctx.devices });
|
|
129
|
+
for (const f of copies) {
|
|
130
|
+
const a = applied.get(f.id);
|
|
131
|
+
if (a)
|
|
132
|
+
f.suppressed = a;
|
|
133
|
+
}
|
|
134
|
+
return { findings: copies, statuses };
|
|
135
|
+
}
|
|
136
|
+
/** Every finding the sweeps and the scan reported, once each, in first-seen order. */
|
|
137
|
+
export function collectFindings(sweeps, scan) {
|
|
138
|
+
const byId = new Map();
|
|
139
|
+
for (const f of [...sweeps.flatMap((s) => s.findings), ...(scan?.findings ?? [])])
|
|
140
|
+
if (!byId.has(f.id))
|
|
141
|
+
byId.set(f.id, f);
|
|
142
|
+
return [...byId.values()];
|
|
143
|
+
}
|
|
144
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
145
|
+
export function evaluateCi(input) {
|
|
146
|
+
const { flows, sweeps, scanRuns, findings, groups, sel } = input;
|
|
147
|
+
const failing = failingFindings(findings, sel);
|
|
148
|
+
const failingIds = failing.map((f) => f.id);
|
|
149
|
+
const reasons = [];
|
|
150
|
+
const failedFlows = flows.filter((f) => !f.passed);
|
|
151
|
+
for (const f of failedFlows)
|
|
152
|
+
reasons.push(`flow "${f.name}" failed${f.failedStep ? ` at step ${f.failedStep.index} (${f.failedStep.label}): ${f.failedStep.text}` : ''}`);
|
|
153
|
+
// The scan's own policy text says what was and was not counted; a flow or a sweep adds its own facts.
|
|
154
|
+
const policy = evaluatePolicy(findings, scanRuns, { failOn: sel.failOn, failOnErrors: sel.scenarioErrors === 'fail', failOnHeuristic: sel.failOnHeuristic });
|
|
155
|
+
const idle = 'no findings and no failed scenario runs';
|
|
156
|
+
for (const r of policy.reasons)
|
|
157
|
+
if (r !== idle)
|
|
158
|
+
reasons.push(r);
|
|
159
|
+
const failedWidths = sweeps.flatMap((s) => s.devices.filter((d) => d.status === 'error').map((d) => `${s.route} at ${d.device}`));
|
|
160
|
+
if (failedWidths.length) {
|
|
161
|
+
reasons.push(`${plural(failedWidths.length, 'sweep width', 'sweep widths')} could not be measured${sel.scenarioErrors === 'fail' ? '' : ' but scenarioErrors is report'}: ${failedWidths.join(', ')}`);
|
|
162
|
+
}
|
|
163
|
+
const failed = failedFlows.length > 0 || policy.result === 'fail' || (sel.scenarioErrors === 'fail' && failedWidths.length > 0);
|
|
164
|
+
if (!reasons.length) {
|
|
165
|
+
const runs = sweeps.reduce((n, s) => n + s.devices.length, 0) + scanRuns.length;
|
|
166
|
+
reasons.push(`no findings, no failed flows and no failed runs (${plural(flows.length, 'flow', 'flows')}, ${plural(runs, 'sweep or scenario run', 'sweep and scenario runs')})`);
|
|
167
|
+
}
|
|
168
|
+
const failingGroups = groups.filter((g) => g.findings.some((id) => failingIds.includes(id))).map((g) => g.id);
|
|
169
|
+
return { result: failed ? 'fail' : 'pass', exitCode: failed ? EXIT.failed : EXIT.pass, reasons, failingFindings: failingIds, failingGroups };
|
|
170
|
+
}
|
|
171
|
+
/** Group findings for the result: the shared fingerprint grouping of scans. */
|
|
172
|
+
export const groupForCi = (findings) => groupFindings(findings);
|
|
173
|
+
/** Paths of the evidence frames the findings point at (absolute, as the Lab wrote them). */
|
|
174
|
+
export function frameRefs(findings) {
|
|
175
|
+
return [...new Set(findings.flatMap((f) => [...(f.frame ? [f.frame] : []), ...(f.frames ?? []).map((x) => x.path)]))];
|
|
176
|
+
}
|
|
177
|
+
/** Findings with frame paths replaced by `map` (source → relative path); a frame without an entry is dropped. */
|
|
178
|
+
export function withFrames(findings, map) {
|
|
179
|
+
return findings.map((f) => {
|
|
180
|
+
const { frame, frames, ...rest } = structuredClone(f);
|
|
181
|
+
const own = frame ? map.get(frame) : undefined;
|
|
182
|
+
const more = (frames ?? []).flatMap((x) => { const p = map.get(x.path); return p ? [{ ...x, path: p }] : []; });
|
|
183
|
+
return { ...rest, ...(own ? { frame: own } : {}), ...(more.length ? { frames: more } : {}) };
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// ---------- text ----------
|
|
187
|
+
const verdictWord = (v) => v.result === 'pass' ? 'PASS' : v.result === 'fail' ? 'FAIL' : 'ERROR';
|
|
188
|
+
export function formatCiText(r) {
|
|
189
|
+
const v = r.verdict;
|
|
190
|
+
const out = [];
|
|
191
|
+
out.push(`agentlab ${r.command}: ${verdictWord(v)} (exit ${v.exitCode}) ${r.project.name} ${formatDuration(r.ms)}`);
|
|
192
|
+
out.push('reasons:', ...v.reasons.map((x) => ` - ${x}`));
|
|
193
|
+
const sel = r.selections;
|
|
194
|
+
out.push(`policy: failOn=${sel.failOn}${sel.failOnHeuristic ? ' (heuristics count)' : ''}, scenarioErrors=${sel.scenarioErrors}; auth ${r.environment.auth}`);
|
|
195
|
+
if (r.flows.length) {
|
|
196
|
+
out.push(`flows: ${r.flows.length}, ${r.flows.filter((f) => !f.passed).length} failed`);
|
|
197
|
+
for (const f of r.flows)
|
|
198
|
+
out.push(f.passed ? ` PASS ${f.name} (${f.completed}/${f.steps} steps)` : ` FAIL ${f.name}${f.failedStep ? `: step ${f.failedStep.index} "${f.failedStep.label}": ${f.failedStep.text}` : ''}`);
|
|
199
|
+
}
|
|
200
|
+
if (r.sweeps.length) {
|
|
201
|
+
out.push(`sweeps: ${r.sweeps.length} route${r.sweeps.length === 1 ? '' : 's'}`);
|
|
202
|
+
for (const s of r.sweeps) {
|
|
203
|
+
const ok = s.devices.filter((d) => d.status === 'ok').length;
|
|
204
|
+
const ids = new Set(s.devices.flatMap((d) => d.findings));
|
|
205
|
+
out.push(` ${s.route}: ${ok}/${s.devices.length} widths measured, ${plural(ids.size, 'finding', 'findings')}${s.devices.filter((d) => d.status === 'error').map((d) => `; ${d.device} failed: ${d.error ?? 'error'}`).join('')}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (r.scan) {
|
|
209
|
+
const failedRuns = r.scan.runs.filter((x) => x.status === 'failed');
|
|
210
|
+
out.push(`scenarios: ${r.scan.runs.length} runs, ${failedRuns.length} failed`);
|
|
211
|
+
for (const x of failedRuns)
|
|
212
|
+
out.push(` FAIL "${x.scenario}" @ ${x.device}${x.failedAt ? ` (${x.failedAt})` : ''}${'error' in x && x.error ? `: ${x.error}` : ''}`);
|
|
213
|
+
}
|
|
214
|
+
const live = r.groups.filter((g) => !g.suppressed);
|
|
215
|
+
const counts = `${live.filter((g) => g.confidence === 'confirmed').length} confirmed, ${live.filter((g) => g.confidence === 'heuristic').length} heuristic, ${r.groups.length - live.length} suppressed`;
|
|
216
|
+
out.push(`findings: ${plural(r.groups.length, 'group', 'groups')} (${counts})`);
|
|
217
|
+
const failing = r.groups.filter((g) => v.failingGroups.includes(g.id));
|
|
218
|
+
if (failing.length) {
|
|
219
|
+
out.push('failing groups:');
|
|
220
|
+
for (const g of failing.slice(0, 10))
|
|
221
|
+
out.push(` ${g.id} [${g.severity}, ${g.confidence}] ${g.title} devices: ${g.devices.join(', ')} findings: ${g.findings.join(', ')}`);
|
|
222
|
+
if (failing.length > 10)
|
|
223
|
+
out.push(` … and ${failing.length - 10} more (see report.html)`);
|
|
224
|
+
}
|
|
225
|
+
const unused = r.suppressions.filter((s) => s.status !== 'applied');
|
|
226
|
+
if (unused.length)
|
|
227
|
+
out.push(`suppressions: ${unused.map((s) => `rule ${s.rule} ${s.status}`).join(', ')}`);
|
|
228
|
+
for (const t of r.traces)
|
|
229
|
+
out.push(t.file ? `trace: ${t.name} → ${t.file}` : `trace: ${t.name} dropped (${t.dropped})`);
|
|
230
|
+
for (const b of r.bundles)
|
|
231
|
+
out.push(`bundle: ${b.for} → ${b.dir}${b.trace === 'included' ? ' (with trace)' : ''}`);
|
|
232
|
+
if (r.artifacts.length)
|
|
233
|
+
out.push(`artifacts: ${r.artifacts.map((a) => a.path).join(', ')}`);
|
|
234
|
+
return out.join('\n') + '\n';
|
|
235
|
+
}
|
|
236
|
+
// ---------- JUnit ----------
|
|
237
|
+
/** Escape for XML 1.0 text and attributes; characters XML cannot carry are dropped. */
|
|
238
|
+
export function xml(v) {
|
|
239
|
+
let out = '';
|
|
240
|
+
for (const ch of String(v)) {
|
|
241
|
+
const c = ch.codePointAt(0);
|
|
242
|
+
const ok = c === 0x9 || c === 0xa || c === 0xd || (c >= 0x20 && c <= 0xd7ff) || (c >= 0xe000 && c <= 0xfffd) || (c >= 0x10000 && c <= 0x10ffff);
|
|
243
|
+
if (ok)
|
|
244
|
+
out += { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch] ?? ch;
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
export function ciJunit(r) {
|
|
249
|
+
const byId = new Map(r.findings.map((f) => [f.id, f]));
|
|
250
|
+
const failing = new Set(r.verdict.failingFindings);
|
|
251
|
+
const describe = (ids) => [...new Set(ids)].map((id) => byId.get(id)).filter((f) => !!f).map((f) => `${f.id} [${f.severity}, ${f.confidence}${f.suppressed ? ', suppressed' : ''}] ${f.kind}: ${f.message}`);
|
|
252
|
+
const forFindings = (name, ms, ids) => {
|
|
253
|
+
const bad = ids.filter((id) => failing.has(id));
|
|
254
|
+
const rest = ids.filter((id) => !failing.has(id));
|
|
255
|
+
return { name, time: ms / 1000, ...(bad.length ? { failure: describe(bad).join('\n') } : {}), ...(rest.length ? { note: `not counted:\n${describe(rest).join('\n')}` } : {}) };
|
|
256
|
+
};
|
|
257
|
+
const errorsFail = r.selections.scenarioErrors === 'fail';
|
|
258
|
+
const flows = r.flows.map((f) => ({
|
|
259
|
+
name: f.name, time: f.ms / 1000,
|
|
260
|
+
...(f.passed ? {} : { failure: f.failedStep ? `step ${f.failedStep.index} (${f.failedStep.label}): ${f.failedStep.text}` : 'the flow did not finish' }),
|
|
261
|
+
}));
|
|
262
|
+
const sweeps = r.sweeps.flatMap((s) => s.devices.map((d) => {
|
|
263
|
+
const c = forFindings(`${s.route} @ ${d.device}`, d.ms, d.findings);
|
|
264
|
+
return d.status === 'error' ? { name: c.name, time: c.time, ...(errorsFail ? { error: d.error ?? 'could not be measured' } : { skipped: d.error ?? 'could not be measured' }) } : c;
|
|
265
|
+
}));
|
|
266
|
+
const scenarios = (r.scan?.runs ?? []).map((x) => {
|
|
267
|
+
const name = `${x.scenario} @ ${x.device}`;
|
|
268
|
+
if (x.status === 'failed') {
|
|
269
|
+
const why = `${x.failedAt ? `failed at ${x.failedAt}` : 'failed'}${'error' in x && x.error ? `: ${x.error}` : ''}`;
|
|
270
|
+
return { name, time: 0, ...(errorsFail ? { error: why } : { skipped: why }) };
|
|
271
|
+
}
|
|
272
|
+
return forFindings(name, 0, x.findings);
|
|
273
|
+
});
|
|
274
|
+
const suites = [{ name: 'agentlab.flows', cases: flows }, { name: 'agentlab.sweeps', cases: sweeps }, { name: 'agentlab.scenarios', cases: scenarios }]
|
|
275
|
+
.filter((s) => s.cases.length);
|
|
276
|
+
if (r.verdict.result === 'error') {
|
|
277
|
+
suites.push({ name: 'agentlab.run', cases: [{ name: r.timedOut ? 'timed out' : r.cancelled ? 'cancelled' : 'run', time: 0, error: r.verdict.reasons.join('; ') }] });
|
|
278
|
+
}
|
|
279
|
+
const n = (cs, k) => cs.filter((c) => c[k] !== undefined).length;
|
|
280
|
+
const total = suites.flatMap((s) => s.cases);
|
|
281
|
+
const body = suites.map((s) => {
|
|
282
|
+
const time = s.cases.reduce((t, c) => t + c.time, 0).toFixed(3);
|
|
283
|
+
const cases = s.cases.map((c) => {
|
|
284
|
+
const inner = (c.failure !== undefined ? `<failure message="${xml(c.failure.split('\n')[0])}" type="agentlab">${xml(c.failure)}</failure>` : '')
|
|
285
|
+
+ (c.error !== undefined ? `<error message="${xml(c.error.split('\n')[0])}" type="agentlab">${xml(c.error)}</error>` : '')
|
|
286
|
+
+ (c.skipped !== undefined ? `<skipped message="${xml(c.skipped)}"/>` : '')
|
|
287
|
+
+ (c.note ? `<system-out>${xml(c.note)}</system-out>` : '');
|
|
288
|
+
return ` <testcase classname="${xml(s.name)}" name="${xml(c.name)}" time="${c.time.toFixed(3)}"${inner ? `>${inner}</testcase>` : '/>'}`;
|
|
289
|
+
}).join('\n');
|
|
290
|
+
return ` <testsuite name="${xml(s.name)}" tests="${s.cases.length}" failures="${n(s.cases, 'failure')}" errors="${n(s.cases, 'error')}" skipped="${n(s.cases, 'skipped')}" time="${time}" timestamp="${xml(r.startedAt)}">\n${cases}\n </testsuite>`;
|
|
291
|
+
}).join('\n');
|
|
292
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites name="agentlab" tests="${total.length}" failures="${n(total, 'failure')}" errors="${n(total, 'error')}" skipped="${n(total, 'skipped')}" time="${(r.ms / 1000).toFixed(3)}">\n${body}\n</testsuites>\n`;
|
|
293
|
+
}
|
|
294
|
+
// ---------- HTML ----------
|
|
295
|
+
/** `dir` is the artifacts directory: the relative `frames/…` paths of the findings resolve against it. */
|
|
296
|
+
export function ciHtml(r, dir) {
|
|
297
|
+
const findings = r.findings.map((f) => {
|
|
298
|
+
const abs = (p) => isAbsolute(p) ? p : join(dir, p);
|
|
299
|
+
return { ...f, ...(f.frame ? { frame: abs(f.frame) } : {}), ...(f.frames ? { frames: f.frames.map((x) => ({ ...x, path: abs(x.path) })) } : {}) };
|
|
300
|
+
});
|
|
301
|
+
const byId = new Map(findings.map((f) => [f.id, f]));
|
|
302
|
+
const v = r.verdict;
|
|
303
|
+
const sel = r.selections;
|
|
304
|
+
const cls = v.result === 'pass' ? 'pass' : v.result === 'fail' ? 'fail' : 'error';
|
|
305
|
+
const live = r.groups.filter((g) => !g.suppressed);
|
|
306
|
+
const confirmed = live.filter((g) => g.confidence === 'confirmed');
|
|
307
|
+
const heuristic = live.filter((g) => g.confidence === 'heuristic');
|
|
308
|
+
const suppressed = r.groups.filter((g) => g.suppressed);
|
|
309
|
+
// A partly suppressed group is listed with its live members above; its suppressed findings are listed here too.
|
|
310
|
+
const partial = findings.filter((f) => f.suppressed && !suppressed.some((g) => g.findings.includes(f.id)));
|
|
311
|
+
const groups = (gs, quiet, none) => gs.length ? gs.map((g) => groupHtml(dir, g, byId, quiet)).join('') : `<p class="muted">${esc(none)}</p>`;
|
|
312
|
+
const row = (cells, klass = '') => `<tr>${cells.map((c, i) => `<td${i === 0 && klass ? ` class="${klass}"` : ''}>${esc(c)}</td>`).join('')}</tr>`;
|
|
313
|
+
const table = (head, rows) => `<table><thead><tr>${head.map((h) => `<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.join('')}</tbody></table>`;
|
|
314
|
+
const flows = r.flows.length ? table(['Flow', 'Result', 'Steps', 'Time', 'Detail'], r.flows.map((f) => `<tr><td>${esc(f.name)}</td><td class="${f.passed ? 'ok' : 'failed'}">${f.passed ? 'pass' : 'FAIL'}</td><td>${esc(f.completed)}/${esc(f.steps)}</td><td>${esc(formatDuration(f.ms))}</td>` +
|
|
315
|
+
`<td>${f.failedStep ? esc(`step ${f.failedStep.index} (${f.failedStep.label}): ${f.failedStep.text}`) : ''}${f.bundle ? ` <code>${esc(f.bundle)}</code>` : ''}</td></tr>`)) : '<p class="muted">No flows were selected.</p>';
|
|
316
|
+
const sweeps = r.sweeps.length ? r.sweeps.map((s) => `<h3>${esc(s.route)} <span class="muted">${esc(s.id)} · ${esc(formatDuration(s.ms))}</span></h3>` + table(['Device', 'Width', 'Status', 'Findings'], s.devices.map((d) => `<tr><td>${esc(d.device)}</td><td>${esc(d.width)}</td><td class="${d.status === 'ok' ? 'ok' : 'failed'}">${esc(d.status === 'ok' ? 'ok' : `error: ${d.error ?? ''}`)}</td><td>${esc(d.findings.join(', '))}</td></tr>`))).join('') : '<p class="muted">No routes were swept.</p>';
|
|
317
|
+
const scan = r.scan ? (() => {
|
|
318
|
+
const scenarios = [...new Set(r.scan.runs.map((x) => x.scenario))];
|
|
319
|
+
const devices = [...new Set(r.scan.runs.map((x) => x.device))];
|
|
320
|
+
const cell = (x) => !x ? '<td class="muted">not run</td>'
|
|
321
|
+
: x.status === 'ok' ? `<td class="ok">ok · ${esc(x.states)} states · ${esc(x.findings.length)} findings</td>`
|
|
322
|
+
: `<td class="failed"><strong>failed at ${esc(x.failedAt ?? 'unknown')}</strong>${'error' in x && x.error ? `: ${esc(x.error)}` : ''}</td>`;
|
|
323
|
+
return `<table><thead><tr><th>Scenario</th>${devices.map((d) => `<th>${esc(d)}</th>`).join('')}</tr></thead><tbody>${scenarios.map((s) => `<tr><th>${esc(s)}</th>${devices.map((d) => cell(r.scan.runs.find((x) => x.scenario === s && x.device === d))).join('')}</tr>`).join('')}</tbody></table>` +
|
|
324
|
+
`<p class="muted">The scan's own report: <code>${esc(r.scan.reports.html)}</code></p>`;
|
|
325
|
+
})() : '<p class="muted">No scenarios were scanned.</p>';
|
|
326
|
+
const supRows = r.suppressions.map((s) => {
|
|
327
|
+
const label = s.status === 'applied' ? `applied to ${s.matched.length} findings (${s.matched.join(', ')})` : s.status === 'unmatched' ? 'stale: matched nothing in this run'
|
|
328
|
+
: s.status === 'expired' ? `expired on ${s.expires ?? 'an earlier date'}` : 'not evaluated (scope not in this run)';
|
|
329
|
+
return `<tr><td>${esc(s.rule)}</td><td>${esc(s.reason)}</td><td>${esc(label)}</td><td>${esc(s.expires ?? '')}</td></tr>`;
|
|
330
|
+
});
|
|
331
|
+
return `<!doctype html>
|
|
332
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
333
|
+
<title>agentlab ${esc(r.command)}: ${esc(r.project.name)}</title><style>${CSS}.verdict.error{background:var(--med)}</style></head><body><main>
|
|
334
|
+
<h1>${esc(r.project.name)} <span class="verdict ${cls}">${esc(verdictWord(v))}</span></h1>
|
|
335
|
+
<p class="muted">agentlab ${esc(r.command)} · exit ${esc(v.exitCode)} · started ${esc(r.startedAt)} · ${esc(formatDuration(r.ms))}${r.timedOut ? ' · timed out' : ''}${r.cancelled ? ` · cancelled (${esc(r.cancelled)})` : ''}</p>
|
|
336
|
+
<ul>${v.reasons.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>
|
|
337
|
+
<h2>Selections and environment</h2>
|
|
338
|
+
${table(['Setting', 'Value'], [
|
|
339
|
+
row(['flows', sel.flows.join(', ') || 'none']), row(['routes', sel.routes.join(', ') || 'none']), row(['scenarios', sel.scenarios.join(', ') || 'none']),
|
|
340
|
+
row(['devices', (sel.devices ?? ['each scenario\'s own']).join(', ')]), row(['sweep devices', sel.sweepDevices.join(', ')]),
|
|
341
|
+
row(['failOn', `${sel.failOn}${sel.failOnHeuristic ? ' (heuristics count)' : ''}`]), row(['scenarioErrors', sel.scenarioErrors]),
|
|
342
|
+
row(['trace / evidence', `${sel.trace} / ${sel.evidence}`]), row(['timeout', formatDuration(sel.timeoutMs)]),
|
|
343
|
+
row(['sign-in', r.environment.auth]), row(['agentlab', r.product.version]),
|
|
344
|
+
row(['node / platform', `${r.environment.node} on ${r.environment.platform}/${r.environment.arch}`]), row(['chromium', r.environment.chromium ?? 'not started']),
|
|
345
|
+
])}
|
|
346
|
+
<h2>Flows</h2>${flows}
|
|
347
|
+
<h2>Sweeps</h2>${sweeps}
|
|
348
|
+
<h2>Scenario matrix</h2>${scan}
|
|
349
|
+
<h2>Confirmed problems</h2>${groups(confirmed, false, 'None.')}
|
|
350
|
+
<h2>Heuristic warnings</h2>${groups(heuristic, true, 'None.')}
|
|
351
|
+
<h2>Suppressed findings</h2>${groups(suppressed, true, 'No group is fully suppressed.')}${partial.length ? `<h3>Suppressed findings in groups that are still reported</h3>${findingsTable(partial)}` : ''}
|
|
352
|
+
<h2>Suppressions</h2>${r.suppressions.length ? table(['Rule', 'Reason', 'Status', 'Expires'], supRows) : '<p class="muted">No suppressions configured.</p>'}
|
|
353
|
+
<h2>Artifacts</h2>
|
|
354
|
+
<ul>${r.artifacts.map((a) => `<li><code>${esc(a.path)}</code> <span class="muted">${esc(a.kind)}</span></li>`).join('')}${r.bundles.map((b) => `<li><code>${esc(b.dir)}</code> <span class="muted">failure bundle for ${esc(b.for)}${b.trace === 'included' ? ', with trace' : ''}</span></li>`).join('')}${r.traces.map((t) => `<li>${t.file ? `<code>${esc(t.file)}</code>` : esc(t.name)} <span class="muted">${t.file ? 'trace' : `trace dropped: ${esc(t.dropped ?? '')}`}</span></li>`).join('')}</ul>
|
|
355
|
+
<p class="muted">Full data: <code>ci-result.json</code>. No environment values, request bodies, headers or sign-in state are ever written here.</p>
|
|
356
|
+
</main></body></html>
|
|
357
|
+
`;
|
|
358
|
+
}
|
|
359
|
+
// ---------- reading a saved result ----------
|
|
360
|
+
/** Parse and check a saved ci-result.json. A newer version is refused with a clear message. */
|
|
361
|
+
export function parseCiResult(raw) {
|
|
362
|
+
const bad = (why) => { throw new LabError('invalid_request', `Not a usable agentlab CI result: ${why}`); };
|
|
363
|
+
if (!raw || typeof raw !== 'object')
|
|
364
|
+
return bad('not a JSON object');
|
|
365
|
+
const o = raw;
|
|
366
|
+
if (o.schema !== CI_SCHEMA)
|
|
367
|
+
return bad(`"schema" must be "${CI_SCHEMA}"`);
|
|
368
|
+
if (typeof o.version !== 'number' || !Number.isInteger(o.version) || o.version < 1)
|
|
369
|
+
return bad('"version" must be a positive integer');
|
|
370
|
+
if (o.version > CONTRACT_VERSIONS.report) {
|
|
371
|
+
throw new LabError('invalid_request', `This CI result is version ${o.version}; this agentlab ${productVersion()} reads up to ${CONTRACT_VERSIONS.report}`, { hint: 'Upgrade agentlab to render it.' });
|
|
372
|
+
}
|
|
373
|
+
for (const k of ['flows', 'sweeps', 'findings', 'groups', 'suppressions', 'bundles', 'traces', 'artifacts'])
|
|
374
|
+
if (!Array.isArray(o[k]))
|
|
375
|
+
bad(`"${k}" must be a list`);
|
|
376
|
+
if (!o.verdict || typeof o.verdict !== 'object' || !o.selections || typeof o.selections !== 'object' || !o.environment || !o.project)
|
|
377
|
+
bad('"verdict", "selections", "environment" and "project" are required');
|
|
378
|
+
return raw;
|
|
379
|
+
}
|
|
380
|
+
export function renderCi(r, format, dir) {
|
|
381
|
+
switch (format) {
|
|
382
|
+
case 'text': return formatCiText(r);
|
|
383
|
+
case 'json': return JSON.stringify(r, null, 2) + '\n';
|
|
384
|
+
case 'junit': return ciJunit(r);
|
|
385
|
+
case 'html': return ciHtml(r, dir);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
export const CI_FILES = { text: 'summary.txt', json: 'ci-result.json', html: 'report.html', junit: 'junit.xml' };
|
|
389
|
+
//# sourceMappingURL=ci.js.map
|