fix(test-runner): bun's headerless failure recap can't invent a phantom failing file

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.
This commit is contained in:
Garry Tan 2026-08-15 17:48:21 -07:00
parent aca5061274
commit 8169b30a80
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 33 additions and 1 deletions

View File

@ -587,6 +587,7 @@ export class FreeRunReporter {
private readonly failures: FreeRunFailure[] = [];
private readonly crashed = new Set<string>();
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] });
}

View File

@ -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', () => {