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,430 @@
|
||||
/**
|
||||
* 编辑模式测试
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import {
|
||||
createWholeFileEdit,
|
||||
createSearchReplaceEdit,
|
||||
createSingleSearchReplaceEdit,
|
||||
parseSearchReplaceBlocks,
|
||||
detectEditMode,
|
||||
normalizeSearchString,
|
||||
findSearchPositions,
|
||||
getSearchLineNumbers,
|
||||
validateEdit,
|
||||
applyEdit,
|
||||
applyBatchEdits,
|
||||
previewEdit,
|
||||
type SearchReplaceBlock,
|
||||
} from '../../src/editors/index.js';
|
||||
|
||||
describe('Edit Parsers', () => {
|
||||
describe('parseSearchReplaceBlocks', () => {
|
||||
it('should parse marker format blocks', () => {
|
||||
const content = `
|
||||
<<<<<<< SEARCH
|
||||
old content
|
||||
=======
|
||||
new content
|
||||
>>>>>>> REPLACE
|
||||
`;
|
||||
const blocks = parseSearchReplaceBlocks(content);
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].search).toBe('old content');
|
||||
expect(blocks[0].replace).toBe('new content');
|
||||
});
|
||||
|
||||
it('should parse multiple marker format blocks', () => {
|
||||
const content = `
|
||||
<<<<<<< SEARCH
|
||||
first old
|
||||
=======
|
||||
first new
|
||||
>>>>>>> REPLACE
|
||||
|
||||
<<<<<<< SEARCH
|
||||
second old
|
||||
=======
|
||||
second new
|
||||
>>>>>>> REPLACE
|
||||
`;
|
||||
const blocks = parseSearchReplaceBlocks(content);
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks[0].search).toBe('first old');
|
||||
expect(blocks[1].search).toBe('second old');
|
||||
});
|
||||
|
||||
it('should parse JSON array format', () => {
|
||||
const content = JSON.stringify([
|
||||
{ search: 'old1', replace: 'new1' },
|
||||
{ search: 'old2', replace: 'new2' },
|
||||
]);
|
||||
const blocks = parseSearchReplaceBlocks(content);
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks[0].search).toBe('old1');
|
||||
expect(blocks[1].replace).toBe('new2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWholeFileEdit', () => {
|
||||
it('should create whole file edit', () => {
|
||||
const edit = createWholeFileEdit('/test/file.ts', 'new content');
|
||||
expect(edit.mode).toBe('whole');
|
||||
expect(edit.filePath).toBe('/test/file.ts');
|
||||
expect(edit.content).toBe('new content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSearchReplaceEdit', () => {
|
||||
it('should create search-replace edit', () => {
|
||||
const blocks: SearchReplaceBlock[] = [
|
||||
{ search: 'old', replace: 'new' },
|
||||
];
|
||||
const edit = createSearchReplaceEdit('/test/file.ts', blocks);
|
||||
expect(edit.mode).toBe('search-replace');
|
||||
expect(edit.blocks).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectEditMode', () => {
|
||||
it('should detect diff mode', () => {
|
||||
const content = `diff --git a/file.txt b/file.txt
|
||||
@@ -1,3 +1,4 @@
|
||||
line1
|
||||
+new line
|
||||
line2`;
|
||||
expect(detectEditMode(content, 100)).toBe('diff');
|
||||
});
|
||||
|
||||
it('should detect search-replace mode', () => {
|
||||
const content = `<<<<<<< SEARCH
|
||||
old
|
||||
=======
|
||||
new
|
||||
>>>>>>> REPLACE`;
|
||||
expect(detectEditMode(content, 100)).toBe('search-replace');
|
||||
});
|
||||
|
||||
it('should default to whole for small content', () => {
|
||||
const content = 'simple content';
|
||||
expect(detectEditMode(content, 100)).toBe('whole');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeSearchString', () => {
|
||||
it('should normalize line endings', () => {
|
||||
const input = 'line1\r\nline2\rline3\n';
|
||||
const result = normalizeSearchString(input);
|
||||
expect(result).toBe('line1\nline2\nline3\n');
|
||||
});
|
||||
|
||||
it('should trim trailing whitespace', () => {
|
||||
const input = 'line1 \nline2 ';
|
||||
const result = normalizeSearchString(input, { trimTrailingWhitespace: true });
|
||||
expect(result).toBe('line1\nline2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSearchPositions', () => {
|
||||
it('should find all positions', () => {
|
||||
const content = 'foo bar foo baz foo';
|
||||
const positions = findSearchPositions(content, 'foo');
|
||||
expect(positions).toEqual([0, 8, 16]);
|
||||
});
|
||||
|
||||
it('should return empty array when not found', () => {
|
||||
const content = 'hello world';
|
||||
const positions = findSearchPositions(content, 'xyz');
|
||||
expect(positions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSearchLineNumbers', () => {
|
||||
it('should return line numbers', () => {
|
||||
const content = 'line1\nfoo\nline3\nfoo';
|
||||
const lines = getSearchLineNumbers(content, 'foo');
|
||||
expect(lines).toEqual([2, 4]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit Validator', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = path.join(os.tmpdir(), `edit-test-${Date.now()}`);
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
describe('validateEdit', () => {
|
||||
it('should validate whole file edit for new file', async () => {
|
||||
const filePath = path.join(tempDir, 'new.txt');
|
||||
const edit = createWholeFileEdit(filePath, 'new content');
|
||||
const result = await validateEdit(edit);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should validate search-replace with unique match', async () => {
|
||||
const filePath = path.join(tempDir, 'test.txt');
|
||||
await fs.writeFile(filePath, 'hello world');
|
||||
|
||||
const edit = createSingleSearchReplaceEdit(filePath, 'hello', 'hi');
|
||||
const result = await validateEdit(edit);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail search-replace when not found', async () => {
|
||||
const filePath = path.join(tempDir, 'test.txt');
|
||||
await fs.writeFile(filePath, 'hello world');
|
||||
|
||||
const edit = createSingleSearchReplaceEdit(filePath, 'xyz', 'abc');
|
||||
const result = await validateEdit(edit);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0].type).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should fail search-replace when ambiguous', async () => {
|
||||
const filePath = path.join(tempDir, 'test.txt');
|
||||
await fs.writeFile(filePath, 'foo bar foo baz');
|
||||
|
||||
const edit = createSingleSearchReplaceEdit(filePath, 'foo', 'qux');
|
||||
const result = await validateEdit(edit);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0].type).toBe('ambiguous');
|
||||
expect(result.errors[0].occurrences).toBe(2);
|
||||
});
|
||||
|
||||
it('should fail search-replace when file not exists', async () => {
|
||||
const filePath = path.join(tempDir, 'nonexistent.txt');
|
||||
const edit = createSingleSearchReplaceEdit(filePath, 'old', 'new');
|
||||
const result = await validateEdit(edit);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0].type).toBe('not_found');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit Applier', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = path.join(os.tmpdir(), `edit-apply-test-${Date.now()}`);
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
describe('applyEdit', () => {
|
||||
it('should apply whole file edit to new file', async () => {
|
||||
const filePath = path.join(tempDir, 'new.txt');
|
||||
const edit = createWholeFileEdit(filePath, 'hello world');
|
||||
|
||||
const result = await applyEdit(edit, { runDiagnostics: false });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newContent).toBe('hello world');
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
expect(content).toBe('hello world');
|
||||
});
|
||||
|
||||
it('should apply whole file edit to existing file', async () => {
|
||||
const filePath = path.join(tempDir, 'existing.txt');
|
||||
await fs.writeFile(filePath, 'old content');
|
||||
|
||||
const edit = createWholeFileEdit(filePath, 'new content');
|
||||
const result = await applyEdit(edit, { runDiagnostics: false });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.originalContent).toBe('old content');
|
||||
expect(result.newContent).toBe('new content');
|
||||
});
|
||||
|
||||
it('should apply single search-replace edit', async () => {
|
||||
const filePath = path.join(tempDir, 'test.txt');
|
||||
await fs.writeFile(filePath, 'hello world');
|
||||
|
||||
const edit = createSingleSearchReplaceEdit(filePath, 'world', 'universe');
|
||||
const result = await applyEdit(edit, { runDiagnostics: false });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newContent).toBe('hello universe');
|
||||
});
|
||||
|
||||
it('should apply multiple search-replace blocks', async () => {
|
||||
const filePath = path.join(tempDir, 'test.txt');
|
||||
await fs.writeFile(filePath, 'const foo = 1;\nconst bar = 2;');
|
||||
|
||||
const edit = createSearchReplaceEdit(filePath, [
|
||||
{ search: 'const foo = 1;', replace: 'const foo = 10;' },
|
||||
{ search: 'const bar = 2;', replace: 'const bar = 20;' },
|
||||
]);
|
||||
const result = await applyEdit(edit, { runDiagnostics: false });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newContent).toBe('const foo = 10;\nconst bar = 20;');
|
||||
});
|
||||
|
||||
it('should fail when search not found', async () => {
|
||||
const filePath = path.join(tempDir, 'test.txt');
|
||||
await fs.writeFile(filePath, 'hello world');
|
||||
|
||||
const edit = createSingleSearchReplaceEdit(filePath, 'xyz', 'abc');
|
||||
const result = await applyEdit(edit, { runDiagnostics: false });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('验证失败');
|
||||
});
|
||||
|
||||
it('should fail when search is ambiguous', async () => {
|
||||
const filePath = path.join(tempDir, 'test.txt');
|
||||
await fs.writeFile(filePath, 'foo foo foo');
|
||||
|
||||
const edit = createSingleSearchReplaceEdit(filePath, 'foo', 'bar');
|
||||
const result = await applyEdit(edit, { runDiagnostics: false });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('验证失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('previewEdit', () => {
|
||||
it('should preview edit without writing', async () => {
|
||||
const filePath = path.join(tempDir, 'test.txt');
|
||||
await fs.writeFile(filePath, 'hello world');
|
||||
|
||||
const edit = createSingleSearchReplaceEdit(filePath, 'world', 'universe');
|
||||
const result = await previewEdit(edit);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newContent).toBe('hello universe');
|
||||
|
||||
// 文件应该保持不变
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
expect(content).toBe('hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyBatchEdits', () => {
|
||||
it('should apply multiple edits', async () => {
|
||||
const file1 = path.join(tempDir, 'file1.txt');
|
||||
const file2 = path.join(tempDir, 'file2.txt');
|
||||
await fs.writeFile(file1, 'content 1');
|
||||
await fs.writeFile(file2, 'content 2');
|
||||
|
||||
const result = await applyBatchEdits({
|
||||
edits: [
|
||||
createWholeFileEdit(file1, 'new content 1'),
|
||||
createWholeFileEdit(file2, 'new content 2'),
|
||||
],
|
||||
}, { runDiagnostics: false });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.results).toHaveLength(2);
|
||||
|
||||
const content1 = await fs.readFile(file1, 'utf-8');
|
||||
const content2 = await fs.readFile(file2, 'utf-8');
|
||||
expect(content1).toBe('new content 1');
|
||||
expect(content2).toBe('new content 2');
|
||||
});
|
||||
|
||||
it('should rollback atomic batch on failure', async () => {
|
||||
const file1 = path.join(tempDir, 'file1.txt');
|
||||
const file2 = path.join(tempDir, 'file2.txt');
|
||||
await fs.writeFile(file1, 'original 1');
|
||||
await fs.writeFile(file2, 'original 2');
|
||||
|
||||
const result = await applyBatchEdits({
|
||||
edits: [
|
||||
createWholeFileEdit(file1, 'new content 1'),
|
||||
createSingleSearchReplaceEdit(file2, 'not found', 'replacement'),
|
||||
],
|
||||
atomic: true,
|
||||
}, { runDiagnostics: false });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
// file1 应该被回滚
|
||||
const content1 = await fs.readFile(file1, 'utf-8');
|
||||
expect(content1).toBe('original 1');
|
||||
});
|
||||
|
||||
it('should calculate total stats', async () => {
|
||||
const file1 = path.join(tempDir, 'file1.txt');
|
||||
const file2 = path.join(tempDir, 'file2.txt');
|
||||
await fs.writeFile(file1, 'line1\nline2');
|
||||
await fs.writeFile(file2, 'a\nb\nc');
|
||||
|
||||
const result = await applyBatchEdits({
|
||||
edits: [
|
||||
createWholeFileEdit(file1, 'line1\nline2\nline3'),
|
||||
createWholeFileEdit(file2, 'x\ny'),
|
||||
],
|
||||
}, { runDiagnostics: false });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.totalStats.blocksApplied).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit Stats', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = path.join(os.tmpdir(), `edit-stats-test-${Date.now()}`);
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should count additions for new file', async () => {
|
||||
const filePath = path.join(tempDir, 'new.txt');
|
||||
const edit = createWholeFileEdit(filePath, 'line1\nline2\nline3');
|
||||
|
||||
const result = await applyEdit(edit, { runDiagnostics: false });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stats?.additions).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should count blocks applied', async () => {
|
||||
const filePath = path.join(tempDir, 'test.txt');
|
||||
await fs.writeFile(filePath, 'aaa bbb ccc');
|
||||
|
||||
const edit = createSearchReplaceEdit(filePath, [
|
||||
{ search: 'aaa', replace: 'AAA' },
|
||||
{ search: 'ccc', replace: 'CCC' },
|
||||
]);
|
||||
|
||||
const result = await applyEdit(edit, { runDiagnostics: false });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.stats?.blocksApplied).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user