import { beforeEach, describe, expect, it, vi } from 'vitest'; const claudeBinaryResolverClearCacheMock = vi.hoisted(() => vi.fn()); const codexBinaryResolverClearCacheMock = vi.hoisted(() => vi.fn()); vi.mock('@shared/utils/logger', () => ({ createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }), })); vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ ClaudeBinaryResolver: { clearCache: claudeBinaryResolverClearCacheMock, }, })); vi.mock('@main/services/infrastructure/codexAppServer', () => ({ CodexBinaryResolver: { clearCache: codexBinaryResolverClearCacheMock, }, })); import { initializeCliInstallerHandlers, registerCliInstallerHandlers, } from '@main/ipc/cliInstaller'; import { CLI_INSTALLER_GET_PROVIDER_STATUS, CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_INVALIDATE_STATUS, CLI_INSTALLER_VERIFY_PROVIDER_MODELS, } from '@preload/constants/ipcChannels'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; import type { CliInstallerService } from '@main/services'; import type { CliInstallationStatus, CliProviderId, CliProviderStatus, IpcResult, } from '@shared/types'; import type { IpcMain, IpcMainInvokeEvent } from 'electron'; type IpcHandler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown; function deferred(): { promise: Promise; resolve: (value: T) => void; reject: (reason?: unknown) => void; } { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((innerResolve, innerReject) => { resolve = innerResolve; reject = innerReject; }); return { promise, resolve, reject }; } function createMockIpcMain(): IpcMain & { invoke: (channel: string, ...args: unknown[]) => Promise; } { const handlers = new Map(); const ipcMain = { handle: vi.fn((channel: string, handler: IpcHandler) => { handlers.set(channel, handler); }), removeHandler: vi.fn((channel: string) => { handlers.delete(channel); }), invoke: async (channel: string, ...args: unknown[]) => { const handler = handlers.get(channel); if (!handler) { throw new Error(`No handler for ${channel}`); } return await Promise.resolve(handler({} as IpcMainInvokeEvent, ...args)); }, }; return ipcMain as unknown as IpcMain & { invoke: (channel: string, ...args: unknown[]) => Promise; }; } function provider(overrides: Partial & { providerId: CliProviderId }): CliProviderStatus { const { providerId, ...rest } = overrides; return { providerId, displayName: providerId, supported: true, authenticated: false, authMethod: null, verificationState: 'unknown', modelVerificationState: 'idle', modelCatalogRefreshState: 'idle', statusMessage: null, detailMessage: null, models: [], modelAvailability: [], canLoginFromUi: providerId !== 'opencode', capabilities: { teamLaunch: true, oneShot: true, extensions: createDefaultCliExtensionCapabilities(), }, selectedBackendId: null, resolvedBackendId: null, availableBackends: [], externalRuntimeDiagnostics: [], backend: null, connection: null, modelCatalog: null, runtimeCapabilities: null, subscriptionRateLimits: null, ...rest, }; } function status(providers: CliProviderStatus[]): CliInstallationStatus { const authenticatedProvider = providers.find((entry) => entry.authenticated) ?? null; return { flavor: 'agent_teams_orchestrator', displayName: 'Multimodel runtime', supportsSelfUpdate: false, showVersionDetails: false, showBinaryPath: false, installed: true, installedVersion: '0.0.3', binaryPath: '/mock/agent_teams_orchestrator', launchError: null, latestVersion: null, updateAvailable: false, authLoggedIn: authenticatedProvider !== null, authStatusChecking: false, authMethod: authenticatedProvider?.authMethod ?? null, providers, }; } describe('cliInstaller IPC handlers', () => { let ipcMain: ReturnType; let service: { getLatestStatusSnapshot: ReturnType; getStatus: ReturnType; getProviderStatus: ReturnType; verifyProviderModels: ReturnType; install: ReturnType; invalidateStatusCache: ReturnType; }; beforeEach(async () => { vi.clearAllMocks(); ipcMain = createMockIpcMain(); service = { getLatestStatusSnapshot: vi.fn(() => null), getStatus: vi.fn(), getProviderStatus: vi.fn(), verifyProviderModels: vi.fn(), install: vi.fn(), invalidateStatusCache: vi.fn(), }; initializeCliInstallerHandlers(service as unknown as CliInstallerService); registerCliInstallerHandlers(ipcMain); await ipcMain.invoke(CLI_INSTALLER_INVALIDATE_STATUS); vi.clearAllMocks(); }); it('does not let explicit hidden Gemini refresh poison cached frontend auth status', async () => { service.getStatus.mockResolvedValue( status([ provider({ providerId: 'anthropic' }), provider({ providerId: 'codex' }), provider({ providerId: 'opencode', canLoginFromUi: false }), ]) ); service.getProviderStatus.mockResolvedValue( provider({ providerId: 'gemini', authenticated: true, authMethod: 'gemini_api_key', models: ['gemini-2.5-pro'], }) ); const initial = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult; expect(initial.success).toBe(true); expect(initial.data?.providers.map((entry) => entry.providerId)).toEqual([ 'anthropic', 'codex', 'opencode', ]); const gemini = (await ipcMain.invoke( CLI_INSTALLER_GET_PROVIDER_STATUS, 'gemini' )) as IpcResult; expect(gemini.success).toBe(true); expect(gemini.data?.authenticated).toBe(true); const cached = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult; expect(service.getStatus).toHaveBeenCalledTimes(1); expect(cached.success).toBe(true); expect(cached.data?.providers.map((entry) => entry.providerId)).toEqual([ 'anthropic', 'codex', 'opencode', ]); expect(cached.data?.authLoggedIn).toBe(false); expect(cached.data?.authMethod).toBeNull(); }); it('clears Claude and Codex binary resolver caches when status is invalidated', async () => { const result = (await ipcMain.invoke(CLI_INSTALLER_INVALIDATE_STATUS)) as IpcResult; expect(result.success).toBe(true); expect(claudeBinaryResolverClearCacheMock).toHaveBeenCalledTimes(1); expect(codexBinaryResolverClearCacheMock).toHaveBeenCalledTimes(1); expect(service.invalidateStatusCache).toHaveBeenCalledTimes(1); }); it('serializes explicit provider runtime status requests to avoid startup memory spikes', async () => { const codexRequest = deferred(); const opencodeRequest = deferred(); const startedProviders: CliProviderId[] = []; service.getProviderStatus.mockImplementation((providerId: CliProviderId) => { startedProviders.push(providerId); return providerId === 'codex' ? codexRequest.promise : opencodeRequest.promise; }); const codexInvoke = ipcMain.invoke( CLI_INSTALLER_GET_PROVIDER_STATUS, 'codex' ) as Promise>; await vi.waitFor(() => expect(service.getProviderStatus).toHaveBeenCalledTimes(1)); const opencodeInvoke = ipcMain.invoke( CLI_INSTALLER_GET_PROVIDER_STATUS, 'opencode' ) as Promise>; await Promise.resolve(); await Promise.resolve(); expect(startedProviders).toEqual(['codex']); expect(service.getProviderStatus).toHaveBeenCalledTimes(1); codexRequest.resolve(provider({ providerId: 'codex', authenticated: true })); await expect(codexInvoke).resolves.toMatchObject({ success: true, data: { providerId: 'codex' }, }); await vi.waitFor(() => expect(service.getProviderStatus).toHaveBeenCalledTimes(2)); expect(startedProviders).toEqual(['codex', 'opencode']); opencodeRequest.resolve(provider({ providerId: 'opencode', authenticated: true })); await expect(opencodeInvoke).resolves.toMatchObject({ success: true, data: { providerId: 'opencode' }, }); }); it('does not reuse or recache a status request that was in flight before invalidation', async () => { const staleStatus = status([ provider({ providerId: 'codex', verificationState: 'error', statusMessage: 'Codex CLI not found', }), ]); const freshStatus = status([ provider({ providerId: 'codex', authenticated: true, authMethod: 'chatgpt', verificationState: 'verified', statusMessage: 'ChatGPT account ready', }), ]); const staleRequest = deferred(); const freshRequest = deferred(); service.getStatus .mockReturnValueOnce(staleRequest.promise) .mockReturnValueOnce(freshRequest.promise); const firstInvoke = ipcMain.invoke(CLI_INSTALLER_GET_STATUS) as Promise< IpcResult >; await vi.waitFor(() => expect(service.getStatus).toHaveBeenCalledTimes(1)); await ipcMain.invoke(CLI_INSTALLER_INVALIDATE_STATUS); const secondInvoke = ipcMain.invoke(CLI_INSTALLER_GET_STATUS) as Promise< IpcResult >; await vi.waitFor(() => expect(service.getStatus).toHaveBeenCalledTimes(2)); staleRequest.resolve(staleStatus); freshRequest.resolve(freshStatus); await expect(firstInvoke).resolves.toMatchObject({ success: true, data: { authLoggedIn: false }, }); await expect(secondInvoke).resolves.toMatchObject({ success: true, data: { authLoggedIn: true, authMethod: 'chatgpt' }, }); const cached = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult; expect(service.getStatus).toHaveBeenCalledTimes(2); expect(cached.success).toBe(true); expect(cached.data?.authLoggedIn).toBe(true); expect(cached.data?.providers[0]?.statusMessage).toBe('ChatGPT account ready'); }); it('keeps lightweight startup status cache separate from full provider status cache', async () => { const deferredStartupStatus = status([ provider({ providerId: 'anthropic', supported: false, statusMessage: 'Provider status will refresh when needed.', }), provider({ providerId: 'codex', supported: false, statusMessage: 'Provider status will refresh when needed.', }), ]); const fullStatus = status([ provider({ providerId: 'anthropic', authenticated: true, authMethod: 'oauth_token', verificationState: 'verified', statusMessage: 'Connected', }), provider({ providerId: 'codex', authenticated: true, authMethod: 'chatgpt', verificationState: 'verified', statusMessage: 'ChatGPT account ready', }), ]); const startupRequest = deferred(); const fullRequest = deferred(); service.getStatus.mockImplementation((options?: { providerStatusMode?: string }) => options?.providerStatusMode === 'defer' ? startupRequest.promise : fullRequest.promise ); const startupInvoke = ipcMain.invoke(CLI_INSTALLER_GET_STATUS, { providerStatusMode: 'defer', }) as Promise>; const fullInvoke = ipcMain.invoke(CLI_INSTALLER_GET_STATUS) as Promise< IpcResult >; await vi.waitFor(() => expect(service.getStatus).toHaveBeenCalledTimes(2)); startupRequest.resolve(deferredStartupStatus); fullRequest.resolve(fullStatus); await expect(startupInvoke).resolves.toMatchObject({ success: true, data: { authLoggedIn: false }, }); await expect(fullInvoke).resolves.toMatchObject({ success: true, data: { authLoggedIn: true, authMethod: 'oauth_token' }, }); const cachedStartup = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS, { providerStatusMode: 'defer', })) as IpcResult; const cachedFull = (await ipcMain.invoke( CLI_INSTALLER_GET_STATUS )) as IpcResult; expect(service.getStatus).toHaveBeenCalledTimes(2); expect(cachedStartup.data?.authLoggedIn).toBe(false); expect(cachedStartup.data?.providers[0]?.statusMessage).toBe( 'Provider status will refresh when needed.' ); expect(cachedFull.data?.authLoggedIn).toBe(true); expect(cachedFull.data?.providers[1]?.statusMessage).toBe('ChatGPT account ready'); }); it('does not replace a cached full provider status with a deferred startup snapshot', async () => { const fullStatus = status([ provider({ providerId: 'anthropic', authenticated: true, authMethod: 'oauth_token', verificationState: 'verified', statusMessage: 'Connected', }), ]); const deferredStartupStatus = status([ provider({ providerId: 'anthropic', supported: false, verificationState: 'unknown', statusMessage: 'Provider status will refresh when needed.', }), ]); service.getStatus.mockResolvedValueOnce(fullStatus); const first = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult; expect(first.success).toBe(true); expect(first.data?.providers[0]?.statusMessage).toBe('Connected'); service.getLatestStatusSnapshot.mockReturnValue(deferredStartupStatus); const cached = (await ipcMain.invoke( CLI_INSTALLER_GET_STATUS )) as IpcResult; expect(service.getStatus).toHaveBeenCalledTimes(1); expect(cached.success).toBe(true); expect(cached.data?.authLoggedIn).toBe(true); expect(cached.data?.providers[0]?.statusMessage).toBe('Connected'); }); it('does not cache incomplete full provider status responses', async () => { const incompleteFullStatus = status([ provider({ providerId: 'anthropic', supported: false, verificationState: 'unknown', statusMessage: 'Provider status will refresh when needed.', }), provider({ providerId: 'codex', supported: false, verificationState: 'unknown', statusMessage: 'Checking...', }), ]); const freshFullStatus = status([ provider({ providerId: 'anthropic', authenticated: true, authMethod: 'oauth_token', verificationState: 'verified', statusMessage: 'Connected', }), provider({ providerId: 'codex', authenticated: true, authMethod: 'chatgpt', verificationState: 'verified', statusMessage: 'ChatGPT account ready', }), ]); incompleteFullStatus.authStatusChecking = true; service.getStatus .mockResolvedValueOnce(incompleteFullStatus) .mockResolvedValueOnce(freshFullStatus); const first = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult; const second = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult; expect(service.getStatus).toHaveBeenCalledTimes(2); expect(first.success).toBe(true); expect(first.data?.providers[0]?.statusMessage).toBe( 'Provider status will refresh when needed.' ); expect(second.success).toBe(true); expect(second.data?.authLoggedIn).toBe(true); expect(second.data?.providers[0]?.statusMessage).toBe('Connected'); }); it('does not let a stale in-flight provider refresh patch the cache after invalidation', async () => { const staleProviderRequest = deferred(); service.getStatus .mockResolvedValueOnce( status([ provider({ providerId: 'anthropic' }), provider({ providerId: 'codex', statusMessage: 'Checking...' }), ]) ) .mockResolvedValueOnce( status([ provider({ providerId: 'anthropic' }), provider({ providerId: 'codex', authenticated: true, authMethod: 'chatgpt', verificationState: 'verified', statusMessage: 'ChatGPT account ready', }), ]) ); service.getProviderStatus.mockReturnValueOnce(staleProviderRequest.promise); const initial = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult; expect(initial.success).toBe(true); expect(initial.data?.authLoggedIn).toBe(false); const staleProviderInvoke = ipcMain.invoke( CLI_INSTALLER_GET_PROVIDER_STATUS, 'codex' ) as Promise>; await vi.waitFor(() => expect(service.getProviderStatus).toHaveBeenCalledTimes(1)); await ipcMain.invoke(CLI_INSTALLER_INVALIDATE_STATUS); const fresh = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult; expect(fresh.success).toBe(true); expect(fresh.data?.authLoggedIn).toBe(true); staleProviderRequest.resolve( provider({ providerId: 'codex', verificationState: 'error', statusMessage: 'Codex CLI not found', }) ); await expect(staleProviderInvoke).resolves.toMatchObject({ success: true, data: { statusMessage: 'Codex CLI not found' }, }); const cached = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult; expect(service.getStatus).toHaveBeenCalledTimes(2); expect(cached.success).toBe(true); expect(cached.data?.authLoggedIn).toBe(true); expect(cached.data?.providers.find((entry) => entry.providerId === 'codex')?.statusMessage).toBe( 'ChatGPT account ready' ); }); it('does not let a stale model verification patch the cache after invalidation', async () => { const staleVerificationRequest = deferred(); service.getStatus .mockResolvedValueOnce( status([ provider({ providerId: 'anthropic' }), provider({ providerId: 'codex', statusMessage: 'Checking...' }), ]) ) .mockResolvedValueOnce( status([ provider({ providerId: 'anthropic' }), provider({ providerId: 'codex', authenticated: true, authMethod: 'chatgpt', verificationState: 'verified', statusMessage: 'ChatGPT account ready', }), ]) ); service.verifyProviderModels.mockReturnValueOnce(staleVerificationRequest.promise); const initial = (await ipcMain.invoke( CLI_INSTALLER_GET_STATUS )) as IpcResult; expect(initial.success).toBe(true); expect(initial.data?.authLoggedIn).toBe(false); const staleVerificationInvoke = ipcMain.invoke( CLI_INSTALLER_VERIFY_PROVIDER_MODELS, 'codex' ) as Promise>; await vi.waitFor(() => expect(service.verifyProviderModels).toHaveBeenCalledTimes(1)); await ipcMain.invoke(CLI_INSTALLER_INVALIDATE_STATUS); const fresh = (await ipcMain.invoke( CLI_INSTALLER_GET_STATUS )) as IpcResult; expect(fresh.success).toBe(true); expect(fresh.data?.authLoggedIn).toBe(true); staleVerificationRequest.resolve( provider({ providerId: 'codex', verificationState: 'error', statusMessage: 'Stale model verification failed', }) ); await expect(staleVerificationInvoke).resolves.toMatchObject({ success: true, data: { statusMessage: 'Stale model verification failed' }, }); const cached = (await ipcMain.invoke( CLI_INSTALLER_GET_STATUS )) as IpcResult; expect(service.getStatus).toHaveBeenCalledTimes(2); expect(cached.success).toBe(true); expect(cached.data?.authLoggedIn).toBe(true); expect(cached.data?.providers.find((entry) => entry.providerId === 'codex')?.statusMessage).toBe( 'ChatGPT account ready' ); }); });