#!/usr/bin/env node /** * scripts/phase0-test-call.js * * 真跑一遍 mock 模式下的 PM Agent 调用,并验证: * [1] LoopController.createProject 能在 sandbox 模式下分配项目 + git init + 首次 commit * [2] ClaudeCliProtocol(mock=true) 能 connect / sendPrompt / disconnect 无报错 * [3] fixture 的 chunks 全部通过 session_update 流出 * [4] session/result 事件 mock=true & stop_reason=end_turn * [5] LoopController.commitAfterAgent 在工作树无改动时返回 null * [6] 写一个虚拟产出文件 → commitAfterAgent 能产出新 sha * [7] git log 至少 2 个 commit (init + agent 产出) * * 通过即 exit 0,任一失败 exit 1。 * * 用法: LEMONBOSS_CLAUDE_MODE=mock node scripts/phase0-test-call.js * (脚本会自动设置环境变量,无需手动) */ process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; const fs = require('fs'); const path = require('path'); const os = require('os'); const loopController = require('../server/src/core/devloop/loop-controller'); const pathResolver = require('../server/services/devloop/path-resolver'); const gitVault = require('../server/services/devloop/git-vault'); const { ClaudeCliProtocol } = require('../packages/acp-core/protocol/ClaudeCliProtocol'); const results = []; function step(name, fn) { return (async () => { try { const r = await fn(); results.push({ name, ok: true, detail: r?.detail || '' }); return r; } catch (e) { results.push({ name, ok: false, reason: e.message }); throw e; } })(); } async function main() { let pid; // 用唯一 company_id 隔离这次跑(不污染 real data dir 的常规 c-test) const cid = 'c-phase0-' + Date.now().toString(36); try { const proj = await step('createProject (sandbox + git init)', async () => { const r = await loopController.createProject({ company_id: cid, mode: 'sandbox', idea: 'Phase0 acceptance test: minimal devloop scaffolding', }); if (!r.pid?.startsWith('p-')) throw new Error('bad pid: ' + r.pid); if (!fs.existsSync(path.join(r.root_path, '.git'))) { throw new Error('.git not initialized'); } return { detail: `pid=${r.pid} sha=${r.first_commit?.sha?.slice(0, 7) || 'init'}` }; }); pid = (await pathResolver.listProjects()).find(p => p.company_id === cid)?.pid; if (!pid) { const all = pathResolver.listProjects(); pid = all.find(p => p.company_id === cid)?.pid; } if (!pid) throw new Error('cannot resolve pid after createProject'); const protocol = new ClaudeCliProtocol({ name: 'devloop-pm' }, { mock: true }); await step('ClaudeCliProtocol(mock) connect', async () => { await protocol.connect(null, pathResolver.resolveProjectRoot(pid)); if (!protocol.isConnected) throw new Error('not connected'); return { detail: 'isConnected=true' }; }); const chunks = []; let resultEvent = null; protocol.on('session_update', (u) => { if (u.update?.sessionUpdate === 'agent_message_chunk') { chunks.push(u.update.content.text); } }); protocol.on('acp_event', (ev) => { if (ev.method === 'session/result') resultEvent = ev.params; }); await step('sendPrompt → chunks + result', async () => { await protocol.sendPrompt('please scaffold a minimal PRD for a TODO app'); if (chunks.length === 0) throw new Error('no chunks received'); if (!resultEvent) throw new Error('no session/result'); if (resultEvent.mock !== true) throw new Error('result.mock !== true'); if (resultEvent.stop_reason !== 'end_turn') throw new Error('bad stop_reason: ' + resultEvent.stop_reason); return { detail: `${chunks.length} chunks, result.length=${resultEvent.result.length}` }; }); protocol.disconnect(); await step('commitAfterAgent (no changes → null)', async () => { const r = await loopController.commitAfterAgent(pid, 'devloop-pm', 'no-op test'); if (r !== null) throw new Error('expected null, got: ' + JSON.stringify(r)); return { detail: 'no commit when clean' }; }); await step('write virtual PRD file + commitAfterAgent', async () => { const root = pathResolver.resolveProjectRoot(pid); const prdDir = path.join(root, 'prd'); fs.mkdirSync(prdDir, { recursive: true }); fs.writeFileSync(path.join(prdDir, 'index.html'), 'mock'); const r = await loopController.commitAfterAgent(pid, 'devloop-pm', 'mock PRD scaffold'); if (!r || !r.sha) throw new Error('expected commit, got: ' + JSON.stringify(r)); return { detail: `sha=${r.sha.slice(0, 7)}` }; }); await step('git log shows ≥2 commits', async () => { const log = await gitVault.log(pid, { limit: 10 }); const commits = Array.isArray(log) ? log : (log.commits || []); if (commits.length < 2) { throw new Error('expected ≥2 commits, got: ' + commits.length); } return { detail: `${commits.length} commits` }; }); await step('timeline.ndjson has project.created + git.commit events', async () => { const tl = pathResolver.resolveTimelineLog(pid); if (!fs.existsSync(tl)) throw new Error('timeline.ndjson missing'); const lines = fs.readFileSync(tl, 'utf8').trim().split('\n').filter(Boolean); const kinds = lines.map(l => { try { return JSON.parse(l).kind; } catch { return ''; }}); if (!kinds.includes('project.created')) throw new Error('no project.created in timeline'); if (!kinds.includes('git.commit')) throw new Error('no git.commit in timeline'); return { detail: `${lines.length} timeline events` }; }); } finally { // cleanup: 删 sandbox 项目目录(本次测试专用) if (pid) { try { const root = pathResolver.resolveProjectRoot(pid); const companyDir = path.dirname(path.dirname(root)); if (companyDir.includes(cid)) { fs.rmSync(companyDir, { recursive: true, force: true }); } } catch { /* ignore */ } } } let pass = 0, fail = 0; console.log('\n┌─ DevLoop Phase 0 — Mock End-to-End Test ────────────'); for (const r of results) { const tag = r.ok ? '✓' : '✗'; console.log(`│ ${tag} ${r.name.padEnd(50)} ${r.ok ? r.detail : '— ' + r.reason}`); r.ok ? pass++ : fail++; } console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`); process.exit(fail ? 1 : 0); } main().catch(err => { console.error('\n[phase0-test-call] fatal:', err); for (const r of results) { console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason); } process.exit(1); });