diff --git a/scripts/quality-gate/coverage.test.ts b/scripts/quality-gate/coverage.test.ts index 95501fab..82b0382a 100644 --- a/scripts/quality-gate/coverage.test.ts +++ b/scripts/quality-gate/coverage.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { @@ -13,6 +14,7 @@ import { parseChangedLinesFromDiff, parseLcov, prefixRelativeLcovSourcePaths, + runCommand, } from './coverage' describe('coverage gate helpers', () => { @@ -403,3 +405,52 @@ describe('coverage gate helpers', () => { expect(failures).toEqual(['new-suite: branches coverage 80% is below minimum 85%']) }) }) + +describe('coverage subprocess output', () => { + test('captures large synchronous reports to regular files without losing artifacts or exit status', async () => { + const root = mkdtempSync(join(tmpdir(), 'cc-haha-coverage-output-')) + const script = join(root, 'reporter.ts') + const logPath = join(root, 'logs', 'coverage.log') + const summary = '\nRan 1 test across 1 file. [1.00ms]\n' + const lcov = 'SF:src/example.ts\nDA:1,1\nLF:1\nLH:1\nend_of_record\n' + const rounds = 128 + const chunkSize = 64 * 1024 + try { + writeFileSync(script, ` + import { fstatSync, writeSync, writeFileSync } from 'node:fs' + // A synchronous reporter must not depend on a nonblocking pipe's + // available buffer capacity. Assert the child receives disk-backed FDs. + if (!fstatSync(1).isFile() || !fstatSync(2).isFile()) { + throw new Error('synchronous coverage reporter requires regular output files') + } + const stdoutChunk = Buffer.alloc(${chunkSize}, 'o') + const stderrChunk = Buffer.alloc(${chunkSize}, 'e') + for (let round = 0; round < ${rounds}; round++) { + writeSync(1, stdoutChunk) + writeSync(2, stderrChunk) + } + writeFileSync('lcov.info', ${JSON.stringify(lcov)}) + writeSync(2, ${JSON.stringify(summary)}) + process.exit(7) + `) + const command = [process.execPath, '--no-env-file', script] + const result = await runCommand(command, root, logPath) + expect(result.exitCode).toBe(7) + expect(parseBunTestFileCount(result.output)).toBe(1) + expect(result.output.length).toBe(rounds * chunkSize * 2 + summary.length) + const expected = createHash('sha256') + for (let round = 0; round < rounds; round++) { + expected.update(Buffer.alloc(chunkSize, 'o')) + expected.update(Buffer.alloc(chunkSize, 'e')) + } + expected.update(summary) + expect(createHash('sha256').update(result.output).digest('hex')) + .toBe(expected.digest('hex')) + expect(readFileSync(logPath, 'utf8').slice(`$ ${command.join(' ')}\n`.length)) + .toBe(result.output) + expect(hasUsableLcov(readFileSync(join(root, 'lcov.info'), 'utf8'))).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/scripts/quality-gate/coverage.ts b/scripts/quality-gate/coverage.ts index 0123a345..164e930e 100644 --- a/scripts/quality-gate/coverage.ts +++ b/scripts/quality-gate/coverage.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, relative, sep, win32 } from 'node:path' import { rootBunTestFilter } from '../pr/bun-test-filter' @@ -448,29 +448,35 @@ export function hasUsableCoverageSummary(summary: CoverageSummary) { return Object.values(summary).some((coverage) => coverage.total > 0) } -async function runCommand(command: string[], cwd: string, logPath: string) { +export async function runCommand(command: string[], cwd: string, logPath: string) { const started = Date.now() const sandboxHome = mkdtempSync(join(tmpdir(), 'cc-haha-coverage-test-')) + const header = `$ ${command.join(' ')}\n` + let logFd: number | undefined try { + mkdirSync(dirname(logPath), { recursive: true }) + logFd = openSync(logPath, 'w') + writeFileSync(logFd, header) + // Bun's synchronous text coverage reporter can fail while writing a large + // report to a pipe, before it emits LCOV or its test-count summary. Give + // both streams a regular file descriptor, with no pipe buffer to exhaust. const proc = Bun.spawn(command, { cwd, env: createSandboxedTestEnvironment(sandboxHome), - stdout: 'pipe', - stderr: 'pipe', + stdout: logFd, + stderr: logFd, }) - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]) - mkdirSync(dirname(logPath), { recursive: true }) - writeFileSync(logPath, `$ ${command.join(' ')}\n${stdout}${stderr}`) + const exitCode = await proc.exited + closeSync(logFd) + logFd = undefined + const output = readFileSync(logPath, 'utf8').slice(header.length) return { exitCode, durationMs: Date.now() - started, - output: `${stdout}${stderr}`, + output, } } finally { + if (logFd !== undefined) closeSync(logFd) rmSync(sandboxHome, { recursive: true, force: true }) } }