run-project.test.js 5.73 KB
/**
 * 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);
    });
  });
});