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.
- package/bin/loopwork +0 -0
- package/package.json +48 -4
- package/src/backends/github.ts +6 -3
- package/src/backends/json.ts +28 -10
- package/src/commands/run.ts +2 -2
- package/src/contracts/config.ts +3 -75
- package/src/contracts/index.ts +0 -6
- package/src/core/cli.ts +25 -16
- package/src/core/state.ts +10 -4
- package/src/core/utils.ts +10 -4
- package/src/monitor/index.ts +56 -34
- package/src/plugins/index.ts +9 -131
- package/examples/README.md +0 -70
- package/examples/basic-json-backend/.specs/tasks/TASK-001.md +0 -22
- package/examples/basic-json-backend/.specs/tasks/TASK-002.md +0 -23
- package/examples/basic-json-backend/.specs/tasks/TASK-003.md +0 -37
- package/examples/basic-json-backend/.specs/tasks/tasks.json +0 -19
- package/examples/basic-json-backend/README.md +0 -32
- package/examples/basic-json-backend/TESTING.md +0 -184
- package/examples/basic-json-backend/hello.test.ts +0 -9
- package/examples/basic-json-backend/hello.ts +0 -3
- package/examples/basic-json-backend/loopwork.config.js +0 -35
- package/examples/basic-json-backend/math.test.ts +0 -29
- package/examples/basic-json-backend/math.ts +0 -3
- package/examples/basic-json-backend/package.json +0 -15
- package/examples/basic-json-backend/quick-start.sh +0 -80
- package/loopwork.config.ts +0 -164
- package/src/plugins/asana.ts +0 -192
- package/src/plugins/cost-tracking.ts +0 -402
- package/src/plugins/discord.ts +0 -269
- package/src/plugins/everhour.ts +0 -335
- package/src/plugins/telegram/bot.ts +0 -517
- package/src/plugins/telegram/index.ts +0 -6
- package/src/plugins/telegram/notifications.ts +0 -198
- package/src/plugins/todoist.ts +0 -261
- package/test/backends.test.ts +0 -929
- package/test/cli.test.ts +0 -145
- package/test/config.test.ts +0 -90
- package/test/e2e.test.ts +0 -458
- package/test/github-tasks.test.ts +0 -191
- package/test/loopwork-config-types.test.ts +0 -288
- package/test/monitor.test.ts +0 -123
- package/test/plugins.test.ts +0 -1175
- package/test/state.test.ts +0 -295
- package/test/utils.test.ts +0 -60
- package/tsconfig.json +0 -20
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, mock, beforeEach } from 'bun:test'
|
|
2
|
-
import { GitHubTaskAdapter } from '../src/backends/github'
|
|
3
|
-
|
|
4
|
-
// Note: GitHubIssue is internal to the adapter, so we define it here for testing
|
|
5
|
-
interface GitHubIssue {
|
|
6
|
-
number: number
|
|
7
|
-
title: string
|
|
8
|
-
body: string
|
|
9
|
-
state: 'open' | 'closed'
|
|
10
|
-
labels: { name: string }[]
|
|
11
|
-
url: string
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
describe('GitHubTaskAdapter', () => {
|
|
15
|
-
let manager: GitHubTaskAdapter
|
|
16
|
-
|
|
17
|
-
beforeEach(() => {
|
|
18
|
-
manager = new GitHubTaskAdapter({ type: 'github' })
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
describe('adaptIssue conversion', () => {
|
|
22
|
-
test('extracts task ID from title', () => {
|
|
23
|
-
const issue: GitHubIssue = {
|
|
24
|
-
number: 123,
|
|
25
|
-
title: 'TASK-025-01: Add health score calculation',
|
|
26
|
-
body: '## Goal\nCalculate health score',
|
|
27
|
-
state: 'open',
|
|
28
|
-
labels: [{ name: 'loopwork-task' }, { name: 'loopwork:pending' }],
|
|
29
|
-
url: 'https://github.com/owner/repo/issues/123',
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const task = (manager as any).adaptIssue(issue)
|
|
33
|
-
|
|
34
|
-
expect(task.id).toBe('TASK-025-01')
|
|
35
|
-
expect(task.metadata.issueNumber).toBe(123)
|
|
36
|
-
expect(task.status).toBe('pending')
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
test('generates GH-{number} ID when no task ID in title', () => {
|
|
40
|
-
const issue: GitHubIssue = {
|
|
41
|
-
number: 456,
|
|
42
|
-
title: 'Fix the login bug',
|
|
43
|
-
body: 'Fix it',
|
|
44
|
-
state: 'open',
|
|
45
|
-
labels: [{ name: 'loopwork-task' }],
|
|
46
|
-
url: 'https://github.com/owner/repo/issues/456',
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const task = (manager as any).adaptIssue(issue)
|
|
50
|
-
|
|
51
|
-
expect(task.id).toBe('GH-456')
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
test('detects in-progress status from labels', () => {
|
|
55
|
-
const issue: GitHubIssue = {
|
|
56
|
-
number: 789,
|
|
57
|
-
title: 'TASK-001-01: Test',
|
|
58
|
-
body: 'Test',
|
|
59
|
-
state: 'open',
|
|
60
|
-
labels: [{ name: 'loopwork-task' }, { name: 'loopwork:in-progress' }],
|
|
61
|
-
url: 'https://github.com/owner/repo/issues/789',
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const task = (manager as any).adaptIssue(issue)
|
|
65
|
-
|
|
66
|
-
expect(task.status).toBe('in-progress')
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
test('detects failed status from labels', () => {
|
|
70
|
-
const issue: GitHubIssue = {
|
|
71
|
-
number: 101,
|
|
72
|
-
title: 'TASK-002-01: Another test',
|
|
73
|
-
body: 'Body',
|
|
74
|
-
state: 'open',
|
|
75
|
-
labels: [{ name: 'loopwork-task' }, { name: 'loopwork:failed' }],
|
|
76
|
-
url: 'https://github.com/owner/repo/issues/101',
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const task = (manager as any).adaptIssue(issue)
|
|
80
|
-
|
|
81
|
-
expect(task.status).toBe('failed')
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
test('detects completed status from closed state', () => {
|
|
85
|
-
const issue: GitHubIssue = {
|
|
86
|
-
number: 102,
|
|
87
|
-
title: 'TASK-003-01: Closed task',
|
|
88
|
-
body: 'Done',
|
|
89
|
-
state: 'closed',
|
|
90
|
-
labels: [{ name: 'loopwork-task' }],
|
|
91
|
-
url: 'https://github.com/owner/repo/issues/102',
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const task = (manager as any).adaptIssue(issue)
|
|
95
|
-
|
|
96
|
-
expect(task.status).toBe('completed')
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
test('extracts priority from labels', () => {
|
|
100
|
-
const highPriority: GitHubIssue = {
|
|
101
|
-
number: 1,
|
|
102
|
-
title: 'TASK-001-01: High',
|
|
103
|
-
body: '',
|
|
104
|
-
state: 'open',
|
|
105
|
-
labels: [{ name: 'loopwork-task' }, { name: 'priority:high' }],
|
|
106
|
-
url: 'https://github.com/owner/repo/issues/1',
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const lowPriority: GitHubIssue = {
|
|
110
|
-
number: 2,
|
|
111
|
-
title: 'TASK-001-02: Low',
|
|
112
|
-
body: '',
|
|
113
|
-
state: 'open',
|
|
114
|
-
labels: [{ name: 'loopwork-task' }, { name: 'priority:low' }],
|
|
115
|
-
url: 'https://github.com/owner/repo/issues/2',
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
expect((manager as any).adaptIssue(highPriority).priority).toBe('high')
|
|
119
|
-
expect((manager as any).adaptIssue(lowPriority).priority).toBe('low')
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
test('extracts feature from labels', () => {
|
|
123
|
-
const issue: GitHubIssue = {
|
|
124
|
-
number: 3,
|
|
125
|
-
title: 'TASK-025-01: Feature task',
|
|
126
|
-
body: '',
|
|
127
|
-
state: 'open',
|
|
128
|
-
labels: [
|
|
129
|
-
{ name: 'loopwork-task' },
|
|
130
|
-
{ name: 'feat:profile-health' },
|
|
131
|
-
],
|
|
132
|
-
url: 'https://github.com/owner/repo/issues/3',
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const task = (manager as any).adaptIssue(issue)
|
|
136
|
-
|
|
137
|
-
expect(task.feature).toBe('profile-health')
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
test('defaults to medium priority when not specified', () => {
|
|
141
|
-
const issue: GitHubIssue = {
|
|
142
|
-
number: 4,
|
|
143
|
-
title: 'TASK-001-01: No priority',
|
|
144
|
-
body: '',
|
|
145
|
-
state: 'open',
|
|
146
|
-
labels: [{ name: 'loopwork-task' }],
|
|
147
|
-
url: 'https://github.com/owner/repo/issues/4',
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const task = (manager as any).adaptIssue(issue)
|
|
151
|
-
|
|
152
|
-
expect(task.priority).toBe('medium')
|
|
153
|
-
})
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
describe('repoFlag', () => {
|
|
157
|
-
test('returns empty string when no repo specified', () => {
|
|
158
|
-
const mgr = new GitHubTaskAdapter({ type: 'github' })
|
|
159
|
-
expect((mgr as any).repoFlag()).toBe('')
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
test('returns --repo flag when repo specified', () => {
|
|
163
|
-
const mgr = new GitHubTaskAdapter({ type: 'github', repo: 'owner/repo' })
|
|
164
|
-
expect((mgr as any).repoFlag()).toBe('--repo owner/repo')
|
|
165
|
-
})
|
|
166
|
-
})
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
describe('Types', () => {
|
|
170
|
-
test('LABELS constants are correct', async () => {
|
|
171
|
-
const { LABELS } = await import('../src/contracts')
|
|
172
|
-
|
|
173
|
-
expect(LABELS.LOOPWORK_TASK).toBe('loopwork-task')
|
|
174
|
-
expect(LABELS.STATUS_PENDING).toBe('loopwork:pending')
|
|
175
|
-
expect(LABELS.STATUS_IN_PROGRESS).toBe('loopwork:in-progress')
|
|
176
|
-
expect(LABELS.STATUS_FAILED).toBe('loopwork:failed')
|
|
177
|
-
expect(LABELS.PRIORITY_HIGH).toBe('priority:high')
|
|
178
|
-
expect(LABELS.PRIORITY_MEDIUM).toBe('priority:medium')
|
|
179
|
-
expect(LABELS.PRIORITY_LOW).toBe('priority:low')
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
test('DEFAULT_CONFIG has expected values', async () => {
|
|
183
|
-
const { DEFAULT_CONFIG } = await import('../src/contracts')
|
|
184
|
-
|
|
185
|
-
expect(DEFAULT_CONFIG.maxIterations).toBe(50)
|
|
186
|
-
expect(DEFAULT_CONFIG.timeout).toBe(600)
|
|
187
|
-
expect(DEFAULT_CONFIG.cli).toBe('opencode')
|
|
188
|
-
expect(DEFAULT_CONFIG.autoConfirm).toBe(false)
|
|
189
|
-
expect(DEFAULT_CONFIG.dryRun).toBe(false)
|
|
190
|
-
})
|
|
191
|
-
})
|
|
@@ -1,288 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
3
|
-
defineConfig,
|
|
4
|
-
withTelegram,
|
|
5
|
-
withCostTracking,
|
|
6
|
-
withJSON,
|
|
7
|
-
withGitHub,
|
|
8
|
-
withPlugin,
|
|
9
|
-
withAsana,
|
|
10
|
-
withEverhour,
|
|
11
|
-
withTodoist,
|
|
12
|
-
withDiscord,
|
|
13
|
-
compose,
|
|
14
|
-
defaults,
|
|
15
|
-
} from '../src/plugins'
|
|
16
|
-
|
|
17
|
-
describe('loopwork-config-types', () => {
|
|
18
|
-
describe('defineConfig', () => {
|
|
19
|
-
test('returns config with defaults', () => {
|
|
20
|
-
const config = defineConfig({
|
|
21
|
-
backend: { type: 'json', tasksFile: 'tasks.json' },
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
expect(config.backend).toEqual({ type: 'json', tasksFile: 'tasks.json' })
|
|
25
|
-
expect(config.cli).toBe('opencode')
|
|
26
|
-
expect(config.maxIterations).toBe(50)
|
|
27
|
-
expect(config.timeout).toBe(600)
|
|
28
|
-
expect(config.namespace).toBe('default')
|
|
29
|
-
expect(config.plugins).toEqual([])
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
test('overrides defaults with provided values', () => {
|
|
33
|
-
const config = defineConfig({
|
|
34
|
-
backend: { type: 'github', repo: 'owner/repo' },
|
|
35
|
-
cli: 'claude',
|
|
36
|
-
maxIterations: 100,
|
|
37
|
-
timeout: 300,
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
expect(config.cli).toBe('claude')
|
|
41
|
-
expect(config.maxIterations).toBe(100)
|
|
42
|
-
expect(config.timeout).toBe(300)
|
|
43
|
-
})
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
describe('withTelegram', () => {
|
|
47
|
-
test('adds telegram config with defaults', () => {
|
|
48
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
49
|
-
const config = withTelegram()(base)
|
|
50
|
-
|
|
51
|
-
expect(config.telegram).toBeDefined()
|
|
52
|
-
expect(config.telegram?.notifications).toBe(true)
|
|
53
|
-
expect(config.telegram?.silent).toBe(false)
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
test('uses provided options', () => {
|
|
57
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
58
|
-
const config = withTelegram({
|
|
59
|
-
botToken: 'test-token',
|
|
60
|
-
chatId: 'test-chat',
|
|
61
|
-
silent: true,
|
|
62
|
-
})(base)
|
|
63
|
-
|
|
64
|
-
expect(config.telegram?.botToken).toBe('test-token')
|
|
65
|
-
expect(config.telegram?.chatId).toBe('test-chat')
|
|
66
|
-
expect(config.telegram?.silent).toBe(true)
|
|
67
|
-
})
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
describe('withCostTracking', () => {
|
|
71
|
-
test('adds cost tracking config with defaults', () => {
|
|
72
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
73
|
-
const config = withCostTracking()(base)
|
|
74
|
-
|
|
75
|
-
expect(config.costTracking).toBeDefined()
|
|
76
|
-
expect(config.costTracking?.enabled).toBe(true)
|
|
77
|
-
expect(config.costTracking?.defaultModel).toBe('claude-3.5-sonnet')
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
test('uses provided options', () => {
|
|
81
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
82
|
-
const config = withCostTracking({
|
|
83
|
-
enabled: false,
|
|
84
|
-
defaultModel: 'gpt-4',
|
|
85
|
-
})(base)
|
|
86
|
-
|
|
87
|
-
expect(config.costTracking?.enabled).toBe(false)
|
|
88
|
-
expect(config.costTracking?.defaultModel).toBe('gpt-4')
|
|
89
|
-
})
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
describe('withJSON', () => {
|
|
93
|
-
test('sets json backend with defaults', () => {
|
|
94
|
-
const base = defineConfig({ backend: { type: 'github' } })
|
|
95
|
-
const config = withJSON()(base)
|
|
96
|
-
|
|
97
|
-
expect(config.backend.type).toBe('json')
|
|
98
|
-
expect(config.backend.tasksFile).toBe('.specs/tasks/tasks.json')
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
test('uses provided options', () => {
|
|
102
|
-
const base = defineConfig({ backend: { type: 'github' } })
|
|
103
|
-
const config = withJSON({ tasksFile: 'custom/tasks.json', tasksDir: 'custom' })(base)
|
|
104
|
-
|
|
105
|
-
expect(config.backend.tasksFile).toBe('custom/tasks.json')
|
|
106
|
-
expect(config.backend.tasksDir).toBe('custom')
|
|
107
|
-
})
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
describe('withGitHub', () => {
|
|
111
|
-
test('sets github backend', () => {
|
|
112
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
113
|
-
const config = withGitHub({ repo: 'owner/repo' })(base)
|
|
114
|
-
|
|
115
|
-
expect(config.backend.type).toBe('github')
|
|
116
|
-
expect(config.backend.repo).toBe('owner/repo')
|
|
117
|
-
})
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
describe('withPlugin', () => {
|
|
121
|
-
test('adds plugin to plugins array', () => {
|
|
122
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
123
|
-
const plugin = { name: 'test-plugin' }
|
|
124
|
-
const config = withPlugin(plugin)(base)
|
|
125
|
-
|
|
126
|
-
expect(config.plugins).toHaveLength(1)
|
|
127
|
-
expect(config.plugins?.[0].name).toBe('test-plugin')
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
test('appends to existing plugins', () => {
|
|
131
|
-
const base = defineConfig({
|
|
132
|
-
backend: { type: 'json' },
|
|
133
|
-
plugins: [{ name: 'existing' }],
|
|
134
|
-
})
|
|
135
|
-
const config = withPlugin({ name: 'new-plugin' })(base)
|
|
136
|
-
|
|
137
|
-
expect(config.plugins).toHaveLength(2)
|
|
138
|
-
expect(config.plugins?.[0].name).toBe('existing')
|
|
139
|
-
expect(config.plugins?.[1].name).toBe('new-plugin')
|
|
140
|
-
})
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
describe('withAsana', () => {
|
|
144
|
-
test('adds asana config with defaults', () => {
|
|
145
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
146
|
-
const config = withAsana()(base)
|
|
147
|
-
|
|
148
|
-
expect(config.asana).toBeDefined()
|
|
149
|
-
expect(config.asana?.autoCreate).toBe(false)
|
|
150
|
-
expect(config.asana?.syncStatus).toBe(true)
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
test('uses provided options', () => {
|
|
154
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
155
|
-
const config = withAsana({
|
|
156
|
-
accessToken: 'test-token',
|
|
157
|
-
projectId: 'project-123',
|
|
158
|
-
autoCreate: true,
|
|
159
|
-
})(base)
|
|
160
|
-
|
|
161
|
-
expect(config.asana?.accessToken).toBe('test-token')
|
|
162
|
-
expect(config.asana?.projectId).toBe('project-123')
|
|
163
|
-
expect(config.asana?.autoCreate).toBe(true)
|
|
164
|
-
})
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
describe('withEverhour', () => {
|
|
168
|
-
test('adds everhour config with defaults', () => {
|
|
169
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
170
|
-
const config = withEverhour()(base)
|
|
171
|
-
|
|
172
|
-
expect(config.everhour).toBeDefined()
|
|
173
|
-
expect(config.everhour?.autoStartTimer).toBe(true)
|
|
174
|
-
expect(config.everhour?.autoStopTimer).toBe(true)
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
test('uses provided options', () => {
|
|
178
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
179
|
-
const config = withEverhour({
|
|
180
|
-
apiKey: 'test-key',
|
|
181
|
-
autoStartTimer: false,
|
|
182
|
-
})(base)
|
|
183
|
-
|
|
184
|
-
expect(config.everhour?.apiKey).toBe('test-key')
|
|
185
|
-
expect(config.everhour?.autoStartTimer).toBe(false)
|
|
186
|
-
})
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
describe('withTodoist', () => {
|
|
190
|
-
test('adds todoist config with defaults', () => {
|
|
191
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
192
|
-
const config = withTodoist()(base)
|
|
193
|
-
|
|
194
|
-
expect(config.todoist).toBeDefined()
|
|
195
|
-
expect(config.todoist?.syncStatus).toBe(true)
|
|
196
|
-
expect(config.todoist?.addComments).toBe(true)
|
|
197
|
-
})
|
|
198
|
-
|
|
199
|
-
test('uses provided options', () => {
|
|
200
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
201
|
-
const config = withTodoist({
|
|
202
|
-
apiToken: 'test-token',
|
|
203
|
-
projectId: 'project-456',
|
|
204
|
-
addComments: false,
|
|
205
|
-
})(base)
|
|
206
|
-
|
|
207
|
-
expect(config.todoist?.apiToken).toBe('test-token')
|
|
208
|
-
expect(config.todoist?.projectId).toBe('project-456')
|
|
209
|
-
expect(config.todoist?.addComments).toBe(false)
|
|
210
|
-
})
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
describe('withDiscord', () => {
|
|
214
|
-
test('adds discord config with defaults', () => {
|
|
215
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
216
|
-
const config = withDiscord()(base)
|
|
217
|
-
|
|
218
|
-
expect(config.discord).toBeDefined()
|
|
219
|
-
expect(config.discord?.username).toBe('Loopwork')
|
|
220
|
-
expect(config.discord?.notifyOnStart).toBe(false)
|
|
221
|
-
expect(config.discord?.notifyOnComplete).toBe(true)
|
|
222
|
-
expect(config.discord?.notifyOnFail).toBe(true)
|
|
223
|
-
expect(config.discord?.notifyOnLoopEnd).toBe(true)
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
test('uses provided options', () => {
|
|
227
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
228
|
-
const config = withDiscord({
|
|
229
|
-
webhookUrl: 'https://discord.com/webhook',
|
|
230
|
-
username: 'Custom Bot',
|
|
231
|
-
mentionOnFail: '<@123>',
|
|
232
|
-
})(base)
|
|
233
|
-
|
|
234
|
-
expect(config.discord?.webhookUrl).toBe('https://discord.com/webhook')
|
|
235
|
-
expect(config.discord?.username).toBe('Custom Bot')
|
|
236
|
-
expect(config.discord?.mentionOnFail).toBe('<@123>')
|
|
237
|
-
})
|
|
238
|
-
})
|
|
239
|
-
|
|
240
|
-
describe('compose', () => {
|
|
241
|
-
test('composes multiple wrappers', () => {
|
|
242
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
243
|
-
const config = compose(
|
|
244
|
-
withTelegram({ botToken: 'tg-token' }),
|
|
245
|
-
withCostTracking({ enabled: true }),
|
|
246
|
-
withDiscord({ webhookUrl: 'discord-url' })
|
|
247
|
-
)(base)
|
|
248
|
-
|
|
249
|
-
expect(config.telegram?.botToken).toBe('tg-token')
|
|
250
|
-
expect(config.costTracking?.enabled).toBe(true)
|
|
251
|
-
expect(config.discord?.webhookUrl).toBe('discord-url')
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
test('applies wrappers in order', () => {
|
|
255
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
256
|
-
const config = compose(
|
|
257
|
-
withJSON({ tasksFile: 'first.json' }),
|
|
258
|
-
withJSON({ tasksFile: 'second.json' })
|
|
259
|
-
)(base)
|
|
260
|
-
|
|
261
|
-
// Last wrapper wins
|
|
262
|
-
expect(config.backend.tasksFile).toBe('second.json')
|
|
263
|
-
})
|
|
264
|
-
|
|
265
|
-
test('works with empty wrappers', () => {
|
|
266
|
-
const base = defineConfig({ backend: { type: 'json' } })
|
|
267
|
-
const config = compose()(base)
|
|
268
|
-
|
|
269
|
-
expect(config).toEqual(base)
|
|
270
|
-
})
|
|
271
|
-
})
|
|
272
|
-
|
|
273
|
-
describe('defaults', () => {
|
|
274
|
-
test('has expected default values', () => {
|
|
275
|
-
expect(defaults.cli).toBe('opencode')
|
|
276
|
-
expect(defaults.maxIterations).toBe(50)
|
|
277
|
-
expect(defaults.timeout).toBe(600)
|
|
278
|
-
expect(defaults.namespace).toBe('default')
|
|
279
|
-
expect(defaults.autoConfirm).toBe(false)
|
|
280
|
-
expect(defaults.dryRun).toBe(false)
|
|
281
|
-
expect(defaults.debug).toBe(false)
|
|
282
|
-
expect(defaults.maxRetries).toBe(3)
|
|
283
|
-
expect(defaults.circuitBreakerThreshold).toBe(5)
|
|
284
|
-
expect(defaults.taskDelay).toBe(2000)
|
|
285
|
-
expect(defaults.retryDelay).toBe(3000)
|
|
286
|
-
})
|
|
287
|
-
})
|
|
288
|
-
})
|
package/test/monitor.test.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
|
|
2
|
-
import fs from 'fs'
|
|
3
|
-
import path from 'path'
|
|
4
|
-
import os from 'os'
|
|
5
|
-
import { LoopworkMonitor } from '../src/monitor'
|
|
6
|
-
|
|
7
|
-
describe('LoopworkMonitor', () => {
|
|
8
|
-
let tempDir: string
|
|
9
|
-
let monitor: LoopworkMonitor
|
|
10
|
-
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ralph-monitor-test-'))
|
|
13
|
-
monitor = new LoopworkMonitor(tempDir)
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
afterEach(() => {
|
|
17
|
-
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
test('getRunningProcesses returns empty array when no state', () => {
|
|
21
|
-
const result = monitor.getRunningProcesses()
|
|
22
|
-
expect(result).toEqual([])
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
test('getRunningProcesses cleans up dead processes', () => {
|
|
26
|
-
const stateFile = path.join(tempDir, '.loopwork-monitor-state.json')
|
|
27
|
-
fs.writeFileSync(stateFile, JSON.stringify({
|
|
28
|
-
processes: [{
|
|
29
|
-
namespace: 'dead-ns',
|
|
30
|
-
pid: 999999999,
|
|
31
|
-
startedAt: new Date().toISOString(),
|
|
32
|
-
logFile: '/tmp/test.log',
|
|
33
|
-
args: [],
|
|
34
|
-
}]
|
|
35
|
-
}))
|
|
36
|
-
|
|
37
|
-
const result = monitor.getRunningProcesses()
|
|
38
|
-
expect(result).toEqual([])
|
|
39
|
-
|
|
40
|
-
const newState = JSON.parse(fs.readFileSync(stateFile, 'utf-8'))
|
|
41
|
-
expect(newState.processes).toEqual([])
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
test('getRunningProcesses returns alive processes', () => {
|
|
45
|
-
const stateFile = path.join(tempDir, '.loopwork-monitor-state.json')
|
|
46
|
-
fs.writeFileSync(stateFile, JSON.stringify({
|
|
47
|
-
processes: [{
|
|
48
|
-
namespace: 'alive-ns',
|
|
49
|
-
pid: process.pid,
|
|
50
|
-
startedAt: new Date().toISOString(),
|
|
51
|
-
logFile: '/tmp/test.log',
|
|
52
|
-
args: [],
|
|
53
|
-
}]
|
|
54
|
-
}))
|
|
55
|
-
|
|
56
|
-
const running = monitor.getRunningProcesses()
|
|
57
|
-
expect(running.length).toBe(1)
|
|
58
|
-
expect(running[0].namespace).toBe('alive-ns')
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
test('getStatus returns empty when no runs', () => {
|
|
62
|
-
const result = monitor.getStatus()
|
|
63
|
-
expect(result.running).toEqual([])
|
|
64
|
-
expect(result.namespaces).toEqual([])
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
test('getStatus discovers namespaces from directories', () => {
|
|
68
|
-
const ns1Dir = path.join(tempDir, 'loopwork-runs', 'feature-a', '2024-01-01T00-00-00')
|
|
69
|
-
const ns2Dir = path.join(tempDir, 'loopwork-runs', 'feature-b', '2024-01-02T00-00-00')
|
|
70
|
-
fs.mkdirSync(ns1Dir, { recursive: true })
|
|
71
|
-
fs.mkdirSync(ns2Dir, { recursive: true })
|
|
72
|
-
|
|
73
|
-
const result = monitor.getStatus()
|
|
74
|
-
expect(result.namespaces.length).toBe(2)
|
|
75
|
-
expect(result.namespaces.map(n => n.name).sort()).toEqual(['feature-a', 'feature-b'])
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
test('getLogs returns message when no logs', () => {
|
|
79
|
-
const result = monitor.getLogs('nonexistent')
|
|
80
|
-
expect(result[0]).toContain('No logs found')
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
test('getLogs reads from monitor-logs directory', () => {
|
|
84
|
-
const logsDir = path.join(tempDir, 'loopwork-runs', 'test-ns', 'monitor-logs')
|
|
85
|
-
fs.mkdirSync(logsDir, { recursive: true })
|
|
86
|
-
fs.writeFileSync(path.join(logsDir, '2024-01-01.log'), 'Line 1\nLine 2\nLine 3')
|
|
87
|
-
|
|
88
|
-
const result = monitor.getLogs('test-ns', 10)
|
|
89
|
-
const joined = result.join('\n')
|
|
90
|
-
expect(joined).toContain('Line 1')
|
|
91
|
-
expect(joined).toContain('Line 2')
|
|
92
|
-
expect(joined).toContain('Line 3')
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
test('stop returns error when namespace not found', () => {
|
|
96
|
-
const result = monitor.stop('nonexistent')
|
|
97
|
-
expect(result.success).toBe(false)
|
|
98
|
-
expect(result.error).toContain('No running loop found')
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
test('stopAll returns empty when nothing running', () => {
|
|
102
|
-
const result = monitor.stopAll()
|
|
103
|
-
expect(result.stopped).toEqual([])
|
|
104
|
-
expect(result.errors).toEqual([])
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
test('start fails for duplicate namespace', async () => {
|
|
108
|
-
const stateFile = path.join(tempDir, '.loopwork-monitor-state.json')
|
|
109
|
-
fs.writeFileSync(stateFile, JSON.stringify({
|
|
110
|
-
processes: [{
|
|
111
|
-
namespace: 'test-ns',
|
|
112
|
-
pid: process.pid,
|
|
113
|
-
startedAt: new Date().toISOString(),
|
|
114
|
-
logFile: '/tmp/test.log',
|
|
115
|
-
args: [],
|
|
116
|
-
}]
|
|
117
|
-
}))
|
|
118
|
-
|
|
119
|
-
const result = await monitor.start('test-ns')
|
|
120
|
-
expect(result.success).toBe(false)
|
|
121
|
-
expect(result.error).toContain('already running')
|
|
122
|
-
})
|
|
123
|
-
})
|