p4-l3-feedback.test.js
9.07 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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
/**
* P4 · L3 Human Feedback tests
*
* 覆盖:
* - project.status FSM: draft → running → shipped → shipped-final
* - submitFeedback bumps prd_rev + shipped → running
* - signOff requires status=shipped
* - abandon any active state → abandoned (emits close)
* - InvalidProjectStatusTransition error mapping
* - PM incremental fixture variant runs on 2nd call
*/
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-p4-'));
process.env.HOME = TEST_HOME;
const pathResolver = require('../../services/devloop/path-resolver');
const sseBroker = require('../../services/devloop/sse-broker');
const lc = require('../../src/core/devloop/loop-controller');
const HAPPY_DIR = path.resolve(__dirname, '../../../fixtures/devloop-mock-scenarios/happy_path');
async function fresh(idea = 'p4 test') {
pathResolver._resetForTesting();
sseBroker._resetForTesting();
const cid = 'c-p4-' + Math.random().toString(36).slice(2, 7);
const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea });
return { pid: proj.pid, cid };
}
function cleanup(cid) {
try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); }
catch { /* ignore */ }
}
describe('P4 · L3 status FSM', () => {
describe('happy-path: draft → running → shipped → shipped-final', () => {
let pid, cid;
beforeAll(async () => { ({ pid, cid } = await fresh()); });
afterAll(() => cleanup(cid));
it('starts as draft', () => {
expect(lc.getProjectState(pid).status).toBe('draft');
});
it('runDocsPhase → running', async () => {
await lc.runDocsPhase(pid, 'TODO', { mockFixtureDir: HAPPY_DIR });
expect(lc.getProjectState(pid).status).toBe('running');
});
it('runProject → shipped', async () => {
const r = await lc.runProject(pid, { mockFixtureDir: HAPPY_DIR });
expect(r.final_state).toBe('shipped');
expect(lc.getProjectState(pid).status).toBe('shipped');
});
it('feedback bumps prd_rev 1 → 2 and flips status shipped → running', async () => {
const r = await lc.submitFeedback(pid, 'add toggle done feature', { mockFixtureDir: HAPPY_DIR });
expect(r.prd_rev_after).toBe(2);
expect(r.prd_rev_before).toBe(1);
expect(r.status).toBe('running');
});
it('changelog has prd_rev=2 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 === 'prd_rev' && l.rev === 2)).toBe(true);
});
it('runProject again → shipped', async () => {
await lc.runProject(pid, { mockFixtureDir: HAPPY_DIR });
expect(lc.getProjectState(pid).status).toBe('shipped');
});
it('signOff → shipped-final', async () => {
const meta = await lc.signOff(pid, { by: 'yibo' });
expect(meta.status).toBe('shipped-final');
});
it('feedback after sign-off is rejected', async () => {
await expect(lc.submitFeedback(pid, 'late change', { mockFixtureDir: HAPPY_DIR }))
.rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' });
});
it('signOff after sign-off is rejected', async () => {
await expect(lc.signOff(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' });
});
});
describe('abandon path', () => {
let pid, cid;
beforeAll(async () => { ({ pid, cid } = await fresh('abandon test')); });
afterAll(() => cleanup(cid));
it('abandon from draft state succeeds', async () => {
const m = await lc.abandon(pid, { by: 'yibo', reason: 'no longer needed' });
expect(m.status).toBe('abandoned');
});
it('abandon after abandon is rejected', async () => {
await expect(lc.abandon(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' });
});
it('feedback on abandoned project is rejected', async () => {
await expect(lc.submitFeedback(pid, 'x', { mockFixtureDir: HAPPY_DIR }))
.rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' });
});
});
describe('signOff requires shipped (not running)', () => {
let pid, cid;
beforeAll(async () => {
({ pid, cid } = await fresh('not yet shipped'));
await lc.runDocsPhase(pid, 'TODO', { mockFixtureDir: HAPPY_DIR });
// 不跑 runProject,status 还是 running
});
afterAll(() => cleanup(cid));
it('signOff from running fails', async () => {
await expect(lc.signOff(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition', from: 'running', to: 'shipped-final' });
});
});
describe('feedback empty text rejected', () => {
let pid, cid;
beforeAll(async () => { ({ pid, cid } = await fresh('empty feedback')); });
afterAll(() => cleanup(cid));
it('throws on empty/missing text', async () => {
await expect(lc.submitFeedback(pid, '')).rejects.toThrow(/text/);
await expect(lc.submitFeedback(pid, null)).rejects.toThrow(/text/);
});
});
});
describe('P4 · routes', () => {
let app, base;
const cids = [];
beforeAll(async () => {
const express = require('express');
const devloopRouter = require('../../routes/devloop');
pathResolver._resetForTesting();
sseBroker._resetForTesting();
const e = express();
e.use(express.json());
e.use('/api/devloop', devloopRouter);
await new Promise(r => {
app = e.listen(0, '127.0.0.1', () => {
base = `http://127.0.0.1:${app.address().port}`;
r();
});
});
});
afterAll(() => {
app.close();
for (const c of cids) cleanup(c);
});
it('POST /sign-off requires shipped status (400)', async () => {
const cid = 'c-route-signoff-' + Math.random().toString(36).slice(2, 7);
cids.push(cid);
const r = await fetch(`${base}/api/devloop/projects`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }),
});
const j = await r.json();
// immediately after creation/run (full flow), check signoff
const r2 = await fetch(`${base}/api/devloop/projects/${j.pid}/sign-off`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
});
// After POST /projects, status is 'running' (not yet shipped via runProject)
// → signOff should 400
expect(r2.status).toBe(400);
});
it('POST /feedback bumps PRD rev', async () => {
const cid = 'c-route-feedback-' + Math.random().toString(36).slice(2, 7);
cids.push(cid);
const r = await fetch(`${base}/api/devloop/projects`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }),
});
const pid = (await r.json()).pid;
const fb = await fetch(`${base}/api/devloop/projects/${pid}/feedback`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: 'add toggle', mockFixtureDir: HAPPY_DIR }),
});
expect(fb.status).toBe(200);
const j = await fb.json();
expect(j.prd_rev_after).toBeGreaterThan(j.prd_rev_before);
});
it('POST /feedback missing text → 400', async () => {
const cid = 'c-route-fb-empty-' + Math.random().toString(36).slice(2, 7);
cids.push(cid);
const r = await fetch(`${base}/api/devloop/projects`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }),
});
const pid = (await r.json()).pid;
const fb = await fetch(`${base}/api/devloop/projects/${pid}/feedback`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
});
expect(fb.status).toBe(400);
});
it('POST /abandon → status=abandoned', async () => {
const cid = 'c-route-abandon-' + Math.random().toString(36).slice(2, 7);
cids.push(cid);
const r = await fetch(`${base}/api/devloop/projects`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }),
});
const pid = (await r.json()).pid;
const ab = await fetch(`${base}/api/devloop/projects/${pid}/abandon`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ by: 'yibo', reason: 'test' }),
});
expect(ab.status).toBe(200);
const j = await ab.json();
expect(j.status).toBe('abandoned');
});
it('POST /sign-off / abandon / feedback on unknown pid → 404', async () => {
for (const ep of ['sign-off', 'abandon', 'feedback']) {
const r = await fetch(`${base}/api/devloop/projects/p-nope/${ep}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ep === 'feedback' ? { text: 'x' } : {}),
});
expect(r.status).toBe(404);
}
});
});