1683 lines
51 KiB
TypeScript
1683 lines
51 KiB
TypeScript
import React, { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
|
|
|
const hoisted = vi.hoisted(() => ({
|
|
openExternal: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@renderer/api', () => ({
|
|
api: {
|
|
openExternal: hoisted.openExternal,
|
|
},
|
|
}));
|
|
|
|
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', () => ({
|
|
TooltipProvider: ({
|
|
children,
|
|
delayDuration,
|
|
skipDelayDuration,
|
|
}: {
|
|
children: React.ReactNode;
|
|
delayDuration?: number;
|
|
skipDelayDuration?: number;
|
|
}) =>
|
|
React.createElement(
|
|
'div',
|
|
{
|
|
'data-testid': 'tooltip-provider',
|
|
'data-delay-duration': delayDuration,
|
|
'data-skip-delay-duration': skipDelayDuration,
|
|
},
|
|
children
|
|
),
|
|
Tooltip: ({
|
|
children,
|
|
delayDuration,
|
|
open,
|
|
}: {
|
|
children: React.ReactNode;
|
|
delayDuration?: number;
|
|
open?: boolean;
|
|
}) =>
|
|
React.createElement(
|
|
'div',
|
|
{
|
|
'data-testid': 'tooltip-root',
|
|
'data-delay-duration': delayDuration,
|
|
'data-open': open,
|
|
},
|
|
children
|
|
),
|
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(React.Fragment, null, children),
|
|
TooltipContent: ({
|
|
children,
|
|
className,
|
|
}: {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}) => React.createElement('div', { className, 'data-testid': 'tooltip-content' }, 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;
|
|
|
|
const failedSpawnEntry: MemberSpawnStatusEntry = {
|
|
status: 'error',
|
|
launchState: 'failed_to_start',
|
|
runtimeAlive: false,
|
|
bootstrapConfirmed: false,
|
|
hardFailure: true,
|
|
hardFailureReason: 'spawn failed',
|
|
agentToolAccepted: false,
|
|
livenessKind: 'not_found',
|
|
runtimeDiagnostic: 'spawn failed',
|
|
runtimeDiagnosticSeverity: 'error',
|
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
|
};
|
|
|
|
const skippedSpawnEntry: MemberSpawnStatusEntry = {
|
|
status: 'skipped',
|
|
launchState: 'skipped_for_launch',
|
|
runtimeAlive: false,
|
|
bootstrapConfirmed: false,
|
|
hardFailure: false,
|
|
agentToolAccepted: false,
|
|
skippedForLaunch: true,
|
|
skipReason: 'Skipped by user after launch failure: spawn failed',
|
|
skippedAt: '2026-04-24T12:01:00.000Z',
|
|
updatedAt: '2026-04-24T12:01:00.000Z',
|
|
};
|
|
|
|
describe('MemberCard starting-state visuals', () => {
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
hoisted.openExternal.mockReset();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
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('shows timed OpenCode quota advisory with a relaunch action', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const onRestartMember = vi.fn();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(MemberCard, {
|
|
member: {
|
|
...member,
|
|
providerId: 'opencode',
|
|
runtimeAdvisory: {
|
|
kind: 'api_error',
|
|
observedAt: '2026-05-17T21:44:34.000Z',
|
|
retryUntil: '2099-05-18T00:00:00.000Z',
|
|
retryDelayMs: 8_000,
|
|
reasonCode: 'quota_exhausted',
|
|
message: 'Free usage exceeded, subscribe to Go https://opencode.ai/go',
|
|
},
|
|
},
|
|
memberColor: 'blue',
|
|
currentTask,
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'online',
|
|
spawnLaunchState: 'confirmed_alive',
|
|
spawnRuntimeAlive: true,
|
|
onRestartMember,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('OpenCode quota error · retry');
|
|
const relaunchButton = host.querySelector('button[aria-label="Relaunch OpenCode"]');
|
|
expect(relaunchButton).not.toBeNull();
|
|
expect(host.querySelector('button[aria-label="Copy diagnostics"]')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
(relaunchButton as HTMLButtonElement).click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(onRestartMember).toHaveBeenCalledWith('alice');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('shows the OpenCode advisory relaunch action in awaiting-reply rows', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const onRestartMember = vi.fn();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(MemberCard, {
|
|
member: {
|
|
...member,
|
|
providerId: 'opencode',
|
|
runtimeAdvisory: {
|
|
kind: 'api_error',
|
|
observedAt: '2026-05-17T21:44:34.000Z',
|
|
retryUntil: '2099-05-18T00:00:00.000Z',
|
|
retryDelayMs: 8_000,
|
|
reasonCode: 'quota_exhausted',
|
|
message: 'Free usage exceeded, subscribe to Go https://opencode.ai/go',
|
|
},
|
|
},
|
|
memberColor: 'blue',
|
|
isAwaitingReply: true,
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'online',
|
|
spawnLaunchState: 'confirmed_alive',
|
|
spawnRuntimeAlive: true,
|
|
onRestartMember,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('OpenCode quota error · retry');
|
|
const relaunchButton = host.querySelector('button[aria-label="Relaunch OpenCode"]');
|
|
expect(relaunchButton).not.toBeNull();
|
|
expect(host.querySelector('button[aria-label="Copy diagnostics"]')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
(relaunchButton as HTMLButtonElement).click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(onRestartMember).toHaveBeenCalledWith('alice');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('does not show the OpenCode advisory relaunch action for protocol-proof warnings', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const onRestartMember = vi.fn();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(MemberCard, {
|
|
member: {
|
|
...member,
|
|
providerId: 'opencode',
|
|
runtimeAdvisory: {
|
|
kind: 'api_error',
|
|
observedAt: '2026-05-17T21:44:34.000Z',
|
|
reasonCode: 'protocol_proof_missing',
|
|
message: 'non_visible_tool_without_task_progress',
|
|
},
|
|
},
|
|
memberColor: 'blue',
|
|
currentTask,
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'online',
|
|
spawnLaunchState: 'confirmed_alive',
|
|
spawnRuntimeAlive: true,
|
|
onRestartMember,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('OpenCode proof missing');
|
|
expect(host.querySelector('button[aria-label="Relaunch OpenCode"]')).toBeNull();
|
|
expect(host.querySelector('button[aria-label="Copy diagnostics"]')).toBeNull();
|
|
expect(onRestartMember).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('keeps runtime-pending launch status visible even when the teammate 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,
|
|
},
|
|
memberColor: 'blue',
|
|
currentTask,
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'online',
|
|
spawnLaunchState: 'runtime_pending_bootstrap',
|
|
spawnRuntimeAlive: true,
|
|
spawnLivenessSource: 'process',
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('waiting for bootstrap');
|
|
expect(host.textContent).not.toContain('online');
|
|
expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('keeps registered-only OpenCode status visible next to active task context', 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,
|
|
providerId: 'opencode',
|
|
currentTaskId: currentTask.id,
|
|
},
|
|
memberColor: 'blue',
|
|
currentTask,
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'waiting',
|
|
spawnLaunchState: 'runtime_pending_bootstrap',
|
|
spawnRuntimeAlive: false,
|
|
runtimeEntry: {
|
|
memberName: 'alice',
|
|
alive: false,
|
|
restartable: false,
|
|
providerId: 'opencode',
|
|
livenessKind: 'registered_only',
|
|
runtimeDiagnostic: 'registered runtime metadata without live process',
|
|
updatedAt: '2026-04-27T12:17:58.714Z',
|
|
},
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('registered');
|
|
expect(host.querySelector('[aria-label="registered"]')).not.toBeNull();
|
|
expect(host.firstElementChild?.className).toContain('-mx-[calc(1rem-5px)]');
|
|
expect(host.firstElementChild?.className).toContain('px-[calc(1rem-5px)]');
|
|
expect(host.querySelector('[role="button"]')?.className).toContain('-mx-[calc(1rem-5px)]');
|
|
|
|
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="awaiting permission"]')).not.toBeNull();
|
|
expect(host.querySelector('.member-waiting-shimmer')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('shows a waiting-for-bootstrap badge while runtime bootstrap is still pending after the process comes online', 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: 'Gemini · flash · Medium',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'online',
|
|
spawnLaunchState: 'runtime_pending_bootstrap',
|
|
spawnRuntimeAlive: true,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('waiting for bootstrap');
|
|
expect(host.textContent).not.toContain('ready');
|
|
expect(host.querySelector('[aria-label="waiting for bootstrap"]')).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('');
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
it('labels shared OpenCode host memory instead of member-owned runtime memory', 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: 'minimax · via OpenCode · 183.9 MB',
|
|
runtimeEntry: {
|
|
memberName: 'alice',
|
|
alive: true,
|
|
restartable: false,
|
|
providerId: 'opencode',
|
|
pid: 333,
|
|
runtimeLoadScope: 'shared-host',
|
|
rssBytes: 183.9 * 1024 * 1024,
|
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
|
},
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.querySelector('[title="RSS source: shared OpenCode host"]')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('renders the bottom runtime telemetry strip when resource history is available', 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: 'gpt-5.4-mini · Codex · 238.3 MB',
|
|
runtimeEntry: {
|
|
memberName: 'alice',
|
|
alive: true,
|
|
restartable: true,
|
|
providerId: 'codex',
|
|
pid: 222,
|
|
pidSource: 'tmux_child',
|
|
rssBytes: 238.3 * 1024 * 1024,
|
|
cpuPercent: 14,
|
|
primaryCpuPercent: 4,
|
|
primaryRssBytes: 210 * 1024 * 1024,
|
|
childCpuPercent: 10,
|
|
childRssBytes: 28.3 * 1024 * 1024,
|
|
processCount: 3,
|
|
runtimeLoadScope: 'process-tree',
|
|
resourceHistory: [
|
|
{
|
|
timestamp: '2026-04-24T12:00:00.000Z',
|
|
rssBytes: 220 * 1024 * 1024,
|
|
cpuPercent: 0,
|
|
primaryCpuPercent: 0,
|
|
primaryRssBytes: 210 * 1024 * 1024,
|
|
childCpuPercent: 0,
|
|
childRssBytes: 10 * 1024 * 1024,
|
|
processCount: 2,
|
|
runtimeLoadScope: 'process-tree',
|
|
pidSource: 'tmux_child',
|
|
pid: 222,
|
|
},
|
|
{
|
|
timestamp: '2026-04-24T12:00:05.000Z',
|
|
rssBytes: 238.3 * 1024 * 1024,
|
|
cpuPercent: 14,
|
|
primaryCpuPercent: 4,
|
|
primaryRssBytes: 210 * 1024 * 1024,
|
|
childCpuPercent: 10,
|
|
childRssBytes: 28.3 * 1024 * 1024,
|
|
processCount: 3,
|
|
runtimeLoadScope: 'process-tree',
|
|
pidSource: 'tmux_child',
|
|
pid: 222,
|
|
},
|
|
],
|
|
updatedAt: '2026-04-24T12:00:05.000Z',
|
|
},
|
|
runtimeTelemetryVisible: true,
|
|
runtimeTelemetryScale: {
|
|
cpuCapPercent: 100,
|
|
memoryCapBytes: 512 * 1024 * 1024,
|
|
},
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const strip = host.querySelector('[data-testid="member-runtime-telemetry-strip"]');
|
|
expect(strip).not.toBeNull();
|
|
expect(strip?.querySelector('path[fill="#22c55e"]')).not.toBeNull();
|
|
const cpuPath = strip?.querySelector('path[stroke="#3b82f6"]');
|
|
expect(cpuPath).not.toBeNull();
|
|
expect(cpuPath?.getAttribute('d')).toContain('M0 16.10');
|
|
expect(strip?.getAttribute('title')).toBeNull();
|
|
expect(
|
|
host.querySelector('[data-testid="tooltip-root"][data-delay-duration="0"]')
|
|
).not.toBeNull();
|
|
expect(host.querySelector('[data-testid="tooltip-root"]')?.getAttribute('data-open')).toBe(
|
|
'false'
|
|
);
|
|
expect(host.textContent).toContain('Local runtime load');
|
|
expect(host.textContent).toContain('Parent and child processes only.');
|
|
expect(host.textContent).toContain('root PID 222');
|
|
expect(host.textContent).toContain('3 processes');
|
|
expect(host.textContent).toContain('CPU');
|
|
expect(host.textContent).toContain('14%');
|
|
expect(host.textContent).toContain('Memory');
|
|
expect(host.textContent).toContain('238 MB');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('ignores malformed runtime telemetry history without crashing', 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: 'gpt-5.4-mini · Codex · 238.3 MB',
|
|
runtimeEntry: {
|
|
memberName: 'alice',
|
|
alive: true,
|
|
restartable: true,
|
|
providerId: 'codex',
|
|
pid: 222,
|
|
resourceHistory: 'not-an-array',
|
|
updatedAt: '2026-04-24T12:00:05.000Z',
|
|
} as any,
|
|
runtimeTelemetryVisible: true,
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.querySelector('[data-testid="member-runtime-telemetry-strip"]')).toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('ignores malformed runtime telemetry samples while rendering valid samples', 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: 'gpt-5.4-mini · Codex · 238.3 MB',
|
|
runtimeEntry: {
|
|
memberName: 'alice',
|
|
alive: true,
|
|
restartable: true,
|
|
providerId: 'codex',
|
|
pid: 222,
|
|
resourceHistory: [
|
|
null,
|
|
{
|
|
timestamp: '2026-04-24T12:00:00.000Z',
|
|
rssBytes: 220 * 1024 * 1024,
|
|
cpuPercent: 0,
|
|
},
|
|
'bad-sample',
|
|
{
|
|
timestamp: '2026-04-24T12:00:05.000Z',
|
|
rssBytes: 238 * 1024 * 1024,
|
|
cpuPercent: 12,
|
|
},
|
|
],
|
|
updatedAt: '2026-04-24T12:00:05.000Z',
|
|
} as any,
|
|
runtimeTelemetryVisible: true,
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const strip = host.querySelector('[data-testid="member-runtime-telemetry-strip"]');
|
|
expect(strip).not.toBeNull();
|
|
expect(strip?.querySelector('path[stroke="#3b82f6"]')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('shows a worktree badge only for teammates configured with worktree isolation', 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,
|
|
providerId: 'opencode',
|
|
isolation: 'worktree',
|
|
cwd: '/tmp/project-alice-worktree',
|
|
},
|
|
memberColor: 'blue',
|
|
runtimeSummary: 'kimi · via OpenCode',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('worktree');
|
|
expect(host.textContent).toContain('Worktree isolation is enabled.');
|
|
expect(host.textContent).toContain('Path: /tmp/project-alice-worktree');
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(MemberCard, {
|
|
member: {
|
|
...member,
|
|
providerId: 'opencode',
|
|
isolation: 'worktree',
|
|
},
|
|
memberColor: 'blue',
|
|
runtimeEntry: {
|
|
memberName: 'alice',
|
|
alive: true,
|
|
restartable: true,
|
|
providerId: 'opencode',
|
|
cwd: '/tmp/project',
|
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
|
},
|
|
runtimeSummary: 'kimi · via OpenCode',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('worktree');
|
|
expect(host.textContent).toContain('Path is not available yet.');
|
|
expect(host.textContent).not.toContain('Runtime cwd: /tmp/project');
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(MemberCard, {
|
|
member: {
|
|
...member,
|
|
providerId: 'opencode',
|
|
cwd: '/tmp/project',
|
|
},
|
|
memberColor: 'blue',
|
|
runtimeSummary: 'kimi · via OpenCode',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).not.toContain('worktree');
|
|
expect(host.textContent).not.toContain('shared');
|
|
expect(host.querySelector('[title^="Shared workspace"]')).toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('copies bounded launch diagnostics only for launch errors', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: { writeText },
|
|
});
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(MemberCard, {
|
|
member,
|
|
memberColor: 'blue',
|
|
runtimeRunId: 'run-42',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'waiting',
|
|
spawnLaunchState: 'runtime_pending_bootstrap',
|
|
spawnRuntimeAlive: false,
|
|
spawnEntry: {
|
|
status: 'waiting',
|
|
launchState: 'runtime_pending_bootstrap',
|
|
runtimeAlive: false,
|
|
bootstrapConfirmed: false,
|
|
hardFailure: false,
|
|
agentToolAccepted: true,
|
|
livenessKind: 'shell_only',
|
|
runtimeDiagnostic: 'tmux pane foreground command is zsh',
|
|
runtimeDiagnosticSeverity: 'warning',
|
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
|
},
|
|
runtimeEntry: {
|
|
memberName: 'alice',
|
|
alive: false,
|
|
restartable: true,
|
|
pid: 26676,
|
|
pidSource: 'tmux_pane',
|
|
paneCurrentCommand: 'zsh',
|
|
processCommand: 'node runtime --token super-secret',
|
|
updatedAt: '2026-04-24T12:00:01.000Z',
|
|
},
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.querySelector('[aria-label="Copy diagnostics"]')).toBeNull();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(MemberCard, {
|
|
member,
|
|
memberColor: 'blue',
|
|
runtimeRunId: 'run-42',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'error',
|
|
spawnLaunchState: 'failed_to_start',
|
|
spawnRuntimeAlive: false,
|
|
spawnError: 'spawn failed',
|
|
spawnEntry: {
|
|
status: 'error',
|
|
launchState: 'failed_to_start',
|
|
runtimeAlive: false,
|
|
bootstrapConfirmed: false,
|
|
hardFailure: true,
|
|
hardFailureReason: 'spawn failed',
|
|
agentToolAccepted: false,
|
|
livenessKind: 'not_found',
|
|
runtimeDiagnostic: 'spawn failed',
|
|
runtimeDiagnosticSeverity: 'error',
|
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
|
},
|
|
runtimeEntry: {
|
|
memberName: 'alice',
|
|
alive: false,
|
|
restartable: true,
|
|
pid: 26676,
|
|
pidSource: 'tmux_pane',
|
|
paneCurrentCommand: 'zsh',
|
|
processCommand: 'node runtime --token super-secret',
|
|
updatedAt: '2026-04-24T12:00:01.000Z',
|
|
},
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const button = host.querySelector('[aria-label="Copy diagnostics"]') as HTMLButtonElement;
|
|
expect(button).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
button.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(writeText).toHaveBeenCalledTimes(1);
|
|
const payload = JSON.parse(writeText.mock.calls[0][0] as string) as {
|
|
runId?: string;
|
|
livenessKind?: string;
|
|
processCommand?: string;
|
|
};
|
|
expect(payload.runId).toBe('run-42');
|
|
expect(payload.livenessKind).toBe('not_found');
|
|
expect(payload.processCommand).toContain('--token [redacted]');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('renders retry for failed teammate launches', 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: 'error',
|
|
spawnLaunchState: 'failed_to_start',
|
|
spawnRuntimeAlive: false,
|
|
spawnError: 'spawn failed',
|
|
spawnEntry: failedSpawnEntry,
|
|
onRestartMember: vi.fn(),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('shows a compact failed launch reason on the member row with clickable links', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const reason =
|
|
'Latest assistant message msg_df2d6414f0016Bn0Pc0QJbo5sU failed with APIError - Insufficient credits. Add more using https://openrouter.ai/settings/credits';
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(MemberCard, {
|
|
member,
|
|
memberColor: 'blue',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'error',
|
|
spawnLaunchState: 'failed_to_start',
|
|
spawnRuntimeAlive: false,
|
|
spawnError: reason,
|
|
spawnEntry: {
|
|
...failedSpawnEntry,
|
|
hardFailureReason: reason,
|
|
runtimeDiagnostic: reason,
|
|
},
|
|
onRestartMember: vi.fn(),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const failureReason = host.querySelector('[data-testid="member-launch-failure-reason"]');
|
|
expect(failureReason?.textContent).toContain('Insufficient credits');
|
|
expect(failureReason?.textContent).toContain('OpenRouter credits');
|
|
expect(failureReason?.textContent).not.toContain('Latest assistant message');
|
|
expect(failureReason?.textContent).not.toContain('msg_df2d6414');
|
|
|
|
const link = failureReason?.querySelector(
|
|
'a[href="https://openrouter.ai/settings/credits"]'
|
|
) as HTMLAnchorElement | null;
|
|
expect(link).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
link?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(hoisted.openExternal).toHaveBeenCalledWith('https://openrouter.ai/settings/credits');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('does not truncate long failed launch reasons on the member row', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const reason = `APIError - ${'Codex runtime context includes missing login session. '.repeat(
|
|
8
|
|
)}final diagnostic marker`;
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(MemberCard, {
|
|
member,
|
|
memberColor: 'blue',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'error',
|
|
spawnLaunchState: 'failed_to_start',
|
|
spawnRuntimeAlive: false,
|
|
spawnError: reason,
|
|
spawnEntry: {
|
|
...failedSpawnEntry,
|
|
hardFailureReason: reason,
|
|
runtimeDiagnostic: reason,
|
|
},
|
|
onRestartMember: vi.fn(),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const failureReason = host.querySelector('[data-testid="member-launch-failure-reason"]');
|
|
expect(failureReason?.textContent).toContain('final diagnostic marker');
|
|
expect(failureReason?.querySelector('.line-clamp-2')).toBeNull();
|
|
expect(failureReason?.textContent).not.toContain('...');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('renders Relaunch OpenCode for registered-only OpenCode teammates', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const onRestartMember = vi.fn(async () => undefined);
|
|
const onClick = vi.fn();
|
|
const openCodeMember: ResolvedTeamMember = {
|
|
...member,
|
|
providerId: 'opencode',
|
|
};
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(MemberCard, {
|
|
member: openCodeMember,
|
|
memberColor: 'blue',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'online',
|
|
spawnLaunchState: 'confirmed_alive',
|
|
spawnRuntimeAlive: true,
|
|
spawnEntry: {
|
|
status: 'online',
|
|
launchState: 'confirmed_alive',
|
|
runtimeAlive: false,
|
|
bootstrapConfirmed: false,
|
|
livenessKind: 'registered_only',
|
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
|
},
|
|
runtimeEntry: {
|
|
memberName: 'alice',
|
|
alive: false,
|
|
restartable: true,
|
|
providerId: 'opencode',
|
|
livenessKind: 'registered_only',
|
|
runtimeDiagnostic: 'registered runtime metadata without live process',
|
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
|
},
|
|
onClick,
|
|
onRestartMember,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const button = host.querySelector('[aria-label="Relaunch OpenCode"]') as HTMLButtonElement;
|
|
expect(button).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
button.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(onRestartMember).toHaveBeenCalledWith('alice');
|
|
expect(onClick).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('does not render Relaunch OpenCode for fresh runtime candidates', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-04-24T12:01:00.000Z'));
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(MemberCard, {
|
|
member: {
|
|
...member,
|
|
providerId: 'opencode',
|
|
},
|
|
memberColor: 'blue',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'online',
|
|
spawnLaunchState: 'runtime_pending_bootstrap',
|
|
spawnRuntimeAlive: true,
|
|
spawnEntry: {
|
|
status: 'online',
|
|
launchState: 'runtime_pending_bootstrap',
|
|
runtimeAlive: true,
|
|
bootstrapConfirmed: false,
|
|
livenessKind: 'runtime_process_candidate',
|
|
firstSpawnAcceptedAt: '2026-04-24T12:00:00.000Z',
|
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
|
},
|
|
runtimeEntry: {
|
|
memberName: 'alice',
|
|
alive: true,
|
|
restartable: true,
|
|
providerId: 'opencode',
|
|
livenessKind: 'runtime_process_candidate',
|
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
|
},
|
|
onRestartMember: vi.fn(),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.querySelector('[aria-label="Relaunch OpenCode"]')).toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('renders skip for failed teammate launches', 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: 'error',
|
|
spawnLaunchState: 'failed_to_start',
|
|
spawnRuntimeAlive: false,
|
|
spawnError: 'spawn failed',
|
|
spawnEntry: failedSpawnEntry,
|
|
onSkipMemberForLaunch: vi.fn(),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.querySelector('[aria-label="Skip for this launch"]')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('retries failed teammate launches without opening the member row', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const onClick = vi.fn();
|
|
let resolveRetry!: () => void;
|
|
const retryPromise = new Promise<void>((resolve) => {
|
|
resolveRetry = resolve;
|
|
});
|
|
const onRestartMember = vi.fn(() => retryPromise);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(MemberCard, {
|
|
member,
|
|
memberColor: 'blue',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'error',
|
|
spawnLaunchState: 'failed_to_start',
|
|
spawnRuntimeAlive: false,
|
|
spawnError: 'spawn failed',
|
|
spawnEntry: failedSpawnEntry,
|
|
onClick,
|
|
onRestartMember,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const button = host.querySelector('[aria-label="Retry teammate"]') as HTMLButtonElement;
|
|
expect(button).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
button.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(onRestartMember).toHaveBeenCalledWith('alice');
|
|
expect(onClick).not.toHaveBeenCalled();
|
|
expect(host.querySelector('[aria-label="Retrying teammate"]')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
resolveRetry();
|
|
await retryPromise;
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('skips failed teammate launches without opening the member row', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const onClick = vi.fn();
|
|
let resolveSkip!: () => void;
|
|
const skipPromise = new Promise<void>((resolve) => {
|
|
resolveSkip = resolve;
|
|
});
|
|
const onSkipMemberForLaunch = vi.fn(() => skipPromise);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(MemberCard, {
|
|
member,
|
|
memberColor: 'blue',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'error',
|
|
spawnLaunchState: 'failed_to_start',
|
|
spawnRuntimeAlive: false,
|
|
spawnError: 'spawn failed',
|
|
spawnEntry: failedSpawnEntry,
|
|
onClick,
|
|
onSkipMemberForLaunch,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const button = host.querySelector('[aria-label="Skip for this launch"]') as HTMLButtonElement;
|
|
expect(button).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
button.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(onSkipMemberForLaunch).toHaveBeenCalledWith('alice');
|
|
expect(onClick).not.toHaveBeenCalled();
|
|
expect(host.querySelector('[aria-label="Skipping teammate"]')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
resolveSkip();
|
|
await skipPromise;
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.querySelector('[aria-label="Skip for this launch"]')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('keeps retry available and exposes retry errors after rejection', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const onRestartMember = vi.fn(async () => {
|
|
throw new Error('restart failed');
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(MemberCard, {
|
|
member,
|
|
memberColor: 'blue',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'error',
|
|
spawnLaunchState: 'failed_to_start',
|
|
spawnRuntimeAlive: false,
|
|
spawnError: 'spawn failed',
|
|
spawnEntry: failedSpawnEntry,
|
|
onRestartMember,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const button = host.querySelector('[aria-label="Retry teammate"]') as HTMLButtonElement;
|
|
expect(button).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
button.click();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(onRestartMember).toHaveBeenCalledWith('alice');
|
|
expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull();
|
|
expect(host.textContent).toContain('restart failed');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('keeps skip available and exposes skip errors after rejection', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const onSkipMemberForLaunch = vi.fn(async () => {
|
|
throw new Error('skip failed');
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(MemberCard, {
|
|
member,
|
|
memberColor: 'blue',
|
|
isTeamAlive: true,
|
|
isTeamProvisioning: false,
|
|
spawnStatus: 'error',
|
|
spawnLaunchState: 'failed_to_start',
|
|
spawnRuntimeAlive: false,
|
|
spawnError: 'spawn failed',
|
|
spawnEntry: failedSpawnEntry,
|
|
onSkipMemberForLaunch,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const button = host.querySelector('[aria-label="Skip for this launch"]') as HTMLButtonElement;
|
|
expect(button).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
button.click();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(onSkipMemberForLaunch).toHaveBeenCalledWith('alice');
|
|
expect(host.querySelector('[aria-label="Skip for this launch"]')).not.toBeNull();
|
|
expect(host.textContent).toContain('skip failed');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('shows skipped teammates as skipped and keeps retry available', 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: 'skipped',
|
|
spawnLaunchState: 'skipped_for_launch',
|
|
spawnRuntimeAlive: false,
|
|
spawnEntry: skippedSpawnEntry,
|
|
onRestartMember: vi.fn(),
|
|
onSkipMemberForLaunch: vi.fn(),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('skipped');
|
|
expect(host.textContent).toContain('Skipped by user after launch failure');
|
|
expect(host.querySelector('[aria-label="Retry teammate"]')).not.toBeNull();
|
|
expect(host.querySelector('[aria-label="Skip for this launch"]')).toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('moves worktree branch details into the worktree badge tooltip', 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,
|
|
name: 'jack',
|
|
isolation: 'worktree',
|
|
cwd: '/Users/belief/.claude/team-worktrees/sol-team-proj-abc/room/jack',
|
|
gitBranch: 'agent-teams/room/jack-abc',
|
|
},
|
|
memberColor: 'turquoise',
|
|
isTeamAlive: true,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('worktree');
|
|
expect(host.textContent).toContain(
|
|
'Path: /Users/belief/.claude/team-worktrees/sol-team-proj-abc/room/jack'
|
|
);
|
|
expect(host.textContent).toContain('Branch: agent-teams/room/jack-abc');
|
|
expect(host.textContent?.match(/agent-teams\/room\/jack-abc/g)).toHaveLength(1);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
});
|