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:
2025-12-11 20:37:03 +08:00
parent f8b0cd4bec
commit bca19b7741
24 changed files with 6347 additions and 4 deletions
+429
View File
@@ -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');
});
});
});
+71
View File
@@ -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);
});
});
});
+201
View File
@@ -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);
});
});
});
});
+317
View File
@@ -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('%');
});
});
});
+411
View File
@@ -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('无法处理图片');
});
});
+264
View File
@@ -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' });
});
});
+356
View File
@@ -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();
});
});
+357
View File
@@ -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);
});
});
});
+346
View File
@@ -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();
});
});
});
+399
View File
@@ -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 });
});
});
});
+226
View File
@@ -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('项目目录外的路径');
});
});
});
+272
View File
@@ -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');
});
});
});
+205
View File
@@ -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'
);
});
});
});
+59 -1
View File
@@ -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('不存在');
});
});
});
+274
View File
@@ -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');
});
});
});
+143
View File
@@ -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');
});
});
});
+362
View File
@@ -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);
});
});
});
+338
View File
@@ -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();
});
});
});
+163 -1
View File
@@ -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');
});
});
});
+363
View File
@@ -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();
});
});
});
+51 -1
View File
@@ -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() {
+207 -1
View File
@@ -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')
);
});
});
});