Files
ai-terminal-assistant/packages/core/tests/unit/permission/file-prompt.test.ts
T
kurihada 5e32375f0e feat: 重构为 Monorepo 架构并实现 HTTP Server
架构变更:
- 采用 pnpm workspaces 实现 Monorepo 结构
- 将现有代码迁移到 packages/core
- 新增 packages/server HTTP 服务层

Server 功能:
- REST API: 会话管理、工具管理、配置管理
- WebSocket: 实时双向通信支持
- SSE: 服务端事件推送
- Hono + Bun 作为运行时

API 端点:
- GET/POST /api/sessions - 会话 CRUD
- GET/POST /api/sessions/:id/messages - 消息管理
- GET /api/sessions/:id/events - SSE 事件流
- WS /api/ws/:sessionId - WebSocket 连接
- GET/POST /api/tools - 工具管理
- GET/PUT /api/config - 配置管理
2025-12-12 10:42:20 +08:00

400 lines
12 KiB
TypeScript

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 });
});
});
});