run-unit.test.js
4.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
/**
* 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);
});
});