fix(test): verify isolated child and release suite completion (#2425)

This commit is contained in:
JATMN
2026-10-26 08:26:19 -07:00
committed by GitHub
parent 7b83353db1
commit ae7eb82187
6 changed files with 100 additions and 56 deletions
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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(/<testsuites\b[^>]*>/)?.[0]
if (!summary || !report.trimEnd().endsWith('</testsuites>')) {
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) || /<failure\b|<error\b/.test(report)) {
if (status === 'failed') {
if (result === 0) console.error('Test runner reported failures despite exiting successfully.')
if (exitCode === 0) exitCode = 1
}
+6 -1
View File
@@ -10,7 +10,12 @@ import {
const scenario = process.env.ISOLATION_FIXTURE_SCENARIO
const inChild = process.env[REAL_PROVIDER_TEST_CHILD_ENV] === '1'
if (scenario === 'assertion-failure') {
if (scenario === 'early-exit') {
test('premature successful exit', () => 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')
})
+44 -30
View File
@@ -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 })
}
+37 -21
View File
@@ -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<boolean> {
export async function runTestFileWithRealProviders(
testFile: string,
): Promise<void> {
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 })
}
}
+8
View File
@@ -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(/<testsuites\b[^>]*>/)?.[0]
if (!summary || !report.trimEnd().endsWith('</testsuites>')) return 'incomplete'
return !/\bfailures="0"/.test(summary) || /<failure\b|<error\b/.test(report)
? 'failed'
: 'passed'
}