Files
ai-terminal-assistant/tests/unit/tools/filesystem/write_file-extended.test.ts
T
kurihada 59dbed926e feat: 实现统一编辑模式系统
- 新增 src/editors 模块,支持三种编辑模式:
  - whole: 整文件替换
  - search-replace: 搜索替换(支持多块)
  - diff: 统一 diff 格式

- 新增 multi_edit 工具,支持批量编辑和原子操作

- 重构 edit_file 和 write_file 工具使用新的编辑系统

- 功能特性:
  - 编辑验证(唯一性检查、文件存在性检查)
  - 友好的错误提示(显示匹配数量、相似内容提示)
  - LSP 诊断集成
  - 批量编辑支持原子操作和回滚
  - 空白字符规范化处理

- 新增 30 个编辑器测试用例
2025-12-12 09:58:19 +08:00

263 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
readFile: vi.fn().mockRejectedValue(new Error('ENOENT: file not found')),
}));
// Mock permission manager
const mockCheckFilePermission = vi.fn();
vi.mock('../../../../src/permission/index.js', () => ({
getPermissionManager: vi.fn(() => ({
checkFilePermission: mockCheckFilePermission,
})),
}));
// Mock loadDescription
vi.mock('../../../../src/tools/load_description.js', () => ({
loadDescription: vi.fn(() => '写入文件内容'),
}));
// Mock LSP
const mockTouchFile = vi.fn();
const mockGetFormattedFileDiagnostics = vi.fn();
const mockIsLanguageSupported = vi.fn();
vi.mock('../../../../src/lsp/index.js', () => ({
touchFile: (...args: unknown[]) => mockTouchFile(...args),
getFormattedFileDiagnostics: (...args: unknown[]) => mockGetFormattedFileDiagnostics(...args),
isLanguageSupported: (...args: unknown[]) => mockIsLanguageSupported(...args),
}));
import * as fs from 'fs/promises';
describe('writeFileTool - 写入文件工具扩展测试', () => {
beforeEach(() => {
vi.clearAllMocks();
// 重置所有 mock 返回值
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
mockCheckFilePermission.mockResolvedValue({
allowed: true,
action: 'allow',
});
mockIsLanguageSupported.mockReturnValue(false);
});
describe('LSP 集成', () => {
it('支持的语言触发 LSP 诊断', async () => {
mockIsLanguageSupported.mockReturnValue(true);
mockTouchFile.mockResolvedValue(false);
mockGetFormattedFileDiagnostics.mockResolvedValue(null);
const result = await writeFileTool.execute({
path: './test.ts',
content: 'const x = 1;',
});
expect(result.success).toBe(true);
expect(mockTouchFile).toHaveBeenCalled();
expect(mockGetFormattedFileDiagnostics).toHaveBeenCalled();
});
it('首次启动等待更长时间', async () => {
mockIsLanguageSupported.mockReturnValue(true);
mockTouchFile.mockResolvedValue(true); // 首次启动返回 true
mockGetFormattedFileDiagnostics.mockResolvedValue(null);
const startTime = Date.now();
await writeFileTool.execute({
path: './test.ts',
content: 'const x = 1;',
});
const elapsed = Date.now() - startTime;
// 首次启动应该等待更长时间(2000ms vs 300ms
expect(elapsed).toBeGreaterThanOrEqual(1900);
});
it('有诊断问题时在输出中显示', async () => {
mockIsLanguageSupported.mockReturnValue(true);
mockTouchFile.mockResolvedValue(false);
mockGetFormattedFileDiagnostics.mockResolvedValue(
'\n<file_diagnostics>\n1:1 ERROR: Type error\n</file_diagnostics>'
);
const result = await writeFileTool.execute({
path: './test.ts',
content: 'const x: string = 1;',
});
expect(result.success).toBe(true);
expect(result.output).toContain('代码检查发现问题');
expect(result.output).toContain('file_diagnostics');
});
it('LSP 错误不影响主流程', async () => {
mockIsLanguageSupported.mockReturnValue(true);
mockTouchFile.mockRejectedValue(new Error('LSP error'));
const result = await writeFileTool.execute({
path: './test.ts',
content: 'const x = 1;',
});
// 即使 LSP 失败,文件写入仍然成功
expect(result.success).toBe(true);
expect(result.output).toContain('文件已写入');
});
it('不支持的语言不触发 LSP', async () => {
mockIsLanguageSupported.mockReturnValue(false);
await writeFileTool.execute({
path: './test.txt',
content: 'plain text',
});
expect(mockTouchFile).not.toHaveBeenCalled();
expect(mockGetFormattedFileDiagnostics).not.toHaveBeenCalled();
});
});
describe('路径处理', () => {
it('相对路径转换为绝对路径', async () => {
await writeFileTool.execute({
path: './relative/path/file.txt',
content: 'content',
});
expect(fs.mkdir).toHaveBeenCalledWith(
expect.stringMatching(/relative\/path$/),
{ recursive: true }
);
});
it('绝对路径保持不变', async () => {
await writeFileTool.execute({
path: '/absolute/path/file.txt',
content: 'content',
});
expect(fs.writeFile).toHaveBeenCalledWith(
'/absolute/path/file.txt',
'content',
'utf-8'
);
});
});
describe('权限检查详情', () => {
it('权限检查包含正确的上下文', async () => {
await writeFileTool.execute({
path: './test.txt',
content: 'file content',
});
expect(mockCheckFilePermission).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'write',
newContent: 'file content',
})
);
});
it('被拒绝时返回具体原因', async () => {
mockCheckFilePermission.mockResolvedValue({
allowed: false,
action: 'deny',
reason: '系统文件不允许修改',
});
const result = await writeFileTool.execute({
path: '/etc/passwd',
content: 'malicious',
});
expect(result.success).toBe(false);
expect(result.error).toContain('权限被拒绝');
expect(result.error).toContain('系统文件不允许修改');
});
it('需要确认时返回具体原因', async () => {
mockCheckFilePermission.mockResolvedValue({
allowed: false,
action: 'ask',
needsConfirmation: true,
reason: '首次写入此文件',
});
const result = await writeFileTool.execute({
path: './new-file.txt',
content: 'content',
});
expect(result.success).toBe(false);
expect(result.error).toContain('需要用户确认');
expect(result.error).toContain('首次写入此文件');
});
});
describe('错误处理', () => {
it('文件系统错误返回错误信息', async () => {
vi.mocked(fs.writeFile).mockRejectedValue(new Error('EACCES: permission denied'));
const result = await writeFileTool.execute({
path: './test.txt',
content: 'content',
});
expect(result.success).toBe(false);
expect(result.error).toContain('EACCES');
});
it('目录创建失败返回错误信息', async () => {
vi.mocked(fs.mkdir).mockImplementationOnce(() => {
return Promise.reject(new Error('mkdir failed'));
});
const result = await writeFileTool.execute({
path: './deep/nested/file.txt',
content: 'content',
});
expect(result.success).toBe(false);
// The error is now wrapped by the editors module
expect(result.error).toBeTruthy();
});
it('非 Error 对象也能正确处理', async () => {
vi.mocked(fs.writeFile).mockRejectedValue('String error');
const result = await writeFileTool.execute({
path: './test.txt',
content: 'content',
});
expect(result.success).toBe(false);
expect(result.error).toContain('String error');
});
});
describe('工具元数据', () => {
it('包含完整的元数据', () => {
expect(writeFileTool.metadata.category).toBe('filesystem');
expect(writeFileTool.metadata.keywords).toContain('write');
expect(writeFileTool.metadata.keywords).toContain('save');
expect(writeFileTool.metadata.keywords).toContain('create');
expect(writeFileTool.metadata.keywords).toContain('写入');
expect(writeFileTool.metadata.deferLoading).toBe(false);
});
it('参数定义正确', () => {
expect(writeFileTool.parameters.path.required).toBe(true);
expect(writeFileTool.parameters.content.required).toBe(true);
expect(writeFileTool.parameters.path.type).toBe('string');
expect(writeFileTool.parameters.content.type).toBe('string');
});
});
});