340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { MCPAgentConfigEngine, type MCPAgentId, type AgentConfigResult } from '../../src/main/engine/MCPAgentConfigEngine';
|
|
|
|
// Mock fs and os
|
|
const mockReadFileSync = vi.fn();
|
|
const mockWriteFileSync = vi.fn();
|
|
const mockExistsSync = vi.fn();
|
|
const mockMkdirSync = vi.fn();
|
|
|
|
vi.mock('fs', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('fs')>();
|
|
return {
|
|
...actual,
|
|
default: {
|
|
...actual,
|
|
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
|
|
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
|
|
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
|
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
|
|
},
|
|
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
|
|
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
|
|
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
|
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
|
|
};
|
|
});
|
|
|
|
describe('MCPAgentConfigEngine', () => {
|
|
let engine: MCPAgentConfigEngine;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
engine = new MCPAgentConfigEngine({
|
|
homeDir: '/home/testuser',
|
|
platform: 'darwin',
|
|
mcpUrl: 'http://127.0.0.1:4124/mcp',
|
|
});
|
|
});
|
|
|
|
describe('getAgents', () => {
|
|
it('returns all supported agent definitions', () => {
|
|
const agents = engine.getAgents();
|
|
expect(agents).toHaveLength(4);
|
|
const ids = agents.map((a) => a.id);
|
|
expect(ids).toContain('claude-code');
|
|
expect(ids).toContain('github-copilot');
|
|
expect(ids).toContain('gemini-cli');
|
|
expect(ids).toContain('opencode');
|
|
});
|
|
|
|
it('includes display labels for each agent', () => {
|
|
const agents = engine.getAgents();
|
|
for (const agent of agents) {
|
|
expect(agent.label).toBeTruthy();
|
|
expect(typeof agent.label).toBe('string');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('getConfigPath', () => {
|
|
it('returns ~/.claude.json for claude-code', () => {
|
|
expect(engine.getConfigPath('claude-code')).toBe('/home/testuser/.claude.json');
|
|
});
|
|
|
|
it('returns macOS VS Code user mcp.json for github-copilot on darwin', () => {
|
|
expect(engine.getConfigPath('github-copilot')).toBe(
|
|
'/home/testuser/Library/Application Support/Code/User/mcp.json',
|
|
);
|
|
});
|
|
|
|
it('returns Linux VS Code user mcp.json for github-copilot on linux', () => {
|
|
const linuxEngine = new MCPAgentConfigEngine({
|
|
homeDir: '/home/user',
|
|
platform: 'linux',
|
|
mcpUrl: 'http://127.0.0.1:4124/mcp',
|
|
});
|
|
expect(linuxEngine.getConfigPath('github-copilot')).toBe(
|
|
'/home/user/.config/Code/User/mcp.json',
|
|
);
|
|
});
|
|
|
|
it('returns ~/.gemini/settings.json for gemini-cli', () => {
|
|
expect(engine.getConfigPath('gemini-cli')).toBe('/home/testuser/.gemini/settings.json');
|
|
});
|
|
|
|
it('returns ~/.opencode.json for opencode', () => {
|
|
expect(engine.getConfigPath('opencode')).toBe('/home/testuser/.opencode.json');
|
|
});
|
|
});
|
|
|
|
describe('addToConfig (claude-code)', () => {
|
|
it('creates new config when file does not exist', () => {
|
|
mockExistsSync.mockReturnValue(false);
|
|
|
|
const result = engine.addToConfig('claude-code');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.configPath).toBe('/home/testuser/.claude.json');
|
|
expect(mockWriteFileSync).toHaveBeenCalledOnce();
|
|
|
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
|
expect(written.mcpServers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' });
|
|
});
|
|
|
|
it('merges into existing config without overwriting other servers', () => {
|
|
mockExistsSync.mockReturnValue(true);
|
|
mockReadFileSync.mockReturnValue(
|
|
JSON.stringify({
|
|
mcpServers: { other: { type: 'stdio', command: 'npx' } },
|
|
someOtherKey: 'keep',
|
|
}),
|
|
);
|
|
|
|
const result = engine.addToConfig('claude-code');
|
|
|
|
expect(result.success).toBe(true);
|
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
|
expect(written.mcpServers.other).toEqual({ type: 'stdio', command: 'npx' });
|
|
expect(written.mcpServers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' });
|
|
expect(written.someOtherKey).toBe('keep');
|
|
});
|
|
|
|
it('overwrites existing bDS entry to update URL', () => {
|
|
mockExistsSync.mockReturnValue(true);
|
|
mockReadFileSync.mockReturnValue(
|
|
JSON.stringify({
|
|
mcpServers: { bDS: { type: 'http', url: 'http://old:1234/mcp' } },
|
|
}),
|
|
);
|
|
|
|
const result = engine.addToConfig('claude-code');
|
|
|
|
expect(result.success).toBe(true);
|
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
|
expect(written.mcpServers.bDS.url).toBe('http://127.0.0.1:4124/mcp');
|
|
});
|
|
});
|
|
|
|
describe('addToConfig (github-copilot)', () => {
|
|
it('creates new .vscode/mcp.json with correct servers key', () => {
|
|
mockExistsSync.mockReturnValue(false);
|
|
|
|
const result = engine.addToConfig('github-copilot');
|
|
|
|
expect(result.success).toBe(true);
|
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
|
expect(written.servers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' });
|
|
// Should NOT have mcpServers key
|
|
expect(written.mcpServers).toBeUndefined();
|
|
});
|
|
|
|
it('creates parent directory if needed', () => {
|
|
mockExistsSync.mockReturnValue(false);
|
|
|
|
engine.addToConfig('github-copilot');
|
|
|
|
expect(mockMkdirSync).toHaveBeenCalledWith(
|
|
expect.stringContaining('Code/User'),
|
|
{ recursive: true },
|
|
);
|
|
});
|
|
|
|
it('merges into existing VS Code config preserving other servers', () => {
|
|
mockExistsSync.mockReturnValue(true);
|
|
mockReadFileSync.mockReturnValue(
|
|
JSON.stringify({
|
|
servers: { github: { type: 'http', url: 'https://api.githubcopilot.com/mcp' } },
|
|
}),
|
|
);
|
|
|
|
const result = engine.addToConfig('github-copilot');
|
|
|
|
expect(result.success).toBe(true);
|
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
|
expect(written.servers.github.url).toBe('https://api.githubcopilot.com/mcp');
|
|
expect(written.servers.bDS.url).toBe('http://127.0.0.1:4124/mcp');
|
|
});
|
|
});
|
|
|
|
describe('addToConfig (gemini-cli)', () => {
|
|
it('creates new settings.json with httpUrl entry', () => {
|
|
mockExistsSync.mockReturnValue(false);
|
|
|
|
const result = engine.addToConfig('gemini-cli');
|
|
|
|
expect(result.success).toBe(true);
|
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
|
expect(written.mcpServers.bDS).toEqual({ httpUrl: 'http://127.0.0.1:4124/mcp' });
|
|
});
|
|
|
|
it('creates ~/.gemini directory if needed', () => {
|
|
mockExistsSync.mockReturnValue(false);
|
|
|
|
engine.addToConfig('gemini-cli');
|
|
|
|
expect(mockMkdirSync).toHaveBeenCalledWith(
|
|
expect.stringContaining('.gemini'),
|
|
{ recursive: true },
|
|
);
|
|
});
|
|
|
|
it('preserves existing settings when adding MCP server', () => {
|
|
mockExistsSync.mockReturnValue(true);
|
|
mockReadFileSync.mockReturnValue(
|
|
JSON.stringify({
|
|
theme: 'dark',
|
|
mcpServers: { existing: { command: 'python' } },
|
|
}),
|
|
);
|
|
|
|
const result = engine.addToConfig('gemini-cli');
|
|
|
|
expect(result.success).toBe(true);
|
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
|
expect(written.theme).toBe('dark');
|
|
expect(written.mcpServers.existing).toEqual({ command: 'python' });
|
|
expect(written.mcpServers.bDS).toEqual({ httpUrl: 'http://127.0.0.1:4124/mcp' });
|
|
});
|
|
});
|
|
|
|
describe('addToConfig (opencode)', () => {
|
|
it('creates new .opencode.json with sse type', () => {
|
|
mockExistsSync.mockReturnValue(false);
|
|
|
|
const result = engine.addToConfig('opencode');
|
|
|
|
expect(result.success).toBe(true);
|
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
|
expect(written.mcpServers.bDS).toEqual({ type: 'sse', url: 'http://127.0.0.1:4124/mcp' });
|
|
});
|
|
|
|
it('merges into existing opencode config', () => {
|
|
mockExistsSync.mockReturnValue(true);
|
|
mockReadFileSync.mockReturnValue(
|
|
JSON.stringify({
|
|
providers: { openai: { apiKey: 'key' } },
|
|
mcpServers: { debugger: { type: 'stdio', command: 'debug' } },
|
|
}),
|
|
);
|
|
|
|
const result = engine.addToConfig('opencode');
|
|
|
|
expect(result.success).toBe(true);
|
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
|
expect(written.providers.openai.apiKey).toBe('key');
|
|
expect(written.mcpServers.debugger.command).toBe('debug');
|
|
expect(written.mcpServers.bDS.type).toBe('sse');
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('returns error result when read fails', () => {
|
|
mockExistsSync.mockReturnValue(true);
|
|
mockReadFileSync.mockImplementation(() => {
|
|
throw new Error('Permission denied');
|
|
});
|
|
|
|
const result = engine.addToConfig('claude-code');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Permission denied');
|
|
});
|
|
|
|
it('returns error result when write fails', () => {
|
|
mockExistsSync.mockReturnValue(false);
|
|
mockWriteFileSync.mockImplementation(() => {
|
|
throw new Error('Disk full');
|
|
});
|
|
|
|
const result = engine.addToConfig('claude-code');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Disk full');
|
|
});
|
|
|
|
it('returns error for invalid existing JSON', () => {
|
|
mockExistsSync.mockReturnValue(true);
|
|
mockReadFileSync.mockReturnValue('not valid json{{{');
|
|
|
|
const result = engine.addToConfig('claude-code');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('isConfigured', () => {
|
|
it('returns true when bDS entry exists in config', () => {
|
|
mockExistsSync.mockReturnValue(true);
|
|
mockReadFileSync.mockReturnValue(
|
|
JSON.stringify({
|
|
mcpServers: { bDS: { type: 'http', url: 'http://127.0.0.1:4124/mcp' } },
|
|
}),
|
|
);
|
|
|
|
expect(engine.isConfigured('claude-code')).toBe(true);
|
|
});
|
|
|
|
it('returns false when config file does not exist', () => {
|
|
mockExistsSync.mockReturnValue(false);
|
|
|
|
expect(engine.isConfigured('claude-code')).toBe(false);
|
|
});
|
|
|
|
it('returns false when bDS entry is missing', () => {
|
|
mockExistsSync.mockReturnValue(true);
|
|
mockReadFileSync.mockReturnValue(
|
|
JSON.stringify({ mcpServers: { other: {} } }),
|
|
);
|
|
|
|
expect(engine.isConfigured('claude-code')).toBe(false);
|
|
});
|
|
|
|
it('checks VS Code servers key for github-copilot', () => {
|
|
mockExistsSync.mockReturnValue(true);
|
|
mockReadFileSync.mockReturnValue(
|
|
JSON.stringify({ servers: { bDS: { type: 'http', url: 'x' } } }),
|
|
);
|
|
|
|
expect(engine.isConfigured('github-copilot')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('dynamic port', () => {
|
|
it('uses the provided mcpUrl in server entries', () => {
|
|
const customEngine = new MCPAgentConfigEngine({
|
|
homeDir: '/tmp',
|
|
platform: 'darwin',
|
|
mcpUrl: 'http://127.0.0.1:9999/mcp',
|
|
});
|
|
mockExistsSync.mockReturnValue(false);
|
|
|
|
customEngine.addToConfig('claude-code');
|
|
|
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
|
expect(written.mcpServers.bDS.url).toBe('http://127.0.0.1:9999/mcp');
|
|
});
|
|
});
|
|
});
|