agent-ecosystem/test/main/ipc/cliInstaller.test.ts

598 lines
20 KiB
TypeScript

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<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((innerResolve, innerReject) => {
resolve = innerResolve;
reject = innerReject;
});
return { promise, resolve, reject };
}
function createMockIpcMain(): IpcMain & {
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
} {
const handlers = new Map<string, IpcHandler>();
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<unknown>;
};
}
function provider(overrides: Partial<CliProviderStatus> & { 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<typeof createMockIpcMain>;
let service: {
getLatestStatusSnapshot: ReturnType<typeof vi.fn>;
getStatus: ReturnType<typeof vi.fn>;
getProviderStatus: ReturnType<typeof vi.fn>;
verifyProviderModels: ReturnType<typeof vi.fn>;
install: ReturnType<typeof vi.fn>;
invalidateStatusCache: ReturnType<typeof vi.fn>;
};
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<CliInstallationStatus>;
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<CliProviderStatus | null>;
expect(gemini.success).toBe(true);
expect(gemini.data?.authenticated).toBe(true);
const cached = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult<CliInstallationStatus>;
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<void>;
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<CliProviderStatus>();
const opencodeRequest = deferred<CliProviderStatus>();
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<IpcResult<CliProviderStatus | null>>;
await vi.waitFor(() => expect(service.getProviderStatus).toHaveBeenCalledTimes(1));
const opencodeInvoke = ipcMain.invoke(
CLI_INSTALLER_GET_PROVIDER_STATUS,
'opencode'
) as Promise<IpcResult<CliProviderStatus | null>>;
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<CliInstallationStatus>();
const freshRequest = deferred<CliInstallationStatus>();
service.getStatus
.mockReturnValueOnce(staleRequest.promise)
.mockReturnValueOnce(freshRequest.promise);
const firstInvoke = ipcMain.invoke(CLI_INSTALLER_GET_STATUS) as Promise<
IpcResult<CliInstallationStatus>
>;
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<CliInstallationStatus>
>;
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<CliInstallationStatus>;
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<CliInstallationStatus>();
const fullRequest = deferred<CliInstallationStatus>();
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<IpcResult<CliInstallationStatus>>;
const fullInvoke = ipcMain.invoke(CLI_INSTALLER_GET_STATUS) as Promise<
IpcResult<CliInstallationStatus>
>;
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<CliInstallationStatus>;
const cachedFull = (await ipcMain.invoke(
CLI_INSTALLER_GET_STATUS
)) as IpcResult<CliInstallationStatus>;
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<CliInstallationStatus>;
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<CliInstallationStatus>;
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<CliInstallationStatus>;
const second = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult<CliInstallationStatus>;
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<CliProviderStatus | null>();
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<CliInstallationStatus>;
expect(initial.success).toBe(true);
expect(initial.data?.authLoggedIn).toBe(false);
const staleProviderInvoke = ipcMain.invoke(
CLI_INSTALLER_GET_PROVIDER_STATUS,
'codex'
) as Promise<IpcResult<CliProviderStatus | null>>;
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<CliInstallationStatus>;
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<CliInstallationStatus>;
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<CliProviderStatus | null>();
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<CliInstallationStatus>;
expect(initial.success).toBe(true);
expect(initial.data?.authLoggedIn).toBe(false);
const staleVerificationInvoke = ipcMain.invoke(
CLI_INSTALLER_VERIFY_PROVIDER_MODELS,
'codex'
) as Promise<IpcResult<CliProviderStatus | null>>;
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<CliInstallationStatus>;
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<CliInstallationStatus>;
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'
);
});
});