feat(permission): 实现 WebSocket 权限确认机制
重构权限系统,将终端 UI 代码从 core 模块移除,实现基于 WebSocket 的权限确认流程: Core 模块清理: - 删除 permission/prompt.ts 和 file-prompt.ts(终端交互) - 删除 diff.ts 中的 chalk 渲染函数 - 删除 config.ts 中的 inquirer 交互 - 移除 chalk 依赖 Server 权限处理: - 新增 permission/handler.ts,实现 WebSocket 权限请求/响应 - 更新 agent/adapter.ts 设置权限回调 - 更新 ws.ts 处理 permission_response 消息 Web 权限组件: - 新增 PermissionDialog 组件,显示权限请求详情和 Diff - 更新 useChat hook 管理权限状态 - 更新 Chat 页面集成权限弹窗
This commit is contained in:
@@ -1,399 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
promptFileWrite,
|
||||
promptFileEdit,
|
||||
promptFilePermission,
|
||||
} from '../../../src/permission/file-prompt.js';
|
||||
import type { FilePermissionContext } from '../../../src/permission/types.js';
|
||||
|
||||
// Mock readline
|
||||
vi.mock('readline', () => ({
|
||||
createInterface: vi.fn(() => ({
|
||||
question: vi.fn(),
|
||||
close: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock chalk
|
||||
vi.mock('chalk', () => ({
|
||||
default: {
|
||||
yellow: (s: string) => s,
|
||||
cyan: (s: string) => s,
|
||||
white: (s: string) => s,
|
||||
gray: (s: string) => s,
|
||||
red: (s: string) => s,
|
||||
green: (s: string) => s,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock diff utils
|
||||
vi.mock('../../../src/utils/diff.js', () => ({
|
||||
computeDiff: vi.fn(() => ({
|
||||
isNew: false,
|
||||
oldContent: 'old',
|
||||
newContent: 'new',
|
||||
hunks: [],
|
||||
})),
|
||||
formatDiff: vi.fn(() => 'diff output'),
|
||||
countChanges: vi.fn(() => ({ additions: 5, deletions: 3 })),
|
||||
formatEditDiff: vi.fn(() => 'edit diff output'),
|
||||
}));
|
||||
|
||||
import * as readline from 'readline';
|
||||
import * as fs from 'fs/promises';
|
||||
import { computeDiff, countChanges } from '../../../src/utils/diff.js';
|
||||
|
||||
describe('File Prompt - 文件操作提示', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('promptFileWrite - 文件写入提示', () => {
|
||||
const baseContext: FilePermissionContext = {
|
||||
operation: 'write',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'write_file',
|
||||
newContent: 'new content',
|
||||
};
|
||||
|
||||
it('无内容时使用简单确认', async () => {
|
||||
const ctx: FilePermissionContext = {
|
||||
...baseContext,
|
||||
newContent: undefined,
|
||||
};
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(ctx);
|
||||
|
||||
expect(result.allow).toBe(true);
|
||||
});
|
||||
|
||||
it('内容相同时直接允许', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('new content');
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
});
|
||||
|
||||
it('新文件显示新增行数', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
vi.mocked(computeDiff).mockReturnValue({
|
||||
isNew: true,
|
||||
oldContent: null,
|
||||
newContent: 'new content',
|
||||
hunks: [],
|
||||
} as any);
|
||||
vi.mocked(countChanges).mockReturnValue({ additions: 10, deletions: 0 });
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptFileWrite(baseContext);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('新文件');
|
||||
expect(calls).toContain('+10 行');
|
||||
});
|
||||
|
||||
it('修改文件显示增删行数', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
vi.mocked(computeDiff).mockReturnValue({
|
||||
isNew: false,
|
||||
oldContent: 'old content',
|
||||
newContent: 'new content',
|
||||
hunks: [],
|
||||
} as any);
|
||||
vi.mocked(countChanges).mockReturnValue({ additions: 5, deletions: 3 });
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptFileWrite(baseContext);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('+5 行');
|
||||
expect(calls).toContain('-3 行');
|
||||
});
|
||||
|
||||
it('用户输入 y 返回允许不记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
});
|
||||
|
||||
it('用户输入 Y 返回允许并记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('Y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: true });
|
||||
});
|
||||
|
||||
it('用户输入 n 返回拒绝不记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('n')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: false });
|
||||
});
|
||||
|
||||
it('用户输入 N 返回拒绝并记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('N')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: true });
|
||||
});
|
||||
|
||||
it('无效输入默认拒绝', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('invalid')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: false });
|
||||
});
|
||||
|
||||
it('超长 diff 被截断', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
// 模拟超过 50 行的 diff
|
||||
const longDiff = Array(100).fill('line').join('\n');
|
||||
const { formatDiff } = await import('../../../src/utils/diff.js');
|
||||
vi.mocked(formatDiff).mockReturnValue(longDiff);
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptFileWrite(baseContext);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('省略');
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptFileEdit - 文件编辑提示', () => {
|
||||
const baseContext: FilePermissionContext = {
|
||||
operation: 'edit',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'edit_file',
|
||||
oldContent: 'old text',
|
||||
newContent: 'new text',
|
||||
};
|
||||
|
||||
it('无内容时使用简单确认', async () => {
|
||||
const ctx: FilePermissionContext = {
|
||||
...baseContext,
|
||||
oldContent: undefined,
|
||||
newContent: undefined,
|
||||
};
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileEdit(ctx);
|
||||
|
||||
expect(result.allow).toBe(true);
|
||||
});
|
||||
|
||||
it('显示编辑 diff', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptFileEdit(baseContext);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('文件编辑预览');
|
||||
expect(calls).toContain('/test/file.ts');
|
||||
});
|
||||
|
||||
it('用户确认后返回决定', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('Y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileEdit(baseContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptFilePermission - 统一入口', () => {
|
||||
it('write 操作调用 promptFileWrite', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('same content');
|
||||
|
||||
const ctx: FilePermissionContext = {
|
||||
operation: 'write',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'write_file',
|
||||
newContent: 'same content',
|
||||
};
|
||||
|
||||
const result = await promptFilePermission(ctx);
|
||||
|
||||
// 内容相同直接允许
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
});
|
||||
|
||||
it('edit 操作调用 promptFileEdit', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const ctx: FilePermissionContext = {
|
||||
operation: 'edit',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'edit_file',
|
||||
oldContent: 'old',
|
||||
newContent: 'new',
|
||||
};
|
||||
|
||||
await promptFilePermission(ctx);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('文件编辑预览');
|
||||
});
|
||||
|
||||
it('其他操作使用简单确认', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const ctx: FilePermissionContext = {
|
||||
operation: 'delete',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'delete_file',
|
||||
};
|
||||
|
||||
await promptFilePermission(ctx);
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('文件操作确认');
|
||||
});
|
||||
});
|
||||
|
||||
describe('确认选项显示', () => {
|
||||
it('显示所有选项', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptFileWrite({
|
||||
operation: 'write',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'write_file',
|
||||
newContent: 'new content',
|
||||
});
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('[y]');
|
||||
expect(calls).toContain('[Y]');
|
||||
expect(calls).toContain('[n]');
|
||||
expect(calls).toContain('[N]');
|
||||
expect(calls).toContain('确认执行');
|
||||
expect(calls).toContain('拒绝执行');
|
||||
expect(calls).toContain('记住');
|
||||
});
|
||||
});
|
||||
|
||||
describe('输入处理', () => {
|
||||
it('输入被 trim', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback(' y ')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptFileWrite({
|
||||
operation: 'write',
|
||||
path: '/test/file.ts',
|
||||
workdir: '/test',
|
||||
toolName: 'write_file',
|
||||
newContent: 'new content',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,226 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { promptPermission, showPermissionDenied, showPermissionAllowed } from '../../../src/permission/prompt.js';
|
||||
import type { PermissionContext } from '../../../src/permission/types.js';
|
||||
|
||||
// Mock readline
|
||||
vi.mock('readline', () => ({
|
||||
createInterface: vi.fn(() => ({
|
||||
question: vi.fn(),
|
||||
close: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock chalk
|
||||
vi.mock('chalk', () => ({
|
||||
default: {
|
||||
yellow: (s: string) => s,
|
||||
cyan: (s: string) => s,
|
||||
white: (s: string) => s,
|
||||
gray: (s: string) => s,
|
||||
red: (s: string) => s,
|
||||
green: (s: string) => s,
|
||||
},
|
||||
}));
|
||||
|
||||
import * as readline from 'readline';
|
||||
|
||||
describe('Permission Prompt - 权限提示模块', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('showPermissionDenied - 显示权限被拒绝', () => {
|
||||
it('显示命令和原因', () => {
|
||||
showPermissionDenied('rm -rf /', '危险命令');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('权限被拒绝'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('rm -rf /'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('危险命令'));
|
||||
});
|
||||
|
||||
it('输出包含空行', () => {
|
||||
showPermissionDenied('test', 'reason');
|
||||
|
||||
// 第一个和最后一个调用是空行
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('showPermissionAllowed - 显示权限允许', () => {
|
||||
it('显示执行的命令', () => {
|
||||
showPermissionAllowed('npm install');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('执行'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('npm install'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptPermission - 交互式权限提示', () => {
|
||||
const mockContext: PermissionContext = {
|
||||
command: 'git push',
|
||||
workdir: '/project',
|
||||
toolName: 'bash',
|
||||
};
|
||||
|
||||
it('用户输入 y 返回允许不记住', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
expect(mockRl.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('用户输入 Y 返回允许并记住', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('Y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: true });
|
||||
});
|
||||
|
||||
it('用户输入 n 返回拒绝不记住', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('n')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: false });
|
||||
});
|
||||
|
||||
it('用户输入 N 返回拒绝并记住', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('N')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: true });
|
||||
});
|
||||
|
||||
it('无效输入默认为拒绝', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('invalid')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: false });
|
||||
});
|
||||
|
||||
it('空输入默认为拒绝', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: false, remember: false });
|
||||
});
|
||||
|
||||
it('带空格的输入会被 trim', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback(' y ')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await promptPermission(mockContext);
|
||||
|
||||
expect(result).toEqual({ allow: true, remember: false });
|
||||
});
|
||||
|
||||
it('显示命令和工作目录', async () => {
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptPermission(mockContext);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('git push'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('/project'));
|
||||
});
|
||||
|
||||
it('显示外部路径警告', async () => {
|
||||
const contextWithExternal: PermissionContext = {
|
||||
...mockContext,
|
||||
externalPaths: ['/etc/passwd', '/root/.ssh'],
|
||||
};
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('n')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptPermission(contextWithExternal);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('项目目录外的路径'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('/etc/passwd'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('/root/.ssh'));
|
||||
});
|
||||
|
||||
it('显示匹配模式', async () => {
|
||||
const contextWithPatterns: PermissionContext = {
|
||||
...mockContext,
|
||||
patterns: ['*.js', '*.ts'],
|
||||
};
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptPermission(contextWithPatterns);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('*.js'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('*.ts'));
|
||||
});
|
||||
|
||||
it('不显示空的外部路径', async () => {
|
||||
const contextEmptyExternal: PermissionContext = {
|
||||
...mockContext,
|
||||
externalPaths: [],
|
||||
};
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await promptPermission(contextEmptyExternal);
|
||||
|
||||
// 不应该显示外部路径相关的警告
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).not.toContain('项目目录外的路径');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,39 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
computeDiff,
|
||||
formatDiff,
|
||||
countChanges,
|
||||
formatEditDiff,
|
||||
confirmFileChange,
|
||||
} from '../../../src/utils/diff.js';
|
||||
|
||||
// Mock readline
|
||||
vi.mock('readline', () => ({
|
||||
createInterface: vi.fn(() => ({
|
||||
question: vi.fn(),
|
||||
close: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock chalk (保留原始功能以便测试输出格式)
|
||||
vi.mock('chalk', () => ({
|
||||
default: {
|
||||
yellow: (s: string) => `[yellow]${s}[/yellow]`,
|
||||
cyan: (s: string) => `[cyan]${s}[/cyan]`,
|
||||
white: (s: string) => `[white]${s}[/white]`,
|
||||
gray: (s: string) => `[gray]${s}[/gray]`,
|
||||
red: (s: string) => `[red]${s}[/red]`,
|
||||
green: (s: string) => `[green]${s}[/green]`,
|
||||
},
|
||||
}));
|
||||
|
||||
import * as readline from 'readline';
|
||||
import * as fs from 'fs/promises';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeDiff, countChanges } from '../../../src/utils/diff.js';
|
||||
|
||||
describe('Diff - 差异比较扩展测试', () => {
|
||||
describe('computeDiff - 计算 diff', () => {
|
||||
@@ -120,45 +86,6 @@ describe('Diff - 差异比较扩展测试', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDiff - 格式化 diff', () => {
|
||||
it('新文件显示 +++ 新文件标记', () => {
|
||||
const diff = computeDiff(null, 'new content');
|
||||
const formatted = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(formatted).toContain('新文件');
|
||||
expect(formatted).toContain('/test/file.ts');
|
||||
});
|
||||
|
||||
it('修改文件显示 --- 和 +++ 标记', () => {
|
||||
const diff = computeDiff('old', 'new');
|
||||
const formatted = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(formatted).toContain('原文件');
|
||||
expect(formatted).toContain('修改后');
|
||||
});
|
||||
|
||||
it('显示 hunk 头部 @@ 信息', () => {
|
||||
const diff = computeDiff('old', 'new');
|
||||
const formatted = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(formatted).toContain('@@');
|
||||
});
|
||||
|
||||
it('添加行使用 + 前缀', () => {
|
||||
const diff = computeDiff('', 'added line');
|
||||
const formatted = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(formatted).toContain('+ added line');
|
||||
});
|
||||
|
||||
it('删除行使用 - 前缀', () => {
|
||||
const diff = computeDiff('removed line', '');
|
||||
const formatted = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(formatted).toContain('- removed line');
|
||||
});
|
||||
});
|
||||
|
||||
describe('countChanges - 统计变更', () => {
|
||||
it('统计添加行数', () => {
|
||||
const diff = computeDiff(null, 'line1\nline2\nline3');
|
||||
@@ -191,173 +118,4 @@ describe('Diff - 差异比较扩展测试', () => {
|
||||
expect(changes.additions + changes.deletions).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatEditDiff - 编辑 diff 格式化', () => {
|
||||
it('显示删除和添加内容', () => {
|
||||
const formatted = formatEditDiff('old text', 'new text');
|
||||
|
||||
expect(formatted).toContain('old text');
|
||||
expect(formatted).toContain('new text');
|
||||
expect(formatted).toContain('-');
|
||||
expect(formatted).toContain('+');
|
||||
});
|
||||
|
||||
it('处理多行内容', () => {
|
||||
const formatted = formatEditDiff('old1\nold2', 'new1\nnew2');
|
||||
|
||||
expect(formatted).toContain('old1');
|
||||
expect(formatted).toContain('old2');
|
||||
expect(formatted).toContain('new1');
|
||||
expect(formatted).toContain('new2');
|
||||
});
|
||||
|
||||
it('包含变更内容标题', () => {
|
||||
const formatted = formatEditDiff('old', 'new');
|
||||
|
||||
expect(formatted).toContain('变更内容');
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmFileChange - 文件变更确认', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('内容相同直接返回确认', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('same content');
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'same content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.remember).toBe(false);
|
||||
});
|
||||
|
||||
it('新文件(读取失败)显示 diff', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(true);
|
||||
});
|
||||
|
||||
it('用户输入 y 确认写入', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.remember).toBe(false);
|
||||
});
|
||||
|
||||
it('用户输入 Y 确认并记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('Y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.remember).toBe(true);
|
||||
});
|
||||
|
||||
it('用户输入 n 取消操作', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('n')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(false);
|
||||
expect(result.remember).toBe(false);
|
||||
});
|
||||
|
||||
it('用户输入 N 取消并记住', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('N')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(false);
|
||||
expect(result.remember).toBe(true);
|
||||
});
|
||||
|
||||
it('无效输入默认取消', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('invalid')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
expect(result.confirmed).toBe(false);
|
||||
expect(result.remember).toBe(false);
|
||||
});
|
||||
|
||||
it('显示变更预览信息', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await confirmFileChange('/test/file.ts', 'new content', 'write');
|
||||
|
||||
const output = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(output).toContain('文件变更预览');
|
||||
expect(output).toContain('写入文件');
|
||||
expect(output).toContain('/test/file.ts');
|
||||
});
|
||||
|
||||
it('显示编辑操作类型', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
||||
|
||||
const mockRl = {
|
||||
question: vi.fn((_, callback) => callback('y')),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
||||
|
||||
await confirmFileChange('/test/file.ts', 'new content', 'edit');
|
||||
|
||||
const output = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(output).toContain('编辑文件');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeDiff, countChanges, formatEditDiff, formatDiff } from '../../../src/utils/diff.js';
|
||||
import { computeDiff, countChanges } from '../../../src/utils/diff.js';
|
||||
|
||||
describe('computeDiff - 计算文件差异', () => {
|
||||
describe('新文件', () => {
|
||||
@@ -156,31 +156,6 @@ describe('countChanges - 统计变更数量', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatEditDiff - 格式化编辑差异', () => {
|
||||
it('显示删除和新增内容', () => {
|
||||
const result = formatEditDiff('old text', 'new text');
|
||||
|
||||
expect(result).toContain('变更内容');
|
||||
expect(result).toContain('old text');
|
||||
expect(result).toContain('new text');
|
||||
});
|
||||
|
||||
it('多行内容正确显示', () => {
|
||||
const result = formatEditDiff('line1\nline2', 'new1\nnew2\nnew3');
|
||||
|
||||
expect(result).toContain('line1');
|
||||
expect(result).toContain('line2');
|
||||
expect(result).toContain('new1');
|
||||
expect(result).toContain('new2');
|
||||
expect(result).toContain('new3');
|
||||
});
|
||||
|
||||
it('空内容处理', () => {
|
||||
const result = formatEditDiff('', 'new');
|
||||
|
||||
expect(result).toContain('new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiffResult 结构', () => {
|
||||
it('包含所有必要字段', () => {
|
||||
@@ -248,55 +223,6 @@ describe('LCS 算法测试', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDiff - 格式化 diff 输出', () => {
|
||||
it('新文件显示新增标记', () => {
|
||||
const diff = computeDiff(null, 'line1\nline2');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(result).toContain('新文件');
|
||||
expect(result).toContain('/test/file.ts');
|
||||
});
|
||||
|
||||
it('修改文件显示原文件和修改后标记', () => {
|
||||
const diff = computeDiff('old', 'new');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(result).toContain('原文件');
|
||||
expect(result).toContain('修改后');
|
||||
expect(result).toContain('/test/file.ts');
|
||||
});
|
||||
|
||||
it('hunk 头部显示正确的行号范围', () => {
|
||||
const diff = computeDiff('line1\nline2', 'line1\nnew\nline2');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(result).toContain('@@');
|
||||
});
|
||||
|
||||
it('新增行显示 + 前缀', () => {
|
||||
const diff = computeDiff(null, 'added line');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(result).toContain('+');
|
||||
expect(result).toContain('added line');
|
||||
});
|
||||
|
||||
it('删除行显示 - 前缀', () => {
|
||||
const diff = computeDiff('removed line', 'new line');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
expect(result).toContain('-');
|
||||
expect(result).toContain('removed line');
|
||||
});
|
||||
|
||||
it('空 diff 返回基本格式', () => {
|
||||
const diff = computeDiff('same', 'same');
|
||||
const result = formatDiff(diff, '/test/file.ts');
|
||||
|
||||
// 没有 hunks 时只有头部
|
||||
expect(result).toContain('/test/file.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('实际代码场景', () => {
|
||||
it('函数修改', () => {
|
||||
|
||||
Reference in New Issue
Block a user