phase5-verify.js
7.11 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
#!/usr/bin/env node
/**
* scripts/phase5-verify.js
*
* P5 Vue Dashboard 验收 (后端 + 静态产物):
* ① Vue 构建产物 (DevLoopDashboard + DevLoopOps + 8 子组件) 存在
* ② GET /:pid/state 返带 status + units 字段
* ③ GET /:pid/timeline 返事件列表
* ④ GET /:pid/changelog 返条目列表
* ⑤ GET /:pid/units/:unitId 返 unit + runs + coder_log_tail
* ⑥ GET /:pid/bugs 返 bug 列表
* ⑦ SSE /:pid/stream 一秒内收到 ≥1 心跳/事件
*
* 注: 这是后端 + 产物层面的验证;真正点击鼠标 UI 需要 vite dev 跑起来手测。
*/
process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
const fs = require('fs');
const path = require('path');
const http = require('http');
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 lc = require('../server/src/core/devloop/loop-controller');
const devloopRouter = require('../server/routes/devloop');
const HAPPY_DIR = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios/happy_path');
const WEB_DIST = path.resolve(__dirname, '../web/dist');
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() {
await step('① Vue build 产物存在 (Dashboard + Ops + components)', () => {
const required = [
'index.html', 'assets',
];
for (const f of required) {
if (!fs.existsSync(path.join(WEB_DIST, f))) throw new Error(`web/dist/${f} missing — run npm run web:build`);
}
const assets = fs.readdirSync(path.join(WEB_DIST, 'assets'));
if (!assets.some(n => /DevLoopDashboard.*\.js$/.test(n))) throw new Error('DevLoopDashboard chunk not built');
if (!assets.some(n => /DevLoopOps.*\.js$/.test(n))) throw new Error('DevLoopOps chunk not built');
return { detail: `DevLoopDashboard + DevLoopOps chunks present in web/dist/assets` };
});
const { srv, base } = await startApp();
const cid = 'c-phase5-' + Date.now().toString(36);
let pid;
try {
await step('② 准备项目: POST /projects → state', async () => {
const r = await fetch(`${base}/api/devloop/projects`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ company_id: cid, requirement: 'TODO', mockFixtureDir: HAPPY_DIR }),
});
pid = (await r.json()).pid;
const s = await (await fetch(`${base}/api/devloop/projects/${pid}/state`)).json();
if (!s.status) throw new Error('state.status missing');
if (!Array.isArray(s.units)) throw new Error('state.units missing');
return { detail: `pid=${pid} status=${s.status} units=${s.units.length}` };
});
await step('③ GET /:pid/timeline returns events', async () => {
const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline?limit=50`);
const j = await r.json();
if (!Array.isArray(j.events)) throw new Error('events not array');
if (j.events.length === 0) throw new Error('events empty');
return { detail: `${j.events.length} events` };
});
await step('④ GET /:pid/changelog returns entries', async () => {
const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`);
const j = await r.json();
if (!Array.isArray(j.entries)) throw new Error('entries not array');
return { detail: `${j.entries.length} entries` };
});
await step('⑤ GET /:pid/units/todo-be returns unit + runs', async () => {
const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`);
if (r.status !== 200) throw new Error(`status ${r.status}`);
const j = await r.json();
if (!j.unit) throw new Error('unit missing');
if (!Array.isArray(j.runs)) throw new Error('runs not array');
return { detail: `unit.status=${j.unit?.status} · ${j.runs.length} runs` };
});
await step('⑥ GET /:pid/bugs returns bug list', async () => {
const r = await fetch(`${base}/api/devloop/projects/${pid}/bugs`);
const j = await r.json();
if (!Array.isArray(j.bugs)) throw new Error('bugs not array');
return { detail: `${j.bugs.length} bugs` };
});
await step('⑦ SSE /:pid/stream delivers ≥1 event in 1s after emit', async () => {
// 用 http.get 拿原生 stream
const url = new URL(`${base}/api/devloop/projects/${pid}/stream`);
const events = await new Promise((resolve) => {
const out = [];
const req = http.get({
hostname: url.hostname, port: url.port, path: url.pathname,
headers: { 'Accept': 'text/event-stream' },
}, (res) => {
let buf = '';
res.on('data', (chunk) => {
buf += chunk.toString();
const matches = buf.matchAll(/^event: (.+)$/gm);
for (const m of matches) out.push(m[1]);
buf = buf.slice(buf.lastIndexOf('\n\n') + 2);
});
res.on('error', () => resolve(out));
});
// Trigger an event so we have something to receive
setTimeout(async () => {
try {
await fetch(`${base}/api/devloop/projects/${pid}/feedback`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: 'feedback for sse test', mockFixtureDir: HAPPY_DIR }),
});
} catch { /* ignore */ }
}, 100);
setTimeout(() => { try { req.destroy(); } catch {} resolve(out); }, 1500);
});
if (events.length === 0) throw new Error('no SSE events received');
return { detail: `${events.length} events: ${events.slice(0, 4).join(', ')}` };
});
} 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 5 — Vue Dashboard Acceptance ──────');
for (const r of results) {
const tag = r.ok ? '✓' : '✗';
console.log(`│ ${tag} ${r.name.padEnd(60)} ${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[phase5-verify] fatal:', e);
for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason);
process.exit(1);
});