366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
import React, { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
|
|
|
vi.mock('@renderer/components/ui/badge', () => ({
|
|
Badge: ({
|
|
children,
|
|
className,
|
|
title,
|
|
}: {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
title?: string;
|
|
}) => React.createElement('span', { className, title }, children),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/tooltip', () => ({
|
|
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(React.Fragment, null, children),
|
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(React.Fragment, null, children),
|
|
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement('div', null, children),
|
|
}));
|
|
|
|
vi.mock('@renderer/hooks/useTheme', () => ({
|
|
useTheme: () => ({ isLight: false }),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/members/CurrentTaskIndicator', () => ({
|
|
CurrentTaskIndicator: () => null,
|
|
}));
|
|
|
|
import { MemberCard } from '@renderer/components/team/members/MemberCard';
|
|
|
|
const member: ResolvedTeamMember = {
|
|
name: 'alice',
|
|
status: 'unknown',
|
|
taskCount: 0,
|
|
currentTaskId: null,
|
|
lastActiveAt: null,
|
|
messageCount: 0,
|
|
color: 'blue',
|
|
agentType: 'reviewer',
|
|
role: 'Reviewer',
|
|
providerId: 'gemini',
|
|
removedAt: undefined,
|
|
};
|
|
|
|
const currentTask: TeamTaskWithKanban = {
|
|
id: 'task-1',
|
|
displayId: 'abc12345',
|
|
subject: 'Build calculator UI',
|
|
status: 'in_progress',
|
|
} as unknown as TeamTaskWithKanban;
|
|
|
|
describe('MemberCard starting-state visuals', () => {
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
it('shows runtime summary while keeping the starting treatment after provisioning stops', 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(MemberCard, {
|
|
member,
|
|
memberColor: 'blue',
|
|
runtimeSummary: 'Anthropic · haiku · Medium',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'spawning',
|
|
spawnLaunchState: 'starting',
|
|
spawnRuntimeAlive: false,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('starting');
|
|
expect(host.textContent).toContain('Anthropic · haiku · Medium');
|
|
expect(host.querySelector('.member-waiting-shimmer')).not.toBeNull();
|
|
expect(host.querySelectorAll('.skeleton-shimmer').length).toBe(0);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('shows provider retry advisory instead of plain online while bootstrap contact is still pending', 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(MemberCard, {
|
|
member: {
|
|
...member,
|
|
runtimeAdvisory: {
|
|
kind: 'sdk_retrying',
|
|
observedAt: '2026-04-07T09:00:00.000Z',
|
|
retryUntil: '2099-04-07T09:00:45.000Z',
|
|
retryDelayMs: 45_000,
|
|
reasonCode: 'quota_exhausted',
|
|
},
|
|
},
|
|
memberColor: 'blue',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'online',
|
|
spawnLaunchState: 'runtime_pending_bootstrap',
|
|
spawnRuntimeAlive: true,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('Gemini quota retry');
|
|
expect(host.textContent).not.toContain('online');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('shows a full loading badge for connecting teammates during provisioning', 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(MemberCard, {
|
|
member,
|
|
memberColor: 'blue',
|
|
isTeamAlive: false,
|
|
isTeamProvisioning: true,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('connecting');
|
|
expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('keeps runtime retry visible even while the teammate already has an active task', 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(MemberCard, {
|
|
member: {
|
|
...member,
|
|
currentTaskId: currentTask.id,
|
|
runtimeAdvisory: {
|
|
kind: 'sdk_retrying',
|
|
observedAt: '2026-04-07T09:00:00.000Z',
|
|
retryUntil: '2099-04-07T09:00:45.000Z',
|
|
retryDelayMs: 45_000,
|
|
reasonCode: 'quota_exhausted',
|
|
message: 'Gemini cli backend error: capacity exceeded.',
|
|
},
|
|
},
|
|
memberColor: 'blue',
|
|
currentTask,
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'online',
|
|
spawnLaunchState: 'confirmed_alive',
|
|
spawnRuntimeAlive: true,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('Gemini quota retry');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('keeps the starting treatment and runtime summary visible while a runtime is still joining', 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(MemberCard, {
|
|
member,
|
|
memberColor: 'blue',
|
|
runtimeSummary: 'Anthropic · sonnet · Medium',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
isLaunchSettling: true,
|
|
spawnStatus: 'online',
|
|
spawnLaunchState: 'runtime_pending_bootstrap',
|
|
spawnRuntimeAlive: true,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('starting');
|
|
expect(host.textContent).toContain('Anthropic · sonnet · Medium');
|
|
expect(host.textContent).not.toContain('online');
|
|
expect(host.querySelector('.member-waiting-shimmer')).not.toBeNull();
|
|
expect(host.querySelectorAll('.skeleton-shimmer').length).toBe(0);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('shows an awaiting permission badge for teammates blocked on runtime permissions', 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(MemberCard, {
|
|
member,
|
|
memberColor: 'blue',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'online',
|
|
spawnLaunchState: 'runtime_pending_permission',
|
|
spawnRuntimeAlive: true,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('awaiting permission');
|
|
expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull();
|
|
expect(host.querySelector('.member-waiting-shimmer')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('shows ready instead of idle for confirmed teammates while launch is still settling', 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(MemberCard, {
|
|
member,
|
|
memberColor: 'blue',
|
|
runtimeSummary: 'Anthropic · sonnet · Medium',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
isLaunchSettling: true,
|
|
spawnStatus: 'online',
|
|
spawnLaunchState: 'confirmed_alive',
|
|
spawnRuntimeAlive: true,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('ready');
|
|
expect(host.textContent).not.toContain('idle');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('shows member color on the avatar ring instead of a colored card rail', 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(MemberCard, {
|
|
member,
|
|
memberColor: 'blue',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const img = host.querySelector('img');
|
|
const avatarRing = img?.parentElement;
|
|
const clickableCard = host.querySelector('[role="button"]') as HTMLElement | null;
|
|
|
|
expect(avatarRing).not.toBeNull();
|
|
expect(avatarRing?.style.borderColor).toBe('#3b82f6');
|
|
expect(clickableCard?.style.borderLeft).toBe('');
|
|
expect(clickableCard?.style.background).toBe('');
|
|
expect(clickableCard?.className).not.toContain('px-');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('renders memory after the role label in the compact runtime summary row', 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(MemberCard, {
|
|
member,
|
|
memberColor: 'blue',
|
|
runtimeSummary: '5.2 · Medium · 238.3 MB',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const text = host.textContent ?? '';
|
|
expect(text).toContain('5.2 · Medium');
|
|
expect(text).toContain('Reviewer');
|
|
expect(text).toContain('238.3 MB');
|
|
expect(text.indexOf('Reviewer')).toBeLessThan(text.indexOf('238.3 MB'));
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
});
|