diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index edc6e0c3a..bee1ce9c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -105,7 +105,7 @@ jobs: run: bun run build - name: Run unit tests - run: bun test --feature=UNATTENDED_RETRY --max-concurrency=1 + run: bun run test - name: Smoke test run: bun run smoke diff --git a/scripts/run-tests.ts b/scripts/run-tests.ts index 90c1ceb35..1c149d154 100644 --- a/scripts/run-tests.ts +++ b/scripts/run-tests.ts @@ -1,3 +1,4 @@ +import { testReportStatus } from '../src/test/testReport.js' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { closeSync, fstatSync, openSync, readSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -125,13 +126,13 @@ try { } if (result !== 0 && exitCode === 0) exitCode = result const report = await readFile(reportPath, 'utf8').catch(() => '') - const summary = report.match(/]*>/)?.[0] - if (!summary || !report.trimEnd().endsWith('')) { + const status = testReportStatus(report) + if (status === 'incomplete') { console.error('Test runner exited without a complete JUnit report.') if (exitCode === 0) exitCode = 1 } else { completed++ - if (!/\bfailures="0"/.test(summary) || / process.exit(0)) + test('unreached assertion', () => expect(true).toBe(false)) +} else if (scenario === 'complete') { + test('completed child sentinel', () => expect(inChild).toBe(true)) +} else if (scenario === 'assertion-failure') { test('child assertion failure sentinel', () => { expect('actual child value').toBe('expected child value') }) diff --git a/src/test/providerModuleIsolation.test.ts b/src/test/providerModuleIsolation.test.ts index 575877879..e8176d591 100644 --- a/src/test/providerModuleIsolation.test.ts +++ b/src/test/providerModuleIsolation.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test' -import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { @@ -75,33 +75,47 @@ for (const scenario of [ }, { timeout: testTimeout }) } -test('propagates child assertion failures through the launcher', async () => { - await withFixture('assertion-failure', async (entry, env) => { - // Run the real launcher in a disposable process so coordinator env and mocks - // remain untouched even when other suites share this process. - const runner = join(entry, '..', 'launcher.ts') - writeFileSync(runner, ` - import { runTestFileWithRealProviders } from ${JSON.stringify( - join(import.meta.dir, 'providerModuleIsolation.ts'), - )} - await runTestFileWithRealProviders(${JSON.stringify(entry)}) - `) - const child = Bun.spawn({ - cmd: [process.execPath, runner], - env, - stdout: 'pipe', - stderr: 'pipe', - timeout: fixtureTimeout, - killSignal: process.platform === 'win32' ? undefined : 'SIGKILL', +for (const scenario of ['assertion-failure', 'early-exit', 'complete']) { + test(`verifies isolated child completion: ${scenario}`, async () => { + await withFixture(scenario, async (entry, env) => { + // Run the real launcher in a disposable process so coordinator env and mocks + // remain untouched even when other suites share this process. + const runner = join(entry, '..', 'launcher.test.ts') + writeFileSync(runner, ` + import { test } from 'bun:test' + import { runTestFileWithRealProviders } from ${JSON.stringify( + join(import.meta.dir, 'providerModuleIsolation.ts'), + )} + test('nested launcher', () => runTestFileWithRealProviders(${JSON.stringify(entry)}), ${REAL_PROVIDER_TEST_TIMEOUT_MS + 5_000}) + `) + const child = Bun.spawn({ + cmd: [process.execPath, join(import.meta.dir, '../../scripts/run-tests.ts'), runner], + env: { ...env, TMPDIR: join(entry, '..'), TMP: join(entry, '..'), TEMP: join(entry, '..') }, + stdout: 'pipe', + stderr: 'pipe', + timeout: fixtureTimeout, + killSignal: process.platform === 'win32' ? undefined : 'SIGKILL', + }) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + const output = `${stdout}\n${stderr}` + expect(exitCode, output).toBe(scenario === 'complete' ? 0 : 1) + if (scenario !== 'complete') { + expect(output).toContain('provider-isolated test process failed') + expect(output).toMatch(/^\s*1 fail$/m) + expect(output).toContain(scenario === 'early-exit' + ? 'without a complete JUnit report' + : 'expected child value') + if (scenario === 'assertion-failure') expect(output).toContain('child assertion failure sentinel') + } else { + expect(output).toMatch(/^\s*1 pass$/m) + } + expect(readdirSync(join(entry, '..')).filter(name => + name.startsWith('openclaude-test-report-'), + )).toEqual([]) }) - const [exitCode, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text(), - ]) - expect(exitCode).not.toBe(0) - expect(`${stdout}\n${stderr}`).toContain('provider-isolated test process failed') - expect(`${stdout}\n${stderr}`).toContain('child assertion failure sentinel') - expect(`${stdout}\n${stderr}`).toContain('expected child value') - }) -}, { timeout: testTimeout }) + }, { timeout: testTimeout }) +} diff --git a/src/test/providerModuleIsolation.ts b/src/test/providerModuleIsolation.ts index 938812350..176b907b9 100644 --- a/src/test/providerModuleIsolation.ts +++ b/src/test/providerModuleIsolation.ts @@ -1,3 +1,8 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { testReportStatus } from './testReport.js' + export const REAL_PROVIDER_TEST_CHILD_ENV = 'OPENCLAUDE_REAL_PROVIDER_TEST_CHILD' export const REAL_PROVIDER_TEST_TIMEOUT_MS = 30_000 @@ -91,26 +96,37 @@ export async function attributionModulesNeedIsolation(): Promise { export async function runTestFileWithRealProviders( testFile: string, ): Promise { - const child = Bun.spawn({ - cmd: [process.execPath, 'test', testFile], - cwd: process.cwd(), - env: { - ...process.env, - [REAL_PROVIDER_TEST_CHILD_ENV]: '1', - }, - stdout: 'pipe', - stderr: 'pipe', - timeout: REAL_PROVIDER_TEST_TIMEOUT_MS, - killSignal: process.platform === 'win32' ? undefined : 'SIGKILL', - }) - const [exitCode, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text(), - ]) - if (exitCode !== 0) { - throw new Error( - `provider-isolated test process failed for ${testFile} (${exitCode})\n${stdout}\n${stderr}`, - ) + const reportDir = await mkdtemp(join(tmpdir(), 'openclaude-test-report-')) + const reportPath = join(reportDir, 'results.xml') + try { + const child = Bun.spawn({ + cmd: [process.execPath, 'test', testFile, '--reporter=junit', `--reporter-outfile=${reportPath}`], + cwd: process.cwd(), + env: { + ...process.env, + [REAL_PROVIDER_TEST_CHILD_ENV]: '1', + }, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + timeout: REAL_PROVIDER_TEST_TIMEOUT_MS, + killSignal: process.platform === 'win32' ? undefined : 'SIGKILL', + }) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + const status = testReportStatus(await readFile(reportPath, 'utf8').catch(() => '')) + if (exitCode !== 0 || status !== 'passed') { + const reason = status === 'incomplete' + ? 'Test runner exited without a complete JUnit report.' + : status === 'failed' ? 'Test runner reported failures.' : '' + throw new Error( + `provider-isolated test process failed for ${testFile} (${exitCode})\n${reason}\n${stdout}\n${stderr}`, + ) + } + } finally { + await rm(reportDir, { recursive: true, force: true }) } } diff --git a/src/test/testReport.ts b/src/test/testReport.ts new file mode 100644 index 000000000..2154d16ef --- /dev/null +++ b/src/test/testReport.ts @@ -0,0 +1,8 @@ +/** Verify the completion and failure markers in Bun's final JUnit report. */ +export function testReportStatus(report: string): 'incomplete' | 'failed' | 'passed' { + const summary = report.match(/]*>/)?.[0] + if (!summary || !report.trimEnd().endsWith('')) return 'incomplete' + return !/\bfailures="0"/.test(summary) || /