/** * 跑一轮的测试:参数拼装 + stream-json 解析 + 假进程的整轮行为。 * * 不需要真的 ZCode、也不需要模型 —— 用可注入的 spawn 造一个说同样协议的假进程。 * 这样「--mode 忘了传」「结果行没解析对」「超时不杀进程树」这些问题 * 都能在本地断言,而不是等到线上某封信没人回。 */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { buildRunArgs, parseStreamLine, runTurn } from '../src/zcode-run.mjs'; /** 造一个假子进程:按脚本吐 stdout/stderr,然后以指定退出码关闭。 */ function fakeSpawn({ stdout = '', stderr = '', exitCode = 0, onStart, neverExit = false } = {}) { const calls = []; const fn = (file, args, opts) => { calls.push({ file, args, opts }); const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.pid = 4242; child.kill = () => true; onStart?.(child, { file, args, opts }); setImmediate(() => { if (stdout) child.stdout.emit('data', Buffer.from(stdout)); if (stderr) child.stderr.emit('data', Buffer.from(stderr)); if (!neverExit) { child.exitCode = exitCode; child.emit('close', exitCode); } }); return child; }; fn.calls = calls; return fn; } // ─── 参数拼装 ───────────────────────────────────────────────────── test('★ 参数里必须带 --mode(不传等于默认 yolo,会绕过授权)', () => { const args = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build' }); assert.ok(args.includes('--mode')); assert.equal(args[args.indexOf('--mode') + 1], 'build'); }); test('★ 漏传 mode 直接抛错,而不是悄悄退回默认', () => { // 这是刻意的:静默退回意味着「授权系统消失但没人发现」。 assert.throws(() => buildRunArgs({ prompt: 'p', cwd: '/tmp' }), /mode/); }); test('默认用 stream-json,便于把过程写进日志', () => { const args = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build' }); assert.equal(args[args.indexOf('--output-format') + 1], 'stream-json'); }); test('resume 只在有时才带(首轮不该带空 --resume)', () => { const first = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build' }); assert.equal(first.includes('--resume'), false); const next = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build', resumeSessionId: 'sess_1' }); assert.equal(next[next.indexOf('--resume') + 1], 'sess_1'); }); test('maxTurns 与工具黑白名单按需传递', () => { const args = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'plan', maxTurns: 6, allowedTools: ['Read', 'Grep'], disallowedTools: ['Bash'] }); assert.equal(args[args.indexOf('--max-turns') + 1], '6'); assert.equal(args[args.indexOf('--allowed-tools') + 1], 'Read,Grep'); assert.equal(args[args.indexOf('--disallowed-tools') + 1], 'Bash'); }); // ─── 解析 ───────────────────────────────────────────────────────── test('result 行取出 sessionId 与 response', () => { const r = parseStreamLine( JSON.stringify({ type: 'result', sessionId: 'sess_abc', response: '做完了', eventCount: 3 }) ); assert.deepEqual(r, { kind: 'result', sessionId: 'sess_abc', response: '做完了', eventCount: 3, projection: undefined }); }); test('普通事件行归为 event', () => { const e = parseStreamLine(JSON.stringify({ type: 'tool.call.started', toolName: 'Bash' })); assert.equal(e.kind, 'event'); assert.equal(e.event.toolName, 'Bash'); }); test('非 JSON / 空行返回 null(不抛)', () => { for (const line of ['', ' ', 'not json', '[1,2]', 'null', '42']) { assert.equal(parseStreamLine(line), null, JSON.stringify(line)); } }); test('★ 输出格式变了会被计数,而不是静默当成没输出', async () => { // 若 CLI 换掉了输出格式,所有的行都会变成不可解析 —— 那时必须能看见 // 「解析不了的行有 N 条」,否则现象是「回合跑完了但什么都没回」。 const spawn = fakeSpawn({ stdout: 'human readable output\nmore text\n' }); const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn }); assert.equal(r.unparsable, 2); assert.equal(r.response, ''); }); // ─── 整轮行为 ───────────────────────────────────────────────────── test('整轮:解析事件、取出最终回复与会话 id', async () => { const spawn = fakeSpawn({ stdout: JSON.stringify({ type: 'tool.call.started', toolName: 'Read' }) + '\n' + JSON.stringify({ type: 'result', sessionId: 'sess_9', response: '结论:可以' }) + '\n' }); const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn }); assert.equal(r.sessionId, 'sess_9'); assert.equal(r.response, '结论:可以'); assert.equal(r.events.length, 1); assert.equal(r.exitCode, 0); }); test('最后一行没有换行也能收到', async () => { const spawn = fakeSpawn({ stdout: JSON.stringify({ type: 'result', sessionId: 's', response: 'ok' }) }); const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn }); assert.equal(r.response, 'ok'); }); test('分块到达(一条 JSON 被切成两半)也能拼回来', async () => { const payload = JSON.stringify({ type: 'result', sessionId: 'sess_split', response: '完整' }); const half = Math.floor(payload.length / 2); const spawn = fakeSpawn({ onStart: child => { setImmediate(() => { child.stdout.emit('data', Buffer.from(payload.slice(0, half))); child.stdout.emit('data', Buffer.from(`${payload.slice(half)}\n`)); child.emit('close', 0); }); }, neverExit: true }); const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn }); assert.equal(r.response, '完整'); }); test('非零退出码原样带出(不吞成成功)', async () => { const spawn = fakeSpawn({ stdout: '', stderr: 'boom\n', exitCode: 7 }); const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn }); assert.equal(r.exitCode, 7); assert.match(r.stderrTail, /boom/); }); test('spawn 本身失败时给可读结果,而不是抛出去', async () => { const spawn = () => { throw new Error('ENOENT'); }; const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn }); assert.equal(r.exitCode, -1); assert.match(r.stderrTail, /ENOENT/); }); test('★ 超时会标记 timedOut 并杀进程树', async () => { const signals = []; const spawn = fakeSpawn({ neverExit: true, onStart: child => { child.kill = sig => { signals.push(sig); }; } }); // 孩子永不退出:这是最坏情况 —— 既不退也不报错。 const r = await runTurn( { prompt: 'p', cwd: '/tmp', mode: 'build', turnTimeoutMs: 60, killGraceMs: 60, settleGraceMs: 60 }, { spawn } ); assert.equal(r.timedOut, true, '必须报告超时'); assert.equal(r.exitCode, -1); // ★ 更关键的是**它一定会结束**:不结束的话驱动会对这封信永远挂住, // 而队列是串行的,后面的信全都不再被处理。 // 升级阶梯:先礼后兵。只断言「杀过」会漏掉「SIGTERM 之后没升级」—— // 那种情况下要等满宽限期才能收尾,而工具子进程可能已经跑完了坏事。 assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']); }); test('进程组隔离:非 win32 平台用 detached 起', async () => { const spawn = fakeSpawn({ stdout: '' }); await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn }); const opts = spawn.calls[0].opts; if (process.platform !== 'win32') assert.equal(opts.detached, true); assert.equal(opts.cwd, '/tmp'); }); test('注入的环境变量会传给子进程(钩子靠它判断档位与有无本地界面)', async () => { const spawn = fakeSpawn({ stdout: '' }); await runTurn( { prompt: 'p', cwd: '/tmp', mode: 'build', env: { AGENTMAIL_SESSION_ID: 'sess-x', AGENTMAIL_PERMISSION_MODE: 'workspace' } }, { spawn } ); const env = spawn.calls[0].opts.env; assert.equal(env.AGENTMAIL_SESSION_ID, 'sess-x'); assert.equal(env.AGENTMAIL_PERMISSION_MODE, 'workspace'); });