diff --git a/scripts/test-free-shards.ts b/scripts/test-free-shards.ts index 54d194ff1..e009f7be6 100755 --- a/scripts/test-free-shards.ts +++ b/scripts/test-free-shards.ts @@ -676,6 +676,8 @@ export interface FreeRunReport { inFlight: string[]; /** Planned files never observed in the stream (silent passers + never-flushed wedges). */ filesWithNoOutput: number; + /** The last nonblank line either stream printed: a native abort's message when bun dies without a summary. */ + lastOutputLine: string | null; } interface FileProgress { @@ -711,6 +713,7 @@ export class FreeRunReporter { private testsRan: number | null = null; private filesRan: number | null = null; private sawSummary = false; + private lastOutputLine: string | null = null; constructor( private readonly plannedFiles: string[], @@ -758,6 +761,7 @@ export class FreeRunReporter { unhandledErrors: [...this.unhandled], inFlight, filesWithNoOutput: this.plannedFiles.filter((f) => !this.progress.has(normalizeRelativePath(f))).length, + lastOutputLine: this.lastOutputLine, }; } @@ -768,6 +772,7 @@ export class FreeRunReporter { // (fail) lines land under a second phantom file (observed on the first // Linux run: 5 real failures reported as 10 across 2 files). const line = stripAnsiLine(rawLine).replace(/^::group::/, ''); + if (line.trim() !== '') this.lastOutputLine = line.trim(); let visible = false; const failedCount = this.failureSummary.consume(line, origin); if (failedCount !== null) { @@ -903,6 +908,10 @@ export function buildRunEpilogue( for (const u of report.unhandledErrors) { lines.push(` ⚠ unhandled error between tests (around ${u.file ?? 'unknown file'})`); } + if (status === 'failed' && !report.sawTerminalSummary) { + lines.push(` ⚠ test process ended before its summary; in flight: ${report.inFlight.length > 0 ? report.inFlight.join(', ') : 'unknown'}`); + if (report.lastOutputLine) lines.push(` ⚠ last output: ${report.lastOutputLine}`); + } if (status === 'timed-out') { if (report.inFlight.length > 0) { lines.push(` ⏱ in flight at kill: ${report.inFlight.join(', ')}`); diff --git a/test/test-free-shards.test.ts b/test/test-free-shards.test.ts index dad210f2e..2ef9d8fbc 100644 --- a/test/test-free-shards.test.ts +++ b/test/test-free-shards.test.ts @@ -805,6 +805,17 @@ describe('test-free-shards: output contract (log capture, quiet console, failure expect(lines.some((l) => l.startsWith('[test:free] FAIL — '))).toBe(true); }, 30_000); + test('a test process that aborts before its summary names the in-flight file and its last output', () => { + const reporter = new FreeRunReporter(['test/done.test.ts', 'test/aborted.test.ts']); + reporter.write('::group::test/done.test.ts:\n(pass) fine\n::endgroup::\n', 'stdout'); + reporter.write('::group::test/aborted.test.ts:\n(pass) first\n', 'stdout'); + reporter.write('GetQueuedCompletionStatusEx: (735) ERROR_ABANDONED_WAIT_0\n', 'stderr'); + reporter.end(); + const lines = buildRunEpilogue('failed', reporter.report(), 16_000, '/tmp/x.log'); + expect(lines).toContain(' ⚠ test process ended before its summary; in flight: test/aborted.test.ts'); + expect(lines).toContain(' ⚠ last output: GetQueuedCompletionStatusEx: (735) ERROR_ABANDONED_WAIT_0'); + }); + test('timeout with no observable header falls back to the buffered-parallel explanation', () => { const reporter = new FreeRunReporter(['test/a.test.ts', 'test/b.test.ts']); reporter.end();