import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; const member: ResolvedTeamMember = { name: 'alice', status: 'unknown', taskCount: 0, currentTaskId: null, lastActiveAt: null, messageCount: 0, color: 'blue', providerId: 'gemini', agentType: 'reviewer', role: 'Reviewer', removedAt: undefined, }; const storeState = { selectedTeamData: { members: [member], isAlive: true, tasks: [] as TeamTaskWithKanban[], }, selectedTeamName: 'northstar-core', progress: null as Record | null, memberSpawnStatusesByTeam: { 'northstar-core': { alice: { status: 'spawning', launchState: 'starting', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: false, }, }, } as Record< string, Record< string, { status: string; launchState: string; updatedAt: string; runtimeAlive: boolean; livenessSource?: string; livenessKind?: string; runtimeDiagnostic?: string; runtimeDiagnosticSeverity?: string; error?: string; hardFailure?: boolean; hardFailureReason?: string; } > >, memberSpawnSnapshotsByTeam: { 'northstar-core': undefined, } as Record, leadActivityByTeam: {}, teamAgentRuntimeByTeam: {} as Record< string, { runId: string | null; members: Record>; } >, openMemberProfile: vi.fn(), }; const hoverCardMockState = vi.hoisted(() => ({ autoOpen: true, wideSelectorCalls: 0, })); vi.mock('@renderer/store', () => ({ useStore: (selector: (state: typeof storeState) => unknown) => { const selected = selector(storeState); if ( selected && typeof selected === 'object' && 'member' in selected && 'teamMembers' in selected && 'runtimeEntry' in selected ) { hoverCardMockState.wideSelectorCalls += 1; } return selected; }, })); vi.mock('@renderer/store/slices/teamSlice', () => ({ getCurrentProvisioningProgressForTeam: () => storeState.progress, selectResolvedMemberForTeamName: ( state: typeof storeState, teamName: string, memberName: string ) => (state.selectedTeamName === teamName ? state.selectedTeamData : null)?.members.find( (candidate) => candidate.name === memberName ) ?? null, selectTeamMemberSnapshotsForName: (state: typeof storeState, teamName: string) => (state.selectedTeamName === teamName ? state.selectedTeamData : null)?.members ?? [], selectTeamTasksForName: (state: typeof storeState, teamName: string) => (state.selectedTeamName === teamName ? state.selectedTeamData : null)?.tasks ?? [], selectTeamIsAliveForName: (state: typeof storeState, teamName: string) => (state.selectedTeamName === teamName ? state.selectedTeamData : null)?.isAlive, })); vi.mock('@renderer/hooks/useTheme', () => ({ useTheme: () => ({ isLight: false }), })); vi.mock('@renderer/components/ui/badge', () => ({ Badge: ({ children }: { children: React.ReactNode }) => React.createElement('span', null, children), })); vi.mock('@renderer/components/ui/hover-card', () => ({ HoverCard: ({ children, open, onOpenChange, }: { children: React.ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; }) => { React.useEffect(() => { if (hoverCardMockState.autoOpen && open !== true) { onOpenChange?.(true); } }, [onOpenChange, open]); return React.createElement('div', { 'data-hover-card-open': open ? 'true' : 'false' }, children); }, HoverCardTrigger: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), HoverCardContent: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, 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/components/team/members/CurrentTaskIndicator', () => ({ CurrentTaskIndicator: ({ task, activityLabel, }: { task: TeamTaskWithKanban; activityLabel?: string; }) => React.createElement( 'span', { 'data-testid': 'hover-current-task' }, `${activityLabel ?? 'task'} ${task.id}` ), })); import { MemberHoverCard } from '@renderer/components/team/members/MemberHoverCard'; describe('MemberHoverCard spawn-aware presence', () => { afterEach(() => { document.body.innerHTML = ''; vi.unstubAllGlobals(); }); beforeEach(() => { storeState.selectedTeamData.members = [member]; storeState.selectedTeamData.isAlive = true; storeState.selectedTeamData.tasks = []; storeState.selectedTeamName = 'northstar-core'; storeState.progress = null; storeState.memberSpawnStatusesByTeam['northstar-core'].alice = { status: 'spawning', launchState: 'starting', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: false, }; storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined; storeState.teamAgentRuntimeByTeam = {}; storeState.openMemberProfile.mockReset(); hoverCardMockState.autoOpen = true; hoverCardMockState.wideSelectorCalls = 0; }); it('does not run detailed store selectors while closed', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); hoverCardMockState.autoOpen = false; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(MemberHoverCard, { name: 'alice', children: React.createElement('button', { type: 'button' }, 'alice'), }) ); await Promise.resolve(); }); expect(host.textContent).toBe('alice'); expect(hoverCardMockState.wideSelectorCalls).toBe(0); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('shows starting from the team spawn snapshot even when provisioning is no longer active', 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(MemberHoverCard, { name: 'alice', children: React.createElement('button', { type: 'button' }, 'alice'), }) ); await Promise.resolve(); }); expect(host.textContent).toContain('starting'); expect(host.textContent).not.toContain('idle'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('shows starting for runtime-pending members while launch is still settling', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.progress = { runId: 'run-1', teamName: 'northstar-core', state: 'ready', startedAt: '2026-04-09T10:00:00.000Z', pid: 4321, configReady: true, }; storeState.memberSpawnStatusesByTeam['northstar-core'].alice = { status: 'online', launchState: 'runtime_pending_bootstrap', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: true, livenessSource: 'process', }; storeState.memberSpawnSnapshotsByTeam['northstar-core'] = { runId: 'run-1', expectedMembers: ['alice'], statuses: {}, summary: { confirmedCount: 0, pendingCount: 1, failedCount: 0, runtimeAlivePendingCount: 1, }, source: 'merged', }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(MemberHoverCard, { name: 'alice', children: React.createElement('button', { type: 'button' }, 'alice'), }) ); await Promise.resolve(); }); expect(host.textContent).toContain('starting'); expect(host.textContent).not.toContain('online'); expect(host.querySelector('[aria-label="starting"]')).not.toBeNull(); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('shows waiting for bootstrap while runtime is online but bootstrap is still pending outside launch settling', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.progress = null; storeState.memberSpawnStatusesByTeam['northstar-core'].alice = { status: 'online', launchState: 'runtime_pending_bootstrap', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: true, livenessSource: 'process', }; storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(MemberHoverCard, { name: 'alice', children: React.createElement('button', { type: 'button' }, 'alice'), }) ); 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('surfaces runtime retry state in the hover card after the teammate has already joined', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.selectedTeamData.members = [ { ...member, runtimeAdvisory: { kind: 'sdk_retrying', observedAt: '2026-04-09T10:00:00.000Z', retryUntil: '2099-04-09T10:00:45.000Z', retryDelayMs: 45_000, reasonCode: 'quota_exhausted', message: 'Gemini cli backend error: capacity exceeded.', }, }, ]; storeState.memberSpawnStatusesByTeam['northstar-core'].alice = { status: 'online', launchState: 'confirmed_alive', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: true, livenessSource: 'heartbeat', }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(MemberHoverCard, { name: 'alice', children: React.createElement('button', { type: 'button' }, 'alice'), }) ); await Promise.resolve(); }); expect(host.textContent).toContain('Gemini quota retry'); expect(host.textContent).not.toContain('idle'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('does not show a working-on task when the member is offline', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const task: TeamTaskWithKanban = { id: 'task-active', subject: 'Active work', status: 'in_progress', }; storeState.selectedTeamData.members = [{ ...member, currentTaskId: task.id }]; storeState.selectedTeamData.tasks = [task]; storeState.memberSpawnStatusesByTeam['northstar-core'].alice = { status: 'offline', launchState: 'confirmed_alive', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: false, }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(MemberHoverCard, { name: 'alice', children: React.createElement('button', { type: 'button' }, 'alice'), }) ); await Promise.resolve(); }); expect(host.querySelector('[data-testid="hover-current-task"]')).toBeNull(); expect(host.textContent).not.toContain('working on'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('copies launch diagnostics with the active runtime run id 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 }, }); storeState.memberSpawnStatusesByTeam['northstar-core'].alice = { status: 'waiting', launchState: 'runtime_pending_bootstrap', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: false, livenessKind: 'shell_only', runtimeDiagnostic: 'tmux pane foreground command is zsh', runtimeDiagnosticSeverity: 'warning', }; storeState.teamAgentRuntimeByTeam['northstar-core'] = { runId: 'runtime-run-1', members: { alice: { memberName: 'alice', alive: false, restartable: true, livenessKind: 'shell_only', pidSource: 'tmux_pane', paneCurrentCommand: 'zsh', processCommand: 'node runtime --token secret', updatedAt: '2026-04-09T10:00:01.000Z', }, }, }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(MemberHoverCard, { name: 'alice', children: React.createElement('button', { type: 'button' }, 'alice'), }) ); await Promise.resolve(); }); expect(host.querySelector('[aria-label="Copy diagnostics"]')).toBeNull(); storeState.memberSpawnStatusesByTeam['northstar-core'].alice = { status: 'error', launchState: 'failed_to_start', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: false, livenessKind: 'not_found', runtimeDiagnostic: 'spawn failed', runtimeDiagnosticSeverity: 'error', error: 'spawn failed', hardFailure: true, hardFailureReason: 'spawn failed', }; await act(async () => { root.render( React.createElement(MemberHoverCard, { name: 'alice', children: React.createElement('button', { type: 'button' }, 'alice'), }) ); 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(); }); const payload = JSON.parse(writeText.mock.calls[0][0] as string) as { runId?: string; processCommand?: string; }; expect(payload.runId).toBe('runtime-run-1'); expect(payload.processCommand).toContain('--token [redacted]'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); });