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:
2025-12-13 01:09:35 +08:00
parent 5d4afecd48
commit 1d69fd876d
20 changed files with 739 additions and 1560 deletions
-1
View File
@@ -52,7 +52,6 @@
"@ai-sdk/openai": "^2.0.80",
"@tavily/core": "^0.6.0",
"ai": "^5.0.108",
"chalk": "^5.3.0",
"js-yaml": "^4.1.1",
"minimatch": "^10.1.1",
"nanoid": "^5.1.6",
+11 -2
View File
@@ -1,6 +1,7 @@
export { Agent } from './core/agent.js';
export { toolRegistry, todoManager, initTaskContext, updateTaskDescription, updateSkillDescription } from './tools/index.js';
export { loadConfig, initConfig } from './utils/config.js';
export { loadConfig, saveConfig, getConfig, loadVisionConfig } from './utils/config.js';
export type { VisionConfig } from './utils/config.js';
export { SessionStorage } from './session/storage.js';
export { SessionManager } from './session/index.js';
export type { SessionData, SessionSummary } from './session/types.js';
@@ -9,7 +10,15 @@ export type { SessionData, SessionSummary } from './session/types.js';
export type { UserInput } from './types/index.js';
// Permission
export { getPermissionManager, promptPermission } from './permission/index.js';
export { getPermissionManager } from './permission/index.js';
export type {
PermissionContext,
PermissionDecision,
PermissionCheckResult,
FilePermissionContext,
GitPermissionContext,
WebPermissionContext,
} from './permission/index.js';
// LSP
export { initLSP, shutdownLSP } from './lsp/index.js';
+1 -23
View File
@@ -9,7 +9,6 @@ import type {
PermissionContext,
} from '../types.js';
import type { PermissionChecker } from './base.js';
import { promptFilePermission } from '../file-prompt.js';
const CONFIG_DIR = path.join(os.homedir(), '.ai-terminal-assistant');
const FILE_PERMISSION_FILE = path.join(CONFIG_DIR, 'file-permissions.json');
@@ -252,28 +251,7 @@ export class FilePermissionChecker implements PermissionChecker {
sessionKey: string,
reason: string
): Promise<PermissionCheckResult> {
// 对于 write/edit 操作,如果有内容信息,使用 diff 显示
if ((ctx.operation === 'write' || ctx.operation === 'edit') && ctx.newContent !== undefined) {
// 更新 ctx 中的路径为绝对路径
const ctxWithAbsPath: FilePermissionContext = {
...ctx,
path: absolutePath,
};
const decision = await promptFilePermission(ctxWithAbsPath);
if (decision.remember) {
this.sessionPermissions.set(sessionKey, decision.allow ? 'allow' : 'deny');
}
return {
allowed: decision.allow,
action: decision.allow ? 'allow' : 'deny',
reason: decision.allow ? '用户允许' : '用户拒绝',
};
}
// 其他操作使用原有的回调
// 如果没有回调,返回需要确认的状态
if (!this.askCallback) {
return {
allowed: false,
-186
View File
@@ -1,186 +0,0 @@
/**
* 文件操作确认提示
* 显示 diff 对比并让用户确认
*/
import * as readline from 'readline';
import * as fs from 'fs/promises';
import chalk from 'chalk';
import type { FilePermissionContext, PermissionDecision } from './types.js';
import { computeDiff, formatDiff, countChanges, formatEditDiff } from '../utils/diff.js';
/**
* 显示文件写入的 diff 并请求确认
*/
export async function promptFileWrite(ctx: FilePermissionContext): Promise<PermissionDecision> {
const { path: filePath, newContent } = ctx;
if (!newContent) {
// 没有内容,使用简单确认
return promptSimpleConfirm(ctx);
}
// 读取原文件内容
let oldContent: string | null = null;
try {
oldContent = await fs.readFile(filePath, 'utf-8');
} catch {
// 文件不存在,是新文件
}
// 如果内容相同,直接允许
if (oldContent === newContent) {
return { allow: true, remember: false };
}
// 计算 diff
const diff = computeDiff(oldContent, newContent);
const changes = countChanges(diff);
// 显示 diff
console.log('');
console.log(chalk.yellow('📝 文件写入预览'));
console.log(chalk.cyan('文件: ') + chalk.white(filePath));
if (diff.isNew) {
console.log(chalk.green('状态: ') + chalk.white('新文件'));
console.log(chalk.green(`+${changes.additions}`));
} else {
console.log(chalk.green(`+${changes.additions}`) + ' / ' + chalk.red(`-${changes.deletions}`));
}
console.log('');
console.log(chalk.gray('─'.repeat(60)));
// 限制显示行数
const diffOutput = formatDiff(diff, filePath);
const lines = diffOutput.split('\n');
const MAX_LINES = 50;
if (lines.length > MAX_LINES) {
console.log(lines.slice(0, MAX_LINES).join('\n'));
console.log(chalk.yellow(`\n... 省略 ${lines.length - MAX_LINES} 行 ...`));
} else {
console.log(diffOutput);
}
console.log(chalk.gray('─'.repeat(60)));
console.log('');
// 询问用户确认
return promptConfirm();
}
/**
* 显示文件编辑的 diff 并请求确认
*/
export async function promptFileEdit(ctx: FilePermissionContext): Promise<PermissionDecision> {
const { path: filePath, oldContent, newContent } = ctx;
if (!oldContent || !newContent) {
// 没有内容,使用简单确认
return promptSimpleConfirm(ctx);
}
// 显示编辑 diff
console.log('');
console.log(chalk.yellow('✏️ 文件编辑预览'));
console.log(chalk.cyan('文件: ') + chalk.white(filePath));
console.log('');
console.log(chalk.gray('─'.repeat(60)));
console.log(formatEditDiff(oldContent, newContent));
console.log(chalk.gray('─'.repeat(60)));
console.log('');
// 询问用户确认
return promptConfirm();
}
/**
* 简单确认(无 diff
*/
async function promptSimpleConfirm(ctx: FilePermissionContext): Promise<PermissionDecision> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
console.log('');
console.log(chalk.yellow('⚠️ 文件操作确认'));
console.log(chalk.cyan('操作: ') + chalk.white(ctx.operation));
console.log(chalk.cyan('文件: ') + chalk.white(ctx.path));
console.log('');
showConfirmOptions();
rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => {
rl.close();
resolve(parseAnswer(answer));
});
});
}
/**
* 通用确认提示
*/
async function promptConfirm(): Promise<PermissionDecision> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
showConfirmOptions();
rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => {
rl.close();
resolve(parseAnswer(answer));
});
});
}
/**
* 显示确认选项
*/
function showConfirmOptions(): void {
console.log(chalk.white('选择操作:'));
console.log(chalk.green(' [y] ') + '确认执行');
console.log(chalk.green(' [Y] ') + '确认执行,并记住此类操作(本次会话)');
console.log(chalk.red(' [n] ') + '拒绝执行');
console.log(chalk.red(' [N] ') + '拒绝执行,并记住此类操作(本次会话)');
console.log('');
}
/**
* 解析用户输入
*/
function parseAnswer(answer: string): PermissionDecision {
const choice = answer.trim();
switch (choice) {
case 'y':
return { allow: true, remember: false };
case 'Y':
return { allow: true, remember: true };
case 'N':
return { allow: false, remember: true };
case 'n':
default:
return { allow: false, remember: false };
}
}
/**
* 根据操作类型选择合适的确认提示
*/
export async function promptFilePermission(ctx: FilePermissionContext): Promise<PermissionDecision> {
switch (ctx.operation) {
case 'write':
return promptFileWrite(ctx);
case 'edit':
return promptFileEdit(ctx);
default:
return promptSimpleConfirm(ctx);
}
}
+5 -4
View File
@@ -8,16 +8,17 @@ export type {
FileOperation,
FilePermissionContext,
FilePermissionConfig,
WebPermissionContext,
WebPermissionConfig,
GitOperation,
GitPermissionContext,
GitPermissionConfig,
} from './types.js';
export { matchPattern, matchRules, parseCommand, generateAskPattern } from './wildcard.js';
export { PermissionManager, getPermissionManager, resetPermissionManager } from './manager.js';
export { promptPermission, showPermissionDenied, showPermissionAllowed } from './prompt.js';
export { promptFilePermission, promptFileWrite, promptFileEdit } from './file-prompt.js';
// Checker pattern exports
export type { PermissionChecker, BasePermissionConfig } from './checkers/base.js';
export { BashPermissionChecker } from './checkers/bash.js';
-79
View File
@@ -1,79 +0,0 @@
import * as readline from 'readline';
import chalk from 'chalk';
import type { PermissionContext, PermissionDecision } from './types.js';
/**
* 在终端中提示用户确认权限
*/
export async function promptPermission(ctx: PermissionContext): Promise<PermissionDecision> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
console.log('');
console.log(chalk.yellow('⚠️ 权限确认'));
console.log(chalk.cyan('命令: ') + chalk.white(ctx.command));
console.log(chalk.cyan('目录: ') + chalk.gray(ctx.workdir));
if (ctx.externalPaths && ctx.externalPaths.length > 0) {
console.log(chalk.red('⚠️ 此命令访问项目目录外的路径:'));
ctx.externalPaths.forEach(p => {
console.log(chalk.red(' • ') + chalk.gray(p));
});
}
if (ctx.patterns && ctx.patterns.length > 0) {
console.log(chalk.gray('匹配模式: ') + ctx.patterns.join(', '));
}
console.log('');
console.log(chalk.white('选择操作:'));
console.log(chalk.green(' [y] ') + '允许执行');
console.log(chalk.green(' [Y] ') + '允许执行,并记住此类命令(本次会话)');
console.log(chalk.red(' [n] ') + '拒绝执行');
console.log(chalk.red(' [N] ') + '拒绝执行,并记住此类命令(本次会话)');
console.log('');
rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => {
rl.close();
const choice = answer.trim();
switch (choice) {
case 'y':
resolve({ allow: true, remember: false });
break;
case 'Y':
resolve({ allow: true, remember: true });
break;
case 'N':
resolve({ allow: false, remember: true });
break;
case 'n':
default:
resolve({ allow: false, remember: false });
break;
}
});
});
}
/**
* 显示权限被拒绝的消息
*/
export function showPermissionDenied(command: string, reason: string): void {
console.log('');
console.log(chalk.red('🚫 权限被拒绝'));
console.log(chalk.cyan('命令: ') + chalk.white(command));
console.log(chalk.cyan('原因: ') + chalk.gray(reason));
console.log('');
}
/**
* 显示权限允许的消息
*/
export function showPermissionAllowed(command: string): void {
console.log(chalk.green('✓ ') + chalk.gray(`执行: ${command}`));
}
-161
View File
@@ -223,164 +223,3 @@ export function saveConfig(config: Partial<StoredConfig>): void {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(newConfig, null, 2));
}
// 初始化配置向导
export async function initConfig(): Promise<void> {
const { default: inquirer } = await import('inquirer');
console.log('\n🔧 初始化 AI Terminal Assistant 配置\n');
// 选择 provider
const { provider } = await inquirer.prompt([
{
type: 'list',
name: 'provider',
message: '选择 AI 服务商:',
choices: [
{ name: 'Anthropic (Claude)', value: 'anthropic' },
{ name: 'OpenAI (GPT)', value: 'openai' },
{ name: 'OpenAI 兼容服务 (阿里云百炼、Azure 等)', value: 'openai-compatible' },
{ name: 'DeepSeek', value: 'deepseek' },
],
default: 'anthropic',
},
]);
// 是否是 OpenAI 兼容服务
const isOpenAICompatible = provider === 'openai-compatible';
const actualProvider = isOpenAICompatible ? 'openai' : provider;
// 如果是 OpenAI 兼容服务,询问 base URL
let baseUrl: string | undefined;
if (isOpenAICompatible) {
const { customBaseUrl } = await inquirer.prompt([
{
type: 'input',
name: 'customBaseUrl',
message: '请输入 API 基础 URL (如: https://dashscope.aliyuncs.com/compatible-mode/v1):',
validate: (input: string) => {
if (!input) return 'Base URL 不能为空';
try {
new URL(input);
return true;
} catch {
return '请输入有效的 URL';
}
},
},
]);
baseUrl = customBaseUrl;
}
// 根据 provider 显示不同的模型选项
let modelChoices: Array<{ name: string; value: string }>;
let allowCustomModel = false;
if (actualProvider === 'anthropic') {
modelChoices = [
{ name: 'Claude Sonnet 4 (推荐,平衡性能和成本)', value: 'claude-sonnet-4-20250514' },
{ name: 'Claude Opus 4 (最强,成本较高)', value: 'claude-opus-4-20250514' },
{ name: 'Claude 3.5 Haiku (快速,成本低)', value: 'claude-3-5-haiku-20241022' },
];
} else if (actualProvider === 'openai') {
if (isOpenAICompatible) {
// OpenAI 兼容服务允许自定义模型名称
modelChoices = [
{ name: 'qwen-plus (通义千问)', value: 'qwen-plus' },
{ name: 'qwen-turbo (通义千问快速版)', value: 'qwen-turbo' },
{ name: 'qwen-max (通义千问最强版)', value: 'qwen-max' },
{ name: 'gpt-4o', value: 'gpt-4o' },
{ name: '自定义模型名称...', value: '__custom__' },
];
allowCustomModel = true;
} else {
modelChoices = [
{ name: 'GPT-4o (推荐,支持 vision)', value: 'gpt-4o' },
{ name: 'GPT-4o mini (快速,成本低)', value: 'gpt-4o-mini' },
{ name: 'GPT-4 Turbo', value: 'gpt-4-turbo' },
{ name: 'o1 (推理增强)', value: 'o1' },
{ name: 'o1-mini (推理,成本低)', value: 'o1-mini' },
];
}
} else {
modelChoices = [
{ name: 'DeepSeek Chat (推荐)', value: 'deepseek-chat' },
{ name: 'DeepSeek Reasoner (推理增强)', value: 'deepseek-reasoner' },
];
}
const apiKeyMessageMap: Record<string, string> = {
anthropic: '请输入你的 Anthropic API Key:',
openai: '请输入你的 OpenAI API Key:',
deepseek: '请输入你的 DeepSeek API Key:',
};
// 分开询问 API Key
const { apiKey } = await inquirer.prompt([
{
type: 'password',
name: 'apiKey',
message: isOpenAICompatible ? '请输入你的 API Key:' : apiKeyMessageMap[actualProvider],
validate: (input: string) => input.length > 0 || 'API Key 不能为空',
},
]);
// 询问模型配置
const { model: selectedModel } = await inquirer.prompt([
{
type: 'list',
name: 'model',
message: '选择默认模型:',
choices: modelChoices,
default: DEFAULT_MODELS[actualProvider as ProviderType],
},
]);
// 如果选择自定义模型,询问模型名称
let finalModel = selectedModel;
if (allowCustomModel && selectedModel === '__custom__') {
const { customModel } = await inquirer.prompt([
{
type: 'input',
name: 'customModel',
message: '请输入模型名称:',
validate: (input: string) => input.length > 0 || '模型名称不能为空',
},
]);
finalModel = customModel;
}
// 询问 token 配置
const { maxTokens } = await inquirer.prompt([
{
type: 'number',
name: 'maxTokens',
message: '最大输出 token 数:',
default: 4096,
},
]);
// 根据 provider 构建配置对象
const configToSave: Partial<StoredConfig> = {
provider: actualProvider as ProviderType,
model: finalModel,
maxTokens,
};
// 存储 API Key 到对应字段
if (actualProvider === 'anthropic') {
configToSave.apiKey = apiKey;
} else if (actualProvider === 'openai') {
configToSave.openaiApiKey = apiKey;
} else if (actualProvider === 'deepseek') {
configToSave.deepseekApiKey = apiKey;
}
// 存储 base URL
if (baseUrl) {
configToSave.baseUrl = baseUrl;
}
saveConfig(configToSave);
console.log('\n✅ 配置已保存到', CONFIG_FILE);
console.log('现在可以运行 ai-assist 开始使用了!\n');
}
+2 -157
View File
@@ -1,12 +1,8 @@
/**
* 文件 diff 对比和确认工具
* 用于在写入文件前显示变更并让用户确认
* 文件 diff 计算工具
* 纯计算逻辑,不包含任何 UI 渲染
*/
import * as readline from 'readline';
import * as fs from 'fs/promises';
import chalk from 'chalk';
export interface DiffLine {
type: 'add' | 'remove' | 'context';
lineNumber: number | null;
@@ -225,47 +221,6 @@ function computeLCS(oldLines: string[], newLines: string[]): Array<{ content: st
return result;
}
/**
* 格式化 diff 输出
*/
export function formatDiff(diff: DiffResult, filePath: string): string {
const lines: string[] = [];
if (diff.isNew) {
lines.push(chalk.green(`+++ 新文件: ${filePath}`));
} else {
lines.push(chalk.gray(`--- ${filePath} (原文件)`));
lines.push(chalk.green(`+++ ${filePath} (修改后)`));
}
lines.push('');
for (const hunk of diff.hunks) {
// Hunk 头部
lines.push(chalk.cyan(`@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`));
for (const line of hunk.lines) {
const lineNum = line.lineNumber ? chalk.gray(`${line.lineNumber.toString().padStart(4)} `) : ' ';
switch (line.type) {
case 'add':
lines.push(chalk.green(`${lineNum}+ ${line.content}`));
break;
case 'remove':
lines.push(chalk.red(`${lineNum}- ${line.content}`));
break;
case 'context':
lines.push(chalk.gray(`${lineNum} ${line.content}`));
break;
}
}
lines.push('');
}
return lines.join('\n');
}
/**
* 统计变更数量
*/
@@ -282,113 +237,3 @@ export function countChanges(diff: DiffResult): { additions: number; deletions:
return { additions, deletions };
}
export interface FileConfirmResult {
confirmed: boolean;
remember: boolean;
}
/**
* 显示 diff 并让用户确认
*/
export async function confirmFileChange(
filePath: string,
newContent: string,
operation: 'write' | 'edit'
): Promise<FileConfirmResult> {
// 读取原文件内容
let oldContent: string | null = null;
try {
oldContent = await fs.readFile(filePath, 'utf-8');
} catch {
// 文件不存在,是新文件
}
// 如果内容相同,直接通过
if (oldContent === newContent) {
return { confirmed: true, remember: false };
}
// 计算 diff
const diff = computeDiff(oldContent, newContent);
const changes = countChanges(diff);
// 显示 diff
console.log('');
console.log(chalk.yellow('📝 文件变更预览'));
console.log(chalk.cyan('操作: ') + chalk.white(operation === 'write' ? '写入文件' : '编辑文件'));
console.log(chalk.cyan('文件: ') + chalk.white(filePath));
console.log(chalk.green(`+${changes.additions}`) + ' / ' + chalk.red(`-${changes.deletions}`));
console.log('');
console.log(chalk.gray('─'.repeat(60)));
console.log(formatDiff(diff, filePath));
console.log(chalk.gray('─'.repeat(60)));
console.log('');
// 询问用户确认
return promptFileConfirm();
}
/**
* 提示用户确认文件操作
*/
async function promptFileConfirm(): Promise<FileConfirmResult> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
console.log(chalk.white('选择操作:'));
console.log(chalk.green(' [y] ') + '确认写入');
console.log(chalk.green(' [Y] ') + '确认写入,并记住此类操作(本次会话)');
console.log(chalk.red(' [n] ') + '取消操作');
console.log(chalk.red(' [N] ') + '取消操作,并记住此类操作(本次会话)');
console.log('');
rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => {
rl.close();
const choice = answer.trim();
switch (choice) {
case 'y':
resolve({ confirmed: true, remember: false });
break;
case 'Y':
resolve({ confirmed: true, remember: true });
break;
case 'N':
resolve({ confirmed: false, remember: true });
break;
case 'n':
default:
resolve({ confirmed: false, remember: false });
break;
}
});
});
}
/**
* 简化的 diff 显示(用于编辑操作,只显示变更部分)
*/
export function formatEditDiff(oldString: string, newString: string): string {
const lines: string[] = [];
lines.push(chalk.gray('变更内容:'));
// 显示删除的内容
const oldLines = oldString.split('\n');
for (const line of oldLines) {
lines.push(chalk.red(`- ${line}`));
}
// 显示新增的内容
const newLines = newString.split('\n');
for (const line of newLines) {
lines.push(chalk.green(`+ ${line}`));
}
return lines.join('\n');
}
@@ -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 -75
View File
@@ -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('函数修改', () => {