test(cli): isolate startup regression from shared builds (#2413)

This commit is contained in:
JATMN
2026-10-24 17:22:50 -07:00
committed by GitHub
parent 2a6d75ee92
commit 11a1caa292
+96 -48
View File
@@ -14,8 +14,10 @@ import {
mock,
} from 'bun:test'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { copyFile, mkdtemp, rm, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Command } from '@commander-js/extra-typings'
import { applyChildProcessHeapOptions } from './applyChildProcessHeapOptions.js'
import {
@@ -1114,84 +1116,130 @@ describe('cli.tsx — background routing behavior', () => {
})
describe('Node 24 premature exit regression (issue #1678)', () => {
let buildDir: string
beforeAll(async () => {
const repoRoot = resolve(import.meta.dir, '../..')
buildDir = await mkdtemp(join(tmpdir(), 'openclaude-startup-test-'))
// Build current source privately, as in commanderCompatibility.test.ts.
// Unit tests must not depend on a missing or stale repository dist bundle.
for (const directory of ['src', 'scripts', 'node_modules']) {
await symlink(join(repoRoot, directory), join(buildDir, directory), 'junction')
}
for (const file of ['package.json', 'tsconfig.json']) {
await copyFile(join(repoRoot, file), join(buildDir, file))
}
const build = Bun.spawn([process.execPath, 'run', 'scripts/build.ts'], {
cwd: buildDir,
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
timeout: 120_000,
})
const [exitCode, stdout, stderr] = await Promise.all([
build.exited,
new Response(build.stdout).text(),
new Response(build.stderr).text(),
])
if (exitCode !== 0) {
throw new Error(`CLI build failed (${exitCode}):\n${stdout}\n${stderr}`)
}
}, 130_000) // Leave time beyond the build deadline for output and cleanup.
afterAll(async () => {
if (buildDir) await rm(buildDir, { recursive: true, force: true })
})
it('built CLI stays alive during initialization in interactive mode without premature exit', async () => {
const os = await import('node:os')
const path = await import('node:path')
const fs = await import('node:fs/promises')
const url = await import('node:url')
const scriptPath = path.join(os.tmpdir(), `test-cli-startup-${Date.now()}.mjs`)
const cliUrl = url.pathToFileURL(path.resolve(import.meta.dir, '../../dist/cli.mjs')).href
let proc
try {
await Bun.write(scriptPath, `
const scriptPath = join(buildDir, 'startup.mjs')
const cliUrl = pathToFileURL(join(buildDir, 'dist/cli.mjs')).href
await Bun.write(scriptPath, `
// Mock TTY so the CLI thinks it's interactive and starts the TUI
process.stdout.isTTY = true;
process.stdin.isTTY = true;
process.stdin.setRawMode = () => {};
process.stdin.ref = () => process.stdin;
process.stdin.unref = () => process.stdin;
process.env.OPENCLAUDE_DISABLE_TELEMETRY = '1';
process.env.OPENGATEWAY_API_KEY = 'dummy';
// Ensure the CLI auto-runs even if the test runner disabled it globally
delete process.env.OPENCLAUDE_DISABLE_CLI_ENTRYPOINT_AUTO_RUN;
// Use absolute import to work from os.tmpdir()
// If the entrypoint uses void main(), this promise resolves immediately.
// If it correctly uses await main(), it stays pending while the CLI runs.
import('${cliUrl}').then(() => {
import(${JSON.stringify(cliUrl)}).then(() => {
console.log('---PREMATURE_EVAL_END---');
process.exit(0);
});
`)
proc = Bun.spawn(['node', scriptPath], { stdout: 'pipe' })
const reader = proc.stdout.getReader()
let gotOutput = false
let evaluationEndedPrematurely = false
async function readStdout() {
// Deliberately supply only the environment needed by this interactive
// fixture. Parent provider settings, CI, and auto-run guards must not
// change startup, and no real user/project configuration should be read.
const proc = Bun.spawn(['node', scriptPath], {
cwd: buildDir,
env: {
PATH: process.env.PATH,
SystemRoot: process.env.SystemRoot,
TMPDIR: tmpdir(),
TEMP: tmpdir(),
TMP: tmpdir(),
HOME: buildDir,
USERPROFILE: buildDir,
OPENCLAUDE_CONFIG_DIR: join(buildDir, 'config'),
XDG_CONFIG_HOME: join(buildDir, 'config'),
XDG_CACHE_HOME: join(buildDir, 'cache'),
NODE_COMPILE_CACHE: join(buildDir, 'cache'),
OPENCLAUDE_DISABLE_TELEMETRY: '1',
OPENGATEWAY_API_KEY: 'dummy',
},
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
timeout: 8_000,
})
let stdout = ''
let stderr = ''
async function capture(stream: ReadableStream<Uint8Array>, append: (text: string) => void) {
const reader = stream.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = new TextDecoder().decode(value)
if (text.includes('---PREMATURE_EVAL_END---')) {
evaluationEndedPrematurely = true
} else if (text.trim().length > 0) {
gotOutput = true
}
append(decoder.decode(value, { stream: true }))
}
append(decoder.decode())
} finally {
reader.releaseLock()
}
}
const output = Promise.all([
capture(proc.stdout, text => { stdout += text }),
capture(proc.stderr, text => { stderr += text }),
])
// Start reading without awaiting it yet
const readPromise = readStdout()
// Wait until we get startup output or detect premature evaluation end
const start = Date.now()
while (!gotOutput && !evaluationEndedPrematurely && Date.now() - start < 5000) {
try {
// Wait for the real startup banner, a premature evaluation, or an exit.
const deadline = Date.now() + 7_000
while (!stdout.includes('Ready — type') &&
!stdout.includes('---PREMATURE_EVAL_END---') &&
proc.exitCode === null && Date.now() < deadline) {
await new Promise(r => setTimeout(r, 10))
}
expect(gotOutput).toBe(true)
if (!stdout.includes('Ready — type')) {
throw new Error(`CLI did not reach startup (exit ${proc.exitCode}):\n${stdout}\n${stderr}`)
}
// The critical regression window: wait 500ms *after* output.
// With void main(), Node 24 will exit during the subsequent async imports because the event loop empties,
// which allows the import() promise above to resolve and emit the signal.
await new Promise(r => setTimeout(r, 500))
expect(evaluationEndedPrematurely).toBe(false)
expect(proc.exitCode).toBe(null)
expect(stdout).not.toContain('---PREMATURE_EVAL_END---')
expect(proc.exitCode, `CLI exited during initialization:\n${stderr}`).toBe(null)
expect(proc.killed).toBe(false)
} finally {
if (proc && proc.exitCode === null && !proc.killed) {
proc.kill()
}
await fs.unlink(scriptPath).catch(() => {})
if (proc.exitCode === null) proc.kill('SIGKILL')
await proc.exited
await output
}
})
}, 10_000) // Leave time beyond the eight-second child deadline for cleanup.
it('cli.tsx uses top-level await for main() to prevent premature exit', async () => {
const src = await Bun.file(`${import.meta.dir}/cli.tsx`).text()