/** * 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'; 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); }); });