run-unit.test.js 4.54 KB
/**
 * loop-controller.runUnit (P2 L1 单 Unit Loop) 单测
 *
 * 验:
 *   - state 链路 pending → coding → testing → failed → coding → testing → passed
 *   - coder.log 第二条决策含 last_failure (反思生效)
 *   - 沙箱违例被工具层拒绝
 */

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-ru-'));
process.env.HOME = TEST_HOME;

const pathResolver = require('../../services/devloop/path-resolver');
const unitRegistry = require('../../services/devloop/unit-registry');
const lc = require('../../src/core/devloop/loop-controller');
const { ClaudeCliProtocol } = require('../../../packages/acp-core/protocol/ClaudeCliProtocol');
const { assertWritable } = require('../../src/tools/devloop/_sandbox');
const { ToolSandboxError } = require('../../src/tools/devloop/_errors');

describe('runUnit (P2 L1)', () => {
  let pid, cid;
  const created = [];

  beforeAll(async () => {
    pathResolver._resetForTesting();
    ClaudeCliProtocol.resetMockState();
    cid = 'c-rununit-' + Math.random().toString(36).slice(2, 7);
    created.push(cid);
    const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'p2 test' });
    pid = proj.pid;
    await lc.runDocsPhase(pid, 'TODO list');
  });

  afterAll(() => {
    for (const c of created) {
      try {
        fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true });
      } catch { /* ignore */ }
    }
  });

  it('todo-be: fail → retry → pass in 2 attempts', async () => {
    // 验证起始 status=pending
    expect(unitRegistry.get(pid, 'todo-be').status).toBe('pending');

    // 监听 unit-registry 的 SSE 事件来捕获完整 FSM 链路
    const fsmTrail = [];
    unitRegistry.attachSseBroker({
      emit: (_pid, kind, ev) => {
        if (kind === 'unit.state' && ev.unit_id === 'todo-be') {
          fsmTrail.push(`${ev.from}→${ev.to}`);
        }
      },
    });

    const r = await lc.runUnit(pid, 'todo-be');
    expect(r.ok).toBe(true);
    expect(r.attempts).toBe(2);
    expect(r.final_state).toBe('passed');

    // FSM 链路: pending→coding (att 1) · coding→testing · testing→failed · failed→coding (att 2) · coding→testing · testing→passed
    expect(fsmTrail).toEqual([
      'pending→coding',
      'coding→testing',
      'testing→failed',
      'failed→coding',
      'coding→testing',
      'testing→passed',
    ]);
  });

  it('coder.log second decision references previous failure', () => {
    const logPath = path.join(pathResolver.resolveUnitDir(pid, 'todo-be'), 'coder.log');
    const log = fs.readFileSync(logPath, 'utf8');
    // 必须至少有两条 attempt
    expect(log).toMatch(/attempt: 1/);
    expect(log).toMatch(/attempt: 2/);
    // 第二条 entry 必须包含反思字段
    const att2Section = log.split('attempt: 2')[1] || '';
    expect(att2Section).toMatch(/last_failure/);
    expect(att2Section).toMatch(/last_cause/);
    expect(att2Section).toMatch(/this_fix/);
  });

  it('produced runs/{runId}/result.json for both attempts', () => {
    const runsDir = path.join(pathResolver.resolveUnitDir(pid, 'todo-be'), 'runs');
    const ids = fs.readdirSync(runsDir).sort();
    expect(ids.length).toBeGreaterThanOrEqual(2);
    // 第一个 result.ok=false, 最后 result.ok=true
    const first = JSON.parse(fs.readFileSync(path.join(runsDir, ids[0], 'result.json'), 'utf8'));
    const last = JSON.parse(fs.readFileSync(path.join(runsDir, ids[ids.length - 1], 'result.json'), 'utf8'));
    expect(first.ok).toBe(false);
    expect(last.ok).toBe(true);
  });

  it('sandbox enforcement: writing outside unit dir is rejected by _sandbox.assertWritable', () => {
    const root = pathResolver.resolveProjectRoot(pid);
    const ctx = { agent: 'devloop-coder', projectDir: root, unit_id: 'todo-be' };
    // 合法
    expect(() => assertWritable(
      path.join(root, 'units/todo-be/code/server.js'),
      ['units/{unitId}/code/**', 'units/{unitId}/contract.json', 'units/{unitId}/coder.log'],
      ctx,
    )).not.toThrow();
    // 越权: 写别的 unit
    expect(() => assertWritable(
      path.join(root, 'units/todo-fe/code/foo.js'),
      ['units/{unitId}/code/**', 'units/{unitId}/contract.json', 'units/{unitId}/coder.log'],
      ctx,
    )).toThrow(ToolSandboxError);
    // 越权: 写 prd
    expect(() => assertWritable(
      path.join(root, 'prd/anything.html'),
      ['units/{unitId}/code/**'],
      ctx,
    )).toThrow(ToolSandboxError);
  });
});