loop-controller.test.js
4.9 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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
/**
* loop-controller (P1) 单测
* - callAgent (mock 模式) 能从 fixture 产出文件 + git commit
* - runDocsPhase 串行跑 PM + Architect,产物齐全
* - getProjectState 返回 prd_rev/sdd_rev/units
*/
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-lc-'));
process.env.HOME = TEST_HOME;
const pathResolver = require('../../services/devloop/path-resolver');
const lc = require('../../src/core/devloop/loop-controller');
describe('loop-controller (P1)', () => {
let pid, cid;
const created = [];
// 公司 id 用序号而不是随机后缀:随机后缀等于「清理漏掉的那一次会换个名字永远留在
// data/companies/ 下」(28 号 E2E 基线里就躺着 `tmp-w7-blank-1787029777338-8idwe` 这类垃圾)。
// 序号在同一次跑里照样唯一,跨次跑复用同一个 id,afterAll 删的也就是那一个。
let seq = 0;
afterAll(() => {
// 清理 sandbox 项目目录
for (const c of created) {
try {
fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true });
} catch { /* ignore */ }
}
});
async function fresh() {
pathResolver._resetForTesting();
cid = 'c-test-' + (++seq);
created.push(cid);
const proj = await lc.createProject({
company_id: cid, mode: 'sandbox', idea: 'unit-test',
});
pid = proj.pid;
return proj;
}
describe('callAgent (mock devloop-pm)', () => {
beforeAll(async () => { await fresh(); });
it('produces files from fixture + commits', async () => {
const r = await lc.callAgent(pid, 'devloop-pm', 'requirement: hello');
expect(r.ok).toBe(true);
expect(r.mock).toBe(true);
expect(r.files_written.length).toBeGreaterThanOrEqual(10);
expect(r.commit).not.toBeNull();
expect(r.commit.sha).toBeTruthy();
const root = pathResolver.resolveProjectRoot(pid);
expect(fs.existsSync(path.join(root, 'prd', 'index.html'))).toBe(true);
expect(fs.existsSync(path.join(root, 'agent-state', 'pm.json'))).toBe(true);
});
it('subsequent call still commits (timeline.ndjson always appended)', async () => {
const r = await lc.callAgent(pid, 'devloop-pm', 'second time same fixture');
// mock 产物相同但 timeline.ndjson 会追加一行 → 总有 diff,有 commit
expect(r.commit).not.toBeNull();
expect(r.commit.sha).toBeTruthy();
});
});
describe('runDocsPhase (PM → Architect)', () => {
beforeAll(async () => { await fresh(); });
let phase;
it('completes both agents', async () => {
phase = await lc.runDocsPhase(pid, '做一个 TODO 列表');
expect(phase.ok).toBe(true);
expect(phase.pm.commit?.sha).toBeTruthy();
expect(phase.architect.commit?.sha).toBeTruthy();
});
it('produces full P1 acceptance file tree', () => {
const root = pathResolver.resolveProjectRoot(pid);
const required = [
'project.json',
'prd/index.html',
'prd/reading-guide.html',
'prd/product/background.html',
'prd/product/personas.html',
'prd/product/scope.html',
'prd/feature/feature-tree.html',
'prd/flow/user-flows.html',
'sdd/index.html',
'sdd/mvcp/model.html',
'sdd/mvcp/view.html',
'sdd/mvcp/control.html',
'sdd/mvcp/process.html',
'units.json',
'integration/spec.json',
'agent-state/pm.json',
'agent-state/pm-todo.json',
'agent-state/architect.json',
'agent-state/architect-todo.json',
'history/prd-rev-001.html',
'history/sdd-rev-001.html',
];
const missing = required.filter(f => !fs.existsSync(path.join(root, f)));
expect(missing).toEqual([]);
});
it('units.json has 3 units with project-level l2_rounds/l2_budget', () => {
const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
expect(u.units).toHaveLength(3);
expect(typeof u.l2_rounds).toBe('number');
expect(typeof u.l2_budget).toBe('number');
for (const x of u.units) {
expect(x.status).toBe('pending');
expect(typeof x.retry_budget).toBe('number');
}
});
it('getProjectState reflects produced docs', () => {
const s = lc.getProjectState(pid);
expect(s.prd_site).toBe(true);
expect(s.sdd_site).toBe(true);
expect(s.prd_rev).toBe(1);
expect(s.sdd_rev).toBe(1);
expect(s.units).toHaveLength(3);
expect(Array.isArray(s.timeline_tail)).toBe(true);
expect(s.timeline_tail.length).toBeGreaterThan(0);
});
});
describe('runDocsPhase rejects bad input', () => {
beforeAll(async () => { await fresh(); });
it('throws when requirement missing', async () => {
await expect(lc.runDocsPhase(pid, '')).rejects.toThrow(/requirement/);
});
});
});