loopwork 0.3.0 → 0.3.1

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 (46) hide show
  1. package/bin/loopwork +0 -0
  2. package/package.json +48 -4
  3. package/src/backends/github.ts +6 -3
  4. package/src/backends/json.ts +28 -10
  5. package/src/commands/run.ts +2 -2
  6. package/src/contracts/config.ts +3 -75
  7. package/src/contracts/index.ts +0 -6
  8. package/src/core/cli.ts +25 -16
  9. package/src/core/state.ts +10 -4
  10. package/src/core/utils.ts +10 -4
  11. package/src/monitor/index.ts +56 -34
  12. package/src/plugins/index.ts +9 -131
  13. package/examples/README.md +0 -70
  14. package/examples/basic-json-backend/.specs/tasks/TASK-001.md +0 -22
  15. package/examples/basic-json-backend/.specs/tasks/TASK-002.md +0 -23
  16. package/examples/basic-json-backend/.specs/tasks/TASK-003.md +0 -37
  17. package/examples/basic-json-backend/.specs/tasks/tasks.json +0 -19
  18. package/examples/basic-json-backend/README.md +0 -32
  19. package/examples/basic-json-backend/TESTING.md +0 -184
  20. package/examples/basic-json-backend/hello.test.ts +0 -9
  21. package/examples/basic-json-backend/hello.ts +0 -3
  22. package/examples/basic-json-backend/loopwork.config.js +0 -35
  23. package/examples/basic-json-backend/math.test.ts +0 -29
  24. package/examples/basic-json-backend/math.ts +0 -3
  25. package/examples/basic-json-backend/package.json +0 -15
  26. package/examples/basic-json-backend/quick-start.sh +0 -80
  27. package/loopwork.config.ts +0 -164
  28. package/src/plugins/asana.ts +0 -192
  29. package/src/plugins/cost-tracking.ts +0 -402
  30. package/src/plugins/discord.ts +0 -269
  31. package/src/plugins/everhour.ts +0 -335
  32. package/src/plugins/telegram/bot.ts +0 -517
  33. package/src/plugins/telegram/index.ts +0 -6
  34. package/src/plugins/telegram/notifications.ts +0 -198
  35. package/src/plugins/todoist.ts +0 -261
  36. package/test/backends.test.ts +0 -929
  37. package/test/cli.test.ts +0 -145
  38. package/test/config.test.ts +0 -90
  39. package/test/e2e.test.ts +0 -458
  40. package/test/github-tasks.test.ts +0 -191
  41. package/test/loopwork-config-types.test.ts +0 -288
  42. package/test/monitor.test.ts +0 -123
  43. package/test/plugins.test.ts +0 -1175
  44. package/test/state.test.ts +0 -295
  45. package/test/utils.test.ts +0 -60
  46. package/tsconfig.json +0 -20
package/test/cli.test.ts DELETED
@@ -1,145 +0,0 @@
1
- import { describe, test, expect } from 'bun:test'
2
- import { EXEC_MODELS, FALLBACK_MODELS, CliConfig } from '../src/core/cli'
3
-
4
- describe('CLI Model Pools', () => {
5
- describe('EXEC_MODELS', () => {
6
- test('has at least one model', () => {
7
- expect(EXEC_MODELS.length).toBeGreaterThan(0)
8
- })
9
-
10
- test('all models have required fields', () => {
11
- for (const model of EXEC_MODELS) {
12
- expect(model.name).toBeDefined()
13
- expect(model.cli).toBeDefined()
14
- expect(model.model).toBeDefined()
15
- expect(['opencode', 'claude']).toContain(model.cli)
16
- }
17
- })
18
-
19
- test('includes claude sonnet', () => {
20
- const claudeSonnet = EXEC_MODELS.find(m => m.cli === 'claude' && m.model === 'sonnet')
21
- expect(claudeSonnet).toBeDefined()
22
- })
23
-
24
- test('includes opencode models', () => {
25
- const opencodeModels = EXEC_MODELS.filter(m => m.cli === 'opencode')
26
- expect(opencodeModels.length).toBeGreaterThan(0)
27
- })
28
-
29
- test('model names are unique', () => {
30
- const names = EXEC_MODELS.map(m => m.name)
31
- const uniqueNames = new Set(names)
32
- expect(uniqueNames.size).toBe(names.length)
33
- })
34
- })
35
-
36
- describe('FALLBACK_MODELS', () => {
37
- test('has at least one model', () => {
38
- expect(FALLBACK_MODELS.length).toBeGreaterThan(0)
39
- })
40
-
41
- test('all models have required fields', () => {
42
- for (const model of FALLBACK_MODELS) {
43
- expect(model.name).toBeDefined()
44
- expect(model.cli).toBeDefined()
45
- expect(model.model).toBeDefined()
46
- expect(['opencode', 'claude']).toContain(model.cli)
47
- }
48
- })
49
-
50
- test('includes opus as fallback', () => {
51
- const opus = FALLBACK_MODELS.find(m => m.model === 'opus')
52
- expect(opus).toBeDefined()
53
- })
54
-
55
- test('model names are unique', () => {
56
- const names = FALLBACK_MODELS.map(m => m.name)
57
- const uniqueNames = new Set(names)
58
- expect(uniqueNames.size).toBe(names.length)
59
- })
60
-
61
- test('fallback models are different from exec models', () => {
62
- const execNames = new Set(EXEC_MODELS.map(m => m.name))
63
- for (const fallback of FALLBACK_MODELS) {
64
- expect(execNames.has(fallback.name)).toBe(false)
65
- }
66
- })
67
- })
68
-
69
- describe('CliConfig interface', () => {
70
- test('can create valid CliConfig', () => {
71
- const config: CliConfig = {
72
- name: 'test-model',
73
- cli: 'claude',
74
- model: 'sonnet',
75
- }
76
-
77
- expect(config.name).toBe('test-model')
78
- expect(config.cli).toBe('claude')
79
- expect(config.model).toBe('sonnet')
80
- })
81
-
82
- test('cli must be opencode or claude', () => {
83
- const validConfigs: CliConfig[] = [
84
- { name: 'a', cli: 'opencode', model: 'x' },
85
- { name: 'b', cli: 'claude', model: 'y' },
86
- ]
87
-
88
- for (const config of validConfigs) {
89
- expect(['opencode', 'claude']).toContain(config.cli)
90
- }
91
- })
92
- })
93
-
94
- describe('Model rotation logic', () => {
95
- test('EXEC_MODELS rotation cycles through all models', () => {
96
- const visited = new Set<string>()
97
- for (let i = 0; i < EXEC_MODELS.length; i++) {
98
- const model = EXEC_MODELS[i % EXEC_MODELS.length]
99
- visited.add(model.name)
100
- }
101
- expect(visited.size).toBe(EXEC_MODELS.length)
102
- })
103
-
104
- test('FALLBACK_MODELS rotation cycles through all models', () => {
105
- const visited = new Set<string>()
106
- for (let i = 0; i < FALLBACK_MODELS.length; i++) {
107
- const model = FALLBACK_MODELS[i % FALLBACK_MODELS.length]
108
- visited.add(model.name)
109
- }
110
- expect(visited.size).toBe(FALLBACK_MODELS.length)
111
- })
112
-
113
- test('total attempts cover all models', () => {
114
- const maxAttempts = EXEC_MODELS.length + FALLBACK_MODELS.length
115
- expect(maxAttempts).toBe(EXEC_MODELS.length + FALLBACK_MODELS.length)
116
- })
117
- })
118
- })
119
-
120
- describe('Rate limit detection patterns', () => {
121
- const rateLimitPatterns = /rate.*limit|too.*many.*request|429|RESOURCE_EXHAUSTED/i
122
- const quotaPatterns = /quota.*exceed|billing.*limit/i
123
-
124
- test('detects rate limit errors', () => {
125
- expect(rateLimitPatterns.test('Error: rate limit exceeded')).toBe(true)
126
- expect(rateLimitPatterns.test('Too many requests')).toBe(true)
127
- expect(rateLimitPatterns.test('HTTP 429')).toBe(true)
128
- expect(rateLimitPatterns.test('RESOURCE_EXHAUSTED')).toBe(true)
129
- })
130
-
131
- test('does not false positive on normal output', () => {
132
- expect(rateLimitPatterns.test('Task completed successfully')).toBe(false)
133
- expect(rateLimitPatterns.test('Writing to file')).toBe(false)
134
- })
135
-
136
- test('detects quota errors', () => {
137
- expect(quotaPatterns.test('quota exceeded')).toBe(true)
138
- expect(quotaPatterns.test('billing limit reached')).toBe(true)
139
- })
140
-
141
- test('does not false positive quota on normal output', () => {
142
- expect(quotaPatterns.test('Task completed successfully')).toBe(false)
143
- expect(quotaPatterns.test('Checking bill status')).toBe(false)
144
- })
145
- })
@@ -1,90 +0,0 @@
1
- import { describe, test, expect } from 'bun:test'
2
- import type { Config } from '../src/core/config'
3
- import { DEFAULT_CONFIG } from '../src/contracts'
4
-
5
- describe('Config', () => {
6
- describe('DEFAULT_CONFIG', () => {
7
- test('has expected default values', () => {
8
- expect(DEFAULT_CONFIG.maxIterations).toBe(50)
9
- expect(DEFAULT_CONFIG.timeout).toBe(600)
10
- expect(DEFAULT_CONFIG.cli).toBe('opencode')
11
- expect(DEFAULT_CONFIG.autoConfirm).toBe(false)
12
- expect(DEFAULT_CONFIG.dryRun).toBe(false)
13
- })
14
-
15
- test('cli default is a valid option', () => {
16
- const validClis = ['opencode', 'claude', 'gemini']
17
- expect(validClis).toContain(DEFAULT_CONFIG.cli)
18
- })
19
- })
20
-
21
- describe('Config interface', () => {
22
- test('can create a valid config object', () => {
23
- const config: Config = {
24
- ...DEFAULT_CONFIG,
25
- projectRoot: '/test/project',
26
- outputDir: '/test/project/loopwork-runs/2026-01-17',
27
- sessionId: 'loopwork-2026-01-17-123',
28
- debug: false,
29
- resume: false,
30
- }
31
-
32
- expect(config.projectRoot).toBe('/test/project')
33
- expect(config.sessionId).toMatch(/^loopwork-/)
34
- })
35
-
36
- test('supports optional fields', () => {
37
- const config: Config = {
38
- ...DEFAULT_CONFIG,
39
- projectRoot: '/test',
40
- outputDir: '/test/output',
41
- sessionId: 'test',
42
- debug: false,
43
- resume: false,
44
- repo: 'owner/repo',
45
- feature: 'profile-health',
46
- startTask: 123,
47
- model: 'claude-sonnet',
48
- }
49
-
50
- expect(config.repo).toBe('owner/repo')
51
- expect(config.feature).toBe('profile-health')
52
- expect(config.startTask).toBe(123)
53
- expect(config.model).toBe('claude-sonnet')
54
- })
55
-
56
- test('config without optional fields is valid', () => {
57
- const config: Config = {
58
- ...DEFAULT_CONFIG,
59
- projectRoot: '/test',
60
- outputDir: '/test/output',
61
- sessionId: 'test',
62
- debug: false,
63
- resume: false,
64
- }
65
-
66
- expect(config.repo).toBeUndefined()
67
- expect(config.feature).toBeUndefined()
68
- expect(config.startTask).toBeUndefined()
69
- })
70
- })
71
-
72
- describe('sessionId format', () => {
73
- test('sessionId follows expected pattern', () => {
74
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
75
- const sessionId = `loopwork-${timestamp}-${process.pid}`
76
-
77
- expect(sessionId).toMatch(/^loopwork-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d+$/)
78
- })
79
- })
80
-
81
- describe('outputDir format', () => {
82
- test('outputDir includes timestamp', () => {
83
- const timestamp = '2026-01-17T10-30-00'
84
- const projectRoot = '/home/user/project'
85
- const outputDir = `${projectRoot}/loopwork-runs/${timestamp}`
86
-
87
- expect(outputDir).toBe('/home/user/project/loopwork-runs/2026-01-17T10-30-00')
88
- })
89
- })
90
- })
package/test/e2e.test.ts DELETED
@@ -1,458 +0,0 @@
1
- import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
2
- import fs from 'fs'
3
- import path from 'path'
4
- import os from 'os'
5
- import { JsonTaskAdapter } from '../src/backends/json'
6
- import { StateManager } from '../src/core/state'
7
- import { CliExecutor } from '../src/core/cli'
8
- import type { Config } from '../src/core/config'
9
- import type { Task } from '../src/backends/types'
10
-
11
- /**
12
- * End-to-End Test for Loopwork with JSON Backend
13
- *
14
- * This test simulates the complete loopwork workflow:
15
- * 1. Setting up tasks in JSON format
16
- * 2. Running the task loop
17
- * 3. Executing tasks (mocked CLI)
18
- * 4. Verifying task status changes
19
- */
20
-
21
- describe('Loopwork E2E with JSON Backend', () => {
22
- let tempDir: string
23
- let tasksFile: string
24
- let config: Config
25
- let backend: JsonTaskAdapter
26
- let stateManager: StateManager
27
-
28
- beforeEach(() => {
29
- // Create temp directory for test
30
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loopwork-e2e-'))
31
- tasksFile = path.join(tempDir, 'tasks.json')
32
-
33
- // Create config
34
- config = {
35
- projectRoot: tempDir,
36
- backend: {
37
- type: 'json',
38
- tasksFile,
39
- tasksDir: tempDir,
40
- },
41
- cli: 'claude',
42
- maxIterations: 10,
43
- timeout: 30,
44
- namespace: 'test',
45
- sessionId: 'test-session-123',
46
- outputDir: path.join(tempDir, 'output'),
47
- dryRun: false,
48
- debug: false,
49
- autoConfirm: true,
50
- maxRetries: 2,
51
- circuitBreakerThreshold: 3,
52
- taskDelay: 0,
53
- retryDelay: 0,
54
- }
55
-
56
- // Initialize backend and state manager
57
- backend = new JsonTaskAdapter(config.backend)
58
- stateManager = new StateManager(config)
59
-
60
- // Create output directory
61
- fs.mkdirSync(config.outputDir, { recursive: true })
62
- fs.mkdirSync(path.join(config.outputDir, 'logs'), { recursive: true })
63
- })
64
-
65
- afterEach(() => {
66
- // Cleanup
67
- stateManager.releaseLock()
68
- fs.rmSync(tempDir, { recursive: true, force: true })
69
- })
70
-
71
- test('completes simple task workflow', async () => {
72
- // Setup: Create tasks
73
- const tasksData = {
74
- tasks: [
75
- { id: 'TASK-001-01', status: 'pending' as const, priority: 'high' as const },
76
- { id: 'TASK-001-02', status: 'pending' as const, priority: 'medium' as const },
77
- ],
78
- }
79
- fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
80
-
81
- // Create PRD files
82
- fs.writeFileSync(
83
- path.join(tempDir, 'TASK-001-01.md'),
84
- '# TASK-001-01: Implement login\n\n## Goal\nAdd login functionality with username and password'
85
- )
86
- fs.writeFileSync(
87
- path.join(tempDir, 'TASK-001-02.md'),
88
- '# TASK-001-02: Add logout button\n\n## Goal\nAdd a logout button to the navbar'
89
- )
90
-
91
- // Verify initial state
92
- const initialPending = await backend.countPending()
93
- expect(initialPending).toBe(2)
94
-
95
- // Execute first task
96
- const task1 = await backend.findNextTask()
97
- expect(task1).not.toBeNull()
98
- expect(task1!.id).toBe('TASK-001-01') // High priority first
99
-
100
- await backend.markInProgress(task1!.id)
101
- let loadedTask = await backend.getTask(task1!.id)
102
- expect(loadedTask!.status).toBe('in-progress')
103
-
104
- // Complete first task
105
- await backend.markCompleted(task1!.id)
106
- loadedTask = await backend.getTask(task1!.id)
107
- expect(loadedTask!.status).toBe('completed')
108
-
109
- // Verify only one pending remains
110
- const midPending = await backend.countPending()
111
- expect(midPending).toBe(1)
112
-
113
- // Execute second task
114
- const task2 = await backend.findNextTask()
115
- expect(task2).not.toBeNull()
116
- expect(task2!.id).toBe('TASK-001-02')
117
-
118
- await backend.markInProgress(task2!.id)
119
- await backend.markCompleted(task2!.id)
120
-
121
- // Verify all tasks completed
122
- const finalPending = await backend.countPending()
123
- expect(finalPending).toBe(0)
124
- })
125
-
126
- test('handles task failure and retry', async () => {
127
- // Setup: Create task
128
- const tasksData = {
129
- tasks: [{ id: 'TASK-002-01', status: 'pending' as const, priority: 'high' as const }],
130
- }
131
- fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
132
- fs.writeFileSync(
133
- path.join(tempDir, 'TASK-002-01.md'),
134
- '# TASK-002-01: Complex task\n\n## Goal\nImplement complex feature'
135
- )
136
-
137
- const task = await backend.findNextTask()
138
- expect(task).not.toBeNull()
139
-
140
- // Mark as in progress
141
- await backend.markInProgress(task!.id)
142
-
143
- // Simulate failure
144
- await backend.markFailed(task!.id, 'Test error: Something went wrong')
145
- let loadedTask = await backend.getTask(task!.id)
146
- expect(loadedTask!.status).toBe('failed')
147
-
148
- // Check error log
149
- const logFile = path.join(tempDir, `${task!.id}.log`)
150
- expect(fs.existsSync(logFile)).toBe(true)
151
- const logContent = fs.readFileSync(logFile, 'utf-8')
152
- expect(logContent).toContain('Something went wrong')
153
-
154
- // Reset to pending for retry
155
- await backend.resetToPending(task!.id)
156
- loadedTask = await backend.getTask(task!.id)
157
- expect(loadedTask!.status).toBe('pending')
158
-
159
- // Second attempt succeeds
160
- await backend.markInProgress(task!.id)
161
- await backend.markCompleted(task!.id)
162
- loadedTask = await backend.getTask(task!.id)
163
- expect(loadedTask!.status).toBe('completed')
164
- })
165
-
166
- test('respects task priority ordering', async () => {
167
- // Setup: Create tasks with different priorities
168
- const tasksData = {
169
- tasks: [
170
- { id: 'TASK-003-01', status: 'pending' as const, priority: 'low' as const },
171
- { id: 'TASK-003-02', status: 'pending' as const, priority: 'high' as const },
172
- { id: 'TASK-003-03', status: 'pending' as const, priority: 'medium' as const },
173
- ],
174
- }
175
- fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
176
-
177
- // Create PRD files
178
- for (const task of tasksData.tasks) {
179
- fs.writeFileSync(path.join(tempDir, `${task.id}.md`), `# ${task.id}\n\nTask content`)
180
- }
181
-
182
- // Get tasks in priority order
183
- const tasks = await backend.listPendingTasks()
184
- expect(tasks.length).toBe(3)
185
- expect(tasks[0].id).toBe('TASK-003-02') // High priority first
186
- expect(tasks[1].id).toBe('TASK-003-03') // Medium priority second
187
- expect(tasks[2].id).toBe('TASK-003-01') // Low priority last
188
-
189
- // Find next task should return highest priority
190
- const nextTask = await backend.findNextTask()
191
- expect(nextTask!.id).toBe('TASK-003-02')
192
- })
193
-
194
- test('handles task dependencies', async () => {
195
- // Setup: Create tasks with dependencies
196
- const tasksData = {
197
- tasks: [
198
- { id: 'TASK-004-01', status: 'pending' as const, priority: 'high' as const },
199
- {
200
- id: 'TASK-004-02',
201
- status: 'pending' as const,
202
- priority: 'high' as const,
203
- dependsOn: ['TASK-004-01'],
204
- },
205
- ],
206
- }
207
- fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
208
-
209
- // Create PRD files
210
- for (const task of tasksData.tasks) {
211
- fs.writeFileSync(path.join(tempDir, `${task.id}.md`), `# ${task.id}\n\nTask content`)
212
- }
213
-
214
- // Task 2 should be blocked initially
215
- const pendingTasks = await backend.listPendingTasks()
216
- expect(pendingTasks.length).toBe(1)
217
- expect(pendingTasks[0].id).toBe('TASK-004-01') // Only unblocked task
218
-
219
- // Check dependencies
220
- const depsMet = await backend.areDependenciesMet('TASK-004-02')
221
- expect(depsMet).toBe(false)
222
-
223
- // Complete Task 1
224
- await backend.markInProgress('TASK-004-01')
225
- await backend.markCompleted('TASK-004-01')
226
-
227
- // Task 2 should now be unblocked
228
- const newPendingTasks = await backend.listPendingTasks()
229
- expect(newPendingTasks.length).toBe(1)
230
- expect(newPendingTasks[0].id).toBe('TASK-004-02')
231
-
232
- const depsMetNow = await backend.areDependenciesMet('TASK-004-02')
233
- expect(depsMetNow).toBe(true)
234
- })
235
-
236
- test('creates and manages sub-tasks', async () => {
237
- // Setup: Create parent task
238
- const tasksData = {
239
- tasks: [{ id: 'TASK-005-01', status: 'pending' as const, priority: 'high' as const }],
240
- }
241
- fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
242
- fs.writeFileSync(
243
- path.join(tempDir, 'TASK-005-01.md'),
244
- '# TASK-005-01: Parent task\n\nParent task content'
245
- )
246
-
247
- // Create sub-task
248
- const subtask = await backend.createSubTask('TASK-005-01', {
249
- title: 'TASK-005-01a: Sub-task 1',
250
- description: 'First sub-task',
251
- priority: 'medium',
252
- })
253
-
254
- expect(subtask.id).toBe('TASK-005-01a')
255
- expect(subtask.parentId).toBe('TASK-005-01')
256
- expect(subtask.status).toBe('pending')
257
-
258
- // Verify PRD file was created
259
- const prdPath = path.join(tempDir, 'TASK-005-01a.md')
260
- expect(fs.existsSync(prdPath)).toBe(true)
261
-
262
- // Get sub-tasks
263
- const subtasks = await backend.getSubTasks('TASK-005-01')
264
- expect(subtasks.length).toBe(1)
265
- expect(subtasks[0].id).toBe('TASK-005-01a')
266
-
267
- // List pending can filter by parent
268
- const pendingSubtasks = await backend.listPendingTasks({ parentId: 'TASK-005-01' })
269
- expect(pendingSubtasks.length).toBe(1)
270
- expect(pendingSubtasks[0].id).toBe('TASK-005-01a')
271
- })
272
-
273
- test('filters tasks by feature', async () => {
274
- // Setup: Create tasks with features
275
- const tasksData = {
276
- tasks: [
277
- { id: 'TASK-006-01', status: 'pending' as const, priority: 'high' as const, feature: 'auth' },
278
- { id: 'TASK-006-02', status: 'pending' as const, priority: 'high' as const, feature: 'billing' },
279
- { id: 'TASK-006-03', status: 'pending' as const, priority: 'high' as const, feature: 'auth' },
280
- ],
281
- features: {
282
- auth: { name: 'Authentication' },
283
- billing: { name: 'Billing System' },
284
- },
285
- }
286
- fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
287
-
288
- // Create PRD files
289
- for (const task of tasksData.tasks) {
290
- fs.writeFileSync(path.join(tempDir, `${task.id}.md`), `# ${task.id}\n\nTask content`)
291
- }
292
-
293
- // Filter by auth feature
294
- const authTasks = await backend.listPendingTasks({ feature: 'auth' })
295
- expect(authTasks.length).toBe(2)
296
- expect(authTasks.every(t => t.feature === 'auth')).toBe(true)
297
-
298
- // Filter by billing feature
299
- const billingTasks = await backend.listPendingTasks({ feature: 'billing' })
300
- expect(billingTasks.length).toBe(1)
301
- expect(billingTasks[0].feature).toBe('billing')
302
-
303
- // Count by feature
304
- const authCount = await backend.countPending({ feature: 'auth' })
305
- expect(authCount).toBe(2)
306
- })
307
-
308
- test('state manager handles locking', async () => {
309
- // Acquire lock
310
- const acquired = stateManager.acquireLock()
311
- expect(acquired).toBe(true)
312
-
313
- // Lock file should exist
314
- const lockFile = stateManager.getLockFile()
315
- expect(fs.existsSync(lockFile)).toBe(true)
316
-
317
- // Second acquire should fail
318
- const stateManager2 = new StateManager(config)
319
- const acquired2 = stateManager2.acquireLock()
320
- expect(acquired2).toBe(false)
321
-
322
- // Release lock
323
- stateManager.releaseLock()
324
- expect(fs.existsSync(lockFile)).toBe(false)
325
-
326
- // Now second manager can acquire
327
- const acquired3 = stateManager2.acquireLock()
328
- expect(acquired3).toBe(true)
329
- stateManager2.releaseLock()
330
- })
331
-
332
- test('state manager saves and loads state', async () => {
333
- // Save state
334
- stateManager.saveState(42, 5)
335
-
336
- // State file should exist
337
- const stateFile = stateManager.getStateFile()
338
- expect(fs.existsSync(stateFile)).toBe(true)
339
-
340
- // Load state
341
- const loadedState = stateManager.loadState()
342
- expect(loadedState).not.toBeNull()
343
- expect(loadedState!.lastIssue).toBe(42)
344
- expect(loadedState!.lastIteration).toBe(5)
345
-
346
- // Clear state
347
- stateManager.clearState()
348
- expect(fs.existsSync(stateFile)).toBe(false)
349
- })
350
-
351
- test('adds comments to task log', async () => {
352
- // Setup: Create task
353
- const tasksData = {
354
- tasks: [{ id: 'TASK-007-01', status: 'pending' as const, priority: 'high' as const }],
355
- }
356
- fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
357
- fs.writeFileSync(path.join(tempDir, 'TASK-007-01.md'), '# TASK-007-01\n\nTask content')
358
-
359
- // Add comments
360
- await backend.addComment('TASK-007-01', 'Starting implementation')
361
- await backend.addComment('TASK-007-01', 'Tests passing')
362
-
363
- // Check log file
364
- const logFile = path.join(tempDir, 'TASK-007-01.log')
365
- expect(fs.existsSync(logFile)).toBe(true)
366
- const logContent = fs.readFileSync(logFile, 'utf-8')
367
- expect(logContent).toContain('Starting implementation')
368
- expect(logContent).toContain('Tests passing')
369
- })
370
-
371
- test('backend ping checks health', async () => {
372
- // With valid tasks file
373
- const tasksData = { tasks: [] }
374
- fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
375
-
376
- const result = await backend.ping()
377
- expect(result.ok).toBe(true)
378
- expect(result.latencyMs).toBeGreaterThanOrEqual(0)
379
-
380
- // With missing file
381
- fs.unlinkSync(tasksFile)
382
- const result2 = await backend.ping()
383
- expect(result2.ok).toBe(false)
384
- expect(result2.error).toContain('not found')
385
- })
386
-
387
- test('complete workflow simulation', async () => {
388
- // Setup: Create realistic task set
389
- const tasksData = {
390
- tasks: [
391
- { id: 'TASK-100-01', status: 'pending' as const, priority: 'high' as const, feature: 'auth' },
392
- { id: 'TASK-100-02', status: 'pending' as const, priority: 'medium' as const, feature: 'auth' },
393
- { id: 'TASK-100-03', status: 'pending' as const, priority: 'low' as const },
394
- ],
395
- features: {
396
- auth: { name: 'Authentication System' },
397
- },
398
- }
399
- fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
400
-
401
- // Create PRD files
402
- const prds = [
403
- { id: 'TASK-100-01', title: 'Implement login API', goal: 'Create login endpoint' },
404
- { id: 'TASK-100-02', title: 'Add JWT tokens', goal: 'Implement JWT authentication' },
405
- { id: 'TASK-100-03', title: 'Update README', goal: 'Document the changes' },
406
- ]
407
-
408
- for (const prd of prds) {
409
- fs.writeFileSync(
410
- path.join(tempDir, `${prd.id}.md`),
411
- `# ${prd.id}: ${prd.title}\n\n## Goal\n${prd.goal}\n\n## Requirements\n- Requirement 1\n- Requirement 2`
412
- )
413
- }
414
-
415
- // Simulate workflow loop
416
- let iteration = 0
417
- const maxIterations = 10
418
- let tasksCompleted = 0
419
-
420
- while (iteration < maxIterations) {
421
- iteration++
422
-
423
- // Find next task
424
- const task = await backend.findNextTask()
425
- if (!task) break
426
-
427
- // Mark in progress
428
- await backend.markInProgress(task.id)
429
-
430
- // Simulate execution (would normally call CLI)
431
- await backend.addComment(task.id, `Iteration ${iteration}: Executing task`)
432
-
433
- // Simulate success
434
- await backend.markCompleted(task.id, `Completed in iteration ${iteration}`)
435
- tasksCompleted++
436
-
437
- // Small delay to simulate work
438
- await new Promise(r => setTimeout(r, 10))
439
- }
440
-
441
- // Verify all tasks completed
442
- expect(tasksCompleted).toBe(3)
443
- const finalPending = await backend.countPending()
444
- expect(finalPending).toBe(0)
445
-
446
- // Verify all tasks are marked completed
447
- for (const task of tasksData.tasks) {
448
- const loadedTask = await backend.getTask(task.id)
449
- expect(loadedTask!.status).toBe('completed')
450
- }
451
-
452
- // Verify log files exist
453
- for (const task of tasksData.tasks) {
454
- const logFile = path.join(tempDir, `${task.id}.log`)
455
- expect(fs.existsSync(logFile)).toBe(true)
456
- }
457
- })
458
- })