feat: 添加完整的单元测试套件
- 新增 vitest 测试框架配置 - 添加 54 个测试文件,共 951 个测试用例 - 覆盖核心模块: - Agent: executor, registry, config-loader, permission-merger - Context: manager, compaction, prune, token-counter - Permission: manager, bash/file/git/web checkers, wildcard - Session: manager, storage - Tools: filesystem (12个), git (10个), web, shell, todo, task - LSP: client, server, language - Utils: config, diff - UI: terminal
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
copyFile: vi.fn().mockResolvedValue(undefined),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
readdir: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '复制文件'),
|
||||
}));
|
||||
|
||||
import { copyFileTool } from '../../../../src/tools/filesystem/copy_file.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('copyFileTool - 文件复制工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fs.stat).mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(copyFileTool.name).toBe('copy_file');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(copyFileTool.metadata.category).toBe('filesystem');
|
||||
expect(copyFileTool.metadata.keywords).toContain('copy');
|
||||
expect(copyFileTool.metadata.keywords).toContain('cp');
|
||||
});
|
||||
|
||||
it('定义了必需参数', () => {
|
||||
expect(copyFileTool.parameters.source.required).toBe(true);
|
||||
expect(copyFileTool.parameters.destination.required).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功复制文件', async () => {
|
||||
// 第一次调用检查源文件,第二次调用检查目标是否是目录
|
||||
vi.mocked(fs.stat)
|
||||
.mockResolvedValueOnce({ isDirectory: () => false } as any)
|
||||
.mockRejectedValueOnce(new Error('ENOENT')); // 目标不存在
|
||||
|
||||
const result = await copyFileTool.execute({
|
||||
source: 'src.txt',
|
||||
destination: 'dest.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('已复制');
|
||||
expect(fs.copyFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('复制到已存在的目录', async () => {
|
||||
vi.mocked(fs.stat)
|
||||
.mockResolvedValueOnce({ isDirectory: () => false } as any) // 源文件
|
||||
.mockResolvedValueOnce({ isDirectory: () => true } as any); // 目标是目录
|
||||
|
||||
const result = await copyFileTool.execute({
|
||||
source: 'file.txt',
|
||||
destination: '/target/dir',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('file.txt');
|
||||
});
|
||||
|
||||
it('递归复制目录', async () => {
|
||||
vi.mocked(fs.stat)
|
||||
.mockResolvedValueOnce({ isDirectory: () => true } as any) // 源是目录
|
||||
.mockRejectedValueOnce(new Error('ENOENT')); // 目标不存在
|
||||
|
||||
vi.mocked(fs.readdir).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await copyFileTool.execute({
|
||||
source: 'src_dir',
|
||||
destination: 'dest_dir',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.mkdir).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('源文件读取权限被拒绝', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许读取',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await copyFileTool.execute({
|
||||
source: '/etc/passwd',
|
||||
destination: 'copy.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('目标位置写入权限被拒绝', async () => {
|
||||
const mockCheck = vi.fn()
|
||||
.mockResolvedValueOnce({ allowed: true }) // 读取权限
|
||||
.mockResolvedValueOnce({ allowed: false, action: 'deny', reason: '不允许写入' }); // 复制权限
|
||||
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: mockCheck,
|
||||
} as any);
|
||||
|
||||
const result = await copyFileTool.execute({
|
||||
source: 'src.txt',
|
||||
destination: '/protected/dest.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('源文件需要确认', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await copyFileTool.execute({
|
||||
source: '/sensitive/file',
|
||||
destination: 'dest.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('源文件不存在返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
|
||||
vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT: no such file'));
|
||||
|
||||
const result = await copyFileTool.execute({
|
||||
source: 'nonexistent.txt',
|
||||
destination: 'dest.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('ENOENT');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '创建目录'),
|
||||
}));
|
||||
|
||||
import { createDirectoryTool } from '../../../../src/tools/filesystem/create_directory.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('createDirectoryTool - 创建目录工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// 默认目录不存在
|
||||
vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(createDirectoryTool.name).toBe('create_directory');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(createDirectoryTool.metadata.category).toBe('filesystem');
|
||||
expect(createDirectoryTool.metadata.keywords).toContain('create');
|
||||
expect(createDirectoryTool.metadata.keywords).toContain('directory');
|
||||
expect(createDirectoryTool.metadata.keywords).toContain('mkdir');
|
||||
});
|
||||
|
||||
it('定义了必需的 path 参数', () => {
|
||||
expect(createDirectoryTool.parameters.path.required).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功创建目录', async () => {
|
||||
const result = await createDirectoryTool.execute({ path: 'new_dir' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('已创建目录');
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ recursive: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('目录已存在返回成功', async () => {
|
||||
vi.mocked(fs.stat).mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
} as any);
|
||||
|
||||
const result = await createDirectoryTool.execute({ path: 'existing_dir' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('目录已存在');
|
||||
expect(fs.mkdir).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('路径是文件返回错误', async () => {
|
||||
vi.mocked(fs.stat).mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
} as any);
|
||||
|
||||
const result = await createDirectoryTool.execute({ path: 'file.txt' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('路径已存在且不是目录');
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许创建目录',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await createDirectoryTool.execute({ path: '/protected/dir' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await createDirectoryTool.execute({ path: 'new_dir' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('创建嵌套目录', async () => {
|
||||
// 确保权限检查通过
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
|
||||
const result = await createDirectoryTool.execute({ path: 'a/b/c/d' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ recursive: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('传递正确参数给权限检查', async () => {
|
||||
const mockCheck = vi.fn().mockResolvedValue({ allowed: true });
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: mockCheck,
|
||||
} as any);
|
||||
|
||||
await createDirectoryTool.execute({ path: 'test_dir' });
|
||||
|
||||
expect(mockCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operation: 'mkdir',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('处理创建错误', async () => {
|
||||
vi.mocked(fs.mkdir).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const result = await createDirectoryTool.execute({ path: 'new_dir' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Permission denied');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
rmdir: vi.fn().mockResolvedValue(undefined),
|
||||
rm: vi.fn().mockResolvedValue(undefined),
|
||||
readdir: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '删除文件'),
|
||||
}));
|
||||
|
||||
import { deleteFileTool } from '../../../../src/tools/filesystem/delete_file.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('deleteFileTool - 文件删除工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(deleteFileTool.name).toBe('delete_file');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(deleteFileTool.metadata.category).toBe('filesystem');
|
||||
expect(deleteFileTool.metadata.keywords).toContain('delete');
|
||||
expect(deleteFileTool.metadata.keywords).toContain('remove');
|
||||
expect(deleteFileTool.metadata.keywords).toContain('rm');
|
||||
});
|
||||
|
||||
it('定义了必需的 path 参数', () => {
|
||||
expect(deleteFileTool.parameters.path.required).toBe(true);
|
||||
});
|
||||
|
||||
it('定义了可选的 recursive 参数', () => {
|
||||
expect(deleteFileTool.parameters.recursive.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功删除文件', async () => {
|
||||
vi.mocked(fs.stat).mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
} as any);
|
||||
|
||||
const result = await deleteFileTool.execute({ path: 'file.txt' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('已删除文件');
|
||||
expect(fs.unlink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('删除空目录', async () => {
|
||||
vi.mocked(fs.stat).mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
} as any);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([]);
|
||||
|
||||
const result = await deleteFileTool.execute({ path: 'empty_dir' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('已删除目录');
|
||||
expect(fs.rmdir).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('非空目录无 recursive 返回错误', async () => {
|
||||
vi.mocked(fs.stat).mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
} as any);
|
||||
vi.mocked(fs.readdir).mockResolvedValue(['file1.txt', 'file2.txt'] as any);
|
||||
|
||||
const result = await deleteFileTool.execute({ path: 'nonempty_dir' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('目录不为空');
|
||||
expect(result.error).toContain('recursive: true');
|
||||
});
|
||||
|
||||
it('递归删除非空目录', async () => {
|
||||
vi.mocked(fs.stat).mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
} as any);
|
||||
|
||||
const result = await deleteFileTool.execute({
|
||||
path: 'nonempty_dir',
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('已删除目录');
|
||||
expect(fs.rm).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ recursive: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许删除',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await deleteFileTool.execute({ path: '/protected/file' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await deleteFileTool.execute({ path: 'important.txt' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('文件不存在返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
|
||||
vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await deleteFileTool.execute({ path: 'nonexistent.txt' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('ENOENT');
|
||||
});
|
||||
|
||||
it('传递正确参数给权限检查', async () => {
|
||||
const mockCheck = vi.fn().mockResolvedValue({ allowed: true });
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: mockCheck,
|
||||
} as any);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => false } as any);
|
||||
|
||||
await deleteFileTool.execute({ path: 'test.txt' });
|
||||
|
||||
expect(mockCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operation: 'delete',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '编辑文件'),
|
||||
}));
|
||||
|
||||
// Mock LSP
|
||||
vi.mock('../../../../src/lsp/index.js', () => ({
|
||||
touchFile: vi.fn().mockResolvedValue(false),
|
||||
getFormattedFileDiagnostics: vi.fn().mockResolvedValue(null),
|
||||
isLanguageSupported: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
import { editFileTool } from '../../../../src/tools/filesystem/edit_file.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
import { isLanguageSupported, touchFile, getFormattedFileDiagnostics } from '../../../../src/lsp/index.js';
|
||||
|
||||
describe('editFileTool - 文件编辑工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fs.readFile).mockResolvedValue('original content here');
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(editFileTool.name).toBe('edit_file');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(editFileTool.metadata.category).toBe('filesystem');
|
||||
expect(editFileTool.metadata.keywords).toContain('edit');
|
||||
expect(editFileTool.metadata.keywords).toContain('replace');
|
||||
});
|
||||
|
||||
it('定义了必需参数', () => {
|
||||
expect(editFileTool.parameters.path.required).toBe(true);
|
||||
expect(editFileTool.parameters.old_string.required).toBe(true);
|
||||
expect(editFileTool.parameters.new_string.required).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功编辑文件', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('hello world');
|
||||
|
||||
const result = await editFileTool.execute({
|
||||
path: 'test.txt',
|
||||
old_string: 'world',
|
||||
new_string: 'universe',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('文件已编辑');
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'hello universe',
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('old_string 不存在返回错误', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('hello world');
|
||||
|
||||
const result = await editFileTool.execute({
|
||||
path: 'test.txt',
|
||||
old_string: 'notfound',
|
||||
new_string: 'replacement',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('未找到要替换的字符串');
|
||||
});
|
||||
|
||||
it('old_string 多次出现返回错误', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('hello hello hello');
|
||||
|
||||
const result = await editFileTool.execute({
|
||||
path: 'test.txt',
|
||||
old_string: 'hello',
|
||||
new_string: 'hi',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('3 处匹配');
|
||||
expect(result.error).toContain('必须唯一');
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('content');
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许编辑',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await editFileTool.execute({
|
||||
path: 'test.txt',
|
||||
old_string: 'content',
|
||||
new_string: 'new',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('content');
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await editFileTool.execute({
|
||||
path: 'test.txt',
|
||||
old_string: 'content',
|
||||
new_string: 'new',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('文件不存在返回错误', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT: no such file'));
|
||||
|
||||
const result = await editFileTool.execute({
|
||||
path: 'nonexistent.txt',
|
||||
old_string: 'text',
|
||||
new_string: 'new',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('ENOENT');
|
||||
});
|
||||
|
||||
it('支持 LSP 时获取诊断信息', async () => {
|
||||
// 确保权限检查通过
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
vi.mocked(fs.readFile).mockResolvedValue('const x = 1');
|
||||
vi.mocked(isLanguageSupported).mockReturnValue(true);
|
||||
vi.mocked(touchFile).mockResolvedValue(false);
|
||||
vi.mocked(getFormattedFileDiagnostics).mockResolvedValue('\n错误: 类型不匹配');
|
||||
|
||||
const result = await editFileTool.execute({
|
||||
path: 'test.ts',
|
||||
old_string: 'const x = 1',
|
||||
new_string: 'const x: string = 1',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('代码检查发现问题');
|
||||
});
|
||||
|
||||
it('传递正确参数给权限检查', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old text');
|
||||
const mockCheck = vi.fn().mockResolvedValue({ allowed: true });
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: mockCheck,
|
||||
} as any);
|
||||
|
||||
await editFileTool.execute({
|
||||
path: 'test.txt',
|
||||
old_string: 'old text',
|
||||
new_string: 'new text',
|
||||
});
|
||||
|
||||
expect(mockCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operation: 'edit',
|
||||
oldContent: 'old text',
|
||||
newContent: 'new text',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
readlink: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '获取文件信息'),
|
||||
}));
|
||||
|
||||
import { getFileInfoTool } from '../../../../src/tools/filesystem/get_file_info.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('getFileInfoTool - 获取文件信息工具', () => {
|
||||
const mockStats = {
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
size: 1024,
|
||||
mode: 0o100644,
|
||||
birthtime: new Date('2024-01-01'),
|
||||
mtime: new Date('2024-01-15'),
|
||||
atime: new Date('2024-01-20'),
|
||||
ino: 12345,
|
||||
nlink: 1,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fs.stat).mockResolvedValue(mockStats as any);
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(getFileInfoTool.name).toBe('get_file_info');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(getFileInfoTool.metadata.category).toBe('filesystem');
|
||||
expect(getFileInfoTool.metadata.keywords).toContain('file');
|
||||
expect(getFileInfoTool.metadata.keywords).toContain('info');
|
||||
expect(getFileInfoTool.metadata.keywords).toContain('stat');
|
||||
});
|
||||
|
||||
it('定义了必需的 path 参数', () => {
|
||||
expect(getFileInfoTool.parameters.path.required).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功获取文件信息', async () => {
|
||||
const result = await getFileInfoTool.execute({ path: 'test.txt' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('路径:');
|
||||
expect(result.output).toContain('类型: 文件');
|
||||
expect(result.output).toContain('大小:');
|
||||
expect(result.output).toContain('权限:');
|
||||
expect(result.output).toContain('创建时间:');
|
||||
expect(result.output).toContain('修改时间:');
|
||||
expect(result.output).toContain('inode:');
|
||||
});
|
||||
|
||||
it('正确显示目录信息', async () => {
|
||||
vi.mocked(fs.stat).mockResolvedValue({
|
||||
...mockStats,
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
} as any);
|
||||
vi.mocked(fs.readdir).mockResolvedValue(['file1', 'file2', 'dir1'] as any);
|
||||
|
||||
const result = await getFileInfoTool.execute({ path: 'test_dir' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('类型: 目录');
|
||||
expect(result.output).toContain('子项数量: 3');
|
||||
});
|
||||
|
||||
it('正确显示符号链接信息', async () => {
|
||||
vi.mocked(fs.stat).mockResolvedValue({
|
||||
...mockStats,
|
||||
isSymbolicLink: () => true,
|
||||
isFile: () => false,
|
||||
} as any);
|
||||
vi.mocked(fs.readlink).mockResolvedValue('/real/path');
|
||||
|
||||
const result = await getFileInfoTool.execute({ path: 'link' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('类型: 符号链接');
|
||||
expect(result.output).toContain('链接目标: /real/path');
|
||||
});
|
||||
|
||||
it('正确格式化文件大小', async () => {
|
||||
// 测试不同大小
|
||||
const sizes = [
|
||||
{ size: 500, expected: 'B' },
|
||||
{ size: 1024, expected: 'KB' },
|
||||
{ size: 1024 * 1024, expected: 'MB' },
|
||||
{ size: 1024 * 1024 * 1024, expected: 'GB' },
|
||||
];
|
||||
|
||||
for (const { size, expected } of sizes) {
|
||||
vi.mocked(fs.stat).mockResolvedValue({
|
||||
...mockStats,
|
||||
size,
|
||||
} as any);
|
||||
|
||||
const result = await getFileInfoTool.execute({ path: 'test.txt' });
|
||||
|
||||
expect(result.output).toContain(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许获取信息',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await getFileInfoTool.execute({ path: '/protected/file' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await getFileInfoTool.execute({ path: 'file.txt' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('文件不存在返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
|
||||
vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await getFileInfoTool.execute({ path: 'nonexistent.txt' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('ENOENT');
|
||||
});
|
||||
|
||||
it('传递正确参数给权限检查', async () => {
|
||||
const mockCheck = vi.fn().mockResolvedValue({ allowed: true });
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: mockCheck,
|
||||
} as any);
|
||||
|
||||
await getFileInfoTool.execute({ path: 'test.txt' });
|
||||
|
||||
expect(mockCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operation: 'info',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '在文件内容中搜索文本'),
|
||||
}));
|
||||
|
||||
import { grepContentTool } from '../../../../src/tools/filesystem/grep_content.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('grepContentTool - 内容搜索工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(grepContentTool.name).toBe('grep_content');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(grepContentTool.metadata.category).toBe('filesystem');
|
||||
expect(grepContentTool.metadata.keywords).toContain('grep');
|
||||
expect(grepContentTool.metadata.keywords).toContain('search');
|
||||
expect(grepContentTool.metadata.keywords).toContain('content');
|
||||
});
|
||||
|
||||
it('定义了必需参数', () => {
|
||||
expect(grepContentTool.parameters.directory.required).toBe(true);
|
||||
expect(grepContentTool.parameters.pattern.required).toBe(true);
|
||||
});
|
||||
|
||||
it('定义了可选参数', () => {
|
||||
expect(grepContentTool.parameters.file_pattern.required).toBe(false);
|
||||
expect(grepContentTool.parameters.max_results.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功搜索并返回匹配结果', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'test.ts', isDirectory: () => false, isFile: () => true },
|
||||
] as any);
|
||||
vi.mocked(fs.readFile).mockResolvedValue('const hello = "world";\nconst foo = "bar";');
|
||||
|
||||
const result = await grepContentTool.execute({
|
||||
directory: '.',
|
||||
pattern: 'hello',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('找到');
|
||||
expect(result.output).toContain('hello');
|
||||
});
|
||||
|
||||
it('没有匹配时返回提示', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'test.ts', isDirectory: () => false, isFile: () => true },
|
||||
] as any);
|
||||
vi.mocked(fs.readFile).mockResolvedValue('const foo = "bar";');
|
||||
|
||||
const result = await grepContentTool.execute({
|
||||
directory: '.',
|
||||
pattern: 'notfound',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('没有找到匹配的内容');
|
||||
});
|
||||
|
||||
it('按文件模式过滤', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'test.ts', isDirectory: () => false, isFile: () => true },
|
||||
{ name: 'test.js', isDirectory: () => false, isFile: () => true },
|
||||
] as any);
|
||||
vi.mocked(fs.readFile).mockResolvedValue('const hello = "world";');
|
||||
|
||||
const result = await grepContentTool.execute({
|
||||
directory: '.',
|
||||
pattern: 'hello',
|
||||
file_pattern: '*.ts',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// 只搜索 .ts 文件
|
||||
expect(fs.readFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('限制最大结果数', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'test.ts', isDirectory: () => false, isFile: () => true },
|
||||
] as any);
|
||||
// 多行匹配内容
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
'hello1\nhello2\nhello3\nhello4\nhello5'
|
||||
);
|
||||
|
||||
const result = await grepContentTool.execute({
|
||||
directory: '.',
|
||||
pattern: 'hello',
|
||||
max_results: 2,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('已达上限');
|
||||
});
|
||||
|
||||
it('跳过隐藏文件和 node_modules', async () => {
|
||||
// 第一次调用返回根目录内容,第二次调用返回 src 目录内容
|
||||
vi.mocked(fs.readdir)
|
||||
.mockResolvedValueOnce([
|
||||
{ name: '.hidden', isDirectory: () => true, isFile: () => false },
|
||||
{ name: 'node_modules', isDirectory: () => true, isFile: () => false },
|
||||
{ name: 'src', isDirectory: () => true, isFile: () => false },
|
||||
] as any)
|
||||
.mockResolvedValueOnce([
|
||||
{ name: 'index.ts', isDirectory: () => false, isFile: () => true },
|
||||
] as any);
|
||||
vi.mocked(fs.readFile).mockResolvedValue('const test = 1;');
|
||||
|
||||
await grepContentTool.execute({
|
||||
directory: '.',
|
||||
pattern: 'test',
|
||||
});
|
||||
|
||||
// 不应该进入隐藏目录或 node_modules,只进入 src
|
||||
expect(fs.readdir).toHaveBeenCalledTimes(2); // 根目录 + src
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许搜索',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await grepContentTool.execute({
|
||||
directory: '/protected',
|
||||
pattern: 'test',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await grepContentTool.execute({
|
||||
directory: '.',
|
||||
pattern: 'test',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('递归搜索子目录', async () => {
|
||||
// 恢复权限检查
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
vi.mocked(fs.readdir)
|
||||
.mockResolvedValueOnce([
|
||||
{ name: 'src', isDirectory: () => true, isFile: () => false },
|
||||
] as any)
|
||||
.mockResolvedValueOnce([
|
||||
{ name: 'index.ts', isDirectory: () => false, isFile: () => true },
|
||||
] as any);
|
||||
vi.mocked(fs.readFile).mockResolvedValue('const test = 1;');
|
||||
|
||||
const result = await grepContentTool.execute({
|
||||
directory: '.',
|
||||
pattern: 'test',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.readdir).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('传递正确参数给权限检查', async () => {
|
||||
const mockCheck = vi.fn().mockResolvedValue({ allowed: true });
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: mockCheck,
|
||||
} as any);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([]);
|
||||
|
||||
await grepContentTool.execute({
|
||||
directory: 'src',
|
||||
pattern: 'test',
|
||||
});
|
||||
|
||||
expect(mockCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operation: 'grep',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { listDirTool } from '../../../../src/tools/filesystem/list_directory.js';
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readdir: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '列出目录内容'),
|
||||
}));
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('listDirTool - 列出目录工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(listDirTool.name).toBe('list_directory');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(listDirTool.metadata.category).toBe('filesystem');
|
||||
expect(listDirTool.metadata.keywords).toContain('list');
|
||||
expect(listDirTool.metadata.keywords).toContain('directory');
|
||||
expect(listDirTool.metadata.keywords).toContain('ls');
|
||||
});
|
||||
|
||||
it('定义了必需的 path 参数', () => {
|
||||
expect(listDirTool.parameters.path).toBeDefined();
|
||||
expect(listDirTool.parameters.path.required).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功列出目录内容', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'file1.txt', isDirectory: () => false },
|
||||
{ name: 'folder', isDirectory: () => true },
|
||||
{ name: 'file2.js', isDirectory: () => false },
|
||||
] as any);
|
||||
|
||||
const result = await listDirTool.execute({ path: './' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('file1.txt');
|
||||
expect(result.output).toContain('folder');
|
||||
expect(result.output).toContain('file2.js');
|
||||
});
|
||||
|
||||
it('使用正确的图标区分文件和目录', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'file.txt', isDirectory: () => false },
|
||||
{ name: 'folder', isDirectory: () => true },
|
||||
] as any);
|
||||
|
||||
const result = await listDirTool.execute({ path: './' });
|
||||
|
||||
expect(result.output).toMatch(/📄.*file\.txt/);
|
||||
expect(result.output).toMatch(/📁.*folder/);
|
||||
});
|
||||
|
||||
it('空目录显示提示', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([]);
|
||||
|
||||
const result = await listDirTool.execute({ path: './' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe('(空目录)');
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许列出此目录',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await listDirTool.execute({ path: '/etc' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await listDirTool.execute({ path: '/home/user' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('目录不存在时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
} as any);
|
||||
vi.mocked(fs.readdir).mockRejectedValue(new Error('ENOENT: no such directory'));
|
||||
|
||||
const result = await listDirTool.execute({ path: './nonexistent' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('ENOENT');
|
||||
});
|
||||
|
||||
it('使用 withFileTypes 选项调用 readdir', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([]);
|
||||
|
||||
await listDirTool.execute({ path: './' });
|
||||
|
||||
expect(fs.readdir).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ withFileTypes: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
stat: vi.fn(),
|
||||
rename: vi.fn().mockResolvedValue(undefined),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '移动文件'),
|
||||
}));
|
||||
|
||||
import { moveFileTool } from '../../../../src/tools/filesystem/move_file.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('moveFileTool - 文件移动工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(moveFileTool.name).toBe('move_file');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(moveFileTool.metadata.category).toBe('filesystem');
|
||||
expect(moveFileTool.metadata.keywords).toContain('move');
|
||||
expect(moveFileTool.metadata.keywords).toContain('rename');
|
||||
expect(moveFileTool.metadata.keywords).toContain('mv');
|
||||
});
|
||||
|
||||
it('定义了必需参数', () => {
|
||||
expect(moveFileTool.parameters.source.required).toBe(true);
|
||||
expect(moveFileTool.parameters.destination.required).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功移动文件', async () => {
|
||||
vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT')); // 目标不存在
|
||||
|
||||
const result = await moveFileTool.execute({
|
||||
source: 'old.txt',
|
||||
destination: 'new.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('已移动');
|
||||
expect(fs.rename).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('移动到已存在的目录', async () => {
|
||||
vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => true } as any);
|
||||
|
||||
const result = await moveFileTool.execute({
|
||||
source: 'file.txt',
|
||||
destination: '/target/dir',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('file.txt');
|
||||
});
|
||||
|
||||
it('源文件移动权限被拒绝', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许移动',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await moveFileTool.execute({
|
||||
source: '/protected/file',
|
||||
destination: 'dest.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('目标位置写入权限被拒绝', async () => {
|
||||
const mockCheck = vi.fn()
|
||||
.mockResolvedValueOnce({ allowed: true }) // 移动权限
|
||||
.mockResolvedValueOnce({ allowed: false, action: 'deny', reason: '不允许写入' }); // 写入权限
|
||||
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: mockCheck,
|
||||
} as any);
|
||||
|
||||
const result = await moveFileTool.execute({
|
||||
source: 'src.txt',
|
||||
destination: '/protected/dest.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await moveFileTool.execute({
|
||||
source: 'file.txt',
|
||||
destination: 'new.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('源文件不存在返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await moveFileTool.execute({
|
||||
source: 'nonexistent.txt',
|
||||
destination: 'dest.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('ENOENT');
|
||||
});
|
||||
|
||||
it('创建目标目录', async () => {
|
||||
// 确保权限检查通过
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
// 源文件存在
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
// 目标不存在
|
||||
vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await moveFileTool.execute({
|
||||
source: 'file.txt',
|
||||
destination: '/new/path/file.txt',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ recursive: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { readFileTool } from '../../../../src/tools/filesystem/read_file.js';
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '读取文件内容'),
|
||||
}));
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('readFileTool - 读取文件工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(readFileTool.name).toBe('read_file');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(readFileTool.metadata.category).toBe('filesystem');
|
||||
expect(readFileTool.metadata.keywords).toContain('read');
|
||||
expect(readFileTool.metadata.keywords).toContain('file');
|
||||
});
|
||||
|
||||
it('定义了必需的 path 参数', () => {
|
||||
expect(readFileTool.parameters.path).toBeDefined();
|
||||
expect(readFileTool.parameters.path.required).toBe(true);
|
||||
expect(readFileTool.parameters.path.type).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功读取文件', async () => {
|
||||
const mockContent = 'Hello, World!';
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockContent);
|
||||
|
||||
const result = await readFileTool.execute({ path: './test.txt' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe(mockContent);
|
||||
});
|
||||
|
||||
it('处理绝对路径', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('content');
|
||||
|
||||
await readFileTool.execute({ path: '/absolute/path/file.txt' });
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith('/absolute/path/file.txt', 'utf-8');
|
||||
});
|
||||
|
||||
it('处理相对路径', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('content');
|
||||
|
||||
await readFileTool.execute({ path: './relative/file.txt' });
|
||||
|
||||
// 应该解析为绝对路径
|
||||
expect(fs.readFile).toHaveBeenCalled();
|
||||
const calledPath = vi.mocked(fs.readFile).mock.calls[0][0] as string;
|
||||
expect(calledPath.endsWith('relative/file.txt')).toBe(true);
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许读取此文件',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await readFileTool.execute({ path: '/etc/passwd' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
reason: '需要确认',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await readFileTool.execute({ path: './sensitive.txt' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('文件不存在时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
} as any);
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT: no such file'));
|
||||
|
||||
const result = await readFileTool.execute({ path: './nonexistent.txt' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('ENOENT');
|
||||
});
|
||||
|
||||
it('读取大文件', async () => {
|
||||
const largeContent = 'x'.repeat(10000);
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
} as any);
|
||||
vi.mocked(fs.readFile).mockResolvedValue(largeContent);
|
||||
|
||||
const result = await readFileTool.execute({ path: './large.txt' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output.length).toBe(10000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readdir: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '按文件名搜索文件'),
|
||||
}));
|
||||
|
||||
import { searchFilesTool } from '../../../../src/tools/filesystem/search_files.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('searchFilesTool - 文件搜索工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(searchFilesTool.name).toBe('search_files');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(searchFilesTool.metadata.category).toBe('filesystem');
|
||||
expect(searchFilesTool.metadata.keywords).toContain('search');
|
||||
expect(searchFilesTool.metadata.keywords).toContain('find');
|
||||
expect(searchFilesTool.metadata.keywords).toContain('glob');
|
||||
});
|
||||
|
||||
it('定义了必需参数', () => {
|
||||
expect(searchFilesTool.parameters.directory.required).toBe(true);
|
||||
expect(searchFilesTool.parameters.pattern.required).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功搜索并返回匹配文件', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'test.ts', isDirectory: () => false, isFile: () => true },
|
||||
{ name: 'test.js', isDirectory: () => false, isFile: () => true },
|
||||
] as any);
|
||||
|
||||
const result = await searchFilesTool.execute({
|
||||
directory: '.',
|
||||
pattern: '*.ts',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('test.ts');
|
||||
expect(result.output).not.toContain('test.js');
|
||||
});
|
||||
|
||||
it('没有匹配时返回提示', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'test.js', isDirectory: () => false, isFile: () => true },
|
||||
] as any);
|
||||
|
||||
const result = await searchFilesTool.execute({
|
||||
directory: '.',
|
||||
pattern: '*.tsx',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('没有找到匹配的文件');
|
||||
});
|
||||
|
||||
it('递归搜索子目录', async () => {
|
||||
vi.mocked(fs.readdir)
|
||||
.mockResolvedValueOnce([
|
||||
{ name: 'src', isDirectory: () => true, isFile: () => false },
|
||||
{ name: 'index.ts', isDirectory: () => false, isFile: () => true },
|
||||
] as any)
|
||||
.mockResolvedValueOnce([
|
||||
{ name: 'app.ts', isDirectory: () => false, isFile: () => true },
|
||||
] as any);
|
||||
|
||||
const result = await searchFilesTool.execute({
|
||||
directory: '.',
|
||||
pattern: '*.ts',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('index.ts');
|
||||
expect(result.output).toContain('app.ts');
|
||||
});
|
||||
|
||||
it('跳过隐藏文件和 node_modules', async () => {
|
||||
// 第一次调用返回根目录内容,第二次调用返回 src 目录内容
|
||||
vi.mocked(fs.readdir)
|
||||
.mockResolvedValueOnce([
|
||||
{ name: '.git', isDirectory: () => true, isFile: () => false },
|
||||
{ name: 'node_modules', isDirectory: () => true, isFile: () => false },
|
||||
{ name: 'src', isDirectory: () => true, isFile: () => false },
|
||||
] as any)
|
||||
.mockResolvedValueOnce([
|
||||
{ name: 'index.ts', isDirectory: () => false, isFile: () => true },
|
||||
] as any);
|
||||
|
||||
await searchFilesTool.execute({
|
||||
directory: '.',
|
||||
pattern: '*',
|
||||
});
|
||||
|
||||
// 不应该进入隐藏目录或 node_modules,只进入 src
|
||||
expect(fs.readdir).toHaveBeenCalledTimes(2); // 根目录 + src
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许搜索',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await searchFilesTool.execute({
|
||||
directory: '/protected',
|
||||
pattern: '*',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await searchFilesTool.execute({
|
||||
directory: '.',
|
||||
pattern: '*',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('支持 glob 模式匹配', async () => {
|
||||
// 恢复权限检查
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'component.tsx', isDirectory: () => false, isFile: () => true },
|
||||
{ name: 'helper.ts', isDirectory: () => false, isFile: () => true },
|
||||
{ name: 'style.css', isDirectory: () => false, isFile: () => true },
|
||||
] as any);
|
||||
|
||||
// *.tsx 模式会匹配 component.tsx
|
||||
const result = await searchFilesTool.execute({
|
||||
directory: '.',
|
||||
pattern: '*.tsx',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('component.tsx');
|
||||
expect(result.output).not.toContain('style.css');
|
||||
});
|
||||
|
||||
it('传递正确参数给权限检查', async () => {
|
||||
const mockCheck = vi.fn().mockResolvedValue({ allowed: true });
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: mockCheck,
|
||||
} as any);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([]);
|
||||
|
||||
await searchFilesTool.execute({
|
||||
directory: 'src',
|
||||
pattern: '*.ts',
|
||||
});
|
||||
|
||||
expect(mockCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operation: 'search',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
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
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '写入文件内容'),
|
||||
}));
|
||||
|
||||
// Mock LSP
|
||||
vi.mock('../../../../src/lsp/index.js', () => ({
|
||||
touchFile: vi.fn().mockResolvedValue(false),
|
||||
getFormattedFileDiagnostics: vi.fn().mockResolvedValue(null),
|
||||
isLanguageSupported: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('writeFileTool - 写入文件工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(writeFileTool.name).toBe('write_file');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(writeFileTool.metadata.category).toBe('filesystem');
|
||||
expect(writeFileTool.metadata.keywords).toContain('write');
|
||||
expect(writeFileTool.metadata.keywords).toContain('save');
|
||||
});
|
||||
|
||||
it('定义了必需的参数', () => {
|
||||
expect(writeFileTool.parameters.path).toBeDefined();
|
||||
expect(writeFileTool.parameters.path.required).toBe(true);
|
||||
expect(writeFileTool.parameters.content).toBeDefined();
|
||||
expect(writeFileTool.parameters.content.required).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功写入文件', async () => {
|
||||
const result = await writeFileTool.execute({
|
||||
path: './test.txt',
|
||||
content: 'Hello, World!',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('文件已写入');
|
||||
});
|
||||
|
||||
it('创建必要的目录', async () => {
|
||||
await writeFileTool.execute({
|
||||
path: './deep/nested/file.txt',
|
||||
content: 'content',
|
||||
});
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ recursive: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许写入此文件',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await writeFileTool.execute({
|
||||
path: '/etc/passwd',
|
||||
content: 'malicious',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await writeFileTool.execute({
|
||||
path: './new-file.txt',
|
||||
content: 'content',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('写入失败时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
} as any);
|
||||
vi.mocked(fs.writeFile).mockRejectedValue(new Error('Write failed'));
|
||||
|
||||
const result = await writeFileTool.execute({
|
||||
path: './test.txt',
|
||||
content: 'content',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Write failed');
|
||||
});
|
||||
|
||||
it('传递 newContent 给权限检查', async () => {
|
||||
const mockCheck = vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
});
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkFilePermission: mockCheck,
|
||||
} as any);
|
||||
|
||||
await writeFileTool.execute({
|
||||
path: './test.txt',
|
||||
content: 'new content',
|
||||
});
|
||||
|
||||
expect(mockCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operation: 'write',
|
||||
newContent: 'new content',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// 定义一个可控的 mock
|
||||
let mockExecAsyncResult: { stdout: string; stderr: string } | Error = {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
};
|
||||
|
||||
// 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().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => 'Git add 命令'),
|
||||
}));
|
||||
|
||||
import { gitAddTool } from '../../../../src/tools/git/git_add.js';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('gitAddTool - Git Add 工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExecAsyncResult = {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
};
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(gitAddTool.name).toBe('git_add');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(gitAddTool.metadata.category).toBe('git');
|
||||
expect(gitAddTool.metadata.keywords).toContain('add');
|
||||
expect(gitAddTool.metadata.keywords).toContain('stage');
|
||||
});
|
||||
|
||||
it('定义了可选参数', () => {
|
||||
expect(gitAddTool.parameters.files.required).toBe(false);
|
||||
expect(gitAddTool.parameters.all.required).toBe(false);
|
||||
expect(gitAddTool.parameters.update.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('暂存所有文件 (all: true)', async () => {
|
||||
mockExecAsyncResult = { stdout: '', stderr: '' };
|
||||
|
||||
const result = await gitAddTool.execute({ all: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('文件已暂存');
|
||||
});
|
||||
|
||||
it('暂存指定文件', async () => {
|
||||
mockExecAsyncResult = { stdout: '', stderr: '' };
|
||||
|
||||
const result = await gitAddTool.execute({
|
||||
files: ['file1.txt', 'file2.txt'],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('暂存单个文件(字符串)', async () => {
|
||||
mockExecAsyncResult = { stdout: '', stderr: '' };
|
||||
|
||||
const result = await gitAddTool.execute({ files: 'single.txt' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('使用 update 选项', async () => {
|
||||
mockExecAsyncResult = { stdout: '', stderr: '' };
|
||||
|
||||
const result = await gitAddTool.execute({ update: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('无参数返回错误', async () => {
|
||||
const result = await gitAddTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('请指定要暂存的文件');
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '操作不被允许',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitAddTool.execute({ all: true });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitAddTool.execute({ all: true });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('命令执行失败返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
} as any);
|
||||
mockExecAsyncResult = Object.assign(
|
||||
new Error('Command failed'),
|
||||
{ stdout: '', stderr: 'fatal: not a git repository', message: 'Command failed' }
|
||||
);
|
||||
|
||||
const result = await gitAddTool.execute({ all: true });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not a git repository');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock child_process
|
||||
const mockExec = vi.fn();
|
||||
vi.mock('child_process', () => ({
|
||||
exec: (cmd: string, opts: any, cb?: Function) => {
|
||||
if (typeof opts === 'function') {
|
||||
cb = opts;
|
||||
}
|
||||
// 使用 setImmediate 模拟异步
|
||||
setImmediate(() => {
|
||||
const result = mockExec(cmd);
|
||||
if (result.error) {
|
||||
const err = result.error;
|
||||
err.stdout = result.stdout || '';
|
||||
err.stderr = result.stderr || '';
|
||||
cb?.(err, result.stdout || '', result.stderr || '');
|
||||
} else {
|
||||
cb?.(null, result.stdout || '', result.stderr || '');
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '管理 Git 分支'),
|
||||
}));
|
||||
|
||||
import { gitBranchTool } from '../../../../src/tools/git/git_branch.js';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('gitBranchTool - Git 分支工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExec.mockReturnValue({ stdout: '* main\n develop', stderr: '' });
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(gitBranchTool.name).toBe('git_branch');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(gitBranchTool.metadata.category).toBe('git');
|
||||
expect(gitBranchTool.metadata.keywords).toContain('branch');
|
||||
expect(gitBranchTool.metadata.keywords).toContain('create');
|
||||
expect(gitBranchTool.metadata.keywords).toContain('delete');
|
||||
});
|
||||
|
||||
it('定义了可选参数', () => {
|
||||
expect(gitBranchTool.parameters.action.required).toBe(false);
|
||||
expect(gitBranchTool.parameters.name.required).toBe(false);
|
||||
expect(gitBranchTool.parameters.new_name.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('列出分支(默认操作)', async () => {
|
||||
const result = await gitBranchTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// 源代码使用 -v 参数,输出可能包含 main 或在空结果时返回空字符串
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git branch'));
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('-v'));
|
||||
});
|
||||
|
||||
it('创建新分支', async () => {
|
||||
mockExec.mockReturnValue({ stdout: '', stderr: '' });
|
||||
|
||||
const result = await gitBranchTool.execute({
|
||||
action: 'create',
|
||||
name: 'feature/new-branch',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git branch feature/new-branch'));
|
||||
});
|
||||
|
||||
it('删除分支', async () => {
|
||||
mockExec.mockReturnValue({ stdout: '', stderr: '' });
|
||||
|
||||
const result = await gitBranchTool.execute({
|
||||
action: 'delete',
|
||||
name: 'old-branch',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git branch -d old-branch'));
|
||||
});
|
||||
|
||||
it('强制删除分支', async () => {
|
||||
mockExec.mockReturnValue({ stdout: '', stderr: '' });
|
||||
|
||||
const result = await gitBranchTool.execute({
|
||||
action: 'delete',
|
||||
name: 'unmerged-branch',
|
||||
force: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git branch -D unmerged-branch'));
|
||||
});
|
||||
|
||||
it('重命名分支', async () => {
|
||||
mockExec.mockReturnValue({ stdout: '', stderr: '' });
|
||||
|
||||
const result = await gitBranchTool.execute({
|
||||
action: 'rename',
|
||||
name: 'old-name',
|
||||
new_name: 'new-name',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git branch -m old-name new-name'));
|
||||
});
|
||||
|
||||
it('显示远程分支', async () => {
|
||||
mockExec.mockReturnValue({ stdout: 'origin/main\norigin/develop', stderr: '' });
|
||||
|
||||
const result = await gitBranchTool.execute({
|
||||
action: 'list',
|
||||
remote: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git branch -r'));
|
||||
});
|
||||
|
||||
it('显示所有分支', async () => {
|
||||
const result = await gitBranchTool.execute({
|
||||
action: 'list',
|
||||
all: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git branch -a'));
|
||||
});
|
||||
|
||||
it('创建分支缺少名称返回错误', async () => {
|
||||
const result = await gitBranchTool.execute({
|
||||
action: 'create',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要提供分支名称');
|
||||
});
|
||||
|
||||
it('删除分支缺少名称返回错误', async () => {
|
||||
const result = await gitBranchTool.execute({
|
||||
action: 'delete',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要提供分支名称');
|
||||
});
|
||||
|
||||
it('重命名缺少参数返回错误', async () => {
|
||||
const result = await gitBranchTool.execute({
|
||||
action: 'rename',
|
||||
name: 'old-name',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('新名称');
|
||||
});
|
||||
|
||||
it('未知操作返回错误', async () => {
|
||||
const result = await gitBranchTool.execute({
|
||||
action: 'unknown',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('未知操作');
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许操作分支',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitBranchTool.execute({
|
||||
action: 'create',
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitBranchTool.execute({
|
||||
action: 'delete',
|
||||
name: 'branch',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('Git 命令失败返回错误', async () => {
|
||||
// 恢复权限检查
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
mockExec.mockReturnValue({
|
||||
error: new Error('Command failed'),
|
||||
stdout: '',
|
||||
stderr: 'fatal: not a git repository',
|
||||
});
|
||||
|
||||
const result = await gitBranchTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not a git repository');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock child_process
|
||||
const mockExec = vi.fn();
|
||||
vi.mock('child_process', () => ({
|
||||
exec: (cmd: string, opts: any, cb?: Function) => {
|
||||
if (typeof opts === 'function') {
|
||||
cb = opts;
|
||||
}
|
||||
setImmediate(() => {
|
||||
const result = mockExec(cmd);
|
||||
if (result.error) {
|
||||
const err = result.error;
|
||||
err.stdout = result.stdout || '';
|
||||
err.stderr = result.stderr || '';
|
||||
cb?.(err, result.stdout || '', result.stderr || '');
|
||||
} else {
|
||||
cb?.(null, result.stdout || '', result.stderr || '');
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '切换分支或恢复文件'),
|
||||
}));
|
||||
|
||||
import { gitCheckoutTool } from '../../../../src/tools/git/git_checkout.js';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('gitCheckoutTool - Git Checkout 工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExec.mockReturnValue({ stdout: '', stderr: "Switched to branch 'develop'" });
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(gitCheckoutTool.name).toBe('git_checkout');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(gitCheckoutTool.metadata.category).toBe('git');
|
||||
expect(gitCheckoutTool.metadata.keywords).toContain('checkout');
|
||||
expect(gitCheckoutTool.metadata.keywords).toContain('switch');
|
||||
});
|
||||
|
||||
it('定义了必需和可选参数', () => {
|
||||
expect(gitCheckoutTool.parameters.target.required).toBe(true);
|
||||
expect(gitCheckoutTool.parameters.create.required).toBe(false);
|
||||
expect(gitCheckoutTool.parameters.force.required).toBe(false);
|
||||
expect(gitCheckoutTool.parameters.file.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功切换分支', async () => {
|
||||
const result = await gitCheckoutTool.execute({
|
||||
target: 'develop',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('develop');
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git checkout develop'));
|
||||
});
|
||||
|
||||
it('创建并切换到新分支', async () => {
|
||||
mockExec.mockReturnValue({ stdout: '', stderr: "Switched to a new branch 'feature/test'" });
|
||||
|
||||
const result = await gitCheckoutTool.execute({
|
||||
target: 'feature/test',
|
||||
create: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git checkout -b feature/test'));
|
||||
});
|
||||
|
||||
it('强制切换分支', async () => {
|
||||
const result = await gitCheckoutTool.execute({
|
||||
target: 'develop',
|
||||
force: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git checkout -f develop'));
|
||||
});
|
||||
|
||||
it('恢复文件', async () => {
|
||||
mockExec.mockReturnValue({ stdout: '', stderr: '' });
|
||||
|
||||
const result = await gitCheckoutTool.execute({
|
||||
target: 'src/index.ts',
|
||||
file: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git checkout -- src/index.ts'));
|
||||
});
|
||||
|
||||
it('缺少 target 返回错误', async () => {
|
||||
const result = await gitCheckoutTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('请指定目标');
|
||||
});
|
||||
|
||||
it('本地变更冲突时返回友好错误', async () => {
|
||||
// 确保权限通过
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
mockExec.mockReturnValue({
|
||||
error: new Error('Command failed'),
|
||||
stdout: '',
|
||||
stderr: 'error: Your local changes would be overwritten by checkout',
|
||||
});
|
||||
|
||||
const result = await gitCheckoutTool.execute({
|
||||
target: 'main',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('本地有未提交的变更');
|
||||
expect(result.error).toContain('force: true');
|
||||
});
|
||||
|
||||
it('分支不存在时返回友好错误', async () => {
|
||||
// 确保权限通过
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
mockExec.mockReturnValue({
|
||||
error: new Error('Command failed'),
|
||||
stdout: '',
|
||||
stderr: "error: pathspec 'nonexistent' did not match",
|
||||
});
|
||||
|
||||
const result = await gitCheckoutTool.execute({
|
||||
target: 'nonexistent',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('找不到分支或文件');
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许切换分支',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitCheckoutTool.execute({
|
||||
target: 'main',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitCheckoutTool.execute({
|
||||
target: 'develop',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// 定义一个可控的 mock
|
||||
let mockExecAsyncResult: { stdout: string; stderr: string } | Error = {
|
||||
stdout: '[main abc1234] test commit\n1 file changed',
|
||||
stderr: '',
|
||||
};
|
||||
|
||||
// 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().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// 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\n1 file changed',
|
||||
stderr: '',
|
||||
};
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(gitCommitTool.name).toBe('git_commit');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(gitCommitTool.metadata.category).toBe('git');
|
||||
expect(gitCommitTool.metadata.keywords).toContain('commit');
|
||||
});
|
||||
|
||||
it('定义了必需的 message 参数', () => {
|
||||
expect(gitCommitTool.parameters.message).toBeDefined();
|
||||
expect(gitCommitTool.parameters.message.required).toBe(true);
|
||||
});
|
||||
|
||||
it('定义了可选参数', () => {
|
||||
expect(gitCommitTool.parameters.amend).toBeDefined();
|
||||
expect(gitCommitTool.parameters.amend.required).toBe(false);
|
||||
expect(gitCommitTool.parameters.all).toBeDefined();
|
||||
expect(gitCommitTool.parameters.all.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功提交', async () => {
|
||||
const result = await gitCommitTool.execute({ message: 'test commit' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('test commit');
|
||||
});
|
||||
|
||||
it('无消息且无 amend 返回错误', async () => {
|
||||
const result = await gitCommitTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('提交信息是必填的');
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '操作不被允许',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitCommitTool.execute({ message: 'test' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitCommitTool.execute({ message: 'test' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('无变更可提交时返回友好提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
} as any);
|
||||
mockExecAsyncResult = Object.assign(
|
||||
new Error('nothing to commit'),
|
||||
{ stderr: 'nothing to commit, working tree clean', stdout: '', message: 'nothing to commit' }
|
||||
);
|
||||
|
||||
const result = await gitCommitTool.execute({ message: 'test' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
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: 'test message' });
|
||||
|
||||
expect(mockCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operation: 'commit',
|
||||
message: 'test message',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// 定义一个可控的 mock
|
||||
let mockExecAsyncResult: { stdout: string; stderr: string } | Error = {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
};
|
||||
|
||||
// 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().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => 'Git diff 命令'),
|
||||
}));
|
||||
|
||||
import { gitDiffTool } from '../../../../src/tools/git/git_diff.js';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('gitDiffTool - Git Diff 工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExecAsyncResult = {
|
||||
stdout: 'diff --git a/file.txt b/file.txt\n-old\n+new',
|
||||
stderr: '',
|
||||
};
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(gitDiffTool.name).toBe('git_diff');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(gitDiffTool.metadata.category).toBe('git');
|
||||
expect(gitDiffTool.metadata.keywords).toContain('diff');
|
||||
expect(gitDiffTool.metadata.keywords).toContain('compare');
|
||||
});
|
||||
|
||||
it('定义了可选参数', () => {
|
||||
expect(gitDiffTool.parameters.path.required).toBe(false);
|
||||
expect(gitDiffTool.parameters.staged.required).toBe(false);
|
||||
expect(gitDiffTool.parameters.commit.required).toBe(false);
|
||||
expect(gitDiffTool.parameters.stat.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功获取差异', async () => {
|
||||
const result = await gitDiffTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('diff');
|
||||
});
|
||||
|
||||
it('无差异时显示提示', async () => {
|
||||
mockExecAsyncResult = { stdout: '', stderr: '' };
|
||||
|
||||
const result = await gitDiffTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('无差异');
|
||||
});
|
||||
|
||||
it('显示暂存区差异', async () => {
|
||||
mockExecAsyncResult = { stdout: 'staged changes', stderr: '' };
|
||||
|
||||
const result = await gitDiffTool.execute({ staged: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('staged changes');
|
||||
});
|
||||
|
||||
it('与指定提交对比', async () => {
|
||||
mockExecAsyncResult = { stdout: 'commit diff', stderr: '' };
|
||||
|
||||
const result = await gitDiffTool.execute({ commit: 'HEAD~1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('指定文件路径', async () => {
|
||||
mockExecAsyncResult = { stdout: 'file diff', stderr: '' };
|
||||
|
||||
const result = await gitDiffTool.execute({ path: 'src/file.ts' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('仅显示统计信息', async () => {
|
||||
mockExecAsyncResult = {
|
||||
stdout: ' file.txt | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)',
|
||||
stderr: '',
|
||||
};
|
||||
|
||||
const result = await gitDiffTool.execute({ stat: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('file changed');
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '操作不被允许',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitDiffTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitDiffTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('命令执行失败返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
} as any);
|
||||
mockExecAsyncResult = Object.assign(
|
||||
new Error('Command failed'),
|
||||
{ stdout: '', stderr: 'fatal: not a git repository', message: 'Command failed' }
|
||||
);
|
||||
|
||||
const result = await gitDiffTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not a git repository');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// 定义一个可控的 mock
|
||||
let mockExecAsyncResult: { stdout: string; stderr: string } | Error = {
|
||||
stdout: 'abc123 Fix bug (John, 2 days ago)\ndef456 Add feature (Jane, 3 days ago)',
|
||||
stderr: '',
|
||||
};
|
||||
|
||||
// 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().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '查看 Git 提交历史'),
|
||||
}));
|
||||
|
||||
import { gitLogTool } from '../../../../src/tools/git/git_log.js';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('gitLogTool - Git Log 工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExecAsyncResult = {
|
||||
stdout: 'abc123 Fix bug (John, 2 days ago)\ndef456 Add feature (Jane, 3 days ago)',
|
||||
stderr: '',
|
||||
};
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(gitLogTool.name).toBe('git_log');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(gitLogTool.metadata.category).toBe('git');
|
||||
expect(gitLogTool.metadata.keywords).toContain('log');
|
||||
expect(gitLogTool.metadata.keywords).toContain('history');
|
||||
expect(gitLogTool.metadata.keywords).toContain('commit');
|
||||
});
|
||||
|
||||
it('所有参数都是可选的', () => {
|
||||
expect(gitLogTool.parameters.limit.required).toBe(false);
|
||||
expect(gitLogTool.parameters.oneline.required).toBe(false);
|
||||
expect(gitLogTool.parameters.file.required).toBe(false);
|
||||
expect(gitLogTool.parameters.author.required).toBe(false);
|
||||
expect(gitLogTool.parameters.since.required).toBe(false);
|
||||
expect(gitLogTool.parameters.graph.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功获取提交历史', async () => {
|
||||
const result = await gitLogTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('abc123');
|
||||
expect(result.output).toContain('Fix bug');
|
||||
});
|
||||
|
||||
it('使用自定义 limit', async () => {
|
||||
const result = await gitLogTool.execute({ limit: 5 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('使用 oneline 格式', async () => {
|
||||
const result = await gitLogTool.execute({ oneline: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('显示分支图', async () => {
|
||||
const result = await gitLogTool.execute({ graph: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('按作者筛选', async () => {
|
||||
const result = await gitLogTool.execute({ author: 'John' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('按日期筛选', async () => {
|
||||
const result = await gitLogTool.execute({ since: '2024-01-01' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('查看指定文件的历史', async () => {
|
||||
const result = await gitLogTool.execute({ file: 'src/index.ts' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('没有提交记录时返回提示', async () => {
|
||||
mockExecAsyncResult = { stdout: '', stderr: '' };
|
||||
|
||||
const result = await gitLogTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('无提交记录');
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许查看历史',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitLogTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitLogTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('Git 命令失败返回错误', async () => {
|
||||
// 恢复权限检查
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
mockExecAsyncResult = Object.assign(
|
||||
new Error('Command failed'),
|
||||
{ stdout: '', stderr: 'fatal: not a git repository' }
|
||||
);
|
||||
|
||||
const result = await gitLogTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not a git repository');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// 定义一个可控的 mock
|
||||
let mockExecAsyncResult: { stdout: string; stderr: string } | Error = {
|
||||
stdout: 'Already up to date.',
|
||||
stderr: '',
|
||||
};
|
||||
|
||||
// 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().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '从远程仓库拉取更新'),
|
||||
}));
|
||||
|
||||
import { gitPullTool } from '../../../../src/tools/git/git_pull.js';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('gitPullTool - Git Pull 工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExecAsyncResult = {
|
||||
stdout: 'Already up to date.',
|
||||
stderr: '',
|
||||
};
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(gitPullTool.name).toBe('git_pull');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(gitPullTool.metadata.category).toBe('git');
|
||||
expect(gitPullTool.metadata.keywords).toContain('pull');
|
||||
expect(gitPullTool.metadata.keywords).toContain('fetch');
|
||||
});
|
||||
|
||||
it('所有参数都是可选的', () => {
|
||||
expect(gitPullTool.parameters.remote.required).toBe(false);
|
||||
expect(gitPullTool.parameters.branch.required).toBe(false);
|
||||
expect(gitPullTool.parameters.rebase.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功拉取更新', async () => {
|
||||
const result = await gitPullTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('Already up to date');
|
||||
});
|
||||
|
||||
it('指定远程仓库和分支', async () => {
|
||||
const result = await gitPullTool.execute({
|
||||
remote: 'upstream',
|
||||
branch: 'develop',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('使用 rebase 模式', async () => {
|
||||
const result = await gitPullTool.execute({ rebase: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('合并冲突时返回友好错误', async () => {
|
||||
mockExecAsyncResult = Object.assign(
|
||||
new Error('Command failed'),
|
||||
{ stdout: '', stderr: 'CONFLICT (content): Merge conflict in file.txt' }
|
||||
);
|
||||
|
||||
const result = await gitPullTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('合并冲突');
|
||||
expect(result.error).toContain('手动解决');
|
||||
});
|
||||
|
||||
it('本地变更时返回友好错误', async () => {
|
||||
mockExecAsyncResult = Object.assign(
|
||||
new Error('Command failed'),
|
||||
{ stdout: '', stderr: 'error: Your local changes would be overwritten by merge' }
|
||||
);
|
||||
|
||||
const result = await gitPullTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('未提交的变更');
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许拉取',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitPullTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitPullTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('Git 命令失败返回错误', async () => {
|
||||
// 恢复权限检查(因为之前的测试可能修改了 mock)
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
mockExecAsyncResult = Object.assign(
|
||||
new Error('Command failed'),
|
||||
{ stdout: '', stderr: 'fatal: not a git repository' }
|
||||
);
|
||||
|
||||
const result = await gitPullTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not a git repository');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock child_process
|
||||
const mockExec = vi.fn();
|
||||
vi.mock('child_process', () => ({
|
||||
exec: (cmd: string, opts: any, cb?: Function) => {
|
||||
if (typeof opts === 'function') {
|
||||
cb = opts;
|
||||
}
|
||||
setImmediate(() => {
|
||||
const result = mockExec(cmd);
|
||||
if (result.error) {
|
||||
const err = result.error;
|
||||
err.stdout = result.stdout || '';
|
||||
err.stderr = result.stderr || '';
|
||||
cb?.(err, result.stdout || '', result.stderr || '');
|
||||
} else {
|
||||
cb?.(null, result.stdout || '', result.stderr || '');
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '推送 Git 变更到远程仓库'),
|
||||
}));
|
||||
|
||||
import { gitPushTool } from '../../../../src/tools/git/git_push.js';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('gitPushTool - Git Push 工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExec.mockReturnValue({
|
||||
stdout: '',
|
||||
stderr: 'Everything up-to-date',
|
||||
});
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(gitPushTool.name).toBe('git_push');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(gitPushTool.metadata.category).toBe('git');
|
||||
expect(gitPushTool.metadata.keywords).toContain('push');
|
||||
expect(gitPushTool.metadata.keywords).toContain('upload');
|
||||
});
|
||||
|
||||
it('所有参数都是可选的', () => {
|
||||
expect(gitPushTool.parameters.remote.required).toBe(false);
|
||||
expect(gitPushTool.parameters.branch.required).toBe(false);
|
||||
expect(gitPushTool.parameters.force.required).toBe(false);
|
||||
expect(gitPushTool.parameters.set_upstream.required).toBe(false);
|
||||
expect(gitPushTool.parameters.tags.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功推送', async () => {
|
||||
const result = await gitPushTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// 源代码: stdout || stderr || '推送成功'
|
||||
expect(result.output).toContain('推送成功');
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git push origin'));
|
||||
});
|
||||
|
||||
it('指定远程仓库和分支', async () => {
|
||||
const result = await gitPushTool.execute({
|
||||
remote: 'upstream',
|
||||
branch: 'develop',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git push upstream develop'));
|
||||
});
|
||||
|
||||
it('设置上游分支', async () => {
|
||||
const result = await gitPushTool.execute({ set_upstream: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git push -u'));
|
||||
});
|
||||
|
||||
it('强制推送', async () => {
|
||||
const result = await gitPushTool.execute({ force: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git push --force'));
|
||||
});
|
||||
|
||||
it('推送标签', async () => {
|
||||
const result = await gitPushTool.execute({ tags: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git push --tags'));
|
||||
});
|
||||
|
||||
it('推送被拒绝时返回友好错误', async () => {
|
||||
// 确保权限通过
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
mockExec.mockReturnValue({
|
||||
error: new Error('Command failed'),
|
||||
stdout: '',
|
||||
stderr: '! [rejected] main -> main (fetch first)',
|
||||
});
|
||||
|
||||
const result = await gitPushTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('推送被拒绝');
|
||||
expect(result.error).toContain('git_pull');
|
||||
});
|
||||
|
||||
it('没有上游分支时返回友好错误', async () => {
|
||||
// 确保权限通过
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
mockExec.mockReturnValue({
|
||||
error: new Error('Command failed'),
|
||||
stdout: '',
|
||||
stderr: 'fatal: The current branch has no upstream branch',
|
||||
});
|
||||
|
||||
const result = await gitPushTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('没有设置上游分支');
|
||||
expect(result.error).toContain('set_upstream: true');
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许推送',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitPushTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitPushTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('Git 命令失败返回错误', async () => {
|
||||
// 恢复权限检查
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
mockExec.mockReturnValue({
|
||||
error: new Error('Command failed'),
|
||||
stdout: '',
|
||||
stderr: 'fatal: not a git repository',
|
||||
});
|
||||
|
||||
const result = await gitPushTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not a git repository');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock child_process
|
||||
const mockExec = vi.fn();
|
||||
vi.mock('child_process', () => ({
|
||||
exec: (cmd: string, opts: any, cb?: Function) => {
|
||||
if (typeof opts === 'function') {
|
||||
cb = opts;
|
||||
}
|
||||
setImmediate(() => {
|
||||
const result = mockExec(cmd);
|
||||
if (result.error) {
|
||||
const err = result.error;
|
||||
err.stdout = result.stdout || '';
|
||||
err.stderr = result.stderr || '';
|
||||
cb?.(err, result.stdout || '', result.stderr || '');
|
||||
} else {
|
||||
cb?.(null, result.stdout || '', result.stderr || '');
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '暂存工作区变更'),
|
||||
}));
|
||||
|
||||
import { gitStashTool } from '../../../../src/tools/git/git_stash.js';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('gitStashTool - Git Stash 工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExec.mockReturnValue({ stdout: 'Saved working directory', stderr: '' });
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(gitStashTool.name).toBe('git_stash');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(gitStashTool.metadata.category).toBe('git');
|
||||
expect(gitStashTool.metadata.keywords).toContain('stash');
|
||||
expect(gitStashTool.metadata.keywords).toContain('save');
|
||||
});
|
||||
|
||||
it('所有参数都是可选的', () => {
|
||||
expect(gitStashTool.parameters.action.required).toBe(false);
|
||||
expect(gitStashTool.parameters.message.required).toBe(false);
|
||||
expect(gitStashTool.parameters.index.required).toBe(false);
|
||||
expect(gitStashTool.parameters.include_untracked.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('默认 push 操作暂存变更', async () => {
|
||||
const result = await gitStashTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git stash push'));
|
||||
});
|
||||
|
||||
it('带消息暂存', async () => {
|
||||
const result = await gitStashTool.execute({
|
||||
message: 'WIP: feature',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('-m'));
|
||||
});
|
||||
|
||||
it('包含未跟踪文件', async () => {
|
||||
const result = await gitStashTool.execute({
|
||||
include_untracked: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('-u'));
|
||||
});
|
||||
|
||||
it('列出暂存', async () => {
|
||||
mockExec.mockReturnValue({
|
||||
stdout: 'stash@{0}: WIP on main\nstash@{1}: feature',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
const result = await gitStashTool.execute({ action: 'list' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git stash list'));
|
||||
});
|
||||
|
||||
it('恢复暂存 (pop)', async () => {
|
||||
mockExec.mockReturnValue({ stdout: '', stderr: '' });
|
||||
|
||||
const result = await gitStashTool.execute({ action: 'pop' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('暂存已恢复并删除');
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git stash pop'));
|
||||
});
|
||||
|
||||
it('应用暂存 (apply)', async () => {
|
||||
mockExec.mockReturnValue({ stdout: '', stderr: '' });
|
||||
|
||||
const result = await gitStashTool.execute({ action: 'apply' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('暂存已恢复');
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git stash apply'));
|
||||
});
|
||||
|
||||
it('删除暂存 (drop)', async () => {
|
||||
mockExec.mockReturnValue({ stdout: '', stderr: '' });
|
||||
|
||||
const result = await gitStashTool.execute({ action: 'drop', index: 1 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git stash drop stash@{1}'));
|
||||
});
|
||||
|
||||
it('清除所有暂存', async () => {
|
||||
mockExec.mockReturnValue({ stdout: '', stderr: '' });
|
||||
|
||||
const result = await gitStashTool.execute({ action: 'clear' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('所有暂存已清除');
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git stash clear'));
|
||||
});
|
||||
|
||||
it('显示暂存内容', async () => {
|
||||
mockExec.mockReturnValue({ stdout: 'diff --git a/file.ts', stderr: '' });
|
||||
|
||||
const result = await gitStashTool.execute({ action: 'show' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('git stash show -p'));
|
||||
});
|
||||
|
||||
it('未知操作返回错误', async () => {
|
||||
const result = await gitStashTool.execute({ action: 'unknown' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('未知操作');
|
||||
});
|
||||
|
||||
it('没有变更时返回友好错误', async () => {
|
||||
// 确保权限通过
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
mockExec.mockReturnValue({
|
||||
error: new Error('Command failed'),
|
||||
stdout: '',
|
||||
stderr: 'No local changes to save',
|
||||
});
|
||||
|
||||
const result = await gitStashTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('没有需要暂存的变更');
|
||||
});
|
||||
|
||||
it('恢复冲突时返回友好错误', async () => {
|
||||
// 确保权限通过
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
mockExec.mockReturnValue({
|
||||
error: new Error('Command failed'),
|
||||
stdout: '',
|
||||
stderr: 'CONFLICT (content): Merge conflict',
|
||||
});
|
||||
|
||||
const result = await gitStashTool.execute({ action: 'pop' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('冲突');
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '不允许暂存操作',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitStashTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitStashTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// 定义一个可控的 mock
|
||||
let mockExecAsyncResult: { stdout: string; stderr: string } | Error = {
|
||||
stdout: 'On branch main\nnothing to commit',
|
||||
stderr: '',
|
||||
};
|
||||
|
||||
// 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().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '查看 Git 仓库状态'),
|
||||
}));
|
||||
|
||||
import { gitStatusTool } from '../../../../src/tools/git/git_status.js';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('gitStatusTool - Git 状态工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExecAsyncResult = {
|
||||
stdout: 'On branch main\nnothing to commit',
|
||||
stderr: '',
|
||||
};
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(gitStatusTool.name).toBe('git_status');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(gitStatusTool.metadata.category).toBe('git');
|
||||
expect(gitStatusTool.metadata.keywords).toContain('git');
|
||||
expect(gitStatusTool.metadata.keywords).toContain('status');
|
||||
});
|
||||
|
||||
it('定义了可选参数', () => {
|
||||
expect(gitStatusTool.parameters.short).toBeDefined();
|
||||
expect(gitStatusTool.parameters.short.required).toBe(false);
|
||||
expect(gitStatusTool.parameters.branch).toBeDefined();
|
||||
expect(gitStatusTool.parameters.branch.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功获取状态', async () => {
|
||||
const result = await gitStatusTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('On branch main');
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '操作不被允许',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitStatusTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await gitStatusTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('非 git 仓库返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkGitPermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
} as any);
|
||||
mockExecAsyncResult = Object.assign(
|
||||
new Error('Command failed'),
|
||||
{ stderr: 'fatal: not a git repository', stdout: '' }
|
||||
);
|
||||
|
||||
const result = await gitStatusTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not a git repository');
|
||||
});
|
||||
|
||||
it('包含 stderr 输出', async () => {
|
||||
mockExecAsyncResult = {
|
||||
stdout: 'main output',
|
||||
stderr: 'warning message',
|
||||
};
|
||||
|
||||
const result = await gitStatusTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('main output');
|
||||
expect(result.output).toContain('warning message');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ToolRegistry } from '../../../src/tools/registry.js';
|
||||
import type { ToolWithMetadata, ToolMetadata, ToolCategory } from '../../../src/tools/types.js';
|
||||
|
||||
// 创建 mock 工具
|
||||
function createMockToolWithMetadata(
|
||||
name: string,
|
||||
options: Partial<{
|
||||
category: ToolCategory;
|
||||
deferLoading: boolean;
|
||||
keywords: string[];
|
||||
description: string;
|
||||
}> = {}
|
||||
): ToolWithMetadata {
|
||||
const metadata: ToolMetadata = {
|
||||
name,
|
||||
category: options.category ?? 'core',
|
||||
description: options.description ?? `Mock tool: ${name}`,
|
||||
keywords: options.keywords ?? [name],
|
||||
deferLoading: options.deferLoading ?? false,
|
||||
};
|
||||
|
||||
return {
|
||||
name,
|
||||
description: metadata.description,
|
||||
parameters: { type: 'object', properties: {}, required: [] },
|
||||
execute: async () => ({ success: true, output: `executed ${name}` }),
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ToolRegistry - 工具注册表', () => {
|
||||
let registry: ToolRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new ToolRegistry();
|
||||
});
|
||||
|
||||
describe('register / registerAll', () => {
|
||||
it('注册单个工具', () => {
|
||||
const tool = createMockToolWithMetadata('test_tool');
|
||||
registry.register(tool);
|
||||
|
||||
expect(registry.has('test_tool')).toBe(true);
|
||||
expect(registry.size).toBe(1);
|
||||
});
|
||||
|
||||
it('批量注册工具', () => {
|
||||
const tools = [
|
||||
createMockToolWithMetadata('tool_a'),
|
||||
createMockToolWithMetadata('tool_b'),
|
||||
createMockToolWithMetadata('tool_c'),
|
||||
];
|
||||
registry.registerAll(tools);
|
||||
|
||||
expect(registry.size).toBe(3);
|
||||
expect(registry.has('tool_a')).toBe(true);
|
||||
expect(registry.has('tool_b')).toBe(true);
|
||||
expect(registry.has('tool_c')).toBe(true);
|
||||
});
|
||||
|
||||
it('同名工具覆盖注册', () => {
|
||||
const tool1 = createMockToolWithMetadata('test_tool', { description: 'version 1' });
|
||||
const tool2 = createMockToolWithMetadata('test_tool', { description: 'version 2' });
|
||||
|
||||
registry.register(tool1);
|
||||
registry.register(tool2);
|
||||
|
||||
expect(registry.size).toBe(1);
|
||||
const retrieved = registry.getTool('test_tool');
|
||||
expect(retrieved?.description).toBe('version 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTool / getTools', () => {
|
||||
beforeEach(() => {
|
||||
registry.registerAll([
|
||||
createMockToolWithMetadata('read_file'),
|
||||
createMockToolWithMetadata('write_file'),
|
||||
createMockToolWithMetadata('bash'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('获取存在的工具', () => {
|
||||
const tool = registry.getTool('read_file');
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool?.name).toBe('read_file');
|
||||
});
|
||||
|
||||
it('获取不存在的工具返回 undefined', () => {
|
||||
const tool = registry.getTool('non_existent');
|
||||
expect(tool).toBeUndefined();
|
||||
});
|
||||
|
||||
it('批量获取工具', () => {
|
||||
const tools = registry.getTools(['read_file', 'bash']);
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools.map((t) => t.name)).toContain('read_file');
|
||||
expect(tools.map((t) => t.name)).toContain('bash');
|
||||
});
|
||||
|
||||
it('批量获取时跳过不存在的工具', () => {
|
||||
const tools = registry.getTools(['read_file', 'non_existent', 'bash']);
|
||||
expect(tools).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('批量获取空列表返回空数组', () => {
|
||||
const tools = registry.getTools([]);
|
||||
expect(tools).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCoreTools - 核心工具(非延迟加载)', () => {
|
||||
it('只返回 deferLoading=false 的工具', () => {
|
||||
registry.registerAll([
|
||||
createMockToolWithMetadata('core_tool_1', { deferLoading: false }),
|
||||
createMockToolWithMetadata('core_tool_2', { deferLoading: false }),
|
||||
createMockToolWithMetadata('deferred_tool', { deferLoading: true }),
|
||||
]);
|
||||
|
||||
const coreTools = registry.getCoreTools();
|
||||
|
||||
expect(coreTools).toHaveLength(2);
|
||||
expect(coreTools.map((t) => t.name)).toContain('core_tool_1');
|
||||
expect(coreTools.map((t) => t.name)).toContain('core_tool_2');
|
||||
expect(coreTools.map((t) => t.name)).not.toContain('deferred_tool');
|
||||
});
|
||||
|
||||
it('所有工具都是延迟加载时返回空数组', () => {
|
||||
registry.registerAll([
|
||||
createMockToolWithMetadata('tool_1', { deferLoading: true }),
|
||||
createMockToolWithMetadata('tool_2', { deferLoading: true }),
|
||||
]);
|
||||
|
||||
const coreTools = registry.getCoreTools();
|
||||
expect(coreTools).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('默认 deferLoading=false 作为核心工具', () => {
|
||||
registry.register(createMockToolWithMetadata('default_tool'));
|
||||
const coreTools = registry.getCoreTools();
|
||||
expect(coreTools).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllTools / getAllMetadata', () => {
|
||||
beforeEach(() => {
|
||||
registry.registerAll([
|
||||
createMockToolWithMetadata('tool_a', { category: 'filesystem', deferLoading: false }),
|
||||
createMockToolWithMetadata('tool_b', { category: 'shell', deferLoading: true }),
|
||||
createMockToolWithMetadata('tool_c', { category: 'core', deferLoading: false }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('获取所有工具', () => {
|
||||
const allTools = registry.getAllTools();
|
||||
expect(allTools).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('获取所有元数据', () => {
|
||||
const metadata = registry.getAllMetadata();
|
||||
|
||||
expect(metadata).toHaveLength(3);
|
||||
expect(metadata.find((m) => m.name === 'tool_a')?.category).toBe('filesystem');
|
||||
expect(metadata.find((m) => m.name === 'tool_b')?.deferLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('has / size', () => {
|
||||
it('空注册表', () => {
|
||||
expect(registry.size).toBe(0);
|
||||
expect(registry.has('any')).toBe(false);
|
||||
});
|
||||
|
||||
it('注册后正确检测', () => {
|
||||
registry.register(createMockToolWithMetadata('test'));
|
||||
|
||||
expect(registry.size).toBe(1);
|
||||
expect(registry.has('test')).toBe(true);
|
||||
expect(registry.has('other')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search - 工具搜索', () => {
|
||||
// 注意:searchTools 只搜索 deferLoading=true 的工具
|
||||
beforeEach(() => {
|
||||
registry.registerAll([
|
||||
createMockToolWithMetadata('read_file', {
|
||||
category: 'filesystem',
|
||||
keywords: ['read', 'file', 'open', 'cat'],
|
||||
description: '读取文件内容',
|
||||
deferLoading: true, // 必须为 true 才能被搜索
|
||||
}),
|
||||
createMockToolWithMetadata('write_file', {
|
||||
category: 'filesystem',
|
||||
keywords: ['write', 'file', 'save', 'create'],
|
||||
description: '写入文件内容',
|
||||
deferLoading: true,
|
||||
}),
|
||||
createMockToolWithMetadata('bash', {
|
||||
category: 'shell',
|
||||
keywords: ['bash', 'shell', 'command', 'execute', 'run'],
|
||||
description: '执行 bash 命令',
|
||||
deferLoading: true,
|
||||
}),
|
||||
createMockToolWithMetadata('glob', {
|
||||
category: 'filesystem',
|
||||
keywords: ['glob', 'pattern', 'find', 'search', 'file'],
|
||||
description: '搜索匹配模式的文件',
|
||||
deferLoading: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('按关键词搜索', () => {
|
||||
const results = registry.search('file');
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
// file 相关工具应该排在前面
|
||||
const fileTools = results.filter((r) => r.name.includes('file') || r.category === 'filesystem');
|
||||
expect(fileTools.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('限制返回结果数量', () => {
|
||||
const results = registry.search('file', 2);
|
||||
expect(results.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('搜索 shell 相关', () => {
|
||||
const results = registry.search('shell');
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.some((r) => r.name === 'bash')).toBe(true);
|
||||
});
|
||||
|
||||
it('无匹配时返回空数组或低分结果', () => {
|
||||
const results = registry.search('xyznonexistent');
|
||||
// 可能返回空数组或低分结果,取决于搜索实现
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it('只搜索 deferLoading=true 的工具', () => {
|
||||
// 创建新的注册表
|
||||
const testRegistry = new ToolRegistry();
|
||||
testRegistry.registerAll([
|
||||
createMockToolWithMetadata('core_tool', {
|
||||
keywords: ['test'],
|
||||
deferLoading: false, // 核心工具,不被搜索
|
||||
}),
|
||||
createMockToolWithMetadata('deferred_tool', {
|
||||
keywords: ['test'],
|
||||
deferLoading: true, // 延迟加载,可被搜索
|
||||
}),
|
||||
]);
|
||||
|
||||
const results = testRegistry.search('test');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].name).toBe('deferred_tool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toBasicTool - 转换为基础工具类型', () => {
|
||||
it('转换后只包含基础属性', () => {
|
||||
const toolWithMeta = createMockToolWithMetadata('test', {
|
||||
category: 'filesystem',
|
||||
deferLoading: true,
|
||||
keywords: ['test', 'mock'],
|
||||
});
|
||||
registry.register(toolWithMeta);
|
||||
|
||||
const basicTool = registry.getTool('test');
|
||||
|
||||
expect(basicTool).toBeDefined();
|
||||
expect(basicTool).toHaveProperty('name');
|
||||
expect(basicTool).toHaveProperty('description');
|
||||
expect(basicTool).toHaveProperty('parameters');
|
||||
expect(basicTool).toHaveProperty('execute');
|
||||
// 不应该包含 metadata
|
||||
expect(basicTool).not.toHaveProperty('metadata');
|
||||
});
|
||||
|
||||
it('execute 函数正常工作', async () => {
|
||||
registry.register(createMockToolWithMetadata('test'));
|
||||
const tool = registry.getTool('test');
|
||||
|
||||
const result = await tool?.execute({});
|
||||
|
||||
expect(result).toEqual({ success: true, output: 'executed test' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolRegistry 实际使用场景', () => {
|
||||
let registry: ToolRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new ToolRegistry();
|
||||
});
|
||||
|
||||
it('模拟工具发现流程', () => {
|
||||
// 注册一批工具,部分为核心工具,部分为延迟加载
|
||||
registry.registerAll([
|
||||
createMockToolWithMetadata('tool_search', {
|
||||
deferLoading: false,
|
||||
category: 'core',
|
||||
keywords: ['search', 'discover', 'find', 'tool'],
|
||||
}),
|
||||
createMockToolWithMetadata('read_file', {
|
||||
deferLoading: false,
|
||||
category: 'filesystem',
|
||||
keywords: ['read', 'file'],
|
||||
}),
|
||||
createMockToolWithMetadata('advanced_git', {
|
||||
deferLoading: true,
|
||||
category: 'git',
|
||||
keywords: ['git', 'version', 'control'],
|
||||
}),
|
||||
createMockToolWithMetadata('database_query', {
|
||||
deferLoading: true,
|
||||
category: 'database',
|
||||
keywords: ['database', 'sql', 'query'],
|
||||
}),
|
||||
]);
|
||||
|
||||
// 1. 获取核心工具(会话开始时)
|
||||
const coreTools = registry.getCoreTools();
|
||||
expect(coreTools).toHaveLength(2);
|
||||
expect(coreTools.map((t) => t.name)).toContain('tool_search');
|
||||
|
||||
// 2. 搜索工具
|
||||
const gitResults = registry.search('git');
|
||||
expect(gitResults.some((r) => r.name === 'advanced_git')).toBe(true);
|
||||
|
||||
// 3. 按需加载发现的工具
|
||||
const discoveredTools = registry.getTools(['advanced_git']);
|
||||
expect(discoveredTools).toHaveLength(1);
|
||||
expect(discoveredTools[0].name).toBe('advanced_git');
|
||||
});
|
||||
|
||||
it('模拟多分类工具注册', () => {
|
||||
const categories: ToolCategory[] = ['core', 'filesystem', 'shell', 'git', 'web'];
|
||||
|
||||
for (const category of categories) {
|
||||
registry.register(
|
||||
createMockToolWithMetadata(`${category}_tool`, {
|
||||
category,
|
||||
keywords: [category],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = registry.getAllMetadata();
|
||||
|
||||
expect(metadata).toHaveLength(5);
|
||||
for (const category of categories) {
|
||||
expect(metadata.some((m) => m.category === category)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { searchTools } from '../../../src/tools/search.js';
|
||||
import type { ToolMetadata, ToolCategory } from '../../../src/tools/types.js';
|
||||
|
||||
// 创建测试用的工具元数据
|
||||
function createToolMetadata(
|
||||
name: string,
|
||||
options: Partial<{
|
||||
category: ToolCategory;
|
||||
description: string;
|
||||
keywords: string[];
|
||||
deferLoading: boolean;
|
||||
}> = {}
|
||||
): ToolMetadata {
|
||||
return {
|
||||
name,
|
||||
category: options.category ?? 'core',
|
||||
description: options.description ?? `Description for ${name}`,
|
||||
keywords: options.keywords ?? [name],
|
||||
deferLoading: options.deferLoading ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
describe('searchTools - 工具搜索算法', () => {
|
||||
const testTools: ToolMetadata[] = [
|
||||
createToolMetadata('read_file', {
|
||||
category: 'filesystem',
|
||||
description: '读取文件内容',
|
||||
keywords: ['read', 'file', 'open', 'cat', '文件', '读取'],
|
||||
}),
|
||||
createToolMetadata('write_file', {
|
||||
category: 'filesystem',
|
||||
description: '写入文件内容',
|
||||
keywords: ['write', 'file', 'save', 'create', '文件', '写入'],
|
||||
}),
|
||||
createToolMetadata('bash', {
|
||||
category: 'shell',
|
||||
description: '执行 bash 命令',
|
||||
keywords: ['bash', 'shell', 'command', 'execute', 'run', '命令', '执行'],
|
||||
}),
|
||||
createToolMetadata('glob', {
|
||||
category: 'filesystem',
|
||||
description: '搜索匹配模式的文件',
|
||||
keywords: ['glob', 'pattern', 'find', 'search', 'file', '搜索', '模式'],
|
||||
}),
|
||||
createToolMetadata('grep', {
|
||||
category: 'filesystem',
|
||||
description: '在文件内容中搜索',
|
||||
keywords: ['grep', 'search', 'content', 'find', '搜索', '内容'],
|
||||
}),
|
||||
createToolMetadata('git_status', {
|
||||
category: 'git',
|
||||
description: '查看 Git 仓库状态',
|
||||
keywords: ['git', 'status', 'repository', '状态', '仓库'],
|
||||
}),
|
||||
createToolMetadata('git_commit', {
|
||||
category: 'git',
|
||||
description: '提交代码更改',
|
||||
keywords: ['git', 'commit', 'save', '提交', '保存'],
|
||||
}),
|
||||
];
|
||||
|
||||
describe('基础搜索功能', () => {
|
||||
it('按名称精确匹配得最高分', () => {
|
||||
const results = searchTools('bash', testTools);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].name).toBe('bash');
|
||||
expect(results[0].score).toBeGreaterThanOrEqual(10); // 名称精确匹配 +10
|
||||
});
|
||||
|
||||
it('按名称包含匹配', () => {
|
||||
const results = searchTools('file', testTools);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
// read_file 和 write_file 都应该匹配
|
||||
const fileTools = results.filter((r) => r.name.includes('file'));
|
||||
expect(fileTools.length).toBe(2);
|
||||
});
|
||||
|
||||
it('按关键词精确匹配', () => {
|
||||
const results = searchTools('shell', testTools);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].name).toBe('bash');
|
||||
});
|
||||
|
||||
it('按描述内容匹配', () => {
|
||||
const results = searchTools('仓库', testTools);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.some((r) => r.name === 'git_status')).toBe(true);
|
||||
});
|
||||
|
||||
it('中文关键词搜索', () => {
|
||||
const results = searchTools('文件', testTools);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
// read_file 和 write_file 都有 '文件' 关键词
|
||||
expect(results.some((r) => r.name === 'read_file')).toBe(true);
|
||||
expect(results.some((r) => r.name === 'write_file')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('分词功能', () => {
|
||||
it('空格分隔的多词查询', () => {
|
||||
const results = searchTools('read file', testTools);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
// read_file 应该得到最高分(匹配 read 和 file)
|
||||
expect(results[0].name).toBe('read_file');
|
||||
});
|
||||
|
||||
it('逗号分隔的多词查询', () => {
|
||||
const results = searchTools('git,status', testTools);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].name).toBe('git_status');
|
||||
});
|
||||
|
||||
it('中文逗号分隔', () => {
|
||||
const results = searchTools('读取,文件', testTools);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].name).toBe('read_file');
|
||||
});
|
||||
|
||||
it('下划线分隔', () => {
|
||||
const results = searchTools('git_commit', testTools);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].name).toBe('git_commit');
|
||||
});
|
||||
|
||||
it('连字符分隔', () => {
|
||||
const results = searchTools('read-write', testTools);
|
||||
|
||||
// 应该匹配到包含 read 或 write 关键词的工具
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('顿号分隔(中文)', () => {
|
||||
const results = searchTools('搜索、文件', testTools);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('评分规则', () => {
|
||||
it('名称精确匹配优先于包含匹配', () => {
|
||||
const tools: ToolMetadata[] = [
|
||||
createToolMetadata('bash', { keywords: ['shell'] }),
|
||||
createToolMetadata('bash_advanced', { keywords: ['advanced'] }), // 移除 bash 关键词,避免额外得分
|
||||
];
|
||||
|
||||
const results = searchTools('bash', tools);
|
||||
|
||||
expect(results[0].name).toBe('bash'); // 精确匹配得分更高
|
||||
});
|
||||
|
||||
it('关键词精确匹配优先于包含匹配', () => {
|
||||
const tools: ToolMetadata[] = [
|
||||
createToolMetadata('tool_a', { keywords: ['git'] }),
|
||||
createToolMetadata('tool_b', { keywords: ['github', 'gitlab'] }),
|
||||
];
|
||||
|
||||
const results = searchTools('git', tools);
|
||||
|
||||
expect(results[0].name).toBe('tool_a'); // 关键词精确匹配得分更高
|
||||
});
|
||||
|
||||
it('多词查询累加分数', () => {
|
||||
const results = searchTools('git commit save', testTools);
|
||||
|
||||
// git_commit 应该匹配 git, commit, save (在关键词中)
|
||||
expect(results[0].name).toBe('git_commit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('结果限制', () => {
|
||||
it('默认返回最多 5 个结果', () => {
|
||||
const results = searchTools('file', testTools);
|
||||
|
||||
expect(results.length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('自定义限制结果数量', () => {
|
||||
const results = searchTools('file', testTools, 2);
|
||||
|
||||
expect(results.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('limit 为 0 时返回空数组', () => {
|
||||
const results = searchTools('file', testTools, 0);
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('只搜索延迟加载的工具', () => {
|
||||
it('跳过 deferLoading=false 的工具', () => {
|
||||
const tools: ToolMetadata[] = [
|
||||
createToolMetadata('core_tool', {
|
||||
keywords: ['test'],
|
||||
deferLoading: false,
|
||||
}),
|
||||
createToolMetadata('deferred_tool', {
|
||||
keywords: ['test'],
|
||||
deferLoading: true,
|
||||
}),
|
||||
];
|
||||
|
||||
const results = searchTools('test', tools);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].name).toBe('deferred_tool');
|
||||
});
|
||||
|
||||
it('全部为 deferLoading=false 时返回空数组', () => {
|
||||
const tools: ToolMetadata[] = [
|
||||
createToolMetadata('tool_a', { deferLoading: false }),
|
||||
createToolMetadata('tool_b', { deferLoading: false }),
|
||||
];
|
||||
|
||||
const results = searchTools('tool', tools);
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('空查询返回空数组', () => {
|
||||
const results = searchTools('', testTools);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('只有空格的查询返回空数组', () => {
|
||||
const results = searchTools(' ', testTools);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('空工具列表返回空数组', () => {
|
||||
const results = searchTools('test', []);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('无匹配时返回空数组', () => {
|
||||
const results = searchTools('xyznonexistent', testTools);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('大小写不敏感', () => {
|
||||
const results = searchTools('BASH', testTools);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].name).toBe('bash');
|
||||
});
|
||||
});
|
||||
|
||||
describe('搜索结果结构', () => {
|
||||
it('返回正确的结果结构', () => {
|
||||
const results = searchTools('bash', testTools);
|
||||
|
||||
expect(results[0]).toHaveProperty('name');
|
||||
expect(results[0]).toHaveProperty('description');
|
||||
expect(results[0]).toHaveProperty('category');
|
||||
expect(results[0]).toHaveProperty('score');
|
||||
});
|
||||
|
||||
it('结果按分数降序排列', () => {
|
||||
const results = searchTools('file', testTools);
|
||||
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// 定义一个可控的 mock
|
||||
let mockExecAsyncResult: { stdout: string; stderr: string } | Error = {
|
||||
stdout: 'command output',
|
||||
stderr: '',
|
||||
};
|
||||
|
||||
// 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(() => ({
|
||||
checkBashPermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '执行 shell 命令'),
|
||||
}));
|
||||
|
||||
import { bashTool } from '../../../../src/tools/shell/bash.js';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
|
||||
describe('bashTool - Bash 命令工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExecAsyncResult = {
|
||||
stdout: 'command output',
|
||||
stderr: '',
|
||||
};
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(bashTool.name).toBe('bash');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(bashTool.metadata.category).toBe('shell');
|
||||
expect(bashTool.metadata.keywords).toContain('bash');
|
||||
expect(bashTool.metadata.keywords).toContain('command');
|
||||
expect(bashTool.metadata.keywords).toContain('terminal');
|
||||
});
|
||||
|
||||
it('定义了必需的 command 参数', () => {
|
||||
expect(bashTool.parameters.command).toBeDefined();
|
||||
expect(bashTool.parameters.command.required).toBe(true);
|
||||
});
|
||||
|
||||
it('定义了可选的 cwd 参数', () => {
|
||||
expect(bashTool.parameters.cwd).toBeDefined();
|
||||
expect(bashTool.parameters.cwd.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功执行命令', async () => {
|
||||
const result = await bashTool.execute({ command: 'ls -la' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('command output');
|
||||
});
|
||||
|
||||
it('包含 stderr 输出', async () => {
|
||||
mockExecAsyncResult = {
|
||||
stdout: 'output',
|
||||
stderr: 'warning message',
|
||||
};
|
||||
|
||||
const result = await bashTool.execute({ command: 'some_cmd' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('output');
|
||||
expect(result.output).toContain('STDERR');
|
||||
expect(result.output).toContain('warning message');
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkBashPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '命令不被允许执行',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await bashTool.execute({ command: 'rm -rf /' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkBashPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
reason: '需要确认',
|
||||
patterns: ['rm *'],
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await bashTool.execute({ command: 'rm file.txt' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
expect(result.error).toContain('rm file.txt');
|
||||
});
|
||||
|
||||
it('命令执行失败时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkBashPermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
} as any);
|
||||
mockExecAsyncResult = Object.assign(
|
||||
new Error('Command failed'),
|
||||
{ stdout: '', stderr: 'command not found', message: 'Command failed' }
|
||||
);
|
||||
|
||||
const result = await bashTool.execute({ command: 'nonexistent_cmd' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('command not found');
|
||||
});
|
||||
|
||||
it('保留失败命令的 stdout', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkBashPermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
} as any);
|
||||
mockExecAsyncResult = Object.assign(
|
||||
new Error('Command failed'),
|
||||
{ stdout: 'partial output', stderr: 'error occurred', message: 'Command failed' }
|
||||
);
|
||||
|
||||
const result = await bashTool.execute({ command: 'failing_cmd' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.output).toBe('partial output');
|
||||
expect(result.error).toContain('error occurred');
|
||||
});
|
||||
|
||||
it('传递正确的参数给权限检查', async () => {
|
||||
const mockCheck = vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
});
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkBashPermission: mockCheck,
|
||||
} as any);
|
||||
|
||||
await bashTool.execute({ command: 'ls -la', cwd: '/home/user' });
|
||||
|
||||
expect(mockCheck).toHaveBeenCalledWith({
|
||||
command: 'ls -la',
|
||||
workdir: '/home/user',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// 使用可变的引用对象来绕过 hoisting 问题
|
||||
const mockState = {
|
||||
execute: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock agent registry 和 AgentExecutor
|
||||
vi.mock('../../../../src/agent/index.js', () => {
|
||||
// 在 mock 工厂内部定义类
|
||||
return {
|
||||
agentRegistry: {
|
||||
listSubagents: vi.fn(() => [
|
||||
{ name: 'explore', description: '代码探索', mode: 'subagent' },
|
||||
{ name: 'code-reviewer', description: '代码审查', mode: 'subagent' },
|
||||
]),
|
||||
get: vi.fn(),
|
||||
},
|
||||
AgentExecutor: class {
|
||||
execute(...args: any[]) {
|
||||
return mockState.execute(...args);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock tool registry
|
||||
vi.mock('../../../../src/tools/registry.js', () => ({
|
||||
toolRegistry: {},
|
||||
}));
|
||||
|
||||
// Mock session manager
|
||||
vi.mock('../../../../src/session/index.js', () => ({
|
||||
SessionManager: vi.fn(),
|
||||
}));
|
||||
|
||||
import { taskTool, initTaskContext, updateTaskDescription } from '../../../../src/tools/task/task.js';
|
||||
import { agentRegistry } from '../../../../src/agent/index.js';
|
||||
|
||||
describe('taskTool - Task 工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// 重置上下文为 null
|
||||
initTaskContext(null as any, null as any);
|
||||
// 重置 mock
|
||||
mockState.execute.mockResolvedValue({
|
||||
success: true,
|
||||
text: '任务完成',
|
||||
steps: 3,
|
||||
});
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(taskTool.name).toBe('task');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(taskTool.metadata.category).toBe('agent');
|
||||
expect(taskTool.metadata.keywords).toContain('task');
|
||||
expect(taskTool.metadata.keywords).toContain('subagent');
|
||||
});
|
||||
|
||||
it('定义了必需的参数', () => {
|
||||
expect(taskTool.parameters.description.required).toBe(true);
|
||||
expect(taskTool.parameters.prompt.required).toBe(true);
|
||||
expect(taskTool.parameters.subagent_type.required).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initTaskContext - 初始化上下文', () => {
|
||||
it('设置上下文不报错', () => {
|
||||
const mockConfig = { model: 'test' };
|
||||
const mockSession = {
|
||||
getSessionId: vi.fn(() => 'session-id'),
|
||||
createChildSession: vi.fn(() => ({ id: 'child', messages: [] })),
|
||||
saveChildSession: vi.fn(),
|
||||
};
|
||||
|
||||
expect(() => initTaskContext(mockConfig as any, mockSession as any)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskDescription - 更新描述', () => {
|
||||
it('更新工具描述', () => {
|
||||
vi.mocked(agentRegistry.listSubagents).mockReturnValue([
|
||||
{ name: 'explore', description: '代码探索', mode: 'subagent' },
|
||||
{ name: 'code-reviewer', description: '代码审查', mode: 'subagent' },
|
||||
]);
|
||||
|
||||
updateTaskDescription();
|
||||
|
||||
expect(taskTool.description).toContain('explore');
|
||||
expect(taskTool.description).toContain('code-reviewer');
|
||||
});
|
||||
|
||||
it('无子 Agent 时显示提示', () => {
|
||||
vi.mocked(agentRegistry.listSubagents).mockReturnValue([]);
|
||||
|
||||
updateTaskDescription();
|
||||
|
||||
expect(taskTool.description).toContain('没有可用');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('未初始化上下文时返回错误', async () => {
|
||||
// 确保上下文为 null
|
||||
initTaskContext(null as any, null as any);
|
||||
vi.mocked(agentRegistry.get).mockReturnValue(undefined);
|
||||
|
||||
const result = await taskTool.execute({
|
||||
description: 'test task',
|
||||
prompt: 'do something',
|
||||
subagent_type: 'explore',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
// 可能是未初始化或未找到 Agent
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('成功执行子任务', async () => {
|
||||
const mockSession = {
|
||||
getSessionId: vi.fn(() => 'parent-session'),
|
||||
createChildSession: vi.fn(() => ({
|
||||
id: 'child-session',
|
||||
messages: [],
|
||||
})),
|
||||
saveChildSession: vi.fn(),
|
||||
};
|
||||
|
||||
initTaskContext({ model: 'test' } as any, mockSession as any);
|
||||
|
||||
vi.mocked(agentRegistry.get).mockReturnValue({
|
||||
name: 'explore',
|
||||
description: '探索 Agent',
|
||||
mode: 'subagent',
|
||||
prompt: '你是探索助手',
|
||||
});
|
||||
|
||||
const result = await taskTool.execute({
|
||||
description: 'search code',
|
||||
prompt: 'find all API routes',
|
||||
subagent_type: 'explore',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('任务完成');
|
||||
});
|
||||
|
||||
it('未找到 Agent 时返回错误', async () => {
|
||||
const mockSession = {
|
||||
getSessionId: vi.fn(() => 'parent-session'),
|
||||
createChildSession: vi.fn(() => ({ id: 'child', messages: [] })),
|
||||
saveChildSession: vi.fn(),
|
||||
};
|
||||
|
||||
initTaskContext({ model: 'test' } as any, mockSession as any);
|
||||
|
||||
vi.mocked(agentRegistry.get).mockReturnValue(undefined);
|
||||
vi.mocked(agentRegistry.listSubagents).mockReturnValue([
|
||||
{ name: 'explore', description: '探索', mode: 'subagent' },
|
||||
]);
|
||||
|
||||
const result = await taskTool.execute({
|
||||
description: 'test',
|
||||
prompt: 'test',
|
||||
subagent_type: 'nonexistent',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('未找到 Agent');
|
||||
});
|
||||
|
||||
it('primary 模式 Agent 不能作为子任务', async () => {
|
||||
const mockSession = {
|
||||
getSessionId: vi.fn(() => 'parent-session'),
|
||||
createChildSession: vi.fn(() => ({ id: 'child', messages: [] })),
|
||||
saveChildSession: vi.fn(),
|
||||
};
|
||||
|
||||
initTaskContext({ model: 'test' } as any, mockSession as any);
|
||||
|
||||
vi.mocked(agentRegistry.get).mockReturnValue({
|
||||
name: 'primary-agent',
|
||||
description: '主 Agent',
|
||||
mode: 'primary',
|
||||
prompt: '你是主助手',
|
||||
});
|
||||
|
||||
const result = await taskTool.execute({
|
||||
description: 'test',
|
||||
prompt: 'test',
|
||||
subagent_type: 'primary-agent',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('primary 模式');
|
||||
});
|
||||
|
||||
it('子任务失败时返回错误', async () => {
|
||||
const mockSession = {
|
||||
getSessionId: vi.fn(() => 'parent-session'),
|
||||
createChildSession: vi.fn(() => ({ id: 'child', messages: [] })),
|
||||
saveChildSession: vi.fn(),
|
||||
};
|
||||
|
||||
initTaskContext({ model: 'test' } as any, mockSession as any);
|
||||
|
||||
vi.mocked(agentRegistry.get).mockReturnValue({
|
||||
name: 'explore',
|
||||
description: '探索 Agent',
|
||||
mode: 'subagent',
|
||||
prompt: '你是探索助手',
|
||||
});
|
||||
|
||||
mockState.execute.mockResolvedValue({
|
||||
success: false,
|
||||
text: '',
|
||||
error: '执行失败',
|
||||
steps: 1,
|
||||
});
|
||||
|
||||
const result = await taskTool.execute({
|
||||
description: 'test',
|
||||
prompt: 'test',
|
||||
subagent_type: 'explore',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('执行失败');
|
||||
});
|
||||
|
||||
it('返回元数据', async () => {
|
||||
const mockSession = {
|
||||
getSessionId: vi.fn(() => 'parent-session'),
|
||||
createChildSession: vi.fn(() => ({
|
||||
id: 'child-session-123',
|
||||
messages: [],
|
||||
})),
|
||||
saveChildSession: vi.fn(),
|
||||
};
|
||||
|
||||
initTaskContext({ model: 'test' } as any, mockSession as any);
|
||||
|
||||
vi.mocked(agentRegistry.get).mockReturnValue({
|
||||
name: 'explore',
|
||||
description: '探索 Agent',
|
||||
mode: 'subagent',
|
||||
prompt: '你是探索助手',
|
||||
});
|
||||
|
||||
mockState.execute.mockResolvedValue({
|
||||
success: true,
|
||||
text: '完成',
|
||||
steps: 5,
|
||||
});
|
||||
|
||||
const result = await taskTool.execute({
|
||||
description: 'test',
|
||||
prompt: 'test',
|
||||
subagent_type: 'explore',
|
||||
});
|
||||
|
||||
expect(result.metadata).toBeDefined();
|
||||
expect(result.metadata?.agent).toBe('explore');
|
||||
expect(result.metadata?.sessionId).toBe('child-session-123');
|
||||
expect(result.metadata?.steps).toBe(5);
|
||||
});
|
||||
|
||||
it('保存子会话', async () => {
|
||||
const saveChildSession = vi.fn();
|
||||
const mockSession = {
|
||||
getSessionId: vi.fn(() => 'parent-session'),
|
||||
createChildSession: vi.fn(() => ({
|
||||
id: 'child-session',
|
||||
messages: [],
|
||||
})),
|
||||
saveChildSession,
|
||||
};
|
||||
|
||||
initTaskContext({ model: 'test' } as any, mockSession as any);
|
||||
|
||||
vi.mocked(agentRegistry.get).mockReturnValue({
|
||||
name: 'explore',
|
||||
description: '探索 Agent',
|
||||
mode: 'subagent',
|
||||
prompt: '你是探索助手',
|
||||
});
|
||||
|
||||
mockState.execute.mockResolvedValue({
|
||||
success: true,
|
||||
text: '任务结果',
|
||||
steps: 2,
|
||||
});
|
||||
|
||||
await taskTool.execute({
|
||||
description: 'test',
|
||||
prompt: 'test prompt',
|
||||
subagent_type: 'explore',
|
||||
});
|
||||
|
||||
expect(saveChildSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { Todo, TodoStatus } from '../../../src/session/types.js';
|
||||
import type { SessionManager } from '../../../src/session/index.js';
|
||||
|
||||
// 需要单独导入 todoManager 因为它是单例
|
||||
// 每个测试创建新的实例来避免状态污染
|
||||
|
||||
// 简单的 TodoManager 测试类(复制逻辑以测试)
|
||||
class TestTodoManager {
|
||||
private sessionManager: SessionManager | null = null;
|
||||
|
||||
setSessionManager(manager: SessionManager): void {
|
||||
this.sessionManager = manager;
|
||||
}
|
||||
|
||||
getTodos(): Todo[] {
|
||||
if (!this.sessionManager) {
|
||||
return [];
|
||||
}
|
||||
return this.sessionManager.getTodos();
|
||||
}
|
||||
|
||||
async setTodos(todos: Todo[]): Promise<void> {
|
||||
if (!this.sessionManager) {
|
||||
return;
|
||||
}
|
||||
await this.sessionManager.setTodos(todos);
|
||||
}
|
||||
|
||||
async addTodo(content: string, status: TodoStatus = 'pending'): Promise<Todo> {
|
||||
const todos = this.getTodos();
|
||||
const now = new Date().toISOString();
|
||||
const newTodo: Todo = {
|
||||
id: this.generateId(),
|
||||
content,
|
||||
status,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
todos.push(newTodo);
|
||||
await this.setTodos(todos);
|
||||
return newTodo;
|
||||
}
|
||||
|
||||
async updateTodoStatus(id: string, status: TodoStatus): Promise<boolean> {
|
||||
const todos = this.getTodos();
|
||||
const todo = todos.find((t) => t.id === id);
|
||||
if (!todo) return false;
|
||||
|
||||
todo.status = status;
|
||||
todo.updatedAt = new Date().toISOString();
|
||||
await this.setTodos(todos);
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteTodo(id: string): Promise<boolean> {
|
||||
const todos = this.getTodos();
|
||||
const index = todos.findIndex((t) => t.id === id);
|
||||
if (index === -1) return false;
|
||||
|
||||
todos.splice(index, 1);
|
||||
await this.setTodos(todos);
|
||||
return true;
|
||||
}
|
||||
|
||||
async clearTodos(): Promise<void> {
|
||||
await this.setTodos([]);
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 10);
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.sessionManager !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// Mock SessionManager
|
||||
function createMockSessionManager(): SessionManager {
|
||||
let todos: Todo[] = [];
|
||||
|
||||
return {
|
||||
getTodos: vi.fn(() => [...todos]),
|
||||
setTodos: vi.fn(async (newTodos: Todo[]) => {
|
||||
todos = [...newTodos];
|
||||
}),
|
||||
} as unknown as SessionManager;
|
||||
}
|
||||
|
||||
describe('TodoManager - Todo 管理器', () => {
|
||||
let todoManager: TestTodoManager;
|
||||
let mockSessionManager: SessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
todoManager = new TestTodoManager();
|
||||
mockSessionManager = createMockSessionManager();
|
||||
});
|
||||
|
||||
describe('初始化状态', () => {
|
||||
it('未设置 sessionManager 时 isInitialized 返回 false', () => {
|
||||
expect(todoManager.isInitialized()).toBe(false);
|
||||
});
|
||||
|
||||
it('设置 sessionManager 后 isInitialized 返回 true', () => {
|
||||
todoManager.setSessionManager(mockSessionManager);
|
||||
|
||||
expect(todoManager.isInitialized()).toBe(true);
|
||||
});
|
||||
|
||||
it('未初始化时 getTodos 返回空数组', () => {
|
||||
expect(todoManager.getTodos()).toEqual([]);
|
||||
});
|
||||
|
||||
it('未初始化时 setTodos 不报错', async () => {
|
||||
await expect(todoManager.setTodos([{
|
||||
id: '1',
|
||||
content: 'test',
|
||||
status: 'pending',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}])).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTodos', () => {
|
||||
beforeEach(() => {
|
||||
todoManager.setSessionManager(mockSessionManager);
|
||||
});
|
||||
|
||||
it('返回空数组当没有 todos', () => {
|
||||
const todos = todoManager.getTodos();
|
||||
|
||||
expect(todos).toEqual([]);
|
||||
});
|
||||
|
||||
it('返回 sessionManager 中的 todos', async () => {
|
||||
await todoManager.addTodo('Task 1');
|
||||
await todoManager.addTodo('Task 2');
|
||||
|
||||
const todos = todoManager.getTodos();
|
||||
|
||||
expect(todos).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTodos', () => {
|
||||
beforeEach(() => {
|
||||
todoManager.setSessionManager(mockSessionManager);
|
||||
});
|
||||
|
||||
it('设置 todos 列表', async () => {
|
||||
const todos: Todo[] = [
|
||||
{
|
||||
id: '1',
|
||||
content: 'Task 1',
|
||||
status: 'pending',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
await todoManager.setTodos(todos);
|
||||
|
||||
expect(todoManager.getTodos()).toHaveLength(1);
|
||||
expect(todoManager.getTodos()[0].content).toBe('Task 1');
|
||||
});
|
||||
|
||||
it('清空 todos', async () => {
|
||||
await todoManager.addTodo('Task 1');
|
||||
await todoManager.setTodos([]);
|
||||
|
||||
expect(todoManager.getTodos()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTodo', () => {
|
||||
beforeEach(() => {
|
||||
todoManager.setSessionManager(mockSessionManager);
|
||||
});
|
||||
|
||||
it('添加新 todo 默认状态为 pending', async () => {
|
||||
const todo = await todoManager.addTodo('New task');
|
||||
|
||||
expect(todo.content).toBe('New task');
|
||||
expect(todo.status).toBe('pending');
|
||||
expect(todo.id).toBeDefined();
|
||||
expect(todo.createdAt).toBeDefined();
|
||||
expect(todo.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('添加新 todo 指定状态', async () => {
|
||||
const todo = await todoManager.addTodo('In progress task', 'in_progress');
|
||||
|
||||
expect(todo.status).toBe('in_progress');
|
||||
});
|
||||
|
||||
it('添加的 todo 出现在列表中', async () => {
|
||||
await todoManager.addTodo('Task 1');
|
||||
await todoManager.addTodo('Task 2');
|
||||
|
||||
const todos = todoManager.getTodos();
|
||||
|
||||
expect(todos).toHaveLength(2);
|
||||
expect(todos.some(t => t.content === 'Task 1')).toBe(true);
|
||||
expect(todos.some(t => t.content === 'Task 2')).toBe(true);
|
||||
});
|
||||
|
||||
it('每个 todo 有唯一 ID', async () => {
|
||||
const todo1 = await todoManager.addTodo('Task 1');
|
||||
const todo2 = await todoManager.addTodo('Task 2');
|
||||
|
||||
expect(todo1.id).not.toBe(todo2.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTodoStatus', () => {
|
||||
beforeEach(() => {
|
||||
todoManager.setSessionManager(mockSessionManager);
|
||||
});
|
||||
|
||||
it('更新存在的 todo 状态', async () => {
|
||||
const todo = await todoManager.addTodo('Task');
|
||||
const result = await todoManager.updateTodoStatus(todo.id, 'completed');
|
||||
|
||||
expect(result).toBe(true);
|
||||
const updated = todoManager.getTodos().find(t => t.id === todo.id);
|
||||
expect(updated?.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('更新不存在的 todo 返回 false', async () => {
|
||||
const result = await todoManager.updateTodoStatus('non-existent-id', 'completed');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('更新状态时更新 updatedAt', async () => {
|
||||
const todo = await todoManager.addTodo('Task');
|
||||
const originalUpdatedAt = todo.updatedAt;
|
||||
|
||||
// 等待一小段时间确保时间戳不同
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
await todoManager.updateTodoStatus(todo.id, 'in_progress');
|
||||
const updated = todoManager.getTodos().find(t => t.id === todo.id);
|
||||
|
||||
expect(updated?.updatedAt).not.toBe(originalUpdatedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteTodo', () => {
|
||||
beforeEach(() => {
|
||||
todoManager.setSessionManager(mockSessionManager);
|
||||
});
|
||||
|
||||
it('删除存在的 todo', async () => {
|
||||
const todo = await todoManager.addTodo('Task to delete');
|
||||
const result = await todoManager.deleteTodo(todo.id);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(todoManager.getTodos().find(t => t.id === todo.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('删除不存在的 todo 返回 false', async () => {
|
||||
const result = await todoManager.deleteTodo('non-existent-id');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('删除后列表长度减少', async () => {
|
||||
await todoManager.addTodo('Task 1');
|
||||
const todo2 = await todoManager.addTodo('Task 2');
|
||||
await todoManager.addTodo('Task 3');
|
||||
|
||||
await todoManager.deleteTodo(todo2.id);
|
||||
|
||||
expect(todoManager.getTodos()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearTodos', () => {
|
||||
beforeEach(() => {
|
||||
todoManager.setSessionManager(mockSessionManager);
|
||||
});
|
||||
|
||||
it('清空所有 todos', async () => {
|
||||
await todoManager.addTodo('Task 1');
|
||||
await todoManager.addTodo('Task 2');
|
||||
await todoManager.addTodo('Task 3');
|
||||
|
||||
await todoManager.clearTodos();
|
||||
|
||||
expect(todoManager.getTodos()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('清空空列表不报错', async () => {
|
||||
await expect(todoManager.clearTodos()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Todo 状态流转', () => {
|
||||
let todoManager: TestTodoManager;
|
||||
let mockSessionManager: SessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
todoManager = new TestTodoManager();
|
||||
mockSessionManager = createMockSessionManager();
|
||||
todoManager.setSessionManager(mockSessionManager);
|
||||
});
|
||||
|
||||
it('pending -> in_progress -> completed', async () => {
|
||||
const todo = await todoManager.addTodo('Task');
|
||||
|
||||
expect(todo.status).toBe('pending');
|
||||
|
||||
await todoManager.updateTodoStatus(todo.id, 'in_progress');
|
||||
let updated = todoManager.getTodos().find(t => t.id === todo.id);
|
||||
expect(updated?.status).toBe('in_progress');
|
||||
|
||||
await todoManager.updateTodoStatus(todo.id, 'completed');
|
||||
updated = todoManager.getTodos().find(t => t.id === todo.id);
|
||||
expect(updated?.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('直接从 pending 到 completed', async () => {
|
||||
const todo = await todoManager.addTodo('Quick task');
|
||||
|
||||
await todoManager.updateTodoStatus(todo.id, 'completed');
|
||||
const updated = todoManager.getTodos().find(t => t.id === todo.id);
|
||||
expect(updated?.status).toBe('completed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// 使用可变的引用对象来绕过 hoisting 问题
|
||||
const mockState = {
|
||||
isInitialized: vi.fn().mockReturnValue(true),
|
||||
getTodos: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
|
||||
vi.mock('../../../../src/tools/todo/todo-manager.js', () => ({
|
||||
todoManager: {
|
||||
isInitialized: () => mockState.isInitialized(),
|
||||
getTodos: () => mockState.getTodos(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { todoReadTool } from '../../../../src/tools/todo/todoread.js';
|
||||
|
||||
describe('todoReadTool - Todo 读取工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.isInitialized.mockReturnValue(true);
|
||||
mockState.getTodos.mockReturnValue([]);
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(todoReadTool.name).toBe('todoread');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(todoReadTool.metadata.category).toBe('core');
|
||||
expect(todoReadTool.metadata.keywords).toContain('todo');
|
||||
expect(todoReadTool.metadata.keywords).toContain('task');
|
||||
expect(todoReadTool.metadata.keywords).toContain('list');
|
||||
});
|
||||
|
||||
it('无必需参数', () => {
|
||||
expect(Object.keys(todoReadTool.parameters)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功读取空列表', async () => {
|
||||
const result = await todoReadTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe('[]');
|
||||
expect(result.metadata?.totalCount).toBe(0);
|
||||
expect(result.metadata?.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it('成功读取待办列表', async () => {
|
||||
const todos = [
|
||||
{ id: '1', content: '任务1', status: 'pending', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
|
||||
{ id: '2', content: '任务2', status: 'in_progress', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
|
||||
{ id: '3', content: '任务3', status: 'completed', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
|
||||
];
|
||||
mockState.getTodos.mockReturnValue(todos);
|
||||
|
||||
const result = await todoReadTool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.metadata?.totalCount).toBe(3);
|
||||
expect(result.metadata?.pendingCount).toBe(2); // pending + in_progress
|
||||
});
|
||||
|
||||
it('返回 JSON 格式输出', async () => {
|
||||
const todos = [
|
||||
{ id: '1', content: '任务1', status: 'pending', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
|
||||
];
|
||||
mockState.getTodos.mockReturnValue(todos);
|
||||
|
||||
const result = await todoReadTool.execute({});
|
||||
|
||||
const parsed = JSON.parse(result.output);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0].content).toBe('任务1');
|
||||
});
|
||||
|
||||
it('未初始化时返回错误', async () => {
|
||||
mockState.isInitialized.mockReturnValue(false);
|
||||
|
||||
const result = await todoReadTool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('会话管理器未初始化');
|
||||
});
|
||||
|
||||
it('返回正确的元数据', async () => {
|
||||
const todos = [
|
||||
{ id: '1', content: '任务1', status: 'pending' },
|
||||
{ id: '2', content: '任务2', status: 'completed' },
|
||||
];
|
||||
mockState.getTodos.mockReturnValue(todos);
|
||||
|
||||
const result = await todoReadTool.execute({});
|
||||
|
||||
expect(result.metadata?.todos).toEqual(todos);
|
||||
expect(result.metadata?.pendingCount).toBe(1);
|
||||
expect(result.metadata?.totalCount).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// 使用可变的引用对象来绕过 hoisting 问题
|
||||
const mockState = {
|
||||
isInitialized: vi.fn().mockReturnValue(true),
|
||||
getTodos: vi.fn().mockReturnValue([]),
|
||||
setTodos: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
vi.mock('../../../../src/tools/todo/todo-manager.js', () => ({
|
||||
todoManager: {
|
||||
isInitialized: () => mockState.isInitialized(),
|
||||
getTodos: () => mockState.getTodos(),
|
||||
setTodos: (todos: any) => mockState.setTodos(todos),
|
||||
},
|
||||
}));
|
||||
|
||||
import { todoWriteTool } from '../../../../src/tools/todo/todowrite.js';
|
||||
|
||||
describe('todoWriteTool - Todo 写入工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.isInitialized.mockReturnValue(true);
|
||||
mockState.getTodos.mockReturnValue([]);
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(todoWriteTool.name).toBe('todowrite');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(todoWriteTool.metadata.category).toBe('core');
|
||||
expect(todoWriteTool.metadata.keywords).toContain('todo');
|
||||
expect(todoWriteTool.metadata.keywords).toContain('task');
|
||||
expect(todoWriteTool.metadata.keywords).toContain('write');
|
||||
});
|
||||
|
||||
it('定义了必需的 todos 参数', () => {
|
||||
expect(todoWriteTool.parameters.todos.required).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功创建待办列表', async () => {
|
||||
const result = await todoWriteTool.execute({
|
||||
todos: [
|
||||
{ content: '任务1', status: 'pending' },
|
||||
{ content: '任务2', status: 'in_progress' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('待办事项已更新');
|
||||
expect(mockState.setTodos).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('返回正确的统计信息', async () => {
|
||||
const result = await todoWriteTool.execute({
|
||||
todos: [
|
||||
{ content: '任务1', status: 'pending' },
|
||||
{ content: '任务2', status: 'in_progress' },
|
||||
{ content: '任务3', status: 'completed' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// pendingCount 包含所有非 completed 状态的任务(pending + in_progress)
|
||||
expect(result.metadata?.pendingCount).toBe(2);
|
||||
expect(result.metadata?.inProgressCount).toBe(1);
|
||||
expect(result.metadata?.completedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('更新现有任务', async () => {
|
||||
mockState.getTodos.mockReturnValue([
|
||||
{ id: 'existing-1', content: '任务1', status: 'pending', createdAt: '2024-01-01', updatedAt: '2024-01-01' },
|
||||
]);
|
||||
|
||||
const result = await todoWriteTool.execute({
|
||||
todos: [
|
||||
{ content: '任务1', status: 'completed' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const savedTodos = mockState.setTodos.mock.calls[0][0];
|
||||
expect(savedTodos[0].id).toBe('existing-1'); // 保留原有 ID
|
||||
expect(savedTodos[0].status).toBe('completed');
|
||||
});
|
||||
|
||||
it('未初始化时返回错误', async () => {
|
||||
mockState.isInitialized.mockReturnValue(false);
|
||||
|
||||
const result = await todoWriteTool.execute({
|
||||
todos: [{ content: '任务', status: 'pending' }],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('会话管理器未初始化');
|
||||
});
|
||||
|
||||
it('todos 非数组返回错误', async () => {
|
||||
const result = await todoWriteTool.execute({
|
||||
todos: 'not an array',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('todos 参数必须是数组');
|
||||
});
|
||||
|
||||
it('无效的待办项格式返回错误', async () => {
|
||||
const result = await todoWriteTool.execute({
|
||||
todos: [
|
||||
{ content: '有效任务', status: 'pending' },
|
||||
{ content: '', status: 'pending' }, // 空内容
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('第 2 个待办事项格式无效');
|
||||
});
|
||||
|
||||
it('无效的状态值返回错误', async () => {
|
||||
const result = await todoWriteTool.execute({
|
||||
todos: [
|
||||
{ content: '任务', status: 'invalid_status' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('格式无效');
|
||||
});
|
||||
|
||||
it('缺少 content 返回错误', async () => {
|
||||
const result = await todoWriteTool.execute({
|
||||
todos: [
|
||||
{ status: 'pending' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('格式无效');
|
||||
});
|
||||
|
||||
it('为新任务生成 ID', async () => {
|
||||
const result = await todoWriteTool.execute({
|
||||
todos: [
|
||||
{ content: '新任务', status: 'pending' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const savedTodos = mockState.setTodos.mock.calls[0][0];
|
||||
expect(savedTodos[0].id).toBeDefined();
|
||||
expect(savedTodos[0].id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('设置创建和更新时间', async () => {
|
||||
const result = await todoWriteTool.execute({
|
||||
todos: [
|
||||
{ content: '新任务', status: 'pending' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const savedTodos = mockState.setTodos.mock.calls[0][0];
|
||||
expect(savedTodos[0].createdAt).toBeDefined();
|
||||
expect(savedTodos[0].updatedAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock tavily
|
||||
const mockExtract = vi.fn();
|
||||
vi.mock('@tavily/core', () => ({
|
||||
tavily: vi.fn(() => ({
|
||||
extract: mockExtract,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock config
|
||||
vi.mock('../../../../src/utils/config.js', () => ({
|
||||
getConfig: vi.fn(() => ({
|
||||
tavilyApiKey: 'test-api-key',
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkWebPermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '从网页URL提取内容'),
|
||||
}));
|
||||
|
||||
import { webExtractTool } from '../../../../src/tools/web/web_extract.js';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
import { getConfig } from '../../../../src/utils/config.js';
|
||||
|
||||
describe('webExtractTool - 网页内容提取工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExtract.mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
url: 'https://example.com',
|
||||
rawContent: '# Hello World\n\nThis is example content.',
|
||||
images: ['https://example.com/img1.png'],
|
||||
},
|
||||
],
|
||||
failedResults: [],
|
||||
responseTime: 1.5,
|
||||
});
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(webExtractTool.name).toBe('web_extract');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(webExtractTool.metadata.category).toBe('web');
|
||||
expect(webExtractTool.metadata.keywords).toContain('extract');
|
||||
expect(webExtractTool.metadata.keywords).toContain('url');
|
||||
expect(webExtractTool.metadata.keywords).toContain('scrape');
|
||||
});
|
||||
|
||||
it('定义了必需的 urls 参数', () => {
|
||||
expect(webExtractTool.parameters.urls.required).toBe(true);
|
||||
});
|
||||
|
||||
it('定义了可选参数', () => {
|
||||
expect(webExtractTool.parameters.extract_depth.required).toBe(false);
|
||||
expect(webExtractTool.parameters.format.required).toBe(false);
|
||||
expect(webExtractTool.parameters.include_images.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功提取单个 URL 内容', async () => {
|
||||
const result = await webExtractTool.execute({
|
||||
urls: ['https://example.com'],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('网页内容提取');
|
||||
expect(result.output).toContain('example.com');
|
||||
expect(result.output).toContain('Hello World');
|
||||
});
|
||||
|
||||
it('支持字符串格式的单个 URL', async () => {
|
||||
const result = await webExtractTool.execute({
|
||||
urls: 'https://example.com',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockExtract).toHaveBeenCalledWith(
|
||||
['https://example.com'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('支持多个 URL', async () => {
|
||||
mockExtract.mockResolvedValue({
|
||||
results: [
|
||||
{ url: 'https://example1.com', rawContent: 'Content 1' },
|
||||
{ url: 'https://example2.com', rawContent: 'Content 2' },
|
||||
],
|
||||
failedResults: [],
|
||||
});
|
||||
|
||||
const result = await webExtractTool.execute({
|
||||
urls: ['https://example1.com', 'https://example2.com'],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('example1.com');
|
||||
expect(result.output).toContain('example2.com');
|
||||
});
|
||||
|
||||
it('限制最多 20 个 URL', async () => {
|
||||
const urls = Array.from({ length: 25 }, (_, i) => `https://example${i}.com`);
|
||||
|
||||
await webExtractTool.execute({ urls });
|
||||
|
||||
expect(mockExtract).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.any(Object)
|
||||
);
|
||||
const calledUrls = mockExtract.mock.calls[0][0];
|
||||
expect(calledUrls.length).toBe(20);
|
||||
});
|
||||
|
||||
it('使用 advanced 提取深度', async () => {
|
||||
await webExtractTool.execute({
|
||||
urls: ['https://example.com'],
|
||||
extract_depth: 'advanced',
|
||||
});
|
||||
|
||||
expect(mockExtract).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ extractDepth: 'advanced' })
|
||||
);
|
||||
});
|
||||
|
||||
it('包含图片列表', async () => {
|
||||
const result = await webExtractTool.execute({
|
||||
urls: ['https://example.com'],
|
||||
include_images: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('图片');
|
||||
expect(result.output).toContain('img1.png');
|
||||
});
|
||||
|
||||
it('显示失败的 URL', async () => {
|
||||
mockExtract.mockResolvedValue({
|
||||
results: [],
|
||||
failedResults: [
|
||||
{ url: 'https://failed.com', error: '404 Not Found' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await webExtractTool.execute({
|
||||
urls: ['https://failed.com'],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('提取失败');
|
||||
expect(result.output).toContain('failed.com');
|
||||
expect(result.output).toContain('404');
|
||||
});
|
||||
|
||||
it('显示响应时间', async () => {
|
||||
const result = await webExtractTool.execute({
|
||||
urls: ['https://example.com'],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('提取耗时');
|
||||
});
|
||||
|
||||
it('截断过长的内容', async () => {
|
||||
mockExtract.mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
url: 'https://example.com',
|
||||
rawContent: 'a'.repeat(6000), // 超过 5000 字符
|
||||
},
|
||||
],
|
||||
failedResults: [],
|
||||
});
|
||||
|
||||
const result = await webExtractTool.execute({
|
||||
urls: ['https://example.com'],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('内容已截断');
|
||||
});
|
||||
|
||||
it('无 API Key 返回错误', async () => {
|
||||
vi.mocked(getConfig).mockReturnValue({} as any);
|
||||
const originalEnv = process.env.TAVILY_API_KEY;
|
||||
delete process.env.TAVILY_API_KEY;
|
||||
|
||||
const result = await webExtractTool.execute({
|
||||
urls: ['https://example.com'],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('未配置 Tavily API Key');
|
||||
|
||||
process.env.TAVILY_API_KEY = originalEnv;
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkWebPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '提取不被允许',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await webExtractTool.execute({
|
||||
urls: ['https://example.com'],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkWebPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await webExtractTool.execute({
|
||||
urls: ['https://example.com'],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('提取失败返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkWebPermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
mockExtract.mockRejectedValue(new Error('API 调用失败'));
|
||||
|
||||
const result = await webExtractTool.execute({
|
||||
urls: ['https://example.com'],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('提取失败');
|
||||
expect(result.error).toContain('API 调用失败');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock tavily
|
||||
const mockSearch = vi.fn();
|
||||
vi.mock('@tavily/core', () => ({
|
||||
tavily: vi.fn(() => ({
|
||||
search: mockSearch,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock config
|
||||
vi.mock('../../../../src/utils/config.js', () => ({
|
||||
getConfig: vi.fn(() => ({
|
||||
tavilyApiKey: 'test-api-key',
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock permission manager
|
||||
vi.mock('../../../../src/permission/index.js', () => ({
|
||||
getPermissionManager: vi.fn(() => ({
|
||||
checkWebPermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
action: 'allow',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock loadDescription
|
||||
vi.mock('../../../../src/tools/load_description.js', () => ({
|
||||
loadDescription: vi.fn(() => '网络搜索'),
|
||||
}));
|
||||
|
||||
import { webSearchTool } from '../../../../src/tools/web/web_search.js';
|
||||
import { getPermissionManager } from '../../../../src/permission/index.js';
|
||||
import { getConfig } from '../../../../src/utils/config.js';
|
||||
|
||||
describe('webSearchTool - 网络搜索工具', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSearch.mockResolvedValue({
|
||||
answer: '搜索摘要',
|
||||
results: [
|
||||
{ title: '结果1', url: 'https://example.com/1', content: '内容1' },
|
||||
{ title: '结果2', url: 'https://example.com/2', content: '内容2' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe('工具定义', () => {
|
||||
it('有正确的名称', () => {
|
||||
expect(webSearchTool.name).toBe('web_search');
|
||||
});
|
||||
|
||||
it('有正确的元数据', () => {
|
||||
expect(webSearchTool.metadata.category).toBe('web');
|
||||
expect(webSearchTool.metadata.keywords).toContain('search');
|
||||
expect(webSearchTool.metadata.keywords).toContain('web');
|
||||
});
|
||||
|
||||
it('定义了必需的 query 参数', () => {
|
||||
expect(webSearchTool.parameters.query.required).toBe(true);
|
||||
});
|
||||
|
||||
it('定义了可选参数', () => {
|
||||
expect(webSearchTool.parameters.max_results.required).toBe(false);
|
||||
expect(webSearchTool.parameters.search_depth.required).toBe(false);
|
||||
expect(webSearchTool.parameters.topic.required).toBe(false);
|
||||
expect(webSearchTool.parameters.include_answer.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('成功搜索并返回结果', async () => {
|
||||
const result = await webSearchTool.execute({ query: 'test query' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('搜索结果');
|
||||
expect(result.output).toContain('test query');
|
||||
expect(result.output).toContain('搜索摘要');
|
||||
expect(result.output).toContain('结果1');
|
||||
expect(result.output).toContain('结果2');
|
||||
});
|
||||
|
||||
it('无结果时显示提示', async () => {
|
||||
mockSearch.mockResolvedValue({
|
||||
answer: null,
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await webSearchTool.execute({ query: 'no results' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('未找到相关结果');
|
||||
});
|
||||
|
||||
it('限制最大结果数量', async () => {
|
||||
const result = await webSearchTool.execute({
|
||||
query: 'test',
|
||||
max_results: 100, // 超过限制
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockSearch).toHaveBeenCalledWith(
|
||||
'test',
|
||||
expect.objectContaining({ maxResults: 20 }) // 最大 20
|
||||
);
|
||||
});
|
||||
|
||||
it('使用指定的搜索深度', async () => {
|
||||
await webSearchTool.execute({
|
||||
query: 'test',
|
||||
search_depth: 'advanced',
|
||||
});
|
||||
|
||||
expect(mockSearch).toHaveBeenCalledWith(
|
||||
'test',
|
||||
expect.objectContaining({ searchDepth: 'advanced' })
|
||||
);
|
||||
});
|
||||
|
||||
it('使用指定的主题', async () => {
|
||||
await webSearchTool.execute({
|
||||
query: 'test',
|
||||
topic: 'news',
|
||||
});
|
||||
|
||||
expect(mockSearch).toHaveBeenCalledWith(
|
||||
'test',
|
||||
expect.objectContaining({ topic: 'news' })
|
||||
);
|
||||
});
|
||||
|
||||
it('无 API Key 返回错误', async () => {
|
||||
vi.mocked(getConfig).mockReturnValue({} as any);
|
||||
const originalEnv = process.env.TAVILY_API_KEY;
|
||||
delete process.env.TAVILY_API_KEY;
|
||||
|
||||
const result = await webSearchTool.execute({ query: 'test' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('未配置 Tavily API Key');
|
||||
|
||||
process.env.TAVILY_API_KEY = originalEnv;
|
||||
});
|
||||
|
||||
it('权限被拒绝时返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkWebPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'deny',
|
||||
reason: '搜索不被允许',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await webSearchTool.execute({ query: 'test' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限被拒绝');
|
||||
});
|
||||
|
||||
it('需要确认时返回提示', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkWebPermission: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
action: 'ask',
|
||||
needsConfirmation: true,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await webSearchTool.execute({ query: 'test' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('需要用户确认');
|
||||
});
|
||||
|
||||
it('搜索失败返回错误', async () => {
|
||||
vi.mocked(getPermissionManager).mockReturnValue({
|
||||
checkWebPermission: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
} as any);
|
||||
mockSearch.mockRejectedValue(new Error('API 调用失败'));
|
||||
|
||||
const result = await webSearchTool.execute({ query: 'test' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('搜索失败');
|
||||
expect(result.error).toContain('API 调用失败');
|
||||
});
|
||||
|
||||
it('截断过长的内容', async () => {
|
||||
mockSearch.mockResolvedValue({
|
||||
answer: null,
|
||||
results: [
|
||||
{
|
||||
title: '长内容结果',
|
||||
url: 'https://example.com',
|
||||
content: 'a'.repeat(500), // 超过 300 字符
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await webSearchTool.execute({ query: 'test' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('...');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user