mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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>
172 lines
5.2 KiB
TypeScript
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)
|
|
})
|
|
})
|