cc-haha/src/server/__tests__/cron-scheduler.test.ts
程序员阿江(Relakkes) ce0908a4e0 feat: complete server-side gaps — tool visibility, cron execution engine, team real-time push
- 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>
2026-04-05 14:42:32 +08:00

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')
})
})