mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 20:58:14 -04:00
test(cli): isolate startup regression from shared builds (#2413)
This commit is contained in:
+96
-48
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user