cc-haha/src/server/__tests__/tasks.test.ts
程序员阿江(Relakkes) 29c9fb8f4a feat: add CLI subprocess chat, Agent Teams API, and real background tasks
Three new modules completing the server-side functionality:

1. ConversationService: spawns CLI as subprocess with --input-format
   stream-json, forwards WebSocket messages to CLI stdin/stdout.
   CLI handles all AI communication, tool execution, and permissions.
   Graceful fallback to echo mode when CLI unavailable.

2. Agent Teams API: GET/DELETE /api/teams, member listing with status
   derivation (running/idle/completed), transcript reading from
   CLI-generated JSONL files. Teams created by CLI, API is read-only.

3. TaskService: replaces placeholder with real task file scanning
   from ~/.claude/tasks/, supports nested team directories.

229 tests passing (49 new: 12 conversations + 27 teams + 10 tasks).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:05:22 +08:00

172 lines
5.2 KiB
TypeScript

/**
* Unit tests for TaskService and Tasks API
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { TaskService } from '../services/taskService.js'
// ============================================================================
// TaskService unit tests
// ============================================================================
describe('TaskService', () => {
let tmpDir: string
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-tasks-'))
process.env.CLAUDE_CONFIG_DIR = tmpDir
})
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true })
delete process.env.CLAUDE_CONFIG_DIR
})
it('should return empty list when no tasks dir', async () => {
const svc = new TaskService()
const tasks = await svc.listTasks()
expect(tasks).toEqual([])
})
it('should list tasks from JSON files', async () => {
const tasksDir = path.join(tmpDir, 'tasks')
await fs.mkdir(tasksDir, { recursive: true })
await fs.writeFile(path.join(tasksDir, 'task-001.json'), JSON.stringify({
id: 'task-001',
type: 'local_agent',
status: 'completed',
name: 'code-review',
description: 'Review PR #42',
createdAt: Date.now() - 60000,
completedAt: Date.now(),
}))
await fs.writeFile(path.join(tasksDir, 'task-002.json'), JSON.stringify({
id: 'task-002',
type: 'in_process_teammate',
status: 'running',
name: 'frontend-dev',
teamName: 'ui-team',
createdAt: Date.now(),
}))
const svc = new TaskService()
const tasks = await svc.listTasks()
expect(tasks.length).toBe(2)
// 按 createdAt 倒序
expect(tasks[0].id).toBe('task-002')
expect(tasks[1].id).toBe('task-001')
})
it('should scan nested team task directories', async () => {
const teamDir = path.join(tmpDir, 'tasks', 'my-team')
await fs.mkdir(teamDir, { recursive: true })
await fs.writeFile(path.join(teamDir, 'member-1.json'), JSON.stringify({
id: 'member-1',
type: 'in_process_teammate',
status: 'completed',
teamName: 'my-team',
}))
const svc = new TaskService()
const tasks = await svc.listTasks()
expect(tasks.length).toBe(1)
expect(tasks[0].teamName).toBe('my-team')
})
it('should get single task by ID', async () => {
const tasksDir = path.join(tmpDir, 'tasks')
await fs.mkdir(tasksDir, { recursive: true })
await fs.writeFile(path.join(tasksDir, 'abc.json'), JSON.stringify({
id: 'abc',
type: 'local_shell',
status: 'failed',
name: 'build',
}))
const svc = new TaskService()
const task = await svc.getTask('abc')
expect(task).toBeDefined()
expect(task!.status).toBe('failed')
})
it('should return null for unknown task', async () => {
const svc = new TaskService()
const task = await svc.getTask('nonexistent')
expect(task).toBeNull()
})
it('should skip invalid JSON files gracefully', async () => {
const tasksDir = path.join(tmpDir, 'tasks')
await fs.mkdir(tasksDir, { recursive: true })
await fs.writeFile(path.join(tasksDir, 'bad.json'), 'not json {{{')
const svc = new TaskService()
const tasks = await svc.listTasks()
expect(tasks).toEqual([])
})
})
// ============================================================================
// Tasks API integration tests
// ============================================================================
describe('Tasks API', () => {
let server: any
let baseUrl: string
let tmpDir: string
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-tasks-api-'))
process.env.CLAUDE_CONFIG_DIR = tmpDir
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
const port = 15500 + Math.floor(Math.random() * 500)
const { startServer } = await import('../../server/index.js')
server = startServer(port, '127.0.0.1')
baseUrl = `http://127.0.0.1:${port}`
})
afterEach(async () => {
server?.stop()
await fs.rm(tmpDir, { recursive: true, force: true })
delete process.env.CLAUDE_CONFIG_DIR
})
it('should return empty tasks list', async () => {
const res = await fetch(`${baseUrl}/api/tasks`)
const data = await res.json()
expect(res.status).toBe(200)
expect(data.tasks).toEqual([])
})
it('should return tasks when files exist', async () => {
const tasksDir = path.join(tmpDir, 'tasks')
await fs.mkdir(tasksDir, { recursive: true })
await fs.writeFile(path.join(tasksDir, 'test.json'), JSON.stringify({
id: 'test', type: 'local_agent', status: 'completed', name: 'test-task',
}))
const res = await fetch(`${baseUrl}/api/tasks`)
const data = await res.json()
expect(data.tasks.length).toBe(1)
expect(data.tasks[0].name).toBe('test-task')
})
it('should return 404 for unknown task', async () => {
const res = await fetch(`${baseUrl}/api/tasks/nonexistent`)
expect(res.status).toBe(404)
})
it('should reject non-GET methods', async () => {
const res = await fetch(`${baseUrl}/api/tasks`, { method: 'POST' })
expect(res.status).toBe(405)
})
})