fe-harness 1.0.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.
Files changed (41) hide show
  1. package/README.md +55 -0
  2. package/agents/fe-codebase-mapper.md +945 -0
  3. package/agents/fe-design-scanner.md +47 -0
  4. package/agents/fe-executor.md +221 -0
  5. package/agents/fe-fix-loop.md +310 -0
  6. package/agents/fe-fixer.md +153 -0
  7. package/agents/fe-project-scanner.md +95 -0
  8. package/agents/fe-reviewer.md +141 -0
  9. package/agents/fe-verifier.md +231 -0
  10. package/agents/fe-wave-runner.md +477 -0
  11. package/bin/install.js +292 -0
  12. package/commands/fe/complete.md +35 -0
  13. package/commands/fe/execute.md +46 -0
  14. package/commands/fe/help.md +17 -0
  15. package/commands/fe/map-codebase.md +60 -0
  16. package/commands/fe/plan.md +36 -0
  17. package/commands/fe/status.md +39 -0
  18. package/fe-harness/bin/browser.cjs +271 -0
  19. package/fe-harness/bin/fe-tools.cjs +317 -0
  20. package/fe-harness/bin/lib/__tests__/browser.test.cjs +422 -0
  21. package/fe-harness/bin/lib/__tests__/config.test.cjs +93 -0
  22. package/fe-harness/bin/lib/__tests__/core.test.cjs +127 -0
  23. package/fe-harness/bin/lib/__tests__/scoring.test.cjs +130 -0
  24. package/fe-harness/bin/lib/__tests__/tasks.test.cjs +698 -0
  25. package/fe-harness/bin/lib/browser-core.cjs +365 -0
  26. package/fe-harness/bin/lib/config.cjs +34 -0
  27. package/fe-harness/bin/lib/core.cjs +135 -0
  28. package/fe-harness/bin/lib/logger.cjs +93 -0
  29. package/fe-harness/bin/lib/scoring.cjs +219 -0
  30. package/fe-harness/bin/lib/tasks.cjs +632 -0
  31. package/fe-harness/references/model-profiles.md +44 -0
  32. package/fe-harness/templates/config.jsonc +31 -0
  33. package/fe-harness/vendor/.gitkeep +0 -0
  34. package/fe-harness/vendor/puppeteer-core.cjs +445 -0
  35. package/fe-harness/workflows/complete.md +143 -0
  36. package/fe-harness/workflows/execute.md +227 -0
  37. package/fe-harness/workflows/help.md +89 -0
  38. package/fe-harness/workflows/map-codebase.md +331 -0
  39. package/fe-harness/workflows/plan.md +244 -0
  40. package/package.json +35 -0
  41. package/scripts/bundle-puppeteer.js +38 -0
@@ -0,0 +1,698 @@
1
+ 'use strict';
2
+
3
+ const { describe, it, beforeEach, afterEach } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+ const { initConfig } = require('../config.cjs');
9
+ const {
10
+ listTasks, getTask, updateTask, getNextTask,
11
+ propagateFailure, failTask, completeTasks, saveRetryState,
12
+ resetTask, resetAllFailed, getStatus,
13
+ getWaves, checkConflicts, resolveConflicts, tasksPath, saveTasks,
14
+ getCompletionSummary, archiveTasks,
15
+ } = require('../tasks.cjs');
16
+
17
+ let tmpDir;
18
+
19
+ function seedTasks(tasks) {
20
+ saveTasks(tmpDir, tasks);
21
+ }
22
+
23
+ beforeEach(() => {
24
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fe-test-'));
25
+ initConfig(tmpDir, { maxRetries: 5 });
26
+ });
27
+
28
+ afterEach(() => {
29
+ fs.rmSync(tmpDir, { recursive: true, force: true });
30
+ });
31
+
32
+ // --- listTasks ---
33
+
34
+ describe('listTasks', () => {
35
+ it('should return empty array when no tasks file', () => {
36
+ assert.deepEqual(listTasks(tmpDir), []);
37
+ });
38
+
39
+ it('should return all tasks', () => {
40
+ seedTasks([{ id: 1, name: 'A', status: 'pending' }, { id: 2, name: 'B', status: 'done' }]);
41
+ const tasks = listTasks(tmpDir);
42
+ assert.equal(tasks.length, 2);
43
+ });
44
+ });
45
+
46
+ // --- getTask ---
47
+
48
+ describe('getTask', () => {
49
+ it('should return task by id', () => {
50
+ seedTasks([{ id: 1, name: 'A', status: 'pending' }]);
51
+ const task = getTask(tmpDir, '1');
52
+ assert.equal(task.name, 'A');
53
+ });
54
+
55
+ it('should return error for missing task', () => {
56
+ seedTasks([]);
57
+ const result = getTask(tmpDir, '99');
58
+ assert.ok(result.error);
59
+ });
60
+ });
61
+
62
+ // --- updateTask ---
63
+
64
+ describe('updateTask', () => {
65
+ it('should update a field', () => {
66
+ seedTasks([{ id: 1, name: 'A', status: 'pending' }]);
67
+ const result = updateTask(tmpDir, '1', 'status', 'in_progress');
68
+ assert.equal(result.ok, true);
69
+ assert.equal(getTask(tmpDir, '1').status, 'in_progress');
70
+ });
71
+
72
+ it('should set completedAt when status becomes done', () => {
73
+ seedTasks([{ id: 1, name: 'A', status: 'in_progress' }]);
74
+ updateTask(tmpDir, '1', 'status', 'done');
75
+ const task = getTask(tmpDir, '1');
76
+ assert.equal(task.status, 'done');
77
+ assert.ok(task.completedAt);
78
+ });
79
+
80
+ it('should auto-parse numbers', () => {
81
+ seedTasks([{ id: 1, name: 'A', status: 'pending', retryCount: 0 }]);
82
+ updateTask(tmpDir, '1', 'retryCount', '3');
83
+ assert.equal(getTask(tmpDir, '1').retryCount, 3);
84
+ });
85
+
86
+ it('should return error for missing task', () => {
87
+ seedTasks([]);
88
+ const result = updateTask(tmpDir, '99', 'status', 'done');
89
+ assert.ok(result.error);
90
+ });
91
+ });
92
+
93
+ // --- getNextTask ---
94
+
95
+ describe('getNextTask', () => {
96
+ it('should return in_progress task first', () => {
97
+ seedTasks([
98
+ { id: 1, name: 'A', status: 'pending' },
99
+ { id: 2, name: 'B', status: 'in_progress' },
100
+ ]);
101
+ const next = getNextTask(tmpDir);
102
+ assert.equal(next.id, 2);
103
+ });
104
+
105
+ it('should return first pending task with all deps done', () => {
106
+ seedTasks([
107
+ { id: 1, name: 'A', status: 'done' },
108
+ { id: 2, name: 'B', status: 'pending', dependsOn: [1] },
109
+ { id: 3, name: 'C', status: 'pending', dependsOn: [1] },
110
+ ]);
111
+ const next = getNextTask(tmpDir);
112
+ assert.equal(next.id, 2);
113
+ });
114
+
115
+ it('should skip pending task with unsatisfied deps', () => {
116
+ seedTasks([
117
+ { id: 1, name: 'A', status: 'pending' },
118
+ { id: 2, name: 'B', status: 'pending', dependsOn: [1] },
119
+ ]);
120
+ const next = getNextTask(tmpDir);
121
+ assert.equal(next.id, 1);
122
+ });
123
+
124
+ it('should return done message when all completed', () => {
125
+ seedTasks([{ id: 1, name: 'A', status: 'done' }]);
126
+ const next = getNextTask(tmpDir);
127
+ assert.equal(next.done, true);
128
+ });
129
+
130
+ it('should return done message when no tasks', () => {
131
+ seedTasks([]);
132
+ const next = getNextTask(tmpDir);
133
+ assert.equal(next.done, true);
134
+ });
135
+
136
+ it('should handle tasks with no dependsOn field', () => {
137
+ seedTasks([{ id: 1, name: 'A', status: 'pending' }]);
138
+ const next = getNextTask(tmpDir);
139
+ assert.equal(next.id, 1);
140
+ });
141
+ });
142
+
143
+ // --- propagateFailure ---
144
+
145
+ describe('propagateFailure', () => {
146
+ it('should skip direct dependents of failed task', () => {
147
+ seedTasks([
148
+ { id: 1, name: 'A', status: 'failed' },
149
+ { id: 2, name: 'B', status: 'pending', dependsOn: [1] },
150
+ { id: 3, name: 'C', status: 'pending' },
151
+ ]);
152
+ const result = propagateFailure(tmpDir, '1');
153
+ assert.equal(result.ok, true);
154
+ assert.deepEqual(result.skipped, [2]);
155
+ assert.equal(getTask(tmpDir, '2').status, 'skipped');
156
+ assert.equal(getTask(tmpDir, '3').status, 'pending');
157
+ });
158
+
159
+ it('should recursively skip transitive dependents', () => {
160
+ seedTasks([
161
+ { id: 1, name: 'A', status: 'failed' },
162
+ { id: 2, name: 'B', status: 'pending', dependsOn: [1] },
163
+ { id: 3, name: 'C', status: 'pending', dependsOn: [2] },
164
+ ]);
165
+ const result = propagateFailure(tmpDir, '1');
166
+ assert.deepEqual(result.skipped, [2, 3]);
167
+ });
168
+
169
+ it('should return error for missing task', () => {
170
+ seedTasks([]);
171
+ const result = propagateFailure(tmpDir, '99');
172
+ assert.ok(result.error);
173
+ });
174
+ });
175
+
176
+ // --- failTask ---
177
+
178
+ describe('failTask', () => {
179
+ it('should set status, error, and propagate in one call', () => {
180
+ seedTasks([
181
+ { id: 1, name: 'A', status: 'pending' },
182
+ { id: 2, name: 'B', status: 'pending', dependsOn: [1] },
183
+ { id: 3, name: 'C', status: 'pending', dependsOn: [2] },
184
+ ]);
185
+ const result = failTask(tmpDir, '1', 'merge conflict');
186
+ assert.equal(result.ok, true);
187
+ assert.equal(result.id, 1);
188
+ assert.deepEqual(result.skipped, [2, 3]);
189
+
190
+ const t1 = getTask(tmpDir, '1');
191
+ assert.equal(t1.status, 'failed');
192
+ assert.equal(t1.lastError, 'merge conflict');
193
+ assert.equal(getTask(tmpDir, '2').status, 'skipped');
194
+ assert.equal(getTask(tmpDir, '3').status, 'skipped');
195
+ });
196
+
197
+ it('should default error to empty string', () => {
198
+ seedTasks([{ id: 1, name: 'A', status: 'pending' }]);
199
+ failTask(tmpDir, '1');
200
+ assert.equal(getTask(tmpDir, '1').lastError, '');
201
+ });
202
+
203
+ it('should return error for missing task', () => {
204
+ seedTasks([]);
205
+ assert.ok(failTask(tmpDir, '99', 'err').error);
206
+ });
207
+ });
208
+
209
+ // --- completeTasks ---
210
+
211
+ describe('completeTasks', () => {
212
+ it('should batch-complete multiple tasks', () => {
213
+ seedTasks([
214
+ { id: 1, name: 'A', status: 'pending' },
215
+ { id: 2, name: 'B', status: 'pending' },
216
+ { id: 3, name: 'C', status: 'pending' },
217
+ ]);
218
+ const result = completeTasks(tmpDir, ['1', '3']);
219
+ assert.equal(result.ok, true);
220
+ assert.deepEqual(result.completed, [1, 3]);
221
+
222
+ assert.equal(getTask(tmpDir, '1').status, 'done');
223
+ assert.equal(getTask(tmpDir, '1').verifyPassed, true);
224
+ assert.ok(getTask(tmpDir, '1').completedAt);
225
+ assert.equal(getTask(tmpDir, '2').status, 'pending');
226
+ assert.equal(getTask(tmpDir, '3').status, 'done');
227
+ });
228
+
229
+ it('should skip non-existent ids', () => {
230
+ seedTasks([{ id: 1, name: 'A', status: 'pending' }]);
231
+ const result = completeTasks(tmpDir, ['1', '99']);
232
+ assert.deepEqual(result.completed, [1]);
233
+ });
234
+ });
235
+
236
+ // --- saveRetryState ---
237
+
238
+ describe('saveRetryState', () => {
239
+ it('should atomically update retry fields', () => {
240
+ const scores = { layout: 8, spacing: 7 };
241
+ seedTasks([{ id: 1, name: 'A', status: 'in_progress', retryCount: 0, bestScore: 0, bestScoresJSON: null }]);
242
+ const result = saveRetryState(tmpDir, '1', { retryCount: 2, bestScore: 75, bestScoresJSON: scores });
243
+ assert.equal(result.ok, true);
244
+
245
+ const task = getTask(tmpDir, '1');
246
+ assert.equal(task.retryCount, 2);
247
+ assert.equal(task.bestScore, 75);
248
+ assert.deepEqual(task.bestScoresJSON, scores);
249
+ });
250
+
251
+ it('should update only provided fields', () => {
252
+ seedTasks([{ id: 1, name: 'A', status: 'in_progress', retryCount: 1, bestScore: 50, bestScoresJSON: null }]);
253
+ saveRetryState(tmpDir, '1', { retryCount: 2 });
254
+ const task = getTask(tmpDir, '1');
255
+ assert.equal(task.retryCount, 2);
256
+ assert.equal(task.bestScore, 50);
257
+ assert.equal(task.bestScoresJSON, null);
258
+ });
259
+
260
+ it('should return error for missing task', () => {
261
+ seedTasks([]);
262
+ assert.ok(saveRetryState(tmpDir, '99', { retryCount: 1 }).error);
263
+ });
264
+ });
265
+
266
+ // --- resetTask ---
267
+
268
+ describe('resetTask', () => {
269
+ it('should reset task to pending', () => {
270
+ seedTasks([{ id: 1, name: 'A', status: 'failed', retryCount: 3, lastError: 'err', completedAt: '2024-01-01' }]);
271
+ const result = resetTask(tmpDir, '1');
272
+ assert.equal(result.ok, true);
273
+
274
+ const task = getTask(tmpDir, '1');
275
+ assert.equal(task.status, 'pending');
276
+ assert.equal(task.retryCount, 0);
277
+ assert.equal(task.lastError, '');
278
+ assert.equal(task.completedAt, '');
279
+ });
280
+
281
+ it('should return error for missing task', () => {
282
+ seedTasks([]);
283
+ assert.ok(resetTask(tmpDir, '99').error);
284
+ });
285
+ });
286
+
287
+ // --- resetAllFailed ---
288
+
289
+ describe('resetAllFailed', () => {
290
+ it('should reset all failed and skipped tasks', () => {
291
+ seedTasks([
292
+ { id: 1, name: 'A', status: 'failed', retryCount: 2, lastError: 'err' },
293
+ { id: 2, name: 'B', status: 'skipped', retryCount: 0, lastError: 'dep' },
294
+ { id: 3, name: 'C', status: 'done' },
295
+ ]);
296
+ const result = resetAllFailed(tmpDir);
297
+ assert.deepEqual(result.reset, [1, 2]);
298
+ assert.equal(getTask(tmpDir, '1').status, 'pending');
299
+ assert.equal(getTask(tmpDir, '2').status, 'pending');
300
+ assert.equal(getTask(tmpDir, '3').status, 'done');
301
+ });
302
+ });
303
+
304
+ // --- retryCount / bestScore persistence ---
305
+
306
+ describe('retryCount and bestScore persistence', () => {
307
+ it('should persist retryCount via updateTask', () => {
308
+ seedTasks([{ id: 1, name: 'A', status: 'in_progress', retryCount: 0 }]);
309
+ updateTask(tmpDir, '1', 'retryCount', '3');
310
+ const task = getTask(tmpDir, '1');
311
+ assert.equal(task.retryCount, 3);
312
+ });
313
+
314
+ it('should persist bestScore via updateTask', () => {
315
+ seedTasks([{ id: 1, name: 'A', status: 'in_progress', bestScore: 0 }]);
316
+ updateTask(tmpDir, '1', 'bestScore', '75');
317
+ const task = getTask(tmpDir, '1');
318
+ assert.equal(task.bestScore, 75);
319
+ });
320
+
321
+ it('should persist complex bestScoresJSON via saveTasks', () => {
322
+ const scores = { layout: 8, spacing: 7, colors: 9, typography: 6, borders: 5, shadows: 4, icons_images: 7, completeness: 8 };
323
+ seedTasks([{ id: 1, name: 'A', status: 'in_progress', bestScoresJSON: scores }]);
324
+ const task = getTask(tmpDir, '1');
325
+ assert.deepEqual(task.bestScoresJSON, scores);
326
+ });
327
+ });
328
+
329
+ // --- resetTask clears bestScore fields ---
330
+
331
+ describe('resetTask with bestScore fields', () => {
332
+ it('should reset bestScore and bestScoresJSON on reset', () => {
333
+ seedTasks([{
334
+ id: 1, name: 'A', status: 'failed',
335
+ retryCount: 3, bestScore: 65, bestScoresJSON: { layout: 8 },
336
+ lastError: 'err', completedAt: '2024-01-01',
337
+ }]);
338
+ resetTask(tmpDir, '1');
339
+ const task = getTask(tmpDir, '1');
340
+ assert.equal(task.retryCount, 0);
341
+ assert.equal(task.bestScore, 0);
342
+ assert.equal(task.bestScoresJSON, null);
343
+ });
344
+ });
345
+
346
+ // --- getStatus ---
347
+
348
+ describe('getStatus', () => {
349
+ it('should return correct status summary', () => {
350
+ seedTasks([
351
+ { id: 1, name: 'A', status: 'done' },
352
+ { id: 2, name: 'B', status: 'pending' },
353
+ { id: 3, name: 'C', status: 'in_progress', figmaUrl: 'https://figma.com/...' },
354
+ { id: 4, name: 'D', status: 'failed' },
355
+ ]);
356
+ const status = getStatus(tmpDir);
357
+ assert.equal(status.total, 4);
358
+ assert.equal(status.done, 1);
359
+ assert.equal(status.pending, 1);
360
+ assert.equal(status.in_progress, 1);
361
+ assert.equal(status.failed, 1);
362
+ assert.equal(status.tasks.length, 4);
363
+ assert.equal(status.tasks[2].type, 'design');
364
+ assert.equal(status.tasks[1].type, 'logic');
365
+ });
366
+
367
+ it('should return zeros when no tasks', () => {
368
+ seedTasks([]);
369
+ const status = getStatus(tmpDir);
370
+ assert.equal(status.total, 0);
371
+ });
372
+ });
373
+
374
+ // --- getWaves ---
375
+
376
+ describe('getWaves', () => {
377
+ it('should return empty waves when no tasks', () => {
378
+ seedTasks([]);
379
+ const result = getWaves(tmpDir);
380
+ assert.deepEqual(result.waves, {});
381
+ assert.deepEqual(result.waveOrder, []);
382
+ assert.equal(result.taskCount, 0);
383
+ });
384
+
385
+ it('should put all independent tasks in wave 1', () => {
386
+ seedTasks([
387
+ { id: 1, name: 'A', status: 'pending', dependsOn: [] },
388
+ { id: 2, name: 'B', status: 'pending', dependsOn: [] },
389
+ { id: 3, name: 'C', status: 'pending', dependsOn: [] },
390
+ ]);
391
+ const result = getWaves(tmpDir);
392
+ assert.deepEqual(result.waveOrder, [1]);
393
+ assert.equal(result.waves[1].total, 3);
394
+ });
395
+
396
+ it('should assign correct waves based on dependencies', () => {
397
+ seedTasks([
398
+ { id: 1, name: 'A', status: 'pending', dependsOn: [] },
399
+ { id: 2, name: 'B', status: 'pending', dependsOn: [] },
400
+ { id: 3, name: 'C', status: 'pending', dependsOn: [1] },
401
+ { id: 4, name: 'D', status: 'pending', dependsOn: [1, 2] },
402
+ { id: 5, name: 'E', status: 'pending', dependsOn: [3, 4] },
403
+ ]);
404
+ const result = getWaves(tmpDir);
405
+ assert.deepEqual(result.waveOrder, [1, 2, 3]);
406
+
407
+ // Wave 1: A, B (no deps)
408
+ assert.equal(result.waves[1].total, 2);
409
+ const w1ids = result.waves[1].tasks.map(t => t.id);
410
+ assert.ok(w1ids.includes(1));
411
+ assert.ok(w1ids.includes(2));
412
+
413
+ // Wave 2: C (depends on A), D (depends on A, B)
414
+ assert.equal(result.waves[2].total, 2);
415
+ const w2ids = result.waves[2].tasks.map(t => t.id);
416
+ assert.ok(w2ids.includes(3));
417
+ assert.ok(w2ids.includes(4));
418
+
419
+ // Wave 3: E (depends on C, D)
420
+ assert.equal(result.waves[3].total, 1);
421
+ assert.equal(result.waves[3].tasks[0].id, 5);
422
+ });
423
+
424
+ it('should handle tasks with no dependsOn field', () => {
425
+ seedTasks([
426
+ { id: 1, name: 'A', status: 'pending' },
427
+ { id: 2, name: 'B', status: 'pending' },
428
+ ]);
429
+ const result = getWaves(tmpDir);
430
+ assert.deepEqual(result.waveOrder, [1]);
431
+ assert.equal(result.waves[1].total, 2);
432
+ });
433
+
434
+ it('should include status summary per wave', () => {
435
+ seedTasks([
436
+ { id: 1, name: 'A', status: 'done', dependsOn: [] },
437
+ { id: 2, name: 'B', status: 'pending', dependsOn: [] },
438
+ { id: 3, name: 'C', status: 'failed', dependsOn: [1] },
439
+ ]);
440
+ const result = getWaves(tmpDir);
441
+ assert.equal(result.waves[1].done, 1);
442
+ assert.equal(result.waves[1].pending, 1);
443
+ assert.equal(result.waves[2].failed, 1);
444
+ });
445
+
446
+ it('should handle circular dependencies gracefully', () => {
447
+ seedTasks([
448
+ { id: 1, name: 'A', status: 'pending', dependsOn: [2] },
449
+ { id: 2, name: 'B', status: 'pending', dependsOn: [1] },
450
+ ]);
451
+ // Should not throw, should assign waves (breaking cycle)
452
+ const result = getWaves(tmpDir);
453
+ assert.ok(result.waveOrder.length > 0);
454
+ assert.equal(result.taskCount, 2);
455
+ // Cycle-broken node should not be overwritten to a higher wave
456
+ const wave1 = result.waves[result.waveOrder[0]];
457
+ assert.ok(wave1.tasks.length >= 1);
458
+ assert.ok(result.circularWarning);
459
+ });
460
+
461
+ it('should not overwrite cycle-detected wave assignment', () => {
462
+ // A→B→A: cycle detection sets A=wave1, B should be wave2
463
+ // Without the fix, A would be overwritten to wave3
464
+ seedTasks([
465
+ { id: 1, name: 'A', status: 'pending', dependsOn: [2] },
466
+ { id: 2, name: 'B', status: 'pending', dependsOn: [1] },
467
+ ]);
468
+ const result = getWaves(tmpDir);
469
+ // Both tasks should be within the first 2 waves (not wave 3+)
470
+ assert.ok(result.waveOrder.every(w => w <= 2));
471
+ });
472
+
473
+ it('should handle 3-node circular dependency', () => {
474
+ seedTasks([
475
+ { id: 1, name: 'A', status: 'pending', dependsOn: [3] },
476
+ { id: 2, name: 'B', status: 'pending', dependsOn: [1] },
477
+ { id: 3, name: 'C', status: 'pending', dependsOn: [2] },
478
+ ]);
479
+ const result = getWaves(tmpDir);
480
+ assert.equal(result.taskCount, 3);
481
+ assert.ok(result.circularWarning);
482
+ // No task should end up in an unreasonably high wave
483
+ assert.ok(result.waveOrder.every(w => w <= 3));
484
+ });
485
+
486
+ it('should include task type in wave tasks', () => {
487
+ seedTasks([
488
+ { id: 1, name: 'A', status: 'pending', dependsOn: [], figmaUrl: 'https://figma.com/...' },
489
+ { id: 2, name: 'B', status: 'pending', dependsOn: [] },
490
+ ]);
491
+ const result = getWaves(tmpDir);
492
+ const tasks = result.waves[1].tasks;
493
+ assert.equal(tasks.find(t => t.id === 1).type, 'design');
494
+ assert.equal(tasks.find(t => t.id === 2).type, 'logic');
495
+ });
496
+ });
497
+
498
+ // --- checkConflicts ---
499
+
500
+ describe('checkConflicts', () => {
501
+ it('should return no conflicts when no file overlap', () => {
502
+ seedTasks([
503
+ { id: 1, name: 'A', status: 'pending', dependsOn: [], filesModified: ['src/a.ts'] },
504
+ { id: 2, name: 'B', status: 'pending', dependsOn: [], filesModified: ['src/b.ts'] },
505
+ ]);
506
+ const result = checkConflicts(tmpDir);
507
+ assert.equal(result.hasConflicts, false);
508
+ assert.equal(result.conflicts.length, 0);
509
+ });
510
+
511
+ it('should detect conflicts in same wave', () => {
512
+ seedTasks([
513
+ { id: 1, name: 'A', status: 'pending', dependsOn: [], filesModified: ['src/shared.ts', 'src/a.ts'] },
514
+ { id: 2, name: 'B', status: 'pending', dependsOn: [], filesModified: ['src/shared.ts', 'src/b.ts'] },
515
+ ]);
516
+ const result = checkConflicts(tmpDir);
517
+ assert.equal(result.hasConflicts, true);
518
+ assert.equal(result.conflicts.length, 1);
519
+ assert.equal(result.conflicts[0].file, 'src/shared.ts');
520
+ assert.deepEqual(result.conflicts[0].tasks, [1, 2]);
521
+ });
522
+
523
+ it('should not flag conflicts across different waves', () => {
524
+ seedTasks([
525
+ { id: 1, name: 'A', status: 'pending', dependsOn: [], filesModified: ['src/shared.ts'] },
526
+ { id: 2, name: 'B', status: 'pending', dependsOn: [1], filesModified: ['src/shared.ts'] },
527
+ ]);
528
+ const result = checkConflicts(tmpDir);
529
+ assert.equal(result.hasConflicts, false);
530
+ });
531
+
532
+ it('should handle tasks without filesModified', () => {
533
+ seedTasks([
534
+ { id: 1, name: 'A', status: 'pending', dependsOn: [] },
535
+ { id: 2, name: 'B', status: 'pending', dependsOn: [] },
536
+ ]);
537
+ const result = checkConflicts(tmpDir);
538
+ assert.equal(result.hasConflicts, false);
539
+ });
540
+ });
541
+
542
+ // --- resolveConflicts ---
543
+
544
+ describe('resolveConflicts', () => {
545
+ it('should add dependency to resolve file conflict', () => {
546
+ seedTasks([
547
+ { id: 1, name: 'A', status: 'pending', dependsOn: [], filesModified: ['src/shared.ts'] },
548
+ { id: 2, name: 'B', status: 'pending', dependsOn: [], filesModified: ['src/shared.ts'] },
549
+ ]);
550
+ const result = resolveConflicts(tmpDir);
551
+ assert.equal(result.ok, true);
552
+ assert.equal(result.resolved, 1);
553
+
554
+ // Task 2 should now depend on task 1
555
+ const task2 = getTask(tmpDir, '2');
556
+ assert.ok(task2.dependsOn.includes(1));
557
+
558
+ // Waves should now be separated
559
+ const waves = getWaves(tmpDir);
560
+ assert.deepEqual(waves.waveOrder, [1, 2]);
561
+ });
562
+
563
+ it('should do nothing when no conflicts', () => {
564
+ seedTasks([
565
+ { id: 1, name: 'A', status: 'pending', dependsOn: [], filesModified: ['src/a.ts'] },
566
+ { id: 2, name: 'B', status: 'pending', dependsOn: [], filesModified: ['src/b.ts'] },
567
+ ]);
568
+ const result = resolveConflicts(tmpDir);
569
+ assert.equal(result.ok, true);
570
+ assert.equal(result.resolved, 0);
571
+ });
572
+
573
+ it('should build a complete chain for 3+ tasks conflicting on same file', () => {
574
+ seedTasks([
575
+ { id: 1, name: 'A', status: 'pending', dependsOn: [], filesModified: ['src/shared.ts'] },
576
+ { id: 2, name: 'B', status: 'pending', dependsOn: [], filesModified: ['src/shared.ts'] },
577
+ { id: 3, name: 'C', status: 'pending', dependsOn: [], filesModified: ['src/shared.ts'] },
578
+ ]);
579
+ const result = resolveConflicts(tmpDir);
580
+ assert.equal(result.ok, true);
581
+ assert.equal(result.resolved, 2); // 1→2 and 2→3
582
+
583
+ // Task 2 depends on 1, task 3 depends on 2
584
+ const task2 = getTask(tmpDir, '2');
585
+ const task3 = getTask(tmpDir, '3');
586
+ assert.ok(task2.dependsOn.includes(1));
587
+ assert.ok(task3.dependsOn.includes(2));
588
+
589
+ // All three should now be in separate waves
590
+ const waves = getWaves(tmpDir);
591
+ assert.deepEqual(waves.waveOrder, [1, 2, 3]);
592
+ });
593
+ });
594
+
595
+ // --- getCompletionSummary ---
596
+
597
+ describe('getCompletionSummary', () => {
598
+ it('should return error when no tasks', () => {
599
+ const result = getCompletionSummary(tmpDir);
600
+ assert.equal(result.error, 'No tasks found');
601
+ });
602
+
603
+ it('should report isAllFinished when all done/failed/skipped', () => {
604
+ seedTasks([
605
+ { id: 1, name: 'A', status: 'done', bestScore: 90, retryCount: 0, completedAt: '2026-01-01 12:00:00' },
606
+ { id: 2, name: 'B', status: 'failed', bestScore: 0, retryCount: 2, lastError: 'timeout' },
607
+ { id: 3, name: 'C', status: 'skipped', bestScore: 0, retryCount: 0, lastError: '依赖任务失败' },
608
+ ]);
609
+ const result = getCompletionSummary(tmpDir);
610
+ assert.equal(result.isAllFinished, true);
611
+ assert.equal(result.total, 3);
612
+ assert.equal(result.done, 1);
613
+ assert.equal(result.failed, 1);
614
+ assert.equal(result.skipped, 1);
615
+ assert.equal(result.totalRetries, 2);
616
+ assert.equal(result.hasWarnings, true);
617
+ assert.equal(result.warnings.length, 2);
618
+ });
619
+
620
+ it('should report not finished when pending tasks exist', () => {
621
+ seedTasks([
622
+ { id: 1, name: 'A', status: 'done', bestScore: 85 },
623
+ { id: 2, name: 'B', status: 'pending' },
624
+ ]);
625
+ const result = getCompletionSummary(tmpDir);
626
+ assert.equal(result.isAllFinished, false);
627
+ assert.equal(result.pending, 1);
628
+ });
629
+
630
+ it('should calculate score stats correctly', () => {
631
+ seedTasks([
632
+ { id: 1, name: 'A', status: 'done', bestScore: 80, figmaUrl: 'https://figma.com/1' },
633
+ { id: 2, name: 'B', status: 'done', bestScore: 90 },
634
+ { id: 3, name: 'C', status: 'done', bestScore: 100 },
635
+ ]);
636
+ const result = getCompletionSummary(tmpDir);
637
+ assert.equal(result.scores.avg, 90);
638
+ assert.equal(result.scores.min, 80);
639
+ assert.equal(result.scores.max, 100);
640
+ assert.equal(result.designTasks.total, 1);
641
+ assert.equal(result.designTasks.done, 1);
642
+ assert.equal(result.logicTasks.total, 2);
643
+ assert.equal(result.logicTasks.done, 2);
644
+ });
645
+ });
646
+
647
+ // --- archiveTasks ---
648
+
649
+ describe('archiveTasks', () => {
650
+ it('should return error when no tasks.json', () => {
651
+ const result = archiveTasks(tmpDir);
652
+ assert.equal(result.error, 'No tasks.json to archive');
653
+ });
654
+
655
+ it('should archive tasks.json and clean up', () => {
656
+ seedTasks([
657
+ { id: 1, name: 'A', status: 'done' },
658
+ ]);
659
+ // Create a context file
660
+ const contextDir = path.join(tmpDir, '.fe-runtime', 'context');
661
+ fs.mkdirSync(contextDir, { recursive: true });
662
+ fs.writeFileSync(path.join(contextDir, 'verify-result-1.json'), '{}');
663
+
664
+ const result = archiveTasks(tmpDir);
665
+ assert.equal(result.ok, true);
666
+ assert.ok(result.archiveDir.startsWith('.fe-runtime/history/'));
667
+
668
+ // tasks.json should be gone
669
+ assert.equal(fs.existsSync(tasksPath(tmpDir)), false);
670
+
671
+ // Context should be cleaned
672
+ assert.equal(fs.readdirSync(contextDir).length, 0);
673
+
674
+ // Archive should contain files
675
+ const archivePath = path.join(tmpDir, result.archiveDir);
676
+ assert.ok(fs.existsSync(path.join(archivePath, 'tasks.json')));
677
+ assert.ok(fs.existsSync(path.join(archivePath, 'context', 'verify-result-1.json')));
678
+ });
679
+
680
+ it('should archive and clean progress.md', () => {
681
+ seedTasks([
682
+ { id: 1, name: 'A', status: 'done' },
683
+ ]);
684
+ // Create progress.md
685
+ const progressPath = path.join(tmpDir, '.fe-runtime', 'progress.md');
686
+ fs.writeFileSync(progressPath, '# Progress\n- Task 1: done');
687
+
688
+ const result = archiveTasks(tmpDir);
689
+ assert.equal(result.ok, true);
690
+
691
+ // progress.md should be gone from runtime
692
+ assert.equal(fs.existsSync(progressPath), false);
693
+
694
+ // progress.md should be in archive
695
+ const archivePath = path.join(tmpDir, result.archiveDir);
696
+ assert.ok(fs.existsSync(path.join(archivePath, 'progress.md')));
697
+ });
698
+ });