5e32375f0e
架构变更: - 采用 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 - 配置管理
358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { LSPClientManager } from '../../../src/lsp/client.js';
|
|
import { DiagnosticSeverity } from 'vscode-languageserver-protocol';
|
|
|
|
// Mock child_process
|
|
const mockSpawn = vi.fn();
|
|
const mockExecSync = vi.fn();
|
|
|
|
vi.mock('child_process', () => ({
|
|
spawn: (...args: unknown[]) => mockSpawn(...args),
|
|
execSync: (...args: unknown[]) => mockExecSync(...args),
|
|
}));
|
|
|
|
// Mock vscode-jsonrpc
|
|
const mockConnection = {
|
|
listen: vi.fn(),
|
|
sendRequest: vi.fn().mockResolvedValue({}),
|
|
sendNotification: vi.fn(),
|
|
onNotification: vi.fn(),
|
|
dispose: vi.fn(),
|
|
};
|
|
|
|
vi.mock('vscode-jsonrpc/node.js', () => ({
|
|
createMessageConnection: vi.fn(() => mockConnection),
|
|
StreamMessageReader: vi.fn(),
|
|
StreamMessageWriter: vi.fn(),
|
|
}));
|
|
|
|
// Mock fs/promises
|
|
vi.mock('fs/promises', () => ({
|
|
readFile: vi.fn().mockResolvedValue('file content'),
|
|
}));
|
|
|
|
// Mock language module
|
|
vi.mock('../../../src/lsp/language.js', () => ({
|
|
getLanguageId: vi.fn((path: string) => {
|
|
if (path.endsWith('.ts')) return 'typescript';
|
|
if (path.endsWith('.py')) return 'python';
|
|
return undefined;
|
|
}),
|
|
}));
|
|
|
|
// Mock server module
|
|
vi.mock('../../../src/lsp/server.js', () => ({
|
|
getServerConfig: vi.fn((languageId: string) => {
|
|
if (languageId === 'typescript') {
|
|
return {
|
|
command: 'typescript-language-server',
|
|
args: ['--stdio'],
|
|
env: {},
|
|
initializationOptions: {},
|
|
};
|
|
}
|
|
if (languageId === 'python') {
|
|
return {
|
|
command: 'pylsp',
|
|
args: [],
|
|
env: {},
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
}));
|
|
|
|
import { getLanguageId } from '../../../src/lsp/language.js';
|
|
|
|
describe('LSPClientManager - 扩展测试', () => {
|
|
let manager: LSPClientManager;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
manager = new LSPClientManager('/test/project');
|
|
|
|
// 默认命令存在
|
|
mockExecSync.mockReturnValue(Buffer.from(''));
|
|
|
|
// 模拟 spawn 返回的进程
|
|
mockSpawn.mockReturnValue({
|
|
stdin: { on: vi.fn(), write: vi.fn() },
|
|
stdout: { on: vi.fn(), pipe: vi.fn() },
|
|
stderr: { on: vi.fn() },
|
|
on: vi.fn(),
|
|
kill: vi.fn(),
|
|
pid: 12345,
|
|
});
|
|
});
|
|
|
|
describe('getClient - 启动客户端', () => {
|
|
it('重复获取同一语言返回相同客户端', async () => {
|
|
const client1 = await manager.getClient('typescript');
|
|
const client2 = await manager.getClient('typescript');
|
|
|
|
expect(client1).toBe(client2);
|
|
});
|
|
|
|
it('成功启动时设置初始化状态', async () => {
|
|
const client = await manager.getClient('typescript');
|
|
|
|
if (client) {
|
|
expect(client.initialized).toBe(true);
|
|
expect(client.languageId).toBe('typescript');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('touchFile - 文件变更通知', () => {
|
|
it('相对路径转换为绝对路径', async () => {
|
|
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
|
|
|
// 先获取客户端
|
|
await manager.getClient('typescript');
|
|
|
|
// touchFile 应该能处理相对路径
|
|
const result = await manager.touchFile('relative/file.ts');
|
|
|
|
// 由于客户端已初始化,应该调用通知
|
|
expect(typeof result).toBe('boolean');
|
|
});
|
|
|
|
it('首次启动返回 true', async () => {
|
|
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
|
|
|
const result = await manager.touchFile('/test/file.ts');
|
|
|
|
// 首次启动服务器应返回 true
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('后续调用返回 false', async () => {
|
|
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
|
|
|
// 首次调用
|
|
await manager.touchFile('/test/file.ts');
|
|
|
|
// 第二次调用
|
|
const result = await manager.touchFile('/test/file.ts');
|
|
|
|
// 已经在运行,返回 false
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('closeFile - 关闭文件', () => {
|
|
it('正常关闭打开的文件', async () => {
|
|
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
|
|
|
// 先打开文件
|
|
await manager.touchFile('/test/file.ts');
|
|
|
|
// 关闭文件
|
|
await manager.closeFile('/test/file.ts');
|
|
|
|
expect(mockConnection.sendNotification).toHaveBeenCalledWith(
|
|
'textDocument/didClose',
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('诊断信息转换', () => {
|
|
it('正确转换诊断严重性', async () => {
|
|
// 通过 onNotification 回调模拟诊断
|
|
let diagnosticsCallback: ((params: unknown) => void) | null = null;
|
|
mockConnection.onNotification.mockImplementation((method: string, callback: (params: unknown) => void) => {
|
|
if (method === 'textDocument/publishDiagnostics') {
|
|
diagnosticsCallback = callback;
|
|
}
|
|
});
|
|
|
|
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
|
await manager.getClient('typescript');
|
|
|
|
// 模拟收到诊断
|
|
if (diagnosticsCallback) {
|
|
diagnosticsCallback({
|
|
uri: 'file:///test/file.ts',
|
|
diagnostics: [
|
|
{
|
|
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } },
|
|
severity: DiagnosticSeverity.Error,
|
|
message: 'Test error',
|
|
source: 'typescript',
|
|
code: 'TS2345',
|
|
},
|
|
{
|
|
range: { start: { line: 1, character: 0 }, end: { line: 1, character: 5 } },
|
|
severity: DiagnosticSeverity.Warning,
|
|
message: 'Test warning',
|
|
},
|
|
{
|
|
range: { start: { line: 2, character: 0 }, end: { line: 2, character: 5 } },
|
|
severity: DiagnosticSeverity.Information,
|
|
message: 'Test info',
|
|
},
|
|
{
|
|
range: { start: { line: 3, character: 0 }, end: { line: 3, character: 5 } },
|
|
severity: DiagnosticSeverity.Hint,
|
|
message: 'Test hint',
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
// 获取诊断
|
|
const diagnostics = manager.getFileDiagnostics('/test/file.ts');
|
|
|
|
expect(diagnostics.length).toBe(4);
|
|
expect(diagnostics[0].severity).toBe('error');
|
|
expect(diagnostics[1].severity).toBe('warning');
|
|
expect(diagnostics[2].severity).toBe('info');
|
|
expect(diagnostics[3].severity).toBe('hint');
|
|
});
|
|
|
|
it('转换行列号(从 0 开始转为 1 开始)', async () => {
|
|
let diagnosticsCallback: ((params: unknown) => void) | null = null;
|
|
mockConnection.onNotification.mockImplementation((method: string, callback: (params: unknown) => void) => {
|
|
if (method === 'textDocument/publishDiagnostics') {
|
|
diagnosticsCallback = callback;
|
|
}
|
|
});
|
|
|
|
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
|
await manager.getClient('typescript');
|
|
|
|
if (diagnosticsCallback) {
|
|
diagnosticsCallback({
|
|
uri: 'file:///test/file.ts',
|
|
diagnostics: [
|
|
{
|
|
range: { start: { line: 9, character: 4 }, end: { line: 9, character: 20 } },
|
|
severity: DiagnosticSeverity.Error,
|
|
message: 'Error',
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
const diagnostics = manager.getFileDiagnostics('/test/file.ts');
|
|
|
|
expect(diagnostics[0].line).toBe(10); // 9 + 1
|
|
expect(diagnostics[0].column).toBe(5); // 4 + 1
|
|
expect(diagnostics[0].endLine).toBe(10);
|
|
expect(diagnostics[0].endColumn).toBe(21);
|
|
});
|
|
});
|
|
|
|
describe('shutdown - 关闭所有客户端', () => {
|
|
it('关闭所有运行中的客户端', async () => {
|
|
vi.mocked(getLanguageId).mockImplementation((path: string) => {
|
|
if (path.endsWith('.ts')) return 'typescript';
|
|
if (path.endsWith('.py')) return 'python';
|
|
return undefined;
|
|
});
|
|
|
|
// 启动多个客户端
|
|
await manager.getClient('typescript');
|
|
await manager.getClient('python');
|
|
|
|
expect(manager.getRunningServers().length).toBe(2);
|
|
|
|
// 关闭
|
|
await manager.shutdown();
|
|
|
|
expect(manager.getRunningServers().length).toBe(0);
|
|
});
|
|
|
|
it('处理关闭时的错误', async () => {
|
|
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
|
await manager.getClient('typescript');
|
|
|
|
// 模拟 dispose 抛出错误
|
|
mockConnection.dispose.mockImplementation(() => {
|
|
throw new Error('Dispose error');
|
|
});
|
|
|
|
// 应该不抛出错误
|
|
await expect(manager.shutdown()).resolves.not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('进程事件处理', () => {
|
|
it('进程退出时清理客户端', async () => {
|
|
let exitCallback: (() => void) | null = null;
|
|
mockSpawn.mockReturnValue({
|
|
stdin: { on: vi.fn(), write: vi.fn() },
|
|
stdout: { on: vi.fn(), pipe: vi.fn() },
|
|
stderr: { on: vi.fn() },
|
|
on: vi.fn((event: string, callback: () => void) => {
|
|
if (event === 'exit') {
|
|
exitCallback = callback;
|
|
}
|
|
}),
|
|
kill: vi.fn(),
|
|
pid: 12345,
|
|
});
|
|
|
|
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
|
await manager.getClient('typescript');
|
|
|
|
expect(manager.isServerRunning('typescript')).toBe(true);
|
|
|
|
// 模拟进程退出
|
|
if (exitCallback) {
|
|
exitCallback();
|
|
}
|
|
|
|
expect(manager.isServerRunning('typescript')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getDiagnostics - 获取所有诊断', () => {
|
|
it('可以过滤指定文件的诊断', async () => {
|
|
let diagnosticsCallback: ((params: unknown) => void) | null = null;
|
|
mockConnection.onNotification.mockImplementation((method: string, callback: (params: unknown) => void) => {
|
|
if (method === 'textDocument/publishDiagnostics') {
|
|
diagnosticsCallback = callback;
|
|
}
|
|
});
|
|
|
|
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
|
await manager.getClient('typescript');
|
|
|
|
// 为两个文件添加诊断
|
|
if (diagnosticsCallback) {
|
|
diagnosticsCallback({
|
|
uri: 'file:///test/file1.ts',
|
|
diagnostics: [
|
|
{
|
|
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } },
|
|
severity: DiagnosticSeverity.Error,
|
|
message: 'Error in file1',
|
|
},
|
|
],
|
|
});
|
|
diagnosticsCallback({
|
|
uri: 'file:///test/file2.ts',
|
|
diagnostics: [
|
|
{
|
|
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } },
|
|
severity: DiagnosticSeverity.Warning,
|
|
message: 'Warning in file2',
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
// 获取所有诊断
|
|
const allDiagnostics = manager.getDiagnostics();
|
|
expect(allDiagnostics.size).toBe(2);
|
|
|
|
// 只获取 file1 的诊断
|
|
const file1Diagnostics = manager.getDiagnostics('/test/file1.ts');
|
|
expect(file1Diagnostics.size).toBe(1);
|
|
expect(file1Diagnostics.has('/test/file1.ts')).toBe(true);
|
|
});
|
|
});
|
|
});
|