/** * Per-failure artifact bundles. * * When a page fails, the crawler dumps a self-contained directory containing * the screenshot, HTML, error list, run metadata, and the trace up to and * including the failing page. A `repro.sh` script replays the trace so the * failure can be reproduced locally without re-running the full crawl. * * The bundle is designed to be attachable to a CI artefact, GitHub issue, * or chat message: a reviewer with the directory and a checkout of the * project should be able to step through the same sequence of actions. * * Pure helpers (`failureBundleKey`, `shouldSaveArtifacts`, `buildReproScript`) * are unit-testable without a browser. */ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fnv1a } from "./shard.js"; import { serializeTrace, type TraceEntry } from "./trace.js"; import type { FailureArtifactsOptions, PageResult } from "./types.js"; /** * True when this page result should trigger a failure bundle. We bundle on * any of: navigation error, timeout, crashed page, an HTTP 4xx/5xx status, * or any collected PageError (console / exception / network / invariant). * * The HTTP-status check matters because the crawler hooks this *before* * the recovery branch flips a 404/5xx response from `status: "success"` * to `status: "recovered"`. Without the statusCode check a plain 404 page * with no JS errors would fall through and never get bundled. */ export function shouldSaveArtifacts(result: PageResult): boolean { if (result.status === "error" || result.status === "timeout" || result.status === "recovered") { return true; } if (typeof result.statusCode === "number" && result.statusCode >= 400) { return true; } if (result.errors.length > 0) return true; return false; } /** * Stable, sortable directory name for a failure. The numeric prefix keeps * the directory listing in chronological order; the URL prefix gives a * human-readable hint; the FNV-1a suffix disambiguates routes that * sanitize to the same prefix (e.g. `/a/b` and `/a_b`). */ export function failureBundleKey(url: string, sequence: number): string { let prefix: string; try { const u = new URL(url); const path = u.pathname === "/" || u.pathname === "" ? "index" : u.pathname; prefix = sanitize(path); } catch { prefix = sanitize(url); } const hash = fnv1a(url).toString(16).padStart(8, "0"); const seq = sequence.toString().padStart(4, "0"); return `${seq}__${prefix}__${hash}`; } function sanitize(s: string): string { return s .replace(/^\/+/, "") .replace(/\/+$/, "") .replace(/[^A-Za-z0-9._-]+/g, "_") .replace(/_+/g, "_") .replace(/^_|_$/g, "") || "index"; } /** * Render a self-contained shell script that replays the bundled trace. * Kept intentionally minimal — power users will tweak it (different seed, * `--strict`, network throttling) but the default form gets you the same * navigation + actions back. */ export function buildReproScript(opts: { baseUrl: string; tracePath: string }): string { const lines = [ "#!/bin/sh", "# Replay the trace recorded just before this page failed.", "# Generated by chaosbringer.", "set -e", `chaosbringer --url ${shellQuote(opts.baseUrl)} --trace-replay ${shellQuote(opts.tracePath)}`, "", ]; return lines.join("\n"); } function shellQuote(s: string): string { if (s === "") return "''"; if (/^[A-Za-z0-9_\-:/.=?&@%+,]+$/.test(s)) return s; return `'${s.replace(/'/g, `'\\''`)}'`; } /** Metadata written to `info.json`. Stable, machine-readable. */ export interface FailureBundleInfo { url: string; status: PageResult["status"]; statusCode?: number; loadTime: number; hasErrors: boolean; errorCount: number; warningCount: number; discoveryMethod?: PageResult["discoveryMethod"]; sourceUrl?: string; sourceElement?: string; recovery?: PageResult["recovery"]; baseUrl: string; seed: number; sequence: number; /** ISO timestamp when this bundle was created. */ createdAt: string; /** Whether each artefact was written. */ artifacts: { screenshot: boolean; html: boolean; trace: boolean; }; } export interface WriteFailureBundleArgs { options: FailureArtifactsOptions; baseUrl: string; seed: number; sequence: number; result: PageResult; /** PNG bytes of the page at failure time. */ screenshot?: Buffer; /** Page HTML at failure time. */ html?: string; /** Trace entries up to and including the failing page. */ trace?: readonly TraceEntry[]; /** Override `Date.now` for deterministic tests. */ now?: () => Date; } /** Write a failure bundle to disk. Returns the bundle directory path. */ export function writeFailureBundle(args: WriteFailureBundleArgs): string { const opts = args.options; const saveScreenshot = opts.saveScreenshot ?? true; const saveHtml = opts.saveHtml ?? true; const saveTrace = opts.saveTrace ?? true; const now = args.now ?? (() => new Date()); const key = failureBundleKey(args.result.url, args.sequence); const bundleDir = join(opts.dir, key); mkdirSync(bundleDir, { recursive: true }); let wroteScreenshot = false; if (saveScreenshot && args.screenshot) { writeFileSync(join(bundleDir, "screenshot.png"), args.screenshot); wroteScreenshot = true; } let wroteHtml = false; if (saveHtml && typeof args.html === "string") { writeFileSync(join(bundleDir, "page.html"), args.html); wroteHtml = true; } // errors.json — full PageError[]; readers can grep for type=console etc. writeFileSync( join(bundleDir, "errors.json"), JSON.stringify(args.result.errors, null, 2) ); let wroteTrace = false; if (saveTrace && args.trace && args.trace.length > 0) { writeFileSync(join(bundleDir, "trace.jsonl"), serializeTrace(args.trace)); wroteTrace = true; const reproScript = buildReproScript({ baseUrl: args.baseUrl, tracePath: "./trace.jsonl", }); writeFileSync(join(bundleDir, "repro.sh"), reproScript, { mode: 0o755 }); } const info: FailureBundleInfo = { url: args.result.url, status: args.result.status, statusCode: args.result.statusCode, loadTime: args.result.loadTime, hasErrors: args.result.hasErrors, errorCount: args.result.errors.length, warningCount: args.result.warnings.length, discoveryMethod: args.result.discoveryMethod, sourceUrl: args.result.sourceUrl, sourceElement: args.result.sourceElement, recovery: args.result.recovery, baseUrl: args.baseUrl, seed: args.seed, sequence: args.sequence, createdAt: now().toISOString(), artifacts: { screenshot: wroteScreenshot, html: wroteHtml, trace: wroteTrace, }, }; writeFileSync(join(bundleDir, "info.json"), JSON.stringify(info, null, 2)); return bundleDir; }