@lumenflow/cli 2.7.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -105
- package/dist/__tests__/agent-spawn-coordination.test.js +451 -0
- package/dist/__tests__/commands/integrate.test.js +165 -0
- package/dist/__tests__/gates-config.test.js +0 -1
- package/dist/__tests__/hooks/enforcement.test.js +279 -0
- package/dist/__tests__/init-greenfield.test.js +247 -0
- package/dist/__tests__/init-quick-ref.test.js +0 -1
- package/dist/__tests__/init-template-portability.test.js +0 -1
- package/dist/__tests__/init.test.js +27 -0
- package/dist/__tests__/initiative-e2e.test.js +442 -0
- package/dist/__tests__/initiative-plan-replacement.test.js +0 -1
- package/dist/__tests__/memory-integration.test.js +333 -0
- package/dist/__tests__/release.test.js +1 -1
- package/dist/__tests__/safe-git.test.js +0 -1
- package/dist/__tests__/state-doctor.test.js +54 -0
- package/dist/__tests__/sync-templates.test.js +255 -0
- package/dist/__tests__/wu-create-required-fields.test.js +121 -0
- package/dist/__tests__/wu-done-auto-cleanup.test.js +135 -0
- package/dist/__tests__/wu-lifecycle-integration.test.js +388 -0
- package/dist/backlog-prune.js +0 -1
- package/dist/cli-entry-point.js +0 -1
- package/dist/commands/integrate.js +229 -0
- package/dist/docs-sync.js +46 -0
- package/dist/doctor.js +0 -2
- package/dist/gates.js +0 -7
- package/dist/hooks/enforcement-checks.js +209 -0
- package/dist/hooks/enforcement-generator.js +365 -0
- package/dist/hooks/enforcement-sync.js +243 -0
- package/dist/hooks/index.js +7 -0
- package/dist/init.js +256 -13
- package/dist/initiative-add-wu.js +0 -2
- package/dist/initiative-create.js +0 -3
- package/dist/initiative-edit.js +0 -5
- package/dist/initiative-plan.js +0 -1
- package/dist/initiative-remove-wu.js +0 -2
- package/dist/lane-health.js +0 -2
- package/dist/lane-suggest.js +0 -1
- package/dist/mem-checkpoint.js +0 -2
- package/dist/mem-cleanup.js +0 -2
- package/dist/mem-context.js +0 -3
- package/dist/mem-create.js +0 -2
- package/dist/mem-delete.js +0 -3
- package/dist/mem-inbox.js +0 -2
- package/dist/mem-index.js +0 -1
- package/dist/mem-init.js +0 -2
- package/dist/mem-profile.js +0 -1
- package/dist/mem-promote.js +0 -1
- package/dist/mem-ready.js +0 -2
- package/dist/mem-signal.js +0 -2
- package/dist/mem-start.js +0 -2
- package/dist/mem-summarize.js +0 -2
- package/dist/metrics-cli.js +1 -1
- package/dist/metrics-snapshot.js +1 -1
- package/dist/onboarding-smoke-test.js +0 -5
- package/dist/orchestrate-init-status.js +0 -1
- package/dist/orchestrate-initiative.js +0 -1
- package/dist/orchestrate-monitor.js +0 -1
- package/dist/plan-create.js +0 -2
- package/dist/plan-edit.js +0 -2
- package/dist/plan-link.js +0 -2
- package/dist/plan-promote.js +0 -2
- package/dist/signal-cleanup.js +0 -4
- package/dist/state-bootstrap.js +0 -1
- package/dist/state-cleanup.js +0 -4
- package/dist/state-doctor-fix.js +5 -8
- package/dist/state-doctor.js +0 -11
- package/dist/sync-templates.js +188 -34
- package/dist/wu-block.js +100 -48
- package/dist/wu-claim.js +1 -22
- package/dist/wu-cleanup.js +0 -1
- package/dist/wu-create.js +0 -2
- package/dist/wu-done-auto-cleanup.js +139 -0
- package/dist/wu-done.js +11 -4
- package/dist/wu-edit.js +0 -12
- package/dist/wu-preflight.js +0 -1
- package/dist/wu-prep.js +0 -1
- package/dist/wu-proto.js +0 -1
- package/dist/wu-spawn.js +0 -3
- package/dist/wu-unblock.js +0 -2
- package/dist/wu-validate.js +0 -1
- package/package.json +8 -7
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Layer Integration Tests (WU-1363)
|
|
3
|
+
*
|
|
4
|
+
* Integration tests for memory layer operations:
|
|
5
|
+
* - AC3: mem:checkpoint, mem:signal, mem:inbox
|
|
6
|
+
*
|
|
7
|
+
* These tests validate the memory layer's ability to:
|
|
8
|
+
* - Create checkpoints for context preservation
|
|
9
|
+
* - Send and receive signals for agent coordination
|
|
10
|
+
* - Filter and query signals from inbox
|
|
11
|
+
*
|
|
12
|
+
* TDD: Tests written BEFORE implementation verification.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
15
|
+
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { createCheckpoint, createSignal, loadSignals, markSignalsAsRead } from '@lumenflow/memory';
|
|
19
|
+
// Test constants
|
|
20
|
+
const TEST_WU_ID = 'WU-9910';
|
|
21
|
+
const TEST_LANE = 'Framework: CLI';
|
|
22
|
+
// Session ID must be a valid UUID
|
|
23
|
+
const TEST_SESSION_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
24
|
+
/**
|
|
25
|
+
* Helper to create minimal memory directory structure
|
|
26
|
+
*/
|
|
27
|
+
function createMemoryProject(baseDir) {
|
|
28
|
+
const dirs = ['.lumenflow/memory', '.lumenflow/state'];
|
|
29
|
+
for (const dir of dirs) {
|
|
30
|
+
mkdirSync(join(baseDir, dir), { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
// Create minimal config
|
|
33
|
+
const configContent = `
|
|
34
|
+
version: 1
|
|
35
|
+
memory:
|
|
36
|
+
enabled: true
|
|
37
|
+
decay:
|
|
38
|
+
enabled: false
|
|
39
|
+
`;
|
|
40
|
+
writeFileSync(join(baseDir, '.lumenflow.config.yaml'), configContent);
|
|
41
|
+
}
|
|
42
|
+
describe('Memory Layer Integration Tests (WU-1363)', () => {
|
|
43
|
+
let tempDir;
|
|
44
|
+
let originalCwd;
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
tempDir = join(tmpdir(), `memory-integration-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
47
|
+
mkdirSync(tempDir, { recursive: true });
|
|
48
|
+
originalCwd = process.cwd();
|
|
49
|
+
createMemoryProject(tempDir);
|
|
50
|
+
});
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
process.chdir(originalCwd);
|
|
53
|
+
if (existsSync(tempDir)) {
|
|
54
|
+
try {
|
|
55
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Ignore cleanup errors
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
describe('AC3: Integration tests for memory checkpoint, signal, inbox', () => {
|
|
63
|
+
describe('mem:checkpoint functionality', () => {
|
|
64
|
+
it('should create a checkpoint node with correct structure', async () => {
|
|
65
|
+
// Arrange
|
|
66
|
+
process.chdir(tempDir);
|
|
67
|
+
const note = 'Checkpoint before gates';
|
|
68
|
+
// Act
|
|
69
|
+
const result = await createCheckpoint(tempDir, {
|
|
70
|
+
note,
|
|
71
|
+
wuId: TEST_WU_ID,
|
|
72
|
+
sessionId: TEST_SESSION_ID,
|
|
73
|
+
});
|
|
74
|
+
// Assert
|
|
75
|
+
expect(result.success).toBe(true);
|
|
76
|
+
expect(result.checkpoint).toBeDefined();
|
|
77
|
+
expect(result.checkpoint.id).toMatch(/^mem-/);
|
|
78
|
+
expect(result.checkpoint.type).toBe('checkpoint');
|
|
79
|
+
expect(result.checkpoint.content).toContain(note);
|
|
80
|
+
expect(result.checkpoint.wu_id).toBe(TEST_WU_ID);
|
|
81
|
+
expect(result.checkpoint.session_id).toBe(TEST_SESSION_ID);
|
|
82
|
+
});
|
|
83
|
+
it('should include progress and nextSteps in metadata', async () => {
|
|
84
|
+
// Arrange
|
|
85
|
+
process.chdir(tempDir);
|
|
86
|
+
const progress = 'Completed AC1 and AC2';
|
|
87
|
+
const nextSteps = 'Run gates and complete wu:done';
|
|
88
|
+
// Act
|
|
89
|
+
const result = await createCheckpoint(tempDir, {
|
|
90
|
+
note: 'Progress checkpoint',
|
|
91
|
+
wuId: TEST_WU_ID,
|
|
92
|
+
progress,
|
|
93
|
+
nextSteps,
|
|
94
|
+
});
|
|
95
|
+
// Assert
|
|
96
|
+
expect(result.checkpoint.metadata).toBeDefined();
|
|
97
|
+
expect(result.checkpoint.metadata?.progress).toBe(progress);
|
|
98
|
+
expect(result.checkpoint.metadata?.nextSteps).toBe(nextSteps);
|
|
99
|
+
});
|
|
100
|
+
it('should persist checkpoint to memory store', async () => {
|
|
101
|
+
// Arrange
|
|
102
|
+
process.chdir(tempDir);
|
|
103
|
+
// Act
|
|
104
|
+
await createCheckpoint(tempDir, {
|
|
105
|
+
note: 'Persisted checkpoint',
|
|
106
|
+
wuId: TEST_WU_ID,
|
|
107
|
+
});
|
|
108
|
+
// Assert - Check memory file exists (memory store uses memory.jsonl)
|
|
109
|
+
const memoryFile = join(tempDir, '.lumenflow/memory/memory.jsonl');
|
|
110
|
+
expect(existsSync(memoryFile)).toBe(true);
|
|
111
|
+
const content = readFileSync(memoryFile, 'utf-8');
|
|
112
|
+
expect(content).toContain('Persisted checkpoint');
|
|
113
|
+
});
|
|
114
|
+
it('should validate note is required', async () => {
|
|
115
|
+
// Arrange
|
|
116
|
+
process.chdir(tempDir);
|
|
117
|
+
// Act & Assert
|
|
118
|
+
await expect(createCheckpoint(tempDir, {
|
|
119
|
+
note: '',
|
|
120
|
+
wuId: TEST_WU_ID,
|
|
121
|
+
})).rejects.toThrow(/empty/i);
|
|
122
|
+
});
|
|
123
|
+
it('should validate WU ID format if provided', async () => {
|
|
124
|
+
// Arrange
|
|
125
|
+
process.chdir(tempDir);
|
|
126
|
+
// Act & Assert
|
|
127
|
+
await expect(createCheckpoint(tempDir, {
|
|
128
|
+
note: 'Test checkpoint',
|
|
129
|
+
wuId: 'INVALID-ID',
|
|
130
|
+
})).rejects.toThrow(/WU/i);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
describe('mem:signal functionality', () => {
|
|
134
|
+
it('should create a signal with correct structure', async () => {
|
|
135
|
+
// Arrange
|
|
136
|
+
process.chdir(tempDir);
|
|
137
|
+
const message = 'Starting implementation';
|
|
138
|
+
// Act
|
|
139
|
+
const result = await createSignal(tempDir, {
|
|
140
|
+
message,
|
|
141
|
+
wuId: TEST_WU_ID,
|
|
142
|
+
lane: TEST_LANE,
|
|
143
|
+
});
|
|
144
|
+
// Assert
|
|
145
|
+
expect(result.success).toBe(true);
|
|
146
|
+
expect(result.signal).toBeDefined();
|
|
147
|
+
expect(result.signal.id).toMatch(/^sig-/);
|
|
148
|
+
expect(result.signal.message).toBe(message);
|
|
149
|
+
expect(result.signal.wu_id).toBe(TEST_WU_ID);
|
|
150
|
+
expect(result.signal.lane).toBe(TEST_LANE);
|
|
151
|
+
expect(result.signal.read).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
it('should persist signal to signals.jsonl', async () => {
|
|
154
|
+
// Arrange
|
|
155
|
+
process.chdir(tempDir);
|
|
156
|
+
// Act
|
|
157
|
+
await createSignal(tempDir, {
|
|
158
|
+
message: 'Persisted signal',
|
|
159
|
+
wuId: TEST_WU_ID,
|
|
160
|
+
});
|
|
161
|
+
// Assert
|
|
162
|
+
const signalsFile = join(tempDir, '.lumenflow/memory/signals.jsonl');
|
|
163
|
+
expect(existsSync(signalsFile)).toBe(true);
|
|
164
|
+
const content = readFileSync(signalsFile, 'utf-8');
|
|
165
|
+
expect(content).toContain('Persisted signal');
|
|
166
|
+
});
|
|
167
|
+
it('should validate message is required', async () => {
|
|
168
|
+
// Arrange
|
|
169
|
+
process.chdir(tempDir);
|
|
170
|
+
// Act & Assert
|
|
171
|
+
await expect(createSignal(tempDir, {
|
|
172
|
+
message: '',
|
|
173
|
+
wuId: TEST_WU_ID,
|
|
174
|
+
})).rejects.toThrow(/required/i);
|
|
175
|
+
});
|
|
176
|
+
it('should validate WU ID format if provided', async () => {
|
|
177
|
+
// Arrange
|
|
178
|
+
process.chdir(tempDir);
|
|
179
|
+
// Act & Assert
|
|
180
|
+
await expect(createSignal(tempDir, {
|
|
181
|
+
message: 'Test signal',
|
|
182
|
+
wuId: 'INVALID-123',
|
|
183
|
+
})).rejects.toThrow(/WU/i);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
describe('mem:inbox functionality', () => {
|
|
187
|
+
it('should load all signals', async () => {
|
|
188
|
+
// Arrange
|
|
189
|
+
process.chdir(tempDir);
|
|
190
|
+
await createSignal(tempDir, { message: 'Signal 1', wuId: TEST_WU_ID });
|
|
191
|
+
await createSignal(tempDir, { message: 'Signal 2', wuId: TEST_WU_ID });
|
|
192
|
+
await createSignal(tempDir, { message: 'Signal 3', wuId: 'WU-9999' });
|
|
193
|
+
// Act
|
|
194
|
+
const signals = await loadSignals(tempDir);
|
|
195
|
+
// Assert
|
|
196
|
+
expect(signals).toHaveLength(3);
|
|
197
|
+
});
|
|
198
|
+
it('should filter signals by WU ID', async () => {
|
|
199
|
+
// Arrange
|
|
200
|
+
process.chdir(tempDir);
|
|
201
|
+
await createSignal(tempDir, { message: 'Signal 1', wuId: TEST_WU_ID });
|
|
202
|
+
await createSignal(tempDir, { message: 'Signal 2', wuId: TEST_WU_ID });
|
|
203
|
+
await createSignal(tempDir, { message: 'Other WU', wuId: 'WU-9999' });
|
|
204
|
+
// Act
|
|
205
|
+
const signals = await loadSignals(tempDir, { wuId: TEST_WU_ID });
|
|
206
|
+
// Assert
|
|
207
|
+
expect(signals).toHaveLength(2);
|
|
208
|
+
signals.forEach((sig) => expect(sig.wu_id).toBe(TEST_WU_ID));
|
|
209
|
+
});
|
|
210
|
+
it('should filter signals by lane', async () => {
|
|
211
|
+
// Arrange
|
|
212
|
+
process.chdir(tempDir);
|
|
213
|
+
await createSignal(tempDir, { message: 'CLI signal', lane: TEST_LANE });
|
|
214
|
+
await createSignal(tempDir, { message: 'Other lane', lane: 'Framework: Core' });
|
|
215
|
+
// Act
|
|
216
|
+
const signals = await loadSignals(tempDir, { lane: TEST_LANE });
|
|
217
|
+
// Assert
|
|
218
|
+
expect(signals).toHaveLength(1);
|
|
219
|
+
expect(signals[0].lane).toBe(TEST_LANE);
|
|
220
|
+
});
|
|
221
|
+
it('should filter unread signals only', async () => {
|
|
222
|
+
// Arrange
|
|
223
|
+
process.chdir(tempDir);
|
|
224
|
+
const result1 = await createSignal(tempDir, { message: 'Unread signal' });
|
|
225
|
+
await createSignal(tempDir, { message: 'Another unread' });
|
|
226
|
+
// Mark first as read
|
|
227
|
+
await markSignalsAsRead(tempDir, [result1.signal.id]);
|
|
228
|
+
// Act
|
|
229
|
+
const signals = await loadSignals(tempDir, { unreadOnly: true });
|
|
230
|
+
// Assert
|
|
231
|
+
expect(signals).toHaveLength(1);
|
|
232
|
+
expect(signals[0].message).toBe('Another unread');
|
|
233
|
+
});
|
|
234
|
+
it('should filter signals since a specific time', async () => {
|
|
235
|
+
// Arrange
|
|
236
|
+
process.chdir(tempDir);
|
|
237
|
+
const beforeTime = new Date();
|
|
238
|
+
// Wait a bit to ensure time difference
|
|
239
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
240
|
+
await createSignal(tempDir, { message: 'Recent signal' });
|
|
241
|
+
// Act
|
|
242
|
+
const signals = await loadSignals(tempDir, { since: beforeTime });
|
|
243
|
+
// Assert
|
|
244
|
+
expect(signals).toHaveLength(1);
|
|
245
|
+
expect(signals[0].message).toBe('Recent signal');
|
|
246
|
+
});
|
|
247
|
+
it('should return empty array when no signals exist', async () => {
|
|
248
|
+
// Arrange
|
|
249
|
+
process.chdir(tempDir);
|
|
250
|
+
// Act
|
|
251
|
+
const signals = await loadSignals(tempDir);
|
|
252
|
+
// Assert
|
|
253
|
+
expect(signals).toHaveLength(0);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
describe('mark signals as read', () => {
|
|
257
|
+
it('should mark signals as read', async () => {
|
|
258
|
+
// Arrange
|
|
259
|
+
process.chdir(tempDir);
|
|
260
|
+
const result1 = await createSignal(tempDir, { message: 'Signal 1' });
|
|
261
|
+
const result2 = await createSignal(tempDir, { message: 'Signal 2' });
|
|
262
|
+
// Act
|
|
263
|
+
const markResult = await markSignalsAsRead(tempDir, [result1.signal.id, result2.signal.id]);
|
|
264
|
+
// Assert
|
|
265
|
+
expect(markResult.markedCount).toBe(2);
|
|
266
|
+
// Verify signals are now read
|
|
267
|
+
const allSignals = await loadSignals(tempDir);
|
|
268
|
+
expect(allSignals.every((sig) => sig.read)).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
it('should not count already-read signals', async () => {
|
|
271
|
+
// Arrange
|
|
272
|
+
process.chdir(tempDir);
|
|
273
|
+
const result = await createSignal(tempDir, { message: 'Signal 1' });
|
|
274
|
+
// Mark as read first time
|
|
275
|
+
await markSignalsAsRead(tempDir, [result.signal.id]);
|
|
276
|
+
// Act - Try to mark again
|
|
277
|
+
const secondMarkResult = await markSignalsAsRead(tempDir, [result.signal.id]);
|
|
278
|
+
// Assert
|
|
279
|
+
expect(secondMarkResult.markedCount).toBe(0);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
describe('complete memory workflow', () => {
|
|
283
|
+
it('should support full checkpoint and signal workflow', async () => {
|
|
284
|
+
// This test validates the complete memory workflow:
|
|
285
|
+
// 1. Create initial checkpoint
|
|
286
|
+
// 2. Send progress signals
|
|
287
|
+
// 3. Check inbox for signals
|
|
288
|
+
// 4. Mark signals as read
|
|
289
|
+
// 5. Create final checkpoint
|
|
290
|
+
// Arrange
|
|
291
|
+
process.chdir(tempDir);
|
|
292
|
+
// Step 1: Initial checkpoint
|
|
293
|
+
const initialCheckpoint = await createCheckpoint(tempDir, {
|
|
294
|
+
note: 'Starting work on WU',
|
|
295
|
+
wuId: TEST_WU_ID,
|
|
296
|
+
sessionId: TEST_SESSION_ID,
|
|
297
|
+
});
|
|
298
|
+
expect(initialCheckpoint.success).toBe(true);
|
|
299
|
+
// Step 2: Send progress signals
|
|
300
|
+
await createSignal(tempDir, {
|
|
301
|
+
message: 'AC1 complete',
|
|
302
|
+
wuId: TEST_WU_ID,
|
|
303
|
+
lane: TEST_LANE,
|
|
304
|
+
});
|
|
305
|
+
await createSignal(tempDir, {
|
|
306
|
+
message: 'AC2 in progress',
|
|
307
|
+
wuId: TEST_WU_ID,
|
|
308
|
+
lane: TEST_LANE,
|
|
309
|
+
});
|
|
310
|
+
// Step 3: Check inbox
|
|
311
|
+
const inbox = await loadSignals(tempDir, { wuId: TEST_WU_ID, unreadOnly: true });
|
|
312
|
+
expect(inbox).toHaveLength(2);
|
|
313
|
+
// Step 4: Mark as read
|
|
314
|
+
const signalIds = inbox.map((sig) => sig.id);
|
|
315
|
+
await markSignalsAsRead(tempDir, signalIds);
|
|
316
|
+
const unreadAfter = await loadSignals(tempDir, { unreadOnly: true });
|
|
317
|
+
expect(unreadAfter).toHaveLength(0);
|
|
318
|
+
// Step 5: Final checkpoint
|
|
319
|
+
const finalCheckpoint = await createCheckpoint(tempDir, {
|
|
320
|
+
note: 'Work complete, ready for wu:done',
|
|
321
|
+
wuId: TEST_WU_ID,
|
|
322
|
+
sessionId: TEST_SESSION_ID,
|
|
323
|
+
progress: 'All acceptance criteria met',
|
|
324
|
+
nextSteps: 'Run pnpm wu:done --id ' + TEST_WU_ID,
|
|
325
|
+
});
|
|
326
|
+
expect(finalCheckpoint.success).toBe(true);
|
|
327
|
+
// Verify memory store has all data (memory store uses memory.jsonl)
|
|
328
|
+
const memoryFile = join(tempDir, '.lumenflow/memory/memory.jsonl');
|
|
329
|
+
expect(existsSync(memoryFile)).toBe(true);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -107,7 +107,7 @@ describe('release command', () => {
|
|
|
107
107
|
await updatePackageVersions([packagePath], '1.2.3');
|
|
108
108
|
// Read back raw content and check formatting
|
|
109
109
|
const content = await import('node:fs/promises').then((fs) => fs.readFile(packagePath, 'utf-8'));
|
|
110
|
-
expect(content).toMatch(/{\n
|
|
110
|
+
expect(content).toMatch(/{\n {2}"name"/); // Preserve 2-space indent
|
|
111
111
|
});
|
|
112
112
|
});
|
|
113
113
|
describe('buildCommitMessage', () => {
|
|
@@ -229,6 +229,60 @@ describe('state-doctor CLI (WU-1230)', () => {
|
|
|
229
229
|
expect(mockWithMicroWorktree).toHaveBeenCalled();
|
|
230
230
|
});
|
|
231
231
|
});
|
|
232
|
+
// WU-1362: Retry logic for push failures
|
|
233
|
+
describe('WU-1362: retry logic for push failures', () => {
|
|
234
|
+
it('should retry on push failure with exponential backoff', async () => {
|
|
235
|
+
setupTestState(testDir, {
|
|
236
|
+
wus: [],
|
|
237
|
+
events: [{ wuId: 'WU-999', type: 'claimed', timestamp: new Date().toISOString() }],
|
|
238
|
+
});
|
|
239
|
+
let callCount = 0;
|
|
240
|
+
const mockWithMicroWorktree = vi.mocked(withMicroWorktree);
|
|
241
|
+
mockWithMicroWorktree.mockImplementation(async (options) => {
|
|
242
|
+
callCount++;
|
|
243
|
+
// Simulate the micro-worktree handling retries internally
|
|
244
|
+
const result = await options.execute({
|
|
245
|
+
worktreePath: testDir,
|
|
246
|
+
gitWorktree: {
|
|
247
|
+
add: vi.fn(),
|
|
248
|
+
addWithDeletions: vi.fn(),
|
|
249
|
+
commit: vi.fn(),
|
|
250
|
+
push: vi.fn(),
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
return { ...result, ref: 'main' };
|
|
254
|
+
});
|
|
255
|
+
const { createStateDoctorFixDeps } = await import('../state-doctor-fix.js');
|
|
256
|
+
const deps = createStateDoctorFixDeps(testDir);
|
|
257
|
+
// removeEvent should succeed (micro-worktree handles retry internally)
|
|
258
|
+
await deps.removeEvent('WU-999');
|
|
259
|
+
expect(callCount).toBe(1);
|
|
260
|
+
});
|
|
261
|
+
it('should use maxRetries configuration from config', async () => {
|
|
262
|
+
setupTestState(testDir, {
|
|
263
|
+
wus: [],
|
|
264
|
+
events: [{ wuId: 'WU-999', type: 'claimed', timestamp: new Date().toISOString() }],
|
|
265
|
+
});
|
|
266
|
+
const mockWithMicroWorktree = vi.mocked(withMicroWorktree);
|
|
267
|
+
mockWithMicroWorktree.mockImplementation(async (options) => {
|
|
268
|
+
// Verify retries is set (micro-worktree handles retry logic)
|
|
269
|
+
const result = await options.execute({
|
|
270
|
+
worktreePath: testDir,
|
|
271
|
+
gitWorktree: {
|
|
272
|
+
add: vi.fn(),
|
|
273
|
+
addWithDeletions: vi.fn(),
|
|
274
|
+
commit: vi.fn(),
|
|
275
|
+
push: vi.fn(),
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
return { ...result, ref: 'main' };
|
|
279
|
+
});
|
|
280
|
+
const { createStateDoctorFixDeps } = await import('../state-doctor-fix.js');
|
|
281
|
+
const deps = createStateDoctorFixDeps(testDir);
|
|
282
|
+
await deps.removeEvent('WU-999');
|
|
283
|
+
expect(mockWithMicroWorktree).toHaveBeenCalled();
|
|
284
|
+
});
|
|
285
|
+
});
|
|
232
286
|
});
|
|
233
287
|
function setupTestState(baseDir, state) {
|
|
234
288
|
// Create directories
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for sync:templates command (WU-1368)
|
|
3
|
+
*
|
|
4
|
+
* Two bugs being fixed:
|
|
5
|
+
* 1. --check-drift flag syncs files instead of only checking - should be read-only
|
|
6
|
+
* 2. sync:templates writes directly to main checkout - should use micro-worktree isolation
|
|
7
|
+
*
|
|
8
|
+
* TDD: These tests are written BEFORE the implementation changes.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
// Mock modules before importing
|
|
15
|
+
const mockWithMicroWorktree = vi.fn();
|
|
16
|
+
vi.mock('@lumenflow/core/dist/micro-worktree.js', () => ({
|
|
17
|
+
withMicroWorktree: mockWithMicroWorktree,
|
|
18
|
+
}));
|
|
19
|
+
vi.mock('@lumenflow/core/dist/git-adapter.js', () => ({
|
|
20
|
+
getGitForCwd: vi.fn(() => ({
|
|
21
|
+
branch: vi.fn().mockResolvedValue({ current: 'main' }),
|
|
22
|
+
status: vi.fn().mockResolvedValue({ isClean: () => true }),
|
|
23
|
+
})),
|
|
24
|
+
}));
|
|
25
|
+
vi.mock('@lumenflow/core/dist/wu-helpers.js', () => ({
|
|
26
|
+
ensureOnMain: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
}));
|
|
28
|
+
describe('sync:templates --check-drift', () => {
|
|
29
|
+
let tempDir;
|
|
30
|
+
let originalCwd;
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
tempDir = join(tmpdir(), `sync-templates-test-${Date.now()}`);
|
|
33
|
+
mkdirSync(tempDir, { recursive: true });
|
|
34
|
+
originalCwd = process.cwd();
|
|
35
|
+
process.chdir(tempDir);
|
|
36
|
+
// Set up minimal project structure
|
|
37
|
+
const templatesDir = join(tempDir, 'packages', '@lumenflow', 'cli', 'templates', 'core');
|
|
38
|
+
mkdirSync(templatesDir, { recursive: true });
|
|
39
|
+
// Create LUMENFLOW.md source
|
|
40
|
+
writeFileSync(join(tempDir, 'LUMENFLOW.md'), '# LumenFlow\n\nLast updated: 2025-01-01\n');
|
|
41
|
+
// Create matching template (no drift)
|
|
42
|
+
writeFileSync(join(templatesDir, 'LUMENFLOW.md.template'), '# LumenFlow\n\nLast updated: {{DATE}}\n');
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
process.chdir(originalCwd);
|
|
46
|
+
if (existsSync(tempDir)) {
|
|
47
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
vi.clearAllMocks();
|
|
50
|
+
});
|
|
51
|
+
describe('checkTemplateDrift', () => {
|
|
52
|
+
it('should NOT write any files when checking drift', async () => {
|
|
53
|
+
const { checkTemplateDrift } = await import('../sync-templates.js');
|
|
54
|
+
// Get initial file mtimes
|
|
55
|
+
const sourceFile = join(tempDir, 'LUMENFLOW.md');
|
|
56
|
+
const templateFile = join(tempDir, 'packages', '@lumenflow', 'cli', 'templates', 'core', 'LUMENFLOW.md.template');
|
|
57
|
+
const sourceMtimeBefore = existsSync(sourceFile) ? readFileSync(sourceFile, 'utf-8') : null;
|
|
58
|
+
const templateMtimeBefore = existsSync(templateFile)
|
|
59
|
+
? readFileSync(templateFile, 'utf-8')
|
|
60
|
+
: null;
|
|
61
|
+
// Run check-drift
|
|
62
|
+
await checkTemplateDrift(tempDir);
|
|
63
|
+
// Verify files were NOT modified
|
|
64
|
+
const sourceMtimeAfter = existsSync(sourceFile) ? readFileSync(sourceFile, 'utf-8') : null;
|
|
65
|
+
const templateMtimeAfter = existsSync(templateFile)
|
|
66
|
+
? readFileSync(templateFile, 'utf-8')
|
|
67
|
+
: null;
|
|
68
|
+
expect(sourceMtimeAfter).toBe(sourceMtimeBefore);
|
|
69
|
+
expect(templateMtimeAfter).toBe(templateMtimeBefore);
|
|
70
|
+
});
|
|
71
|
+
it('should return hasDrift=false when templates match source', async () => {
|
|
72
|
+
const { checkTemplateDrift } = await import('../sync-templates.js');
|
|
73
|
+
const result = await checkTemplateDrift(tempDir);
|
|
74
|
+
expect(result.hasDrift).toBe(false);
|
|
75
|
+
expect(result.driftingFiles).toHaveLength(0);
|
|
76
|
+
});
|
|
77
|
+
it('should return hasDrift=true when templates differ from source', async () => {
|
|
78
|
+
const { checkTemplateDrift } = await import('../sync-templates.js');
|
|
79
|
+
// Create source with different content
|
|
80
|
+
writeFileSync(join(tempDir, 'LUMENFLOW.md'), '# LumenFlow Updated\n\nNew content here\n');
|
|
81
|
+
const result = await checkTemplateDrift(tempDir);
|
|
82
|
+
expect(result.hasDrift).toBe(true);
|
|
83
|
+
expect(result.driftingFiles.length).toBeGreaterThan(0);
|
|
84
|
+
});
|
|
85
|
+
it('should return hasDrift=true when template file is missing', async () => {
|
|
86
|
+
const { checkTemplateDrift } = await import('../sync-templates.js');
|
|
87
|
+
// Remove template file
|
|
88
|
+
const templateFile = join(tempDir, 'packages', '@lumenflow', 'cli', 'templates', 'core', 'LUMENFLOW.md.template');
|
|
89
|
+
rmSync(templateFile);
|
|
90
|
+
const result = await checkTemplateDrift(tempDir);
|
|
91
|
+
expect(result.hasDrift).toBe(true);
|
|
92
|
+
expect(result.driftingFiles).toContain('packages/@lumenflow/cli/templates/core/LUMENFLOW.md.template');
|
|
93
|
+
});
|
|
94
|
+
it('should NOT call withMicroWorktree during drift check', async () => {
|
|
95
|
+
const { checkTemplateDrift } = await import('../sync-templates.js');
|
|
96
|
+
await checkTemplateDrift(tempDir);
|
|
97
|
+
// withMicroWorktree should NOT be called for read-only drift check
|
|
98
|
+
expect(mockWithMicroWorktree).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe('exit codes', () => {
|
|
102
|
+
it('should exit 1 when drift is detected', async () => {
|
|
103
|
+
const { checkTemplateDrift } = await import('../sync-templates.js');
|
|
104
|
+
// Create drifting source
|
|
105
|
+
writeFileSync(join(tempDir, 'LUMENFLOW.md'), '# LumenFlow Updated\n\nDifferent content\n');
|
|
106
|
+
const result = await checkTemplateDrift(tempDir);
|
|
107
|
+
// The result should indicate drift - CLI will use this to set exit code
|
|
108
|
+
expect(result.hasDrift).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
it('should exit 0 when no drift detected', async () => {
|
|
111
|
+
const { checkTemplateDrift } = await import('../sync-templates.js');
|
|
112
|
+
const result = await checkTemplateDrift(tempDir);
|
|
113
|
+
// The result should indicate no drift - CLI will use this to set exit code
|
|
114
|
+
expect(result.hasDrift).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
describe('sync:templates (sync mode)', () => {
|
|
119
|
+
let tempDir;
|
|
120
|
+
let originalCwd;
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
tempDir = join(tmpdir(), `sync-templates-test-${Date.now()}`);
|
|
123
|
+
mkdirSync(tempDir, { recursive: true });
|
|
124
|
+
originalCwd = process.cwd();
|
|
125
|
+
process.chdir(tempDir);
|
|
126
|
+
// Reset mock
|
|
127
|
+
mockWithMicroWorktree.mockReset();
|
|
128
|
+
mockWithMicroWorktree.mockImplementation(async ({ execute }) => {
|
|
129
|
+
// Simulate micro-worktree by creating temp dir and calling execute
|
|
130
|
+
const wtPath = join(tmpdir(), `micro-wt-${Date.now()}`);
|
|
131
|
+
mkdirSync(wtPath, { recursive: true });
|
|
132
|
+
const result = await execute({
|
|
133
|
+
worktreePath: wtPath,
|
|
134
|
+
gitWorktree: {
|
|
135
|
+
add: vi.fn().mockResolvedValue(undefined),
|
|
136
|
+
commit: vi.fn().mockResolvedValue(undefined),
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
return { ...result, ref: 'main' };
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
afterEach(() => {
|
|
143
|
+
process.chdir(originalCwd);
|
|
144
|
+
if (existsSync(tempDir)) {
|
|
145
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
vi.clearAllMocks();
|
|
148
|
+
});
|
|
149
|
+
describe('micro-worktree isolation', () => {
|
|
150
|
+
it('should use withMicroWorktree for sync operations', async () => {
|
|
151
|
+
const { syncTemplatesWithWorktree } = await import('../sync-templates.js');
|
|
152
|
+
// Set up source files
|
|
153
|
+
writeFileSync(join(tempDir, 'LUMENFLOW.md'), '# LumenFlow\n\nContent\n');
|
|
154
|
+
mkdirSync(join(tempDir, '.lumenflow'), { recursive: true });
|
|
155
|
+
writeFileSync(join(tempDir, '.lumenflow', 'constraints.md'), '# Constraints\n');
|
|
156
|
+
await syncTemplatesWithWorktree(tempDir);
|
|
157
|
+
// Verify withMicroWorktree was called
|
|
158
|
+
expect(mockWithMicroWorktree).toHaveBeenCalledTimes(1);
|
|
159
|
+
expect(mockWithMicroWorktree).toHaveBeenCalledWith(expect.objectContaining({
|
|
160
|
+
operation: 'sync-templates',
|
|
161
|
+
id: expect.any(String),
|
|
162
|
+
execute: expect.any(Function),
|
|
163
|
+
}));
|
|
164
|
+
});
|
|
165
|
+
it('should write files to micro-worktree path, not main checkout', async () => {
|
|
166
|
+
const { syncTemplatesWithWorktree } = await import('../sync-templates.js');
|
|
167
|
+
// Set up source files
|
|
168
|
+
writeFileSync(join(tempDir, 'LUMENFLOW.md'), '# LumenFlow\n\nContent\n');
|
|
169
|
+
let capturedWorktreePath = null;
|
|
170
|
+
mockWithMicroWorktree.mockImplementation(async ({ execute }) => {
|
|
171
|
+
const wtPath = join(tmpdir(), `micro-wt-verify-${Date.now()}`);
|
|
172
|
+
mkdirSync(wtPath, { recursive: true });
|
|
173
|
+
capturedWorktreePath = wtPath;
|
|
174
|
+
// Create templates structure in worktree
|
|
175
|
+
const templatesDir = join(wtPath, 'packages', '@lumenflow', 'cli', 'templates', 'core');
|
|
176
|
+
mkdirSync(templatesDir, { recursive: true });
|
|
177
|
+
const result = await execute({
|
|
178
|
+
worktreePath: wtPath,
|
|
179
|
+
gitWorktree: {
|
|
180
|
+
add: vi.fn().mockResolvedValue(undefined),
|
|
181
|
+
commit: vi.fn().mockResolvedValue(undefined),
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
return { ...result, ref: 'main' };
|
|
185
|
+
});
|
|
186
|
+
await syncTemplatesWithWorktree(tempDir);
|
|
187
|
+
// Verify worktree path was used (not main checkout)
|
|
188
|
+
expect(capturedWorktreePath).not.toBeNull();
|
|
189
|
+
expect(capturedWorktreePath).not.toBe(tempDir);
|
|
190
|
+
expect(capturedWorktreePath.startsWith(tmpdir())).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
it('should return list of synced files for commit', async () => {
|
|
193
|
+
const { syncTemplatesWithWorktree } = await import('../sync-templates.js');
|
|
194
|
+
// Set up source files
|
|
195
|
+
writeFileSync(join(tempDir, 'LUMENFLOW.md'), '# LumenFlow\n\nContent\n');
|
|
196
|
+
let capturedResult = null;
|
|
197
|
+
mockWithMicroWorktree.mockImplementation(async ({ execute }) => {
|
|
198
|
+
const wtPath = join(tmpdir(), `micro-wt-files-${Date.now()}`);
|
|
199
|
+
mkdirSync(wtPath, { recursive: true });
|
|
200
|
+
// Create templates structure
|
|
201
|
+
const templatesDir = join(wtPath, 'packages', '@lumenflow', 'cli', 'templates', 'core');
|
|
202
|
+
mkdirSync(templatesDir, { recursive: true });
|
|
203
|
+
const result = await execute({
|
|
204
|
+
worktreePath: wtPath,
|
|
205
|
+
gitWorktree: {
|
|
206
|
+
add: vi.fn().mockResolvedValue(undefined),
|
|
207
|
+
commit: vi.fn().mockResolvedValue(undefined),
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
capturedResult = result;
|
|
211
|
+
return { ...result, ref: 'main' };
|
|
212
|
+
});
|
|
213
|
+
await syncTemplatesWithWorktree(tempDir);
|
|
214
|
+
expect(capturedResult).not.toBeNull();
|
|
215
|
+
expect(capturedResult.commitMessage).toContain('sync:templates');
|
|
216
|
+
expect(Array.isArray(capturedResult.files)).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
describe('atomic commit', () => {
|
|
220
|
+
it('should create atomic commit via micro-worktree pattern', async () => {
|
|
221
|
+
const { syncTemplatesWithWorktree } = await import('../sync-templates.js');
|
|
222
|
+
// Set up source files
|
|
223
|
+
writeFileSync(join(tempDir, 'LUMENFLOW.md'), '# LumenFlow\n\nContent\n');
|
|
224
|
+
await syncTemplatesWithWorktree(tempDir);
|
|
225
|
+
// Verify withMicroWorktree was called (atomic commit pattern)
|
|
226
|
+
expect(mockWithMicroWorktree).toHaveBeenCalled();
|
|
227
|
+
// Verify the execute function returns proper commit info
|
|
228
|
+
const callArgs = mockWithMicroWorktree.mock.calls[0][0];
|
|
229
|
+
expect(callArgs.operation).toBe('sync-templates');
|
|
230
|
+
});
|
|
231
|
+
it('should include timestamp in operation id for uniqueness', async () => {
|
|
232
|
+
const { syncTemplatesWithWorktree } = await import('../sync-templates.js');
|
|
233
|
+
writeFileSync(join(tempDir, 'LUMENFLOW.md'), '# LumenFlow\n');
|
|
234
|
+
await syncTemplatesWithWorktree(tempDir);
|
|
235
|
+
const callArgs = mockWithMicroWorktree.mock.calls[0][0];
|
|
236
|
+
// ID should be timestamp-based or unique identifier
|
|
237
|
+
expect(typeof callArgs.id).toBe('string');
|
|
238
|
+
expect(callArgs.id.length).toBeGreaterThan(0);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
describe('sync:templates exports', () => {
|
|
243
|
+
it('should export checkTemplateDrift function', async () => {
|
|
244
|
+
const syncTemplates = await import('../sync-templates.js');
|
|
245
|
+
expect(typeof syncTemplates.checkTemplateDrift).toBe('function');
|
|
246
|
+
});
|
|
247
|
+
it('should export syncTemplatesWithWorktree function', async () => {
|
|
248
|
+
const syncTemplates = await import('../sync-templates.js');
|
|
249
|
+
expect(typeof syncTemplates.syncTemplatesWithWorktree).toBe('function');
|
|
250
|
+
});
|
|
251
|
+
it('should export main function for CLI entry', async () => {
|
|
252
|
+
const syncTemplates = await import('../sync-templates.js');
|
|
253
|
+
expect(typeof syncTemplates.main).toBe('function');
|
|
254
|
+
});
|
|
255
|
+
});
|