From bca19b7741ee1af99c0c25811d5360d50abdbd76 Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 11 Dec 2025 20:37:03 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=8F=90=E5=8D=87=E4=BB=A3=E7=A0=81=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增测试文件: - 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 --- tests/unit/agent/executor-extended.test.ts | 429 ++++++++++++++++++ tests/unit/agent/manager.test.ts | 71 +++ tests/unit/agent/presets/index.test.ts | 201 ++++++++ tests/unit/context/manager-extended.test.ts | 317 +++++++++++++ tests/unit/core/agent.test.ts | 411 +++++++++++++++++ tests/unit/core/providers.test.ts | 264 +++++++++++ tests/unit/lsp/cli.test.ts | 356 +++++++++++++++ tests/unit/lsp/client-extended.test.ts | 357 +++++++++++++++ tests/unit/lsp/index.test.ts | 346 ++++++++++++++ tests/unit/permission/file-prompt.test.ts | 399 ++++++++++++++++ tests/unit/permission/prompt.test.ts | 226 +++++++++ tests/unit/skills/builtin/index.test.ts | 272 +++++++++++ .../filesystem/write_file-extended.test.ts | 258 +++++++++++ .../tools/git/git_commit-extended.test.ts | 275 +++++++++++ tests/unit/tools/load_description.test.ts | 205 +++++++++ tests/unit/tools/skill/skill.test.ts | 60 ++- tests/unit/tools/todo/todo-manager.test.ts | 274 +++++++++++ tests/unit/tools/tool-search.test.ts | 143 ++++++ tests/unit/types/index.test.ts | 362 +++++++++++++++ tests/unit/utils/config-extended.test.ts | 338 ++++++++++++++ tests/unit/utils/config.test.ts | 164 ++++++- tests/unit/utils/diff-extended.test.ts | 363 +++++++++++++++ tests/unit/utils/diff.test.ts | 52 ++- tests/unit/utils/image.test.ts | 208 ++++++++- 24 files changed, 6347 insertions(+), 4 deletions(-) create mode 100644 tests/unit/agent/executor-extended.test.ts create mode 100644 tests/unit/agent/presets/index.test.ts create mode 100644 tests/unit/context/manager-extended.test.ts create mode 100644 tests/unit/core/agent.test.ts create mode 100644 tests/unit/core/providers.test.ts create mode 100644 tests/unit/lsp/cli.test.ts create mode 100644 tests/unit/lsp/client-extended.test.ts create mode 100644 tests/unit/lsp/index.test.ts create mode 100644 tests/unit/permission/file-prompt.test.ts create mode 100644 tests/unit/permission/prompt.test.ts create mode 100644 tests/unit/skills/builtin/index.test.ts create mode 100644 tests/unit/tools/filesystem/write_file-extended.test.ts create mode 100644 tests/unit/tools/git/git_commit-extended.test.ts create mode 100644 tests/unit/tools/load_description.test.ts create mode 100644 tests/unit/tools/todo/todo-manager.test.ts create mode 100644 tests/unit/tools/tool-search.test.ts create mode 100644 tests/unit/types/index.test.ts create mode 100644 tests/unit/utils/config-extended.test.ts create mode 100644 tests/unit/utils/diff-extended.test.ts diff --git a/tests/unit/agent/executor-extended.test.ts b/tests/unit/agent/executor-extended.test.ts new file mode 100644 index 0000000..7069519 --- /dev/null +++ b/tests/unit/agent/executor-extended.test.ts @@ -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; + 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'); + }); + }); +}); diff --git a/tests/unit/agent/manager.test.ts b/tests/unit/agent/manager.test.ts index 0c97326..fc68dd6 100644 --- a/tests/unit/agent/manager.test.ts +++ b/tests/unit/agent/manager.test.ts @@ -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); + }); }); }); diff --git a/tests/unit/agent/presets/index.test.ts b/tests/unit/agent/presets/index.test.ts new file mode 100644 index 0000000..c446a99 --- /dev/null +++ b/tests/unit/agent/presets/index.test.ts @@ -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); + }); + }); + }); +}); diff --git a/tests/unit/context/manager-extended.test.ts b/tests/unit/context/manager-extended.test.ts new file mode 100644 index 0000000..e710e34 --- /dev/null +++ b/tests/unit/context/manager-extended.test.ts @@ -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('%'); + }); + }); +}); diff --git a/tests/unit/core/agent.test.ts b/tests/unit/core/agent.test.ts new file mode 100644 index 0000000..5210948 --- /dev/null +++ b/tests/unit/core/agent.test.ts @@ -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('无法处理图片'); + }); +}); diff --git a/tests/unit/core/providers.test.ts b/tests/unit/core/providers.test.ts new file mode 100644 index 0000000..2a1a2f4 --- /dev/null +++ b/tests/unit/core/providers.test.ts @@ -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' }); + }); +}); diff --git a/tests/unit/lsp/cli.test.ts b/tests/unit/lsp/cli.test.ts new file mode 100644 index 0000000..e585d7d --- /dev/null +++ b/tests/unit/lsp/cli.test.ts @@ -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; + let consoleErrorSpy: ReturnType; + + 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(); + }); +}); diff --git a/tests/unit/lsp/client-extended.test.ts b/tests/unit/lsp/client-extended.test.ts new file mode 100644 index 0000000..e19c7c0 --- /dev/null +++ b/tests/unit/lsp/client-extended.test.ts @@ -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); + }); + }); +}); diff --git a/tests/unit/lsp/index.test.ts b/tests/unit/lsp/index.test.ts new file mode 100644 index 0000000..4f5b720 --- /dev/null +++ b/tests/unit/lsp/index.test.ts @@ -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(''); + }); + + 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(); + }); + }); +}); diff --git a/tests/unit/permission/file-prompt.test.ts b/tests/unit/permission/file-prompt.test.ts new file mode 100644 index 0000000..7232613 --- /dev/null +++ b/tests/unit/permission/file-prompt.test.ts @@ -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; + + 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 }); + }); + }); +}); diff --git a/tests/unit/permission/prompt.test.ts b/tests/unit/permission/prompt.test.ts new file mode 100644 index 0000000..e29eaa4 --- /dev/null +++ b/tests/unit/permission/prompt.test.ts @@ -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; + + 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('项目目录外的路径'); + }); + }); +}); diff --git a/tests/unit/skills/builtin/index.test.ts b/tests/unit/skills/builtin/index.test.ts new file mode 100644 index 0000000..aeb15a3 --- /dev/null +++ b/tests/unit/skills/builtin/index.test.ts @@ -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'); + }); + }); +}); diff --git a/tests/unit/tools/filesystem/write_file-extended.test.ts b/tests/unit/tools/filesystem/write_file-extended.test.ts new file mode 100644 index 0000000..4e633ca --- /dev/null +++ b/tests/unit/tools/filesystem/write_file-extended.test.ts @@ -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\n1:1 ERROR: Type error\n' + ); + + 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'); + }); + }); +}); diff --git a/tests/unit/tools/git/git_commit-extended.test.ts b/tests/unit/tools/git/git_commit-extended.test.ts new file mode 100644 index 0000000..febb15f --- /dev/null +++ b/tests/unit/tools/git/git_commit-extended.test.ts @@ -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'); + }); + }); +}); diff --git a/tests/unit/tools/load_description.test.ts b/tests/unit/tools/load_description.test.ts new file mode 100644 index 0000000..ed6d16b --- /dev/null +++ b/tests/unit/tools/load_description.test.ts @@ -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' + ); + }); + }); +}); diff --git a/tests/unit/tools/skill/skill.test.ts b/tests/unit/tools/skill/skill.test.ts index 1e0c14a..5da2227 100644 --- a/tests/unit/tools/skill/skill.test.ts +++ b/tests/unit/tools/skill/skill.test.ts @@ -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('不存在'); + }); + }); }); diff --git a/tests/unit/tools/todo/todo-manager.test.ts b/tests/unit/tools/todo/todo-manager.test.ts new file mode 100644 index 0000000..809f2f2 --- /dev/null +++ b/tests/unit/tools/todo/todo-manager.test.ts @@ -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 = '任务: "测试" & '; + 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'); + }); + }); +}); diff --git a/tests/unit/tools/tool-search.test.ts b/tests/unit/tools/tool-search.test.ts new file mode 100644 index 0000000..b814ca3 --- /dev/null +++ b/tests/unit/tools/tool-search.test.ts @@ -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'); + }); + }); +}); diff --git a/tests/unit/types/index.test.ts b/tests/unit/types/index.test.ts new file mode 100644 index 0000000..5caf7a4 --- /dev/null +++ b/tests/unit/types/index.test.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + field: { + type: 'string', + description: '字段', + // required 未设置 + }, + }; + + const schema = buildZodSchema(parameters); + + // 应该是可选的 + expect(schema.safeParse({}).success).toBe(true); + }); + + it('required=true 时参数必须', () => { + const parameters: Record = { + 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 = { + 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 = {}; + + const schema = buildZodSchema(parameters); + + expect(schema.safeParse({}).success).toBe(true); + expect(Object.keys(schema.shape)).toHaveLength(0); + }); + }); + + describe('描述信息', () => { + it('保留参数描述', () => { + const parameters: Record = { + 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 = { + 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 = { + 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 = { + 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 = { + 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); + }); + }); +}); diff --git a/tests/unit/utils/config-extended.test.ts b/tests/unit/utils/config-extended.test.ts new file mode 100644 index 0000000..242a5b1 --- /dev/null +++ b/tests/unit/utils/config-extended.test.ts @@ -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(); + }); + }); +}); diff --git a/tests/unit/utils/config.test.ts b/tests/unit/utils/config.test.ts index 47275e5..8e548d5 100644 --- a/tests/unit/utils/config.test.ts +++ b/tests/unit/utils/config.test.ts @@ -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'); + }); + }); }); diff --git a/tests/unit/utils/diff-extended.test.ts b/tests/unit/utils/diff-extended.test.ts new file mode 100644 index 0000000..1764b59 --- /dev/null +++ b/tests/unit/utils/diff-extended.test.ts @@ -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; + + 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(); + }); + }); +}); diff --git a/tests/unit/utils/diff.test.ts b/tests/unit/utils/diff.test.ts index 00f55aa..c02a9d7 100644 --- a/tests/unit/utils/diff.test.ts +++ b/tests/unit/utils/diff.test.ts @@ -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() { diff --git a/tests/unit/utils/image.test.ts b/tests/unit/utils/image.test.ts index 8155927..930cde5 100644 --- a/tests/unit/utils/image.test.ts +++ b/tests/unit/utils/image.test.ts @@ -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') + ); + }); + }); });