532 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|