From 8169b30a802a89c87da83fb1a43664c9d4e43835 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 15 Aug 2026 17:48:21 -0700 Subject: [PATCH] fix(test-runner): bun's headerless failure recap can't invent a phantom failing file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 CI showed the remaining half of the recap bug: bun prints 'N tests failed:' then re-prints every (fail) line with NO file headers, so they attributed to the stale currentFile — an innocent file (test/uninstall.test.ts) was charged with another file's 5 failures. The recap marker now ends attribution (currentFile=null, chunk closed) and recap re-prints of already-recorded test names dedupe; a recap-only failure the main run never attributed still records, unattributed, as belt and braces. --- scripts/test-free-shards.ts | 17 ++++++++++++++++- test/test-free-shards.test.ts | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/scripts/test-free-shards.ts b/scripts/test-free-shards.ts index 0d723e043..41106336a 100755 --- a/scripts/test-free-shards.ts +++ b/scripts/test-free-shards.ts @@ -587,6 +587,7 @@ export class FreeRunReporter { private readonly failures: FreeRunFailure[] = []; private readonly crashed = new Set(); private currentFile: string | null = null; + private inRecap = false; private testsRan: number | null = null; private filesRan: number | null = null; private sawSummary = false; @@ -646,6 +647,16 @@ export class FreeRunReporter { const line = stripAnsiLine(rawLine).replace(/^::group::/, ''); let visible = false; + // Bun's terminal recap ("N tests failed:") re-prints every (fail) line + // WITHOUT re-printing file headers. Attributing those to the stale + // currentFile invented a phantom failing file on the first Linux run + // (5 real failures reported as 10 across 2 files, one innocent). + if (/^\d+ tests? failed:$/.test(line)) { + this.inRecap = true; + if (this.currentFile) this.progressFor(this.currentFile).ended = true; + this.currentFile = null; + } + const header = FILE_HEADER_RE.exec(line); if (header) { const file = this.canonicalize(header[1]); @@ -661,8 +672,12 @@ export class FreeRunReporter { const final = fail || retry ? null : CRASH_FINAL_RE.exec(line); if (fail) { visible = true; + // In the recap, a (fail) line only records a failure the main run + // somehow never attributed (belt and braces); known names dedupe. + const recapDuplicate = this.inRecap + && this.failures.some((f) => f.testName === fail[1]); const key = `${this.currentFile ?? ''}\u0000${fail[1]}`; - if (!this.failureKeys.has(key)) { + if (!recapDuplicate && !this.failureKeys.has(key)) { this.failureKeys.add(key); this.failures.push({ file: this.currentFile, testName: fail[1] }); } diff --git a/test/test-free-shards.test.ts b/test/test-free-shards.test.ts index acf676391..f42475378 100644 --- a/test/test-free-shards.test.ts +++ b/test/test-free-shards.test.ts @@ -505,6 +505,23 @@ describe('test-free-shards: GitHub Actions log-group attribution', () => { reporter.end(); expect(reporter.report().failures).toEqual([{ file: 'test/b.test.ts', testName: 'planted' }]); }); + + test('headerless recap re-prints do not invent a phantom failing file', () => { + // Round-3 CI shape: bun's recap prints "N tests failed:" then the (fail) + // lines with NO file headers — the stale currentFile (an innocent file) + // was charged with the previous file's failures. + const reporter = new FreeRunReporter(['test/a.test.ts', 'test/b.test.ts']); + reporter.write('::group::test/a.test.ts:\n', 'stderr'); + reporter.write(`${failLine('planted')}\n`, 'stderr'); + reporter.write('::endgroup::\n', 'stderr'); + reporter.write('::group::test/b.test.ts:\n', 'stderr'); + reporter.write('(pass-ish output, no failures here)\n', 'stderr'); + reporter.write('2 tests failed:\n', 'stderr'); + reporter.write(`${failLine('planted')}\n`, 'stderr'); + reporter.write(`${failLine('planted')}\n`, 'stderr'); + reporter.end(); + expect(reporter.report().failures).toEqual([{ file: 'test/a.test.ts', testName: 'planted' }]); + }); }); describe('test-free-shards: curated-list census pins', () => {