bingocode 1.0.28 → 1.0.30
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/adapters/common/__tests__/chat-queue.test.ts +61 -0
- package/adapters/common/__tests__/format.test.ts +148 -0
- package/adapters/common/__tests__/http-client.test.ts +105 -0
- package/adapters/common/__tests__/message-buffer.test.ts +84 -0
- package/adapters/common/__tests__/message-dedup.test.ts +57 -0
- package/adapters/common/__tests__/session-store.test.ts +62 -0
- package/adapters/common/__tests__/ws-bridge.test.ts +177 -0
- package/adapters/common/attachment/__tests__/attachment-limits.test.ts +52 -0
- package/adapters/common/attachment/__tests__/attachment-store.test.ts +108 -0
- package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +115 -0
- package/adapters/feishu/__tests__/card-errors.test.ts +194 -0
- package/adapters/feishu/__tests__/cardkit.test.ts +295 -0
- package/adapters/feishu/__tests__/extract-payload.test.ts +77 -0
- package/adapters/feishu/__tests__/feishu.test.ts +907 -0
- package/adapters/feishu/__tests__/flush-controller.test.ts +290 -0
- package/adapters/feishu/__tests__/markdown-style.test.ts +353 -0
- package/adapters/feishu/__tests__/media.test.ts +120 -0
- package/adapters/feishu/__tests__/streaming-card.test.ts +914 -0
- package/adapters/telegram/__tests__/media.test.ts +86 -0
- package/adapters/telegram/__tests__/telegram.test.ts +115 -0
- package/adapters/tsconfig.json +18 -0
- package/bunfig.toml +1 -0
- package/package.json +1 -1
- package/preload.ts +30 -0
- package/scripts/count-app-loc.ts +256 -0
- package/scripts/release.ts +130 -0
- package/src/server/__tests__/conversation-service.test.ts +173 -0
- package/src/server/__tests__/conversations.test.ts +458 -0
- package/src/server/__tests__/cron-scheduler.test.ts +575 -0
- package/src/server/__tests__/e2e/business-flow.test.ts +841 -0
- package/src/server/__tests__/e2e/full-flow.test.ts +357 -0
- package/src/server/__tests__/fixtures/mock-sdk-cli.ts +123 -0
- package/src/server/__tests__/haha-oauth-api.test.ts +146 -0
- package/src/server/__tests__/haha-oauth-service.test.ts +185 -0
- package/src/server/__tests__/providers-real.test.ts +244 -0
- package/src/server/__tests__/providers.test.ts +579 -0
- package/src/server/__tests__/proxy-streaming.test.ts +317 -0
- package/src/server/__tests__/proxy-transform.test.ts +469 -0
- package/src/server/__tests__/real-llm-test.ts +526 -0
- package/src/server/__tests__/scheduled-tasks.test.ts +371 -0
- package/src/server/__tests__/sessions.test.ts +786 -0
- package/src/server/__tests__/settings.test.ts +376 -0
- package/src/server/__tests__/skills.test.ts +125 -0
- package/src/server/__tests__/tasks.test.ts +171 -0
- package/src/server/__tests__/team-watcher.test.ts +400 -0
- package/src/server/__tests__/teams.test.ts +627 -0
- package/src/server/middleware/cors.test.ts +27 -0
- package/src/utils/__tests__/cronFrequency.test.ts +153 -0
- package/src/utils/__tests__/cronTasks.test.ts +204 -0
- package/src/utils/computerUse/permissions.test.ts +44 -0
- package/stubs/ant-claude-for-chrome-mcp.ts +24 -0
- package/stubs/color-diff-napi.ts +45 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for CronScheduler — cron matching, task execution, log storage, and API endpoints
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'
|
|
6
|
+
import * as fs from 'fs/promises'
|
|
7
|
+
import * as path from 'path'
|
|
8
|
+
import * as os from 'os'
|
|
9
|
+
import {
|
|
10
|
+
cronMatches,
|
|
11
|
+
fieldMatches,
|
|
12
|
+
CronScheduler,
|
|
13
|
+
type TaskRun,
|
|
14
|
+
} from '../services/cronScheduler.js'
|
|
15
|
+
import { CronService, type CronTask } from '../services/cronService.js'
|
|
16
|
+
|
|
17
|
+
// ─── Test helpers ───────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
let tmpDir: string
|
|
20
|
+
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
21
|
+
|
|
22
|
+
async function createTmpDir(): Promise<string> {
|
|
23
|
+
const dir = path.join(
|
|
24
|
+
os.tmpdir(),
|
|
25
|
+
`claude-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
26
|
+
)
|
|
27
|
+
await fs.mkdir(dir, { recursive: true })
|
|
28
|
+
return dir
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function cleanupTmpDir(dir: string): Promise<void> {
|
|
32
|
+
try {
|
|
33
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
34
|
+
} catch {
|
|
35
|
+
// ignore
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── fieldMatches tests ────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
describe('fieldMatches', () => {
|
|
42
|
+
it('should match wildcard', () => {
|
|
43
|
+
expect(fieldMatches('*', 0)).toBe(true)
|
|
44
|
+
expect(fieldMatches('*', 59)).toBe(true)
|
|
45
|
+
expect(fieldMatches('*', 23)).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('should match exact number', () => {
|
|
49
|
+
expect(fieldMatches('5', 5)).toBe(true)
|
|
50
|
+
expect(fieldMatches('5', 6)).toBe(false)
|
|
51
|
+
expect(fieldMatches('0', 0)).toBe(true)
|
|
52
|
+
expect(fieldMatches('30', 30)).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should match comma-separated list', () => {
|
|
56
|
+
expect(fieldMatches('1,3,5', 1)).toBe(true)
|
|
57
|
+
expect(fieldMatches('1,3,5', 3)).toBe(true)
|
|
58
|
+
expect(fieldMatches('1,3,5', 5)).toBe(true)
|
|
59
|
+
expect(fieldMatches('1,3,5', 2)).toBe(false)
|
|
60
|
+
expect(fieldMatches('1,3,5', 4)).toBe(false)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should match range', () => {
|
|
64
|
+
expect(fieldMatches('1-5', 1)).toBe(true)
|
|
65
|
+
expect(fieldMatches('1-5', 3)).toBe(true)
|
|
66
|
+
expect(fieldMatches('1-5', 5)).toBe(true)
|
|
67
|
+
expect(fieldMatches('1-5', 0)).toBe(false)
|
|
68
|
+
expect(fieldMatches('1-5', 6)).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should match step from wildcard', () => {
|
|
72
|
+
expect(fieldMatches('*/2', 0)).toBe(true)
|
|
73
|
+
expect(fieldMatches('*/2', 2)).toBe(true)
|
|
74
|
+
expect(fieldMatches('*/2', 4)).toBe(true)
|
|
75
|
+
expect(fieldMatches('*/2', 1)).toBe(false)
|
|
76
|
+
expect(fieldMatches('*/2', 3)).toBe(false)
|
|
77
|
+
expect(fieldMatches('*/15', 0)).toBe(true)
|
|
78
|
+
expect(fieldMatches('*/15', 15)).toBe(true)
|
|
79
|
+
expect(fieldMatches('*/15', 30)).toBe(true)
|
|
80
|
+
expect(fieldMatches('*/15', 7)).toBe(false)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('should match step within range', () => {
|
|
84
|
+
expect(fieldMatches('1-10/3', 1)).toBe(true)
|
|
85
|
+
expect(fieldMatches('1-10/3', 4)).toBe(true)
|
|
86
|
+
expect(fieldMatches('1-10/3', 7)).toBe(true)
|
|
87
|
+
expect(fieldMatches('1-10/3', 10)).toBe(true)
|
|
88
|
+
expect(fieldMatches('1-10/3', 2)).toBe(false)
|
|
89
|
+
expect(fieldMatches('1-10/3', 11)).toBe(false)
|
|
90
|
+
expect(fieldMatches('1-10/3', 0)).toBe(false)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('should handle combined comma and range', () => {
|
|
94
|
+
expect(fieldMatches('1-3,7,10-12', 2)).toBe(true)
|
|
95
|
+
expect(fieldMatches('1-3,7,10-12', 7)).toBe(true)
|
|
96
|
+
expect(fieldMatches('1-3,7,10-12', 11)).toBe(true)
|
|
97
|
+
expect(fieldMatches('1-3,7,10-12', 5)).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// ─── cronMatches tests ─────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
describe('cronMatches', () => {
|
|
104
|
+
it('should match every-minute expression', () => {
|
|
105
|
+
const date = new Date(2026, 3, 5, 14, 30, 0) // April 5, 2026 14:30 (Sunday)
|
|
106
|
+
expect(cronMatches('* * * * *', date)).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('should match daily at 9:00', () => {
|
|
110
|
+
const match = new Date(2026, 3, 5, 9, 0, 0)
|
|
111
|
+
const noMatch = new Date(2026, 3, 5, 9, 1, 0)
|
|
112
|
+
expect(cronMatches('0 9 * * *', match)).toBe(true)
|
|
113
|
+
expect(cronMatches('0 9 * * *', noMatch)).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should match every 2 hours at minute 0', () => {
|
|
117
|
+
expect(cronMatches('0 */2 * * *', new Date(2026, 0, 1, 0, 0))).toBe(true)
|
|
118
|
+
expect(cronMatches('0 */2 * * *', new Date(2026, 0, 1, 2, 0))).toBe(true)
|
|
119
|
+
expect(cronMatches('0 */2 * * *', new Date(2026, 0, 1, 4, 0))).toBe(true)
|
|
120
|
+
expect(cronMatches('0 */2 * * *', new Date(2026, 0, 1, 1, 0))).toBe(false)
|
|
121
|
+
expect(cronMatches('0 */2 * * *', new Date(2026, 0, 1, 3, 0))).toBe(false)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should match weekdays at 14:30', () => {
|
|
125
|
+
// April 6, 2026 is a Monday (dow = 1)
|
|
126
|
+
const monday = new Date(2026, 3, 6, 14, 30, 0)
|
|
127
|
+
// April 5, 2026 is a Sunday (dow = 0)
|
|
128
|
+
const sunday = new Date(2026, 3, 5, 14, 30, 0)
|
|
129
|
+
expect(cronMatches('30 14 * * 1-5', monday)).toBe(true)
|
|
130
|
+
expect(cronMatches('30 14 * * 1-5', sunday)).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('should match specific month and day', () => {
|
|
134
|
+
// January 15 at midnight
|
|
135
|
+
const jan15 = new Date(2026, 0, 15, 0, 0)
|
|
136
|
+
const feb15 = new Date(2026, 1, 15, 0, 0)
|
|
137
|
+
expect(cronMatches('0 0 15 1 *', jan15)).toBe(true)
|
|
138
|
+
expect(cronMatches('0 0 15 1 *', feb15)).toBe(false)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('should reject invalid cron expressions', () => {
|
|
142
|
+
const date = new Date()
|
|
143
|
+
expect(cronMatches('* * *', date)).toBe(false) // only 3 fields
|
|
144
|
+
expect(cronMatches('', date)).toBe(false)
|
|
145
|
+
expect(cronMatches('* * * * * *', date)).toBe(false) // 6 fields
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('should match day-of-week with Sunday as 0', () => {
|
|
149
|
+
// Sunday = 0
|
|
150
|
+
const sunday = new Date(2026, 3, 5, 10, 0) // April 5, 2026 is Sunday
|
|
151
|
+
expect(cronMatches('0 10 * * 0', sunday)).toBe(true)
|
|
152
|
+
expect(cronMatches('0 10 * * 6', sunday)).toBe(false)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// ─── CronScheduler execution tests ────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe('CronScheduler', () => {
|
|
159
|
+
let cronService: CronService
|
|
160
|
+
let scheduler: CronScheduler
|
|
161
|
+
|
|
162
|
+
beforeEach(async () => {
|
|
163
|
+
tmpDir = await createTmpDir()
|
|
164
|
+
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
|
165
|
+
cronService = new CronService()
|
|
166
|
+
scheduler = new CronScheduler(cronService)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
afterEach(async () => {
|
|
170
|
+
scheduler.stop()
|
|
171
|
+
if (originalConfigDir) {
|
|
172
|
+
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
|
173
|
+
} else {
|
|
174
|
+
delete process.env.CLAUDE_CONFIG_DIR
|
|
175
|
+
}
|
|
176
|
+
await cleanupTmpDir(tmpDir)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('should start and stop without errors', () => {
|
|
180
|
+
scheduler.start()
|
|
181
|
+
scheduler.stop()
|
|
182
|
+
// Starting again after stop should also work
|
|
183
|
+
scheduler.start()
|
|
184
|
+
scheduler.stop()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('should not start twice', () => {
|
|
188
|
+
scheduler.start()
|
|
189
|
+
// Second start should be a no-op (no error)
|
|
190
|
+
scheduler.start()
|
|
191
|
+
scheduler.stop()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('should return empty runs when no tasks have executed', async () => {
|
|
195
|
+
const runs = await scheduler.getRecentRuns()
|
|
196
|
+
expect(runs).toEqual([])
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('should return empty runs for a non-existent task ID', async () => {
|
|
200
|
+
const runs = await scheduler.getTaskRuns('nonexistent')
|
|
201
|
+
expect(runs).toEqual([])
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('should persist a task run to the log file', async () => {
|
|
205
|
+
// Create a task that runs "echo hello" — we'll invoke executeTask directly
|
|
206
|
+
// with a mock-like approach: create a task then check the log file
|
|
207
|
+
const task = await cronService.createTask({
|
|
208
|
+
cron: '* * * * *',
|
|
209
|
+
prompt: 'echo test',
|
|
210
|
+
name: 'Test Task',
|
|
211
|
+
recurring: true,
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// We can't easily mock Bun.spawn in bun:test, so we'll check the log
|
|
215
|
+
// file was created by reading it after execution attempt.
|
|
216
|
+
// The CLI subprocess will likely fail (not a real CLI available in tests),
|
|
217
|
+
// but the run should still be logged with 'failed' status.
|
|
218
|
+
try {
|
|
219
|
+
await scheduler.executeTask(task)
|
|
220
|
+
} catch {
|
|
221
|
+
// Expected — CLI binary may not be available in test environment
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const logPath = path.join(tmpDir, 'scheduled_tasks_log.json')
|
|
225
|
+
const logExists = await fs
|
|
226
|
+
.stat(logPath)
|
|
227
|
+
.then(() => true)
|
|
228
|
+
.catch(() => false)
|
|
229
|
+
expect(logExists).toBe(true)
|
|
230
|
+
|
|
231
|
+
const logContent = JSON.parse(await fs.readFile(logPath, 'utf-8')) as {
|
|
232
|
+
runs: TaskRun[]
|
|
233
|
+
}
|
|
234
|
+
expect(logContent.runs.length).toBeGreaterThanOrEqual(1)
|
|
235
|
+
expect(logContent.runs[0].taskId).toBe(task.id)
|
|
236
|
+
expect(logContent.runs[0].taskName).toBe('Test Task')
|
|
237
|
+
expect(logContent.runs[0].prompt).toBe('echo test')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('should disable non-recurring task after execution', async () => {
|
|
241
|
+
const task = await cronService.createTask({
|
|
242
|
+
cron: '* * * * *',
|
|
243
|
+
prompt: 'one-shot task',
|
|
244
|
+
recurring: false,
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
await scheduler.executeTask(task)
|
|
249
|
+
} catch {
|
|
250
|
+
// CLI may not be available
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// After execution, the task should be disabled
|
|
254
|
+
const tasks = await cronService.listTasks()
|
|
255
|
+
const updated = tasks.find((t) => t.id === task.id)
|
|
256
|
+
expect(updated?.enabled).toBe(false)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('should NOT disable recurring task after execution', async () => {
|
|
260
|
+
const task = await cronService.createTask({
|
|
261
|
+
cron: '* * * * *',
|
|
262
|
+
prompt: 'recurring task',
|
|
263
|
+
recurring: true,
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
await scheduler.executeTask(task)
|
|
268
|
+
} catch {
|
|
269
|
+
// CLI may not be available
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const tasks = await cronService.listTasks()
|
|
273
|
+
const updated = tasks.find((t) => t.id === task.id)
|
|
274
|
+
// enabled should not have been set to false
|
|
275
|
+
expect(updated?.enabled).not.toBe(false)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('should update lastFiredAt after execution', async () => {
|
|
279
|
+
const task = await cronService.createTask({
|
|
280
|
+
cron: '* * * * *',
|
|
281
|
+
prompt: 'fire test',
|
|
282
|
+
recurring: true,
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
const beforeExec = new Date().toISOString()
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
await scheduler.executeTask(task)
|
|
289
|
+
} catch {
|
|
290
|
+
// CLI may not be available
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const tasks = await cronService.listTasks()
|
|
294
|
+
const updated = tasks.find((t) => t.id === task.id)
|
|
295
|
+
expect(updated?.lastFiredAt).toBeDefined()
|
|
296
|
+
// lastFiredAt should be a valid ISO timestamp at or after beforeExec
|
|
297
|
+
expect(new Date(updated!.lastFiredAt!).getTime()).toBeGreaterThanOrEqual(
|
|
298
|
+
new Date(beforeExec).getTime() - 1000, // allow 1s tolerance
|
|
299
|
+
)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('should skip disabled tasks during tick', async () => {
|
|
303
|
+
// Create a task matching every minute but disabled
|
|
304
|
+
const task = await cronService.createTask({
|
|
305
|
+
cron: '* * * * *',
|
|
306
|
+
prompt: 'should not run',
|
|
307
|
+
enabled: false,
|
|
308
|
+
recurring: true,
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
await scheduler.tick()
|
|
312
|
+
|
|
313
|
+
// No runs should be logged
|
|
314
|
+
const runs = await scheduler.getTaskRuns(task.id)
|
|
315
|
+
expect(runs).toHaveLength(0)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('getTaskRuns should return runs sorted newest first', async () => {
|
|
319
|
+
const task = await cronService.createTask({
|
|
320
|
+
cron: '* * * * *',
|
|
321
|
+
prompt: 'multi run',
|
|
322
|
+
recurring: true,
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
// Execute twice
|
|
326
|
+
try {
|
|
327
|
+
await scheduler.executeTask(task)
|
|
328
|
+
} catch {
|
|
329
|
+
/* ignore */
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
await scheduler.executeTask(task)
|
|
333
|
+
} catch {
|
|
334
|
+
/* ignore */
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const runs = await scheduler.getTaskRuns(task.id)
|
|
338
|
+
expect(runs.length).toBeGreaterThanOrEqual(2)
|
|
339
|
+
// Should be sorted newest first
|
|
340
|
+
if (runs.length >= 2) {
|
|
341
|
+
expect(
|
|
342
|
+
new Date(runs[0].startedAt).getTime(),
|
|
343
|
+
).toBeGreaterThanOrEqual(new Date(runs[1].startedAt).getTime())
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('getRecentRuns should respect limit parameter', async () => {
|
|
348
|
+
const task = await cronService.createTask({
|
|
349
|
+
cron: '* * * * *',
|
|
350
|
+
prompt: 'limit test',
|
|
351
|
+
recurring: true,
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
// Execute 3 times
|
|
355
|
+
for (let i = 0; i < 3; i++) {
|
|
356
|
+
try {
|
|
357
|
+
await scheduler.executeTask(task)
|
|
358
|
+
} catch {
|
|
359
|
+
/* ignore */
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const runs = await scheduler.getRecentRuns(2)
|
|
364
|
+
expect(runs.length).toBeLessThanOrEqual(2)
|
|
365
|
+
})
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
// ─── Execution log trimming ────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
describe('Execution log trimming', () => {
|
|
371
|
+
let cronService: CronService
|
|
372
|
+
let scheduler: CronScheduler
|
|
373
|
+
|
|
374
|
+
beforeEach(async () => {
|
|
375
|
+
tmpDir = await createTmpDir()
|
|
376
|
+
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
|
377
|
+
cronService = new CronService()
|
|
378
|
+
scheduler = new CronScheduler(cronService)
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
afterEach(async () => {
|
|
382
|
+
scheduler.stop()
|
|
383
|
+
if (originalConfigDir) {
|
|
384
|
+
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
|
385
|
+
} else {
|
|
386
|
+
delete process.env.CLAUDE_CONFIG_DIR
|
|
387
|
+
}
|
|
388
|
+
await cleanupTmpDir(tmpDir)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('should keep log entries within the max limit', async () => {
|
|
392
|
+
// Pre-populate the log file with 105 entries for a single task
|
|
393
|
+
const logPath = path.join(tmpDir, 'scheduled_tasks_log.json')
|
|
394
|
+
const runs: TaskRun[] = []
|
|
395
|
+
for (let i = 0; i < 105; i++) {
|
|
396
|
+
runs.push({
|
|
397
|
+
id: `run-${i}`,
|
|
398
|
+
taskId: 'task-1',
|
|
399
|
+
taskName: 'Test',
|
|
400
|
+
startedAt: new Date(Date.now() - (105 - i) * 1000).toISOString(),
|
|
401
|
+
completedAt: new Date(Date.now() - (105 - i) * 1000 + 100).toISOString(),
|
|
402
|
+
status: 'completed',
|
|
403
|
+
prompt: 'test',
|
|
404
|
+
exitCode: 0,
|
|
405
|
+
durationMs: 100,
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
await fs.writeFile(logPath, JSON.stringify({ runs }, null, 2), 'utf-8')
|
|
409
|
+
|
|
410
|
+
// Now execute one more task run — this triggers a trim
|
|
411
|
+
const task = await cronService.createTask({
|
|
412
|
+
cron: '* * * * *',
|
|
413
|
+
prompt: 'trigger trim',
|
|
414
|
+
recurring: true,
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
await scheduler.executeTask(task)
|
|
419
|
+
} catch {
|
|
420
|
+
/* ignore */
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Read back the log
|
|
424
|
+
const logContent = JSON.parse(await fs.readFile(logPath, 'utf-8')) as {
|
|
425
|
+
runs: TaskRun[]
|
|
426
|
+
}
|
|
427
|
+
const task1Runs = logContent.runs.filter((r) => r.taskId === 'task-1')
|
|
428
|
+
// Should have been trimmed to at most 100
|
|
429
|
+
expect(task1Runs.length).toBeLessThanOrEqual(100)
|
|
430
|
+
})
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
// ─── Scheduled Tasks API with runs endpoints ──────────────────────────────
|
|
434
|
+
|
|
435
|
+
describe('Scheduled Tasks API — runs endpoints', () => {
|
|
436
|
+
let handleScheduledTasksApi: (
|
|
437
|
+
req: Request,
|
|
438
|
+
url: URL,
|
|
439
|
+
segments: string[],
|
|
440
|
+
) => Promise<Response>
|
|
441
|
+
|
|
442
|
+
beforeEach(async () => {
|
|
443
|
+
tmpDir = await createTmpDir()
|
|
444
|
+
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
|
445
|
+
|
|
446
|
+
const mod = await import('../api/scheduled-tasks.js')
|
|
447
|
+
handleScheduledTasksApi = mod.handleScheduledTasksApi
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
afterEach(async () => {
|
|
451
|
+
if (originalConfigDir) {
|
|
452
|
+
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
|
453
|
+
} else {
|
|
454
|
+
delete process.env.CLAUDE_CONFIG_DIR
|
|
455
|
+
}
|
|
456
|
+
await cleanupTmpDir(tmpDir)
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('GET /api/scheduled-tasks/runs should return empty runs', async () => {
|
|
460
|
+
const req = new Request('http://localhost/api/scheduled-tasks/runs', {
|
|
461
|
+
method: 'GET',
|
|
462
|
+
})
|
|
463
|
+
const url = new URL(req.url)
|
|
464
|
+
const resp = await handleScheduledTasksApi(req, url, [
|
|
465
|
+
'api',
|
|
466
|
+
'scheduled-tasks',
|
|
467
|
+
'runs',
|
|
468
|
+
])
|
|
469
|
+
const body = (await resp.json()) as { runs: unknown[] }
|
|
470
|
+
expect(resp.status).toBe(200)
|
|
471
|
+
expect(body.runs).toEqual([])
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('GET /api/scheduled-tasks/:id/runs should return empty runs for a task', async () => {
|
|
475
|
+
const req = new Request(
|
|
476
|
+
'http://localhost/api/scheduled-tasks/abc123/runs',
|
|
477
|
+
{ method: 'GET' },
|
|
478
|
+
)
|
|
479
|
+
const url = new URL(req.url)
|
|
480
|
+
const resp = await handleScheduledTasksApi(req, url, [
|
|
481
|
+
'api',
|
|
482
|
+
'scheduled-tasks',
|
|
483
|
+
'abc123',
|
|
484
|
+
'runs',
|
|
485
|
+
])
|
|
486
|
+
const body = (await resp.json()) as { runs: unknown[] }
|
|
487
|
+
expect(resp.status).toBe(200)
|
|
488
|
+
expect(body.runs).toEqual([])
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('GET /api/scheduled-tasks/runs should return runs from log', async () => {
|
|
492
|
+
// Write some runs to the log file
|
|
493
|
+
const logPath = path.join(tmpDir, 'scheduled_tasks_log.json')
|
|
494
|
+
const runs: TaskRun[] = [
|
|
495
|
+
{
|
|
496
|
+
id: 'run-1',
|
|
497
|
+
taskId: 'task-a',
|
|
498
|
+
taskName: 'Task A',
|
|
499
|
+
startedAt: new Date().toISOString(),
|
|
500
|
+
completedAt: new Date().toISOString(),
|
|
501
|
+
status: 'completed',
|
|
502
|
+
prompt: 'test prompt',
|
|
503
|
+
exitCode: 0,
|
|
504
|
+
durationMs: 500,
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
id: 'run-2',
|
|
508
|
+
taskId: 'task-b',
|
|
509
|
+
taskName: 'Task B',
|
|
510
|
+
startedAt: new Date().toISOString(),
|
|
511
|
+
completedAt: new Date().toISOString(),
|
|
512
|
+
status: 'failed',
|
|
513
|
+
prompt: 'another prompt',
|
|
514
|
+
error: 'some error',
|
|
515
|
+
exitCode: 1,
|
|
516
|
+
durationMs: 200,
|
|
517
|
+
},
|
|
518
|
+
]
|
|
519
|
+
await fs.writeFile(logPath, JSON.stringify({ runs }, null, 2), 'utf-8')
|
|
520
|
+
|
|
521
|
+
const req = new Request('http://localhost/api/scheduled-tasks/runs', {
|
|
522
|
+
method: 'GET',
|
|
523
|
+
})
|
|
524
|
+
const url = new URL(req.url)
|
|
525
|
+
const resp = await handleScheduledTasksApi(req, url, [
|
|
526
|
+
'api',
|
|
527
|
+
'scheduled-tasks',
|
|
528
|
+
'runs',
|
|
529
|
+
])
|
|
530
|
+
const body = (await resp.json()) as { runs: TaskRun[] }
|
|
531
|
+
expect(resp.status).toBe(200)
|
|
532
|
+
expect(body.runs).toHaveLength(2)
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
it('GET /api/scheduled-tasks/:id/runs should filter by task ID', async () => {
|
|
536
|
+
const logPath = path.join(tmpDir, 'scheduled_tasks_log.json')
|
|
537
|
+
const runs: TaskRun[] = [
|
|
538
|
+
{
|
|
539
|
+
id: 'run-1',
|
|
540
|
+
taskId: 'task-a',
|
|
541
|
+
taskName: 'Task A',
|
|
542
|
+
startedAt: new Date().toISOString(),
|
|
543
|
+
status: 'completed',
|
|
544
|
+
prompt: 'prompt a',
|
|
545
|
+
exitCode: 0,
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
id: 'run-2',
|
|
549
|
+
taskId: 'task-b',
|
|
550
|
+
taskName: 'Task B',
|
|
551
|
+
startedAt: new Date().toISOString(),
|
|
552
|
+
status: 'completed',
|
|
553
|
+
prompt: 'prompt b',
|
|
554
|
+
exitCode: 0,
|
|
555
|
+
},
|
|
556
|
+
]
|
|
557
|
+
await fs.writeFile(logPath, JSON.stringify({ runs }, null, 2), 'utf-8')
|
|
558
|
+
|
|
559
|
+
const req = new Request(
|
|
560
|
+
'http://localhost/api/scheduled-tasks/task-a/runs',
|
|
561
|
+
{ method: 'GET' },
|
|
562
|
+
)
|
|
563
|
+
const url = new URL(req.url)
|
|
564
|
+
const resp = await handleScheduledTasksApi(req, url, [
|
|
565
|
+
'api',
|
|
566
|
+
'scheduled-tasks',
|
|
567
|
+
'task-a',
|
|
568
|
+
'runs',
|
|
569
|
+
])
|
|
570
|
+
const body = (await resp.json()) as { runs: TaskRun[] }
|
|
571
|
+
expect(resp.status).toBe(200)
|
|
572
|
+
expect(body.runs).toHaveLength(1)
|
|
573
|
+
expect(body.runs[0].taskId).toBe('task-a')
|
|
574
|
+
})
|
|
575
|
+
})
|