mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
- Fix translateCliMessage to return ServerMessage[] with full tool lifecycle (tool_result, tool_use_complete, toolInput, thinking, stream_event) - Add CronScheduler that actually executes scheduled tasks via CLI subprocess, with execution log persistence and GET /api/scheduled-tasks/:id/runs API - Add TeamWatcher polling ~/.claude/teams/ every 3s, broadcasting team_update/ team_created/team_deleted to all WebSocket clients - 280 tests pass (51 new), real LLM integration verified via MiniMax API Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
576 lines
18 KiB
TypeScript
576 lines
18 KiB
TypeScript
/**
|
|
* Tests for CronScheduler — cron matching, task execution, log storage, and API endpoints
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'
|
|
import * as fs from 'fs/promises'
|
|
import * as path from 'path'
|
|
import * as os from 'os'
|
|
import {
|
|
cronMatches,
|
|
fieldMatches,
|
|
CronScheduler,
|
|
type TaskRun,
|
|
} from '../services/cronScheduler.js'
|
|
import { CronService, type CronTask } from '../services/cronService.js'
|
|
|
|
// ─── Test helpers ───────────────────────────────────────────────────────────
|
|
|
|
let tmpDir: string
|
|
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
|
|
async function createTmpDir(): Promise<string> {
|
|
const dir = path.join(
|
|
os.tmpdir(),
|
|
`claude-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
)
|
|
await fs.mkdir(dir, { recursive: true })
|
|
return dir
|
|
}
|
|
|
|
async function cleanupTmpDir(dir: string): Promise<void> {
|
|
try {
|
|
await fs.rm(dir, { recursive: true, force: true })
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
// ─── fieldMatches tests ────────────────────────────────────────────────────
|
|
|
|
describe('fieldMatches', () => {
|
|
it('should match wildcard', () => {
|
|
expect(fieldMatches('*', 0)).toBe(true)
|
|
expect(fieldMatches('*', 59)).toBe(true)
|
|
expect(fieldMatches('*', 23)).toBe(true)
|
|
})
|
|
|
|
it('should match exact number', () => {
|
|
expect(fieldMatches('5', 5)).toBe(true)
|
|
expect(fieldMatches('5', 6)).toBe(false)
|
|
expect(fieldMatches('0', 0)).toBe(true)
|
|
expect(fieldMatches('30', 30)).toBe(true)
|
|
})
|
|
|
|
it('should match comma-separated list', () => {
|
|
expect(fieldMatches('1,3,5', 1)).toBe(true)
|
|
expect(fieldMatches('1,3,5', 3)).toBe(true)
|
|
expect(fieldMatches('1,3,5', 5)).toBe(true)
|
|
expect(fieldMatches('1,3,5', 2)).toBe(false)
|
|
expect(fieldMatches('1,3,5', 4)).toBe(false)
|
|
})
|
|
|
|
it('should match range', () => {
|
|
expect(fieldMatches('1-5', 1)).toBe(true)
|
|
expect(fieldMatches('1-5', 3)).toBe(true)
|
|
expect(fieldMatches('1-5', 5)).toBe(true)
|
|
expect(fieldMatches('1-5', 0)).toBe(false)
|
|
expect(fieldMatches('1-5', 6)).toBe(false)
|
|
})
|
|
|
|
it('should match step from wildcard', () => {
|
|
expect(fieldMatches('*/2', 0)).toBe(true)
|
|
expect(fieldMatches('*/2', 2)).toBe(true)
|
|
expect(fieldMatches('*/2', 4)).toBe(true)
|
|
expect(fieldMatches('*/2', 1)).toBe(false)
|
|
expect(fieldMatches('*/2', 3)).toBe(false)
|
|
expect(fieldMatches('*/15', 0)).toBe(true)
|
|
expect(fieldMatches('*/15', 15)).toBe(true)
|
|
expect(fieldMatches('*/15', 30)).toBe(true)
|
|
expect(fieldMatches('*/15', 7)).toBe(false)
|
|
})
|
|
|
|
it('should match step within range', () => {
|
|
expect(fieldMatches('1-10/3', 1)).toBe(true)
|
|
expect(fieldMatches('1-10/3', 4)).toBe(true)
|
|
expect(fieldMatches('1-10/3', 7)).toBe(true)
|
|
expect(fieldMatches('1-10/3', 10)).toBe(true)
|
|
expect(fieldMatches('1-10/3', 2)).toBe(false)
|
|
expect(fieldMatches('1-10/3', 11)).toBe(false)
|
|
expect(fieldMatches('1-10/3', 0)).toBe(false)
|
|
})
|
|
|
|
it('should handle combined comma and range', () => {
|
|
expect(fieldMatches('1-3,7,10-12', 2)).toBe(true)
|
|
expect(fieldMatches('1-3,7,10-12', 7)).toBe(true)
|
|
expect(fieldMatches('1-3,7,10-12', 11)).toBe(true)
|
|
expect(fieldMatches('1-3,7,10-12', 5)).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ─── cronMatches tests ─────────────────────────────────────────────────────
|
|
|
|
describe('cronMatches', () => {
|
|
it('should match every-minute expression', () => {
|
|
const date = new Date(2026, 3, 5, 14, 30, 0) // April 5, 2026 14:30 (Sunday)
|
|
expect(cronMatches('* * * * *', date)).toBe(true)
|
|
})
|
|
|
|
it('should match daily at 9:00', () => {
|
|
const match = new Date(2026, 3, 5, 9, 0, 0)
|
|
const noMatch = new Date(2026, 3, 5, 9, 1, 0)
|
|
expect(cronMatches('0 9 * * *', match)).toBe(true)
|
|
expect(cronMatches('0 9 * * *', noMatch)).toBe(false)
|
|
})
|
|
|
|
it('should match every 2 hours at minute 0', () => {
|
|
expect(cronMatches('0 */2 * * *', new Date(2026, 0, 1, 0, 0))).toBe(true)
|
|
expect(cronMatches('0 */2 * * *', new Date(2026, 0, 1, 2, 0))).toBe(true)
|
|
expect(cronMatches('0 */2 * * *', new Date(2026, 0, 1, 4, 0))).toBe(true)
|
|
expect(cronMatches('0 */2 * * *', new Date(2026, 0, 1, 1, 0))).toBe(false)
|
|
expect(cronMatches('0 */2 * * *', new Date(2026, 0, 1, 3, 0))).toBe(false)
|
|
})
|
|
|
|
it('should match weekdays at 14:30', () => {
|
|
// April 6, 2026 is a Monday (dow = 1)
|
|
const monday = new Date(2026, 3, 6, 14, 30, 0)
|
|
// April 5, 2026 is a Sunday (dow = 0)
|
|
const sunday = new Date(2026, 3, 5, 14, 30, 0)
|
|
expect(cronMatches('30 14 * * 1-5', monday)).toBe(true)
|
|
expect(cronMatches('30 14 * * 1-5', sunday)).toBe(false)
|
|
})
|
|
|
|
it('should match specific month and day', () => {
|
|
// January 15 at midnight
|
|
const jan15 = new Date(2026, 0, 15, 0, 0)
|
|
const feb15 = new Date(2026, 1, 15, 0, 0)
|
|
expect(cronMatches('0 0 15 1 *', jan15)).toBe(true)
|
|
expect(cronMatches('0 0 15 1 *', feb15)).toBe(false)
|
|
})
|
|
|
|
it('should reject invalid cron expressions', () => {
|
|
const date = new Date()
|
|
expect(cronMatches('* * *', date)).toBe(false) // only 3 fields
|
|
expect(cronMatches('', date)).toBe(false)
|
|
expect(cronMatches('* * * * * *', date)).toBe(false) // 6 fields
|
|
})
|
|
|
|
it('should match day-of-week with Sunday as 0', () => {
|
|
// Sunday = 0
|
|
const sunday = new Date(2026, 3, 5, 10, 0) // April 5, 2026 is Sunday
|
|
expect(cronMatches('0 10 * * 0', sunday)).toBe(true)
|
|
expect(cronMatches('0 10 * * 6', sunday)).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ─── CronScheduler execution tests ────────────────────────────────────────
|
|
|
|
describe('CronScheduler', () => {
|
|
let cronService: CronService
|
|
let scheduler: CronScheduler
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await createTmpDir()
|
|
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
|
cronService = new CronService()
|
|
scheduler = new CronScheduler(cronService)
|
|
})
|
|
|
|
afterEach(async () => {
|
|
scheduler.stop()
|
|
if (originalConfigDir) {
|
|
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
|
} else {
|
|
delete process.env.CLAUDE_CONFIG_DIR
|
|
}
|
|
await cleanupTmpDir(tmpDir)
|
|
})
|
|
|
|
it('should start and stop without errors', () => {
|
|
scheduler.start()
|
|
scheduler.stop()
|
|
// Starting again after stop should also work
|
|
scheduler.start()
|
|
scheduler.stop()
|
|
})
|
|
|
|
it('should not start twice', () => {
|
|
scheduler.start()
|
|
// Second start should be a no-op (no error)
|
|
scheduler.start()
|
|
scheduler.stop()
|
|
})
|
|
|
|
it('should return empty runs when no tasks have executed', async () => {
|
|
const runs = await scheduler.getRecentRuns()
|
|
expect(runs).toEqual([])
|
|
})
|
|
|
|
it('should return empty runs for a non-existent task ID', async () => {
|
|
const runs = await scheduler.getTaskRuns('nonexistent')
|
|
expect(runs).toEqual([])
|
|
})
|
|
|
|
it('should persist a task run to the log file', async () => {
|
|
// Create a task that runs "echo hello" — we'll invoke executeTask directly
|
|
// with a mock-like approach: create a task then check the log file
|
|
const task = await cronService.createTask({
|
|
cron: '* * * * *',
|
|
prompt: 'echo test',
|
|
name: 'Test Task',
|
|
recurring: true,
|
|
})
|
|
|
|
// We can't easily mock Bun.spawn in bun:test, so we'll check the log
|
|
// file was created by reading it after execution attempt.
|
|
// The CLI subprocess will likely fail (not a real CLI available in tests),
|
|
// but the run should still be logged with 'failed' status.
|
|
try {
|
|
await scheduler.executeTask(task)
|
|
} catch {
|
|
// Expected — CLI binary may not be available in test environment
|
|
}
|
|
|
|
const logPath = path.join(tmpDir, 'scheduled_tasks_log.json')
|
|
const logExists = await fs
|
|
.stat(logPath)
|
|
.then(() => true)
|
|
.catch(() => false)
|
|
expect(logExists).toBe(true)
|
|
|
|
const logContent = JSON.parse(await fs.readFile(logPath, 'utf-8')) as {
|
|
runs: TaskRun[]
|
|
}
|
|
expect(logContent.runs.length).toBeGreaterThanOrEqual(1)
|
|
expect(logContent.runs[0].taskId).toBe(task.id)
|
|
expect(logContent.runs[0].taskName).toBe('Test Task')
|
|
expect(logContent.runs[0].prompt).toBe('echo test')
|
|
})
|
|
|
|
it('should disable non-recurring task after execution', async () => {
|
|
const task = await cronService.createTask({
|
|
cron: '* * * * *',
|
|
prompt: 'one-shot task',
|
|
recurring: false,
|
|
})
|
|
|
|
try {
|
|
await scheduler.executeTask(task)
|
|
} catch {
|
|
// CLI may not be available
|
|
}
|
|
|
|
// After execution, the task should be disabled
|
|
const tasks = await cronService.listTasks()
|
|
const updated = tasks.find((t) => t.id === task.id)
|
|
expect(updated?.enabled).toBe(false)
|
|
})
|
|
|
|
it('should NOT disable recurring task after execution', async () => {
|
|
const task = await cronService.createTask({
|
|
cron: '* * * * *',
|
|
prompt: 'recurring task',
|
|
recurring: true,
|
|
})
|
|
|
|
try {
|
|
await scheduler.executeTask(task)
|
|
} catch {
|
|
// CLI may not be available
|
|
}
|
|
|
|
const tasks = await cronService.listTasks()
|
|
const updated = tasks.find((t) => t.id === task.id)
|
|
// enabled should not have been set to false
|
|
expect(updated?.enabled).not.toBe(false)
|
|
})
|
|
|
|
it('should update lastFiredAt after execution', async () => {
|
|
const task = await cronService.createTask({
|
|
cron: '* * * * *',
|
|
prompt: 'fire test',
|
|
recurring: true,
|
|
})
|
|
|
|
const beforeExec = new Date().toISOString()
|
|
|
|
try {
|
|
await scheduler.executeTask(task)
|
|
} catch {
|
|
// CLI may not be available
|
|
}
|
|
|
|
const tasks = await cronService.listTasks()
|
|
const updated = tasks.find((t) => t.id === task.id)
|
|
expect(updated?.lastFiredAt).toBeDefined()
|
|
// lastFiredAt should be a valid ISO timestamp at or after beforeExec
|
|
expect(new Date(updated!.lastFiredAt!).getTime()).toBeGreaterThanOrEqual(
|
|
new Date(beforeExec).getTime() - 1000, // allow 1s tolerance
|
|
)
|
|
})
|
|
|
|
it('should skip disabled tasks during tick', async () => {
|
|
// Create a task matching every minute but disabled
|
|
const task = await cronService.createTask({
|
|
cron: '* * * * *',
|
|
prompt: 'should not run',
|
|
enabled: false,
|
|
recurring: true,
|
|
})
|
|
|
|
await scheduler.tick()
|
|
|
|
// No runs should be logged
|
|
const runs = await scheduler.getTaskRuns(task.id)
|
|
expect(runs).toHaveLength(0)
|
|
})
|
|
|
|
it('getTaskRuns should return runs sorted newest first', async () => {
|
|
const task = await cronService.createTask({
|
|
cron: '* * * * *',
|
|
prompt: 'multi run',
|
|
recurring: true,
|
|
})
|
|
|
|
// Execute twice
|
|
try {
|
|
await scheduler.executeTask(task)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
await scheduler.executeTask(task)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
|
|
const runs = await scheduler.getTaskRuns(task.id)
|
|
expect(runs.length).toBeGreaterThanOrEqual(2)
|
|
// Should be sorted newest first
|
|
if (runs.length >= 2) {
|
|
expect(
|
|
new Date(runs[0].startedAt).getTime(),
|
|
).toBeGreaterThanOrEqual(new Date(runs[1].startedAt).getTime())
|
|
}
|
|
})
|
|
|
|
it('getRecentRuns should respect limit parameter', async () => {
|
|
const task = await cronService.createTask({
|
|
cron: '* * * * *',
|
|
prompt: 'limit test',
|
|
recurring: true,
|
|
})
|
|
|
|
// Execute 3 times
|
|
for (let i = 0; i < 3; i++) {
|
|
try {
|
|
await scheduler.executeTask(task)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
const runs = await scheduler.getRecentRuns(2)
|
|
expect(runs.length).toBeLessThanOrEqual(2)
|
|
})
|
|
})
|
|
|
|
// ─── Execution log trimming ────────────────────────────────────────────────
|
|
|
|
describe('Execution log trimming', () => {
|
|
let cronService: CronService
|
|
let scheduler: CronScheduler
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await createTmpDir()
|
|
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
|
cronService = new CronService()
|
|
scheduler = new CronScheduler(cronService)
|
|
})
|
|
|
|
afterEach(async () => {
|
|
scheduler.stop()
|
|
if (originalConfigDir) {
|
|
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
|
} else {
|
|
delete process.env.CLAUDE_CONFIG_DIR
|
|
}
|
|
await cleanupTmpDir(tmpDir)
|
|
})
|
|
|
|
it('should keep log entries within the max limit', async () => {
|
|
// Pre-populate the log file with 105 entries for a single task
|
|
const logPath = path.join(tmpDir, 'scheduled_tasks_log.json')
|
|
const runs: TaskRun[] = []
|
|
for (let i = 0; i < 105; i++) {
|
|
runs.push({
|
|
id: `run-${i}`,
|
|
taskId: 'task-1',
|
|
taskName: 'Test',
|
|
startedAt: new Date(Date.now() - (105 - i) * 1000).toISOString(),
|
|
completedAt: new Date(Date.now() - (105 - i) * 1000 + 100).toISOString(),
|
|
status: 'completed',
|
|
prompt: 'test',
|
|
exitCode: 0,
|
|
durationMs: 100,
|
|
})
|
|
}
|
|
await fs.writeFile(logPath, JSON.stringify({ runs }, null, 2), 'utf-8')
|
|
|
|
// Now execute one more task run — this triggers a trim
|
|
const task = await cronService.createTask({
|
|
cron: '* * * * *',
|
|
prompt: 'trigger trim',
|
|
recurring: true,
|
|
})
|
|
|
|
try {
|
|
await scheduler.executeTask(task)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
|
|
// Read back the log
|
|
const logContent = JSON.parse(await fs.readFile(logPath, 'utf-8')) as {
|
|
runs: TaskRun[]
|
|
}
|
|
const task1Runs = logContent.runs.filter((r) => r.taskId === 'task-1')
|
|
// Should have been trimmed to at most 100
|
|
expect(task1Runs.length).toBeLessThanOrEqual(100)
|
|
})
|
|
})
|
|
|
|
// ─── Scheduled Tasks API with runs endpoints ──────────────────────────────
|
|
|
|
describe('Scheduled Tasks API — runs endpoints', () => {
|
|
let handleScheduledTasksApi: (
|
|
req: Request,
|
|
url: URL,
|
|
segments: string[],
|
|
) => Promise<Response>
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await createTmpDir()
|
|
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
|
|
|
const mod = await import('../api/scheduled-tasks.js')
|
|
handleScheduledTasksApi = mod.handleScheduledTasksApi
|
|
})
|
|
|
|
afterEach(async () => {
|
|
if (originalConfigDir) {
|
|
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
|
} else {
|
|
delete process.env.CLAUDE_CONFIG_DIR
|
|
}
|
|
await cleanupTmpDir(tmpDir)
|
|
})
|
|
|
|
it('GET /api/scheduled-tasks/runs should return empty runs', async () => {
|
|
const req = new Request('http://localhost/api/scheduled-tasks/runs', {
|
|
method: 'GET',
|
|
})
|
|
const url = new URL(req.url)
|
|
const resp = await handleScheduledTasksApi(req, url, [
|
|
'api',
|
|
'scheduled-tasks',
|
|
'runs',
|
|
])
|
|
const body = (await resp.json()) as { runs: unknown[] }
|
|
expect(resp.status).toBe(200)
|
|
expect(body.runs).toEqual([])
|
|
})
|
|
|
|
it('GET /api/scheduled-tasks/:id/runs should return empty runs for a task', async () => {
|
|
const req = new Request(
|
|
'http://localhost/api/scheduled-tasks/abc123/runs',
|
|
{ method: 'GET' },
|
|
)
|
|
const url = new URL(req.url)
|
|
const resp = await handleScheduledTasksApi(req, url, [
|
|
'api',
|
|
'scheduled-tasks',
|
|
'abc123',
|
|
'runs',
|
|
])
|
|
const body = (await resp.json()) as { runs: unknown[] }
|
|
expect(resp.status).toBe(200)
|
|
expect(body.runs).toEqual([])
|
|
})
|
|
|
|
it('GET /api/scheduled-tasks/runs should return runs from log', async () => {
|
|
// Write some runs to the log file
|
|
const logPath = path.join(tmpDir, 'scheduled_tasks_log.json')
|
|
const runs: TaskRun[] = [
|
|
{
|
|
id: 'run-1',
|
|
taskId: 'task-a',
|
|
taskName: 'Task A',
|
|
startedAt: new Date().toISOString(),
|
|
completedAt: new Date().toISOString(),
|
|
status: 'completed',
|
|
prompt: 'test prompt',
|
|
exitCode: 0,
|
|
durationMs: 500,
|
|
},
|
|
{
|
|
id: 'run-2',
|
|
taskId: 'task-b',
|
|
taskName: 'Task B',
|
|
startedAt: new Date().toISOString(),
|
|
completedAt: new Date().toISOString(),
|
|
status: 'failed',
|
|
prompt: 'another prompt',
|
|
error: 'some error',
|
|
exitCode: 1,
|
|
durationMs: 200,
|
|
},
|
|
]
|
|
await fs.writeFile(logPath, JSON.stringify({ runs }, null, 2), 'utf-8')
|
|
|
|
const req = new Request('http://localhost/api/scheduled-tasks/runs', {
|
|
method: 'GET',
|
|
})
|
|
const url = new URL(req.url)
|
|
const resp = await handleScheduledTasksApi(req, url, [
|
|
'api',
|
|
'scheduled-tasks',
|
|
'runs',
|
|
])
|
|
const body = (await resp.json()) as { runs: TaskRun[] }
|
|
expect(resp.status).toBe(200)
|
|
expect(body.runs).toHaveLength(2)
|
|
})
|
|
|
|
it('GET /api/scheduled-tasks/:id/runs should filter by task ID', async () => {
|
|
const logPath = path.join(tmpDir, 'scheduled_tasks_log.json')
|
|
const runs: TaskRun[] = [
|
|
{
|
|
id: 'run-1',
|
|
taskId: 'task-a',
|
|
taskName: 'Task A',
|
|
startedAt: new Date().toISOString(),
|
|
status: 'completed',
|
|
prompt: 'prompt a',
|
|
exitCode: 0,
|
|
},
|
|
{
|
|
id: 'run-2',
|
|
taskId: 'task-b',
|
|
taskName: 'Task B',
|
|
startedAt: new Date().toISOString(),
|
|
status: 'completed',
|
|
prompt: 'prompt b',
|
|
exitCode: 0,
|
|
},
|
|
]
|
|
await fs.writeFile(logPath, JSON.stringify({ runs }, null, 2), 'utf-8')
|
|
|
|
const req = new Request(
|
|
'http://localhost/api/scheduled-tasks/task-a/runs',
|
|
{ method: 'GET' },
|
|
)
|
|
const url = new URL(req.url)
|
|
const resp = await handleScheduledTasksApi(req, url, [
|
|
'api',
|
|
'scheduled-tasks',
|
|
'task-a',
|
|
'runs',
|
|
])
|
|
const body = (await resp.json()) as { runs: TaskRun[] }
|
|
expect(resp.status).toBe(200)
|
|
expect(body.runs).toHaveLength(1)
|
|
expect(body.runs[0].taskId).toBe('task-a')
|
|
})
|
|
})
|