agent-ecosystem/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts

367 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
const buildProviderAwareCliEnvMock = vi.fn();
const resolveBinaryMock = vi.fn();
const execCliMock = vi.fn();
const spawnCliMock = vi.fn();
const resolveInteractiveShellEnvMock = vi.fn();
function createSpawnProcess(stdoutPayload: unknown, exitCode = 0): {
child: {
stdout: EventEmitter;
stderr: EventEmitter;
stdin: {
write: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
};
once: EventEmitter['once'];
};
stdinWrite: ReturnType<typeof vi.fn>;
} {
const processEvents = new EventEmitter();
const stdout = new EventEmitter();
const stderr = new EventEmitter();
const stdinWrite = vi.fn();
const stdinEnd = vi.fn(() => {
queueMicrotask(() => {
stdout.emit('data', Buffer.from(JSON.stringify(stdoutPayload)));
processEvents.emit('close', exitCode);
});
});
return {
child: {
stdout,
stderr,
stdin: {
write: stdinWrite,
end: stdinEnd,
},
once: processEvents.once.bind(processEvents),
},
stdinWrite,
};
}
vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
buildProviderAwareCliEnv: (...args: unknown[]) => buildProviderAwareCliEnvMock(...args),
}));
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
ClaudeBinaryResolver: {
resolve: () => resolveBinaryMock(),
},
}));
vi.mock('@main/utils/childProcess', () => ({
execCli: (...args: unknown[]) => execCliMock(...args),
spawnCli: (...args: unknown[]) => spawnCliMock(...args),
killProcessTree: vi.fn(),
}));
vi.mock('@main/utils/shellEnv', () => ({
resolveInteractiveShellEnv: () => resolveInteractiveShellEnvMock(),
}));
import { AgentTeamsRuntimeProviderManagementCliClient } from '../../../../src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient';
describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
beforeEach(() => {
vi.clearAllMocks();
resolveBinaryMock.mockResolvedValue('/repo/cli-dev');
resolveInteractiveShellEnvMock.mockResolvedValue({ PATH: '/Users/test/.bun/bin:/usr/bin' });
buildProviderAwareCliEnvMock.mockResolvedValue({
env: { PATH: '/Users/test/.bun/bin:/usr/bin' },
connectionIssues: {},
providerArgs: [],
});
});
it('returns stderr details for failed model tests instead of hiding them behind the command', async () => {
const error = new Error('Command failed: /repo/cli-dev runtime providers test-model');
Object.assign(error, {
stderr: './cli-dev: line 47: exec: bun: not found\n',
stdout: '',
});
execCliMock.mockRejectedValue(error);
const client = new AgentTeamsRuntimeProviderManagementCliClient();
const response = await client.testModel({
runtimeId: 'opencode',
providerId: 'opencode',
modelId: 'opencode/nemotron-3-super-free',
});
expect(response.error?.message).toBe('./cli-dev: line 47: exec: bun: not found');
expect(response.error?.message).not.toContain('runtime providers test-model');
});
it('parses JSON error responses from stdout when the CLI exits non-zero', async () => {
const error = new Error('Command failed: /repo/cli-dev runtime providers test-model');
Object.assign(error, {
stdout: JSON.stringify({
schemaVersion: 1,
runtimeId: 'opencode',
error: {
code: 'auth-required',
message: 'Provider opencode must be connected before testing a model',
recoverable: true,
},
}),
stderr: '',
});
execCliMock.mockRejectedValue(error);
const client = new AgentTeamsRuntimeProviderManagementCliClient();
const response = await client.testModel({
runtimeId: 'opencode',
providerId: 'opencode',
modelId: 'opencode/nemotron-3-super-free',
});
expect(response.error?.code).toBe('auth-required');
expect(response.error?.message).toBe(
'Provider opencode must be connected before testing a model'
);
});
it('parses JSON error responses from failed forget commands', async () => {
const error = new Error('Command failed: /repo/cli-dev runtime providers forget');
Object.assign(error, {
stdout: JSON.stringify({
schemaVersion: 1,
runtimeId: 'opencode',
error: {
code: 'unsupported-action',
message: 'This OpenCode runtime does not advertise credential removal through /doc',
recoverable: true,
},
}),
stderr: '',
});
execCliMock.mockRejectedValue(error);
const client = new AgentTeamsRuntimeProviderManagementCliClient();
const response = await client.forgetCredential({
runtimeId: 'opencode',
providerId: 'openrouter',
});
expect(response.error?.code).toBe('unsupported-action');
expect(response.error?.message).toBe(
'This OpenCode runtime does not advertise credential removal through /doc'
);
});
it('passes project path as cwd and CLI flag for project-aware provider management', async () => {
execCliMock.mockResolvedValue({
stdout: JSON.stringify({
schemaVersion: 1,
runtimeId: 'opencode',
view: {
runtimeId: 'opencode',
title: 'OpenCode',
runtime: {
state: 'ready',
cliPath: '/opt/homebrew/bin/opencode',
version: '1.0.0',
managedProfile: 'active',
localAuth: 'synced',
},
providers: [],
defaultModel: null,
fallbackModel: null,
diagnostics: [],
},
}),
stderr: '',
});
const client = new AgentTeamsRuntimeProviderManagementCliClient();
await client.loadView({
runtimeId: 'opencode',
projectPath: '/Users/test/project',
});
expect(execCliMock).toHaveBeenCalledWith(
'/repo/cli-dev',
expect.arrayContaining(['--project-path', '/Users/test/project']),
expect.objectContaining({ cwd: '/Users/test/project' })
);
});
it('loads provider directory with optional args and omits absent values', async () => {
execCliMock.mockResolvedValue({
stdout: JSON.stringify({
schemaVersion: 1,
runtimeId: 'opencode',
directory: {
runtimeId: 'opencode',
totalCount: 1,
returnedCount: 1,
query: 'deep',
filter: 'connectable',
limit: 10,
cursor: null,
nextCursor: null,
fetchedAt: '2026-04-25T00:00:00.000Z',
entries: [],
diagnostics: [],
},
}),
stderr: '',
});
const client = new AgentTeamsRuntimeProviderManagementCliClient();
const response = await client.loadProviderDirectory({
runtimeId: 'opencode',
projectPath: '/Users/test/project',
query: 'deep',
filter: 'connectable',
limit: 10,
refresh: true,
});
expect(response.directory?.query).toBe('deep');
expect(execCliMock).toHaveBeenCalledWith(
'/repo/cli-dev',
[
'runtime',
'providers',
'directory',
'--runtime',
'opencode',
'--json',
'--project-path',
'/Users/test/project',
'--query',
'deep',
'--filter',
'connectable',
'--limit',
'10',
'--refresh',
],
expect.objectContaining({ cwd: '/Users/test/project' })
);
expect(JSON.stringify(execCliMock.mock.calls[0])).not.toContain('undefined');
});
it('loads provider setup forms through the CLI contract', async () => {
execCliMock.mockResolvedValue({
stdout: JSON.stringify({
schemaVersion: 1,
runtimeId: 'opencode',
setupForm: {
runtimeId: 'opencode',
providerId: 'openrouter',
displayName: 'OpenRouter',
method: 'api',
supported: true,
title: 'Connect OpenRouter',
description: null,
submitLabel: 'Connect',
disabledReason: null,
source: 'curated',
secret: {
key: 'key',
label: 'API key',
placeholder: 'Paste API key',
required: true,
},
prompts: [],
},
}),
stderr: '',
});
const client = new AgentTeamsRuntimeProviderManagementCliClient();
const response = await client.loadSetupForm({
runtimeId: 'opencode',
providerId: 'openrouter',
projectPath: '/Users/test/project',
});
expect(response.setupForm?.providerId).toBe('openrouter');
expect(execCliMock).toHaveBeenCalledWith(
'/repo/cli-dev',
[
'runtime',
'providers',
'setup-form',
'--runtime',
'opencode',
'--provider',
'openrouter',
'--json',
'--project-path',
'/Users/test/project',
],
expect.objectContaining({ cwd: '/Users/test/project' })
);
});
it('passes generic provider setup payload through stdin JSON only', async () => {
const { child, stdinWrite } = createSpawnProcess({
schemaVersion: 1,
runtimeId: 'opencode',
provider: {
providerId: 'cloudflare-ai-gateway',
displayName: 'Cloudflare AI Gateway',
state: 'connected',
ownership: ['managed'],
recommended: false,
modelCount: 0,
defaultModelId: null,
authMethods: ['api'],
actions: [],
detail: null,
},
});
spawnCliMock.mockReturnValue(child);
const client = new AgentTeamsRuntimeProviderManagementCliClient();
const response = await client.connectProvider({
runtimeId: 'opencode',
providerId: 'cloudflare-ai-gateway',
method: 'api',
apiKey: 'sk-secret-value',
metadata: {
accountId: 'account-123',
gatewayId: 'gateway-456',
},
projectPath: '/Users/test/project',
});
expect(response.provider?.providerId).toBe('cloudflare-ai-gateway');
expect(spawnCliMock).toHaveBeenCalledWith(
'/repo/cli-dev',
[
'runtime',
'providers',
'connect',
'--runtime',
'opencode',
'--provider',
'cloudflare-ai-gateway',
'--stdin-json',
'--json',
'--project-path',
'/Users/test/project',
],
expect.objectContaining({ cwd: '/Users/test/project' })
);
expect(JSON.stringify(spawnCliMock.mock.calls[0])).not.toContain('sk-secret-value');
expect(stdinWrite).toHaveBeenCalledWith(
JSON.stringify({
method: 'api',
apiKey: 'sk-secret-value',
metadata: {
accountId: 'account-123',
gatewayId: 'gateway-456',
},
})
);
});
});