import { promises as fsp } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from './spawnClaude' let dir: string beforeEach(async () => { dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'fake-claude-')) }) afterEach(async () => { await fsp.rm(dir, { recursive: true, force: true }) }) /** 假的 claude:一段 sh 脚本 */ async function fakeBin(script: string): Promise { const bin = path.join(dir, 'claude') await fsp.writeFile(bin, `#!/bin/sh\n${script}\n`, { mode: 0o755 }) return bin } describe('跑 claude、逐行交出 stdout', () => { it('每出来完整的一行交一行,最后没带换行的那段结束时交出;参数原样传、在 cwd 里跑', async () => { const bin = await fakeBin('printf \'{"a":1}\\n{"b":\'; sleep 0.1; printf \'2}\\n\'; pwd; printf "%s|" "$@"') const lines: string[] = [] await runClaudeLines({ bin, args: ['-p', '带 空格 的问题'], cwd: dir, onLine: item => lines.push(item) }) expect(lines).toEqual(['{"a":1}', '{"b":2}', await fsp.realpath(dir), '-p|带 空格 的问题|']) }) it('给了 input 就写进 stdin 再关上(stream-json 的消息);不给时 stdin 是空的,读 stdin 的也不会卡住', async () => { const bin = await fakeBin('cat; echo end') const withInput: string[] = [] await runClaudeLines({ bin, args: [], cwd: dir, input: '{"type":"user"}\n', onLine: item => withInput.push(item) }) expect(withInput).toEqual(['{"type":"user"}', 'end']) const without: string[] = [] await runClaudeLines({ bin, args: [], cwd: dir, timeoutMs: 2000, onLine: item => without.push(item) }) expect(without).toEqual(['end']) }) it('退出码非零:带上 stderr 的原话', async () => { const bin = await fakeBin('echo "No conversation found with session ID: x" >&2; exit 1') const error = await runClaudeLines({ bin, args: [], cwd: dir, onLine: () => {} }).catch((err: unknown) => err) expect(error).toBeInstanceOf(ClaudeError) expect((error as ClaudeError).message).toBe('Claude 退出码非零 (1): No conversation found with session ID: x') }) it('叫停:马上结束进程,报 ClaudeCancelledError;已经叫停的不再启动', async () => { const marker = path.join(dir, 'still-running') const bin = await fakeBin(`echo started; sleep 2; touch "${marker}"`) const controller = new AbortController() const started = Date.now() const run = runClaudeLines({ bin, args: [], cwd: dir, signal: controller.signal, onLine: () => controller.abort() }) await expect(run).rejects.toBeInstanceOf(ClaudeCancelledError) expect(Date.now() - started).toBeLessThan(1500) await new Promise(resolve => setTimeout(resolve, 2500)) await expect(fsp.access(marker)).rejects.toThrow() await expect(runClaudeLines({ bin, args: [], cwd: dir, signal: controller.signal, onLine: () => {} })).rejects.toBeInstanceOf(ClaudeCancelledError) }, 10_000) it('超时:结束进程,报 ClaudeTimeoutError', async () => { const bin = await fakeBin('sleep 5') await expect(runClaudeLines({ bin, args: [], cwd: dir, timeoutMs: 200, onLine: () => {} })).rejects.toBeInstanceOf(ClaudeTimeoutError) }) })