agent-ecosystem/test/renderer/components/team/members/MemberList.test.ts

532 lines
15 KiB
TypeScript

import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
vi.mock('@renderer/components/team/members/MemberCard', () => ({
MemberCard: ({
member,
spawnError,
spawnStatus,
spawnLaunchState,
currentTask,
reviewTask,
onRestartMember,
onSkipMemberForLaunch,
}: {
member: ResolvedTeamMember;
spawnError?: string;
spawnStatus?: string;
spawnLaunchState?: string;
currentTask?: TeamTaskWithKanban | null;
reviewTask?: TeamTaskWithKanban | null;
onRestartMember?: (memberName: string) => void;
onSkipMemberForLaunch?: (memberName: string) => void;
}) =>
React.createElement(
'div',
{ 'data-testid': `member-${member.name}` },
spawnError ?? '',
currentTask
? React.createElement('span', { 'data-testid': `current-${member.name}` }, currentTask.id)
: null,
reviewTask
? React.createElement('span', { 'data-testid': `review-${member.name}` }, reviewTask.id)
: null,
onRestartMember && (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start')
? React.createElement(
'button',
{
'data-testid': `retry-${member.name}`,
type: 'button',
onClick: () => onRestartMember(member.name),
},
'retry'
)
: null,
onSkipMemberForLaunch && (spawnStatus === 'error' || spawnLaunchState === 'failed_to_start')
? React.createElement(
'button',
{
'data-testid': `skip-${member.name}`,
type: 'button',
onClick: () => onSkipMemberForLaunch(member.name),
},
'skip'
)
: null
),
}));
import { MemberList } from '@renderer/components/team/members/MemberList';
const member: ResolvedTeamMember = {
name: 'bob',
status: 'unknown',
taskCount: 0,
currentTaskId: null,
lastActiveAt: null,
messageCount: 0,
color: 'blue',
agentType: 'developer',
role: 'Developer',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
removedAt: undefined,
};
function failedSpawnStatus(reason: string): MemberSpawnStatusEntry {
return {
status: 'error',
launchState: 'failed_to_start',
updatedAt: '2026-04-23T10:00:00.000Z',
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: reason,
agentToolAccepted: false,
};
}
function offlineSpawnStatus(): MemberSpawnStatusEntry {
return {
status: 'offline',
launchState: 'confirmed_alive',
updatedAt: '2026-04-23T10:00:00.000Z',
runtimeAlive: false,
bootstrapConfirmed: false,
};
}
function activeTask(id = 'task-active'): TeamTaskWithKanban {
return {
id,
subject: 'Active task',
status: 'in_progress',
};
}
describe('MemberList spawn-status memoization', () => {
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class ResizeObserver {
observe(): void {}
disconnect(): void {}
}
);
});
afterEach(() => {
vi.unstubAllGlobals();
document.body.innerHTML = '';
});
it('does not label an empty roster as solo when the team summary still expects teammates', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberList, {
members: [],
expectedTeammateCount: 2,
isRosterLoading: true,
isTeamAlive: false,
})
);
await Promise.resolve();
});
expect(host.querySelector('[aria-label="Loading team members"]')).not.toBeNull();
expect(host.textContent).not.toContain('Team members are loading');
expect(host.textContent).not.toContain('Solo team');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not render a lead-only roster while expected teammates are still loading', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberList, {
members: [
{
...member,
name: 'team-lead',
agentType: 'team-lead',
role: 'Team Lead',
},
],
expectedTeammateCount: 2,
isRosterLoading: true,
isTeamAlive: false,
})
);
await Promise.resolve();
});
expect(host.querySelector('[aria-label="Loading team members"]')).not.toBeNull();
expect(host.textContent).not.toContain('Team members are loading');
expect(host.querySelector('[data-testid="member-team-lead"]')).toBeNull();
expect(host.textContent).not.toContain('Solo team');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not keep a skeleton for a settled count-only roster summary', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberList, {
members: [],
expectedTeammateCount: 2,
isRosterLoading: false,
isTeamProvisioning: false,
isTeamAlive: false,
})
);
await Promise.resolve();
});
expect(host.querySelector('[aria-label="Loading team members"]')).toBeNull();
expect(host.textContent).toContain('Member roster unavailable');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not keep a skeleton for an offline team with stale settling metadata', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberList, {
members: [],
expectedTeammateCount: 2,
isLaunchSettling: true,
isRosterLoading: false,
isTeamProvisioning: false,
isTeamAlive: false,
})
);
await Promise.resolve();
});
expect(host.querySelector('[aria-label="Loading team members"]')).toBeNull();
expect(host.textContent).toContain('Member roster unavailable');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('renders the lead card after loading settles even when summary still expects teammates', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberList, {
members: [
{
...member,
name: 'team-lead',
agentType: 'team-lead',
role: 'Team Lead',
},
],
expectedTeammateCount: 2,
isRosterLoading: false,
isTeamProvisioning: false,
isTeamAlive: false,
})
);
await Promise.resolve();
});
expect(host.querySelector('[aria-label="Loading team members"]')).toBeNull();
expect(host.querySelector('[data-testid="member-team-lead"]')).not.toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('rerenders cards when only the hard failure reason changes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const members = [member];
await act(async () => {
root.render(
React.createElement(MemberList, {
members,
isTeamAlive: true,
memberSpawnStatuses: new Map([['bob', failedSpawnStatus('initial OpenCode failure')]]),
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('initial OpenCode failure');
await act(async () => {
root.render(
React.createElement(MemberList, {
members,
isTeamAlive: true,
memberSpawnStatuses: new Map([['bob', failedSpawnStatus('updated OpenCode failure')]]),
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('updated OpenCode failure');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('passes retry callbacks to failed member cards and rerenders when the callback changes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const members = [member];
const firstRestart = vi.fn();
const secondRestart = vi.fn();
const spawnStatuses = new Map([['bob', failedSpawnStatus('OpenCode failed')]]);
await act(async () => {
root.render(
React.createElement(MemberList, {
members,
isTeamAlive: true,
memberSpawnStatuses: spawnStatuses,
onRestartMember: firstRestart,
})
);
await Promise.resolve();
});
const firstRetry = host.querySelector('[data-testid="retry-bob"]') as HTMLButtonElement;
expect(firstRetry).not.toBeNull();
await act(async () => {
firstRetry.click();
await Promise.resolve();
});
expect(firstRestart).toHaveBeenCalledWith('bob');
await act(async () => {
root.render(
React.createElement(MemberList, {
members,
isTeamAlive: true,
memberSpawnStatuses: spawnStatuses,
onRestartMember: secondRestart,
})
);
await Promise.resolve();
});
const secondRetry = host.querySelector('[data-testid="retry-bob"]') as HTMLButtonElement;
expect(secondRetry).not.toBeNull();
await act(async () => {
secondRetry.click();
await Promise.resolve();
});
expect(secondRestart).toHaveBeenCalledWith('bob');
expect(firstRestart).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('shows a review task when a stale currentTaskId points at the same non-active task', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const members: ResolvedTeamMember[] = [{ ...member, currentTaskId: 'task-review' }];
const reviewTask: TeamTaskWithKanban = {
id: 'task-review',
subject: 'Review this',
status: 'completed',
reviewState: 'review',
kanbanColumn: 'review',
reviewer: 'bob',
};
await act(async () => {
root.render(
React.createElement(MemberList, {
members,
isTeamAlive: true,
taskMap: new Map([[reviewTask.id, reviewTask]]),
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="current-bob"]')).toBeNull();
expect(host.querySelector('[data-testid="review-bob"]')?.textContent).toBe('task-review');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not pass active current tasks to cards while the whole team is offline', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const task = activeTask();
const members: ResolvedTeamMember[] = [{ ...member, currentTaskId: task.id }];
await act(async () => {
root.render(
React.createElement(MemberList, {
members,
isTeamAlive: false,
taskMap: new Map([[task.id, task]]),
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="current-bob"]')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not pass active current tasks to cards for individually offline members', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const task = activeTask();
const members: ResolvedTeamMember[] = [{ ...member, currentTaskId: task.id }];
await act(async () => {
root.render(
React.createElement(MemberList, {
members,
isTeamAlive: true,
taskMap: new Map([[task.id, task]]),
memberSpawnStatuses: new Map([['bob', offlineSpawnStatus()]]),
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="current-bob"]')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('passes skip callbacks to failed member cards and rerenders when the callback changes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const members = [member];
const firstSkip = vi.fn();
const secondSkip = vi.fn();
const spawnStatuses = new Map([['bob', failedSpawnStatus('OpenCode failed')]]);
await act(async () => {
root.render(
React.createElement(MemberList, {
members,
isTeamAlive: true,
memberSpawnStatuses: spawnStatuses,
onSkipMemberForLaunch: firstSkip,
})
);
await Promise.resolve();
});
const firstButton = host.querySelector('[data-testid="skip-bob"]') as HTMLButtonElement;
expect(firstButton).not.toBeNull();
await act(async () => {
firstButton.click();
await Promise.resolve();
});
expect(firstSkip).toHaveBeenCalledWith('bob');
await act(async () => {
root.render(
React.createElement(MemberList, {
members,
isTeamAlive: true,
memberSpawnStatuses: spawnStatuses,
onSkipMemberForLaunch: secondSkip,
})
);
await Promise.resolve();
});
const secondButton = host.querySelector('[data-testid="skip-bob"]') as HTMLButtonElement;
expect(secondButton).not.toBeNull();
await act(async () => {
secondButton.click();
await Promise.resolve();
});
expect(secondSkip).toHaveBeenCalledWith('bob');
expect(firstSkip).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});