import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { promptFileWrite, promptFileEdit, promptFilePermission, } from '../../../src/permission/file-prompt.js'; import type { FilePermissionContext } from '../../../src/permission/types.js'; // Mock readline vi.mock('readline', () => ({ createInterface: vi.fn(() => ({ question: vi.fn(), close: vi.fn(), })), })); // Mock fs/promises vi.mock('fs/promises', () => ({ readFile: vi.fn(), })); // Mock chalk vi.mock('chalk', () => ({ default: { yellow: (s: string) => s, cyan: (s: string) => s, white: (s: string) => s, gray: (s: string) => s, red: (s: string) => s, green: (s: string) => s, }, })); // Mock diff utils vi.mock('../../../src/utils/diff.js', () => ({ computeDiff: vi.fn(() => ({ isNew: false, oldContent: 'old', newContent: 'new', hunks: [], })), formatDiff: vi.fn(() => 'diff output'), countChanges: vi.fn(() => ({ additions: 5, deletions: 3 })), formatEditDiff: vi.fn(() => 'edit diff output'), })); import * as readline from 'readline'; import * as fs from 'fs/promises'; import { computeDiff, countChanges } from '../../../src/utils/diff.js'; describe('File Prompt - 文件操作提示', () => { let consoleLogSpy: ReturnType; beforeEach(() => { vi.clearAllMocks(); consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); }); afterEach(() => { consoleLogSpy.mockRestore(); }); describe('promptFileWrite - 文件写入提示', () => { const baseContext: FilePermissionContext = { operation: 'write', path: '/test/file.ts', workdir: '/test', toolName: 'write_file', newContent: 'new content', }; it('无内容时使用简单确认', async () => { const ctx: FilePermissionContext = { ...baseContext, newContent: undefined, }; const mockRl = { question: vi.fn((_, callback) => callback('y')), close: vi.fn(), }; vi.mocked(readline.createInterface).mockReturnValue(mockRl as any); const result = await promptFileWrite(ctx); expect(result.allow).toBe(true); }); it('内容相同时直接允许', async () => { vi.mocked(fs.readFile).mockResolvedValue('new content'); const result = await promptFileWrite(baseContext); expect(result).toEqual({ allow: true, remember: false }); }); it('新文件显示新增行数', async () => { vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); vi.mocked(computeDiff).mockReturnValue({ isNew: true, oldContent: null, newContent: 'new content', hunks: [], } as any); vi.mocked(countChanges).mockReturnValue({ additions: 10, deletions: 0 }); const mockRl = { question: vi.fn((_, callback) => callback('y')), close: vi.fn(), }; vi.mocked(readline.createInterface).mockReturnValue(mockRl as any); await promptFileWrite(baseContext); const calls = consoleLogSpy.mock.calls.flat().join('\n'); expect(calls).toContain('新文件'); expect(calls).toContain('+10 行'); }); it('修改文件显示增删行数', async () => { vi.mocked(fs.readFile).mockResolvedValue('old content'); vi.mocked(computeDiff).mockReturnValue({ isNew: false, oldContent: 'old content', newContent: 'new content', hunks: [], } as any); vi.mocked(countChanges).mockReturnValue({ additions: 5, deletions: 3 }); const mockRl = { question: vi.fn((_, callback) => callback('y')), close: vi.fn(), }; vi.mocked(readline.createInterface).mockReturnValue(mockRl as any); await promptFileWrite(baseContext); const calls = consoleLogSpy.mock.calls.flat().join('\n'); expect(calls).toContain('+5 行'); expect(calls).toContain('-3 行'); }); it('用户输入 y 返回允许不记住', async () => { vi.mocked(fs.readFile).mockResolvedValue('old content'); const mockRl = { question: vi.fn((_, callback) => callback('y')), close: vi.fn(), }; vi.mocked(readline.createInterface).mockReturnValue(mockRl as any); const result = await promptFileWrite(baseContext); expect(result).toEqual({ allow: true, remember: false }); }); it('用户输入 Y 返回允许并记住', async () => { vi.mocked(fs.readFile).mockResolvedValue('old content'); const mockRl = { question: vi.fn((_, callback) => callback('Y')), close: vi.fn(), }; vi.mocked(readline.createInterface).mockReturnValue(mockRl as any); const result = await promptFileWrite(baseContext); expect(result).toEqual({ allow: true, remember: true }); }); it('用户输入 n 返回拒绝不记住', async () => { vi.mocked(fs.readFile).mockResolvedValue('old content'); const mockRl = { question: vi.fn((_, callback) => callback('n')), close: vi.fn(), }; vi.mocked(readline.createInterface).mockReturnValue(mockRl as any); const result = await promptFileWrite(baseContext); expect(result).toEqual({ allow: false, remember: false }); }); it('用户输入 N 返回拒绝并记住', async () => { vi.mocked(fs.readFile).mockResolvedValue('old content'); const mockRl = { question: vi.fn((_, callback) => callback('N')), close: vi.fn(), }; vi.mocked(readline.createInterface).mockReturnValue(mockRl as any); const result = await promptFileWrite(baseContext); expect(result).toEqual({ allow: false, remember: true }); }); it('无效输入默认拒绝', async () => { vi.mocked(fs.readFile).mockResolvedValue('old content'); const mockRl = { question: vi.fn((_, callback) => callback('invalid')), close: vi.fn(), }; vi.mocked(readline.createInterface).mockReturnValue(mockRl as any); const result = await promptFileWrite(baseContext); expect(result).toEqual({ allow: false, remember: false }); }); it('超长 diff 被截断', async () => { vi.mocked(fs.readFile).mockResolvedValue('old content'); // 模拟超过 50 行的 diff const longDiff = Array(100).fill('line').join('\n'); const { formatDiff } = await import('../../../src/utils/diff.js'); vi.mocked(formatDiff).mockReturnValue(longDiff); const mockRl = { question: vi.fn((_, callback) => callback('y')), close: vi.fn(), }; vi.mocked(readline.createInterface).mockReturnValue(mockRl as any); await promptFileWrite(baseContext); const calls = consoleLogSpy.mock.calls.flat().join('\n'); expect(calls).toContain('省略'); }); }); describe('promptFileEdit - 文件编辑提示', () => { const baseContext: FilePermissionContext = { operation: 'edit', path: '/test/file.ts', workdir: '/test', toolName: 'edit_file', oldContent: 'old text', newContent: 'new text', }; it('无内容时使用简单确认', async () => { const ctx: FilePermissionContext = { ...baseContext, oldContent: undefined, newContent: undefined, }; const mockRl = { question: vi.fn((_, callback) => callback('y')), close: vi.fn(), }; vi.mocked(readline.createInterface).mockReturnValue(mockRl as any); const result = await promptFileEdit(ctx); expect(result.allow).toBe(true); }); it('显示编辑 diff', async () => { const mockRl = { question: vi.fn((_, callback) => callback('y')), close: vi.fn(), }; vi.mocked(readline.createInterface).mockReturnValue(mockRl as any); await promptFileEdit(baseContext); const calls = consoleLogSpy.mock.calls.flat().join('\n'); expect(calls).toContain('文件编辑预览'); expect(calls).toContain('/test/file.ts'); }); it('用户确认后返回决定', async () => { const mockRl = { question: vi.fn((_, callback) => callback('Y')), close: vi.fn(), }; vi.mocked(readline.createInterface).mockReturnValue(mockRl as any); const result = await promptFileEdit(baseContext); expect(result).toEqual({ allow: true, remember: true }); }); }); describe('promptFilePermission - 统一入口', () => { it('write 操作调用 promptFileWrite', async () => { vi.mocked(fs.readFile).mockResolvedValue('same content'); const ctx: FilePermissionContext = { operation: 'write', path: '/test/file.ts', workdir: '/test', toolName: 'write_file', newContent: 'same content', }; const result = await promptFilePermission(ctx); // 内容相同直接允许 expect(result).toEqual({ allow: true, remember: false }); }); it('edit 操作调用 promptFileEdit', async () => { const mockRl = { question: vi.fn((_, callback) => callback('y')), close: vi.fn(), }; vi.mocked(readline.createInterface).mockReturnValue(mockRl as any); const ctx: FilePermissionContext = { operation: 'edit', path: '/test/file.ts', workdir: '/test', toolName: 'edit_file', oldContent: 'old', newContent: 'new', }; await promptFilePermission(ctx); const calls = consoleLogSpy.mock.calls.flat().join('\n'); expect(calls).toContain('文件编辑预览'); }); it('其他操作使用简单确认', async () => { const mockRl = { question: vi.fn((_, callback) => callback('y')), close: vi.fn(), }; vi.mocked(readline.createInterface).mockReturnValue(mockRl as any); const ctx: FilePermissionContext = { operation: 'delete', path: '/test/file.ts', workdir: '/test', toolName: 'delete_file', }; await promptFilePermission(ctx); const calls = consoleLogSpy.mock.calls.flat().join('\n'); expect(calls).toContain('文件操作确认'); }); }); describe('确认选项显示', () => { it('显示所有选项', async () => { vi.mocked(fs.readFile).mockResolvedValue('old content'); const mockRl = { question: vi.fn((_, callback) => callback('y')), close: vi.fn(), }; vi.mocked(readline.createInterface).mockReturnValue(mockRl as any); await promptFileWrite({ operation: 'write', path: '/test/file.ts', workdir: '/test', toolName: 'write_file', newContent: 'new content', }); const calls = consoleLogSpy.mock.calls.flat().join('\n'); expect(calls).toContain('[y]'); expect(calls).toContain('[Y]'); expect(calls).toContain('[n]'); expect(calls).toContain('[N]'); expect(calls).toContain('确认执行'); expect(calls).toContain('拒绝执行'); expect(calls).toContain('记住'); }); }); describe('输入处理', () => { it('输入被 trim', async () => { vi.mocked(fs.readFile).mockResolvedValue('old content'); const mockRl = { question: vi.fn((_, callback) => callback(' y ')), close: vi.fn(), }; vi.mocked(readline.createInterface).mockReturnValue(mockRl as any); const result = await promptFileWrite({ operation: 'write', path: '/test/file.ts', workdir: '/test', toolName: 'write_file', newContent: 'new content', }); expect(result).toEqual({ allow: true, remember: false }); }); }); });