From 11a1caa29215b4541377515cecb41547c5bb4133 Mon Sep 17 00:00:00 2001 From: JATMN Date: Sat, 24 Oct 2026 17:22:50 -0700 Subject: [PATCH] test(cli): isolate startup regression from shared builds (#2413) --- src/entrypoints/cli.test.ts | 144 ++++++++++++++++++++++++------------ 1 file changed, 96 insertions(+), 48 deletions(-) diff --git a/src/entrypoints/cli.test.ts b/src/entrypoints/cli.test.ts index d901360bf..62859fbed 100644 --- a/src/entrypoints/cli.test.ts +++ b/src/entrypoints/cli.test.ts @@ -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, 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()