diff --git a/scripts/test-paid-shards.ts b/scripts/test-paid-shards.ts index 634b3a4d3..514d3d8d2 100644 --- a/scripts/test-paid-shards.ts +++ b/scripts/test-paid-shards.ts @@ -50,7 +50,7 @@ * bun run scripts/test-paid-shards.ts --timeout 600 --jobs 2 */ -import { spawn, type ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { normalizeRelativePath } from './test-free-shards'; @@ -430,8 +430,8 @@ export async function runPaidShard( let exitCode: number | null = null; try { const streams: Array> = []; - if (child.stdout) streams.push(forwardAndClassify(child.stdout, sink(process.stdout), classifier)); - if (child.stderr) streams.push(forwardAndClassify(child.stderr, sink(process.stderr), classifier)); + if (child.stdout) streams.push(forwardAndClassify(child.stdout, sink(process.stdout), classifier, 'stdout')); + if (child.stderr) streams.push(forwardAndClassify(child.stderr, sink(process.stderr), classifier, 'stderr')); exitCode = await new Promise((resolve, reject) => { child.once('error', reject); child.once('close', (code) => resolve(code)); diff --git a/scripts/test-strict-output.ts b/scripts/test-strict-output.ts index 1eb9aca92..5fdfa5af4 100644 --- a/scripts/test-strict-output.ts +++ b/scripts/test-strict-output.ts @@ -155,25 +155,39 @@ export function parseBunTerminalSummaryLine(rawLine: string): number | null { return match ? Number.parseInt(match[1], 10) : null; } -/** Incrementally classifies output without assuming process chunks align to lines. */ +/** + * Incrementally classifies output without assuming process chunks align to + * lines. Buffers are PER ORIGIN: stdout and stderr are independent pipes, so + * a chunk from one can arrive between two halves of a line from the other. + * A single shared buffer would glue those fragments into garbled lines — a + * sheared `(fail)` line goes uncounted and a sheared terminal summary reads + * as truncation. Counters are shared; only line assembly is per-stream. + */ +export type ClassifierOrigin = 'stdout' | 'stderr'; + export class BunTestOutputClassifier { - private readonly decoder = new StringDecoder('utf8'); - private pending = ''; + private readonly decoders: Record = { + stdout: new StringDecoder('utf8'), + stderr: new StringDecoder('utf8'), + }; + private pending: Record = { stdout: '', stderr: '' }; private failedTests = 0; private unhandledBetweenTests = 0; private terminalFileCounts: number[] = []; - write(chunk: Uint8Array | string): void { - this.pending += typeof chunk === 'string' + write(chunk: Uint8Array | string, origin: ClassifierOrigin = 'stdout'): void { + this.pending[origin] += typeof chunk === 'string' ? chunk - : this.decoder.write(Buffer.from(chunk)); - this.consumeCompleteLines(); + : this.decoders[origin].write(Buffer.from(chunk)); + this.consumeCompleteLines(origin); } end(): BunTestOutputSummary { - this.pending += this.decoder.end(); - if (this.pending.length > 0) this.classify(this.pending); - this.pending = ''; + for (const origin of ['stdout', 'stderr'] as const) { + this.pending[origin] += this.decoders[origin].end(); + if (this.pending[origin].length > 0) this.classify(this.pending[origin]); + this.pending[origin] = ''; + } return this.summary(); } @@ -185,12 +199,12 @@ export class BunTestOutputClassifier { }; } - private consumeCompleteLines(): void { - let newline = this.pending.indexOf('\n'); + private consumeCompleteLines(origin: ClassifierOrigin): void { + let newline = this.pending[origin].indexOf('\n'); while (newline !== -1) { - this.classify(this.pending.slice(0, newline)); - this.pending = this.pending.slice(newline + 1); - newline = this.pending.indexOf('\n'); + this.classify(this.pending[origin].slice(0, newline)); + this.pending[origin] = this.pending[origin].slice(newline + 1); + newline = this.pending[origin].indexOf('\n'); } } @@ -227,10 +241,11 @@ export function forwardAndClassify( stream: NodeJS.ReadableStream, destination: NodeJS.WriteStream, classifier: BunTestOutputClassifier, + origin: ClassifierOrigin = 'stdout', ): Promise { return new Promise((resolve, reject) => { stream.on('data', (chunk: Buffer | string) => { - classifier.write(chunk); + classifier.write(chunk, origin); destination.write(chunk); }); stream.on('end', resolve); diff --git a/test/strict-output.test.ts b/test/strict-output.test.ts index 52f79fd48..149171a3e 100644 --- a/test/strict-output.test.ts +++ b/test/strict-output.test.ts @@ -58,4 +58,28 @@ describe('BunTestOutputClassifier', () => { // passes: 1 file ran, which is what was expected expect(strictTestExitCode(0, summary, 1)).toBe(0); }); + + // stdout and stderr are independent pipes: a chunk from one can land + // between two halves of a line from the other. A single shared buffer + // glues the fragments into garbled lines — a sheared (fail) line goes + // uncounted (defeating the exit-0-with-failures backstop) and a sheared + // summary reads as truncation. Per-origin buffers keep each stream whole. + it('a stderr chunk arriving mid-stdout-line does not shear either line', () => { + const c = new BunTestOutputClassifier(); + c.write('some stdout noise without a newline yet', 'stdout'); + c.write('(fail) planted [0.10ms]\n', 'stderr'); + c.write(' ...rest of the stdout line\n', 'stdout'); + const summary = c.end(); + expect(summary.failedTests).toBe(1); + }); + + it('a terminal summary split around a cross-stream chunk still counts', () => { + const c = new BunTestOutputClassifier(); + c.write('Ran 4 tests acr', 'stdout'); + c.write('stderr diagnostics line\n', 'stderr'); + c.write('oss 2 files. [1.00s]\n', 'stdout'); + const summary = c.end(); + expect(summary.terminalFileCounts).toEqual([2]); + expect(strictTestExitCode(0, summary, 2)).toBe(0); + }); });