import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const storeState = { progress: null as Record | null, cancelProvisioning: vi.fn(), retryFailedOpenCodeSecondaryLanes: vi.fn(), selectedTeamName: 'northstar-core', selectedTeamData: { members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'alice', agentType: 'reviewer', runtimeAdvisory: undefined }, { name: 'bob', agentType: 'developer' }, { name: 'jack', agentType: 'developer' }, ] as Array>, }, teamDataCacheByName: { 'northstar-core': { members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'alice', agentType: 'reviewer', runtimeAdvisory: undefined }, { name: 'bob', agentType: 'developer' }, { name: 'jack', agentType: 'developer' }, ], }, } as Record> }>, memberSpawnStatusesByTeam: { 'northstar-core': {}, }, memberSpawnSnapshotsByTeam: {} as Record, }; vi.mock('@renderer/store', () => ({ useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState), })); vi.mock('@renderer/store/slices/teamSlice', () => ({ getCurrentProvisioningProgressForTeam: () => storeState.progress, selectTeamDataForName: (_state: typeof storeState, teamName: string) => storeState.teamDataCacheByName[teamName] ?? (storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null), selectTeamMemberSnapshotsForName: (_state: typeof storeState, teamName: string) => ( storeState.teamDataCacheByName[teamName] ?? (storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null) )?.members ?? [], selectResolvedMembersForTeamName: (_state: typeof storeState, teamName: string) => ( storeState.teamDataCacheByName[teamName] ?? (storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null) )?.members ?? [], })); vi.mock('zustand/react/shallow', () => ({ useShallow: (selector: unknown) => selector, })); vi.mock('@renderer/components/ui/button', () => ({ Button: ({ children }: { children: React.ReactNode }) => React.createElement('button', { type: 'button' }, children), })); vi.mock('@renderer/components/team/ProvisioningProgressBlock', () => ({ ProvisioningProgressBlock: ({ currentStepIndex, loading, message, successMessage, successMessageSeverity, }: { currentStepIndex: number; loading?: boolean; message?: string | null; successMessage?: string | null; successMessageSeverity?: string; }) => React.createElement( 'div', { 'data-testid': 'progress-block', 'data-current-step-index': String(currentStepIndex), 'data-loading': loading ? 'true' : 'false', 'data-success-severity': successMessageSeverity ?? '', }, [successMessage, message].filter(Boolean).join(' ') ), })); import { TeamProvisioningBanner } from '@renderer/components/team/TeamProvisioningBanner'; describe('TeamProvisioningBanner launch-step alignment', () => { afterEach(() => { document.body.innerHTML = ''; }); beforeEach(() => { storeState.selectedTeamName = 'northstar-core'; storeState.progress = { runId: 'run-1', teamName: 'northstar-core', state: 'ready', startedAt: '2026-04-08T16:00:00.000Z', message: 'Launch completed', messageSeverity: undefined, pid: 1234, cliLogsTail: '', assistantOutput: '', }; storeState.retryFailedOpenCodeSecondaryLanes.mockResolvedValue({ attempted: [], confirmed: [], pending: [], failed: [], skipped: [], }); storeState.memberSpawnStatusesByTeam['northstar-core'] = {}; storeState.selectedTeamData.members = [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'alice', agentType: 'reviewer', runtimeAdvisory: undefined }, { name: 'bob', agentType: 'developer', runtimeAdvisory: undefined }, { name: 'jack', agentType: 'developer', runtimeAdvisory: undefined }, ]; storeState.teamDataCacheByName['northstar-core'] = { members: [...storeState.selectedTeamData.members], }; storeState.memberSpawnSnapshotsByTeam['northstar-core'] = { runId: 'run-1', expectedMembers: ['alice', 'bob', 'jack'], statuses: {}, summary: { confirmedCount: 0, pendingCount: 3, failedCount: 0, runtimeAlivePendingCount: 0, }, source: 'merged', }; }); it('keeps Members joining as the active step while teammates are still starting after ready', 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(TeamProvisioningBanner, { teamName: 'northstar-core' })); await Promise.resolve(); }); const block = host.querySelector('[data-testid="progress-block"]'); expect(block?.getAttribute('data-current-step-index')).toBe('2'); expect(block?.getAttribute('data-loading')).toBe('true'); expect(block?.textContent).toContain('Finishing launch'); expect(block?.textContent).toContain('3 teammates still joining'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('derives teammate counts from team cache even when the team is not selected', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.selectedTeamName = 'other-team'; storeState.progress = { runId: 'run-2', teamName: 'northstar-core', state: 'ready', startedAt: '2026-04-08T16:00:00.000Z', message: 'Launch completed', messageSeverity: undefined, pid: 1234, cliLogsTail: '', assistantOutput: '', }; storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined as unknown as Record< string, unknown >; storeState.memberSpawnStatusesByTeam['northstar-core'] = { alice: { status: 'waiting', launchState: 'starting' }, bob: { status: 'waiting', launchState: 'starting' }, jack: { status: 'waiting', launchState: 'starting' }, } as Record; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); await Promise.resolve(); }); const block = host.querySelector('[data-testid="progress-block"]'); expect(block?.getAttribute('data-current-step-index')).toBe('2'); expect(block?.textContent).toContain('3 teammates still joining'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('keeps Starting active until a real provisioning pid exists', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.progress = { runId: 'run-1', teamName: 'northstar-core', state: 'configuring', startedAt: '2026-04-08T16:00:00.000Z', message: 'Waiting for team configuration...', messageSeverity: undefined, cliLogsTail: '', assistantOutput: '', configReady: false, }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); await Promise.resolve(); }); const block = host.querySelector('[data-testid="progress-block"]'); expect(block?.getAttribute('data-current-step-index')).toBe('0'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('keeps Team setup active while config is not ready after the process starts', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.progress = { runId: 'run-1', teamName: 'northstar-core', state: 'configuring', startedAt: '2026-04-08T16:00:00.000Z', message: 'Waiting for team configuration...', messageSeverity: undefined, pid: 4321, cliLogsTail: '', assistantOutput: '', configReady: false, }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); await Promise.resolve(); }); const block = host.querySelector('[data-testid="progress-block"]'); expect(block?.getAttribute('data-current-step-index')).toBe('1'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('advances to Finalizing once teammate runtimes are attached even before contact is confirmed', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.progress = { runId: 'run-1', teamName: 'northstar-core', state: 'finalizing', startedAt: '2026-04-08T16:00:00.000Z', message: 'Waiting for teammate bootstrap confirmations...', messageSeverity: undefined, pid: 4321, cliLogsTail: '', assistantOutput: '', configReady: true, }; storeState.memberSpawnSnapshotsByTeam['northstar-core'] = { runId: 'run-1', expectedMembers: ['alice', 'bob', 'jack'], statuses: {}, summary: { confirmedCount: 0, pendingCount: 3, failedCount: 0, runtimeAlivePendingCount: 3, runtimeProcessPendingCount: 3, }, source: 'merged', }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); await Promise.resolve(); }); const block = host.querySelector('[data-testid="progress-block"]'); expect(block?.getAttribute('data-current-step-index')).toBe('3'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('shows all steps complete only after teammates actually made contact', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.memberSpawnSnapshotsByTeam['northstar-core'] = { runId: 'run-1', expectedMembers: ['alice', 'bob', 'jack'], statuses: {}, summary: { confirmedCount: 3, pendingCount: 0, failedCount: 0, runtimeAlivePendingCount: 0, }, source: 'merged', }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); await Promise.resolve(); }); const block = host.querySelector('[data-testid="progress-block"]'); expect(block?.getAttribute('data-current-step-index')).toBe('4'); expect(block?.textContent).toContain('all 3 teammates joined'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('does not mark Members joining complete when launch finishes with failed teammates', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.memberSpawnStatusesByTeam['northstar-core'] = { alice: { status: 'online', launchState: 'confirmed_alive', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: true, bootstrapConfirmed: true, hardFailure: false, agentToolAccepted: true, }, bob: { status: 'error', launchState: 'failed_to_start', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: false, bootstrapConfirmed: false, hardFailure: true, hardFailureReason: 'OpenCode lane failed before bootstrap', agentToolAccepted: false, }, jack: { status: 'online', launchState: 'confirmed_alive', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: true, bootstrapConfirmed: true, hardFailure: false, agentToolAccepted: true, }, } as Record; storeState.memberSpawnSnapshotsByTeam['northstar-core'] = { runId: 'run-1', expectedMembers: ['alice', 'bob', 'jack'], statuses: {}, summary: { confirmedCount: 2, pendingCount: 0, failedCount: 1, runtimeAlivePendingCount: 0, }, source: 'merged', }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); await Promise.resolve(); }); const block = host.querySelector('[data-testid="progress-block"]'); expect(block?.getAttribute('data-current-step-index')).toBe('2'); expect(block?.getAttribute('data-loading')).toBe('false'); expect(block?.getAttribute('data-success-severity')).toBe('warning'); expect(block?.textContent).toContain('Launch finished with errors'); expect(block?.textContent).toContain('bob failed to start'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('renders a bulk retry action for failed OpenCode secondary teammates', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.selectedTeamData.members = [ { name: 'team-lead', agentType: 'team-lead', providerId: 'anthropic' }, { name: 'alice', agentType: 'developer', providerId: 'opencode', laneKind: 'secondary', laneOwnerProviderId: 'opencode', }, ]; storeState.teamDataCacheByName['northstar-core'] = { members: [...storeState.selectedTeamData.members], }; storeState.memberSpawnStatusesByTeam['northstar-core'] = { alice: { status: 'error', launchState: 'failed_to_start', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: false, bootstrapConfirmed: false, hardFailure: true, hardFailureReason: 'OpenRouter credits exhausted', agentToolAccepted: false, }, } as Record; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); await Promise.resolve(); }); expect(host.textContent).toContain('Retry failed OpenCode teammates'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('uses info severity while runtimes are online but teammate contact is still pending', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.memberSpawnSnapshotsByTeam['northstar-core'] = { runId: 'run-1', expectedMembers: ['alice', 'bob', 'jack'], statuses: {}, summary: { confirmedCount: 0, pendingCount: 3, failedCount: 0, runtimeAlivePendingCount: 3, }, source: 'merged', }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); await Promise.resolve(); }); const block = host.querySelector('[data-testid="progress-block"]'); expect(block?.getAttribute('data-current-step-index')).toBe('2'); expect(block?.getAttribute('data-success-severity')).toBe('info'); expect(block?.textContent).toContain('Finishing launch'); expect(block?.textContent).toContain('3 teammates still joining'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('surfaces provider retry wording when pending runtimes are retrying provider capacity', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.selectedTeamData.members = [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'alice', agentType: 'reviewer', runtimeAdvisory: { kind: 'sdk_retrying', observedAt: '2026-04-09T10:00:00.000Z', retryUntil: '2026-04-09T10:00:45.000Z', retryDelayMs: 45_000, }, }, { name: 'bob', agentType: 'developer', runtimeAdvisory: { kind: 'sdk_retrying', observedAt: '2026-04-09T10:00:00.000Z', retryUntil: '2026-04-09T10:00:45.000Z', retryDelayMs: 45_000, }, }, { name: 'jack', agentType: 'developer', runtimeAdvisory: { kind: 'sdk_retrying', observedAt: '2026-04-09T10:00:00.000Z', retryUntil: '2026-04-09T10:00:45.000Z', retryDelayMs: 45_000, }, }, ]; storeState.memberSpawnStatusesByTeam['northstar-core'] = { alice: { status: 'online', launchState: 'runtime_pending_bootstrap', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: true, }, bob: { status: 'online', launchState: 'runtime_pending_bootstrap', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: true, }, jack: { status: 'online', launchState: 'runtime_pending_bootstrap', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: true, }, }; storeState.memberSpawnSnapshotsByTeam['northstar-core'] = { runId: 'run-1', expectedMembers: ['alice', 'bob', 'jack'], statuses: {}, summary: { confirmedCount: 1, pendingCount: 3, failedCount: 0, runtimeAlivePendingCount: 3, }, source: 'merged', }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); await Promise.resolve(); }); const block = host.querySelector('[data-testid="progress-block"]'); expect(block?.textContent).toContain('Finishing launch'); expect(block?.textContent).toContain('2 teammates still joining'); expect(block?.getAttribute('data-success-severity')).toBe('info'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('trusts persisted snapshot member statuses even when expectedMembers and team cache are stale', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.selectedTeamData.members = [{ name: 'team-lead', agentType: 'team-lead' }]; storeState.teamDataCacheByName['northstar-core'] = { members: [...storeState.selectedTeamData.members], }; storeState.memberSpawnStatusesByTeam['northstar-core'] = {}; storeState.memberSpawnSnapshotsByTeam['northstar-core'] = { runId: 'run-1', expectedMembers: [], statuses: { alice: { status: 'online', launchState: 'runtime_pending_bootstrap', updatedAt: '2026-04-09T10:00:00.000Z', runtimeAlive: true, livenessSource: 'process', bootstrapConfirmed: false, hardFailure: false, agentToolAccepted: true, }, }, summary: { confirmedCount: 0, pendingCount: 1, failedCount: 0, runtimeAlivePendingCount: 1, }, source: 'persisted', }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); await Promise.resolve(); }); const block = host.querySelector('[data-testid="progress-block"]'); expect(block?.getAttribute('data-current-step-index')).toBe('2'); expect(block?.textContent).toContain('Finishing launch'); expect(block?.textContent).toContain('1 teammate still joining'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); });