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 - 配置管理
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
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';
|
||||
|
||||
describe('Diff - 差异比较扩展测试', () => {
|
||||
describe('computeDiff - 计算 diff', () => {
|
||||
it('新文件所有行都是添加', () => {
|
||||
const diff = computeDiff(null, 'line1\nline2\nline3');
|
||||
|
||||
expect(diff.isNew).toBe(true);
|
||||
expect(diff.oldContent).toBeNull();
|
||||
expect(diff.hunks.length).toBe(1);
|
||||
expect(diff.hunks[0].lines.every(l => l.type === 'add')).toBe(true);
|
||||
expect(diff.hunks[0].newCount).toBe(3);
|
||||
});
|
||||
|
||||
it('相同内容返回空 hunks', () => {
|
||||
const content = 'line1\nline2\nline3';
|
||||
const diff = computeDiff(content, content);
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
// 相同内容可能没有实际变化的 hunks
|
||||
const hasChanges = diff.hunks.some(h =>
|
||||
h.lines.some(l => l.type === 'add' || l.type === 'remove')
|
||||
);
|
||||
expect(hasChanges).toBe(false);
|
||||
});
|
||||
|
||||
it('检测添加的行', () => {
|
||||
const oldContent = 'line1\nline2';
|
||||
const newContent = 'line1\nline2\nline3';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
const addedLines = diff.hunks.flatMap(h => h.lines.filter(l => l.type === 'add'));
|
||||
expect(addedLines.some(l => l.content === 'line3')).toBe(true);
|
||||
});
|
||||
|
||||
it('检测删除的行', () => {
|
||||
const oldContent = 'line1\nline2\nline3';
|
||||
const newContent = 'line1\nline2';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
const removedLines = diff.hunks.flatMap(h => h.lines.filter(l => l.type === 'remove'));
|
||||
expect(removedLines.some(l => l.content === 'line3')).toBe(true);
|
||||
});
|
||||
|
||||
it('检测修改的行(删除+添加)', () => {
|
||||
const oldContent = 'line1\nold line\nline3';
|
||||
const newContent = 'line1\nnew line\nline3';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
const removedLines = diff.hunks.flatMap(h => h.lines.filter(l => l.type === 'remove'));
|
||||
const addedLines = diff.hunks.flatMap(h => h.lines.filter(l => l.type === 'add'));
|
||||
|
||||
expect(removedLines.some(l => l.content === 'old line')).toBe(true);
|
||||
expect(addedLines.some(l => l.content === 'new line')).toBe(true);
|
||||
});
|
||||
|
||||
it('处理空文件', () => {
|
||||
const diff = computeDiff('', 'new content');
|
||||
|
||||
expect(diff.isNew).toBe(false);
|
||||
// 空到有内容是添加
|
||||
const addedLines = diff.hunks.flatMap(h => h.lines.filter(l => l.type === 'add'));
|
||||
expect(addedLines.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('处理多个不连续的变更', () => {
|
||||
const oldContent = 'line1\nkeep1\nold2\nkeep2\nold3\nline6';
|
||||
const newContent = 'line1\nkeep1\nnew2\nkeep2\nnew3\nline6';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
// 应该有变更
|
||||
const changes = diff.hunks.flatMap(h =>
|
||||
h.lines.filter(l => l.type === 'add' || l.type === 'remove')
|
||||
);
|
||||
expect(changes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('处理完全不同的内容', () => {
|
||||
const oldContent = 'completely\ndifferent\ncontent';
|
||||
const newContent = 'totally\nnew\nstuff';
|
||||
const diff = computeDiff(oldContent, newContent);
|
||||
|
||||
expect(diff.hunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
const changes = countChanges(diff);
|
||||
|
||||
expect(changes.additions).toBe(3);
|
||||
expect(changes.deletions).toBe(0);
|
||||
});
|
||||
|
||||
it('统计删除行数', () => {
|
||||
const diff = computeDiff('line1\nline2\nline3', '');
|
||||
const changes = countChanges(diff);
|
||||
|
||||
expect(changes.deletions).toBe(3);
|
||||
});
|
||||
|
||||
it('统计混合变更', () => {
|
||||
const diff = computeDiff('old1\nold2', 'new1\nold2\nnew2');
|
||||
const changes = countChanges(diff);
|
||||
|
||||
// 具体数字取决于 diff 算法实现
|
||||
expect(changes.additions).toBeGreaterThanOrEqual(0);
|
||||
expect(changes.deletions).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('无变更返回零', () => {
|
||||
const diff = computeDiff('same', 'same');
|
||||
const changes = countChanges(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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user