/** * P4 · L3 Human Feedback tests * * 覆盖: * - project.status FSM: draft → running → shipped → shipped-final * - submitFeedback bumps prd_rev + shipped → running * - signOff requires status=shipped * - abandon any active state → abandoned (emits close) * - InvalidProjectStatusTransition error mapping * - PM incremental fixture variant runs on 2nd call */ process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; const fs = require('fs'); const path = require('path'); const os = require('os'); const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-p4-')); process.env.HOME = TEST_HOME; const pathResolver = require('../../services/devloop/path-resolver'); const sseBroker = require('../../services/devloop/sse-broker'); const lc = require('../../src/core/devloop/loop-controller'); const HAPPY_DIR = path.resolve(__dirname, '../../../fixtures/devloop-mock-scenarios/happy_path'); // 公司 id 用序号而不是随机后缀:同一次跑里照样唯一,跨次跑复用同一个 id, // 清理漏一次也不会在 data/companies/ 下越堆越多。 let p4Seq = 0; async function fresh(idea = 'p4 test') { pathResolver._resetForTesting(); sseBroker._resetForTesting(); const cid = 'c-p4-' + (++p4Seq); const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea }); return { pid: proj.pid, cid }; } function cleanup(cid) { try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); } catch { /* ignore */ } require('../helpers/ensure-test-company').removeTestCompany(cid); } describe('P4 · L3 status FSM', () => { describe('happy-path: draft → running → shipped → shipped-final', () => { let pid, cid; beforeAll(async () => { ({ pid, cid } = await fresh()); }); afterAll(() => cleanup(cid)); it('starts as draft', () => { expect(lc.getProjectState(pid).status).toBe('draft'); }); it('runDocsPhase → running', async () => { await lc.runDocsPhase(pid, 'TODO', { mockFixtureDir: HAPPY_DIR }); expect(lc.getProjectState(pid).status).toBe('running'); }); it('runProject → shipped', async () => { const r = await lc.runProject(pid, { mockFixtureDir: HAPPY_DIR }); expect(r.final_state).toBe('shipped'); expect(lc.getProjectState(pid).status).toBe('shipped'); }); it('feedback bumps prd_rev 1 → 2 and flips status shipped → running', async () => { const r = await lc.submitFeedback(pid, 'add toggle done feature', { mockFixtureDir: HAPPY_DIR }); expect(r.prd_rev_after).toBe(2); expect(r.prd_rev_before).toBe(1); expect(r.status).toBe('running'); }); it('changelog has prd_rev=2 entry', () => { const cl = pathResolver.resolveChangelog(pid); expect(fs.existsSync(cl)).toBe(true); const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); expect(lines.some(l => l.kind === 'prd_rev' && l.rev === 2)).toBe(true); }); it('runProject again → shipped', async () => { await lc.runProject(pid, { mockFixtureDir: HAPPY_DIR }); expect(lc.getProjectState(pid).status).toBe('shipped'); }); it('signOff → shipped-final', async () => { const meta = await lc.signOff(pid, { by: 'yibo' }); expect(meta.status).toBe('shipped-final'); }); it('feedback after sign-off is rejected', async () => { await expect(lc.submitFeedback(pid, 'late change', { mockFixtureDir: HAPPY_DIR })) .rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' }); }); it('signOff after sign-off is rejected', async () => { await expect(lc.signOff(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' }); }); }); describe('abandon path', () => { let pid, cid; beforeAll(async () => { ({ pid, cid } = await fresh('abandon test')); }); afterAll(() => cleanup(cid)); it('abandon from draft state succeeds', async () => { const m = await lc.abandon(pid, { by: 'yibo', reason: 'no longer needed' }); expect(m.status).toBe('abandoned'); }); it('abandon after abandon is rejected', async () => { await expect(lc.abandon(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' }); }); it('feedback on abandoned project is rejected', async () => { await expect(lc.submitFeedback(pid, 'x', { mockFixtureDir: HAPPY_DIR })) .rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' }); }); }); describe('signOff requires shipped (not running)', () => { let pid, cid; beforeAll(async () => { ({ pid, cid } = await fresh('not yet shipped')); await lc.runDocsPhase(pid, 'TODO', { mockFixtureDir: HAPPY_DIR }); // 不跑 runProject,status 还是 running }); afterAll(() => cleanup(cid)); it('signOff from running fails', async () => { await expect(lc.signOff(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition', from: 'running', to: 'shipped-final' }); }); }); describe('feedback empty text rejected', () => { let pid, cid; beforeAll(async () => { ({ pid, cid } = await fresh('empty feedback')); }); afterAll(() => cleanup(cid)); it('throws on empty/missing text', async () => { await expect(lc.submitFeedback(pid, '')).rejects.toThrow(/text/); await expect(lc.submitFeedback(pid, null)).rejects.toThrow(/text/); }); }); }); describe('P4 · routes', () => { let app, base; const cids = []; beforeAll(async () => { const express = require('express'); const devloopRouter = require('../../routes/devloop'); pathResolver._resetForTesting(); sseBroker._resetForTesting(); const e = express(); e.use(express.json()); e.use('/api/devloop', devloopRouter); await new Promise(r => { app = e.listen(0, '127.0.0.1', () => { base = `http://127.0.0.1:${app.address().port}`; r(); }); }); }); afterAll(() => { app.close(); for (const c of cids) cleanup(c); }); it('POST /sign-off requires shipped status (400)', async () => { const cid = 'c-route-signoff'; cids.push(cid); require('../helpers/ensure-test-company').ensureTestCompany(cid); const r = await fetch(`${base}/api/devloop/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), }); const j = await r.json(); // immediately after creation/run (full flow), check signoff const r2 = await fetch(`${base}/api/devloop/projects/${j.pid}/sign-off`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', }); // After POST /projects, status is 'running' (not yet shipped via runProject) // → signOff should 400 expect(r2.status).toBe(400); }); it('POST /feedback bumps PRD rev', async () => { const cid = 'c-route-feedback'; cids.push(cid); require('../helpers/ensure-test-company').ensureTestCompany(cid); const r = await fetch(`${base}/api/devloop/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), }); const pid = (await r.json()).pid; const fb = await fetch(`${base}/api/devloop/projects/${pid}/feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'add toggle', mockFixtureDir: HAPPY_DIR }), }); expect(fb.status).toBe(200); const j = await fb.json(); expect(j.prd_rev_after).toBeGreaterThan(j.prd_rev_before); }); it('POST /feedback missing text → 400', async () => { const cid = 'c-route-fb-empty'; cids.push(cid); require('../helpers/ensure-test-company').ensureTestCompany(cid); const r = await fetch(`${base}/api/devloop/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), }); const pid = (await r.json()).pid; const fb = await fetch(`${base}/api/devloop/projects/${pid}/feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', }); expect(fb.status).toBe(400); }); it('POST /abandon → status=abandoned', async () => { const cid = 'c-route-abandon'; cids.push(cid); require('../helpers/ensure-test-company').ensureTestCompany(cid); const r = await fetch(`${base}/api/devloop/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), }); const pid = (await r.json()).pid; const ab = await fetch(`${base}/api/devloop/projects/${pid}/abandon`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ by: 'yibo', reason: 'test' }), }); expect(ab.status).toBe(200); const j = await ab.json(); expect(j.status).toBe('abandoned'); }); it('POST /sign-off / abandon / feedback on unknown pid → 404', async () => { for (const ep of ['sign-off', 'abandon', 'feedback']) { const r = await fetch(`${base}/api/devloop/projects/p-nope/${ep}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ep === 'feedback' ? { text: 'x' } : {}), }); expect(r.status).toBe(404); } }); });