Files
harness-tools/extensions/fast-browser/reports.ts
T
jb fd63b0ccec
fast-browser scenarios / Offline scenario contract tests (push) Failing after 44s
fast-browser scenarios / Local Chromium headless scenarios (push) Failing after 23s
fast-browser-with-jev
2026-09-20 12:24:08 -04:00

565 lines
26 KiB
TypeScript

import {
chmodSync,
closeSync,
constants,
fsyncSync,
lstatSync,
mkdirSync,
openSync,
realpathSync,
renameSync,
unlinkSync,
writeSync,
} from "node:fs";
import { dirname, basename, resolve } from "node:path";
import {
BROWSER_RUNTIME_OPERATIONS,
FAST_BROWSER_REPORT_VERSION,
type BrowserErrorDetails,
type PrivateArtifactMetadata,
type ScenarioOperationReport as RuntimeScenarioOperationReport,
type ScenarioReport as RuntimeScenarioReport,
} from "./contracts.ts";
export const SCENARIO_REPORT_VERSION = FAST_BROWSER_REPORT_VERSION;
const MAX_REPORT_TEXT = 2_000;
const MAX_REPORT_ITEMS = 64;
const MAX_EVIDENCE = 32;
const MAX_PATH = 4_000;
const FIXED_EVIDENCE_REASONS = new Set([
"browser session is unavailable",
"evidence capture was cancelled",
"browser transport is unavailable",
"browser returned no complete failure evidence",
"partial failure evidence",
]);
/** Published runtime report type; kept as an alias so callers use contracts.ts as the source of truth. */
export type PublishedScenarioReport = RuntimeScenarioReport;
export type ScenarioStatus = "passed" | "failed" | "skipped" | "blocked" | "not_run" | "validated";
export type CleanupStatus = "passed" | "failed" | "not_run";
export type EvidenceKind = "screenshot" | "console" | "request" | "other";
export interface ScenarioEvidenceReference {
kind: EvidenceKind;
artifactId: string;
status: "captured" | "failed";
/** A basename or caller-selected local relative label; never a URL/content blob. */
file?: string;
/** Private filesystem path retained for local report/evidence lifetime checks. */
retainedPath?: string;
private: true;
forwardedToModel: false;
error?: string;
}
export interface ScenarioCaseReport {
id: string;
status: ScenarioStatus;
elapsedMs: number;
operations?: RuntimeScenarioOperationReport[];
phase?: string;
browser?: {
product?: string;
headless?: boolean;
owned?: boolean;
profile?: string;
sessionId?: string;
};
failure?: {
phase: string;
message: string;
};
evidence?: ScenarioEvidenceReference[];
cleanup?: {
status: CleanupStatus;
detail: string;
};
}
/** CLI validation/matrix envelope; execution reports use contracts.ts ScenarioReport. */
export interface ScenarioReportEnvelope {
version: typeof SCENARIO_REPORT_VERSION;
kind: "fast-browser-scenario-report";
generatedAt: string;
status: ScenarioStatus;
elapsedMs: number;
scenarios: ScenarioCaseReport[];
evidence: ScenarioEvidenceReference[];
cleanup: {
status: CleanupStatus;
detail: string;
};
/** Original failure is independent of evidence-capture/cleanup failures. */
failure?: {
scenarioId?: string;
phase: string;
message: string;
};
}
export interface ScenarioFailureInput {
scenarioId?: unknown;
phase?: unknown;
error?: unknown;
secrets?: Iterable<string>;
}
export interface BuildScenarioReportInput {
/** Known caller-held secrets used only for final report redaction. */
secrets?: Iterable<string>;
status: ScenarioStatus;
elapsedMs?: unknown;
scenarios?: readonly unknown[];
cleanup?: unknown;
failure?: ScenarioFailureInput;
evidence?: readonly unknown[];
generatedAt?: string;
}
export class ScenarioReportError extends Error {
override name = "ScenarioReportError";
}
function text(value: unknown, fallback: string, max = MAX_REPORT_TEXT): string {
if (typeof value !== "string") return fallback;
const normalized = value.replace(/[\0\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
if (!normalized) return fallback;
return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized;
}
function knownSecrets(secrets: Iterable<string>): string[] {
return [...secrets].filter((secret) => typeof secret === "string" && secret.length > 0);
}
function redactKnownText(value: string, secrets: readonly string[]): string {
let result = value;
for (const secret of secrets) result = result.split(secret).join("[redacted]");
return result;
}
function safeNumber(value: unknown, fallback = 0, max = 120_000): number {
return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(0, Math.round(value))) : fallback;
}
function safeId(value: unknown, fallback = "scenario"): string {
const result = text(value, fallback, 128);
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(result) ? result : fallback;
}
function redactUrl(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
try {
const parsed = new URL(value);
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) return "[redacted-url]";
// Reports carry only an origin. Paths, queries, fragments, and tokens are
// deliberately private to the browser worker.
return parsed.origin;
} catch {
return undefined;
}
}
/** Sanitize an error without exposing credential values or provider responses. */
export function sanitizeReportError(error: unknown, secrets: Iterable<string> = []): string {
let message = error instanceof Error ? error.message : typeof error === "string" ? error : "operation failed";
const replacements = new Set<string>();
for (const secret of secrets) if (typeof secret === "string" && secret.length > 0) replacements.add(secret);
for (const secret of replacements) message = message.split(secret).join("[redacted]");
message = message
.replace(/(?:https?|wss?):\/\/[^\s)]+/gi, (url) => url.toLowerCase().startsWith("ws") ? "[redacted-url]" : (redactUrl(url) ?? "[redacted-url]"))
.replace(/\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*:\s*[^,;\s]+/gi, "$1: [redacted]")
.replace(/\b(?:bearer|basic)\s+[^\s]+/gi, "$1 [redacted]")
.replace(/([?&](?:token|key|secret|password|passwd|auth|code)=)[^&#\s]*/gi, "$1[redacted]")
.replace(/\b(cookie|set-cookie|password|passwd|secret|token|api[_-]?key)\s*[=:]\s*[^\s,;]+/gi, (_match, key: string) => `${key}=[redacted]`);
return text(message, "operation failed");
}
function safeArtifactId(value: unknown): string {
const id = text(value, "evidence", 128);
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id) ? id : "evidence";
}
function safeEvidenceReason(value: unknown, secrets: readonly string[]): string {
const reason = sanitizeReportError(value, secrets);
return FIXED_EVIDENCE_REASONS.has(reason) ? reason : "failure evidence unavailable";
}
function sanitizeEvidence(value: unknown, secrets: readonly string[] = []): ScenarioEvidenceReference | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const source = value as Record<string, unknown>;
const kind = source.kind === "screenshot" || source.kind === "console" || source.kind === "request" || source.kind === "other"
? source.kind : undefined;
if (!kind) return undefined;
const file = source.file === undefined ? undefined : redactKnownText(text(source.file, "", 255), secrets);
const retainedPath = source.retainedPath === undefined ? undefined : redactKnownText(text(source.retainedPath, "", MAX_PATH), secrets);
if (file?.includes("://") || file?.includes("\\") || file?.includes("\0")) return undefined;
if (retainedPath?.includes("://") || retainedPath?.includes("\\") || retainedPath?.includes("\0")) return undefined;
const error = source.error === undefined ? undefined : safeEvidenceReason(source.error, secrets);
const result: ScenarioEvidenceReference = {
kind,
artifactId: safeArtifactId(redactKnownText(text(source.artifactId, "evidence", 128), secrets)),
status: source.status === "captured" ? "captured" : "failed",
private: true,
forwardedToModel: false,
...(retainedPath ? { file: retainedPath } : file ? { file: basename(file) } : {}),
...(retainedPath ? { retainedPath } : {}),
// Only fixed runtime producer reasons cross this boundary; arbitrary
// diagnostics are replaced with a generic unavailable reason.
...(error ? { error } : {}),
};
return result;
}
function sanitizeBrowser(value: unknown, secrets: readonly string[] = []): ScenarioCaseReport["browser"] {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const source = value as Record<string, unknown>;
const browser: NonNullable<ScenarioCaseReport["browser"]> = {};
if (source.product === "vivaldi" || source.product === "chrome" || source.product === "chromium") browser.product = source.product;
if (typeof source.headless === "boolean") browser.headless = source.headless;
if (typeof source.owned === "boolean") browser.owned = source.owned;
if (source.profile !== undefined) {
const profile = redactKnownText(text(source.profile, "", 64), secrets);
if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(profile)) browser.profile = profile;
}
if (source.sessionId !== undefined) browser.sessionId = redactKnownText(text(source.sessionId, "", 200), secrets);
return Object.keys(browser).length ? browser : undefined;
}
function sanitizeCleanup(value: unknown, secrets: readonly string[] = []): { status: CleanupStatus; detail: string } {
if (!value || typeof value !== "object" || Array.isArray(value)) return { status: "not_run", detail: "cleanup not run" };
const source = value as Record<string, unknown>;
const status: CleanupStatus = source.status === "passed" || source.status === "failed" ? source.status : "not_run";
const fallback = status === "passed" ? "cleanup completed" : "cleanup not run";
return { status, detail: source.detail === undefined ? fallback : sanitizeReportError(source.detail, secrets) };
}
function sanitizeScenarioOperation(value: unknown, index: number, secrets: Iterable<string>): RuntimeScenarioOperationReport | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const source = value as Record<string, unknown>;
const operationNames = ["step", "read", "assert", "navigate", "reload", "listTabs", "selectTab", "waitPopup", "closeTab"] as const;
if (!operationNames.includes(source.op as typeof operationNames[number])) return undefined;
return {
index: safeNumber(source.index, index, MAX_REPORT_ITEMS),
op: source.op as RuntimeScenarioOperationReport["op"],
status: source.status === "passed" || source.status === "skipped" ? source.status : "failed",
elapsedMs: safeNumber(source.elapsedMs),
mutationState: source.mutationState === "not_attempted" || source.mutationState === "verified" ? source.mutationState : "unknown",
...(source.error ? { error: sanitizeRuntimeError(source.error, secrets) } : {}),
};
}
function sanitizeCase(value: unknown, secrets: readonly string[], index: number): ScenarioCaseReport {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { id: `scenario-${index + 1}`, status: "failed", elapsedMs: 0, failure: { phase: "report", message: "invalid scenario result" } };
}
const source = value as Record<string, unknown>;
const statuses: ScenarioStatus[] = ["passed", "failed", "skipped", "blocked", "not_run", "validated"];
const status = statuses.includes(source.status as ScenarioStatus) ? source.status as ScenarioStatus : "failed";
const result: ScenarioCaseReport = {
id: safeId(source.id, `scenario-${index + 1}`),
status,
elapsedMs: safeNumber(source.elapsedMs),
...(source.phase === undefined ? {} : { phase: text(source.phase, "unknown", 128) }),
...(sanitizeBrowser(source.browser, secrets) === undefined ? {} : { browser: sanitizeBrowser(source.browser, secrets) }),
};
if (Array.isArray(source.operations)) {
const operations = source.operations.slice(0, MAX_REPORT_ITEMS).map((entry, operationIndex) => sanitizeScenarioOperation(entry, operationIndex, secrets)).filter((entry): entry is RuntimeScenarioOperationReport => Boolean(entry));
if (operations.length) result.operations = operations;
}
const rawFailure = source.failure;
if (rawFailure && typeof rawFailure === "object" && !Array.isArray(rawFailure)) {
const failure = rawFailure as Record<string, unknown>;
result.failure = {
phase: text(failure.phase, "unknown", 128),
message: sanitizeReportError(failure.message, secrets),
};
}
if (source.cleanup !== undefined) result.cleanup = sanitizeCleanup(source.cleanup, secrets);
if (Array.isArray(source.evidence)) {
const evidence = source.evidence.slice(0, MAX_EVIDENCE).map((entry) => sanitizeEvidence(entry, secrets)).filter((entry): entry is ScenarioEvidenceReference => Boolean(entry));
if (evidence.length) result.evidence = evidence;
}
return result;
}
function reportStatus(status: unknown): ScenarioStatus {
const values: ScenarioStatus[] = ["passed", "failed", "skipped", "blocked", "not_run", "validated"];
return values.includes(status as ScenarioStatus) ? status as ScenarioStatus : "failed";
}
/** Build a bounded machine-readable report from runtime-neutral result data. */
export function createScenarioReport(input: BuildScenarioReportInput): ScenarioReportEnvelope {
const secrets = knownSecrets(input.secrets ?? input.failure?.secrets ?? []);
const cases = Array.isArray(input.scenarios)
? input.scenarios.slice(0, MAX_REPORT_ITEMS).map((entry, index) => sanitizeCase(entry, secrets, index))
: [];
const evidence = Array.isArray(input.evidence)
? input.evidence.slice(0, MAX_EVIDENCE).map((entry) => sanitizeEvidence(entry, secrets)).filter((entry): entry is ScenarioEvidenceReference => Boolean(entry))
: [];
const cleanup = sanitizeCleanup(input.cleanup, secrets);
const report: ScenarioReportEnvelope = {
version: SCENARIO_REPORT_VERSION,
kind: "fast-browser-scenario-report",
generatedAt: input.generatedAt && !Number.isNaN(Date.parse(input.generatedAt)) ? new Date(input.generatedAt).toISOString() : new Date().toISOString(),
status: reportStatus(input.status),
elapsedMs: safeNumber(input.elapsedMs),
scenarios: cases,
evidence,
cleanup,
};
if (input.failure) {
report.failure = {
...(input.failure.scenarioId === undefined ? {} : { scenarioId: safeId(input.failure.scenarioId) }),
phase: text(input.failure.phase, "unknown", 128),
message: sanitizeReportError(input.failure.error, secrets),
};
}
return report;
}
function sanitizeArtifactMetadata(value: unknown, secrets: readonly string[] = []): PrivateArtifactMetadata | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const source = value as Record<string, unknown>;
const kind = source.kind === "screenshot" || source.kind === "diagnostics" ? source.kind : undefined;
const contentType = source.contentType === "image/png" || source.contentType === "application/json" ? source.contentType : undefined;
const owner = source.owner;
if (!kind || !contentType || !owner || typeof owner !== "object" || Array.isArray(owner)) return undefined;
const ownerRecord = owner as Record<string, unknown>;
const path = typeof source.path === "string" && !source.path.includes("\\") && !source.path.includes("://")
? redactKnownText(basename(source.path), secrets)
: "private-artifact";
return {
artifactId: safeArtifactId(redactKnownText(text(source.artifactId, "artifact", 128), secrets)),
kind,
contentType,
path,
bytes: safeNumber(source.bytes, 0, 60 * 1024 * 1024),
owner: {
sessionId: redactKnownText(text(ownerRecord.sessionId, "session", 200), secrets),
runId: redactKnownText(text(ownerRecord.runId, "run", 200), secrets),
operationId: redactKnownText(text(ownerRecord.operationId, "operation", 200), secrets),
},
permissions: "0600",
localOnly: true,
modelForwarded: false,
};
}
/** Convert a published private artifact to a report-only local reference. */
export function evidenceReference(metadata: PrivateArtifactMetadata): ScenarioEvidenceReference | undefined {
const safe = sanitizeArtifactMetadata(metadata);
if (!safe) return undefined;
return {
kind: safe.kind === "screenshot" ? "screenshot" : "other",
artifactId: safe.artifactId,
status: "captured",
file: safe.path,
...(typeof metadata.path === "string" ? { retainedPath: metadata.path } : {}),
private: true,
forwardedToModel: false,
};
}
function sanitizeRuntimeError(value: unknown, secrets: Iterable<string>): BrowserErrorDetails | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const source = value as Record<string, unknown>;
const codes = ["aborted", "timeout", "stale", "policy_denied", "invalid_request", "no_tab", "ambiguous_tab", "browser_closed", "unsupported", "worker_failure"] as const;
const retries = ["never", "safe"] as const;
const mutations = ["not_attempted", "verified", "unknown"] as const;
const operationValue = text(source.operation, "unknown", 64);
const operation = BROWSER_RUNTIME_OPERATIONS.includes(operationValue as typeof BROWSER_RUNTIME_OPERATIONS[number])
? operationValue as BrowserErrorDetails["operation"]
: "observe";
const code = codes.includes(source.code as typeof codes[number]) ? source.code as typeof codes[number] : "worker_failure";
const mutationState = mutations.includes(source.mutationState as typeof mutations[number]) ? source.mutationState as typeof mutations[number] : "unknown";
const retry = retries.includes(source.retry as typeof retries[number]) ? source.retry as typeof retries[number] : "never";
return {
code,
operation,
phase: text(source.phase, "unknown", 128),
message: sanitizeReportError(source.message, secrets),
mutationState,
retry,
};
}
/** Sanitize the published runtime report without changing its contract shape. */
export function sanitizeRuntimeScenarioReport(report: RuntimeScenarioReport, secrets: Iterable<string> = []): RuntimeScenarioReport {
const safeSecrets = knownSecrets(secrets);
const operations: RuntimeScenarioOperationReport[] = Array.isArray(report.operations)
? report.operations.slice(0, MAX_REPORT_ITEMS).map((operation, index) => ({
index: safeNumber(operation?.index, index, MAX_REPORT_ITEMS),
op: operation?.op,
status: operation?.status === "passed" || operation?.status === "skipped" ? operation.status : "failed",
elapsedMs: safeNumber(operation?.elapsedMs),
mutationState: operation?.mutationState === "not_attempted" || operation?.mutationState === "verified" ? operation.mutationState : "unknown",
...(operation?.error ? { error: sanitizeRuntimeError(operation.error, safeSecrets) } : {}),
}))
: [];
const evidence = Array.isArray(report.evidence)
? report.evidence.slice(0, MAX_EVIDENCE).map((entry) => sanitizeArtifactMetadata(entry, safeSecrets)).filter((entry): entry is PrivateArtifactMetadata => Boolean(entry))
: [];
return {
version: FAST_BROWSER_REPORT_VERSION,
scenarioId: safeId(report.scenarioId),
status: report.status === "passed" || report.status === "skipped" ? report.status : "failed",
elapsedMs: safeNumber(report.elapsedMs),
operations,
evidence,
cleanup: report.cleanup === "complete" ? "complete" : "failed",
};
}
/**
* Serialize only the public report shape. This second pass protects callers
* that retain a report object and later mutate an untrusted input object.
*/
export function sanitizeScenarioReport(report: ScenarioReportEnvelope, secrets: Iterable<string> = []): ScenarioReportEnvelope {
return createScenarioReport({
status: report.status,
elapsedMs: report.elapsedMs,
generatedAt: report.generatedAt,
scenarios: report.scenarios,
evidence: report.evidence,
cleanup: report.cleanup,
...(report.failure ? { failure: { ...report.failure, error: report.failure.message } } : {}),
secrets: knownSecrets(secrets),
});
}
function assertNoSymlinkPath(path: string): void {
let current = resolve(path);
while (true) {
try { lstatSync(current); break; }
catch {
const next = dirname(current);
if (next === current) return;
current = next;
}
}
if (realpathSync(current) !== current) throw new ScenarioReportError("report path may not contain symlinks");
}
function assertSafeReportDirectory(path: string): void {
const absolute = resolve(path);
const info = lstatSync(absolute);
if (!info.isDirectory() || info.isSymbolicLink()) throw new ScenarioReportError("report directory must be a regular directory");
if (realpathSync(absolute) !== absolute) throw new ScenarioReportError("report directory may not contain symlinks");
if (typeof process.getuid === "function" && info.uid !== process.getuid()) throw new ScenarioReportError("report directory is not owned by this user");
// A normal owned 0755 project directory is safe; group/world writable
// parents are not, and are never chmodded by report output.
if ((info.mode & 0o022) !== 0) throw new ScenarioReportError("report directory must not be group/world writable");
let parent = dirname(absolute);
while (true) {
const parentInfo = lstatSync(parent);
if (parentInfo.isSymbolicLink()) throw new ScenarioReportError("report parent may not contain symlinks");
// A sticky system scratch parent (for example /tmp) is acceptable when
// the actual report directory is owned and non-writable by others.
if ((parentInfo.mode & 0o022) !== 0 && (parentInfo.mode & 0o1000) === 0) throw new ScenarioReportError("report parent must not be group/world writable");
const next = dirname(parent);
if (next === parent) break;
parent = next;
}
}
function validateOutputPath(path: string): string {
if (typeof path !== "string" || path.length === 0 || path.length > MAX_PATH || path.includes("\0") || path.includes("://")) {
throw new ScenarioReportError("report path must be a local bounded path");
}
return resolve(path);
}
/** Atomically write a mode-0600 JSON report beneath an owned, non-writable-by-others directory. */
export function writeScenarioReport(path: string, report: ScenarioReportEnvelope | RuntimeScenarioReport, secrets: Iterable<string> = []): void {
const target = validateOutputPath(path);
const directory = dirname(target);
assertNoSymlinkPath(directory);
mkdirSync(directory, { recursive: true, mode: 0o700 });
assertSafeReportDirectory(directory);
const existing = (() => { try { return lstatSync(target); } catch { return undefined; } })();
if (existing) {
if (existing.isSymbolicLink() || !existing.isFile()) throw new ScenarioReportError("report destination must be a regular file");
if (typeof process.getuid === "function" && existing.uid !== process.getuid()) throw new ScenarioReportError("report destination is not owned by this user");
}
const safe = "scenarioId" in report
? sanitizeRuntimeScenarioReport(report, secrets)
: sanitizeScenarioReport(report, secrets);
const content = `${JSON.stringify(safe, null, 2)}\n`;
const temporary = resolve(directory, `.${basename(target)}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
let fd: number | undefined;
try {
fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
writeSync(fd, content, undefined, "utf8");
fsyncSync(fd);
closeSync(fd);
fd = undefined;
chmodSync(temporary, 0o600);
renameSync(temporary, target);
} catch (error) {
if (fd !== undefined) {
try { closeSync(fd); } catch { /* best effort */ }
}
try { unlinkSync(temporary); } catch { /* preserve an existing report */ }
if (error instanceof ScenarioReportError) throw error;
throw new ScenarioReportError("unable to write scenario report");
}
}
/** Human-readable output never includes steps, field values, page text, or URLs. */
export function formatScenarioReport(report: ScenarioReportEnvelope | RuntimeScenarioReport, secrets: Iterable<string> = []): string {
const safeSecrets = knownSecrets(secrets);
if ("scenarioId" in report) {
const safe = sanitizeRuntimeScenarioReport(report, safeSecrets);
const lines = [
"Fast-browser scenario report",
`Status: ${safe.status}`,
`Scenario: ${safe.scenarioId}`,
`Elapsed: ${safe.elapsedMs}ms`,
`Operations: ${safe.operations.length}`,
`Cleanup: ${safe.cleanup}`,
];
for (const operation of safe.operations) {
lines.push(`${operation.status.toUpperCase()} ${operation.op} (${operation.elapsedMs}ms)`);
if (operation.error) lines.push(` Failure (${operation.error.phase}): ${operation.error.message}`);
}
if (safe.evidence.length) lines.push(`Evidence: ${safe.evidence.length} private local artifact(s)`);
return `${lines.join("\n")}\n`;
}
const safe = sanitizeScenarioReport(report, safeSecrets);
const lines = [
"Fast-browser scenario report",
`Status: ${safe.status}`,
`Scenarios: ${safe.scenarios.length}`,
`Elapsed: ${safe.elapsedMs}ms`,
];
for (const scenario of safe.scenarios) {
lines.push(`${scenario.status.toUpperCase()} ${scenario.id} (${scenario.elapsedMs}ms)`);
for (const operation of scenario.operations ?? []) {
lines.push(` ${operation.status.toUpperCase()} ${operation.op} (${operation.elapsedMs}ms)`);
if (operation.error) lines.push(` Failure (${operation.error.phase}): ${operation.error.message}`);
}
if (scenario.failure) lines.push(` Failure (${scenario.failure.phase}): ${scenario.failure.message}`);
if (scenario.cleanup) lines.push(` Cleanup: ${scenario.cleanup.status} — ${scenario.cleanup.detail}`);
}
if (safe.failure) lines.push(`Failure (${safe.failure.phase}): ${safe.failure.message}`);
lines.push(`Cleanup: ${safe.cleanup.status} — ${safe.cleanup.detail}`);
if (safe.evidence.length) {
lines.push(`Evidence: ${safe.evidence.length} private local artifact(s)`);
for (const evidence of safe.evidence) {
if (evidence.status === "failed" && evidence.error) lines.push(` Evidence ${evidence.kind}: ${evidence.error}`);
}
}
return `${lines.join("\n")}\n`;
}
/** Serialize either report envelope through the same defensive sanitizer used by human output. */
export function formatScenarioReportJson(report: ScenarioReportEnvelope | RuntimeScenarioReport, secrets: Iterable<string> = []): string {
const safeSecrets = knownSecrets(secrets);
const safe = "scenarioId" in report
? sanitizeRuntimeScenarioReport(report, safeSecrets)
: sanitizeScenarioReport(report, safeSecrets);
return `${JSON.stringify(safe, null, 2)}\n`;
}