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) || /