/** * loop-controller.runProject (P3 L2 联动) 单测 * * 跑 4 个 L2 分支: * ① contract_mismatch → shipped · sdd_rev=2 · l2_rounds=1 * ② impl_bug → shipped · sdd_rev=1 · l2_rounds=0 * ③ spec_ambiguous → l2_blocked · human_intervention_needed SSE * ④ infra_flake → shipped · sdd_rev=1 · l2_rounds=0 */ 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-rp-')); process.env.HOME = TEST_HOME; const pathResolver = require('../../services/devloop/path-resolver'); const sseBroker = require('../../services/devloop/sse-broker'); const bugTracker = require('../../services/devloop/bug-tracker'); const lc = require('../../src/core/devloop/loop-controller'); const SCENARIO_BASE = path.resolve(__dirname, '../../../fixtures/devloop-mock-scenarios'); async function setupScenario(name) { pathResolver._resetForTesting(); sseBroker._resetForTesting(); const cid = 'c-p3-' + name + '-' + Math.random().toString(36).slice(2, 7); const dir = path.join(SCENARIO_BASE, name); const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'p3 ' + name }); await lc.runDocsPhase(proj.pid, 'TODO demo', { mockFixtureDir: dir }); return { pid: proj.pid, cid, dir }; } function cleanup(cid) { try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); } catch { /* ignore */ } } describe('runProject · L2 branches', () => { describe('① contract_mismatch', () => { let pid, cid, dir, result; const sseEvents = []; beforeAll(async () => { ({ pid, cid, dir } = await setupScenario('contract_mismatch')); const fakeRes = { setHeader() {}, flushHeaders() {}, end() {}, on() {}, // sse-broker subscribes res.on('close', ...) write(line) { const m = line.match(/^event: (.+)$/m); if (m) sseEvents.push(m[1]); }, }; sseBroker.subscribe(pid, fakeRes, {}); result = await lc.runProject(pid, { mockFixtureDir: dir }); }); afterAll(() => { sseBroker.close(pid); cleanup(cid); }); it('ships after 1 L2 round', () => { expect(result.ok).toBe(true); expect(result.final_state).toBe('shipped'); }); it('units.json l2_rounds=1 and sdd_rev bumped to 2', () => { const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); expect(u.l2_rounds).toBe(1); expect(u.sdd_rev).toBe('2'); }); it('emits sdd.updated event', () => { expect(sseEvents).toContain('sdd.updated'); }); it('changelog has sdd_rev 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 === 'sdd_rev' && l.rev === 2)).toBe(true); }); it('bug opened then transitioned to fixed', () => { const bugs = bugTracker.list(pid, {}); expect(bugs).toHaveLength(1); expect(bugs[0].status).toBe('fixed'); }); }); describe('② impl_bug', () => { let pid, cid, dir, result; beforeAll(async () => { ({ pid, cid, dir } = await setupScenario('impl_bug')); result = await lc.runProject(pid, { mockFixtureDir: dir }); }); afterAll(() => { sseBroker.close(pid); cleanup(cid); }); it('ships without SDD change', () => { expect(result.ok).toBe(true); expect(result.final_state).toBe('shipped'); const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); expect(u.l2_rounds).toBe(0); expect(u.sdd_rev).toBe('1'); }); it('history shows impl_bug reset_units (no sdd_patch)', () => { const actions = result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action); expect(actions.some(a => /impl_bug/.test(a))).toBe(true); expect(actions.some(a => /sdd_patch/.test(a))).toBe(false); }); }); describe('③ spec_ambiguous', () => { let pid, cid, dir, result; const sseEvents = []; beforeAll(async () => { ({ pid, cid, dir } = await setupScenario('spec_ambiguous')); const fakeRes = { setHeader() {}, flushHeaders() {}, end() {}, on() {}, // sse-broker subscribes res.on('close', ...) write(line) { const m = line.match(/^event: (.+)$/m); if (m) sseEvents.push(m[1]); }, }; sseBroker.subscribe(pid, fakeRes, {}); result = await lc.runProject(pid, { mockFixtureDir: dir }); }); afterAll(() => { sseBroker.close(pid); cleanup(cid); }); it('returns l2_blocked', () => { expect(result.ok).toBe(false); expect(result.final_state).toBe('l2_blocked'); }); it('emits human_intervention_needed SSE', () => { expect(sseEvents).toContain('human_intervention_needed'); }); }); describe('④ infra_flake', () => { let pid, cid, dir, result; beforeAll(async () => { ({ pid, cid, dir } = await setupScenario('infra_flake')); result = await lc.runProject(pid, { mockFixtureDir: dir }); }); afterAll(() => { sseBroker.close(pid); cleanup(cid); }); it('ships after re-run without budget burn', () => { expect(result.ok).toBe(true); expect(result.final_state).toBe('shipped'); const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8')); expect(u.l2_rounds).toBe(0); }); it('history records rerun_integrator', () => { const actions = result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action); expect(actions.some(a => /rerun_integrator/.test(a))).toBe(true); }); }); });