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