/** * Turns a failed test file's raw output into the one line a human reads first. * * Pure (Rule 10.1), and split out of runner.ts so it can be tested — runner.ts * calls main() at import time, so nothing inside it is reachable from a test. * Sibling of parse-line.ts / parse-line.test.ts. */ // Strips ANSI escape sequences so pattern matching works on bun's coloured output const ANSI_RE = /\x1b\[[0-9;]*m/g; export function stripAnsi(s: string): string { return s.replace(ANSI_RE, ''); } export interface StageTally { /** Stages that failed on their own merits. */ failed: number; /** Stages that never ran because an earlier stage had already failed. */ skipped: number; } /** * Split a staged suite's failures into real ones and cascade-skips. * * Staged e2e suites guard every stage with `requireStage`, which throws * `Skipped: ` once anything upstream has failed. bun * counts each of those as a failure, so ONE bad fixture line in stage 1 reports * as "9 failed" in a 10-stage suite — nine counts of a defect that does not * exist, and a summary that buries the one that does. * * The `error: Skipped:` line is the semantic signal, and bun prints it in BOTH * orders relative to the `(fail)` marker: a thrown error's block (source * excerpt, error line, stack) lands BEFORE its marker, while a top-level error * print and bun's own timeout pointer land AFTER it. A one-directional window * misattributes both ways — measured as ce-59f9: a window after a timeout * marker catches the NEXT test's excerpt quoting the `Skipped:` template (and * calls the timeout a skip), and a skip's reason pushed past the window calls * the skip a failure. So each reason line is attributed to its NEAREST marker * instead, ties to the earlier one (a thrown-error block sits closest to the * marker that follows it), and a marker no reason line claims is a failure on * its own merits. */ export function tallyStages(lines: string[]): StageTally { const plain = lines.map(stripAnsi); const isFailure = (l: string): boolean => /^\(fail\)\s+\S/.test(l); const isSkippedReason = (l: string): boolean => /^\s*error:\s*Skipped:\s/.test(l); const markers: number[] = []; for (let i = 0; i < plain.length; i++) { if (isFailure(plain[i])) markers.push(i); } const skippedMarkers = new Set(); const MAX_REASON_DISTANCE = 8; for (let i = 0; i < plain.length; i++) { if (!isSkippedReason(plain[i])) continue; let nearest = -1; let nearestDist = Number.POSITIVE_INFINITY; for (const m of markers) { const dist = Math.abs(m - i); if (dist <= MAX_REASON_DISTANCE && dist < nearestDist) { nearest = m; nearestDist = dist; } } if (nearest >= 0) skippedMarkers.add(nearest); } let failed = 0; let skipped = 0; for (const m of markers) { if (skippedMarkers.has(m)) skipped++; else failed++; } return { failed, skipped }; } export function extractFailureMessage(lines: string[], exitCode: number): string { const plain = lines.map(stripAnsi); // The FIRST failing test, in bun's "(fail) name [Xms]" format, wins over // everything below. In a staged suite every later stage fails too — on // purpose, via requireStage — so any rule that picks by content rather than // by order reports a downstream symptom as the cause. // // That is not hypothetical. technitium-pipeline's stage 0 timed out; the // reported failure was "Module 'namecheap' not found", from a stage that had // only ever been reached because stage 0 died. Three people read that as a // module-import bug. The line that actually mattered — "this test timed out // after 120000ms" — was 60 lines up in output.log and in no summary at all. // // The "✗ name" branch below never fired there: bun prints "✗" only in its // per-file summary, and prints "(fail)" as each test completes. const failIdx = plain.findIndex((l) => /^\(fail\)\s+\S/.test(l)); if (failIdx >= 0) { const testName = plain[failIdx] .replace(/^\(fail\)\s+/, '') .replace(/\s*\[[\d.]+ms\].*$/, '') .trim(); // bun puts the reason on the next line, indented and marked with "^". const reason = (plain[failIdx + 1] ?? '').trim().replace(/^\^\s*/, ''); return reason ? `${testName}: ${reason}` : testName; } // bun's own test failure: "✗ test name [Xms]" — extract the failing test name const bunFailIdx = plain.findIndex((l) => /^\s*✗\s+\S/.test(l) && !l.includes('failed (')); if (bunFailIdx >= 0) { const testName = plain[bunFailIdx] .replace(/^\s*✗\s+/, '') .replace(/\s*\[\d+ms\].*$/, '') .trim(); // Grab the error detail on the next few lines (bun indents it under the test) const detail = plain .slice(bunFailIdx + 1, bunFailIdx + 6) .map((l) => l.trim()) .filter((l) => l.length > 0 && !l.startsWith('at ') && !l.startsWith('│') && l !== '└─') .slice(0, 2) .join(' — '); return detail ? `${testName}: ${detail}` : testName; } // "error: ..." lines (may be indented in bun output) const errorIdx = plain.findIndex( (l) => l.trimStart().startsWith('error:') && !l.includes('bun test'), ); if (errorIdx >= 0) { const context = plain .slice(errorIdx, errorIdx + 5) .map((l) => l.trim()) .filter((l) => l.length > 0 && !l.startsWith('at ')); return context.join('\n '); } const celiloError = plain.find( (l) => l.includes('CeliloCommandError') || (l.includes('celilo') && l.includes('failed')), ); if (celiloError) return celiloError.slice(0, 120); const timeout = plain.find((l) => l.includes('Timeout') || l.includes('timed out')); if (timeout) return timeout.slice(0, 120); // Last resort: print the last few non-empty, non-progress lines from the output const tail = plain .filter( (l) => l.length > 0 && !l.startsWith('[progress') && !l.startsWith('[e2e:') && !l.startsWith('[ansible:'), ) .slice(-3) .map((l) => l.trim()) .join(' | '); return tail || `exit code ${exitCode}`; }