test: 补充单元测试提升代码覆盖率
新增测试文件: - agent/executor-extended.test.ts, presets/ - context/manager-extended.test.ts - core/agent.test.ts, providers.test.ts - lsp/cli.test.ts, client-extended.test.ts, index.test.ts - permission/file-prompt.test.ts, prompt.test.ts - skills/builtin/ - tools/filesystem/write_file-extended.test.ts - tools/git/git_commit-extended.test.ts - tools/load_description.test.ts - tools/todo/todo-manager.test.ts - tools/tool-search.test.ts - types/ - utils/config-extended.test.ts, diff-extended.test.ts 修改现有测试: - agent/manager.test.ts - tools/skill/skill.test.ts - utils/config.test.ts, diff.test.ts, image.test.ts
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import type { Tool, AgentConfig } from '../../../src/types/index.js';
|
||||
import type { AgentInfo, AgentExecutionContext } from '../../../src/agent/types.js';
|
||||
|
||||
// Mock ai module
|
||||
const mockGenerateText = vi.fn();
|
||||
const mockStreamText = vi.fn();
|
||||
vi.mock('ai', () => ({
|
||||
generateText: (...args: unknown[]) => mockGenerateText(...args),
|
||||
streamText: (...args: unknown[]) => mockStreamText(...args),
|
||||
stepCountIs: vi.fn((n) => n),
|
||||
}));
|
||||
|
||||
// Mock buildZodSchema
|
||||
vi.mock('../../../src/types/index.js', async (importOriginal) => {
|
||||
const actual = await importOriginal() as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
buildZodSchema: vi.fn(() => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock ToolRegistry
|
||||
class MockToolRegistry {
|
||||
getAllTools = vi.fn(() => []);
|
||||
}
|
||||
vi.mock('../../../src/tools/registry.js', () => ({
|
||||
ToolRegistry: MockToolRegistry,
|
||||
}));
|
||||
|
||||
// Mock permission merger
|
||||
const mockCheckBashPermission = vi.fn();
|
||||
vi.mock('../../../src/agent/permission-merger.js', () => ({
|
||||
checkBashPermission: (...args: unknown[]) => mockCheckBashPermission(...args),
|
||||
}));
|
||||
|
||||
// Mock providers
|
||||
const mockGetModelFactory = vi.fn();
|
||||
vi.mock('../../../src/core/providers.js', () => ({
|
||||
getModelFactory: (...args: unknown[]) => mockGetModelFactory(...args),
|
||||
}));
|
||||
|
||||
import { AgentExecutor } from '../../../src/agent/executor.js';
|
||||
|
||||
describe('AgentExecutor - Agent 执行器扩展测试', () => {
|
||||
let executor: AgentExecutor;
|
||||
let mockToolRegistry: MockToolRegistry;
|
||||
let mockModel: LanguageModel;
|
||||
|
||||
const baseConfig: AgentConfig = {
|
||||
provider: 'anthropic',
|
||||
apiKey: 'test-key',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
maxTokens: 4096,
|
||||
systemPrompt: 'You are a helpful assistant',
|
||||
};
|
||||
|
||||
const basicAgentInfo: AgentInfo = {
|
||||
name: 'test-agent',
|
||||
description: 'Test agent',
|
||||
mode: 'subagent',
|
||||
};
|
||||
|
||||
const createMockTool = (name: string): Tool => ({
|
||||
name,
|
||||
description: `${name} tool`,
|
||||
parameters: {},
|
||||
execute: vi.fn().mockResolvedValue({ success: true, output: 'ok' }),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockToolRegistry = new MockToolRegistry();
|
||||
mockModel = {} as LanguageModel;
|
||||
|
||||
mockGetModelFactory.mockReturnValue(() => mockModel);
|
||||
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: 'Response text',
|
||||
steps: [{ text: 'step1' }],
|
||||
});
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
textStream: (async function* () {
|
||||
yield 'chunk1';
|
||||
yield 'chunk2';
|
||||
})(),
|
||||
response: Promise.resolve({}),
|
||||
});
|
||||
|
||||
mockCheckBashPermission.mockReturnValue('allow');
|
||||
|
||||
executor = new AgentExecutor(basicAgentInfo, baseConfig, mockToolRegistry as any);
|
||||
});
|
||||
|
||||
describe('constructor - 构造函数', () => {
|
||||
it('初始化执行器', () => {
|
||||
expect(executor).toBeDefined();
|
||||
expect(mockGetModelFactory).toHaveBeenCalledWith('anthropic', expect.any(Object));
|
||||
});
|
||||
|
||||
it('使用 Agent 指定的 provider', () => {
|
||||
const agentWithProvider: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
model: { provider: 'openai', model: 'gpt-4' },
|
||||
};
|
||||
|
||||
new AgentExecutor(agentWithProvider, baseConfig, mockToolRegistry as any);
|
||||
|
||||
expect(mockGetModelFactory).toHaveBeenCalledWith('openai', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行任务', () => {
|
||||
it('非流式模式返回结果', async () => {
|
||||
const context: AgentExecutionContext = {};
|
||||
|
||||
const result = await executor.execute('test prompt', context);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.text).toBe('Response text');
|
||||
expect(result.steps).toBe(1);
|
||||
expect(mockGenerateText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('流式模式调用回调', async () => {
|
||||
const onStream = vi.fn();
|
||||
const context: AgentExecutionContext = { onStream };
|
||||
|
||||
const result = await executor.execute('test prompt', context);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(onStream).toHaveBeenCalledWith('chunk1');
|
||||
expect(onStream).toHaveBeenCalledWith('chunk2');
|
||||
expect(mockStreamText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('使用 Agent 的系统提示词', async () => {
|
||||
const agentWithPrompt: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
prompt: 'Custom system prompt',
|
||||
};
|
||||
const customExecutor = new AgentExecutor(agentWithPrompt, baseConfig, mockToolRegistry as any);
|
||||
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
system: 'Custom system prompt',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('使用基础配置的系统提示词', async () => {
|
||||
await executor.execute('test', {});
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
system: 'You are a helpful assistant',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('处理执行错误', async () => {
|
||||
mockGenerateText.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const result = await executor.execute('test', {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('API Error');
|
||||
});
|
||||
|
||||
it('包含图片时构建多模态消息', async () => {
|
||||
const context: AgentExecutionContext = {
|
||||
images: [{ data: 'base64data', mimeType: 'image/png' }],
|
||||
};
|
||||
|
||||
await executor.execute('describe this', context);
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'image' }),
|
||||
expect.objectContaining({ type: 'text' }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('使用 Agent 的 maxSteps 配置', async () => {
|
||||
const agentWithSteps: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
maxSteps: 20,
|
||||
};
|
||||
const customExecutor = new AgentExecutor(agentWithSteps, baseConfig, mockToolRegistry as any);
|
||||
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stopWhen: 20,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('使用 Agent 的 maxTokens 配置', async () => {
|
||||
const agentWithTokens: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
model: { model: 'claude-sonnet', maxTokens: 8000 },
|
||||
};
|
||||
const customExecutor = new AgentExecutor(agentWithTokens, baseConfig, mockToolRegistry as any);
|
||||
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
maxOutputTokens: 8000,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFilteredTools - 工具过滤', () => {
|
||||
it('无配置返回所有工具', async () => {
|
||||
const tools = [createMockTool('read'), createMockTool('write')];
|
||||
mockToolRegistry.getAllTools.mockReturnValue(tools);
|
||||
|
||||
await executor.execute('test', {});
|
||||
|
||||
// 验证所有工具都被使用
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
read: expect.any(Object),
|
||||
write: expect.any(Object),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('enabled 只保留指定工具', async () => {
|
||||
const agentWithEnabled: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
tools: { enabled: ['read'] },
|
||||
};
|
||||
const tools = [createMockTool('read'), createMockTool('write')];
|
||||
mockToolRegistry.getAllTools.mockReturnValue(tools);
|
||||
|
||||
const customExecutor = new AgentExecutor(agentWithEnabled, baseConfig, mockToolRegistry as any);
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
read: expect.any(Object),
|
||||
}),
|
||||
})
|
||||
);
|
||||
// write 不应该存在
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
expect(call.tools.write).toBeUndefined();
|
||||
});
|
||||
|
||||
it('disabled 移除指定工具', async () => {
|
||||
const agentWithDisabled: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
tools: { disabled: ['write'] },
|
||||
};
|
||||
const tools = [createMockTool('read'), createMockTool('write')];
|
||||
mockToolRegistry.getAllTools.mockReturnValue(tools);
|
||||
|
||||
const customExecutor = new AgentExecutor(agentWithDisabled, baseConfig, mockToolRegistry as any);
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
expect(call.tools.read).toBeDefined();
|
||||
expect(call.tools.write).toBeUndefined();
|
||||
});
|
||||
|
||||
it('noTask 移除 task 工具', async () => {
|
||||
const agentWithNoTask: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
tools: { noTask: true },
|
||||
};
|
||||
const tools = [createMockTool('read'), createMockTool('task')];
|
||||
mockToolRegistry.getAllTools.mockReturnValue(tools);
|
||||
|
||||
const customExecutor = new AgentExecutor(agentWithNoTask, baseConfig, mockToolRegistry as any);
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
expect(call.tools.task).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkToolPermission - 权限检查', () => {
|
||||
it('无权限配置时允许所有', async () => {
|
||||
const tools = [createMockTool('bash')];
|
||||
mockToolRegistry.getAllTools.mockReturnValue(tools);
|
||||
|
||||
await executor.execute('test', {});
|
||||
|
||||
// 工具应该被包含
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
expect(call.tools.bash).toBeDefined();
|
||||
});
|
||||
|
||||
it('bash 命令被禁止时拒绝', async () => {
|
||||
const agentWithBashDeny: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
permission: {
|
||||
bash: { allow: [], deny: ['rm'], ask: [] },
|
||||
},
|
||||
};
|
||||
mockCheckBashPermission.mockReturnValue('deny');
|
||||
|
||||
const bashTool = createMockTool('bash');
|
||||
mockToolRegistry.getAllTools.mockReturnValue([bashTool]);
|
||||
|
||||
const customExecutor = new AgentExecutor(agentWithBashDeny, baseConfig, mockToolRegistry as any);
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
// 模拟工具执行
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
const toolResult = await call.tools.bash.execute({ command: 'rm -rf /' });
|
||||
|
||||
expect(toolResult.success).toBe(false);
|
||||
expect(toolResult.error).toContain('权限拒绝');
|
||||
});
|
||||
|
||||
it('文件写入被禁止时拒绝', async () => {
|
||||
const agentWithFileDeny: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
permission: {
|
||||
file: { write: 'deny', edit: 'allow', delete: 'allow' },
|
||||
},
|
||||
};
|
||||
|
||||
const writeTool = createMockTool('write_file');
|
||||
mockToolRegistry.getAllTools.mockReturnValue([writeTool]);
|
||||
|
||||
const customExecutor = new AgentExecutor(agentWithFileDeny, baseConfig, mockToolRegistry as any);
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
const toolResult = await call.tools.write_file.execute({ path: '/test.txt' });
|
||||
|
||||
expect(toolResult.success).toBe(false);
|
||||
expect(toolResult.error).toContain('权限拒绝');
|
||||
});
|
||||
|
||||
it('Git 写操作被禁止时拒绝', async () => {
|
||||
const agentWithGitDeny: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
permission: {
|
||||
git: { write: 'deny' },
|
||||
},
|
||||
};
|
||||
|
||||
const gitCommitTool = createMockTool('git_commit');
|
||||
mockToolRegistry.getAllTools.mockReturnValue([gitCommitTool]);
|
||||
|
||||
const customExecutor = new AgentExecutor(agentWithGitDeny, baseConfig, mockToolRegistry as any);
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
const toolResult = await call.tools.git_commit.execute({ message: 'test' });
|
||||
|
||||
expect(toolResult.success).toBe(false);
|
||||
expect(toolResult.error).toContain('Git 写操作被禁止');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMessageContent - 构建消息内容', () => {
|
||||
it('无图片时返回纯文本', async () => {
|
||||
await executor.execute('simple text', {});
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: [{ role: 'user', content: 'simple text' }],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('有图片时返回多模态内容', async () => {
|
||||
const context: AgentExecutionContext = {
|
||||
images: [
|
||||
{ data: 'img1', mimeType: 'image/png' },
|
||||
{ data: 'img2', mimeType: 'image/jpeg' },
|
||||
],
|
||||
};
|
||||
|
||||
await executor.execute('describe images', context);
|
||||
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
const content = call.messages[0].content;
|
||||
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content.length).toBe(3); // 2 images + 1 text
|
||||
expect(content[0].type).toBe('image');
|
||||
expect(content[1].type).toBe('image');
|
||||
expect(content[2].type).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionId 处理', () => {
|
||||
it('有 parentSessionId 时使用它', async () => {
|
||||
const context: AgentExecutionContext = {
|
||||
parentSessionId: 'parent-123',
|
||||
};
|
||||
|
||||
const result = await executor.execute('test', context);
|
||||
|
||||
expect(result.sessionId).toBe('parent-123');
|
||||
});
|
||||
|
||||
it('无 parentSessionId 时使用 standalone', async () => {
|
||||
const result = await executor.execute('test', {});
|
||||
|
||||
expect(result.sessionId).toBe('standalone');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -251,5 +251,76 @@ describe('AgentManager - Agent 管理器', () => {
|
||||
// 这里只验证 cleanup 不会崩溃
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('使用默认 maxAge', async () => {
|
||||
const agentId = await manager.runInBackground(
|
||||
mockAgentInfo,
|
||||
'任务',
|
||||
'执行',
|
||||
mockConfig,
|
||||
{} as any,
|
||||
mockContext
|
||||
);
|
||||
|
||||
// 等待完成
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
// 使用默认 maxAge(1 小时),不应该清理刚完成的
|
||||
manager.cleanup();
|
||||
|
||||
// Agent 应该还在(因为未超过 1 小时)
|
||||
const agent = manager.getAgent(agentId);
|
||||
expect(agent).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('完成回调', () => {
|
||||
it('多个等待者都收到通知', async () => {
|
||||
const agentId = await manager.runInBackground(
|
||||
mockAgentInfo,
|
||||
'任务',
|
||||
'执行',
|
||||
mockConfig,
|
||||
{} as any,
|
||||
mockContext
|
||||
);
|
||||
|
||||
// 并发等待
|
||||
const [result1, result2] = await Promise.all([
|
||||
manager.getAgentOutput(agentId, true, 5),
|
||||
manager.getAgentOutput(agentId, true, 5),
|
||||
]);
|
||||
|
||||
expect(result1).not.toBeNull();
|
||||
expect(result2).not.toBeNull();
|
||||
expect(result1!.id).toBe(agentId);
|
||||
expect(result2!.id).toBe(agentId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('已完成 Agent 的阻塞查询', () => {
|
||||
it('已完成的 Agent 阻塞查询立即返回', async () => {
|
||||
const agentId = await manager.runInBackground(
|
||||
mockAgentInfo,
|
||||
'任务',
|
||||
'执行',
|
||||
mockConfig,
|
||||
{} as any,
|
||||
mockContext
|
||||
);
|
||||
|
||||
// 等待完成
|
||||
await manager.getAgentOutput(agentId, true, 5);
|
||||
|
||||
// 再次阻塞查询应立即返回
|
||||
const startTime = Date.now();
|
||||
const result = await manager.getAgentOutput(agentId, true, 10);
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.status).not.toBe('running');
|
||||
// 应该立即返回,不需要等 10 秒
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
presetAgents,
|
||||
getPresetAgentNames,
|
||||
isPresetAgent,
|
||||
generalAgent,
|
||||
exploreAgent,
|
||||
codeReviewerAgent,
|
||||
buildAgent,
|
||||
planAgent,
|
||||
visionAgent,
|
||||
} from '../../../../src/agent/presets/index.js';
|
||||
|
||||
describe('Agent Presets - 预设 Agent', () => {
|
||||
describe('presetAgents 集合', () => {
|
||||
it('包含所有预设 Agent', () => {
|
||||
expect(Object.keys(presetAgents)).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('包含 general Agent', () => {
|
||||
expect(presetAgents['general']).toBeDefined();
|
||||
expect(presetAgents['general']).toBe(generalAgent);
|
||||
});
|
||||
|
||||
it('包含 explore Agent', () => {
|
||||
expect(presetAgents['explore']).toBeDefined();
|
||||
expect(presetAgents['explore']).toBe(exploreAgent);
|
||||
});
|
||||
|
||||
it('包含 code-reviewer Agent', () => {
|
||||
expect(presetAgents['code-reviewer']).toBeDefined();
|
||||
expect(presetAgents['code-reviewer']).toBe(codeReviewerAgent);
|
||||
});
|
||||
|
||||
it('包含 build Agent', () => {
|
||||
expect(presetAgents['build']).toBeDefined();
|
||||
expect(presetAgents['build']).toBe(buildAgent);
|
||||
});
|
||||
|
||||
it('包含 plan Agent', () => {
|
||||
expect(presetAgents['plan']).toBeDefined();
|
||||
expect(presetAgents['plan']).toBe(planAgent);
|
||||
});
|
||||
|
||||
it('包含 vision Agent', () => {
|
||||
expect(presetAgents['vision']).toBeDefined();
|
||||
expect(presetAgents['vision']).toBe(visionAgent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPresetAgentNames - 获取预设名称', () => {
|
||||
it('返回所有预设 Agent 名称', () => {
|
||||
const names = getPresetAgentNames();
|
||||
|
||||
expect(names).toContain('general');
|
||||
expect(names).toContain('explore');
|
||||
expect(names).toContain('code-reviewer');
|
||||
expect(names).toContain('build');
|
||||
expect(names).toContain('plan');
|
||||
expect(names).toContain('vision');
|
||||
});
|
||||
|
||||
it('返回数组类型', () => {
|
||||
const names = getPresetAgentNames();
|
||||
expect(Array.isArray(names)).toBe(true);
|
||||
});
|
||||
|
||||
it('返回正确数量', () => {
|
||||
const names = getPresetAgentNames();
|
||||
expect(names).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPresetAgent - 检查是否为预设', () => {
|
||||
it('识别 general', () => {
|
||||
expect(isPresetAgent('general')).toBe(true);
|
||||
});
|
||||
|
||||
it('识别 explore', () => {
|
||||
expect(isPresetAgent('explore')).toBe(true);
|
||||
});
|
||||
|
||||
it('识别 code-reviewer', () => {
|
||||
expect(isPresetAgent('code-reviewer')).toBe(true);
|
||||
});
|
||||
|
||||
it('识别 build', () => {
|
||||
expect(isPresetAgent('build')).toBe(true);
|
||||
});
|
||||
|
||||
it('识别 plan', () => {
|
||||
expect(isPresetAgent('plan')).toBe(true);
|
||||
});
|
||||
|
||||
it('识别 vision', () => {
|
||||
expect(isPresetAgent('vision')).toBe(true);
|
||||
});
|
||||
|
||||
it('不识别未知 Agent', () => {
|
||||
expect(isPresetAgent('unknown')).toBe(false);
|
||||
});
|
||||
|
||||
it('不识别空字符串', () => {
|
||||
expect(isPresetAgent('')).toBe(false);
|
||||
});
|
||||
|
||||
it('区分大小写', () => {
|
||||
expect(isPresetAgent('General')).toBe(false);
|
||||
expect(isPresetAgent('GENERAL')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generalAgent - 通用 Agent', () => {
|
||||
it('有正确的描述', () => {
|
||||
expect(generalAgent.description).toContain('通用');
|
||||
});
|
||||
|
||||
it('mode 为 subagent', () => {
|
||||
expect(generalAgent.mode).toBe('subagent');
|
||||
});
|
||||
|
||||
it('禁止嵌套 Task', () => {
|
||||
expect(generalAgent.tools?.noTask).toBe(true);
|
||||
});
|
||||
|
||||
it('有 maxSteps 限制', () => {
|
||||
expect(generalAgent.maxSteps).toBeDefined();
|
||||
expect(generalAgent.maxSteps).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exploreAgent - 探索 Agent', () => {
|
||||
it('有描述', () => {
|
||||
expect(exploreAgent.description).toBeDefined();
|
||||
});
|
||||
|
||||
it('mode 为 subagent', () => {
|
||||
expect(exploreAgent.mode).toBe('subagent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('codeReviewerAgent - 代码审查 Agent', () => {
|
||||
it('有描述', () => {
|
||||
expect(codeReviewerAgent.description).toBeDefined();
|
||||
});
|
||||
|
||||
it('mode 为 subagent', () => {
|
||||
expect(codeReviewerAgent.mode).toBe('subagent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAgent - 构建 Agent', () => {
|
||||
it('有描述', () => {
|
||||
expect(buildAgent.description).toBeDefined();
|
||||
});
|
||||
|
||||
it('mode 为 primary(主模式)', () => {
|
||||
expect(buildAgent.mode).toBe('primary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('planAgent - 计划 Agent', () => {
|
||||
it('有描述', () => {
|
||||
expect(planAgent.description).toBeDefined();
|
||||
});
|
||||
|
||||
it('mode 为 primary(主模式)', () => {
|
||||
expect(planAgent.mode).toBe('primary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('visionAgent - 视觉 Agent', () => {
|
||||
it('有描述', () => {
|
||||
expect(visionAgent.description).toBeDefined();
|
||||
});
|
||||
|
||||
it('mode 为 subagent', () => {
|
||||
expect(visionAgent.mode).toBe('subagent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('所有预设 Agent 共同特性', () => {
|
||||
it('都有 description', () => {
|
||||
Object.entries(presetAgents).forEach(([name, agent]) => {
|
||||
expect(agent.description, `${name} 缺少 description`).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('都有 mode', () => {
|
||||
Object.entries(presetAgents).forEach(([name, agent]) => {
|
||||
expect(agent.mode, `${name} 缺少 mode`).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('mode 值是 subagent 或 primary', () => {
|
||||
Object.entries(presetAgents).forEach(([name, agent]) => {
|
||||
expect(['subagent', 'primary'], `${name} mode 无效`).toContain(agent.mode);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { ModelMessage, LanguageModel } from 'ai';
|
||||
|
||||
// Mock prune module
|
||||
const mockPrune = vi.fn();
|
||||
const mockFilterCompacted = vi.fn();
|
||||
vi.mock('../../../src/context/prune.js', () => ({
|
||||
prune: (...args: unknown[]) => mockPrune(...args),
|
||||
filterCompacted: (...args: unknown[]) => mockFilterCompacted(...args),
|
||||
}));
|
||||
|
||||
// Mock compaction module
|
||||
const mockCompact = vi.fn();
|
||||
const mockSimpleCompact = vi.fn();
|
||||
const mockIsSummaryMessage = vi.fn();
|
||||
vi.mock('../../../src/context/compaction.js', () => ({
|
||||
compact: (...args: unknown[]) => mockCompact(...args),
|
||||
simpleCompact: (...args: unknown[]) => mockSimpleCompact(...args),
|
||||
isSummaryMessage: (...args: unknown[]) => mockIsSummaryMessage(...args),
|
||||
}));
|
||||
|
||||
// Mock token counter
|
||||
const mockEstimateMessages = vi.fn();
|
||||
const mockFormat = vi.fn();
|
||||
vi.mock('../../../src/context/token-counter.js', () => ({
|
||||
TokenCounter: {
|
||||
estimateMessages: (...args: unknown[]) => mockEstimateMessages(...args),
|
||||
format: (...args: unknown[]) => mockFormat(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import { CompressionManager } from '../../../src/context/manager.js';
|
||||
|
||||
describe('CompressionManager - 压缩管理器扩展测试', () => {
|
||||
let manager: CompressionManager;
|
||||
|
||||
const createMessages = (count: number): ModelMessage[] => {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
role: 'user' as const,
|
||||
content: `Message ${i}`,
|
||||
}));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
manager = new CompressionManager();
|
||||
|
||||
// 默认 mock 返回值
|
||||
mockEstimateMessages.mockReturnValue(1000);
|
||||
mockFormat.mockReturnValue('1K');
|
||||
mockPrune.mockReturnValue({ messages: [], freedTokens: 0 });
|
||||
mockFilterCompacted.mockImplementation((msgs) => msgs);
|
||||
mockCompact.mockResolvedValue({ messages: [], freedTokens: 0 });
|
||||
mockSimpleCompact.mockReturnValue({ messages: [], freedTokens: 0 });
|
||||
mockIsSummaryMessage.mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe('constructor - 构造函数', () => {
|
||||
it('使用默认配置', () => {
|
||||
const config = manager.getConfig();
|
||||
|
||||
expect(config.contextLimit).toBeDefined();
|
||||
expect(config.outputReserve).toBeDefined();
|
||||
expect(config.overflowThreshold).toBeDefined();
|
||||
});
|
||||
|
||||
it('合并自定义配置', () => {
|
||||
const customManager = new CompressionManager({
|
||||
contextLimit: 100000,
|
||||
outputReserve: 5000,
|
||||
});
|
||||
|
||||
const config = customManager.getConfig();
|
||||
expect(config.contextLimit).toBe(100000);
|
||||
expect(config.outputReserve).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setModel - 设置模型', () => {
|
||||
it('设置模型用于摘要生成', () => {
|
||||
const mockModel = {} as LanguageModel;
|
||||
manager.setModel(mockModel);
|
||||
|
||||
// 模型被设置后 compact 应该使用它
|
||||
expect(manager['model']).toBe(mockModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateConfig - 更新配置', () => {
|
||||
it('更新部分配置', () => {
|
||||
const originalConfig = manager.getConfig();
|
||||
|
||||
manager.updateConfig({ contextLimit: 200000 });
|
||||
|
||||
const updatedConfig = manager.getConfig();
|
||||
expect(updatedConfig.contextLimit).toBe(200000);
|
||||
expect(updatedConfig.outputReserve).toBe(originalConfig.outputReserve);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateUsage - 计算使用量', () => {
|
||||
it('计算 token 使用情况', () => {
|
||||
mockEstimateMessages.mockReturnValue(50000);
|
||||
|
||||
const messages = createMessages(10);
|
||||
const usage = manager.calculateUsage(messages);
|
||||
|
||||
expect(usage.input).toBe(50000);
|
||||
expect(usage.contextLimit).toBeDefined();
|
||||
expect(usage.available).toBeDefined();
|
||||
expect(usage.usagePercent).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('使用百分比不超过 100', () => {
|
||||
// 模拟超出限制
|
||||
mockEstimateMessages.mockReturnValue(300000);
|
||||
|
||||
const usage = manager.calculateUsage([]);
|
||||
|
||||
expect(usage.usagePercent).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldCompress - 是否需要压缩', () => {
|
||||
it('未超过阈值返回 false', () => {
|
||||
mockEstimateMessages.mockReturnValue(10000);
|
||||
|
||||
const result = manager.shouldCompress([]);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('超过阈值返回 true', () => {
|
||||
// 设置接近阈值的使用量
|
||||
mockEstimateMessages.mockReturnValue(150000);
|
||||
|
||||
const result = manager.shouldCompress([]);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOverflow - 是否溢出', () => {
|
||||
it('未溢出返回 false', () => {
|
||||
mockEstimateMessages.mockReturnValue(10000);
|
||||
|
||||
const result = manager.isOverflow([]);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('溢出返回 true', () => {
|
||||
mockEstimateMessages.mockReturnValue(500000);
|
||||
|
||||
const result = manager.isOverflow([]);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prune - 执行裁剪', () => {
|
||||
it('调用 prune 函数', () => {
|
||||
const messages = createMessages(5);
|
||||
mockPrune.mockReturnValue({ messages: messages.slice(2), freedTokens: 1000 });
|
||||
|
||||
const result = manager.prune(messages);
|
||||
|
||||
expect(mockPrune).toHaveBeenCalledWith(messages, expect.any(Object));
|
||||
expect(result.freedTokens).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compact - 执行压缩', () => {
|
||||
it('有模型时使用 AI 压缩', async () => {
|
||||
const mockModel = {} as LanguageModel;
|
||||
manager.setModel(mockModel);
|
||||
mockCompact.mockResolvedValue({ messages: [], freedTokens: 2000 });
|
||||
|
||||
const messages = createMessages(5);
|
||||
const result = await manager.compact(messages);
|
||||
|
||||
expect(mockCompact).toHaveBeenCalled();
|
||||
expect(result.freedTokens).toBe(2000);
|
||||
});
|
||||
|
||||
it('无模型时使用简单压缩', async () => {
|
||||
mockSimpleCompact.mockReturnValue({ messages: [], freedTokens: 500 });
|
||||
|
||||
const messages = createMessages(5);
|
||||
const result = await manager.compact(messages);
|
||||
|
||||
expect(mockSimpleCompact).toHaveBeenCalled();
|
||||
expect(result.freedTokens).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compress - 自动压缩', () => {
|
||||
it('先 prune 后不需要 compact', async () => {
|
||||
mockEstimateMessages.mockReturnValue(10000); // 低于阈值
|
||||
mockPrune.mockReturnValue({ messages: [], freedTokens: 500 });
|
||||
|
||||
const messages = createMessages(5);
|
||||
const result = await manager.compress(messages);
|
||||
|
||||
expect(result.type).toBe('prune');
|
||||
expect(mockPrune).toHaveBeenCalled();
|
||||
expect(mockCompact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prune 后仍需 compact', async () => {
|
||||
mockEstimateMessages.mockReturnValue(150000); // 高于阈值
|
||||
mockPrune.mockReturnValue({ messages: createMessages(3), freedTokens: 500 });
|
||||
mockSimpleCompact.mockReturnValue({ messages: [], freedTokens: 1000 });
|
||||
|
||||
const messages = createMessages(5);
|
||||
const result = await manager.compress(messages);
|
||||
|
||||
expect(result.type).toBe('both');
|
||||
expect(mockPrune).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('只 compact 时类型为 compaction', async () => {
|
||||
mockEstimateMessages.mockReturnValue(150000);
|
||||
mockPrune.mockReturnValue({ messages: createMessages(5), freedTokens: 0 });
|
||||
mockSimpleCompact.mockReturnValue({ messages: [], freedTokens: 1000 });
|
||||
|
||||
const messages = createMessages(5);
|
||||
const result = await manager.compress(messages);
|
||||
|
||||
expect(result.type).toBe('compaction');
|
||||
});
|
||||
});
|
||||
|
||||
describe('forceCompress - 强制压缩', () => {
|
||||
it('消息太少不压缩', async () => {
|
||||
const messages = createMessages(3);
|
||||
const result = await manager.forceCompress(messages);
|
||||
|
||||
expect(result.messages).toEqual(messages);
|
||||
expect(result.freedTokens).toBe(0);
|
||||
});
|
||||
|
||||
it('足够消息时执行压缩', async () => {
|
||||
mockEstimateMessages.mockReturnValue(10000);
|
||||
mockPrune.mockReturnValue({ messages: createMessages(3), freedTokens: 500 });
|
||||
mockSimpleCompact.mockReturnValue({ messages: createMessages(2), freedTokens: 300 });
|
||||
|
||||
const messages = createMessages(10);
|
||||
const result = await manager.forceCompress(messages);
|
||||
|
||||
expect(mockPrune).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('有模型时尝试 AI 压缩', async () => {
|
||||
const mockModel = {} as LanguageModel;
|
||||
manager.setModel(mockModel);
|
||||
mockEstimateMessages.mockReturnValue(10000);
|
||||
mockPrune.mockReturnValue({ messages: createMessages(5), freedTokens: 500 });
|
||||
mockCompact.mockResolvedValue({ messages: createMessages(2), freedTokens: 1000 });
|
||||
|
||||
const messages = createMessages(10);
|
||||
await manager.forceCompress(messages);
|
||||
|
||||
expect(mockCompact).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('AI 压缩失败时回退到简单压缩', async () => {
|
||||
const mockModel = {} as LanguageModel;
|
||||
manager.setModel(mockModel);
|
||||
mockEstimateMessages.mockReturnValue(10000);
|
||||
mockPrune.mockReturnValue({ messages: createMessages(5), freedTokens: 500 });
|
||||
mockCompact.mockRejectedValue(new Error('AI error'));
|
||||
mockSimpleCompact.mockReturnValue({ messages: createMessages(2), freedTokens: 800 });
|
||||
|
||||
const messages = createMessages(10);
|
||||
const result = await manager.forceCompress(messages);
|
||||
|
||||
expect(mockSimpleCompact).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterCompacted - 过滤压缩内容', () => {
|
||||
it('调用 filterCompacted 函数', () => {
|
||||
const messages = createMessages(5);
|
||||
mockFilterCompacted.mockReturnValue(messages.slice(1));
|
||||
|
||||
const result = manager.filterCompacted(messages);
|
||||
|
||||
expect(mockFilterCompacted).toHaveBeenCalledWith(messages);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSummaryMessage - 检查摘要消息', () => {
|
||||
it('调用 isSummaryMessage 函数', () => {
|
||||
mockIsSummaryMessage.mockReturnValue(true);
|
||||
|
||||
const message: ModelMessage = { role: 'assistant', content: 'summary' };
|
||||
const result = manager.isSummaryMessage(message);
|
||||
|
||||
expect(mockIsSummaryMessage).toHaveBeenCalledWith(message);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUsage - 格式化使用情况', () => {
|
||||
it('返回格式化字符串', () => {
|
||||
mockEstimateMessages.mockReturnValue(50000);
|
||||
mockFormat.mockImplementation((n) => `${Math.round(n / 1000)}K`);
|
||||
|
||||
const messages = createMessages(5);
|
||||
const result = manager.formatUsage(messages);
|
||||
|
||||
expect(result).toMatch(/\d+K\/\d+K/);
|
||||
expect(result).toContain('%');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { Agent } from '../../../src/core/agent.js';
|
||||
import { ToolRegistry } from '../../../src/tools/registry.js';
|
||||
import { SessionManager } from '../../../src/session/index.js';
|
||||
import type { AgentConfig, Tool } from '../../../src/types/index.js';
|
||||
import type { AgentInfo } from '../../../src/agent/types.js';
|
||||
|
||||
// Mock providers
|
||||
vi.mock('../../../src/core/providers.js', () => ({
|
||||
getModelFactory: vi.fn(() => {
|
||||
return (model: string) => ({
|
||||
modelId: `mock:${model}`,
|
||||
doGenerate: vi.fn(),
|
||||
doStream: vi.fn(),
|
||||
});
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock AI SDK
|
||||
vi.mock('ai', () => ({
|
||||
generateText: vi.fn().mockResolvedValue({
|
||||
text: 'Mock response',
|
||||
response: { messages: [] },
|
||||
steps: [],
|
||||
}),
|
||||
streamText: vi.fn(() => ({
|
||||
textStream: (async function* () {
|
||||
yield 'Mock ';
|
||||
yield 'streamed ';
|
||||
yield 'response';
|
||||
})(),
|
||||
response: Promise.resolve({ messages: [] }),
|
||||
})),
|
||||
stepCountIs: vi.fn(() => () => false),
|
||||
}));
|
||||
|
||||
// Mock agent registry and executor
|
||||
vi.mock('../../../src/agent/index.js', () => ({
|
||||
agentRegistry: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
AgentExecutor: vi.fn().mockImplementation(() => ({
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
text: 'Vision analysis result',
|
||||
steps: 1,
|
||||
sessionId: 'test',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock vision config
|
||||
vi.mock('../../../src/utils/config.js', () => ({
|
||||
loadVisionConfig: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
// Create mock tool
|
||||
function createMockTool(name: string, category = 'core'): Tool & { metadata?: { deferLoading?: boolean } } {
|
||||
return {
|
||||
name,
|
||||
description: `Mock ${name} tool`,
|
||||
parameters: { type: 'object', properties: {}, required: [] },
|
||||
execute: vi.fn().mockResolvedValue({ success: true, output: `${name} result` }),
|
||||
metadata: { deferLoading: category !== 'core' },
|
||||
};
|
||||
}
|
||||
|
||||
// Default test config
|
||||
const testConfig: AgentConfig = {
|
||||
provider: 'anthropic',
|
||||
apiKey: 'test-api-key',
|
||||
model: 'claude-3-sonnet',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
maxTokens: 4096,
|
||||
};
|
||||
|
||||
describe('Agent', () => {
|
||||
let agent: Agent;
|
||||
let registry: ToolRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
registry = new ToolRegistry();
|
||||
|
||||
// Register some tools
|
||||
registry.register(createMockTool('tool_search', 'core') as any);
|
||||
registry.register(createMockTool('bash', 'core') as any);
|
||||
registry.register(createMockTool('read_file', 'filesystem') as any);
|
||||
registry.register(createMockTool('write_file', 'filesystem') as any);
|
||||
|
||||
agent = new Agent(testConfig);
|
||||
agent.setRegistry(registry);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('初始化 Agent 配置', () => {
|
||||
const config = agent.getConfig();
|
||||
expect(config.provider).toBe('anthropic');
|
||||
expect(config.model).toBe('claude-3-sonnet');
|
||||
expect(config.systemPrompt).toBe('You are a helpful assistant.');
|
||||
});
|
||||
|
||||
it('支持自定义压缩配置', () => {
|
||||
const agentWithCompression = new Agent(testConfig, {
|
||||
maxContextTokens: 50000,
|
||||
compressionThreshold: 0.7,
|
||||
});
|
||||
|
||||
expect(agentWithCompression.getCompressionManager()).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRegistry', () => {
|
||||
it('设置工具注册表', () => {
|
||||
const newAgent = new Agent(testConfig);
|
||||
expect(newAgent.getToolCount()).toEqual({ core: 0, discovered: 0, total: 0 });
|
||||
|
||||
newAgent.setRegistry(registry);
|
||||
const counts = newAgent.getToolCount();
|
||||
expect(counts.core).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSessionManager', () => {
|
||||
it('设置会话管理器', async () => {
|
||||
// Mock SessionManager
|
||||
const mockSessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(null),
|
||||
setMessages: vi.fn(),
|
||||
setDiscoveredTools: vi.fn(),
|
||||
newSession: vi.fn(),
|
||||
} as unknown as SessionManager;
|
||||
|
||||
agent.setSessionManager(mockSessionManager);
|
||||
expect(agent.getSessionManager()).toBe(mockSessionManager);
|
||||
});
|
||||
|
||||
it('从会话恢复状态', async () => {
|
||||
// Mock SessionManager with existing session
|
||||
const mockSessionManager = {
|
||||
getSession: vi.fn().mockReturnValue({
|
||||
id: 'test-session',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
discoveredTools: ['read_file', 'write_file'],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
setMessages: vi.fn(),
|
||||
setDiscoveredTools: vi.fn(),
|
||||
newSession: vi.fn(),
|
||||
} as unknown as SessionManager;
|
||||
|
||||
agent.setSessionManager(mockSessionManager);
|
||||
|
||||
const counts = agent.getToolCount();
|
||||
expect(counts.discovered).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolCount', () => {
|
||||
it('返回工具数量统计', () => {
|
||||
const counts = agent.getToolCount();
|
||||
expect(counts).toHaveProperty('core');
|
||||
expect(counts).toHaveProperty('discovered');
|
||||
expect(counts).toHaveProperty('total');
|
||||
expect(counts.total).toBe(counts.core + counts.discovered);
|
||||
});
|
||||
|
||||
it('registry 未设置时返回 0', () => {
|
||||
const newAgent = new Agent(testConfig);
|
||||
const counts = newAgent.getToolCount();
|
||||
expect(counts).toEqual({ core: 0, discovered: 0, total: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHistory', () => {
|
||||
it('初始历史为空', () => {
|
||||
expect(agent.getHistory()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearHistory', () => {
|
||||
it('清空对话历史和发现的工具', async () => {
|
||||
// Add some history by calling chat (mocked)
|
||||
await agent.chat('Hello');
|
||||
|
||||
await agent.clearHistory();
|
||||
expect(agent.getHistory()).toEqual([]);
|
||||
expect(agent.getToolCount().discovered).toBe(0);
|
||||
});
|
||||
|
||||
it('有会话管理器时创建新会话', async () => {
|
||||
const mockSessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(null),
|
||||
setMessages: vi.fn(),
|
||||
setDiscoveredTools: vi.fn(),
|
||||
newSession: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as SessionManager;
|
||||
|
||||
agent.setSessionManager(mockSessionManager);
|
||||
await agent.clearHistory();
|
||||
|
||||
expect(mockSessionManager.newSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Mode', () => {
|
||||
const exploreAgent: AgentInfo = {
|
||||
name: 'explore',
|
||||
description: '代码探索 Agent',
|
||||
mode: 'subagent',
|
||||
prompt: 'You are an explore agent.',
|
||||
tools: {
|
||||
enabled: ['read_file', 'tool_search'],
|
||||
noTask: true,
|
||||
},
|
||||
};
|
||||
|
||||
it('设置 Agent 模式', () => {
|
||||
agent.setAgentMode(exploreAgent);
|
||||
expect(agent.getAgentMode()).toBe(exploreAgent);
|
||||
expect(agent.getAgentModeName()).toBe('explore');
|
||||
});
|
||||
|
||||
it('切换 Agent 时更新 system prompt', () => {
|
||||
agent.setAgentMode(exploreAgent);
|
||||
expect(agent.getConfig().systemPrompt).toBe('You are an explore agent.');
|
||||
});
|
||||
|
||||
it('切换回 default 时恢复原始 prompt', () => {
|
||||
agent.setAgentMode(exploreAgent);
|
||||
agent.setAgentMode(null);
|
||||
|
||||
expect(agent.getAgentModeName()).toBe('default');
|
||||
expect(agent.getConfig().systemPrompt).toBe('You are a helpful assistant.');
|
||||
});
|
||||
|
||||
it('Agent 没有自定义 prompt 时保持原始 prompt', () => {
|
||||
const agentWithoutPrompt: AgentInfo = {
|
||||
name: 'simple',
|
||||
description: 'Simple agent',
|
||||
mode: 'subagent',
|
||||
};
|
||||
|
||||
agent.setAgentMode(agentWithoutPrompt);
|
||||
expect(agent.getConfig().systemPrompt).toBe('You are a helpful assistant.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsVision', () => {
|
||||
it('Anthropic Claude-3 支持 vision', () => {
|
||||
const claudeAgent = new Agent({
|
||||
...testConfig,
|
||||
provider: 'anthropic',
|
||||
model: 'claude-3-opus',
|
||||
});
|
||||
expect(claudeAgent.supportsVision()).toBe(true);
|
||||
});
|
||||
|
||||
it('Anthropic Claude-4 支持 vision', () => {
|
||||
const claudeAgent = new Agent({
|
||||
...testConfig,
|
||||
provider: 'anthropic',
|
||||
model: 'claude-4-sonnet',
|
||||
});
|
||||
expect(claudeAgent.supportsVision()).toBe(true);
|
||||
});
|
||||
|
||||
it('OpenAI GPT-4 支持 vision', () => {
|
||||
const gptAgent = new Agent({
|
||||
...testConfig,
|
||||
provider: 'openai',
|
||||
model: 'gpt-4-turbo',
|
||||
});
|
||||
expect(gptAgent.supportsVision()).toBe(true);
|
||||
});
|
||||
|
||||
it('DeepSeek 不支持 vision', () => {
|
||||
const deepseekAgent = new Agent({
|
||||
...testConfig,
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-chat',
|
||||
});
|
||||
expect(deepseekAgent.supportsVision()).toBe(false);
|
||||
});
|
||||
|
||||
it('旧版 Claude 不支持 vision', () => {
|
||||
const oldClaudeAgent = new Agent({
|
||||
...testConfig,
|
||||
provider: 'anthropic',
|
||||
model: 'claude-2',
|
||||
});
|
||||
expect(oldClaudeAgent.supportsVision()).toBe(false);
|
||||
});
|
||||
|
||||
it('GPT-3.5 不支持 vision', () => {
|
||||
const gpt35Agent = new Agent({
|
||||
...testConfig,
|
||||
provider: 'openai',
|
||||
model: 'gpt-3.5-turbo',
|
||||
});
|
||||
expect(gpt35Agent.supportsVision()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContextUsage', () => {
|
||||
it('返回 token 使用情况', () => {
|
||||
const usage = agent.getContextUsage();
|
||||
expect(usage).toHaveProperty('input');
|
||||
expect(usage).toHaveProperty('contextLimit');
|
||||
expect(usage).toHaveProperty('available');
|
||||
expect(usage).toHaveProperty('usagePercent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContextUsageFormatted', () => {
|
||||
it('返回格式化的使用情况字符串', () => {
|
||||
const formatted = agent.getContextUsageFormatted();
|
||||
expect(typeof formatted).toBe('string');
|
||||
expect(formatted).toContain('k');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCompressionManager', () => {
|
||||
it('返回压缩管理器实例', () => {
|
||||
const manager = agent.getCompressionManager();
|
||||
expect(manager).toBeDefined();
|
||||
expect(manager).toHaveProperty('compress');
|
||||
expect(manager).toHaveProperty('forceCompress');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compactHistory', () => {
|
||||
it('手动压缩对话历史', async () => {
|
||||
const result = await agent.compactHistory();
|
||||
expect(result).toHaveProperty('freedTokens');
|
||||
expect(result).toHaveProperty('type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfig', () => {
|
||||
it('返回配置副本', () => {
|
||||
const config = agent.getConfig();
|
||||
expect(config).toEqual(testConfig);
|
||||
|
||||
// 修改返回值不影响原始配置
|
||||
config.model = 'modified';
|
||||
expect(agent.getConfig().model).toBe('claude-3-sonnet');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent - getAvailableTools error handling', () => {
|
||||
it('registry 未设置时抛出错误', () => {
|
||||
const agent = new Agent(testConfig);
|
||||
|
||||
// 使用 getToolCount 间接测试(因为 getAvailableTools 是 private)
|
||||
// 当 registry 为 null 时,getToolCount 返回 0
|
||||
const counts = agent.getToolCount();
|
||||
expect(counts.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent - chat with images', () => {
|
||||
let agent: Agent;
|
||||
let registry: ToolRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
registry = new ToolRegistry();
|
||||
registry.register(createMockTool('tool_search', 'core') as any);
|
||||
|
||||
agent = new Agent(testConfig);
|
||||
agent.setRegistry(registry);
|
||||
});
|
||||
|
||||
it('支持 vision 的模型直接处理图片', async () => {
|
||||
const visionAgent = new Agent({
|
||||
...testConfig,
|
||||
model: 'claude-3-opus',
|
||||
});
|
||||
visionAgent.setRegistry(registry);
|
||||
|
||||
const result = await visionAgent.chat({
|
||||
text: 'What is in this image?',
|
||||
images: [
|
||||
{ data: 'base64data', mimeType: 'image/png' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('不支持 vision 时返回错误消息(Vision 未配置)', async () => {
|
||||
const deepseekAgent = new Agent({
|
||||
...testConfig,
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-chat',
|
||||
});
|
||||
deepseekAgent.setRegistry(registry);
|
||||
|
||||
const result = await deepseekAgent.chat({
|
||||
text: 'What is in this image?',
|
||||
images: [
|
||||
{ data: 'base64data', mimeType: 'image/png' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toContain('无法处理图片');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getModelFactory, providers } from '../../../src/core/providers.js';
|
||||
|
||||
// Mock AI SDK providers
|
||||
vi.mock('@ai-sdk/anthropic', () => ({
|
||||
createAnthropic: vi.fn(() => {
|
||||
const modelFn = (model: string) => ({ modelId: `anthropic:${model}` });
|
||||
return modelFn;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@ai-sdk/deepseek', () => ({
|
||||
createDeepSeek: vi.fn(() => {
|
||||
const modelFn = (model: string) => ({ modelId: `deepseek:${model}` });
|
||||
return modelFn;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@ai-sdk/openai', () => ({
|
||||
createOpenAI: vi.fn(() => {
|
||||
const modelFn = (model: string) => ({ modelId: `openai:${model}` });
|
||||
return modelFn;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('qwen-ai-provider-v5', () => ({
|
||||
createQwen: vi.fn(() => {
|
||||
const modelFn = (model: string) => ({ modelId: `qwen:${model}` });
|
||||
return modelFn;
|
||||
}),
|
||||
}));
|
||||
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createDeepSeek } from '@ai-sdk/deepseek';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { createQwen } from 'qwen-ai-provider-v5';
|
||||
|
||||
describe('providers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('providers 注册表', () => {
|
||||
it('包含所有支持的 provider 类型', () => {
|
||||
expect(providers).toHaveProperty('anthropic');
|
||||
expect(providers).toHaveProperty('deepseek');
|
||||
expect(providers).toHaveProperty('openai');
|
||||
});
|
||||
|
||||
it('每个 provider 是一个工厂函数', () => {
|
||||
expect(typeof providers.anthropic).toBe('function');
|
||||
expect(typeof providers.deepseek).toBe('function');
|
||||
expect(typeof providers.openai).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Anthropic provider', () => {
|
||||
it('创建 Anthropic 客户端', () => {
|
||||
const factory = providers.anthropic({
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
|
||||
expect(createAnthropic).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: undefined,
|
||||
});
|
||||
expect(typeof factory).toBe('function');
|
||||
});
|
||||
|
||||
it('支持自定义 baseUrl', () => {
|
||||
providers.anthropic({
|
||||
apiKey: 'test-api-key',
|
||||
baseUrl: 'https://custom.anthropic.com',
|
||||
});
|
||||
|
||||
expect(createAnthropic).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://custom.anthropic.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('返回的工厂函数可以创建模型', () => {
|
||||
const factory = providers.anthropic({
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
|
||||
const model = factory('claude-3-opus');
|
||||
expect(model).toEqual({ modelId: 'anthropic:claude-3-opus' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeepSeek provider', () => {
|
||||
it('创建 DeepSeek 客户端', () => {
|
||||
const factory = providers.deepseek({
|
||||
apiKey: 'test-deepseek-key',
|
||||
});
|
||||
|
||||
expect(createDeepSeek).toHaveBeenCalledWith({
|
||||
apiKey: 'test-deepseek-key',
|
||||
baseURL: undefined,
|
||||
});
|
||||
expect(typeof factory).toBe('function');
|
||||
});
|
||||
|
||||
it('支持自定义 baseUrl', () => {
|
||||
providers.deepseek({
|
||||
apiKey: 'test-deepseek-key',
|
||||
baseUrl: 'https://custom.deepseek.com',
|
||||
});
|
||||
|
||||
expect(createDeepSeek).toHaveBeenCalledWith({
|
||||
apiKey: 'test-deepseek-key',
|
||||
baseURL: 'https://custom.deepseek.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('返回的工厂函数可以创建模型', () => {
|
||||
const factory = providers.deepseek({
|
||||
apiKey: 'test-deepseek-key',
|
||||
});
|
||||
|
||||
const model = factory('deepseek-chat');
|
||||
expect(model).toEqual({ modelId: 'deepseek:deepseek-chat' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAI provider', () => {
|
||||
it('创建 OpenAI 客户端(标准 URL)', () => {
|
||||
const factory = providers.openai({
|
||||
apiKey: 'test-openai-key',
|
||||
});
|
||||
|
||||
expect(createOpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-openai-key',
|
||||
baseURL: undefined,
|
||||
});
|
||||
expect(createQwen).not.toHaveBeenCalled();
|
||||
expect(typeof factory).toBe('function');
|
||||
});
|
||||
|
||||
it('支持自定义 baseUrl(非 DashScope)', () => {
|
||||
providers.openai({
|
||||
apiKey: 'test-openai-key',
|
||||
baseUrl: 'https://custom.openai.com/v1',
|
||||
});
|
||||
|
||||
expect(createOpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-openai-key',
|
||||
baseURL: 'https://custom.openai.com/v1',
|
||||
});
|
||||
expect(createQwen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('返回的工厂函数可以创建模型', () => {
|
||||
const factory = providers.openai({
|
||||
apiKey: 'test-openai-key',
|
||||
});
|
||||
|
||||
const model = factory('gpt-4');
|
||||
expect(model).toEqual({ modelId: 'openai:gpt-4' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashScope (Qwen) 检测', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('检测 dashscope URL 并使用 Qwen provider', () => {
|
||||
providers.openai({
|
||||
apiKey: 'test-qwen-key',
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
});
|
||||
|
||||
expect(createQwen).toHaveBeenCalledWith({
|
||||
apiKey: 'test-qwen-key',
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
});
|
||||
expect(createOpenAI).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('检测包含 dashscope 的任意 URL', () => {
|
||||
providers.openai({
|
||||
apiKey: 'test-qwen-key',
|
||||
baseUrl: 'https://api.dashscope.example.com/v1',
|
||||
});
|
||||
|
||||
expect(createQwen).toHaveBeenCalled();
|
||||
expect(createOpenAI).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Qwen 工厂函数可以创建模型', () => {
|
||||
const factory = providers.openai({
|
||||
apiKey: 'test-qwen-key',
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/v1',
|
||||
});
|
||||
|
||||
const model = factory('qwen-turbo');
|
||||
expect(model).toEqual({ modelId: 'qwen:qwen-turbo' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModelFactory', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('获取 Anthropic 模型工厂', () => {
|
||||
const factory = getModelFactory('anthropic', {
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
|
||||
expect(typeof factory).toBe('function');
|
||||
expect(createAnthropic).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('获取 DeepSeek 模型工厂', () => {
|
||||
const factory = getModelFactory('deepseek', {
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
|
||||
expect(typeof factory).toBe('function');
|
||||
expect(createDeepSeek).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('获取 OpenAI 模型工厂', () => {
|
||||
const factory = getModelFactory('openai', {
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
|
||||
expect(typeof factory).toBe('function');
|
||||
expect(createOpenAI).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('传递正确的选项给 provider', () => {
|
||||
getModelFactory('anthropic', {
|
||||
apiKey: 'my-api-key',
|
||||
baseUrl: 'https://my-proxy.com',
|
||||
});
|
||||
|
||||
expect(createAnthropic).toHaveBeenCalledWith({
|
||||
apiKey: 'my-api-key',
|
||||
baseURL: 'https://my-proxy.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('不支持的 provider 抛出错误', () => {
|
||||
expect(() => {
|
||||
getModelFactory('unsupported' as any, {
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
}).toThrow('不支持的 provider: unsupported');
|
||||
});
|
||||
|
||||
it('返回的工厂函数可以创建模型实例', () => {
|
||||
const factory = getModelFactory('anthropic', {
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
|
||||
const model = factory('claude-3-sonnet');
|
||||
expect(model).toEqual({ modelId: 'anthropic:claude-3-sonnet' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
listServers,
|
||||
printServerList,
|
||||
installServer,
|
||||
installAllServers,
|
||||
showServerInfo,
|
||||
type ServerStatus,
|
||||
} from '../../../src/lsp/cli.js';
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
spawnSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock server module
|
||||
vi.mock('../../../src/lsp/server.js', () => ({
|
||||
getUniqueServers: vi.fn(() => [
|
||||
{
|
||||
id: 'typescript-language-server',
|
||||
languages: ['typescript', 'javascript'],
|
||||
config: {
|
||||
displayName: 'TypeScript Language Server',
|
||||
description: 'TypeScript/JavaScript 语言服务器',
|
||||
command: 'typescript-language-server',
|
||||
install: {
|
||||
npm: 'typescript-language-server typescript',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pylsp',
|
||||
languages: ['python'],
|
||||
config: {
|
||||
displayName: 'Python LSP Server',
|
||||
description: 'Python 语言服务器',
|
||||
command: 'pylsp',
|
||||
install: {
|
||||
pip: 'python-lsp-server',
|
||||
manual: '也可以使用: pip3 install python-lsp-server',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gopls',
|
||||
languages: ['go'],
|
||||
config: {
|
||||
displayName: 'Go Language Server',
|
||||
description: 'Go 语言服务器',
|
||||
command: 'gopls',
|
||||
install: {
|
||||
go: 'golang.org/x/tools/gopls@latest',
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
import { execSync, spawnSync } from 'child_process';
|
||||
|
||||
describe('LSP CLI - LSP 命令行工具', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// 默认命令存在
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
if (cmd.includes('which pip3')) return Buffer.from('/usr/bin/pip3');
|
||||
if (cmd.includes('which go')) return Buffer.from('/usr/bin/go');
|
||||
throw new Error('Command not found');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('listServers - 列出服务器', () => {
|
||||
it('返回所有服务器状态', () => {
|
||||
const servers = listServers();
|
||||
|
||||
expect(servers).toHaveLength(3);
|
||||
expect(servers[0].id).toBe('typescript-language-server');
|
||||
expect(servers[1].id).toBe('pylsp');
|
||||
expect(servers[2].id).toBe('gopls');
|
||||
});
|
||||
|
||||
it('检测已安装的服务器', () => {
|
||||
// 只有 typescript-language-server 安装了
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which typescript-language-server') return Buffer.from('/usr/bin/tsc');
|
||||
throw new Error('not found');
|
||||
});
|
||||
|
||||
const servers = listServers();
|
||||
|
||||
expect(servers[0].installed).toBe(true);
|
||||
expect(servers[1].installed).toBe(false);
|
||||
expect(servers[2].installed).toBe(false);
|
||||
});
|
||||
|
||||
it('包含服务器详细信息', () => {
|
||||
const servers = listServers();
|
||||
const tsServer = servers[0];
|
||||
|
||||
expect(tsServer.displayName).toBe('TypeScript Language Server');
|
||||
expect(tsServer.description).toContain('TypeScript');
|
||||
expect(tsServer.command).toBe('typescript-language-server');
|
||||
expect(tsServer.languages).toContain('typescript');
|
||||
expect(tsServer.install).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('printServerList - 打印服务器列表', () => {
|
||||
it('输出格式化的服务器列表', () => {
|
||||
printServerList();
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('语言服务器状态'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('状态'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('服务器'));
|
||||
});
|
||||
|
||||
it('显示已安装数量统计', () => {
|
||||
printServerList();
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('已安装');
|
||||
});
|
||||
});
|
||||
|
||||
describe('installServer - 安装服务器', () => {
|
||||
it('服务器不存在时返回 false', async () => {
|
||||
const result = await installServer('non-existent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('未找到服务器'));
|
||||
});
|
||||
|
||||
it('服务器已安装时返回 true', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which typescript-language-server') return Buffer.from('/usr/bin/tsc');
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
throw new Error('not found');
|
||||
});
|
||||
|
||||
const result = await installServer('typescript-language-server');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('已安装'));
|
||||
});
|
||||
|
||||
it('使用 npm 安装 TypeScript 服务器', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which typescript-language-server') throw new Error('not found');
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as any);
|
||||
|
||||
const result = await installServer('typescript-language-server');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(spawnSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('npm install -g'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('安装失败时返回 false', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which typescript-language-server') throw new Error('not found');
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mocked(spawnSync).mockReturnValue({ status: 1 } as any);
|
||||
|
||||
const result = await installServer('typescript-language-server');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('安装失败'));
|
||||
});
|
||||
|
||||
it('按显示名称查找服务器', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which typescript-language-server') return Buffer.from('/usr/bin/tsc');
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
throw new Error('not found');
|
||||
});
|
||||
|
||||
const result = await installServer('TypeScript Language Server');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('spawnSync 抛出异常时返回 false', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which typescript-language-server') throw new Error('not found');
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mocked(spawnSync).mockImplementation(() => {
|
||||
throw new Error('spawn error');
|
||||
});
|
||||
|
||||
const result = await installServer('typescript-language-server');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('安装出错'));
|
||||
});
|
||||
|
||||
it('无法自动安装时显示手动说明', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// pylsp 未安装,且没有可用的 pip
|
||||
if (cmd === 'which pylsp') throw new Error('not found');
|
||||
throw new Error('not found');
|
||||
});
|
||||
|
||||
const result = await installServer('pylsp');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('无法自动安装'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('installAllServers - 安装所有服务器', () => {
|
||||
it('所有服务器都已安装时显示完成', async () => {
|
||||
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/cmd'));
|
||||
|
||||
await installAllServers();
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('都已安装'));
|
||||
});
|
||||
|
||||
it('安装未安装的服务器', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// 所有服务器都未安装,但有 npm
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as any);
|
||||
|
||||
await installAllServers();
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('将安装'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('安装完成'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('showServerInfo - 显示服务器信息', () => {
|
||||
it('显示已安装服务器的信息', () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which typescript-language-server') return Buffer.from('/usr/bin/tsc');
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
throw new Error('not found');
|
||||
});
|
||||
|
||||
showServerInfo('typescript-language-server');
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('TypeScript Language Server');
|
||||
expect(calls).toContain('已安装');
|
||||
});
|
||||
|
||||
it('显示未安装服务器的安装命令', () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which typescript-language-server') throw new Error('not found');
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
throw new Error('not found');
|
||||
});
|
||||
|
||||
showServerInfo('typescript-language-server');
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('未安装');
|
||||
expect(calls).toContain('安装命令');
|
||||
expect(calls).toContain('npm install');
|
||||
});
|
||||
|
||||
it('服务器不存在时显示错误', () => {
|
||||
showServerInfo('non-existent');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('未找到服务器'));
|
||||
});
|
||||
|
||||
it('按显示名称查找服务器', () => {
|
||||
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/cmd'));
|
||||
|
||||
showServerInfo('Python LSP Server');
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('Python LSP Server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstallCommand - 安装命令选择', () => {
|
||||
it('pip 作为 Python 服务器的安装方式', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which pylsp') throw new Error('not found');
|
||||
if (cmd.includes('which pip3')) return Buffer.from('/usr/bin/pip3');
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as any);
|
||||
|
||||
await installServer('pylsp');
|
||||
|
||||
expect(spawnSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('pip3 install'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('go install 作为 Go 服务器的安装方式', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which gopls') throw new Error('not found');
|
||||
if (cmd.includes('which go')) return Buffer.from('/usr/bin/go');
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as any);
|
||||
|
||||
await installServer('gopls');
|
||||
|
||||
expect(spawnSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('go install'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerStatus 类型', () => {
|
||||
it('包含所有必要字段', () => {
|
||||
const status: ServerStatus = {
|
||||
id: 'test-server',
|
||||
displayName: 'Test Server',
|
||||
description: 'A test server',
|
||||
command: 'test-cmd',
|
||||
installed: true,
|
||||
languages: ['typescript'],
|
||||
install: { npm: 'test-package' },
|
||||
};
|
||||
|
||||
expect(status.id).toBeDefined();
|
||||
expect(status.displayName).toBeDefined();
|
||||
expect(status.command).toBeDefined();
|
||||
expect(status.installed).toBeDefined();
|
||||
expect(status.languages).toBeDefined();
|
||||
expect(status.install).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { LSPClientManager } from '../../../src/lsp/client.js';
|
||||
import { DiagnosticSeverity } from 'vscode-languageserver-protocol';
|
||||
|
||||
// Mock child_process
|
||||
const mockSpawn = vi.fn();
|
||||
const mockExecSync = vi.fn();
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: (...args: unknown[]) => mockSpawn(...args),
|
||||
execSync: (...args: unknown[]) => mockExecSync(...args),
|
||||
}));
|
||||
|
||||
// Mock vscode-jsonrpc
|
||||
const mockConnection = {
|
||||
listen: vi.fn(),
|
||||
sendRequest: vi.fn().mockResolvedValue({}),
|
||||
sendNotification: vi.fn(),
|
||||
onNotification: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('vscode-jsonrpc/node.js', () => ({
|
||||
createMessageConnection: vi.fn(() => mockConnection),
|
||||
StreamMessageReader: vi.fn(),
|
||||
StreamMessageWriter: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn().mockResolvedValue('file content'),
|
||||
}));
|
||||
|
||||
// Mock language module
|
||||
vi.mock('../../../src/lsp/language.js', () => ({
|
||||
getLanguageId: vi.fn((path: string) => {
|
||||
if (path.endsWith('.ts')) return 'typescript';
|
||||
if (path.endsWith('.py')) return 'python';
|
||||
return undefined;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock server module
|
||||
vi.mock('../../../src/lsp/server.js', () => ({
|
||||
getServerConfig: vi.fn((languageId: string) => {
|
||||
if (languageId === 'typescript') {
|
||||
return {
|
||||
command: 'typescript-language-server',
|
||||
args: ['--stdio'],
|
||||
env: {},
|
||||
initializationOptions: {},
|
||||
};
|
||||
}
|
||||
if (languageId === 'python') {
|
||||
return {
|
||||
command: 'pylsp',
|
||||
args: [],
|
||||
env: {},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
}));
|
||||
|
||||
import { getLanguageId } from '../../../src/lsp/language.js';
|
||||
|
||||
describe('LSPClientManager - 扩展测试', () => {
|
||||
let manager: LSPClientManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
manager = new LSPClientManager('/test/project');
|
||||
|
||||
// 默认命令存在
|
||||
mockExecSync.mockReturnValue(Buffer.from(''));
|
||||
|
||||
// 模拟 spawn 返回的进程
|
||||
mockSpawn.mockReturnValue({
|
||||
stdin: { on: vi.fn(), write: vi.fn() },
|
||||
stdout: { on: vi.fn(), pipe: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
pid: 12345,
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClient - 启动客户端', () => {
|
||||
it('重复获取同一语言返回相同客户端', async () => {
|
||||
const client1 = await manager.getClient('typescript');
|
||||
const client2 = await manager.getClient('typescript');
|
||||
|
||||
expect(client1).toBe(client2);
|
||||
});
|
||||
|
||||
it('成功启动时设置初始化状态', async () => {
|
||||
const client = await manager.getClient('typescript');
|
||||
|
||||
if (client) {
|
||||
expect(client.initialized).toBe(true);
|
||||
expect(client.languageId).toBe('typescript');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('touchFile - 文件变更通知', () => {
|
||||
it('相对路径转换为绝对路径', async () => {
|
||||
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
||||
|
||||
// 先获取客户端
|
||||
await manager.getClient('typescript');
|
||||
|
||||
// touchFile 应该能处理相对路径
|
||||
const result = await manager.touchFile('relative/file.ts');
|
||||
|
||||
// 由于客户端已初始化,应该调用通知
|
||||
expect(typeof result).toBe('boolean');
|
||||
});
|
||||
|
||||
it('首次启动返回 true', async () => {
|
||||
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
||||
|
||||
const result = await manager.touchFile('/test/file.ts');
|
||||
|
||||
// 首次启动服务器应返回 true
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('后续调用返回 false', async () => {
|
||||
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
||||
|
||||
// 首次调用
|
||||
await manager.touchFile('/test/file.ts');
|
||||
|
||||
// 第二次调用
|
||||
const result = await manager.touchFile('/test/file.ts');
|
||||
|
||||
// 已经在运行,返回 false
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeFile - 关闭文件', () => {
|
||||
it('正常关闭打开的文件', async () => {
|
||||
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
||||
|
||||
// 先打开文件
|
||||
await manager.touchFile('/test/file.ts');
|
||||
|
||||
// 关闭文件
|
||||
await manager.closeFile('/test/file.ts');
|
||||
|
||||
expect(mockConnection.sendNotification).toHaveBeenCalledWith(
|
||||
'textDocument/didClose',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('诊断信息转换', () => {
|
||||
it('正确转换诊断严重性', async () => {
|
||||
// 通过 onNotification 回调模拟诊断
|
||||
let diagnosticsCallback: ((params: unknown) => void) | null = null;
|
||||
mockConnection.onNotification.mockImplementation((method: string, callback: (params: unknown) => void) => {
|
||||
if (method === 'textDocument/publishDiagnostics') {
|
||||
diagnosticsCallback = callback;
|
||||
}
|
||||
});
|
||||
|
||||
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
||||
await manager.getClient('typescript');
|
||||
|
||||
// 模拟收到诊断
|
||||
if (diagnosticsCallback) {
|
||||
diagnosticsCallback({
|
||||
uri: 'file:///test/file.ts',
|
||||
diagnostics: [
|
||||
{
|
||||
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } },
|
||||
severity: DiagnosticSeverity.Error,
|
||||
message: 'Test error',
|
||||
source: 'typescript',
|
||||
code: 'TS2345',
|
||||
},
|
||||
{
|
||||
range: { start: { line: 1, character: 0 }, end: { line: 1, character: 5 } },
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
message: 'Test warning',
|
||||
},
|
||||
{
|
||||
range: { start: { line: 2, character: 0 }, end: { line: 2, character: 5 } },
|
||||
severity: DiagnosticSeverity.Information,
|
||||
message: 'Test info',
|
||||
},
|
||||
{
|
||||
range: { start: { line: 3, character: 0 }, end: { line: 3, character: 5 } },
|
||||
severity: DiagnosticSeverity.Hint,
|
||||
message: 'Test hint',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 获取诊断
|
||||
const diagnostics = manager.getFileDiagnostics('/test/file.ts');
|
||||
|
||||
expect(diagnostics.length).toBe(4);
|
||||
expect(diagnostics[0].severity).toBe('error');
|
||||
expect(diagnostics[1].severity).toBe('warning');
|
||||
expect(diagnostics[2].severity).toBe('info');
|
||||
expect(diagnostics[3].severity).toBe('hint');
|
||||
});
|
||||
|
||||
it('转换行列号(从 0 开始转为 1 开始)', async () => {
|
||||
let diagnosticsCallback: ((params: unknown) => void) | null = null;
|
||||
mockConnection.onNotification.mockImplementation((method: string, callback: (params: unknown) => void) => {
|
||||
if (method === 'textDocument/publishDiagnostics') {
|
||||
diagnosticsCallback = callback;
|
||||
}
|
||||
});
|
||||
|
||||
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
||||
await manager.getClient('typescript');
|
||||
|
||||
if (diagnosticsCallback) {
|
||||
diagnosticsCallback({
|
||||
uri: 'file:///test/file.ts',
|
||||
diagnostics: [
|
||||
{
|
||||
range: { start: { line: 9, character: 4 }, end: { line: 9, character: 20 } },
|
||||
severity: DiagnosticSeverity.Error,
|
||||
message: 'Error',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const diagnostics = manager.getFileDiagnostics('/test/file.ts');
|
||||
|
||||
expect(diagnostics[0].line).toBe(10); // 9 + 1
|
||||
expect(diagnostics[0].column).toBe(5); // 4 + 1
|
||||
expect(diagnostics[0].endLine).toBe(10);
|
||||
expect(diagnostics[0].endColumn).toBe(21);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdown - 关闭所有客户端', () => {
|
||||
it('关闭所有运行中的客户端', async () => {
|
||||
vi.mocked(getLanguageId).mockImplementation((path: string) => {
|
||||
if (path.endsWith('.ts')) return 'typescript';
|
||||
if (path.endsWith('.py')) return 'python';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// 启动多个客户端
|
||||
await manager.getClient('typescript');
|
||||
await manager.getClient('python');
|
||||
|
||||
expect(manager.getRunningServers().length).toBe(2);
|
||||
|
||||
// 关闭
|
||||
await manager.shutdown();
|
||||
|
||||
expect(manager.getRunningServers().length).toBe(0);
|
||||
});
|
||||
|
||||
it('处理关闭时的错误', async () => {
|
||||
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
||||
await manager.getClient('typescript');
|
||||
|
||||
// 模拟 dispose 抛出错误
|
||||
mockConnection.dispose.mockImplementation(() => {
|
||||
throw new Error('Dispose error');
|
||||
});
|
||||
|
||||
// 应该不抛出错误
|
||||
await expect(manager.shutdown()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('进程事件处理', () => {
|
||||
it('进程退出时清理客户端', async () => {
|
||||
let exitCallback: (() => void) | null = null;
|
||||
mockSpawn.mockReturnValue({
|
||||
stdin: { on: vi.fn(), write: vi.fn() },
|
||||
stdout: { on: vi.fn(), pipe: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event: string, callback: () => void) => {
|
||||
if (event === 'exit') {
|
||||
exitCallback = callback;
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
pid: 12345,
|
||||
});
|
||||
|
||||
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
||||
await manager.getClient('typescript');
|
||||
|
||||
expect(manager.isServerRunning('typescript')).toBe(true);
|
||||
|
||||
// 模拟进程退出
|
||||
if (exitCallback) {
|
||||
exitCallback();
|
||||
}
|
||||
|
||||
expect(manager.isServerRunning('typescript')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDiagnostics - 获取所有诊断', () => {
|
||||
it('可以过滤指定文件的诊断', async () => {
|
||||
let diagnosticsCallback: ((params: unknown) => void) | null = null;
|
||||
mockConnection.onNotification.mockImplementation((method: string, callback: (params: unknown) => void) => {
|
||||
if (method === 'textDocument/publishDiagnostics') {
|
||||
diagnosticsCallback = callback;
|
||||
}
|
||||
});
|
||||
|
||||
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
||||
await manager.getClient('typescript');
|
||||
|
||||
// 为两个文件添加诊断
|
||||
if (diagnosticsCallback) {
|
||||
diagnosticsCallback({
|
||||
uri: 'file:///test/file1.ts',
|
||||
diagnostics: [
|
||||
{
|
||||
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } },
|
||||
severity: DiagnosticSeverity.Error,
|
||||
message: 'Error in file1',
|
||||
},
|
||||
],
|
||||
});
|
||||
diagnosticsCallback({
|
||||
uri: 'file:///test/file2.ts',
|
||||
diagnostics: [
|
||||
{
|
||||
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } },
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
message: 'Warning in file2',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有诊断
|
||||
const allDiagnostics = manager.getDiagnostics();
|
||||
expect(allDiagnostics.size).toBe(2);
|
||||
|
||||
// 只获取 file1 的诊断
|
||||
const file1Diagnostics = manager.getDiagnostics('/test/file1.ts');
|
||||
expect(file1Diagnostics.size).toBe(1);
|
||||
expect(file1Diagnostics.has('/test/file1.ts')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock functions
|
||||
const mockTouchFile = vi.fn();
|
||||
const mockGetDiagnostics = vi.fn();
|
||||
const mockGetFileDiagnostics = vi.fn();
|
||||
const mockShutdown = vi.fn();
|
||||
|
||||
// 创建 mock class
|
||||
class MockLSPClientManager {
|
||||
touchFile = mockTouchFile;
|
||||
getDiagnostics = mockGetDiagnostics;
|
||||
getFileDiagnostics = mockGetFileDiagnostics;
|
||||
shutdown = mockShutdown;
|
||||
|
||||
constructor(_rootPath?: string) {}
|
||||
}
|
||||
|
||||
// Mock client module
|
||||
vi.mock('../../../src/lsp/client.js', () => ({
|
||||
LSPClientManager: MockLSPClientManager,
|
||||
}));
|
||||
|
||||
// Mock language module
|
||||
vi.mock('../../../src/lsp/language.js', () => ({
|
||||
getLanguageId: vi.fn((path: string) => {
|
||||
if (path.endsWith('.ts')) return 'typescript';
|
||||
if (path.endsWith('.py')) return 'python';
|
||||
return undefined;
|
||||
}),
|
||||
isLanguageSupported: vi.fn((path: string) => {
|
||||
return path.endsWith('.ts') || path.endsWith('.py');
|
||||
}),
|
||||
getSupportedExtensions: vi.fn(() => ['.ts', '.js', '.py']),
|
||||
}));
|
||||
|
||||
// Mock server module
|
||||
vi.mock('../../../src/lsp/server.js', () => ({
|
||||
getServerConfig: vi.fn(),
|
||||
hasServerConfig: vi.fn(),
|
||||
getSupportedLanguages: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
describe('LSP Index - LSP 模块入口', () => {
|
||||
let lspModule: typeof import('../../../src/lsp/index.js');
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
// 重置模块缓存以获取干净的状态
|
||||
vi.resetModules();
|
||||
// 重新导入模块
|
||||
lspModule = await import('../../../src/lsp/index.js');
|
||||
});
|
||||
|
||||
describe('initLSP - 初始化 LSP', () => {
|
||||
it('初始化 LSP 管理器', () => {
|
||||
lspModule.initLSP('/test/project');
|
||||
|
||||
expect(lspModule.getLSPManager()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('重复初始化不会创建新实例', () => {
|
||||
lspModule.initLSP('/test/project');
|
||||
const manager1 = lspModule.getLSPManager();
|
||||
|
||||
lspModule.initLSP('/another/project');
|
||||
const manager2 = lspModule.getLSPManager();
|
||||
|
||||
expect(manager1).toBe(manager2);
|
||||
});
|
||||
|
||||
it('默认使用 process.cwd()', () => {
|
||||
lspModule.initLSP();
|
||||
|
||||
expect(lspModule.getLSPManager()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLSPManager - 获取管理器', () => {
|
||||
it('未初始化时返回 null', async () => {
|
||||
// 重置模块获取干净状态
|
||||
vi.resetModules();
|
||||
const freshModule = await import('../../../src/lsp/index.js');
|
||||
|
||||
expect(freshModule.getLSPManager()).toBeNull();
|
||||
});
|
||||
|
||||
it('初始化后返回管理器实例', () => {
|
||||
lspModule.initLSP();
|
||||
|
||||
expect(lspModule.getLSPManager()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('touchFile - 通知文件变更', () => {
|
||||
it('未初始化时返回 false', async () => {
|
||||
vi.resetModules();
|
||||
const freshModule = await import('../../../src/lsp/index.js');
|
||||
|
||||
const result = await freshModule.touchFile('/test/file.ts');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('不支持的语言返回 false', async () => {
|
||||
lspModule.initLSP();
|
||||
|
||||
const result = await lspModule.touchFile('/test/file.txt');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('支持的语言调用管理器', async () => {
|
||||
mockTouchFile.mockResolvedValue(true);
|
||||
lspModule.initLSP();
|
||||
|
||||
const result = await lspModule.touchFile('/test/file.ts');
|
||||
|
||||
expect(mockTouchFile).toHaveBeenCalledWith('/test/file.ts', false);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('新文件传递 isNew 参数', async () => {
|
||||
mockTouchFile.mockResolvedValue(true);
|
||||
lspModule.initLSP();
|
||||
|
||||
await lspModule.touchFile('/test/file.ts', true);
|
||||
|
||||
expect(mockTouchFile).toHaveBeenCalledWith('/test/file.ts', true);
|
||||
});
|
||||
|
||||
it('touchFile 出错时静默返回 false', async () => {
|
||||
mockTouchFile.mockRejectedValue(new Error('LSP error'));
|
||||
lspModule.initLSP();
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const result = await lspModule.touchFile('/test/file.ts');
|
||||
|
||||
expect(result).toBe(false);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDiagnostics - 获取所有诊断', () => {
|
||||
it('未初始化时返回空 Map', async () => {
|
||||
vi.resetModules();
|
||||
const freshModule = await import('../../../src/lsp/index.js');
|
||||
|
||||
const result = freshModule.getDiagnostics();
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('返回管理器的诊断信息', () => {
|
||||
const mockDiagnostics = new Map([
|
||||
['/test/file.ts', [{ file: '/test/file.ts', line: 1, column: 1, severity: 'error', message: 'Test error' }]],
|
||||
]);
|
||||
mockGetDiagnostics.mockReturnValue(mockDiagnostics);
|
||||
lspModule.initLSP();
|
||||
|
||||
const result = lspModule.getDiagnostics();
|
||||
|
||||
expect(result).toBe(mockDiagnostics);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileDiagnostics - 获取单文件诊断', () => {
|
||||
it('未初始化时返回空数组', async () => {
|
||||
vi.resetModules();
|
||||
const freshModule = await import('../../../src/lsp/index.js');
|
||||
|
||||
const result = freshModule.getFileDiagnostics('/test/file.ts');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('返回指定文件的诊断', () => {
|
||||
const mockDiagnostics = [
|
||||
{ file: '/test/file.ts', line: 1, column: 1, severity: 'error' as const, message: 'Error' },
|
||||
];
|
||||
mockGetFileDiagnostics.mockReturnValue(mockDiagnostics);
|
||||
lspModule.initLSP();
|
||||
|
||||
const result = lspModule.getFileDiagnostics('/test/file.ts');
|
||||
|
||||
expect(result).toBe(mockDiagnostics);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDiagnostics - 格式化诊断信息', () => {
|
||||
it('空诊断返回空字符串', () => {
|
||||
const result = lspModule.formatDiagnostics([]);
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('格式化单个诊断', () => {
|
||||
const diagnostics = [
|
||||
{
|
||||
file: '/test/file.ts',
|
||||
line: 10,
|
||||
column: 5,
|
||||
severity: 'error' as const,
|
||||
message: 'Type error',
|
||||
code: 'TS2345',
|
||||
source: 'typescript',
|
||||
},
|
||||
];
|
||||
|
||||
const result = lspModule.formatDiagnostics(diagnostics);
|
||||
|
||||
expect(result).toContain('10:5');
|
||||
expect(result).toContain('ERROR');
|
||||
expect(result).toContain('TS2345');
|
||||
expect(result).toContain('Type error');
|
||||
expect(result).toContain('typescript');
|
||||
});
|
||||
|
||||
it('格式化多个诊断', () => {
|
||||
const diagnostics = [
|
||||
{ file: '/test/file.ts', line: 1, column: 1, severity: 'error' as const, message: 'Error 1' },
|
||||
{ file: '/test/file.ts', line: 2, column: 2, severity: 'warning' as const, message: 'Warning 1' },
|
||||
];
|
||||
|
||||
const result = lspModule.formatDiagnostics(diagnostics);
|
||||
|
||||
expect(result).toContain('Error 1');
|
||||
expect(result).toContain('Warning 1');
|
||||
expect(result).toContain('ERROR');
|
||||
expect(result).toContain('WARNING');
|
||||
});
|
||||
|
||||
it('无 code 时不显示 code', () => {
|
||||
const diagnostics = [
|
||||
{ file: '/test/file.ts', line: 1, column: 1, severity: 'error' as const, message: 'Error' },
|
||||
];
|
||||
|
||||
const result = lspModule.formatDiagnostics(diagnostics);
|
||||
|
||||
expect(result).not.toContain('[');
|
||||
});
|
||||
|
||||
it('无 source 时不显示 source', () => {
|
||||
const diagnostics = [
|
||||
{ file: '/test/file.ts', line: 1, column: 1, severity: 'error' as const, message: 'Error' },
|
||||
];
|
||||
|
||||
const result = lspModule.formatDiagnostics(diagnostics);
|
||||
|
||||
expect(result).not.toContain('(');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFormattedFileDiagnostics - 获取格式化的文件诊断', () => {
|
||||
it('无错误和警告时返回空字符串', async () => {
|
||||
mockGetFileDiagnostics.mockReturnValue([
|
||||
{ file: '/test/file.ts', line: 1, column: 1, severity: 'info', message: 'Info' },
|
||||
{ file: '/test/file.ts', line: 2, column: 2, severity: 'hint', message: 'Hint' },
|
||||
]);
|
||||
lspModule.initLSP();
|
||||
|
||||
const result = await lspModule.getFormattedFileDiagnostics('/test/file.ts');
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('只显示错误和警告', async () => {
|
||||
mockGetFileDiagnostics.mockReturnValue([
|
||||
{ file: '/test/file.ts', line: 1, column: 1, severity: 'error', message: 'Error' },
|
||||
{ file: '/test/file.ts', line: 2, column: 2, severity: 'warning', message: 'Warning' },
|
||||
{ file: '/test/file.ts', line: 3, column: 3, severity: 'info', message: 'Info' },
|
||||
]);
|
||||
lspModule.initLSP();
|
||||
|
||||
const result = await lspModule.getFormattedFileDiagnostics('/test/file.ts');
|
||||
|
||||
expect(result).toContain('Error');
|
||||
expect(result).toContain('Warning');
|
||||
expect(result).not.toContain('Info');
|
||||
});
|
||||
|
||||
it('包含 file_diagnostics 标签', async () => {
|
||||
mockGetFileDiagnostics.mockReturnValue([
|
||||
{ file: '/test/file.ts', line: 1, column: 1, severity: 'error', message: 'Error' },
|
||||
]);
|
||||
lspModule.initLSP();
|
||||
|
||||
const result = await lspModule.getFormattedFileDiagnostics('/test/file.ts');
|
||||
|
||||
expect(result).toContain('<file_diagnostics');
|
||||
expect(result).toContain('/test/file.ts');
|
||||
expect(result).toContain('</file_diagnostics>');
|
||||
});
|
||||
|
||||
it('显示错误和警告数量', async () => {
|
||||
mockGetFileDiagnostics.mockReturnValue([
|
||||
{ file: '/test/file.ts', line: 1, column: 1, severity: 'error', message: 'Error 1' },
|
||||
{ file: '/test/file.ts', line: 2, column: 2, severity: 'error', message: 'Error 2' },
|
||||
{ file: '/test/file.ts', line: 3, column: 3, severity: 'warning', message: 'Warning' },
|
||||
]);
|
||||
lspModule.initLSP();
|
||||
|
||||
const result = await lspModule.getFormattedFileDiagnostics('/test/file.ts');
|
||||
|
||||
expect(result).toContain('2 个错误');
|
||||
expect(result).toContain('1 个警告');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdownLSP - 关闭 LSP', () => {
|
||||
it('关闭管理器', async () => {
|
||||
lspModule.initLSP();
|
||||
|
||||
await lspModule.shutdownLSP();
|
||||
|
||||
expect(mockShutdown).toHaveBeenCalled();
|
||||
expect(lspModule.getLSPManager()).toBeNull();
|
||||
});
|
||||
|
||||
it('未初始化时安全调用', async () => {
|
||||
vi.resetModules();
|
||||
const freshModule = await import('../../../src/lsp/index.js');
|
||||
|
||||
await expect(freshModule.shutdownLSP()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('导出', () => {
|
||||
it('导出 getLanguageId', () => {
|
||||
expect(lspModule.getLanguageId).toBeDefined();
|
||||
});
|
||||
|
||||
it('导出 isLanguageSupported', () => {
|
||||
expect(lspModule.isLanguageSupported).toBeDefined();
|
||||
});
|
||||
|
||||
it('导出 getSupportedExtensions', () => {
|
||||
expect(lspModule.getSupportedExtensions).toBeDefined();
|
||||
});
|
||||
|
||||
it('导出 LSPClientManager', () => {
|
||||
expect(lspModule.LSPClientManager).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
promptFileWrite,
|
||||
promptFileEdit,
|
||||
promptFilePermission,
|
||||
} from '../../../src/permission/file-prompt.js';
|
||||
import type { FilePermissionContext } from '../../../src/permission/types.js';
|
||||
|
||||
// Mock readline
|
||||
vi.mock('readline', () => ({
|
||||
createInterface: vi.fn(() => ({
|
||||
question: vi.fn(),
|
||||
close: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock chalk
|
||||
vi.mock('chalk', () => ({
|
||||
default: {
|
||||
yellow: (s: string) => s,
|
||||
cyan: (s: string) => s,
|
||||
white: (s: string) => s,
|
||||
gray: (s: string) => s,
|
||||
red: (s: string) => s,
|
||||
green: (s: string) => s,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock diff utils
|
||||
vi.mock('../../../src/utils/diff.js', () => ({
|
||||
computeDiff: vi.fn(() => ({
|
||||
isNew: false,
|
||||
oldContent: 'old',
|
||||
newContent: 'new',
|
||||
hunks: [],
|
||||
})),
|
||||
formatDiff: vi.fn(() => 'diff output'),
|
||||
countChanges: vi.fn(() => ({ additions: 5, deletions: 3 })),
|
||||
formatEditDiff: vi.fn(() => 'edit diff output'),
|
||||
}));
|
||||
|
||||
import * as readline from 'readline';
|
||||
import * as fs from 'fs/promises';
|
||||
import { computeDiff, countChanges } from '../../../src/utils/diff.js';
|
||||
|
||||
describe('File Prompt - 文件操作提示', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('promptFileWrite - 文件写入提示', () => {
|
||||
const baseContext: FilePermissionContext = {
|
||||
operation: 'write',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'write_file',
|
||||
newContent: 'new content',
|
||||
};
|
||||
|
||||
it('无内容时使用简单确认', async () => {
|
||||
const ctx: FilePermissionContext = {
|
||||
...baseContext,
|
||||
newContent: undefined,
|
||||
};
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(ctx);
|
||||
|
||||
expect(result.allow).toBe(true);
|
||||
});
|
||||
|
||||
it('内容相同时直接允许', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('new content');
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
});
|
||||
|
||||
it('新文件显示新增行数', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.mocked(computeDiff).mockReturnValue({
|
||||
isNew: true,
|
||||
oldContent: null,
|
||||
newContent: 'new content',
|
||||
hunks: [],
|
||||
} as any);
|
||||
vi.mocked(countChanges).mockReturnValue({ additions: 10, deletions: 0 });
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptFileWrite(baseContext);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('新文件');
|
||||
expect(calls).toContain('+10 行');
|
||||
});
|
||||
|
||||
it('修改文件显示增删行数', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
vi.mocked(computeDiff).mockReturnValue({
|
||||
isNew: false,
|
||||
oldContent: 'old content',
|
||||
newContent: 'new content',
|
||||
hunks: [],
|
||||
} as any);
|
||||
vi.mocked(countChanges).mockReturnValue({ additions: 5, deletions: 3 });
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptFileWrite(baseContext);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('+5 行');
|
||||
expect(calls).toContain('-3 行');
|
||||
});
|
||||
|
||||
it('用户输入 y 返回允许不记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
});
|
||||
|
||||
it('用户输入 Y 返回允许并记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('Y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: true });
|
||||
});
|
||||
|
||||
it('用户输入 n 返回拒绝不记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('n')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: false });
|
||||
});
|
||||
|
||||
it('用户输入 N 返回拒绝并记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('N')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: true });
|
||||
});
|
||||
|
||||
it('无效输入默认拒绝', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('invalid')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: false });
|
||||
});
|
||||
|
||||
it('超长 diff 被截断', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
// 模拟超过 50 行的 diff
|
||||
const longDiff = Array(100).fill('line').join('\n');
|
||||
const { formatDiff } = await import('../../../src/utils/diff.js');
|
||||
vi.mocked(formatDiff).mockReturnValue(longDiff);
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptFileWrite(baseContext);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('省略');
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptFileEdit - 文件编辑提示', () => {
|
||||
const baseContext: FilePermissionContext = {
|
||||
operation: 'edit',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'edit_file',
|
||||
oldContent: 'old text',
|
||||
newContent: 'new text',
|
||||
};
|
||||
|
||||
it('无内容时使用简单确认', async () => {
|
||||
const ctx: FilePermissionContext = {
|
||||
...baseContext,
|
||||
oldContent: undefined,
|
||||
newContent: undefined,
|
||||
};
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileEdit(ctx);
|
||||
|
||||
expect(result.allow).toBe(true);
|
||||
});
|
||||
|
||||
it('显示编辑 diff', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptFileEdit(baseContext);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('文件编辑预览');
|
||||
expect(calls).toContain('/test/file.ts');
|
||||
});
|
||||
|
||||
it('用户确认后返回决定', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('Y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileEdit(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptFilePermission - 统一入口', () => {
|
||||
it('write 操作调用 promptFileWrite', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('same content');
|
||||
|
||||
const ctx: FilePermissionContext = {
|
||||
operation: 'write',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'write_file',
|
||||
newContent: 'same content',
|
||||
};
|
||||
|
||||
const result = await promptFilePermission(ctx);
|
||||
|
||||
// 内容相同直接允许
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
});
|
||||
|
||||
it('edit 操作调用 promptFileEdit', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const ctx: FilePermissionContext = {
|
||||
operation: 'edit',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'edit_file',
|
||||
oldContent: 'old',
|
||||
newContent: 'new',
|
||||
};
|
||||
|
||||
await promptFilePermission(ctx);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('文件编辑预览');
|
||||
});
|
||||
|
||||
it('其他操作使用简单确认', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const ctx: FilePermissionContext = {
|
||||
operation: 'delete',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'delete_file',
|
||||
};
|
||||
|
||||
await promptFilePermission(ctx);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('文件操作确认');
|
||||
});
|
||||
});
|
||||
|
||||
describe('确认选项显示', () => {
|
||||
it('显示所有选项', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptFileWrite({
|
||||
operation: 'write',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'write_file',
|
||||
newContent: 'new content',
|
||||
});
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('[y]');
|
||||
expect(calls).toContain('[Y]');
|
||||
expect(calls).toContain('[n]');
|
||||
expect(calls).toContain('[N]');
|
||||
expect(calls).toContain('确认执行');
|
||||
expect(calls).toContain('拒绝执行');
|
||||
expect(calls).toContain('记住');
|
||||
});
|
||||
});
|
||||
|
||||
describe('输入处理', () => {
|
||||
it('输入被 trim', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback(' y ')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite({
|
||||
operation: 'write',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'write_file',
|
||||
newContent: 'new content',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { promptPermission, showPermissionDenied, showPermissionAllowed } from '../../../src/permission/prompt.js';
|
||||
import type { PermissionContext } from '../../../src/permission/types.js';
|
||||
|
||||
// Mock readline
|
||||
vi.mock('readline', () => ({
|
||||
createInterface: vi.fn(() => ({
|
||||
question: vi.fn(),
|
||||
close: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock chalk
|
||||
vi.mock('chalk', () => ({
|
||||
default: {
|
||||
yellow: (s: string) => s,
|
||||
cyan: (s: string) => s,
|
||||
white: (s: string) => s,
|
||||
gray: (s: string) => s,
|
||||
red: (s: string) => s,
|
||||
green: (s: string) => s,
|
||||
},
|
||||
}));
|
||||
|
||||
import * as readline from 'readline';
|
||||
|
||||
describe('Permission Prompt - 权限提示模块', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('showPermissionDenied - 显示权限被拒绝', () => {
|
||||
it('显示命令和原因', () => {
|
||||
showPermissionDenied('rm -rf /', '危险命令');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('权限被拒绝'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('rm -rf /'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('危险命令'));
|
||||
});
|
||||
|
||||
it('输出包含空行', () => {
|
||||
showPermissionDenied('test', 'reason');
|
||||
|
||||
// 第一个和最后一个调用是空行
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('showPermissionAllowed - 显示权限允许', () => {
|
||||
it('显示执行的命令', () => {
|
||||
showPermissionAllowed('npm install');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('执行'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('npm install'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptPermission - 交互式权限提示', () => {
|
||||
const mockContext: PermissionContext = {
|
||||
command: 'git push',
|
||||
workdir: '/project',
|
||||
toolName: 'bash',
|
||||
};
|
||||
|
||||
it('用户输入 y 返回允许不记住', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
expect(mockRl.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('用户输入 Y 返回允许并记住', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('Y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: true });
|
||||
});
|
||||
|
||||
it('用户输入 n 返回拒绝不记住', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('n')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: false });
|
||||
});
|
||||
|
||||
it('用户输入 N 返回拒绝并记住', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('N')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: true });
|
||||
});
|
||||
|
||||
it('无效输入默认为拒绝', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('invalid')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: false });
|
||||
});
|
||||
|
||||
it('空输入默认为拒绝', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: false });
|
||||
});
|
||||
|
||||
it('带空格的输入会被 trim', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback(' y ')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
});
|
||||
|
||||
it('显示命令和工作目录', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptPermission(mockContext);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('git push'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('/project'));
|
||||
});
|
||||
|
||||
it('显示外部路径警告', async () => {
|
||||
const contextWithExternal: PermissionContext = {
|
||||
...mockContext,
|
||||
externalPaths: ['/etc/passwd', '/root/.ssh'],
|
||||
};
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('n')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptPermission(contextWithExternal);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('项目目录外的路径'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('/etc/passwd'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('/root/.ssh'));
|
||||
});
|
||||
|
||||
it('显示匹配模式', async () => {
|
||||
const contextWithPatterns: PermissionContext = {
|
||||
...mockContext,
|
||||
patterns: ['*.js', '*.ts'],
|
||||
};
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptPermission(contextWithPatterns);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('*.js'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('*.ts'));
|
||||
});
|
||||
|
||||
it('不显示空的外部路径', async () => {
|
||||
const contextEmptyExternal: PermissionContext = {
|
||||
...mockContext,
|
||||
externalPaths: [],
|
||||
};
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptPermission(contextEmptyExternal);
|
||||
|
||||
// 不应该显示外部路径相关的警告
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).not.toContain('项目目录外的路径');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
builtinSkills,
|
||||
codeReviewSkill,
|
||||
explainCodeSkill,
|
||||
generateDocsSkill,
|
||||
generateTestsSkill,
|
||||
refactorSuggestSkill,
|
||||
fixBugSkill,
|
||||
gitCommitSkill,
|
||||
apiDesignSkill,
|
||||
} from '../../../../src/skills/builtin/index.js';
|
||||
|
||||
describe('Builtin Skills - 内置技能', () => {
|
||||
describe('builtinSkills 数组', () => {
|
||||
it('包含所有内置 Skills', () => {
|
||||
expect(builtinSkills).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('所有 Skills 都是启用状态', () => {
|
||||
builtinSkills.forEach((skill) => {
|
||||
expect(skill.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('所有 Skills 都有必要字段', () => {
|
||||
builtinSkills.forEach((skill) => {
|
||||
expect(skill.name).toBeDefined();
|
||||
expect(skill.displayName).toBeDefined();
|
||||
expect(skill.description).toBeDefined();
|
||||
expect(skill.promptTemplate).toBeDefined();
|
||||
expect(skill.parameters).toBeDefined();
|
||||
expect(skill.category).toBeDefined();
|
||||
expect(skill.source).toBe('builtin');
|
||||
});
|
||||
});
|
||||
|
||||
it('所有 Skills 都有关键词', () => {
|
||||
builtinSkills.forEach((skill) => {
|
||||
expect(skill.keywords).toBeDefined();
|
||||
expect(skill.keywords!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('Skills 名称唯一', () => {
|
||||
const names = builtinSkills.map((s) => s.name);
|
||||
const uniqueNames = new Set(names);
|
||||
expect(uniqueNames.size).toBe(names.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codeReviewSkill - 代码审查', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(codeReviewSkill.name).toBe('code-review');
|
||||
expect(codeReviewSkill.displayName).toBe('代码审查');
|
||||
});
|
||||
|
||||
it('属于 development 分类', () => {
|
||||
expect(codeReviewSkill.category).toBe('development');
|
||||
});
|
||||
|
||||
it('code 参数是必需的', () => {
|
||||
expect(codeReviewSkill.parameters.code.required).toBe(true);
|
||||
});
|
||||
|
||||
it('focus 参数是可选的', () => {
|
||||
expect(codeReviewSkill.parameters.focus.required).toBe(false);
|
||||
});
|
||||
|
||||
it('模板包含审查相关内容', () => {
|
||||
expect(codeReviewSkill.promptTemplate).toContain('审查');
|
||||
expect(codeReviewSkill.promptTemplate).toContain('{{code}}');
|
||||
});
|
||||
|
||||
it('关键词包含相关词汇', () => {
|
||||
expect(codeReviewSkill.keywords).toContain('review');
|
||||
expect(codeReviewSkill.keywords).toContain('审查');
|
||||
});
|
||||
});
|
||||
|
||||
describe('explainCodeSkill - 代码解释', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(explainCodeSkill.name).toBe('explain-code');
|
||||
expect(explainCodeSkill.displayName).toBe('代码解释');
|
||||
});
|
||||
|
||||
it('属于 development 分类', () => {
|
||||
expect(explainCodeSkill.category).toBe('development');
|
||||
});
|
||||
|
||||
it('code 参数是必需的', () => {
|
||||
expect(explainCodeSkill.parameters.code.required).toBe(true);
|
||||
});
|
||||
|
||||
it('level 参数有枚举值', () => {
|
||||
expect(explainCodeSkill.parameters.level.enum).toContain('beginner');
|
||||
expect(explainCodeSkill.parameters.level.enum).toContain('intermediate');
|
||||
expect(explainCodeSkill.parameters.level.enum).toContain('advanced');
|
||||
});
|
||||
|
||||
it('level 参数有默认值', () => {
|
||||
expect(explainCodeSkill.parameters.level.default).toBe('intermediate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDocsSkill - 文档生成', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(generateDocsSkill.name).toBe('generate-docs');
|
||||
expect(generateDocsSkill.displayName).toBe('文档生成');
|
||||
});
|
||||
|
||||
it('属于 documentation 分类', () => {
|
||||
expect(generateDocsSkill.category).toBe('documentation');
|
||||
});
|
||||
|
||||
it('type 参数有多种文档类型', () => {
|
||||
expect(generateDocsSkill.parameters.type.enum).toContain('JSDoc');
|
||||
expect(generateDocsSkill.parameters.type.enum).toContain('TSDoc');
|
||||
expect(generateDocsSkill.parameters.type.enum).toContain('README');
|
||||
});
|
||||
|
||||
it('type 参数有默认值', () => {
|
||||
expect(generateDocsSkill.parameters.type.default).toBe('文档注释');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateTestsSkill - 测试生成', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(generateTestsSkill.name).toBe('generate-tests');
|
||||
expect(generateTestsSkill.displayName).toBe('测试生成');
|
||||
});
|
||||
|
||||
it('属于 testing 分类', () => {
|
||||
expect(generateTestsSkill.category).toBe('testing');
|
||||
});
|
||||
|
||||
it('framework 参数有多个框架选项', () => {
|
||||
expect(generateTestsSkill.parameters.framework.enum).toContain('vitest');
|
||||
expect(generateTestsSkill.parameters.framework.enum).toContain('jest');
|
||||
expect(generateTestsSkill.parameters.framework.enum).toContain('mocha');
|
||||
expect(generateTestsSkill.parameters.framework.enum).toContain('pytest');
|
||||
});
|
||||
|
||||
it('framework 默认为 vitest', () => {
|
||||
expect(generateTestsSkill.parameters.framework.default).toBe('vitest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refactorSuggestSkill - 重构建议', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(refactorSuggestSkill.name).toBe('refactor-suggest');
|
||||
expect(refactorSuggestSkill.displayName).toBe('重构建议');
|
||||
});
|
||||
|
||||
it('属于 development 分类', () => {
|
||||
expect(refactorSuggestSkill.category).toBe('development');
|
||||
});
|
||||
|
||||
it('goal 参数是可选的', () => {
|
||||
expect(refactorSuggestSkill.parameters.goal.required).toBe(false);
|
||||
});
|
||||
|
||||
it('模板包含重构相关内容', () => {
|
||||
expect(refactorSuggestSkill.promptTemplate).toContain('重构');
|
||||
expect(refactorSuggestSkill.promptTemplate).toContain('{{code}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixBugSkill - Bug 修复', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(fixBugSkill.name).toBe('fix-bug');
|
||||
expect(fixBugSkill.displayName).toBe('Bug 修复');
|
||||
});
|
||||
|
||||
it('属于 debugging 分类', () => {
|
||||
expect(fixBugSkill.category).toBe('debugging');
|
||||
});
|
||||
|
||||
it('有 error 和 context 可选参数', () => {
|
||||
expect(fixBugSkill.parameters.error.required).toBe(false);
|
||||
expect(fixBugSkill.parameters.context.required).toBe(false);
|
||||
});
|
||||
|
||||
it('模板支持错误信息和上下文', () => {
|
||||
expect(fixBugSkill.promptTemplate).toContain('{{#if error}}');
|
||||
expect(fixBugSkill.promptTemplate).toContain('{{#if context}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gitCommitSkill - Git Commit', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(gitCommitSkill.name).toBe('git-commit');
|
||||
expect(gitCommitSkill.displayName).toBe('Git Commit');
|
||||
});
|
||||
|
||||
it('属于 git 分类', () => {
|
||||
expect(gitCommitSkill.category).toBe('git');
|
||||
});
|
||||
|
||||
it('diff 参数是必需的', () => {
|
||||
expect(gitCommitSkill.parameters.diff.required).toBe(true);
|
||||
});
|
||||
|
||||
it('type 参数有 conventional commit 类型', () => {
|
||||
expect(gitCommitSkill.parameters.type.enum).toContain('feat');
|
||||
expect(gitCommitSkill.parameters.type.enum).toContain('fix');
|
||||
expect(gitCommitSkill.parameters.type.enum).toContain('docs');
|
||||
expect(gitCommitSkill.parameters.type.enum).toContain('refactor');
|
||||
});
|
||||
|
||||
it('模板提到 Conventional Commits', () => {
|
||||
expect(gitCommitSkill.promptTemplate).toContain('Conventional Commits');
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiDesignSkill - API 设计', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(apiDesignSkill.name).toBe('api-design');
|
||||
expect(apiDesignSkill.displayName).toBe('API 设计');
|
||||
});
|
||||
|
||||
it('属于 architecture 分类', () => {
|
||||
expect(apiDesignSkill.category).toBe('architecture');
|
||||
});
|
||||
|
||||
it('requirement 参数是必需的', () => {
|
||||
expect(apiDesignSkill.parameters.requirement.required).toBe(true);
|
||||
});
|
||||
|
||||
it('constraints 参数是可选的', () => {
|
||||
expect(apiDesignSkill.parameters.constraints.required).toBe(false);
|
||||
});
|
||||
|
||||
it('模板提到 RESTful API', () => {
|
||||
expect(apiDesignSkill.promptTemplate).toContain('RESTful API');
|
||||
});
|
||||
});
|
||||
|
||||
describe('参数类型验证', () => {
|
||||
it('所有必需参数都是 string 类型', () => {
|
||||
builtinSkills.forEach((skill) => {
|
||||
Object.entries(skill.parameters).forEach(([, param]) => {
|
||||
if (param.required) {
|
||||
expect(param.type).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('所有参数都有描述', () => {
|
||||
builtinSkills.forEach((skill) => {
|
||||
Object.entries(skill.parameters).forEach(([, param]) => {
|
||||
expect(param.description).toBeDefined();
|
||||
expect(param.description.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('分类覆盖', () => {
|
||||
it('覆盖多个分类', () => {
|
||||
const categories = new Set(builtinSkills.map((s) => s.category));
|
||||
expect(categories.size).toBeGreaterThan(3);
|
||||
expect(categories).toContain('development');
|
||||
expect(categories).toContain('documentation');
|
||||
expect(categories).toContain('testing');
|
||||
expect(categories).toContain('debugging');
|
||||
expect(categories).toContain('git');
|
||||
expect(categories).toContain('architecture');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { writeFileTool } from '../../../../src/tools/filesystem/write_file.js';
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
const mockCheckFilePermission = vi.fn();
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkFilePermission: mockCheckFilePermission,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '写入文件内容'),
|
||||
}));
|
||||
|
||||
// Mock LSP
|
||||
const mockTouchFile = vi.fn();
|
||||
const mockGetFormattedFileDiagnostics = vi.fn();
|
||||
const mockIsLanguageSupported = vi.fn();
|
||||
|
||||
vi.mock('../../../../src/lsp/index.js', () => ({
|
||||
touchFile: (...args: unknown[]) => mockTouchFile(...args),
|
||||
getFormattedFileDiagnostics: (...args: unknown[]) => mockGetFormattedFileDiagnostics(...args),
|
||||
isLanguageSupported: (...args: unknown[]) => mockIsLanguageSupported(...args),
|
||||
}));
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
describe('writeFileTool - 写入文件工具扩展测试', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// 重置所有 mock 返回值
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
mockCheckFilePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
});
|
||||
mockIsLanguageSupported.mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe('LSP 集成', () => {
|
||||
it('支持的语言触发 LSP 诊断', async () => {
|
||||
mockIsLanguageSupported.mockReturnValue(true);
|
||||
mockTouchFile.mockResolvedValue(false);
|
||||
mockGetFormattedFileDiagnostics.mockResolvedValue(null);
|
||||
|
||||
const result = await writeFileTool.execute({
|
||||
path: './test.ts',
|
||||
content: 'const x = 1;',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockTouchFile).toHaveBeenCalled();
|
||||
expect(mockGetFormattedFileDiagnostics).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('首次启动等待更长时间', async () => {
|
||||
mockIsLanguageSupported.mockReturnValue(true);
|
||||
mockTouchFile.mockResolvedValue(true); // 首次启动返回 true
|
||||
mockGetFormattedFileDiagnostics.mockResolvedValue(null);
|
||||
|
||||
const startTime = Date.now();
|
||||
await writeFileTool.execute({
|
||||
path: './test.ts',
|
||||
content: 'const x = 1;',
|
||||
});
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// 首次启动应该等待更长时间(2000ms vs 300ms)
|
||||
expect(elapsed).toBeGreaterThanOrEqual(1900);
|
||||
});
|
||||
|
||||
it('有诊断问题时在输出中显示', async () => {
|
||||
mockIsLanguageSupported.mockReturnValue(true);
|
||||
mockTouchFile.mockResolvedValue(false);
|
||||
mockGetFormattedFileDiagnostics.mockResolvedValue(
|
||||
'\n<file_diagnostics>\n1:1 ERROR: Type error\n</file_diagnostics>'
|
||||
);
|
||||
|
||||
const result = await writeFileTool.execute({
|
||||
path: './test.ts',
|
||||
content: 'const x: string = 1;',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('代码检查发现问题');
|
||||
expect(result.output).toContain('file_diagnostics');
|
||||
});
|
||||
|
||||
it('LSP 错误不影响主流程', async () => {
|
||||
mockIsLanguageSupported.mockReturnValue(true);
|
||||
mockTouchFile.mockRejectedValue(new Error('LSP error'));
|
||||
|
||||
const result = await writeFileTool.execute({
|
||||
path: './test.ts',
|
||||
content: 'const x = 1;',
|
||||
});
|
||||
|
||||
// 即使 LSP 失败,文件写入仍然成功
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('文件已写入');
|
||||
});
|
||||
|
||||
it('不支持的语言不触发 LSP', async () => {
|
||||
mockIsLanguageSupported.mockReturnValue(false);
|
||||
|
||||
await writeFileTool.execute({
|
||||
path: './test.txt',
|
||||
content: 'plain text',
|
||||
});
|
||||
|
||||
expect(mockTouchFile).not.toHaveBeenCalled();
|
||||
expect(mockGetFormattedFileDiagnostics).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('路径处理', () => {
|
||||
it('相对路径转换为绝对路径', async () => {
|
||||
await writeFileTool.execute({
|
||||
path: './relative/path/file.txt',
|
||||
content: 'content',
|
||||
});
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/relative\/path$/),
|
||||
{ recursive: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('绝对路径保持不变', async () => {
|
||||
await writeFileTool.execute({
|
||||
path: '/absolute/path/file.txt',
|
||||
content: 'content',
|
||||
});
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
'/absolute/path/file.txt',
|
||||
'content',
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('权限检查详情', () => {
|
||||
it('权限检查包含正确的上下文', async () => {
|
||||
await writeFileTool.execute({
|
||||
path: './test.txt',
|
||||
content: 'file content',
|
||||
});
|
||||
|
||||
expect(mockCheckFilePermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operation: 'write',
|
||||
newContent: 'file content',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('被拒绝时返回具体原因', async () => {
|
||||
mockCheckFilePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '系统文件不允许修改',
|
||||
});
|
||||
|
||||
const result = await writeFileTool.execute({
|
||||
path: '/etc/passwd',
|
||||
content: 'malicious',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
expect(result.error).toContain('系统文件不允许修改');
|
||||
});
|
||||
|
||||
it('需要确认时返回具体原因', async () => {
|
||||
mockCheckFilePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
reason: '首次写入此文件',
|
||||
});
|
||||
|
||||
const result = await writeFileTool.execute({
|
||||
path: './new-file.txt',
|
||||
content: 'content',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
expect(result.error).toContain('首次写入此文件');
|
||||
});
|
||||
});
|
||||
|
||||
describe('错误处理', () => {
|
||||
it('文件系统错误返回错误信息', async () => {
|
||||
vi.mocked(fs.writeFile).mockRejectedValue(new Error('EACCES: permission denied'));
|
||||
|
||||
const result = await writeFileTool.execute({
|
||||
path: './test.txt',
|
||||
content: 'content',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('EACCES');
|
||||
});
|
||||
|
||||
it('目录创建失败返回错误信息', async () => {
|
||||
vi.mocked(fs.mkdir).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await writeFileTool.execute({
|
||||
path: './deep/nested/file.txt',
|
||||
content: 'content',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('ENOENT');
|
||||
});
|
||||
|
||||
it('非 Error 对象也能正确处理', async () => {
|
||||
vi.mocked(fs.writeFile).mockRejectedValue('String error');
|
||||
|
||||
const result = await writeFileTool.execute({
|
||||
path: './test.txt',
|
||||
content: 'content',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('String error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('工具元数据', () => {
|
||||
it('包含完整的元数据', () => {
|
||||
expect(writeFileTool.metadata.category).toBe('filesystem');
|
||||
expect(writeFileTool.metadata.keywords).toContain('write');
|
||||
expect(writeFileTool.metadata.keywords).toContain('save');
|
||||
expect(writeFileTool.metadata.keywords).toContain('create');
|
||||
expect(writeFileTool.metadata.keywords).toContain('写入');
|
||||
expect(writeFileTool.metadata.deferLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('参数定义正确', () => {
|
||||
expect(writeFileTool.parameters.path.required).toBe(true);
|
||||
expect(writeFileTool.parameters.content.required).toBe(true);
|
||||
expect(writeFileTool.parameters.path.type).toBe('string');
|
||||
expect(writeFileTool.parameters.content.type).toBe('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// 定义可控的 mock 变量
|
||||
let mockExecAsyncResult: { stdout: string; stderr: string } | Error = {
|
||||
stdout: '[main abc1234] Test commit\n 1 file changed, 1 insertion(+)',
|
||||
stderr: '',
|
||||
};
|
||||
|
||||
let mockPermissionResult = {
|
||||
allowed: true,
|
||||
action: 'allow' as const,
|
||||
reason: undefined as string | undefined,
|
||||
needsConfirmation: false,
|
||||
};
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('child_process', () => ({
|
||||
exec: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock util - 返回函数使用外部变量
|
||||
vi.mock('util', () => ({
|
||||
promisify: vi.fn(() => vi.fn(async () => {
|
||||
if (mockExecAsyncResult instanceof Error) {
|
||||
throw mockExecAsyncResult;
|
||||
}
|
||||
return mockExecAsyncResult;
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkGitPermission: vi.fn(async () => mockPermissionResult),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '提交 Git 变更'),
|
||||
}));
|
||||
|
||||
import { gitCommitTool } from '../../../../src/tools/git/git_commit.js';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('gitCommitTool - Git 提交工具扩展测试', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExecAsyncResult = {
|
||||
stdout: '[main abc1234] Test commit\n 1 file changed, 1 insertion(+)',
|
||||
stderr: '',
|
||||
};
|
||||
mockPermissionResult = {
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
reason: undefined,
|
||||
needsConfirmation: false,
|
||||
};
|
||||
});
|
||||
|
||||
describe('基本提交', () => {
|
||||
it('成功提交变更', async () => {
|
||||
const result = await gitCommitTool.execute({
|
||||
message: 'Test commit message',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('Test commit');
|
||||
});
|
||||
|
||||
it('没有 message 返回错误', async () => {
|
||||
const result = await gitCommitTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('提交信息是必填的');
|
||||
});
|
||||
|
||||
it('amend 模式无 message 允许', async () => {
|
||||
const result = await gitCommitTool.execute({
|
||||
amend: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('提交选项', () => {
|
||||
it('使用 -a 选项暂存所有变更', async () => {
|
||||
const result = await gitCommitTool.execute({
|
||||
message: 'Auto stage commit',
|
||||
all: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('amend 带 message', async () => {
|
||||
const result = await gitCommitTool.execute({
|
||||
message: 'Updated message',
|
||||
amend: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('转义 message 中的引号', async () => {
|
||||
const result = await gitCommitTool.execute({
|
||||
message: 'Message with "quotes"',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('权限检查', () => {
|
||||
it('权限拒绝返回错误', async () => {
|
||||
mockPermissionResult = {
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许提交到此仓库',
|
||||
needsConfirmation: false,
|
||||
};
|
||||
|
||||
const result = await gitCommitTool.execute({
|
||||
message: 'Test commit',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
expect(result.error).toContain('不允许提交到此仓库');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
mockPermissionResult = {
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
reason: '首次提交',
|
||||
needsConfirmation: true,
|
||||
};
|
||||
|
||||
const result = await gitCommitTool.execute({
|
||||
message: 'First commit',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
expect(result.error).toContain('首次提交');
|
||||
});
|
||||
|
||||
it('权限检查包含正确上下文', async () => {
|
||||
const mockCheck = vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
});
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: mockCheck,
|
||||
} as any);
|
||||
|
||||
await gitCommitTool.execute({
|
||||
message: 'Check context',
|
||||
});
|
||||
|
||||
expect(mockCheck).toHaveBeenCalledWith({
|
||||
operation: 'commit',
|
||||
message: 'Check context',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('错误处理', () => {
|
||||
it('没有变更可提交', async () => {
|
||||
mockExecAsyncResult = Object.assign(
|
||||
new Error('nothing to commit, working tree clean'),
|
||||
{ stdout: '', stderr: '', message: 'nothing to commit, working tree clean' }
|
||||
);
|
||||
|
||||
const result = await gitCommitTool.execute({
|
||||
message: 'Empty commit',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('没有变更需要提交');
|
||||
expect(result.error).toContain('git_add');
|
||||
});
|
||||
|
||||
it('stderr 包含 nothing to commit', async () => {
|
||||
mockExecAsyncResult = Object.assign(
|
||||
new Error('Command failed'),
|
||||
{ stdout: '', stderr: 'nothing to commit', message: 'Command failed' }
|
||||
);
|
||||
|
||||
const result = await gitCommitTool.execute({
|
||||
message: 'Empty commit',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('没有变更需要提交');
|
||||
});
|
||||
|
||||
it('其他 Git 错误', async () => {
|
||||
mockExecAsyncResult = Object.assign(
|
||||
new Error('fatal: not a git repository'),
|
||||
{ stdout: '', stderr: 'fatal: not a git repository', message: 'fatal: not a git repository' }
|
||||
);
|
||||
|
||||
const result = await gitCommitTool.execute({
|
||||
message: 'Test commit',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not a git repository');
|
||||
});
|
||||
|
||||
it('保留 stdout 在错误中', async () => {
|
||||
mockExecAsyncResult = Object.assign(
|
||||
new Error('error'),
|
||||
{ stdout: 'some output', stderr: 'error message', message: 'error' }
|
||||
);
|
||||
|
||||
const result = await gitCommitTool.execute({
|
||||
message: 'Test',
|
||||
});
|
||||
|
||||
expect(result.output).toBe('some output');
|
||||
expect(result.error).toBe('error message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('工具元数据', () => {
|
||||
it('包含正确的名称', () => {
|
||||
expect(gitCommitTool.name).toBe('git_commit');
|
||||
});
|
||||
|
||||
it('包含正确的类别', () => {
|
||||
expect(gitCommitTool.metadata.category).toBe('git');
|
||||
});
|
||||
|
||||
it('包含关键词', () => {
|
||||
expect(gitCommitTool.metadata.keywords).toContain('git');
|
||||
expect(gitCommitTool.metadata.keywords).toContain('commit');
|
||||
expect(gitCommitTool.metadata.keywords).toContain('提交');
|
||||
});
|
||||
|
||||
it('参数定义正确', () => {
|
||||
expect(gitCommitTool.parameters.message.required).toBe(true);
|
||||
expect(gitCommitTool.parameters.amend.required).toBe(false);
|
||||
expect(gitCommitTool.parameters.all.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('输出格式', () => {
|
||||
it('包含 stdout', async () => {
|
||||
mockExecAsyncResult = {
|
||||
stdout: 'commit output',
|
||||
stderr: '',
|
||||
};
|
||||
|
||||
const result = await gitCommitTool.execute({ message: 'test' });
|
||||
|
||||
expect(result.output).toBe('commit output');
|
||||
});
|
||||
|
||||
it('包含 stdout 和 stderr', async () => {
|
||||
mockExecAsyncResult = {
|
||||
stdout: 'commit output',
|
||||
stderr: 'warning message',
|
||||
};
|
||||
|
||||
const result = await gitCommitTool.execute({ message: 'test' });
|
||||
|
||||
expect(result.output).toContain('commit output');
|
||||
expect(result.output).toContain('warning message');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock fs
|
||||
vi.mock('fs', () => ({
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { loadDescription } from '../../../src/tools/load_description.js';
|
||||
|
||||
describe('loadDescription', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('带分类的工具', () => {
|
||||
it('加载 shell 类工具描述', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(' Bash 工具描述 ');
|
||||
|
||||
const result = loadDescription('bash');
|
||||
|
||||
expect(result).toBe('Bash 工具描述');
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('descriptions/shell/bash.txt'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('加载 filesystem 类工具描述', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('读取文件描述');
|
||||
|
||||
const result = loadDescription('read_file');
|
||||
|
||||
expect(result).toBe('读取文件描述');
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('descriptions/filesystem/read_file.txt'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('加载 write_file 描述', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('写入文件描述');
|
||||
|
||||
loadDescription('write_file');
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('descriptions/filesystem/write_file.txt'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('加载 edit_file 描述', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('编辑文件描述');
|
||||
|
||||
loadDescription('edit_file');
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('descriptions/filesystem/edit_file.txt'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('加载 git 类工具描述', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('Git status 描述');
|
||||
|
||||
loadDescription('git_status');
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('descriptions/git/git_status.txt'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('加载 web 类工具描述', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('Web search 描述');
|
||||
|
||||
loadDescription('web_search');
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('descriptions/web/web_search.txt'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('加载 todo 类工具描述', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('Todo read 描述');
|
||||
|
||||
loadDescription('todo_read');
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('descriptions/todo/todo_read.txt'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('不带分类的工具', () => {
|
||||
it('未知工具使用根目录', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('未知工具描述');
|
||||
|
||||
loadDescription('unknown_tool');
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('descriptions/unknown_tool.txt'),
|
||||
'utf-8'
|
||||
);
|
||||
// 确保不包含子目录
|
||||
expect(fs.readFileSync).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('descriptions/filesystem/unknown_tool.txt'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('错误处理', () => {
|
||||
it('文件不存在时抛出错误', () => {
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
expect(() => loadDescription('nonexistent')).toThrow('无法加载工具描述文件');
|
||||
});
|
||||
|
||||
it('错误信息包含文件路径', () => {
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
expect(() => loadDescription('some_tool')).toThrow('some_tool.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('内容处理', () => {
|
||||
it('去除前后空白', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('\n\n 内容 \n\n');
|
||||
|
||||
const result = loadDescription('bash');
|
||||
|
||||
expect(result).toBe('内容');
|
||||
});
|
||||
|
||||
it('保留中间空白', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('第一行\n第二行\n第三行');
|
||||
|
||||
const result = loadDescription('bash');
|
||||
|
||||
expect(result).toBe('第一行\n第二行\n第三行');
|
||||
});
|
||||
|
||||
it('空文件返回空字符串', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(' ');
|
||||
|
||||
const result = loadDescription('bash');
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('所有映射的工具', () => {
|
||||
const toolCategories = [
|
||||
// shell
|
||||
{ tool: 'bash', category: 'shell' },
|
||||
// filesystem
|
||||
{ tool: 'read_file', category: 'filesystem' },
|
||||
{ tool: 'write_file', category: 'filesystem' },
|
||||
{ tool: 'edit_file', category: 'filesystem' },
|
||||
{ tool: 'list_directory', category: 'filesystem' },
|
||||
{ tool: 'create_directory', category: 'filesystem' },
|
||||
{ tool: 'search_files', category: 'filesystem' },
|
||||
{ tool: 'grep_content', category: 'filesystem' },
|
||||
{ tool: 'get_file_info', category: 'filesystem' },
|
||||
{ tool: 'move_file', category: 'filesystem' },
|
||||
{ tool: 'copy_file', category: 'filesystem' },
|
||||
{ tool: 'delete_file', category: 'filesystem' },
|
||||
// web
|
||||
{ tool: 'web_search', category: 'web' },
|
||||
{ tool: 'web_extract', category: 'web' },
|
||||
// git
|
||||
{ tool: 'git_status', category: 'git' },
|
||||
{ tool: 'git_diff', category: 'git' },
|
||||
{ tool: 'git_log', category: 'git' },
|
||||
{ tool: 'git_branch', category: 'git' },
|
||||
{ tool: 'git_add', category: 'git' },
|
||||
{ tool: 'git_commit', category: 'git' },
|
||||
{ tool: 'git_push', category: 'git' },
|
||||
{ tool: 'git_pull', category: 'git' },
|
||||
{ tool: 'git_checkout', category: 'git' },
|
||||
{ tool: 'git_stash', category: 'git' },
|
||||
// todo
|
||||
{ tool: 'todo_read', category: 'todo' },
|
||||
{ tool: 'todo_write', category: 'todo' },
|
||||
];
|
||||
|
||||
it.each(toolCategories)('$tool 映射到 $category 目录', ({ tool, category }) => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('描述');
|
||||
|
||||
loadDescription(tool);
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`descriptions/${category}/${tool}.txt`),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { skillTool } from '../../../../src/tools/skill/skill.js';
|
||||
import { skillTool, updateSkillDescription } from '../../../../src/tools/skill/skill.js';
|
||||
import { getSkillRegistry, resetSkillRegistry } from '../../../../src/skills/registry.js';
|
||||
import type { Skill } from '../../../../src/skills/types.js';
|
||||
|
||||
@@ -144,4 +144,62 @@ describe('skillTool - Skill 工具', () => {
|
||||
expect(result.metadata?.source).toBe('builtin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('工具描述', () => {
|
||||
it('更新后描述包含可用 Skill 列表', () => {
|
||||
// beforeEach 中已初始化 registry,现在更新描述
|
||||
updateSkillDescription();
|
||||
|
||||
expect(skillTool.description).toContain('code-review');
|
||||
expect(skillTool.description).toContain('代码审查');
|
||||
});
|
||||
|
||||
it('更新后描述包含分类信息', () => {
|
||||
updateSkillDescription();
|
||||
expect(skillTool.description).toContain('[development]');
|
||||
});
|
||||
|
||||
it('更新后描述包含使用示例', () => {
|
||||
updateSkillDescription();
|
||||
expect(skillTool.description).toContain('使用示例');
|
||||
expect(skillTool.description).toContain('skill_name');
|
||||
});
|
||||
|
||||
it('禁用的 Skill 不在描述中', () => {
|
||||
updateSkillDescription();
|
||||
expect(skillTool.description).not.toContain('disabled-skill');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSkillDescription', () => {
|
||||
it('调用后描述被更新', async () => {
|
||||
updateSkillDescription();
|
||||
|
||||
// 描述应该包含当前注册的 Skill
|
||||
expect(skillTool.description).toContain('code-review');
|
||||
expect(typeof skillTool.description).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('params 为 undefined 时使用空对象', async () => {
|
||||
const result = await skillTool.execute({
|
||||
skill_name: 'code-review',
|
||||
// 不提供 params
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('缺少必需参数');
|
||||
});
|
||||
|
||||
it('skill_name 为空字符串时返回错误', async () => {
|
||||
const result = await skillTool.execute({
|
||||
skill_name: '',
|
||||
params: {},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('不存在');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { todoManager } from '../../../../src/tools/todo/todo-manager.js';
|
||||
import type { SessionManager } from '../../../../src/session/index.js';
|
||||
import type { Todo } from '../../../../src/session/types.js';
|
||||
|
||||
describe('TodoManager', () => {
|
||||
let mockSessionManager: SessionManager;
|
||||
let mockTodos: Todo[];
|
||||
|
||||
beforeEach(() => {
|
||||
mockTodos = [];
|
||||
mockSessionManager = {
|
||||
getTodos: vi.fn(() => mockTodos),
|
||||
setTodos: vi.fn(async (todos: Todo[]) => {
|
||||
mockTodos = todos;
|
||||
}),
|
||||
} as unknown as SessionManager;
|
||||
|
||||
// 重置 todoManager 状态
|
||||
todoManager.setSessionManager(mockSessionManager);
|
||||
});
|
||||
|
||||
describe('setSessionManager', () => {
|
||||
it('设置会话管理器后 isInitialized 返回 true', () => {
|
||||
const freshManager = {
|
||||
getTodos: vi.fn(() => []),
|
||||
setTodos: vi.fn(),
|
||||
} as unknown as SessionManager;
|
||||
|
||||
todoManager.setSessionManager(freshManager);
|
||||
expect(todoManager.isInitialized()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTodos', () => {
|
||||
it('返回空数组当没有 todo', () => {
|
||||
const todos = todoManager.getTodos();
|
||||
expect(todos).toEqual([]);
|
||||
});
|
||||
|
||||
it('返回现有的 todo 列表', () => {
|
||||
mockTodos = [
|
||||
{ id: '1', content: 'Task 1', status: 'pending', createdAt: '', updatedAt: '' },
|
||||
{ id: '2', content: 'Task 2', status: 'completed', createdAt: '', updatedAt: '' },
|
||||
];
|
||||
|
||||
const todos = todoManager.getTodos();
|
||||
expect(todos).toHaveLength(2);
|
||||
expect(todos[0].content).toBe('Task 1');
|
||||
expect(todos[1].content).toBe('Task 2');
|
||||
});
|
||||
|
||||
it('正确调用 sessionManager.getTodos', () => {
|
||||
todoManager.getTodos();
|
||||
expect(mockSessionManager.getTodos).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTodos', () => {
|
||||
it('更新 todo 列表', async () => {
|
||||
const newTodos: Todo[] = [
|
||||
{ id: '1', content: 'New Task', status: 'pending', createdAt: '', updatedAt: '' },
|
||||
];
|
||||
|
||||
await todoManager.setTodos(newTodos);
|
||||
|
||||
expect(mockSessionManager.setTodos).toHaveBeenCalledWith(newTodos);
|
||||
expect(mockTodos).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('可以设置空列表', async () => {
|
||||
mockTodos = [
|
||||
{ id: '1', content: 'Task', status: 'pending', createdAt: '', updatedAt: '' },
|
||||
];
|
||||
|
||||
await todoManager.setTodos([]);
|
||||
|
||||
expect(mockTodos).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTodo', () => {
|
||||
it('添加新的 todo(默认状态 pending)', async () => {
|
||||
const todo = await todoManager.addTodo('新任务');
|
||||
|
||||
expect(todo.content).toBe('新任务');
|
||||
expect(todo.status).toBe('pending');
|
||||
expect(todo.id).toBeDefined();
|
||||
expect(todo.createdAt).toBeDefined();
|
||||
expect(todo.updatedAt).toBeDefined();
|
||||
expect(mockTodos).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('添加指定状态的 todo', async () => {
|
||||
const todo = await todoManager.addTodo('进行中的任务', 'in_progress');
|
||||
|
||||
expect(todo.content).toBe('进行中的任务');
|
||||
expect(todo.status).toBe('in_progress');
|
||||
});
|
||||
|
||||
it('添加已完成状态的 todo', async () => {
|
||||
const todo = await todoManager.addTodo('已完成任务', 'completed');
|
||||
|
||||
expect(todo.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('生成唯一 ID', async () => {
|
||||
const todo1 = await todoManager.addTodo('Task 1');
|
||||
const todo2 = await todoManager.addTodo('Task 2');
|
||||
|
||||
expect(todo1.id).not.toBe(todo2.id);
|
||||
});
|
||||
|
||||
it('追加到现有列表', async () => {
|
||||
mockTodos = [
|
||||
{ id: 'existing', content: 'Existing', status: 'pending', createdAt: '', updatedAt: '' },
|
||||
];
|
||||
|
||||
await todoManager.addTodo('New Task');
|
||||
|
||||
expect(mockTodos).toHaveLength(2);
|
||||
expect(mockTodos[0].content).toBe('Existing');
|
||||
expect(mockTodos[1].content).toBe('New Task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTodoStatus', () => {
|
||||
beforeEach(() => {
|
||||
mockTodos = [
|
||||
{ id: 'todo-1', content: 'Task 1', status: 'pending', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
|
||||
{ id: 'todo-2', content: 'Task 2', status: 'pending', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
|
||||
];
|
||||
});
|
||||
|
||||
it('更新存在的 todo 状态', async () => {
|
||||
const result = await todoManager.updateTodoStatus('todo-1', 'completed');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockTodos[0].status).toBe('completed');
|
||||
});
|
||||
|
||||
it('更新 updatedAt 时间戳', async () => {
|
||||
const originalUpdatedAt = mockTodos[0].updatedAt;
|
||||
|
||||
await todoManager.updateTodoStatus('todo-1', 'in_progress');
|
||||
|
||||
expect(mockTodos[0].updatedAt).not.toBe(originalUpdatedAt);
|
||||
});
|
||||
|
||||
it('不存在的 todo 返回 false', async () => {
|
||||
const result = await todoManager.updateTodoStatus('non-existent', 'completed');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('可以更新为任意状态', async () => {
|
||||
await todoManager.updateTodoStatus('todo-1', 'in_progress');
|
||||
expect(mockTodos[0].status).toBe('in_progress');
|
||||
|
||||
await todoManager.updateTodoStatus('todo-1', 'completed');
|
||||
expect(mockTodos[0].status).toBe('completed');
|
||||
|
||||
await todoManager.updateTodoStatus('todo-1', 'pending');
|
||||
expect(mockTodos[0].status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteTodo', () => {
|
||||
beforeEach(() => {
|
||||
mockTodos = [
|
||||
{ id: 'todo-1', content: 'Task 1', status: 'pending', createdAt: '', updatedAt: '' },
|
||||
{ id: 'todo-2', content: 'Task 2', status: 'completed', createdAt: '', updatedAt: '' },
|
||||
{ id: 'todo-3', content: 'Task 3', status: 'in_progress', createdAt: '', updatedAt: '' },
|
||||
];
|
||||
});
|
||||
|
||||
it('删除存在的 todo', async () => {
|
||||
const result = await todoManager.deleteTodo('todo-2');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockTodos).toHaveLength(2);
|
||||
expect(mockTodos.find(t => t.id === 'todo-2')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('删除第一个 todo', async () => {
|
||||
const result = await todoManager.deleteTodo('todo-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockTodos[0].id).toBe('todo-2');
|
||||
});
|
||||
|
||||
it('删除最后一个 todo', async () => {
|
||||
const result = await todoManager.deleteTodo('todo-3');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockTodos).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('不存在的 todo 返回 false', async () => {
|
||||
const result = await todoManager.deleteTodo('non-existent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockTodos).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('删除后其他 todo 保持不变', async () => {
|
||||
await todoManager.deleteTodo('todo-2');
|
||||
|
||||
expect(mockTodos[0].content).toBe('Task 1');
|
||||
expect(mockTodos[1].content).toBe('Task 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearTodos', () => {
|
||||
it('清空所有 todo', async () => {
|
||||
mockTodos = [
|
||||
{ id: '1', content: 'Task 1', status: 'pending', createdAt: '', updatedAt: '' },
|
||||
{ id: '2', content: 'Task 2', status: 'completed', createdAt: '', updatedAt: '' },
|
||||
];
|
||||
|
||||
await todoManager.clearTodos();
|
||||
|
||||
expect(mockTodos).toHaveLength(0);
|
||||
expect(mockSessionManager.setTodos).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('空列表时调用也不报错', async () => {
|
||||
mockTodos = [];
|
||||
|
||||
await expect(todoManager.clearTodos()).resolves.not.toThrow();
|
||||
expect(mockTodos).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInitialized', () => {
|
||||
it('初始化后返回 true', () => {
|
||||
expect(todoManager.isInitialized()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('处理包含特殊字符的 content', async () => {
|
||||
const specialContent = '任务: "测试" & <script>alert(1)</script>';
|
||||
const todo = await todoManager.addTodo(specialContent);
|
||||
|
||||
expect(todo.content).toBe(specialContent);
|
||||
});
|
||||
|
||||
it('处理非常长的 content', async () => {
|
||||
const longContent = 'A'.repeat(10000);
|
||||
const todo = await todoManager.addTodo(longContent);
|
||||
|
||||
expect(todo.content).toBe(longContent);
|
||||
});
|
||||
|
||||
it('处理空字符串 content', async () => {
|
||||
const todo = await todoManager.addTodo('');
|
||||
|
||||
expect(todo.content).toBe('');
|
||||
});
|
||||
|
||||
it('连续操作保持数据一致性', async () => {
|
||||
await todoManager.addTodo('Task 1');
|
||||
await todoManager.addTodo('Task 2');
|
||||
await todoManager.updateTodoStatus(mockTodos[0].id, 'completed');
|
||||
await todoManager.deleteTodo(mockTodos[1].id);
|
||||
await todoManager.addTodo('Task 3');
|
||||
|
||||
expect(mockTodos).toHaveLength(2);
|
||||
expect(mockTodos[0].status).toBe('completed');
|
||||
expect(mockTodos[1].content).toBe('Task 3');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { toolSearchTool } from '../../../src/tools/tool-search.js';
|
||||
|
||||
// Mock toolRegistry
|
||||
vi.mock('../../../src/tools/registry.js', () => ({
|
||||
toolRegistry: {
|
||||
search: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { toolRegistry } from '../../../src/tools/registry.js';
|
||||
|
||||
describe('toolSearchTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('metadata', () => {
|
||||
it('工具名为 tool_search', () => {
|
||||
expect(toolSearchTool.name).toBe('tool_search');
|
||||
});
|
||||
|
||||
it('有描述信息', () => {
|
||||
expect(toolSearchTool.description).toBeDefined();
|
||||
expect(toolSearchTool.description.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('描述中包含功能类别', () => {
|
||||
expect(toolSearchTool.description).toContain('文件操作');
|
||||
expect(toolSearchTool.description).toContain('目录操作');
|
||||
expect(toolSearchTool.description).toContain('Shell');
|
||||
});
|
||||
|
||||
it('有 query 参数', () => {
|
||||
expect(toolSearchTool.parameters.query).toBeDefined();
|
||||
expect(toolSearchTool.parameters.query.type).toBe('string');
|
||||
expect(toolSearchTool.parameters.query.required).toBe(true);
|
||||
});
|
||||
|
||||
it('metadata 设置正确', () => {
|
||||
expect(toolSearchTool.metadata).toBeDefined();
|
||||
expect(toolSearchTool.metadata?.category).toBe('core');
|
||||
expect(toolSearchTool.metadata?.deferLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('metadata 包含搜索关键词', () => {
|
||||
expect(toolSearchTool.metadata?.keywords).toContain('search');
|
||||
expect(toolSearchTool.metadata?.keywords).toContain('搜索');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('空 query 返回错误', async () => {
|
||||
const result = await toolSearchTool.execute({ query: '' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('请提供搜索关键词');
|
||||
});
|
||||
|
||||
it('空白 query 返回错误', async () => {
|
||||
const result = await toolSearchTool.execute({ query: ' ' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('请提供搜索关键词');
|
||||
});
|
||||
|
||||
it('undefined query 返回错误', async () => {
|
||||
const result = await toolSearchTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('请提供搜索关键词');
|
||||
});
|
||||
|
||||
it('没有匹配结果时返回提示', async () => {
|
||||
vi.mocked(toolRegistry.search).mockReturnValue([]);
|
||||
|
||||
const result = await toolSearchTool.execute({ query: 'nonexistent' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('没有找到');
|
||||
expect(result.output).toContain('nonexistent');
|
||||
});
|
||||
|
||||
it('有匹配结果时返回工具列表', async () => {
|
||||
vi.mocked(toolRegistry.search).mockReturnValue([
|
||||
{ name: 'read_file', description: '读取文件', category: 'filesystem' },
|
||||
{ name: 'write_file', description: '写入文件', category: 'filesystem' },
|
||||
] as any);
|
||||
|
||||
const result = await toolSearchTool.execute({ query: '文件' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('找到 2 个相关工具');
|
||||
expect(result.output).toContain('read_file');
|
||||
expect(result.output).toContain('write_file');
|
||||
expect(result.output).toContain('[filesystem]');
|
||||
});
|
||||
|
||||
it('返回结果包含使用提示', async () => {
|
||||
vi.mocked(toolRegistry.search).mockReturnValue([
|
||||
{ name: 'bash', description: '执行命令', category: 'shell' },
|
||||
] as any);
|
||||
|
||||
const result = await toolSearchTool.execute({ query: '命令' });
|
||||
|
||||
expect(result.output).toContain('可以直接调用');
|
||||
});
|
||||
|
||||
it('调用 registry.search 时传递正确参数', async () => {
|
||||
vi.mocked(toolRegistry.search).mockReturnValue([]);
|
||||
|
||||
await toolSearchTool.execute({ query: '搜索关键词' });
|
||||
|
||||
expect(toolRegistry.search).toHaveBeenCalledWith('搜索关键词', 5);
|
||||
});
|
||||
|
||||
it('搜索结果格式化正确', async () => {
|
||||
vi.mocked(toolRegistry.search).mockReturnValue([
|
||||
{ name: 'grep_content', description: '搜索文件内容', category: 'filesystem' },
|
||||
] as any);
|
||||
|
||||
const result = await toolSearchTool.execute({ query: 'grep' });
|
||||
|
||||
expect(result.output).toContain('- grep_content: 搜索文件内容 [filesystem]');
|
||||
});
|
||||
|
||||
it('多个搜索结果正确排列', async () => {
|
||||
vi.mocked(toolRegistry.search).mockReturnValue([
|
||||
{ name: 'tool1', description: 'desc1', category: 'cat1' },
|
||||
{ name: 'tool2', description: 'desc2', category: 'cat2' },
|
||||
{ name: 'tool3', description: 'desc3', category: 'cat3' },
|
||||
] as any);
|
||||
|
||||
const result = await toolSearchTool.execute({ query: 'test' });
|
||||
|
||||
expect(result.output).toContain('找到 3 个相关工具');
|
||||
// 验证每个工具都在输出中
|
||||
expect(result.output).toContain('tool1');
|
||||
expect(result.output).toContain('tool2');
|
||||
expect(result.output).toContain('tool3');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { buildZodSchema, type ToolParameter } from '../../../src/types/index.js';
|
||||
|
||||
describe('buildZodSchema', () => {
|
||||
describe('基本类型转换', () => {
|
||||
it('转换 string 类型参数', () => {
|
||||
const parameters: Record<string, ToolParameter> = {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: '用户名',
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
const schema = buildZodSchema(parameters);
|
||||
expect(schema.shape.name).toBeDefined();
|
||||
|
||||
// 验证 schema 可以正确解析
|
||||
const result = schema.safeParse({ name: 'test' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe('test');
|
||||
}
|
||||
});
|
||||
|
||||
it('转换 number 类型参数', () => {
|
||||
const parameters: Record<string, ToolParameter> = {
|
||||
age: {
|
||||
type: 'number',
|
||||
description: '年龄',
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
const schema = buildZodSchema(parameters);
|
||||
|
||||
const result = schema.safeParse({ age: 25 });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.age).toBe(25);
|
||||
}
|
||||
|
||||
// 字符串应该失败
|
||||
const invalidResult = schema.safeParse({ age: '25' });
|
||||
expect(invalidResult.success).toBe(false);
|
||||
});
|
||||
|
||||
it('转换 boolean 类型参数', () => {
|
||||
const parameters: Record<string, ToolParameter> = {
|
||||
active: {
|
||||
type: 'boolean',
|
||||
description: '是否激活',
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
const schema = buildZodSchema(parameters);
|
||||
|
||||
expect(schema.safeParse({ active: true }).success).toBe(true);
|
||||
expect(schema.safeParse({ active: false }).success).toBe(true);
|
||||
expect(schema.safeParse({ active: 'true' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('转换 array 类型参数', () => {
|
||||
const parameters: Record<string, ToolParameter> = {
|
||||
items: {
|
||||
type: 'array',
|
||||
description: '项目列表',
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
const schema = buildZodSchema(parameters);
|
||||
|
||||
const result = schema.safeParse({ items: [1, 'two', { three: 3 }] });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.items).toEqual([1, 'two', { three: 3 }]);
|
||||
}
|
||||
});
|
||||
|
||||
it('转换 object 类型参数', () => {
|
||||
const parameters: Record<string, ToolParameter> = {
|
||||
config: {
|
||||
type: 'object',
|
||||
description: '配置对象',
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
const schema = buildZodSchema(parameters);
|
||||
|
||||
const result = schema.safeParse({ config: { key: 'value', nested: { a: 1 } } });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.config).toEqual({ key: 'value', nested: { a: 1 } });
|
||||
}
|
||||
});
|
||||
|
||||
it('处理未知类型为 unknown', () => {
|
||||
const parameters = {
|
||||
data: {
|
||||
type: 'custom' as any,
|
||||
description: '自定义类型',
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
const schema = buildZodSchema(parameters);
|
||||
|
||||
// unknown 类型接受任何值
|
||||
expect(schema.safeParse({ data: 'anything' }).success).toBe(true);
|
||||
expect(schema.safeParse({ data: 123 }).success).toBe(true);
|
||||
expect(schema.safeParse({ data: { obj: true } }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('可选参数', () => {
|
||||
it('required=false 时参数可选', () => {
|
||||
const parameters: Record<string, ToolParameter> = {
|
||||
optional_field: {
|
||||
type: 'string',
|
||||
description: '可选字段',
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
const schema = buildZodSchema(parameters);
|
||||
|
||||
// 不提供可选字段应该成功
|
||||
expect(schema.safeParse({}).success).toBe(true);
|
||||
|
||||
// 提供可选字段也应该成功
|
||||
expect(schema.safeParse({ optional_field: 'value' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('required 未设置时默认可选', () => {
|
||||
const parameters: Record<string, ToolParameter> = {
|
||||
field: {
|
||||
type: 'string',
|
||||
description: '字段',
|
||||
// required 未设置
|
||||
},
|
||||
};
|
||||
|
||||
const schema = buildZodSchema(parameters);
|
||||
|
||||
// 应该是可选的
|
||||
expect(schema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it('required=true 时参数必须', () => {
|
||||
const parameters: Record<string, ToolParameter> = {
|
||||
required_field: {
|
||||
type: 'string',
|
||||
description: '必填字段',
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
const schema = buildZodSchema(parameters);
|
||||
|
||||
// 不提供必填字段应该失败
|
||||
expect(schema.safeParse({}).success).toBe(false);
|
||||
|
||||
// 提供必填字段应该成功
|
||||
expect(schema.safeParse({ required_field: 'value' }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('混合参数', () => {
|
||||
it('处理多个混合类型参数', () => {
|
||||
const parameters: Record<string, ToolParameter> = {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: '文件路径',
|
||||
required: true,
|
||||
},
|
||||
line_number: {
|
||||
type: 'number',
|
||||
description: '行号',
|
||||
required: false,
|
||||
},
|
||||
recursive: {
|
||||
type: 'boolean',
|
||||
description: '递归处理',
|
||||
required: false,
|
||||
},
|
||||
patterns: {
|
||||
type: 'array',
|
||||
description: '模式列表',
|
||||
required: false,
|
||||
},
|
||||
options: {
|
||||
type: 'object',
|
||||
description: '额外选项',
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
const schema = buildZodSchema(parameters);
|
||||
|
||||
// 只提供必填参数
|
||||
expect(schema.safeParse({ path: '/test/file.txt' }).success).toBe(true);
|
||||
|
||||
// 提供所有参数
|
||||
const fullResult = schema.safeParse({
|
||||
path: '/test/file.txt',
|
||||
line_number: 42,
|
||||
recursive: true,
|
||||
patterns: ['*.ts', '*.js'],
|
||||
options: { encoding: 'utf-8' },
|
||||
});
|
||||
expect(fullResult.success).toBe(true);
|
||||
});
|
||||
|
||||
it('空参数对象返回空 schema', () => {
|
||||
const parameters: Record<string, ToolParameter> = {};
|
||||
|
||||
const schema = buildZodSchema(parameters);
|
||||
|
||||
expect(schema.safeParse({}).success).toBe(true);
|
||||
expect(Object.keys(schema.shape)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('描述信息', () => {
|
||||
it('保留参数描述', () => {
|
||||
const parameters: Record<string, ToolParameter> = {
|
||||
message: {
|
||||
type: 'string',
|
||||
description: '要发送的消息内容',
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
const schema = buildZodSchema(parameters);
|
||||
|
||||
// 验证 schema 被正确创建
|
||||
expect(schema.shape.message).toBeDefined();
|
||||
|
||||
// 验证 schema 可以正常工作
|
||||
const result = schema.safeParse({ message: 'test' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('实际工具参数示例', () => {
|
||||
it('bash 工具参数', () => {
|
||||
const bashParameters: Record<string, ToolParameter> = {
|
||||
command: {
|
||||
type: 'string',
|
||||
description: '要执行的 bash 命令',
|
||||
required: true,
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: '超时时间(毫秒)',
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
const schema = buildZodSchema(bashParameters);
|
||||
|
||||
expect(schema.safeParse({ command: 'ls -la' }).success).toBe(true);
|
||||
expect(schema.safeParse({ command: 'ls -la', timeout: 5000 }).success).toBe(true);
|
||||
expect(schema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it('read_file 工具参数', () => {
|
||||
const readFileParameters: Record<string, ToolParameter> = {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: '文件路径',
|
||||
required: true,
|
||||
},
|
||||
encoding: {
|
||||
type: 'string',
|
||||
description: '文件编码',
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
const schema = buildZodSchema(readFileParameters);
|
||||
|
||||
expect(schema.safeParse({ path: '/etc/hosts' }).success).toBe(true);
|
||||
expect(schema.safeParse({ path: '/etc/hosts', encoding: 'utf-8' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('write_file 工具参数', () => {
|
||||
const writeFileParameters: Record<string, ToolParameter> = {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: '文件路径',
|
||||
required: true,
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: '文件内容',
|
||||
required: true,
|
||||
},
|
||||
create_dirs: {
|
||||
type: 'boolean',
|
||||
description: '自动创建目录',
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
const schema = buildZodSchema(writeFileParameters);
|
||||
|
||||
expect(schema.safeParse({ path: '/tmp/test.txt', content: 'hello' }).success).toBe(true);
|
||||
expect(schema.safeParse({ path: '/tmp/test.txt', content: 'hello', create_dirs: true }).success).toBe(true);
|
||||
expect(schema.safeParse({ path: '/tmp/test.txt' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('task 工具参数', () => {
|
||||
const taskParameters: Record<string, ToolParameter> = {
|
||||
description: {
|
||||
type: 'string',
|
||||
description: '任务描述',
|
||||
required: true,
|
||||
},
|
||||
prompt: {
|
||||
type: 'string',
|
||||
description: '任务提示',
|
||||
required: true,
|
||||
},
|
||||
subagent_type: {
|
||||
type: 'string',
|
||||
description: 'Agent 类型',
|
||||
required: true,
|
||||
},
|
||||
run_in_background: {
|
||||
type: 'boolean',
|
||||
description: '是否后台运行',
|
||||
required: false,
|
||||
},
|
||||
images: {
|
||||
type: 'array',
|
||||
description: '图片数据',
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
const schema = buildZodSchema(taskParameters);
|
||||
|
||||
expect(schema.safeParse({
|
||||
description: 'Test task',
|
||||
prompt: 'Do something',
|
||||
subagent_type: 'explore',
|
||||
}).success).toBe(true);
|
||||
|
||||
expect(schema.safeParse({
|
||||
description: 'Vision task',
|
||||
prompt: 'Analyze image',
|
||||
subagent_type: 'vision',
|
||||
images: [{ data: 'base64...', mimeType: 'image/png' }],
|
||||
}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
// Mock fs module
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock process.exit
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
// Mock console.error
|
||||
const mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
describe('Config - 配置管理扩展测试', () => {
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.ai-terminal-assistant');
|
||||
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// 清理环境变量
|
||||
delete process.env.AI_PROVIDER;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.DEEPSEEK_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.AI_MODEL;
|
||||
delete process.env.AI_MAX_TOKENS;
|
||||
delete process.env.AI_BASE_URL;
|
||||
delete process.env.VISION_PROVIDER;
|
||||
delete process.env.VISION_MODEL;
|
||||
delete process.env.VISION_API_KEY;
|
||||
delete process.env.VISION_BASE_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockExit.mockClear();
|
||||
mockConsoleError.mockClear();
|
||||
});
|
||||
|
||||
describe('getConfig - 获取原始配置', () => {
|
||||
it('配置文件不存在返回空对象', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
// 需要重新导入以获取最新的 mock
|
||||
vi.resetModules();
|
||||
const { getConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = getConfig();
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it('配置文件存在返回解析后的内容', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'anthropic',
|
||||
apiKey: 'test-key',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { getConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = getConfig();
|
||||
expect(config.provider).toBe('anthropic');
|
||||
expect(config.apiKey).toBe('test-key');
|
||||
});
|
||||
|
||||
it('配置文件解析错误返回空对象', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('invalid json');
|
||||
|
||||
vi.resetModules();
|
||||
const { getConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = getConfig();
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfig - 加载配置', () => {
|
||||
it('优先使用环境变量中的 API Key', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'env-api-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'anthropic',
|
||||
apiKey: 'file-api-key',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.apiKey).toBe('env-api-key');
|
||||
});
|
||||
|
||||
it('使用配置文件中的 provider', async () => {
|
||||
process.env.DEEPSEEK_API_KEY = 'deepseek-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'deepseek',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.provider).toBe('deepseek');
|
||||
expect(config.apiKey).toBe('deepseek-key');
|
||||
});
|
||||
|
||||
it('OpenAI provider 使用 OPENAI_API_KEY', async () => {
|
||||
process.env.OPENAI_API_KEY = 'openai-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'openai',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.provider).toBe('openai');
|
||||
expect(config.apiKey).toBe('openai-key');
|
||||
});
|
||||
|
||||
it('环境变量 AI_MODEL 覆盖配置文件', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
process.env.AI_MODEL = 'claude-opus-4-20250514';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.model).toBe('claude-opus-4-20250514');
|
||||
});
|
||||
|
||||
it('使用默认模型', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.model).toBe('claude-sonnet-4-20250514');
|
||||
});
|
||||
|
||||
it('环境变量 AI_BASE_URL 覆盖配置文件', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
process.env.AI_BASE_URL = 'https://custom-api.example.com';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'anthropic',
|
||||
baseUrl: 'https://config-api.example.com',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.baseUrl).toBe('https://custom-api.example.com');
|
||||
});
|
||||
|
||||
it('无 API Key 时退出程序', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
expect(() => loadConfig()).toThrow('process.exit called');
|
||||
expect(mockConsoleError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadVisionConfig - 加载 Vision 配置', () => {
|
||||
it('返回 null 当没有 API Key', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadVisionConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadVisionConfig();
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
|
||||
it('使用环境变量配置', async () => {
|
||||
process.env.VISION_PROVIDER = 'openai';
|
||||
process.env.VISION_MODEL = 'gpt-4o';
|
||||
process.env.VISION_API_KEY = 'vision-key';
|
||||
process.env.VISION_BASE_URL = 'https://vision-api.example.com';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadVisionConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadVisionConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.provider).toBe('openai');
|
||||
expect(config!.model).toBe('gpt-4o');
|
||||
expect(config!.apiKey).toBe('vision-key');
|
||||
expect(config!.baseUrl).toBe('https://vision-api.example.com');
|
||||
});
|
||||
|
||||
it('默认使用 anthropic provider', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'anthropic-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadVisionConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadVisionConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.provider).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('Vision 专用 Key 优先于 provider Key', async () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'anthropic-key';
|
||||
process.env.VISION_API_KEY = 'vision-specific-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
vi.resetModules();
|
||||
const { loadVisionConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadVisionConfig();
|
||||
expect(config!.apiKey).toBe('vision-specific-key');
|
||||
});
|
||||
|
||||
it('从配置文件加载 Vision 设置', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
visionProvider: 'openai',
|
||||
visionModel: 'gpt-4o',
|
||||
visionApiKey: 'config-vision-key',
|
||||
visionBaseUrl: 'https://config-vision.example.com',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { loadVisionConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadVisionConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.provider).toBe('openai');
|
||||
expect(config!.model).toBe('gpt-4o');
|
||||
expect(config!.apiKey).toBe('config-vision-key');
|
||||
});
|
||||
|
||||
it('DeepSeek provider 回退到 deepseekApiKey', async () => {
|
||||
process.env.DEEPSEEK_API_KEY = 'deepseek-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
visionProvider: 'deepseek',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { loadVisionConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
const config = loadVisionConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.apiKey).toBe('deepseek-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveConfig - 保存配置', () => {
|
||||
it('创建配置目录(如果不存在)', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
vi.resetModules();
|
||||
const { saveConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
saveConfig({ provider: 'anthropic' });
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(CONFIG_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('合并现有配置', async () => {
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => p === CONFIG_FILE);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'anthropic',
|
||||
apiKey: 'existing-key',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { saveConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
saveConfig({ model: 'claude-opus-4-20250514' });
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
CONFIG_FILE,
|
||||
expect.stringContaining('"model": "claude-opus-4-20250514"')
|
||||
);
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
CONFIG_FILE,
|
||||
expect.stringContaining('"provider": "anthropic"')
|
||||
);
|
||||
});
|
||||
|
||||
it('覆盖相同字段', async () => {
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => p === CONFIG_FILE);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
provider: 'anthropic',
|
||||
model: 'old-model',
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { saveConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
saveConfig({ model: 'new-model' });
|
||||
|
||||
const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
|
||||
const savedConfig = JSON.parse(writeCall[1] as string);
|
||||
expect(savedConfig.model).toBe('new-model');
|
||||
});
|
||||
|
||||
it('解析错误时使用空对象合并', async () => {
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => p === CONFIG_FILE);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('invalid json');
|
||||
|
||||
vi.resetModules();
|
||||
const { saveConfig } = await import('../../../src/utils/config.js');
|
||||
|
||||
saveConfig({ provider: 'deepseek' });
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ vi.mock('fs', () => ({
|
||||
}));
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { getConfig, loadConfig, saveConfig } from '../../../src/utils/config.js';
|
||||
import { getConfig, loadConfig, saveConfig, loadVisionConfig } from '../../../src/utils/config.js';
|
||||
|
||||
describe('Config - 配置管理', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
@@ -20,8 +20,14 @@ describe('Config - 配置管理', () => {
|
||||
delete process.env.AI_PROVIDER;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.DEEPSEEK_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.AI_MODEL;
|
||||
delete process.env.AI_MAX_TOKENS;
|
||||
delete process.env.AI_BASE_URL;
|
||||
delete process.env.VISION_PROVIDER;
|
||||
delete process.env.VISION_MODEL;
|
||||
delete process.env.VISION_API_KEY;
|
||||
delete process.env.VISION_BASE_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -192,4 +198,160 @@ describe('Config - 配置管理', () => {
|
||||
expect(fs.mkdirSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfig - baseUrl 支持', () => {
|
||||
it('从环境变量获取 baseUrl', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
process.env.AI_BASE_URL = 'https://custom.api.com/v1';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.baseUrl).toBe('https://custom.api.com/v1');
|
||||
});
|
||||
|
||||
it('从配置文件获取 baseUrl', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
baseUrl: 'https://stored.api.com/v1',
|
||||
}));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.baseUrl).toBe('https://stored.api.com/v1');
|
||||
});
|
||||
|
||||
it('环境变量 baseUrl 优先于配置文件', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
process.env.AI_BASE_URL = 'https://env.api.com/v1';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
baseUrl: 'https://stored.api.com/v1',
|
||||
}));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.baseUrl).toBe('https://env.api.com/v1');
|
||||
});
|
||||
|
||||
it('OpenAI provider 支持 baseUrl', () => {
|
||||
process.env.AI_PROVIDER = 'openai';
|
||||
process.env.OPENAI_API_KEY = 'test-openai-key';
|
||||
process.env.AI_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.provider).toBe('openai');
|
||||
expect(config.baseUrl).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadVisionConfig - Vision 配置', () => {
|
||||
it('返回 null 当没有配置 Vision API Key', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig).toBeNull();
|
||||
});
|
||||
|
||||
it('从环境变量获取 Vision 配置', () => {
|
||||
process.env.VISION_PROVIDER = 'openai';
|
||||
process.env.VISION_API_KEY = 'vision-api-key';
|
||||
process.env.VISION_MODEL = 'gpt-4-vision';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig).not.toBeNull();
|
||||
expect(visionConfig?.provider).toBe('openai');
|
||||
expect(visionConfig?.apiKey).toBe('vision-api-key');
|
||||
expect(visionConfig?.model).toBe('gpt-4-vision');
|
||||
});
|
||||
|
||||
it('从配置文件获取 Vision 配置', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
visionProvider: 'anthropic',
|
||||
visionApiKey: 'stored-vision-key',
|
||||
visionModel: 'claude-3-opus',
|
||||
}));
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig).not.toBeNull();
|
||||
expect(visionConfig?.provider).toBe('anthropic');
|
||||
expect(visionConfig?.apiKey).toBe('stored-vision-key');
|
||||
expect(visionConfig?.model).toBe('claude-3-opus');
|
||||
});
|
||||
|
||||
it('回退到对应 provider 的 API Key', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'anthropic-main-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig).not.toBeNull();
|
||||
expect(visionConfig?.provider).toBe('anthropic'); // 默认
|
||||
expect(visionConfig?.apiKey).toBe('anthropic-main-key'); // 回退
|
||||
});
|
||||
|
||||
it('Vision baseUrl 配置', () => {
|
||||
process.env.VISION_PROVIDER = 'openai';
|
||||
process.env.VISION_API_KEY = 'vision-key';
|
||||
process.env.VISION_BASE_URL = 'https://vision.api.com/v1';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig?.baseUrl).toBe('https://vision.api.com/v1');
|
||||
});
|
||||
|
||||
it('从配置文件回退到 deepseek API key', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
visionProvider: 'deepseek',
|
||||
deepseekApiKey: 'deepseek-stored-key',
|
||||
}));
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig?.provider).toBe('deepseek');
|
||||
expect(visionConfig?.apiKey).toBe('deepseek-stored-key');
|
||||
});
|
||||
|
||||
it('从配置文件回退到 openai API key', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({
|
||||
visionProvider: 'openai',
|
||||
openaiApiKey: 'openai-stored-key',
|
||||
}));
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig?.provider).toBe('openai');
|
||||
expect(visionConfig?.apiKey).toBe('openai-stored-key');
|
||||
});
|
||||
|
||||
it('使用默认 Vision 模型', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig?.model).toBe('claude-sonnet-4-20250514');
|
||||
});
|
||||
|
||||
it('Vision 专用 key 优先于 provider key', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'anthropic-main-key';
|
||||
process.env.VISION_API_KEY = 'vision-specific-key';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const visionConfig = loadVisionConfig();
|
||||
|
||||
expect(visionConfig?.apiKey).toBe('vision-specific-key');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
computeDiff,
|
||||
formatDiff,
|
||||
countChanges,
|
||||
formatEditDiff,
|
||||
confirmFileChange,
|
||||
} from '../../../src/utils/diff.js';
|
||||
|
||||
// Mock readline
|
||||
vi.mock('readline', () => ({
|
||||
createInterface: vi.fn(() => ({
|
||||
question: vi.fn(),
|
||||
close: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock chalk (保留原始功能以便测试输出格式)
|
||||
vi.mock('chalk', () => ({
|
||||
default: {
|
||||
yellow: (s: string) => `[yellow]${s}[/yellow]`,
|
||||
cyan: (s: string) => `[cyan]${s}[/cyan]`,
|
||||
white: (s: string) => `[white]${s}[/white]`,
|
||||
gray: (s: string) => `[gray]${s}[/gray]`,
|
||||
red: (s: string) => `[red]${s}[/red]`,
|
||||
green: (s: string) => `[green]${s}[/green]`,
|
||||
},
|
||||
}));
|
||||
|
||||
import * as readline from 'readline';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
describe('Diff - 差异比较扩展测试', () => {
|
||||
describe('computeDiff - 计算 diff', () => {
|
||||
it('新文件所有行都是添加', () => {
|
||||
const diff = computeDiff(null, 'line1\nline2\nline3');
|
||||
|
||||
expect(diff.isNew).toBe(true);
|
||||
expect(diff.oldContent).toBeNull();
|
||||
expect(diff.hunks.length).toBe(1);
|
||||
expect(diff.hunks[0].lines.every(l => l.type === 'add')).toBe(true);
|
||||
expect(diff.hunks[0].newCount).toBe(3);
|
||||
});
|
||||
|
||||
it('相同内容返回空 hunks', () => {
|
||||
const content = 'line1\nline2\nline3';
|
||||
const diff = computeDiff(content, content);
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
// 相同内容可能没有实际变化的 hunks
|
||||
const hasChanges = diff.hunks.some(h =>
|
||||
h.lines.some(l => l.type === 'add' || l.type === 'remove')
|
||||
);
|
||||
expect(hasChanges).toBe(false);
|
||||
});
|
||||
|
||||
it('检测添加的行', () => {
|
||||
const oldContent = 'line1\nline2';
|
||||
const newContent = 'line1\nline2\nline3';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
const addedLines = diff.hunks.flatMap(h => h.lines.filter(l => l.type === 'add'));
|
||||
expect(addedLines.some(l => l.content === 'line3')).toBe(true);
|
||||
});
|
||||
|
||||
it('检测删除的行', () => {
|
||||
const oldContent = 'line1\nline2\nline3';
|
||||
const newContent = 'line1\nline2';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
const removedLines = diff.hunks.flatMap(h => h.lines.filter(l => l.type === 'remove'));
|
||||
expect(removedLines.some(l => l.content === 'line3')).toBe(true);
|
||||
});
|
||||
|
||||
it('检测修改的行(删除+添加)', () => {
|
||||
const oldContent = 'line1\nold line\nline3';
|
||||
const newContent = 'line1\nnew line\nline3';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
const removedLines = diff.hunks.flatMap(h => h.lines.filter(l => l.type === 'remove'));
|
||||
const addedLines = diff.hunks.flatMap(h => h.lines.filter(l => l.type === 'add'));
|
||||
|
||||
expect(removedLines.some(l => l.content === 'old line')).toBe(true);
|
||||
expect(addedLines.some(l => l.content === 'new line')).toBe(true);
|
||||
});
|
||||
|
||||
it('处理空文件', () => {
|
||||
const diff = computeDiff('', 'new content');
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
// 空到有内容是添加
|
||||
const addedLines = diff.hunks.flatMap(h => h.lines.filter(l => l.type === 'add'));
|
||||
expect(addedLines.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('处理多个不连续的变更', () => {
|
||||
const oldContent = 'line1\nkeep1\nold2\nkeep2\nold3\nline6';
|
||||
const newContent = 'line1\nkeep1\nnew2\nkeep2\nnew3\nline6';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
// 应该有变更
|
||||
const changes = diff.hunks.flatMap(h =>
|
||||
h.lines.filter(l => l.type === 'add' || l.type === 'remove')
|
||||
);
|
||||
expect(changes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('处理完全不同的内容', () => {
|
||||
const oldContent = 'completely\ndifferent\ncontent';
|
||||
const newContent = 'totally\nnew\nstuff';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
expect(diff.hunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDiff - 格式化 diff', () => {
|
||||
it('新文件显示 +++ 新文件标记', () => {
|
||||
const diff = computeDiff(null, 'new content');
|
||||
const formatted = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(formatted).toContain('新文件');
|
||||
expect(formatted).toContain('/test/file.ts');
|
||||
});
|
||||
|
||||
it('修改文件显示 --- 和 +++ 标记', () => {
|
||||
const diff = computeDiff('old', 'new');
|
||||
const formatted = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(formatted).toContain('原文件');
|
||||
expect(formatted).toContain('修改后');
|
||||
});
|
||||
|
||||
it('显示 hunk 头部 @@ 信息', () => {
|
||||
const diff = computeDiff('old', 'new');
|
||||
const formatted = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(formatted).toContain('@@');
|
||||
});
|
||||
|
||||
it('添加行使用 + 前缀', () => {
|
||||
const diff = computeDiff('', 'added line');
|
||||
const formatted = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(formatted).toContain('+ added line');
|
||||
});
|
||||
|
||||
it('删除行使用 - 前缀', () => {
|
||||
const diff = computeDiff('removed line', '');
|
||||
const formatted = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(formatted).toContain('- removed line');
|
||||
});
|
||||
});
|
||||
|
||||
describe('countChanges - 统计变更', () => {
|
||||
it('统计添加行数', () => {
|
||||
const diff = computeDiff(null, 'line1\nline2\nline3');
|
||||
const changes = countChanges(diff);
|
||||
|
||||
expect(changes.additions).toBe(3);
|
||||
expect(changes.deletions).toBe(0);
|
||||
});
|
||||
|
||||
it('统计删除行数', () => {
|
||||
const diff = computeDiff('line1\nline2\nline3', '');
|
||||
const changes = countChanges(diff);
|
||||
|
||||
expect(changes.deletions).toBe(3);
|
||||
});
|
||||
|
||||
it('统计混合变更', () => {
|
||||
const diff = computeDiff('old1\nold2', 'new1\nold2\nnew2');
|
||||
const changes = countChanges(diff);
|
||||
|
||||
// 具体数字取决于 diff 算法实现
|
||||
expect(changes.additions).toBeGreaterThanOrEqual(0);
|
||||
expect(changes.deletions).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('无变更返回零', () => {
|
||||
const diff = computeDiff('same', 'same');
|
||||
const changes = countChanges(diff);
|
||||
|
||||
expect(changes.additions + changes.deletions).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatEditDiff - 编辑 diff 格式化', () => {
|
||||
it('显示删除和添加内容', () => {
|
||||
const formatted = formatEditDiff('old text', 'new text');
|
||||
|
||||
expect(formatted).toContain('old text');
|
||||
expect(formatted).toContain('new text');
|
||||
expect(formatted).toContain('-');
|
||||
expect(formatted).toContain('+');
|
||||
});
|
||||
|
||||
it('处理多行内容', () => {
|
||||
const formatted = formatEditDiff('old1\nold2', 'new1\nnew2');
|
||||
|
||||
expect(formatted).toContain('old1');
|
||||
expect(formatted).toContain('old2');
|
||||
expect(formatted).toContain('new1');
|
||||
expect(formatted).toContain('new2');
|
||||
});
|
||||
|
||||
it('包含变更内容标题', () => {
|
||||
const formatted = formatEditDiff('old', 'new');
|
||||
|
||||
expect(formatted).toContain('变更内容');
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmFileChange - 文件变更确认', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('内容相同直接返回确认', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('same content');
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'same content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.remember).toBe(false);
|
||||
});
|
||||
|
||||
it('新文件(读取失败)显示 diff', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(true);
|
||||
});
|
||||
|
||||
it('用户输入 y 确认写入', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.remember).toBe(false);
|
||||
});
|
||||
|
||||
it('用户输入 Y 确认并记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('Y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.remember).toBe(true);
|
||||
});
|
||||
|
||||
it('用户输入 n 取消操作', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('n')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(false);
|
||||
expect(result.remember).toBe(false);
|
||||
});
|
||||
|
||||
it('用户输入 N 取消并记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('N')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(false);
|
||||
expect(result.remember).toBe(true);
|
||||
});
|
||||
|
||||
it('无效输入默认取消', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('invalid')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(false);
|
||||
expect(result.remember).toBe(false);
|
||||
});
|
||||
|
||||
it('显示变更预览信息', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
const output = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(output).toContain('文件变更预览');
|
||||
expect(output).toContain('写入文件');
|
||||
expect(output).toContain('/test/file.ts');
|
||||
});
|
||||
|
||||
it('显示编辑操作类型', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await confirmFileChange('/test/file.ts', 'new content', 'edit');
|
||||
|
||||
const output = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(output).toContain('编辑文件');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeDiff, countChanges, formatEditDiff } from '../../../src/utils/diff.js';
|
||||
import { computeDiff, countChanges, formatEditDiff, formatDiff } from '../../../src/utils/diff.js';
|
||||
|
||||
describe('computeDiff - 计算文件差异', () => {
|
||||
describe('新文件', () => {
|
||||
@@ -248,6 +248,56 @@ describe('LCS 算法测试', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDiff - 格式化 diff 输出', () => {
|
||||
it('新文件显示新增标记', () => {
|
||||
const diff = computeDiff(null, 'line1\nline2');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(result).toContain('新文件');
|
||||
expect(result).toContain('/test/file.ts');
|
||||
});
|
||||
|
||||
it('修改文件显示原文件和修改后标记', () => {
|
||||
const diff = computeDiff('old', 'new');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(result).toContain('原文件');
|
||||
expect(result).toContain('修改后');
|
||||
expect(result).toContain('/test/file.ts');
|
||||
});
|
||||
|
||||
it('hunk 头部显示正确的行号范围', () => {
|
||||
const diff = computeDiff('line1\nline2', 'line1\nnew\nline2');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(result).toContain('@@');
|
||||
});
|
||||
|
||||
it('新增行显示 + 前缀', () => {
|
||||
const diff = computeDiff(null, 'added line');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(result).toContain('+');
|
||||
expect(result).toContain('added line');
|
||||
});
|
||||
|
||||
it('删除行显示 - 前缀', () => {
|
||||
const diff = computeDiff('removed line', 'new line');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(result).toContain('-');
|
||||
expect(result).toContain('removed line');
|
||||
});
|
||||
|
||||
it('空 diff 返回基本格式', () => {
|
||||
const diff = computeDiff('same', 'same');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
// 没有 hunks 时只有头部
|
||||
expect(result).toContain('/test/file.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('实际代码场景', () => {
|
||||
it('函数修改', () => {
|
||||
const oldContent = `function hello() {
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
isImagePath,
|
||||
extractImageReferences,
|
||||
formatFileSize,
|
||||
loadImage,
|
||||
loadImages,
|
||||
IMAGE_EXTENSIONS,
|
||||
} from '../../../src/utils/image.js';
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
describe('Image Utils - 图片处理工具', () => {
|
||||
describe('IMAGE_EXTENSIONS', () => {
|
||||
it('包含常见图片扩展名', () => {
|
||||
@@ -152,4 +162,200 @@ describe('Image Utils - 图片处理工具', () => {
|
||||
expect(formatFileSize(1024 * 1024 * 1024)).toBe('1024.0MB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadImage - 加载图片文件', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('成功加载 PNG 图片', async () => {
|
||||
const mockBuffer = Buffer.from('fake image data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImage('/test/image.png');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.image).toBeDefined();
|
||||
expect(result.image?.filename).toBe('image.png');
|
||||
expect(result.image?.extension).toBe('.png');
|
||||
expect(result.image?.mimeType).toBe('image/png');
|
||||
expect(result.image?.base64).toBe(mockBuffer.toString('base64'));
|
||||
});
|
||||
|
||||
it('成功加载 JPG 图片', async () => {
|
||||
const mockBuffer = Buffer.from('fake jpg data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImage('/test/photo.jpg');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.image?.mimeType).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('成功加载 JPEG 图片', async () => {
|
||||
const mockBuffer = Buffer.from('fake jpeg data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImage('/test/photo.jpeg');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.image?.mimeType).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('成功加载 GIF 图片', async () => {
|
||||
const mockBuffer = Buffer.from('fake gif data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImage('/test/animation.gif');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.image?.mimeType).toBe('image/gif');
|
||||
});
|
||||
|
||||
it('成功加载 WebP 图片', async () => {
|
||||
const mockBuffer = Buffer.from('fake webp data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImage('/test/modern.webp');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.image?.mimeType).toBe('image/webp');
|
||||
});
|
||||
|
||||
it('返回正确的 dataUrl', async () => {
|
||||
const mockBuffer = Buffer.from('test data');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImage('/test/image.png');
|
||||
|
||||
expect(result.image?.dataUrl).toBe(
|
||||
`data:image/png;base64,${mockBuffer.toString('base64')}`
|
||||
);
|
||||
});
|
||||
|
||||
it('不支持的格式返回错误', async () => {
|
||||
const result = await loadImage('/test/document.txt');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('不支持的图片格式');
|
||||
expect(result.error).toContain('.txt');
|
||||
});
|
||||
|
||||
it('文件不存在返回错误', async () => {
|
||||
const error = new Error('File not found');
|
||||
(error as any).code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
const result = await loadImage('/nonexistent/image.png');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('图片文件不存在');
|
||||
});
|
||||
|
||||
it('读取文件错误返回错误信息', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const result = await loadImage('/test/image.png');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('加载图片失败');
|
||||
expect(result.error).toContain('Permission denied');
|
||||
});
|
||||
|
||||
it('处理相对路径', async () => {
|
||||
const mockBuffer = Buffer.from('test');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImage('./relative/image.png', '/workdir');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.readFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('relative/image.png')
|
||||
);
|
||||
});
|
||||
|
||||
it('处理大写扩展名', async () => {
|
||||
const mockBuffer = Buffer.from('test');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImage('/test/image.PNG');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.image?.mimeType).toBe('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadImages - 批量加载图片', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('成功加载多张图片', async () => {
|
||||
const mockBuffer = Buffer.from('test');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImages(['/test/a.png', '/test/b.jpg']);
|
||||
|
||||
expect(result.images).toHaveLength(2);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('部分加载失败返回错误列表', async () => {
|
||||
const mockBuffer = Buffer.from('test');
|
||||
vi.mocked(fs.readFile).mockImplementation((path) => {
|
||||
if (String(path).includes('good')) {
|
||||
return Promise.resolve(mockBuffer);
|
||||
}
|
||||
const error = new Error('Not found');
|
||||
(error as any).code = 'ENOENT';
|
||||
return Promise.reject(error);
|
||||
});
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
const result = await loadImages(['/test/good.png', '/test/bad.png']);
|
||||
|
||||
expect(result.images).toHaveLength(1);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].path).toBe('/test/bad.png');
|
||||
});
|
||||
|
||||
it('空列表返回空结果', async () => {
|
||||
const result = await loadImages([]);
|
||||
|
||||
expect(result.images).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('全部加载失败时 images 为空', async () => {
|
||||
const error = new Error('Not found');
|
||||
(error as any).code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
const result = await loadImages(['/test/a.png', '/test/b.png']);
|
||||
|
||||
expect(result.images).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('使用自定义工作目录', async () => {
|
||||
const mockBuffer = Buffer.from('test');
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockBuffer);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: mockBuffer.length } as any);
|
||||
|
||||
await loadImages(['./image.png'], '/custom/workdir');
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('custom/workdir')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user