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.
Files changed (53) hide show
  1. package/adapters/common/__tests__/chat-queue.test.ts +61 -0
  2. package/adapters/common/__tests__/format.test.ts +148 -0
  3. package/adapters/common/__tests__/http-client.test.ts +105 -0
  4. package/adapters/common/__tests__/message-buffer.test.ts +84 -0
  5. package/adapters/common/__tests__/message-dedup.test.ts +57 -0
  6. package/adapters/common/__tests__/session-store.test.ts +62 -0
  7. package/adapters/common/__tests__/ws-bridge.test.ts +177 -0
  8. package/adapters/common/attachment/__tests__/attachment-limits.test.ts +52 -0
  9. package/adapters/common/attachment/__tests__/attachment-store.test.ts +108 -0
  10. package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +115 -0
  11. package/adapters/feishu/__tests__/card-errors.test.ts +194 -0
  12. package/adapters/feishu/__tests__/cardkit.test.ts +295 -0
  13. package/adapters/feishu/__tests__/extract-payload.test.ts +77 -0
  14. package/adapters/feishu/__tests__/feishu.test.ts +907 -0
  15. package/adapters/feishu/__tests__/flush-controller.test.ts +290 -0
  16. package/adapters/feishu/__tests__/markdown-style.test.ts +353 -0
  17. package/adapters/feishu/__tests__/media.test.ts +120 -0
  18. package/adapters/feishu/__tests__/streaming-card.test.ts +914 -0
  19. package/adapters/telegram/__tests__/media.test.ts +86 -0
  20. package/adapters/telegram/__tests__/telegram.test.ts +115 -0
  21. package/adapters/tsconfig.json +18 -0
  22. package/bunfig.toml +1 -0
  23. package/package.json +1 -1
  24. package/preload.ts +30 -0
  25. package/scripts/count-app-loc.ts +256 -0
  26. package/scripts/release.ts +130 -0
  27. package/src/server/__tests__/conversation-service.test.ts +173 -0
  28. package/src/server/__tests__/conversations.test.ts +458 -0
  29. package/src/server/__tests__/cron-scheduler.test.ts +575 -0
  30. package/src/server/__tests__/e2e/business-flow.test.ts +841 -0
  31. package/src/server/__tests__/e2e/full-flow.test.ts +357 -0
  32. package/src/server/__tests__/fixtures/mock-sdk-cli.ts +123 -0
  33. package/src/server/__tests__/haha-oauth-api.test.ts +146 -0
  34. package/src/server/__tests__/haha-oauth-service.test.ts +185 -0
  35. package/src/server/__tests__/providers-real.test.ts +244 -0
  36. package/src/server/__tests__/providers.test.ts +579 -0
  37. package/src/server/__tests__/proxy-streaming.test.ts +317 -0
  38. package/src/server/__tests__/proxy-transform.test.ts +469 -0
  39. package/src/server/__tests__/real-llm-test.ts +526 -0
  40. package/src/server/__tests__/scheduled-tasks.test.ts +371 -0
  41. package/src/server/__tests__/sessions.test.ts +786 -0
  42. package/src/server/__tests__/settings.test.ts +376 -0
  43. package/src/server/__tests__/skills.test.ts +125 -0
  44. package/src/server/__tests__/tasks.test.ts +171 -0
  45. package/src/server/__tests__/team-watcher.test.ts +400 -0
  46. package/src/server/__tests__/teams.test.ts +627 -0
  47. package/src/server/middleware/cors.test.ts +27 -0
  48. package/src/utils/__tests__/cronFrequency.test.ts +153 -0
  49. package/src/utils/__tests__/cronTasks.test.ts +204 -0
  50. package/src/utils/computerUse/permissions.test.ts +44 -0
  51. package/stubs/ant-claude-for-chrome-mcp.ts +24 -0
  52. package/stubs/color-diff-napi.ts +45 -0
  53. 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
+ })