run-project.test.js
5.77 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
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
/**
* 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; // 场景名本身唯一,不再加随机后缀(清理漏一次也只留这一个)
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);
});
});
});