#!/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); });