phase0-test-call.js
6.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
#!/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'), '<!doctype html><html><body>mock</body></html>');
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);
});