Download src/server/__tests__/teams.test.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 20 kB
-
https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/server/__tests__/teams.test.ts
- Command line
-
hf download hf://chenbhao/codev@afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/server/__tests__/teams.test.ts
-
curl -L -o teams.test.ts https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/server/__tests__/teams.test.ts
20 kB
| /** | |
| * Unit tests for TeamService and Teams API | |
| */ | |
| import { describe, it, expect, beforeEach, afterEach } from 'bun:test' | |
| import * as fs from 'node:fs/promises' | |
| import * as path from 'node:path' | |
| import * as os from 'node:os' | |
| import { TeamService } from '../services/teamService.js' | |
| // ============================================================================ | |
| // Test helpers | |
| // ============================================================================ | |
| let tmpDir: string | |
| let service: TeamService | |
| async function setupTmpConfigDir(): Promise<string> { | |
| tmpDir = path.join( | |
| os.tmpdir(), | |
| `claude-teams-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, | |
| ) | |
| await fs.mkdir(path.join(tmpDir, 'teams'), { recursive: true }) | |
| await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true }) | |
| process.env.CLAUDE_CONFIG_DIR = tmpDir | |
| return tmpDir | |
| } | |
| async function cleanupTmpDir(): Promise<void> { | |
| if (tmpDir) { | |
| await fs.rm(tmpDir, { recursive: true, force: true }) | |
| } | |
| delete process.env.CLAUDE_CONFIG_DIR | |
| } | |
| /** Write a team config.json to the temp directory. */ | |
| async function writeTeamConfig( | |
| teamName: string, | |
| config: Record<string, unknown>, | |
| ): Promise<string> { | |
| const teamDir = path.join(tmpDir, 'teams', teamName) | |
| await fs.mkdir(teamDir, { recursive: true }) | |
| const configPath = path.join(teamDir, 'config.json') | |
| await fs.writeFile(configPath, JSON.stringify(config), 'utf-8') | |
| return configPath | |
| } | |
| /** Write a mock JSONL transcript file under projects. */ | |
| async function writeTranscriptFile( | |
| projectDir: string, | |
| sessionId: string, | |
| entries: Record<string, unknown>[], | |
| ): Promise<string> { | |
| const dir = path.join(tmpDir, 'projects', projectDir) | |
| await fs.mkdir(dir, { recursive: true }) | |
| const filePath = path.join(dir, `${sessionId}.jsonl`) | |
| const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n' | |
| await fs.writeFile(filePath, content, 'utf-8') | |
| return filePath | |
| } | |
| async function writeSubagentTranscriptFile( | |
| projectDir: string, | |
| leadSessionId: string, | |
| fileName: string, | |
| entries: Record<string, unknown>[], | |
| ): Promise<string> { | |
| const dir = path.join(tmpDir, 'projects', projectDir, leadSessionId, 'subagents') | |
| await fs.mkdir(dir, { recursive: true }) | |
| const filePath = path.join(dir, fileName) | |
| const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n' | |
| await fs.writeFile(filePath, content, 'utf-8') | |
| return filePath | |
| } | |
| /** Create a standard team config for testing. */ | |
| function makeTeamConfig(overrides?: Record<string, unknown>) { | |
| return { | |
| name: 'test-team', | |
| description: 'A test team', | |
| createdAt: 1700000000000, | |
| leadAgentId: 'agent-lead', | |
| members: [ | |
| { | |
| agentId: 'agent-lead', | |
| name: 'Lead Agent', | |
| agentType: 'lead', | |
| model: 'claude-opus-4-7', | |
| color: '#ff0000', | |
| joinedAt: 1700000000000, | |
| tmuxPaneId: '%0', | |
| cwd: '/tmp/project', | |
| sessionId: 'session-lead-001', | |
| isActive: true, | |
| }, | |
| { | |
| agentId: 'agent-worker', | |
| name: 'Worker Agent', | |
| agentType: 'worker', | |
| model: 'claude-sonnet-4-20250514', | |
| color: '#00ff00', | |
| joinedAt: 1700000001000, | |
| tmuxPaneId: '%1', | |
| cwd: '/tmp/project/src', | |
| sessionId: 'session-worker-001', | |
| isActive: false, | |
| }, | |
| ], | |
| ...overrides, | |
| } | |
| } | |
| // ============================================================================ | |
| // TeamService tests | |
| // ============================================================================ | |
| describe('TeamService', () => { | |
| beforeEach(async () => { | |
| await setupTmpConfigDir() | |
| service = new TeamService() | |
| }) | |
| afterEach(async () => { | |
| await cleanupTmpDir() | |
| }) | |
| // -------------------------------------------------------------------------- | |
| // listTeams | |
| // -------------------------------------------------------------------------- | |
| it('should return empty list when no teams exist', async () => { | |
| const teams = await service.listTeams() | |
| expect(teams).toEqual([]) | |
| }) | |
| it('should return empty list when teams directory does not exist', async () => { | |
| await fs.rm(path.join(tmpDir, 'teams'), { recursive: true, force: true }) | |
| const teams = await service.listTeams() | |
| expect(teams).toEqual([]) | |
| }) | |
| it('should list teams from config files', async () => { | |
| await writeTeamConfig('alpha', makeTeamConfig({ name: 'alpha' })) | |
| await writeTeamConfig('beta', makeTeamConfig({ name: 'beta', description: 'Beta team' })) | |
| const teams = await service.listTeams() | |
| expect(teams).toHaveLength(2) | |
| const names = teams.map((t) => t.name).sort() | |
| expect(names).toEqual(['alpha', 'beta']) | |
| }) | |
| it('should compute memberCount and activeMemberCount', async () => { | |
| await writeTeamConfig('gamma', makeTeamConfig({ name: 'gamma' })) | |
| const teams = await service.listTeams() | |
| expect(teams).toHaveLength(1) | |
| expect(teams[0]!.memberCount).toBe(2) | |
| expect(teams[0]!.activeMemberCount).toBe(1) // only lead is active | |
| }) | |
| it('should skip malformed team directories', async () => { | |
| // Create a team dir with invalid JSON | |
| const badDir = path.join(tmpDir, 'teams', 'bad-team') | |
| await fs.mkdir(badDir, { recursive: true }) | |
| await fs.writeFile(path.join(badDir, 'config.json'), 'not json', 'utf-8') | |
| // Also create a valid team | |
| await writeTeamConfig('good-team', makeTeamConfig({ name: 'good-team' })) | |
| const teams = await service.listTeams() | |
| expect(teams).toHaveLength(1) | |
| expect(teams[0]!.name).toBe('good-team') | |
| }) | |
| // -------------------------------------------------------------------------- | |
| // getTeam | |
| // -------------------------------------------------------------------------- | |
| it('should return team detail with members', async () => { | |
| await writeTeamConfig( | |
| 'detail-team', | |
| makeTeamConfig({ | |
| name: 'detail-team', | |
| leadSessionId: 'lead-session-xyz', | |
| }), | |
| ) | |
| const detail = await service.getTeam('detail-team') | |
| expect(detail.name).toBe('detail-team') | |
| expect(detail.leadAgentId).toBe('agent-lead') | |
| expect(detail.leadSessionId).toBe('lead-session-xyz') | |
| expect(detail.members).toHaveLength(2) | |
| expect(detail.members[0]!.agentId).toBe('agent-lead') | |
| expect(detail.members[1]!.agentId).toBe('agent-worker') | |
| }) | |
| it('should discover missing in-process members from subagent transcripts', async () => { | |
| await writeTeamConfig( | |
| 'subagent-team', | |
| makeTeamConfig({ | |
| name: 'subagent-team', | |
| leadSessionId: 'lead-session-subagents', | |
| members: [ | |
| { | |
| agentId: 'agent-lead', | |
| name: 'Lead Agent', | |
| agentType: 'lead', | |
| joinedAt: 1700000000000, | |
| tmuxPaneId: '%0', | |
| cwd: '/tmp/project', | |
| sessionId: 'session-lead-001', | |
| isActive: true, | |
| }, | |
| ], | |
| }), | |
| ) | |
| await writeSubagentTranscriptFile( | |
| '-tmp-project', | |
| 'lead-session-subagents', | |
| 'agent-1.jsonl', | |
| [ | |
| { | |
| agentName: 'security-reviewer', | |
| agentId: 'security-reviewer@subagent-team', | |
| timestamp: '2026-01-01T00:00:00.000Z', | |
| }, | |
| ], | |
| ) | |
| const detail = await service.getTeam('subagent-team') | |
| expect(detail.members.some((member) => member.name === 'security-reviewer')).toBe(true) | |
| }) | |
| it('should derive running status for active member', async () => { | |
| await writeTeamConfig('status-team', makeTeamConfig({ name: 'status-team' })) | |
| const detail = await service.getTeam('status-team') | |
| const lead = detail.members.find((m) => m.agentId === 'agent-lead')! | |
| expect(lead.status).toBe('running') | |
| }) | |
| it('should derive idle status for inactive member', async () => { | |
| await writeTeamConfig('status-team', makeTeamConfig({ name: 'status-team' })) | |
| const detail = await service.getTeam('status-team') | |
| const worker = detail.members.find((m) => m.agentId === 'agent-worker')! | |
| expect(worker.status).toBe('idle') | |
| }) | |
| it('should derive running status when isActive is undefined', async () => { | |
| const config = makeTeamConfig({ name: 'undef-team' }) | |
| // Remove isActive from the first member to simulate undefined | |
| delete (config.members[0] as Record<string, unknown>).isActive | |
| await writeTeamConfig('undef-team', config) | |
| const detail = await service.getTeam('undef-team') | |
| const lead = detail.members.find((m) => m.agentId === 'agent-lead')! | |
| expect(lead.status).toBe('running') | |
| }) | |
| it('should throw 404 for non-existent team', async () => { | |
| expect(service.getTeam('nonexistent')).rejects.toThrow('Team not found') | |
| }) | |
| // -------------------------------------------------------------------------- | |
| // getMemberTranscript | |
| // -------------------------------------------------------------------------- | |
| it('should return transcript messages for a member', async () => { | |
| await writeTeamConfig('transcript-team', makeTeamConfig({ name: 'transcript-team' })) | |
| // Write a mock transcript JSONL for the lead session | |
| await writeTranscriptFile('-tmp-project', 'session-lead-001', [ | |
| { | |
| type: 'file-history-snapshot', | |
| messageId: 'snap-1', | |
| snapshot: {}, | |
| }, | |
| { | |
| type: 'user', | |
| uuid: 'msg-user-1', | |
| message: { role: 'user', content: 'Hello team' }, | |
| timestamp: '2026-01-01T00:01:00.000Z', | |
| }, | |
| { | |
| type: 'assistant', | |
| uuid: 'msg-asst-1', | |
| message: { | |
| role: 'assistant', | |
| content: [{ type: 'text', text: 'Hi! Ready to help.' }], | |
| }, | |
| timestamp: '2026-01-01T00:02:00.000Z', | |
| }, | |
| ]) | |
| const messages = await service.getMemberTranscript( | |
| 'transcript-team', | |
| 'agent-lead', | |
| ) | |
| expect(messages).toHaveLength(2) | |
| expect(messages[0]!.type).toBe('user') | |
| expect(messages[0]!.id).toBe('msg-user-1') | |
| expect(messages[1]!.type).toBe('assistant') | |
| }) | |
| it('should return empty array when member has no sessionId', async () => { | |
| const config = makeTeamConfig({ name: 'no-session-team' }) | |
| delete (config.members[0] as Record<string, unknown>).sessionId | |
| await writeTeamConfig('no-session-team', config) | |
| const messages = await service.getMemberTranscript( | |
| 'no-session-team', | |
| 'agent-lead', | |
| ) | |
| expect(messages).toEqual([]) | |
| }) | |
| it('should return empty array when transcript file not found', async () => { | |
| await writeTeamConfig('no-file-team', makeTeamConfig({ name: 'no-file-team' })) | |
| // Don't write any transcript file | |
| const messages = await service.getMemberTranscript( | |
| 'no-file-team', | |
| 'agent-lead', | |
| ) | |
| expect(messages).toEqual([]) | |
| }) | |
| it('should throw 404 for unknown member', async () => { | |
| await writeTeamConfig('member-team', makeTeamConfig({ name: 'member-team' })) | |
| expect( | |
| service.getMemberTranscript('member-team', 'nonexistent-agent'), | |
| ).rejects.toThrow('Team member not found') | |
| }) | |
| it('should skip meta entries in transcript', async () => { | |
| await writeTeamConfig('meta-team', makeTeamConfig({ name: 'meta-team' })) | |
| await writeTranscriptFile('-tmp-project', 'session-lead-001', [ | |
| { | |
| type: 'user', | |
| uuid: 'msg-meta', | |
| message: { role: 'user', content: 'internal meta' }, | |
| isMeta: true, | |
| timestamp: '2026-01-01T00:00:30.000Z', | |
| }, | |
| { | |
| type: 'user', | |
| uuid: 'msg-real', | |
| message: { role: 'user', content: 'Real message' }, | |
| timestamp: '2026-01-01T00:01:00.000Z', | |
| }, | |
| ]) | |
| const messages = await service.getMemberTranscript('meta-team', 'agent-lead') | |
| expect(messages).toHaveLength(1) | |
| expect(messages[0]!.id).toBe('msg-real') | |
| }) | |
| // -------------------------------------------------------------------------- | |
| // sendMemberMessage | |
| // -------------------------------------------------------------------------- | |
| it('should write member messages into the teammate inbox', async () => { | |
| await writeTeamConfig('mailbox-team', makeTeamConfig({ name: 'mailbox-team' })) | |
| await service.sendMemberMessage( | |
| 'mailbox-team', | |
| 'agent-worker', | |
| 'Please review the latest diff', | |
| ) | |
| const inboxPath = path.join( | |
| tmpDir, | |
| 'teams', | |
| 'mailbox-team', | |
| 'inboxes', | |
| 'Worker-Agent.json', | |
| ) | |
| const rawInbox = await fs.readFile(inboxPath, 'utf-8') | |
| const inbox = JSON.parse(rawInbox) as Array<{ | |
| from: string | |
| text: string | |
| read: boolean | |
| }> | |
| expect(inbox).toHaveLength(1) | |
| expect(inbox[0]).toMatchObject({ | |
| from: 'user', | |
| text: 'Please review the latest diff', | |
| read: false, | |
| }) | |
| }) | |
| it('should send messages to inbox-discovered members', async () => { | |
| await writeTeamConfig('inbox-team', makeTeamConfig({ name: 'inbox-team' })) | |
| const inboxDir = path.join(tmpDir, 'teams', 'inbox-team', 'inboxes') | |
| await fs.mkdir(inboxDir, { recursive: true }) | |
| await fs.writeFile(path.join(inboxDir, 'security-reviewer.json'), '[]', 'utf-8') | |
| await service.sendMemberMessage( | |
| 'inbox-team', | |
| 'security-reviewer@inbox-team', | |
| 'Check the auth changes', | |
| ) | |
| const rawInbox = await fs.readFile( | |
| path.join(inboxDir, 'security-reviewer.json'), | |
| 'utf-8', | |
| ) | |
| const inbox = JSON.parse(rawInbox) as Array<{ text: string }> | |
| expect(inbox.at(-1)?.text).toBe('Check the auth changes') | |
| }) | |
| // -------------------------------------------------------------------------- | |
| // deleteTeam | |
| // -------------------------------------------------------------------------- | |
| it('should delete a team with no active members', async () => { | |
| const config = makeTeamConfig({ name: 'deletable' }) | |
| // Set all members to inactive | |
| for (const member of config.members) { | |
| ;(member as Record<string, unknown>).isActive = false | |
| } | |
| await writeTeamConfig('deletable', config) | |
| await service.deleteTeam('deletable') | |
| // Team dir should be gone | |
| const teamDir = path.join(tmpDir, 'teams', 'deletable') | |
| expect(fs.access(teamDir)).rejects.toThrow() | |
| }) | |
| it('should refuse to delete a team with active members', async () => { | |
| await writeTeamConfig('active-team', makeTeamConfig({ name: 'active-team' })) | |
| expect(service.deleteTeam('active-team')).rejects.toThrow( | |
| 'has active members', | |
| ) | |
| }) | |
| it('should throw 404 when deleting non-existent team', async () => { | |
| expect(service.deleteTeam('ghost')).rejects.toThrow('Team not found') | |
| }) | |
| }) | |
| // ============================================================================ | |
| // Teams API integration tests | |
| // ============================================================================ | |
| describe('Teams API', () => { | |
| let baseUrl: string | |
| let server: ReturnType<typeof Bun.serve> | null = null | |
| beforeEach(async () => { | |
| await setupTmpConfigDir() | |
| service = new TeamService() | |
| const { handleTeamsApi } = await import('../api/teams.js') | |
| server = Bun.serve({ | |
| port: 0, | |
| hostname: '127.0.0.1', | |
| async fetch(req) { | |
| const url = new URL(req.url) | |
| const segments = url.pathname.split('/').filter(Boolean) | |
| if (segments[0] === 'api' && segments[1] === 'teams') { | |
| return handleTeamsApi(req, url, segments) | |
| } | |
| return new Response('Not Found', { status: 404 }) | |
| }, | |
| }) | |
| baseUrl = `http://127.0.0.1:${server.port}` | |
| }) | |
| afterEach(async () => { | |
| if (server) { | |
| server.stop(true) | |
| server = null | |
| } | |
| await cleanupTmpDir() | |
| }) | |
| it('GET /api/teams should return empty list', async () => { | |
| const res = await fetch(`${baseUrl}/api/teams`) | |
| expect(res.status).toBe(200) | |
| const body = (await res.json()) as { teams: unknown[] } | |
| expect(body.teams).toEqual([]) | |
| }) | |
| it('GET /api/teams should list teams', async () => { | |
| await writeTeamConfig('api-team', makeTeamConfig({ name: 'api-team' })) | |
| const res = await fetch(`${baseUrl}/api/teams`) | |
| expect(res.status).toBe(200) | |
| const body = (await res.json()) as { teams: Array<{ name: string }> } | |
| expect(body.teams).toHaveLength(1) | |
| expect(body.teams[0]!.name).toBe('api-team') | |
| }) | |
| it('GET /api/teams/:name should return team detail', async () => { | |
| await writeTeamConfig( | |
| 'detail', | |
| makeTeamConfig({ name: 'detail', leadSessionId: 'leader-session-id' }), | |
| ) | |
| const res = await fetch(`${baseUrl}/api/teams/detail`) | |
| expect(res.status).toBe(200) | |
| const body = (await res.json()) as { | |
| name: string | |
| leadAgentId: string | |
| leadSessionId?: string | |
| members: Array<{ agentId: string }> | |
| } | |
| expect(body.name).toBe('detail') | |
| expect(body.leadAgentId).toBe('agent-lead') | |
| expect(body.leadSessionId).toBe('leader-session-id') | |
| expect(body.members).toHaveLength(2) | |
| }) | |
| it('GET /api/teams/:name should 404 for unknown team', async () => { | |
| const res = await fetch(`${baseUrl}/api/teams/nonexistent`) | |
| expect(res.status).toBe(404) | |
| }) | |
| it('GET /api/teams/:name/members/:id/transcript should return messages', async () => { | |
| await writeTeamConfig('t-team', makeTeamConfig({ name: 't-team' })) | |
| await writeTranscriptFile('-tmp-project', 'session-lead-001', [ | |
| { | |
| type: 'user', | |
| uuid: 'u1', | |
| message: { role: 'user', content: 'Hello' }, | |
| timestamp: '2026-01-01T00:01:00.000Z', | |
| }, | |
| ]) | |
| const res = await fetch( | |
| `${baseUrl}/api/teams/t-team/members/agent-lead/transcript`, | |
| ) | |
| expect(res.status).toBe(200) | |
| const body = (await res.json()) as { | |
| messages: Array<{ id: string; type: string }> | |
| } | |
| expect(body.messages).toHaveLength(1) | |
| expect(body.messages[0]!.type).toBe('user') | |
| }) | |
| it('GET /api/teams/:name/members/:id/transcript should 404 for unknown member', async () => { | |
| await writeTeamConfig('t2-team', makeTeamConfig({ name: 't2-team' })) | |
| const res = await fetch( | |
| `${baseUrl}/api/teams/t2-team/members/unknown-agent/transcript`, | |
| ) | |
| expect(res.status).toBe(404) | |
| }) | |
| it('POST /api/teams/:name/members/:id/messages should enqueue a mailbox message', async () => { | |
| await writeTeamConfig('send-team', makeTeamConfig({ name: 'send-team' })) | |
| const res = await fetch( | |
| `${baseUrl}/api/teams/send-team/members/agent-worker/messages`, | |
| { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ content: 'Please continue with the failing test' }), | |
| }, | |
| ) | |
| expect(res.status).toBe(200) | |
| const body = (await res.json()) as { ok: boolean } | |
| expect(body.ok).toBe(true) | |
| const rawInbox = await fs.readFile( | |
| path.join(tmpDir, 'teams', 'send-team', 'inboxes', 'Worker-Agent.json'), | |
| 'utf-8', | |
| ) | |
| const inbox = JSON.parse(rawInbox) as Array<{ text: string }> | |
| expect(inbox.at(-1)?.text).toBe('Please continue with the failing test') | |
| }) | |
| it('DELETE /api/teams/:name should delete team', async () => { | |
| const config = makeTeamConfig({ name: 'del-team' }) | |
| for (const member of (config as { members: Array<Record<string, unknown>> }).members) { | |
| member.isActive = false | |
| } | |
| await writeTeamConfig('del-team', config) | |
| const res = await fetch(`${baseUrl}/api/teams/del-team`, { | |
| method: 'DELETE', | |
| }) | |
| expect(res.status).toBe(200) | |
| const body = (await res.json()) as { ok: boolean } | |
| expect(body.ok).toBe(true) | |
| // Verify it's gone | |
| const res2 = await fetch(`${baseUrl}/api/teams/del-team`) | |
| expect(res2.status).toBe(404) | |
| }) | |
| it('DELETE /api/teams/:name should 409 when team has active members', async () => { | |
| await writeTeamConfig('active', makeTeamConfig({ name: 'active' })) | |
| const res = await fetch(`${baseUrl}/api/teams/active`, { | |
| method: 'DELETE', | |
| }) | |
| expect(res.status).toBe(409) | |
| }) | |
| it('POST /api/teams should return 405', async () => { | |
| const res = await fetch(`${baseUrl}/api/teams`, { method: 'POST' }) | |
| expect(res.status).toBe(405) | |
| }) | |
| }) | |