agent-ecosystem/test/main/http/teams.test.ts

242 lines
7.3 KiB
TypeScript

import Fastify from 'fastify';
import { describe, expect, it, vi } from 'vitest';
import { registerTeamRoutes } from '@main/http/teams';
import type { HttpServices } from '@main/http';
import type {
TeamLaunchRequest,
TeamLaunchResponse,
TeamProvisioningProgress,
TeamRuntimeState,
} from '@shared/types/team';
describe('HTTP team runtime routes', () => {
function createServicesMock() {
const launchTeam = vi.fn<
(request: TeamLaunchRequest, onProgress: (progress: TeamProvisioningProgress) => void) => Promise<TeamLaunchResponse>
>();
const getRuntimeState = vi.fn<(teamName: string) => Promise<TeamRuntimeState>>();
const getProvisioningStatus = vi.fn<(runId: string) => Promise<TeamProvisioningProgress>>();
const stopTeam = vi.fn<(teamName: string) => void>();
const getAliveTeams = vi.fn<() => string[]>();
const teamProvisioningService = {
launchTeam,
getRuntimeState,
getProvisioningStatus,
stopTeam,
getAliveTeams,
} as Pick<
NonNullable<HttpServices['teamProvisioningService']>,
'launchTeam' | 'getRuntimeState' | 'getProvisioningStatus' | 'stopTeam' | 'getAliveTeams'
> as HttpServices['teamProvisioningService'];
const services = {
projectScanner: {} as HttpServices['projectScanner'],
sessionParser: {} as HttpServices['sessionParser'],
subagentResolver: {} as HttpServices['subagentResolver'],
chunkBuilder: {} as HttpServices['chunkBuilder'],
dataCache: {} as HttpServices['dataCache'],
updaterService: {} as HttpServices['updaterService'],
sshConnectionManager: {} as HttpServices['sshConnectionManager'],
teamProvisioningService,
} satisfies HttpServices;
return {
services,
launchTeam,
getRuntimeState,
getProvisioningStatus,
stopTeam,
getAliveTeams,
};
}
async function createApp() {
const app = Fastify();
const mocks = createServicesMock();
registerTeamRoutes(app, mocks.services);
await app.ready();
return { app, ...mocks };
}
it('launches a team with validated request payload', async () => {
const { app, launchTeam } = await createApp();
launchTeam.mockResolvedValue({ runId: 'run-1' });
try {
const response = await app.inject({
method: 'POST',
url: '/api/teams/demo-team/launch',
payload: {
cwd: '/tmp/project',
prompt: 'Resume work',
skipPermissions: false,
clearContext: true,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ runId: 'run-1' });
expect(launchTeam).toHaveBeenCalledWith(
{
teamName: 'demo-team',
cwd: '/tmp/project',
prompt: 'Resume work',
providerId: 'anthropic',
skipPermissions: false,
clearContext: true,
},
expect.any(Function)
);
} finally {
await app.close();
}
});
it('rejects launch requests with non-absolute cwd', async () => {
const { app, launchTeam } = await createApp();
try {
const response = await app.inject({
method: 'POST',
url: '/api/teams/demo-team/launch',
payload: {
cwd: 'relative/path',
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: 'cwd must be an absolute path' });
expect(launchTeam).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
it('returns runtime state, provisioning status, and stop results', async () => {
const { app, getRuntimeState, getProvisioningStatus, stopTeam, getAliveTeams } = await createApp();
getRuntimeState
.mockResolvedValueOnce({
teamName: 'demo-team',
isAlive: true,
runId: 'run-2',
progress: {
runId: 'run-2',
teamName: 'demo-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T00:00:00.000Z',
updatedAt: '2026-03-12T00:00:01.000Z',
},
})
.mockResolvedValueOnce({
teamName: 'demo-team',
isAlive: false,
runId: null,
progress: null,
})
.mockResolvedValueOnce({
teamName: 'demo-team',
isAlive: true,
runId: 'run-2',
progress: {
runId: 'run-2',
teamName: 'demo-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T00:00:00.000Z',
updatedAt: '2026-03-12T00:00:01.000Z',
},
});
getProvisioningStatus.mockResolvedValue({
runId: 'run-2',
teamName: 'demo-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T00:00:00.000Z',
updatedAt: '2026-03-12T00:00:01.000Z',
});
getAliveTeams.mockReturnValue(['demo-team']);
try {
const runtimeResponse = await app.inject({
method: 'GET',
url: '/api/teams/demo-team/runtime',
});
expect(runtimeResponse.statusCode).toBe(200);
expect(runtimeResponse.json().isAlive).toBe(true);
const provisioningResponse = await app.inject({
method: 'GET',
url: '/api/teams/provisioning/run-2',
});
expect(provisioningResponse.statusCode).toBe(200);
expect(provisioningResponse.json().runId).toBe('run-2');
const stopResponse = await app.inject({
method: 'POST',
url: '/api/teams/demo-team/stop',
});
expect(stopResponse.statusCode).toBe(200);
expect(stopResponse.json()).toEqual({
teamName: 'demo-team',
isAlive: false,
runId: null,
progress: null,
});
expect(stopTeam).toHaveBeenCalledWith('demo-team');
const aliveResponse = await app.inject({
method: 'GET',
url: '/api/teams/runtime/alive',
});
expect(aliveResponse.statusCode).toBe(200);
expect(aliveResponse.json()).toEqual([
{
teamName: 'demo-team',
isAlive: true,
runId: 'run-2',
progress: {
runId: 'run-2',
teamName: 'demo-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T00:00:00.000Z',
updatedAt: '2026-03-12T00:00:01.000Z',
},
},
]);
} finally {
await app.close();
}
});
it('returns 501 when team runtime routes are registered without a runtime service', async () => {
const app = Fastify();
registerTeamRoutes(
app,
{
projectScanner: {} as HttpServices['projectScanner'],
sessionParser: {} as HttpServices['sessionParser'],
subagentResolver: {} as HttpServices['subagentResolver'],
chunkBuilder: {} as HttpServices['chunkBuilder'],
dataCache: {} as HttpServices['dataCache'],
updaterService: {} as HttpServices['updaterService'],
sshConnectionManager: {} as HttpServices['sshConnectionManager'],
} satisfies HttpServices
);
await app.ready();
try {
const response = await app.inject({
method: 'GET',
url: '/api/teams/runtime/alive',
});
expect(response.statusCode).toBe(501);
expect(response.json()).toEqual({ error: 'Team runtime control is not available in this mode' });
} finally {
await app.close();
}
});
});