1009 lines
36 KiB
TypeScript
1009 lines
36 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const { realpathMock } = vi.hoisted(() => ({
|
|
realpathMock: vi.fn(async (value: string) => value),
|
|
}));
|
|
|
|
// Mock dependencies before importing service
|
|
vi.mock('@main/utils/childProcess', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@main/utils/childProcess')>();
|
|
return {
|
|
...actual,
|
|
execCli: vi.fn().mockRejectedValue(new Error('execCli not configured')),
|
|
};
|
|
});
|
|
|
|
vi.mock('fs', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('fs')>();
|
|
return {
|
|
...actual,
|
|
existsSync: vi.fn(() => false),
|
|
createWriteStream: vi.fn(() => ({
|
|
write: vi.fn(),
|
|
end: vi.fn((cb: () => void) => cb()),
|
|
destroy: vi.fn(),
|
|
on: vi.fn(),
|
|
})),
|
|
promises: {
|
|
...actual.promises,
|
|
chmod: vi.fn(),
|
|
realpath: realpathMock,
|
|
unlink: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('https', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('https')>();
|
|
return {
|
|
...actual,
|
|
default: {
|
|
...actual,
|
|
get: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('http', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('http')>();
|
|
return {
|
|
...actual,
|
|
default: {
|
|
...actual,
|
|
get: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
|
ClaudeBinaryResolver: {
|
|
resolve: vi.fn(),
|
|
clearCache: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('@main/services/team/cliFlavor', () => ({
|
|
getConfiguredCliFlavor: vi.fn(() => 'claude'),
|
|
getCliFlavorUiOptions: vi.fn(() => ({
|
|
displayName: 'Claude CLI',
|
|
supportsSelfUpdate: true,
|
|
showVersionDetails: true,
|
|
showBinaryPath: true,
|
|
})),
|
|
}));
|
|
|
|
vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
|
|
buildProviderAwareCliEnv: vi.fn(async () => ({
|
|
env: { HOME: '/Users/tester' },
|
|
connectionIssues: {},
|
|
})),
|
|
}));
|
|
|
|
import {
|
|
CliInstallerService,
|
|
isVersionOlder,
|
|
normalizeVersion,
|
|
} from '@main/services/infrastructure/CliInstallerService';
|
|
import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
|
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
|
import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '@main/services/team/cliFlavor';
|
|
import { execCli } from '@main/utils/childProcess';
|
|
|
|
/**
|
|
* Helper: allow expected console.error/warn calls in tests where service logs errors.
|
|
* The test setup asserts no unexpected console.error/warn, so we re-spy to capture them.
|
|
*/
|
|
function allowConsoleLogs(): void {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
}
|
|
|
|
describe('CliInstallerService', () => {
|
|
let service: CliInstallerService;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
realpathMock.mockReset();
|
|
realpathMock.mockImplementation(async (value: string) => value);
|
|
vi.mocked(getConfiguredCliFlavor).mockReturnValue('claude');
|
|
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
|
displayName: 'Claude CLI',
|
|
supportsSelfUpdate: true,
|
|
showVersionDetails: true,
|
|
showBinaryPath: true,
|
|
});
|
|
service = new CliInstallerService();
|
|
});
|
|
|
|
describe('getStatus', () => {
|
|
it('returns not installed when binary is not found', async () => {
|
|
allowConsoleLogs();
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue(null);
|
|
|
|
const status = await service.getStatus();
|
|
|
|
expect(status.installed).toBe(false);
|
|
expect(status.installedVersion).toBeNull();
|
|
expect(status.binaryPath).toBeNull();
|
|
expect(status.updateAvailable).toBe(false);
|
|
});
|
|
|
|
it('includes frontend-visible providers in unavailable multimodel bootstrap status', async () => {
|
|
allowConsoleLogs();
|
|
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
|
|
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
|
displayName: 'agent_teams_orchestrator',
|
|
supportsSelfUpdate: false,
|
|
showVersionDetails: false,
|
|
showBinaryPath: false,
|
|
});
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue(null);
|
|
|
|
const status = await service.getStatus();
|
|
const openCodeStatus = status.providers.find(
|
|
(provider) => provider.providerId === 'opencode'
|
|
);
|
|
|
|
expect(status.providers.map((provider) => provider.providerId)).toEqual([
|
|
'anthropic',
|
|
'codex',
|
|
'opencode',
|
|
]);
|
|
expect(openCodeStatus).toMatchObject({
|
|
displayName: 'OpenCode (200+ models)',
|
|
supported: false,
|
|
statusMessage: 'Runtime not found.',
|
|
canLoginFromUi: false,
|
|
});
|
|
});
|
|
|
|
it('does not expose hidden Gemini in frontend multimodel authentication snapshots', async () => {
|
|
allowConsoleLogs();
|
|
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
|
|
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
|
displayName: 'agent_teams_orchestrator',
|
|
supportsSelfUpdate: false,
|
|
showVersionDetails: false,
|
|
showBinaryPath: false,
|
|
});
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
|
vi.mocked(execCli).mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' });
|
|
|
|
const providers = [
|
|
{
|
|
providerId: 'anthropic',
|
|
displayName: 'Anthropic',
|
|
supported: true,
|
|
authenticated: false,
|
|
authMethod: null,
|
|
verificationState: 'unknown',
|
|
modelVerificationState: 'idle',
|
|
statusMessage: null,
|
|
models: [],
|
|
modelAvailability: [],
|
|
canLoginFromUi: true,
|
|
capabilities: { teamLaunch: true, oneShot: true, extensions: undefined as never },
|
|
backend: null,
|
|
},
|
|
{
|
|
providerId: 'codex',
|
|
displayName: 'Codex',
|
|
supported: true,
|
|
authenticated: false,
|
|
authMethod: null,
|
|
verificationState: 'unknown',
|
|
modelVerificationState: 'idle',
|
|
statusMessage: null,
|
|
models: [],
|
|
modelAvailability: [],
|
|
canLoginFromUi: true,
|
|
capabilities: { teamLaunch: true, oneShot: true, extensions: undefined as never },
|
|
backend: null,
|
|
},
|
|
{
|
|
providerId: 'gemini',
|
|
displayName: 'Gemini',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'gemini_api_key',
|
|
verificationState: 'verified',
|
|
modelVerificationState: 'idle',
|
|
statusMessage: null,
|
|
models: ['gemini-2.5-pro'],
|
|
modelAvailability: [],
|
|
canLoginFromUi: true,
|
|
capabilities: { teamLaunch: true, oneShot: true, extensions: undefined as never },
|
|
backend: { kind: 'api', label: 'Gemini API' },
|
|
},
|
|
{
|
|
providerId: 'opencode',
|
|
displayName: 'OpenCode',
|
|
supported: true,
|
|
authenticated: false,
|
|
authMethod: null,
|
|
verificationState: 'unknown',
|
|
modelVerificationState: 'idle',
|
|
statusMessage: null,
|
|
models: [],
|
|
modelAvailability: [],
|
|
canLoginFromUi: false,
|
|
capabilities: { teamLaunch: true, oneShot: false, extensions: undefined as never },
|
|
backend: null,
|
|
},
|
|
];
|
|
vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses').mockImplementation(
|
|
async (_binaryPath, onUpdate) => {
|
|
onUpdate?.(providers as never);
|
|
return providers as never;
|
|
}
|
|
);
|
|
|
|
const status = await service.getStatus();
|
|
|
|
expect(status.providers.map((provider) => provider.providerId)).toEqual([
|
|
'anthropic',
|
|
'codex',
|
|
'opencode',
|
|
]);
|
|
expect(status.authLoggedIn).toBe(false);
|
|
expect(status.authMethod).toBeNull();
|
|
expect(
|
|
service
|
|
.getLatestStatusSnapshot()
|
|
?.providers.some((provider) => provider.providerId === 'gemini')
|
|
).toBe(false);
|
|
expect(service.getLatestStatusSnapshot()?.authLoggedIn).toBe(false);
|
|
});
|
|
|
|
it('does not mark the CLI installed when the version probe cannot confirm the binary', async () => {
|
|
allowConsoleLogs();
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
|
|
|
const status = await service.getStatus();
|
|
|
|
expect(status.installed).toBe(false);
|
|
expect(status.binaryPath).toBe('/usr/local/bin/claude');
|
|
expect(status.installedVersion).toBeNull();
|
|
});
|
|
|
|
it('retries the version probe once before marking the runtime unhealthy', async () => {
|
|
allowConsoleLogs();
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
|
vi.mocked(execCli)
|
|
.mockRejectedValueOnce(new Error('Command failed: /usr/local/bin/claude --version'))
|
|
.mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' })
|
|
.mockResolvedValueOnce({
|
|
stdout: '{"loggedIn":true,"authMethod":"oauth_token"}',
|
|
stderr: '',
|
|
});
|
|
|
|
const status = await service.getStatus();
|
|
|
|
expect(status.installed).toBe(true);
|
|
expect(status.installedVersion).toBe('2.3.4');
|
|
expect(execCli).toHaveBeenNthCalledWith(
|
|
1,
|
|
'/usr/local/bin/claude',
|
|
['--version'],
|
|
expect.objectContaining({ timeout: expect.any(Number) })
|
|
);
|
|
expect(execCli).toHaveBeenNthCalledWith(
|
|
2,
|
|
'/usr/local/bin/claude',
|
|
['--version'],
|
|
expect.objectContaining({ timeout: expect.any(Number) })
|
|
);
|
|
});
|
|
|
|
it('reuses the last healthy runtime snapshot when a later version probe fails transiently', async () => {
|
|
allowConsoleLogs();
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
|
vi.mocked(execCli)
|
|
.mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' })
|
|
.mockResolvedValueOnce({
|
|
stdout: '{"loggedIn":true,"authMethod":"oauth_token"}',
|
|
stderr: '',
|
|
})
|
|
.mockRejectedValueOnce(new Error('Command failed: /usr/local/bin/claude --version'))
|
|
.mockRejectedValueOnce(new Error('Command failed: /usr/local/bin/claude --version'));
|
|
|
|
const firstStatus = await service.getStatus();
|
|
const secondStatus = await service.getStatus();
|
|
|
|
expect(firstStatus.installed).toBe(true);
|
|
expect(firstStatus.installedVersion).toBe('2.3.4');
|
|
expect(secondStatus.installed).toBe(true);
|
|
expect(secondStatus.installedVersion).toBe('2.3.4');
|
|
expect(secondStatus.launchError).toBeNull();
|
|
});
|
|
|
|
it('handles spawn EINVAL when binary path contains non-ASCII by falling back', async () => {
|
|
allowConsoleLogs();
|
|
const fakePath = 'C:\\Users\\Алексей\\AppData\\Roaming\\npm\\claude.cmd';
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue(fakePath);
|
|
|
|
// execCli handles the EINVAL → shell fallback internally;
|
|
// here we just verify the service delegates to execCli correctly.
|
|
vi.mocked(execCli)
|
|
.mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' }) // --version
|
|
.mockResolvedValueOnce({ stdout: '{}', stderr: '' }); // auth status
|
|
|
|
const status = await service.getStatus();
|
|
expect(status.installed).toBe(true);
|
|
expect(status.installedVersion).toBe('2.3.4');
|
|
expect(execCli).toHaveBeenCalledWith(
|
|
fakePath,
|
|
['--version'],
|
|
expect.objectContaining({ timeout: expect.any(Number) })
|
|
);
|
|
});
|
|
|
|
it('treats auth as logged in when JSON is embedded after stdout noise', async () => {
|
|
allowConsoleLogs();
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
|
vi.mocked(execCli)
|
|
.mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' })
|
|
.mockResolvedValueOnce({
|
|
stdout: 'notice: something\n{"loggedIn":true,"authMethod":"oauth_token"}\n',
|
|
stderr: '',
|
|
});
|
|
|
|
const status = await service.getStatus();
|
|
expect(status.authLoggedIn).toBe(true);
|
|
expect(status.authMethod).toBe('oauth_token');
|
|
});
|
|
|
|
it('falls back to the installed launcher path when --version reports unknown', async () => {
|
|
allowConsoleLogs();
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/Users/tester/.local/bin/claude');
|
|
vi.spyOn(service as never, 'inferInstalledCliVersionFromPath').mockResolvedValue('2.1.101');
|
|
vi.mocked(execCli)
|
|
.mockResolvedValueOnce({ stdout: 'unknown', stderr: '' })
|
|
.mockResolvedValueOnce({
|
|
stdout: '{"loggedIn":true,"authMethod":"oauth_token"}',
|
|
stderr: '',
|
|
});
|
|
|
|
const status = await service.getStatus();
|
|
|
|
expect(status.installed).toBe(true);
|
|
expect(status.installedVersion).toBe('2.1.101');
|
|
expect(status.authLoggedIn).toBe(true);
|
|
});
|
|
|
|
it('publishes probe-enriched runtime model status snapshots only for explicit verification requests', async () => {
|
|
allowConsoleLogs();
|
|
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
|
|
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
|
displayName: 'agent_teams_orchestrator',
|
|
supportsSelfUpdate: false,
|
|
showVersionDetails: false,
|
|
showBinaryPath: false,
|
|
});
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
|
|
|
vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses').mockImplementation(
|
|
async (_binaryPath, onUpdate) => {
|
|
const providers = [
|
|
{
|
|
providerId: 'anthropic',
|
|
displayName: 'Anthropic',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'oauth_token',
|
|
verificationState: 'verified',
|
|
modelVerificationState: 'idle',
|
|
statusMessage: null,
|
|
models: [],
|
|
modelAvailability: [],
|
|
canLoginFromUi: true,
|
|
capabilities: { teamLaunch: true, oneShot: true },
|
|
backend: null,
|
|
},
|
|
{
|
|
providerId: 'codex',
|
|
displayName: 'Codex',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'oauth_token',
|
|
verificationState: 'verified',
|
|
modelVerificationState: 'idle',
|
|
statusMessage: null,
|
|
models: ['gpt-5.4', 'gpt-5.4-mini'],
|
|
modelAvailability: [],
|
|
canLoginFromUi: true,
|
|
capabilities: { teamLaunch: true, oneShot: true },
|
|
backend: {
|
|
kind: 'openai',
|
|
label: 'OpenAI',
|
|
endpointLabel: 'chatgpt.com/backend-api/codex/responses',
|
|
},
|
|
},
|
|
{
|
|
providerId: 'gemini',
|
|
displayName: 'Gemini',
|
|
supported: false,
|
|
authenticated: false,
|
|
authMethod: null,
|
|
verificationState: 'unknown',
|
|
modelVerificationState: 'idle',
|
|
statusMessage: null,
|
|
models: [],
|
|
modelAvailability: [],
|
|
canLoginFromUi: true,
|
|
capabilities: { teamLaunch: false, oneShot: false },
|
|
backend: null,
|
|
},
|
|
];
|
|
onUpdate?.(providers as never);
|
|
return providers as never;
|
|
}
|
|
);
|
|
|
|
vi.mocked(execCli).mockImplementation(async (_binaryPath, args) => {
|
|
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
|
if (normalizedArgs === '--version') {
|
|
return { stdout: '2.3.4', stderr: '' };
|
|
}
|
|
if (normalizedArgs.includes('--model gpt-5.4-mini')) {
|
|
throw new Error("The 'gpt-5.4-mini' model is not supported in this Codex runtime.");
|
|
}
|
|
if (normalizedArgs.includes('--model gpt-5.4')) {
|
|
return { stdout: 'PONG', stderr: '' };
|
|
}
|
|
throw new Error(`Unexpected execCli call: ${normalizedArgs}`);
|
|
});
|
|
|
|
const mockWindow = {
|
|
isDestroyed: () => false,
|
|
webContents: { send: vi.fn(), isDestroyed: () => false },
|
|
};
|
|
service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow);
|
|
|
|
const status = await service.getStatus();
|
|
expect(
|
|
status.providers.find((provider) => provider.providerId === 'codex')?.modelAvailability
|
|
).toEqual([]);
|
|
|
|
const verifiedProvider = await service.verifyProviderModels('codex');
|
|
expect(verifiedProvider?.modelAvailability).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ modelId: 'gpt-5.4', status: 'checking' }),
|
|
expect.objectContaining({ modelId: 'gpt-5.4-mini', status: 'checking' }),
|
|
])
|
|
);
|
|
expect(verifiedProvider?.modelAvailability).not.toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ modelId: 'gpt-5.2-codex' })])
|
|
);
|
|
|
|
await vi.waitFor(() => {
|
|
const latestCodexProvider = service
|
|
.getLatestStatusSnapshot()
|
|
?.providers.find((provider) => provider.providerId === 'codex');
|
|
|
|
expect(latestCodexProvider?.modelAvailability).toEqual([
|
|
expect.objectContaining({ modelId: 'gpt-5.4', status: 'available' }),
|
|
expect.objectContaining({
|
|
modelId: 'gpt-5.4-mini',
|
|
status: 'unavailable',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
expect(execCli).not.toHaveBeenCalledWith(
|
|
'/usr/local/bin/claude',
|
|
expect.arrayContaining(['--model', 'gpt-5.2-codex']),
|
|
expect.anything()
|
|
);
|
|
|
|
const statusEvents = mockWindow.webContents.send.mock.calls
|
|
.filter((call: unknown[]) => call[0] === 'cliInstaller:progress')
|
|
.map((call: unknown[]) => call[1] as { type?: string; status?: { providers?: unknown[] } })
|
|
.filter((event) => event.type === 'status');
|
|
|
|
expect(statusEvents.length).toBeGreaterThan(1);
|
|
expect(
|
|
statusEvents.some((event) =>
|
|
event.status?.providers?.some(
|
|
(provider) =>
|
|
typeof provider === 'object' &&
|
|
provider !== null &&
|
|
'providerId' in provider &&
|
|
'modelAvailability' in provider &&
|
|
(provider as { providerId?: string }).providerId === 'codex' &&
|
|
Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) &&
|
|
(
|
|
provider as { modelAvailability: Array<{ modelId?: string; status?: string }> }
|
|
).modelAvailability.some(
|
|
(item) => item.modelId === 'gpt-5.4' && item.status === 'available'
|
|
)
|
|
)
|
|
)
|
|
).toBe(true);
|
|
expect(
|
|
statusEvents.some((event) =>
|
|
event.status?.providers?.some(
|
|
(provider) =>
|
|
typeof provider === 'object' &&
|
|
provider !== null &&
|
|
'providerId' in provider &&
|
|
'modelAvailability' in provider &&
|
|
(provider as { providerId?: string }).providerId === 'codex' &&
|
|
Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) &&
|
|
(
|
|
provider as { modelAvailability: Array<{ modelId?: string }> }
|
|
).modelAvailability.some((item) => item.modelId === 'gpt-5.2-codex')
|
|
)
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
it('keeps OpenCode provider verification catalog-only for explicit verify requests', async () => {
|
|
allowConsoleLogs();
|
|
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
|
|
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
|
displayName: 'agent_teams_orchestrator',
|
|
supportsSelfUpdate: false,
|
|
showVersionDetails: false,
|
|
showBinaryPath: false,
|
|
});
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
|
|
|
vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses').mockResolvedValue([
|
|
{
|
|
providerId: 'anthropic',
|
|
displayName: 'Anthropic',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'oauth_token',
|
|
verificationState: 'verified',
|
|
modelVerificationState: 'idle',
|
|
statusMessage: null,
|
|
detailMessage: null,
|
|
models: [],
|
|
modelAvailability: [],
|
|
canLoginFromUi: true,
|
|
capabilities: { teamLaunch: true, oneShot: true, extensions: undefined as never },
|
|
selectedBackendId: null,
|
|
resolvedBackendId: null,
|
|
availableBackends: [],
|
|
externalRuntimeDiagnostics: [],
|
|
backend: null,
|
|
connection: null,
|
|
},
|
|
{
|
|
providerId: 'codex',
|
|
displayName: 'Codex',
|
|
supported: false,
|
|
authenticated: false,
|
|
authMethod: null,
|
|
verificationState: 'unknown',
|
|
modelVerificationState: 'idle',
|
|
statusMessage: null,
|
|
detailMessage: null,
|
|
models: [],
|
|
modelAvailability: [],
|
|
canLoginFromUi: true,
|
|
capabilities: { teamLaunch: false, oneShot: false, extensions: undefined as never },
|
|
selectedBackendId: null,
|
|
resolvedBackendId: null,
|
|
availableBackends: [],
|
|
externalRuntimeDiagnostics: [],
|
|
backend: null,
|
|
connection: null,
|
|
},
|
|
{
|
|
providerId: 'gemini',
|
|
displayName: 'Gemini',
|
|
supported: false,
|
|
authenticated: false,
|
|
authMethod: null,
|
|
verificationState: 'unknown',
|
|
modelVerificationState: 'idle',
|
|
statusMessage: null,
|
|
detailMessage: null,
|
|
models: [],
|
|
modelAvailability: [],
|
|
canLoginFromUi: true,
|
|
capabilities: { teamLaunch: false, oneShot: false, extensions: undefined as never },
|
|
selectedBackendId: null,
|
|
resolvedBackendId: null,
|
|
availableBackends: [],
|
|
externalRuntimeDiagnostics: [],
|
|
backend: null,
|
|
connection: null,
|
|
},
|
|
{
|
|
providerId: 'opencode',
|
|
displayName: 'OpenCode',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'opencode_managed',
|
|
verificationState: 'verified',
|
|
modelVerificationState: 'idle',
|
|
statusMessage: null,
|
|
detailMessage: null,
|
|
models: ['openai/gpt-5.4-mini', 'opencode/big-pickle'],
|
|
modelAvailability: [],
|
|
canLoginFromUi: false,
|
|
capabilities: { teamLaunch: false, oneShot: false, extensions: undefined as never },
|
|
selectedBackendId: null,
|
|
resolvedBackendId: null,
|
|
availableBackends: [],
|
|
externalRuntimeDiagnostics: [],
|
|
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
|
connection: null,
|
|
},
|
|
] as never);
|
|
|
|
vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'verifyProviderStatus').mockResolvedValue({
|
|
providerId: 'opencode',
|
|
displayName: 'OpenCode',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'opencode_managed',
|
|
verificationState: 'verified',
|
|
modelVerificationState: 'idle',
|
|
statusMessage: null,
|
|
detailMessage: null,
|
|
models: ['openai/gpt-5.4-mini', 'opencode/big-pickle'],
|
|
modelAvailability: [],
|
|
canLoginFromUi: false,
|
|
capabilities: { teamLaunch: false, oneShot: false, extensions: undefined as never },
|
|
selectedBackendId: null,
|
|
resolvedBackendId: null,
|
|
availableBackends: [],
|
|
externalRuntimeDiagnostics: [],
|
|
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
|
connection: null,
|
|
} as never);
|
|
|
|
const verifyOpenCodeModelsSpy = vi
|
|
.spyOn(ClaudeMultimodelBridgeService.prototype, 'verifyOpenCodeModels')
|
|
.mockResolvedValue({
|
|
providerId: 'opencode',
|
|
displayName: 'OpenCode',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'opencode_managed',
|
|
verificationState: 'verified',
|
|
modelVerificationState: 'verified',
|
|
statusMessage: null,
|
|
detailMessage: null,
|
|
models: ['openai/gpt-5.4-mini', 'opencode/big-pickle'],
|
|
modelAvailability: [
|
|
{
|
|
modelId: 'openai/gpt-5.4-mini',
|
|
status: 'unavailable',
|
|
reason: 'Token refresh failed: 401',
|
|
},
|
|
{
|
|
modelId: 'opencode/big-pickle',
|
|
status: 'available',
|
|
reason: null,
|
|
},
|
|
],
|
|
canLoginFromUi: false,
|
|
capabilities: { teamLaunch: false, oneShot: false, extensions: undefined as never },
|
|
selectedBackendId: null,
|
|
resolvedBackendId: null,
|
|
availableBackends: [],
|
|
externalRuntimeDiagnostics: [],
|
|
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
|
connection: null,
|
|
} as never);
|
|
|
|
vi.mocked(execCli).mockImplementation(async (_binaryPath, args) => {
|
|
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
|
if (normalizedArgs === '--version') {
|
|
return { stdout: '2.3.4', stderr: '' };
|
|
}
|
|
throw new Error(`Unexpected execCli call: ${normalizedArgs}`);
|
|
});
|
|
|
|
const status = await service.getStatus();
|
|
expect(
|
|
status.providers.find((provider) => provider.providerId === 'opencode')?.modelAvailability
|
|
).toEqual([]);
|
|
|
|
const verifiedProvider = await service.verifyProviderModels('opencode');
|
|
|
|
expect(verifyOpenCodeModelsSpy).not.toHaveBeenCalled();
|
|
expect(verifiedProvider?.modelVerificationState).toBe('idle');
|
|
expect(verifiedProvider?.modelAvailability).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('install mutex', () => {
|
|
it('prevents concurrent installations', async () => {
|
|
allowConsoleLogs();
|
|
|
|
const mockWindow = {
|
|
isDestroyed: () => false,
|
|
webContents: { send: vi.fn(), isDestroyed: () => false },
|
|
};
|
|
service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow);
|
|
|
|
// Start first install (will fail on fetch — that's fine for mutex test)
|
|
const promise1 = service.install();
|
|
// Start second install immediately — should get "already in progress"
|
|
const promise2 = service.install();
|
|
|
|
await Promise.allSettled([promise1, promise2]);
|
|
|
|
// Second call should send "already in progress" error
|
|
const progressCalls = mockWindow.webContents.send.mock.calls;
|
|
const errorCalls = progressCalls.filter(
|
|
(call: unknown[]) =>
|
|
(call[0] as string) === 'cliInstaller:progress' &&
|
|
(call[1] as { type: string; error?: string }).type === 'error' &&
|
|
(call[1] as { type: string; error?: string }).error?.includes('already in progress')
|
|
);
|
|
expect(errorCalls.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('resets mutex after install completes (even on failure)', async () => {
|
|
allowConsoleLogs();
|
|
|
|
const mockWindow = {
|
|
isDestroyed: () => false,
|
|
webContents: { send: vi.fn(), isDestroyed: () => false },
|
|
};
|
|
service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow);
|
|
|
|
// First install will fail (no network mock)
|
|
await service.install();
|
|
|
|
// After failure, mutex should be released — second install should start checking
|
|
mockWindow.webContents.send.mockClear();
|
|
await service.install();
|
|
|
|
const checkingCalls = mockWindow.webContents.send.mock.calls.filter(
|
|
(call: unknown[]) =>
|
|
(call[0] as string) === 'cliInstaller:progress' &&
|
|
(call[1] as { type: string }).type === 'checking'
|
|
);
|
|
expect(checkingCalls.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
describe('setMainWindow', () => {
|
|
it('accepts null to clear window reference', () => {
|
|
service.setMainWindow(null);
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
it('accepts a BrowserWindow instance', () => {
|
|
const mockWindow = {
|
|
isDestroyed: () => false,
|
|
webContents: { send: vi.fn(), isDestroyed: () => false },
|
|
};
|
|
service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow);
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('normalizeVersion', () => {
|
|
it('extracts semver from "claude --version" output', () => {
|
|
expect(normalizeVersion('2.1.34 (Claude Code)\n')).toBe('2.1.34');
|
|
expect(normalizeVersion('2.1.59 (Claude Code)')).toBe('2.1.59');
|
|
});
|
|
|
|
it('handles plain version strings', () => {
|
|
expect(normalizeVersion('2.1.59')).toBe('2.1.59');
|
|
expect(normalizeVersion(' 2.1.59 ')).toBe('2.1.59');
|
|
});
|
|
|
|
it('strips v prefix', () => {
|
|
expect(normalizeVersion('v2.1.59')).toBe('2.1.59');
|
|
expect(normalizeVersion('v2.1.59\n')).toBe('2.1.59');
|
|
});
|
|
|
|
it('returns trimmed input when no semver found', () => {
|
|
expect(normalizeVersion('unknown')).toBe('unknown');
|
|
expect(normalizeVersion(' beta ')).toBe('beta');
|
|
});
|
|
});
|
|
|
|
describe('isVersionOlder', () => {
|
|
it('returns true when installed is older', () => {
|
|
expect(isVersionOlder('2.1.34', '2.1.59')).toBe(true);
|
|
expect(isVersionOlder('1.0.0', '2.0.0')).toBe(true);
|
|
expect(isVersionOlder('2.0.0', '2.1.0')).toBe(true);
|
|
expect(isVersionOlder('2.1.0', '2.1.1')).toBe(true);
|
|
});
|
|
|
|
it('returns false when versions are equal', () => {
|
|
expect(isVersionOlder('2.1.59', '2.1.59')).toBe(false);
|
|
expect(isVersionOlder('1.0.0', '1.0.0')).toBe(false);
|
|
});
|
|
|
|
it('returns false when installed is newer', () => {
|
|
expect(isVersionOlder('2.1.59', '2.1.34')).toBe(false);
|
|
expect(isVersionOlder('3.0.0', '2.9.99')).toBe(false);
|
|
expect(isVersionOlder('2.2.0', '2.1.59')).toBe(false);
|
|
});
|
|
|
|
it('handles numeric comparison correctly (not lexicographic)', () => {
|
|
// "2.10.0" > "2.9.0" numerically (but "10" < "9" lexicographically)
|
|
expect(isVersionOlder('2.9.0', '2.10.0')).toBe(true);
|
|
expect(isVersionOlder('2.10.0', '2.9.0')).toBe(false);
|
|
});
|
|
|
|
it('handles different segment counts', () => {
|
|
expect(isVersionOlder('2.1', '2.1.1')).toBe(true);
|
|
expect(isVersionOlder('2.1.1', '2.1')).toBe(false);
|
|
expect(isVersionOlder('2.1', '2.1.0')).toBe(false); // 2.1 == 2.1.0
|
|
});
|
|
});
|
|
|
|
describe('getStatus timeout', () => {
|
|
it('returns partial result when gatherStatus hangs', async () => {
|
|
allowConsoleLogs();
|
|
vi.useFakeTimers();
|
|
|
|
// ClaudeBinaryResolver.resolve() never settles — simulates thread pool exhaustion
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockReturnValue(new Promise(() => {}));
|
|
|
|
const statusPromise = service.getStatus();
|
|
|
|
// Advance past GET_STATUS_TIMEOUT_MS (30s)
|
|
await vi.advanceTimersByTimeAsync(31_000);
|
|
|
|
const status = await statusPromise;
|
|
|
|
// Should return the default (partial) result — not hang forever
|
|
expect(status.installed).toBe(false);
|
|
expect(status.installedVersion).toBeNull();
|
|
expect(status.binaryPath).toBeNull();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('returns full result when gatherStatus completes before timeout', async () => {
|
|
allowConsoleLogs();
|
|
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
|
vi.mocked(execCli)
|
|
.mockResolvedValueOnce({ stdout: '2.5.0 (Claude Code)', stderr: '' })
|
|
.mockResolvedValueOnce({
|
|
stdout: '{"loggedIn":true,"authMethod":"api_key"}',
|
|
stderr: '',
|
|
});
|
|
|
|
const status = await service.getStatus();
|
|
|
|
expect(status.installed).toBe(true);
|
|
expect(status.installedVersion).toBe('2.5.0');
|
|
expect(status.authLoggedIn).toBe(true);
|
|
expect(status.authMethod).toBe('api_key');
|
|
});
|
|
});
|
|
|
|
describe('auth parallelism', () => {
|
|
let httpsGet: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(async () => {
|
|
// Reset execCli mock queue (clearAllMocks doesn't clear mockResolvedValueOnce queue)
|
|
vi.mocked(execCli).mockReset();
|
|
vi.mocked(execCli).mockRejectedValue(new Error('execCli not configured'));
|
|
|
|
// Get reference to the mocked https.get for per-test control
|
|
const httpsModule = await import('https');
|
|
httpsGet = vi.mocked(httpsModule.default.get);
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Reset https.get so it doesn't leak into subsequent test groups
|
|
httpsGet.mockReset();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('auth is not blocked by slow GCS fetch', async () => {
|
|
allowConsoleLogs();
|
|
vi.useFakeTimers();
|
|
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
|
|
|
// --version resolves immediately, auth resolves immediately
|
|
vi.mocked(execCli)
|
|
.mockResolvedValueOnce({ stdout: '2.5.0 (Claude Code)', stderr: '' })
|
|
.mockResolvedValueOnce({
|
|
stdout: '{"loggedIn":true,"authMethod":"api_key"}',
|
|
stderr: '',
|
|
});
|
|
|
|
// GCS never responds — simulates slow/hanging network.
|
|
// Returns proper req-like object so httpsGetFollowRedirects doesn't crash,
|
|
// but never fires the response callback.
|
|
httpsGet.mockImplementation(() => ({
|
|
setTimeout: vi.fn(),
|
|
on: vi.fn(),
|
|
destroy: vi.fn(),
|
|
}));
|
|
|
|
const statusPromise = service.getStatus();
|
|
|
|
// Advance past GET_STATUS_TIMEOUT_MS (30s) — GCS still hanging,
|
|
// but auth already wrote its result to `r` directly
|
|
await vi.advanceTimersByTimeAsync(31_000);
|
|
|
|
const status = await statusPromise;
|
|
|
|
// Auth succeeded even though GCS is hanging
|
|
expect(status.authLoggedIn).toBe(true);
|
|
expect(status.authMethod).toBe('api_key');
|
|
expect(status.installed).toBe(true);
|
|
expect(status.installedVersion).toBe('2.5.0');
|
|
});
|
|
|
|
it('auth retry works when first attempt fails', async () => {
|
|
allowConsoleLogs();
|
|
vi.useFakeTimers();
|
|
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
|
|
|
// --version ok, auth attempt 1 fails, auth attempt 2 succeeds
|
|
vi.mocked(execCli)
|
|
.mockResolvedValueOnce({ stdout: '2.5.0', stderr: '' })
|
|
.mockRejectedValueOnce(new Error('ENOENT stale lock'))
|
|
.mockResolvedValueOnce({
|
|
stdout: '{"loggedIn":true,"authMethod":"oauth"}',
|
|
stderr: '',
|
|
});
|
|
|
|
const statusPromise = service.getStatus();
|
|
|
|
// Advance past retry delay (1.5s) + auth timeout + outer timeout
|
|
await vi.advanceTimersByTimeAsync(31_000);
|
|
|
|
const status = await statusPromise;
|
|
|
|
expect(status.authLoggedIn).toBe(true);
|
|
expect(status.authMethod).toBe('oauth');
|
|
});
|
|
|
|
it('auth times out independently when both attempts hang', async () => {
|
|
allowConsoleLogs();
|
|
vi.useFakeTimers();
|
|
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
|
|
|
// --version ok, auth hangs forever (never resolves)
|
|
vi.mocked(execCli)
|
|
.mockResolvedValueOnce({ stdout: '2.5.0', stderr: '' })
|
|
.mockReturnValue(new Promise(() => {}));
|
|
|
|
const statusPromise = service.getStatus();
|
|
|
|
// Advance past AUTH_TOTAL_TIMEOUT_MS (15s) and GET_STATUS_TIMEOUT_MS (30s)
|
|
await vi.advanceTimersByTimeAsync(31_000);
|
|
|
|
const status = await statusPromise;
|
|
|
|
// Auth timed out independently → stays false
|
|
expect(status.authLoggedIn).toBe(false);
|
|
expect(status.authMethod).toBeNull();
|
|
// Version was populated before auth started
|
|
expect(status.installedVersion).toBe('2.5.0');
|
|
});
|
|
});
|
|
|
|
describe('sendProgress with destroyed window', () => {
|
|
it('does not throw when window is destroyed', async () => {
|
|
allowConsoleLogs();
|
|
|
|
const mockWindow = {
|
|
isDestroyed: () => true,
|
|
webContents: { send: vi.fn(), isDestroyed: () => true },
|
|
};
|
|
service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow);
|
|
|
|
// install() triggers sendProgress — should not throw even with destroyed window
|
|
await service.install();
|
|
|
|
// send should NOT have been called because window is destroyed
|
|
expect(mockWindow.webContents.send).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|