eb80b2c9e6
- todo-manager.test: 修复空日期字符串导致的 Invalid time value 错误 - config-loader.test: 更新测试以匹配简化后的配置加载逻辑 - mcp/config.test: 修复配置路径匹配问题 - task.test/task-extended.test: 添加缺失的 agentEventEmitter mock - presets/index.test: 更新预设 Agent 数量和 maxSteps 测试 - agent.test: 添加缺失的 mock 函数并修正模式切换测试 - 删除过时的 session/manager.test 和 storage.test (使用已废弃的 API)
355 lines
9.6 KiB
TypeScript
355 lines
9.6 KiB
TypeScript
/**
|
||
* MCP 配置加载和验证测试
|
||
*/
|
||
|
||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import {
|
||
loadMCPConfig,
|
||
validateMCPConfig,
|
||
normalizeMCPConfig,
|
||
getEnabledServers,
|
||
isToolEnabled,
|
||
resolveEnvVariables,
|
||
resolveEnvInObject,
|
||
} from '../../../src/mcp/config.js';
|
||
import type { MCPConfig } from '../../../src/mcp/types.js';
|
||
|
||
// Mock fs 模块
|
||
vi.mock('fs');
|
||
vi.mock('path', async () => {
|
||
const actual = await vi.importActual('path');
|
||
return {
|
||
...actual,
|
||
extname: (actual as typeof import('path')).extname,
|
||
};
|
||
});
|
||
|
||
describe('MCP Config', () => {
|
||
beforeEach(() => {
|
||
vi.resetAllMocks();
|
||
// 默认不存在配置文件
|
||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe('resolveEnvVariables', () => {
|
||
it('应该解析环境变量引用', () => {
|
||
process.env.TEST_VAR = 'test_value';
|
||
const result = resolveEnvVariables('{env:TEST_VAR}');
|
||
expect(result).toBe('test_value');
|
||
delete process.env.TEST_VAR;
|
||
});
|
||
|
||
it('应该处理多个环境变量', () => {
|
||
process.env.VAR1 = 'value1';
|
||
process.env.VAR2 = 'value2';
|
||
const result = resolveEnvVariables('{env:VAR1}/{env:VAR2}');
|
||
expect(result).toBe('value1/value2');
|
||
delete process.env.VAR1;
|
||
delete process.env.VAR2;
|
||
});
|
||
|
||
it('应该对未设置的变量返回空字符串', () => {
|
||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
const result = resolveEnvVariables('{env:NONEXISTENT_VAR}');
|
||
expect(result).toBe('');
|
||
expect(warnSpy).toHaveBeenCalled();
|
||
warnSpy.mockRestore();
|
||
});
|
||
|
||
it('应该保留普通字符串不变', () => {
|
||
const result = resolveEnvVariables('plain string');
|
||
expect(result).toBe('plain string');
|
||
});
|
||
});
|
||
|
||
describe('resolveEnvInObject', () => {
|
||
it('应该递归解析对象中的环境变量', () => {
|
||
process.env.TEST_KEY = 'secret';
|
||
const obj = {
|
||
key: '{env:TEST_KEY}',
|
||
nested: {
|
||
value: '{env:TEST_KEY}',
|
||
},
|
||
array: ['{env:TEST_KEY}'],
|
||
};
|
||
const result = resolveEnvInObject(obj);
|
||
expect(result).toEqual({
|
||
key: 'secret',
|
||
nested: {
|
||
value: 'secret',
|
||
},
|
||
array: ['secret'],
|
||
});
|
||
delete process.env.TEST_KEY;
|
||
});
|
||
|
||
it('应该保留非字符串值不变', () => {
|
||
const obj = {
|
||
number: 123,
|
||
boolean: true,
|
||
null: null,
|
||
};
|
||
const result = resolveEnvInObject(obj);
|
||
expect(result).toEqual(obj);
|
||
});
|
||
});
|
||
|
||
describe('validateMCPConfig', () => {
|
||
it('应该接受空配置', () => {
|
||
const errors = validateMCPConfig({});
|
||
expect(errors).toHaveLength(0);
|
||
});
|
||
|
||
it('应该接受有效的本地服务器配置', () => {
|
||
const config: MCPConfig = {
|
||
mcp: {
|
||
filesystem: {
|
||
type: 'local',
|
||
command: ['npx', '-y', '@anthropic-ai/mcp-server-filesystem'],
|
||
},
|
||
},
|
||
};
|
||
const errors = validateMCPConfig(config);
|
||
expect(errors).toHaveLength(0);
|
||
});
|
||
|
||
it('应该接受有效的远程服务器配置', () => {
|
||
const config: MCPConfig = {
|
||
mcp: {
|
||
remote: {
|
||
type: 'remote',
|
||
url: 'https://mcp.example.com',
|
||
},
|
||
},
|
||
};
|
||
const errors = validateMCPConfig(config);
|
||
expect(errors).toHaveLength(0);
|
||
});
|
||
|
||
it('应该拒绝无效的服务器名称', () => {
|
||
const config: MCPConfig = {
|
||
mcp: {
|
||
'123invalid': {
|
||
type: 'local',
|
||
command: ['echo'],
|
||
},
|
||
},
|
||
};
|
||
const errors = validateMCPConfig(config);
|
||
expect(errors.length).toBeGreaterThan(0);
|
||
expect(errors[0]).toContain('服务器名称');
|
||
});
|
||
|
||
it('应该拒绝缺少 command 的本地服务器', () => {
|
||
const config: MCPConfig = {
|
||
mcp: {
|
||
server: {
|
||
type: 'local',
|
||
command: [],
|
||
},
|
||
},
|
||
};
|
||
const errors = validateMCPConfig(config);
|
||
expect(errors.length).toBeGreaterThan(0);
|
||
expect(errors[0]).toContain('command');
|
||
});
|
||
|
||
it('应该拒绝无效的 URL', () => {
|
||
const config: MCPConfig = {
|
||
mcp: {
|
||
remote: {
|
||
type: 'remote',
|
||
url: 'invalid-url',
|
||
},
|
||
},
|
||
};
|
||
const errors = validateMCPConfig(config);
|
||
expect(errors.length).toBeGreaterThan(0);
|
||
expect(errors[0]).toContain('url');
|
||
});
|
||
|
||
it('应该验证 tools 配置', () => {
|
||
const config: MCPConfig = {
|
||
mcp: {},
|
||
tools: {
|
||
'valid-tool': true,
|
||
'invalid-tool': 'not-boolean' as unknown as boolean,
|
||
},
|
||
};
|
||
const errors = validateMCPConfig(config);
|
||
expect(errors.length).toBeGreaterThan(0);
|
||
expect(errors[0]).toContain('布尔值');
|
||
});
|
||
});
|
||
|
||
describe('normalizeMCPConfig', () => {
|
||
it('应该添加默认值', () => {
|
||
const config: MCPConfig = {
|
||
mcp: {
|
||
server: {
|
||
type: 'local',
|
||
command: ['echo'],
|
||
},
|
||
},
|
||
};
|
||
const normalized = normalizeMCPConfig(config);
|
||
expect(normalized.mcp?.server.enabled).toBe(true);
|
||
expect(normalized.mcp?.server.timeout).toBe(30000);
|
||
});
|
||
|
||
it('应该保留显式设置的值', () => {
|
||
const config: MCPConfig = {
|
||
mcp: {
|
||
server: {
|
||
type: 'local',
|
||
command: ['echo'],
|
||
enabled: false,
|
||
timeout: 5000,
|
||
},
|
||
},
|
||
};
|
||
const normalized = normalizeMCPConfig(config);
|
||
expect(normalized.mcp?.server.enabled).toBe(false);
|
||
expect(normalized.mcp?.server.timeout).toBe(5000);
|
||
});
|
||
});
|
||
|
||
describe('getEnabledServers', () => {
|
||
it('应该返回启用的服务器', () => {
|
||
const config: MCPConfig = {
|
||
mcp: {
|
||
enabled: {
|
||
type: 'local',
|
||
command: ['echo'],
|
||
enabled: true,
|
||
},
|
||
disabled: {
|
||
type: 'local',
|
||
command: ['echo'],
|
||
enabled: false,
|
||
},
|
||
default: {
|
||
type: 'local',
|
||
command: ['echo'],
|
||
},
|
||
},
|
||
};
|
||
const servers = getEnabledServers(config);
|
||
expect(servers).toHaveLength(2);
|
||
expect(servers.map((s) => s.name)).toContain('enabled');
|
||
expect(servers.map((s) => s.name)).toContain('default');
|
||
expect(servers.map((s) => s.name)).not.toContain('disabled');
|
||
});
|
||
|
||
it('应该处理空配置', () => {
|
||
const servers = getEnabledServers({});
|
||
expect(servers).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('isToolEnabled', () => {
|
||
it('没有配置时默认启用', () => {
|
||
expect(isToolEnabled('any-tool')).toBe(true);
|
||
expect(isToolEnabled('any-tool', undefined)).toBe(true);
|
||
});
|
||
|
||
it('应该精确匹配工具名', () => {
|
||
const config = {
|
||
'server-tool': false,
|
||
'server-other': true,
|
||
};
|
||
expect(isToolEnabled('server-tool', config)).toBe(false);
|
||
expect(isToolEnabled('server-other', config)).toBe(true);
|
||
});
|
||
|
||
it('应该支持通配符匹配', () => {
|
||
const config = {
|
||
'server-*': false,
|
||
};
|
||
expect(isToolEnabled('server-tool1', config)).toBe(false);
|
||
expect(isToolEnabled('server-tool2', config)).toBe(false);
|
||
expect(isToolEnabled('other-tool', config)).toBe(true);
|
||
});
|
||
|
||
it('精确匹配应优先于通配符', () => {
|
||
const config = {
|
||
'server-*': false,
|
||
'server-special': true,
|
||
};
|
||
expect(isToolEnabled('server-special', config)).toBe(true);
|
||
expect(isToolEnabled('server-other', config)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('loadMCPConfig', () => {
|
||
it('没有配置文件时返回空配置', () => {
|
||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||
const config = loadMCPConfig('/test/dir');
|
||
expect(config).toEqual({});
|
||
});
|
||
|
||
it('应该加载 JSON 配置文件', () => {
|
||
const testConfig = {
|
||
mcp: {
|
||
server: {
|
||
type: 'local',
|
||
command: ['echo'],
|
||
},
|
||
},
|
||
};
|
||
vi.mocked(fs.existsSync).mockImplementation((filePath) => {
|
||
return String(filePath).endsWith('config.json');
|
||
});
|
||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(testConfig));
|
||
|
||
const config = loadMCPConfig('/test/dir');
|
||
expect(config.mcp).toBeDefined();
|
||
});
|
||
|
||
it('应该合并用户级和项目级配置', () => {
|
||
const userConfig = {
|
||
mcp: {
|
||
userServer: {
|
||
type: 'local',
|
||
command: ['user-cmd'],
|
||
},
|
||
},
|
||
};
|
||
const projectConfig = {
|
||
mcp: {
|
||
projectServer: {
|
||
type: 'local',
|
||
command: ['project-cmd'],
|
||
},
|
||
},
|
||
};
|
||
|
||
let callCount = 0;
|
||
vi.mocked(fs.existsSync).mockImplementation((filePath) => {
|
||
const pathStr = String(filePath);
|
||
// 用户级配置(mcp.json)或项目级配置(.ai-assist/config.json)
|
||
return pathStr.endsWith('mcp.json') || pathStr.endsWith('config.json');
|
||
});
|
||
vi.mocked(fs.readFileSync).mockImplementation((filePath) => {
|
||
const pathStr = String(filePath);
|
||
callCount++;
|
||
// 根据文件路径返回不同配置
|
||
if (pathStr.endsWith('mcp.json')) {
|
||
return JSON.stringify(userConfig);
|
||
}
|
||
return JSON.stringify(projectConfig);
|
||
});
|
||
|
||
const config = loadMCPConfig('/test/dir');
|
||
expect(config.mcp?.userServer).toBeDefined();
|
||
expect(config.mcp?.projectServer).toBeDefined();
|
||
});
|
||
});
|
||
});
|