phase4-verify.js
8.23 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
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
#!/usr/bin/env node
/**
* scripts/phase4-verify.js
*
* P4 L3 Human Feedback 验收 (mock 模式 · 端到端 HTTP):
* ① POST /projects → status=running, PRD rev 1
* ② POST /run → ships, status=shipped
* ③ POST /feedback → PRD rev bumped to 2, status shipped→running, changelog prd_rev=2
* ④ POST /run (re-run) → status=shipped again
* ⑤ POST /sign-off → status=shipped-final
* ⑥ POST /feedback (after sign-off) → 400 InvalidProjectStatusTransition
* ⑦ POST /sign-off (after sign-off) → 400
* ⑧ 第二个项目: POST /abandon → status=abandoned
* ⑨ 第二个项目 POST /abandon 再次 → 400
*/
process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
const fs = require('fs');
const path = require('path');
const serverNodeModules = path.resolve(__dirname, '../server/node_modules');
require('module').globalPaths.push(serverNodeModules);
const express = (() => {
try { return require('express'); }
catch {
return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] }));
}
})();
const pathResolver = require('../server/services/devloop/path-resolver');
const sseBroker = require('../server/services/devloop/sse-broker');
const devloopRouter = require('../server/routes/devloop');
const HAPPY_DIR = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios/happy_path');
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 startApp() {
const app = express();
app.use(express.json());
app.use('/api/devloop', devloopRouter);
return new Promise((resolve) => {
const srv = app.listen(0, '127.0.0.1', () => {
resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` });
});
});
}
async function main() {
const { srv, base } = await startApp();
const cid = 'c-phase4-' + Date.now().toString(36);
let pid1, pid2;
try {
await step('① POST /projects → status=running', async () => {
const r = await fetch(`${base}/api/devloop/projects`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ company_id: cid, requirement: 'TODO demo', mockFixtureDir: HAPPY_DIR }),
});
if (r.status !== 200) throw new Error('status ' + r.status);
const j = await r.json();
pid1 = j.pid;
const s = await (await fetch(`${base}/api/devloop/projects/${pid1}/state`)).json();
if (s.status !== 'running') throw new Error('want running, got ' + s.status);
return { detail: `pid=${pid1} status=running prd_rev=${s.prd_rev}` };
});
await step('② POST /run → status=shipped', async () => {
const r = await fetch(`${base}/api/devloop/projects/${pid1}/run`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }),
});
const j = await r.json();
if (j.final_state !== 'shipped') throw new Error('final_state=' + j.final_state);
const s = await (await fetch(`${base}/api/devloop/projects/${pid1}/state`)).json();
if (s.status !== 'shipped') throw new Error('status=' + s.status);
return { detail: `final_state=shipped l2_rounds=${j.l2_rounds}` };
});
await step('③ POST /feedback → PRD rev 1→2, status shipped→running', async () => {
const r = await fetch(`${base}/api/devloop/projects/${pid1}/feedback`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: 'add toggle done', by: 'yibo', mockFixtureDir: HAPPY_DIR }),
});
if (r.status !== 200) throw new Error('status ' + r.status);
const j = await r.json();
if (j.prd_rev_after !== 2) throw new Error('prd_rev_after=' + j.prd_rev_after);
if (j.status !== 'running') throw new Error('status=' + j.status);
// changelog
const cl = pathResolver.resolveChangelog(pid1);
const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
if (!lines.some(l => l.kind === 'prd_rev' && l.rev === 2)) throw new Error('no prd_rev=2 in changelog');
return { detail: `rev 1→2 · status=running · changelog updated` };
});
await step('④ POST /run (re-run) → status=shipped', async () => {
const r = await fetch(`${base}/api/devloop/projects/${pid1}/run`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mockFixtureDir: HAPPY_DIR }),
});
const j = await r.json();
if (j.final_state !== 'shipped') throw new Error('final_state=' + j.final_state);
return { detail: 'shipped again' };
});
await step('⑤ POST /sign-off → status=shipped-final', async () => {
const r = await fetch(`${base}/api/devloop/projects/${pid1}/sign-off`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ by: 'yibo' }),
});
if (r.status !== 200) throw new Error('status ' + r.status);
const j = await r.json();
if (j.status !== 'shipped-final') throw new Error('status=' + j.status);
return { detail: 'status=shipped-final' };
});
await step('⑥ POST /feedback (after sign-off) → 400', async () => {
const r = await fetch(`${base}/api/devloop/projects/${pid1}/feedback`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: 'late change' }),
});
if (r.status !== 400) throw new Error('want 400, got ' + r.status);
const j = await r.json();
if (j.from !== 'shipped-final') throw new Error('from=' + j.from);
return { detail: '400 InvalidProjectStatusTransition (from=shipped-final)' };
});
await step('⑦ POST /sign-off (after sign-off) → 400', async () => {
const r = await fetch(`${base}/api/devloop/projects/${pid1}/sign-off`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
});
if (r.status !== 400) throw new Error('want 400, got ' + r.status);
return { detail: '400 (terminal state)' };
});
await step('⑧ 第二项目: POST /abandon → status=abandoned', async () => {
const cr = await fetch(`${base}/api/devloop/projects`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ company_id: cid, requirement: 'abandon test', mockFixtureDir: HAPPY_DIR }),
});
pid2 = (await cr.json()).pid;
const ab = await fetch(`${base}/api/devloop/projects/${pid2}/abandon`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ by: 'yibo', reason: 'mock test' }),
});
if (ab.status !== 200) throw new Error('abandon status ' + ab.status);
const j = await ab.json();
if (j.status !== 'abandoned') throw new Error('status=' + j.status);
return { detail: `pid=${pid2} status=abandoned` };
});
await step('⑨ POST /abandon (after abandon) → 400', async () => {
const r = await fetch(`${base}/api/devloop/projects/${pid2}/abandon`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
});
if (r.status !== 400) throw new Error('want 400, got ' + r.status);
return { detail: '400 (terminal state)' };
});
} finally {
srv.close();
try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); }
catch { /* ignore */ }
}
let pass = 0, fail = 0;
console.log('\n┌─ DevLoop Phase 4 — L3 Human Feedback Acceptance ──');
for (const r of results) {
const tag = r.ok ? '✓' : '✗';
console.log(`│ ${tag} ${r.name.padEnd(58)} ${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(e => {
console.error('\n[phase4-verify] fatal:', e);
for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason);
process.exit(1);
});