/** * routes/devloop.js 集成测试 * * 不启动完整 server,只挂 devloop router 到独立 express app + fetch。 */ process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; const fs = require('fs'); const path = require('path'); const os = require('os'); const express = require('express'); const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-rt-')); process.env.HOME = TEST_HOME; const pathResolver = require('../../services/devloop/path-resolver'); const devloopRouter = require('../../routes/devloop'); 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', () => { const port = srv.address().port; resolve({ srv, base: `http://127.0.0.1:${port}` }); }); }); } describe('routes/devloop', () => { let app, base; const created = []; beforeAll(async () => { pathResolver._resetForTesting(); ({ srv: app, base } = await startApp()); }); afterAll(() => { app.close(); for (const c of created) { try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true }); } catch { /* ignore */ } } }); it('POST /api/devloop/projects requires company_id', async () => { const r = await fetch(`${base}/api/devloop/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ requirement: 'x' }), }); expect(r.status).toBe(400); const j = await r.json(); expect(j.error).toMatch(/company_id/); }); it('POST /api/devloop/projects requires requirement', async () => { const r = await fetch(`${base}/api/devloop/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ company_id: 'c-x' }), }); expect(r.status).toBe(400); const j = await r.json(); expect(j.error).toMatch(/requirement/); }); it('POST /api/devloop/projects → creates project and runs PM + Architect', async () => { const cid = 'c-route-test-' + Math.random().toString(36).slice(2, 7); created.push(cid); const r = await fetch(`${base}/api/devloop/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ company_id: cid, name: 'todo-demo', requirement: '做一个 TODO 列表:能新增、列表、Vue 前端', }), }); expect(r.status).toBe(200); const j = await r.json(); expect(j.ok).toBe(true); expect(j.pid).toMatch(/^p-/); expect(j.pm.files_written).toBeGreaterThanOrEqual(10); expect(j.architect.files_written).toBeGreaterThanOrEqual(10); expect(j.pm.commit).toBeTruthy(); expect(j.architect.commit).toBeTruthy(); // 验证文件确实落盘 const root = pathResolver.resolveProjectRoot(j.pid); expect(fs.existsSync(path.join(root, 'prd', 'index.html'))).toBe(true); expect(fs.existsSync(path.join(root, 'sdd', 'mvcp', 'model.html'))).toBe(true); expect(fs.existsSync(path.join(root, 'units.json'))).toBe(true); // GET /state const sR = await fetch(`${base}/api/devloop/projects/${j.pid}/state`); expect(sR.status).toBe(200); const s = await sR.json(); expect(s.pid).toBe(j.pid); expect(s.prd_site).toBe(true); expect(s.sdd_site).toBe(true); expect(s.units).toHaveLength(3); }); it('POST /api/devloop/projects/:pid/run without ?only on unknown pid returns 404 (full-project mode)', async () => { const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); expect(r.status).toBe(404); }); it('POST /api/devloop/projects/:pid/run?only=todo-be runs L1 loop', async () => { const cid = 'c-route-p2-' + Math.random().toString(36).slice(2, 7); created.push(cid); // 准备项目 const r1 = await fetch(`${base}/api/devloop/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ company_id: cid, requirement: '做一个 TODO API' }), }); const j1 = await r1.json(); const pid = j1.pid; // 跑 todo-be const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run?only=todo-be`, { method: 'POST' }); expect(r2.status).toBe(200); const j2 = await r2.json(); expect(j2.ok).toBe(true); expect(j2.unit_id).toBe('todo-be'); expect(j2.attempts).toBe(2); expect(j2.final_state).toBe('passed'); expect(j2.history).toHaveLength(4); }); it('POST /run (full project, no ?only) drives runProject', async () => { const cid = 'c-route-p3-' + Math.random().toString(36).slice(2, 7); created.push(cid); const r1 = await fetch(`${base}/api/devloop/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ company_id: cid, requirement: 'TODO demo' }), }); const pid = (await r1.json()).pid; // ship via full project run (baseline fixtures = happy path) const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); expect(r2.status).toBe(200); const j = await r2.json(); expect(['shipped', 'l2_blocked', 'l2_exhausted']).toContain(j.final_state); expect(typeof j.l2_rounds).toBe('number'); }); it('GET /api/devloop/projects/:pid/bugs returns 404 on unknown pid', async () => { const r = await fetch(`${base}/api/devloop/projects/p-nope/bugs`); expect(r.status).toBe(404); }); it('POST /run?only on unknown unit returns 404', async () => { const cid = 'c-route-p2u-' + Math.random().toString(36).slice(2, 7); created.push(cid); const r1 = await fetch(`${base}/api/devloop/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ company_id: cid, requirement: 'x' }), }); const pid = (await r1.json()).pid; const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run?only=no-such-unit`, { method: 'POST' }); expect(r2.status).toBe(404); }); it('GET /api/devloop/projects/:pid/state on unknown pid returns 404', async () => { const r = await fetch(`${base}/api/devloop/projects/p-doesnotexist/state`); expect(r.status).toBe(404); const j = await r.json(); expect(j.error).toMatch(/unknown/); }); it('GET /api/devloop/projects lists projects', async () => { const r = await fetch(`${base}/api/devloop/projects`); expect(r.status).toBe(200); const j = await r.json(); expect(Array.isArray(j.projects)).toBe(true); }); // P5 — UI-supporting endpoints describe('P5 UI endpoints', () => { let pid; beforeAll(async () => { const cid = 'c-p5-routes-' + Math.random().toString(36).slice(2, 7); created.push(cid); const r = await fetch(`${base}/api/devloop/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ company_id: cid, requirement: 'p5 test' }), }); pid = (await r.json()).pid; }); it('GET /timeline returns events array', async () => { const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline`); expect(r.status).toBe(200); const j = await r.json(); expect(Array.isArray(j.events)).toBe(true); expect(j.events.length).toBeGreaterThan(0); }); it('GET /changelog returns entries array', async () => { const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`); expect(r.status).toBe(200); const j = await r.json(); expect(Array.isArray(j.entries)).toBe(true); }); it('GET /units/:unitId returns unit + runs + coder_log_tail', async () => { const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`); expect(r.status).toBe(200); const j = await r.json(); expect(j).toHaveProperty('unit'); expect(j).toHaveProperty('runs'); expect(Array.isArray(j.runs)).toBe(true); }); it('GET /units/:unitId 404 on unknown unit', async () => { const r = await fetch(`${base}/api/devloop/projects/${pid}/units/no-such-unit`); expect(r.status).toBe(404); }); it('GET /timeline 404 on unknown pid', async () => { const r = await fetch(`${base}/api/devloop/projects/p-nope/timeline`); expect(r.status).toBe(404); }); }); });