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,356 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
listServers,
|
||||
printServerList,
|
||||
installServer,
|
||||
installAllServers,
|
||||
showServerInfo,
|
||||
type ServerStatus,
|
||||
} from '../../../src/lsp/cli.js';
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
spawnSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock server module
|
||||
vi.mock('../../../src/lsp/server.js', () => ({
|
||||
getUniqueServers: vi.fn(() => [
|
||||
{
|
||||
id: 'typescript-language-server',
|
||||
languages: ['typescript', 'javascript'],
|
||||
config: {
|
||||
displayName: 'TypeScript Language Server',
|
||||
description: 'TypeScript/JavaScript 语言服务器',
|
||||
command: 'typescript-language-server',
|
||||
install: {
|
||||
npm: 'typescript-language-server typescript',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pylsp',
|
||||
languages: ['python'],
|
||||
config: {
|
||||
displayName: 'Python LSP Server',
|
||||
description: 'Python 语言服务器',
|
||||
command: 'pylsp',
|
||||
install: {
|
||||
pip: 'python-lsp-server',
|
||||
manual: '也可以使用: pip3 install python-lsp-server',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gopls',
|
||||
languages: ['go'],
|
||||
config: {
|
||||
displayName: 'Go Language Server',
|
||||
description: 'Go 语言服务器',
|
||||
command: 'gopls',
|
||||
install: {
|
||||
go: 'golang.org/x/tools/gopls@latest',
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
import { execSync, spawnSync } from 'child_process';
|
||||
|
||||
describe('LSP CLI - LSP 命令行工具', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// 默认命令存在
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
if (cmd.includes('which pip3')) return Buffer.from('/usr/bin/pip3');
|
||||
if (cmd.includes('which go')) return Buffer.from('/usr/bin/go');
|
||||
throw new Error('Command not found');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('listServers - 列出服务器', () => {
|
||||
it('返回所有服务器状态', () => {
|
||||
const servers = listServers();
|
||||
|
||||
expect(servers).toHaveLength(3);
|
||||
expect(servers[0].id).toBe('typescript-language-server');
|
||||
expect(servers[1].id).toBe('pylsp');
|
||||
expect(servers[2].id).toBe('gopls');
|
||||
});
|
||||
|
||||
it('检测已安装的服务器', () => {
|
||||
// 只有 typescript-language-server 安装了
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which typescript-language-server') return Buffer.from('/usr/bin/tsc');
|
||||
throw new Error('not found');
|
||||
});
|
||||
|
||||
const servers = listServers();
|
||||
|
||||
expect(servers[0].installed).toBe(true);
|
||||
expect(servers[1].installed).toBe(false);
|
||||
expect(servers[2].installed).toBe(false);
|
||||
});
|
||||
|
||||
it('包含服务器详细信息', () => {
|
||||
const servers = listServers();
|
||||
const tsServer = servers[0];
|
||||
|
||||
expect(tsServer.displayName).toBe('TypeScript Language Server');
|
||||
expect(tsServer.description).toContain('TypeScript');
|
||||
expect(tsServer.command).toBe('typescript-language-server');
|
||||
expect(tsServer.languages).toContain('typescript');
|
||||
expect(tsServer.install).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('printServerList - 打印服务器列表', () => {
|
||||
it('输出格式化的服务器列表', () => {
|
||||
printServerList();
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('语言服务器状态'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('状态'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('服务器'));
|
||||
});
|
||||
|
||||
it('显示已安装数量统计', () => {
|
||||
printServerList();
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('已安装');
|
||||
});
|
||||
});
|
||||
|
||||
describe('installServer - 安装服务器', () => {
|
||||
it('服务器不存在时返回 false', async () => {
|
||||
const result = await installServer('non-existent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('未找到服务器'));
|
||||
});
|
||||
|
||||
it('服务器已安装时返回 true', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which typescript-language-server') return Buffer.from('/usr/bin/tsc');
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
throw new Error('not found');
|
||||
});
|
||||
|
||||
const result = await installServer('typescript-language-server');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('已安装'));
|
||||
});
|
||||
|
||||
it('使用 npm 安装 TypeScript 服务器', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which typescript-language-server') throw new Error('not found');
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as any);
|
||||
|
||||
const result = await installServer('typescript-language-server');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(spawnSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('npm install -g'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('安装失败时返回 false', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which typescript-language-server') throw new Error('not found');
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mocked(spawnSync).mockReturnValue({ status: 1 } as any);
|
||||
|
||||
const result = await installServer('typescript-language-server');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('安装失败'));
|
||||
});
|
||||
|
||||
it('按显示名称查找服务器', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which typescript-language-server') return Buffer.from('/usr/bin/tsc');
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
throw new Error('not found');
|
||||
});
|
||||
|
||||
const result = await installServer('TypeScript Language Server');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('spawnSync 抛出异常时返回 false', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which typescript-language-server') throw new Error('not found');
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mocked(spawnSync).mockImplementation(() => {
|
||||
throw new Error('spawn error');
|
||||
});
|
||||
|
||||
const result = await installServer('typescript-language-server');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('安装出错'));
|
||||
});
|
||||
|
||||
it('无法自动安装时显示手动说明', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// pylsp 未安装,且没有可用的 pip
|
||||
if (cmd === 'which pylsp') throw new Error('not found');
|
||||
throw new Error('not found');
|
||||
});
|
||||
|
||||
const result = await installServer('pylsp');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('无法自动安装'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('installAllServers - 安装所有服务器', () => {
|
||||
it('所有服务器都已安装时显示完成', async () => {
|
||||
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/cmd'));
|
||||
|
||||
await installAllServers();
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('都已安装'));
|
||||
});
|
||||
|
||||
it('安装未安装的服务器', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// 所有服务器都未安装,但有 npm
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as any);
|
||||
|
||||
await installAllServers();
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('将安装'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('安装完成'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('showServerInfo - 显示服务器信息', () => {
|
||||
it('显示已安装服务器的信息', () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which typescript-language-server') return Buffer.from('/usr/bin/tsc');
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
throw new Error('not found');
|
||||
});
|
||||
|
||||
showServerInfo('typescript-language-server');
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('TypeScript Language Server');
|
||||
expect(calls).toContain('已安装');
|
||||
});
|
||||
|
||||
it('显示未安装服务器的安装命令', () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which typescript-language-server') throw new Error('not found');
|
||||
if (cmd.includes('which npm')) return Buffer.from('/usr/bin/npm');
|
||||
throw new Error('not found');
|
||||
});
|
||||
|
||||
showServerInfo('typescript-language-server');
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('未安装');
|
||||
expect(calls).toContain('安装命令');
|
||||
expect(calls).toContain('npm install');
|
||||
});
|
||||
|
||||
it('服务器不存在时显示错误', () => {
|
||||
showServerInfo('non-existent');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('未找到服务器'));
|
||||
});
|
||||
|
||||
it('按显示名称查找服务器', () => {
|
||||
vi.mocked(execSync).mockReturnValue(Buffer.from('/usr/bin/cmd'));
|
||||
|
||||
showServerInfo('Python LSP Server');
|
||||
|
||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('Python LSP Server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstallCommand - 安装命令选择', () => {
|
||||
it('pip 作为 Python 服务器的安装方式', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which pylsp') throw new Error('not found');
|
||||
if (cmd.includes('which pip3')) return Buffer.from('/usr/bin/pip3');
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as any);
|
||||
|
||||
await installServer('pylsp');
|
||||
|
||||
expect(spawnSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('pip3 install'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('go install 作为 Go 服务器的安装方式', async () => {
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which gopls') throw new Error('not found');
|
||||
if (cmd.includes('which go')) return Buffer.from('/usr/bin/go');
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as any);
|
||||
|
||||
await installServer('gopls');
|
||||
|
||||
expect(spawnSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('go install'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerStatus 类型', () => {
|
||||
it('包含所有必要字段', () => {
|
||||
const status: ServerStatus = {
|
||||
id: 'test-server',
|
||||
displayName: 'Test Server',
|
||||
description: 'A test server',
|
||||
command: 'test-cmd',
|
||||
installed: true,
|
||||
languages: ['typescript'],
|
||||
install: { npm: 'test-package' },
|
||||
};
|
||||
|
||||
expect(status.id).toBeDefined();
|
||||
expect(status.displayName).toBeDefined();
|
||||
expect(status.command).toBeDefined();
|
||||
expect(status.installed).toBeDefined();
|
||||
expect(status.languages).toBeDefined();
|
||||
expect(status.install).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { LSPClientManager } from '../../../src/lsp/client.js';
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: vi.fn(() => ({
|
||||
stdin: { on: vi.fn() },
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
})),
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock vscode-jsonrpc
|
||||
vi.mock('vscode-jsonrpc/node.js', () => ({
|
||||
createMessageConnection: vi.fn(() => ({
|
||||
listen: vi.fn(),
|
||||
sendRequest: vi.fn().mockResolvedValue({}),
|
||||
sendNotification: vi.fn(),
|
||||
onNotification: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
})),
|
||||
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: {},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
}));
|
||||
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { getLanguageId } from '../../../src/lsp/language.js';
|
||||
import { getServerConfig } from '../../../src/lsp/server.js';
|
||||
|
||||
describe('LSPClientManager - LSP 客户端管理器', () => {
|
||||
let manager: LSPClientManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
manager = new LSPClientManager('/test/project');
|
||||
|
||||
// 默认命令存在
|
||||
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
|
||||
});
|
||||
|
||||
describe('构造函数', () => {
|
||||
it('使用提供的根路径', () => {
|
||||
const m = new LSPClientManager('/custom/path');
|
||||
expect(m).toBeDefined();
|
||||
});
|
||||
|
||||
it('默认使用 process.cwd()', () => {
|
||||
const m = new LSPClientManager();
|
||||
expect(m).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRootPath - 设置根路径', () => {
|
||||
it('更新根路径', () => {
|
||||
manager.setRootPath('/new/path');
|
||||
// 无直接验证方式,但不应报错
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClient - 获取客户端', () => {
|
||||
it('无服务器配置返回 undefined', async () => {
|
||||
vi.mocked(getServerConfig).mockReturnValue(null);
|
||||
|
||||
const client = await manager.getClient('unknown' as any);
|
||||
|
||||
expect(client).toBeUndefined();
|
||||
});
|
||||
|
||||
it('命令不存在时返回 undefined', async () => {
|
||||
vi.mocked(execSync).mockImplementation(() => {
|
||||
throw new Error('command not found');
|
||||
});
|
||||
|
||||
const client = await manager.getClient('typescript');
|
||||
|
||||
expect(client).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('touchFile - 通知文件变更', () => {
|
||||
it('不支持的语言返回 false', async () => {
|
||||
vi.mocked(getLanguageId).mockReturnValue(undefined);
|
||||
|
||||
const result = await manager.touchFile('/test/file.xyz');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDiagnostics - 获取诊断', () => {
|
||||
it('无客户端时返回空 Map', () => {
|
||||
const diagnostics = manager.getDiagnostics();
|
||||
|
||||
expect(diagnostics).toBeInstanceOf(Map);
|
||||
expect(diagnostics.size).toBe(0);
|
||||
});
|
||||
|
||||
it('可以按文件过滤', () => {
|
||||
const diagnostics = manager.getDiagnostics('/test/file.ts');
|
||||
|
||||
expect(diagnostics).toBeInstanceOf(Map);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileDiagnostics - 获取单文件诊断', () => {
|
||||
it('无诊断时返回空数组', () => {
|
||||
const diagnostics = manager.getFileDiagnostics('/test/file.ts');
|
||||
|
||||
expect(Array.isArray(diagnostics)).toBe(true);
|
||||
expect(diagnostics.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isServerRunning - 检查服务器状态', () => {
|
||||
it('未启动的服务器返回 false', () => {
|
||||
expect(manager.isServerRunning('typescript')).toBe(false);
|
||||
expect(manager.isServerRunning('python')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRunningServers - 获取运行中的服务器', () => {
|
||||
it('无服务器时返回空数组', () => {
|
||||
const servers = manager.getRunningServers();
|
||||
|
||||
expect(Array.isArray(servers)).toBe(true);
|
||||
expect(servers.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdown - 关闭', () => {
|
||||
it('无客户端时正常关闭', async () => {
|
||||
await expect(manager.shutdown()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeFile - 关闭文件', () => {
|
||||
it('不支持的语言静默返回', async () => {
|
||||
vi.mocked(getLanguageId).mockReturnValue(undefined);
|
||||
|
||||
await expect(manager.closeFile('/test/file.xyz')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('未打开的文件静默返回', async () => {
|
||||
vi.mocked(getLanguageId).mockReturnValue('typescript');
|
||||
|
||||
await expect(manager.closeFile('/test/file.ts')).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileDiagnostic 类型', () => {
|
||||
it('包含必要字段', () => {
|
||||
const diagnostic = {
|
||||
file: '/test/file.ts',
|
||||
line: 1,
|
||||
column: 1,
|
||||
severity: 'error' as const,
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
expect(diagnostic.file).toBeDefined();
|
||||
expect(diagnostic.line).toBeDefined();
|
||||
expect(diagnostic.column).toBeDefined();
|
||||
expect(diagnostic.severity).toBeDefined();
|
||||
expect(diagnostic.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('支持可选字段', () => {
|
||||
const diagnostic = {
|
||||
file: '/test/file.ts',
|
||||
line: 1,
|
||||
column: 1,
|
||||
endLine: 2,
|
||||
endColumn: 5,
|
||||
severity: 'warning' as const,
|
||||
message: 'Test warning',
|
||||
source: 'typescript',
|
||||
code: 'TS2345',
|
||||
};
|
||||
|
||||
expect(diagnostic.endLine).toBe(2);
|
||||
expect(diagnostic.endColumn).toBe(5);
|
||||
expect(diagnostic.source).toBe('typescript');
|
||||
expect(diagnostic.code).toBe('TS2345');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock functions
|
||||
const mockTouchFile = vi.fn();
|
||||
const mockGetDiagnostics = vi.fn();
|
||||
const mockGetFileDiagnostics = vi.fn();
|
||||
const mockShutdown = vi.fn();
|
||||
|
||||
// 创建 mock class
|
||||
class MockLSPClientManager {
|
||||
touchFile = mockTouchFile;
|
||||
getDiagnostics = mockGetDiagnostics;
|
||||
getFileDiagnostics = mockGetFileDiagnostics;
|
||||
shutdown = mockShutdown;
|
||||
|
||||
constructor(_rootPath?: string) {}
|
||||
}
|
||||
|
||||
// Mock client module
|
||||
vi.mock('../../../src/lsp/client.js', () => ({
|
||||
LSPClientManager: MockLSPClientManager,
|
||||
}));
|
||||
|
||||
// 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;
|
||||
}),
|
||||
isLanguageSupported: vi.fn((path: string) => {
|
||||
return path.endsWith('.ts') || path.endsWith('.py');
|
||||
}),
|
||||
getSupportedExtensions: vi.fn(() => ['.ts', '.js', '.py']),
|
||||
}));
|
||||
|
||||
// Mock server module
|
||||
vi.mock('../../../src/lsp/server.js', () => ({
|
||||
getServerConfig: vi.fn(),
|
||||
hasServerConfig: vi.fn(),
|
||||
getSupportedLanguages: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
describe('LSP Index - LSP 模块入口', () => {
|
||||
let lspModule: typeof import('../../../src/lsp/index.js');
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
// 重置模块缓存以获取干净的状态
|
||||
vi.resetModules();
|
||||
// 重新导入模块
|
||||
lspModule = await import('../../../src/lsp/index.js');
|
||||
});
|
||||
|
||||
describe('initLSP - 初始化 LSP', () => {
|
||||
it('初始化 LSP 管理器', () => {
|
||||
lspModule.initLSP('/test/project');
|
||||
|
||||
expect(lspModule.getLSPManager()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('重复初始化不会创建新实例', () => {
|
||||
lspModule.initLSP('/test/project');
|
||||
const manager1 = lspModule.getLSPManager();
|
||||
|
||||
lspModule.initLSP('/another/project');
|
||||
const manager2 = lspModule.getLSPManager();
|
||||
|
||||
expect(manager1).toBe(manager2);
|
||||
});
|
||||
|
||||
it('默认使用 process.cwd()', () => {
|
||||
lspModule.initLSP();
|
||||
|
||||
expect(lspModule.getLSPManager()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLSPManager - 获取管理器', () => {
|
||||
it('未初始化时返回 null', async () => {
|
||||
// 重置模块获取干净状态
|
||||
vi.resetModules();
|
||||
const freshModule = await import('../../../src/lsp/index.js');
|
||||
|
||||
expect(freshModule.getLSPManager()).toBeNull();
|
||||
});
|
||||
|
||||
it('初始化后返回管理器实例', () => {
|
||||
lspModule.initLSP();
|
||||
|
||||
expect(lspModule.getLSPManager()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('touchFile - 通知文件变更', () => {
|
||||
it('未初始化时返回 false', async () => {
|
||||
vi.resetModules();
|
||||
const freshModule = await import('../../../src/lsp/index.js');
|
||||
|
||||
const result = await freshModule.touchFile('/test/file.ts');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('不支持的语言返回 false', async () => {
|
||||
lspModule.initLSP();
|
||||
|
||||
const result = await lspModule.touchFile('/test/file.txt');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('支持的语言调用管理器', async () => {
|
||||
mockTouchFile.mockResolvedValue(true);
|
||||
lspModule.initLSP();
|
||||
|
||||
const result = await lspModule.touchFile('/test/file.ts');
|
||||
|
||||
expect(mockTouchFile).toHaveBeenCalledWith('/test/file.ts', false);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('新文件传递 isNew 参数', async () => {
|
||||
mockTouchFile.mockResolvedValue(true);
|
||||
lspModule.initLSP();
|
||||
|
||||
await lspModule.touchFile('/test/file.ts', true);
|
||||
|
||||
expect(mockTouchFile).toHaveBeenCalledWith('/test/file.ts', true);
|
||||
});
|
||||
|
||||
it('touchFile 出错时静默返回 false', async () => {
|
||||
mockTouchFile.mockRejectedValue(new Error('LSP error'));
|
||||
lspModule.initLSP();
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const result = await lspModule.touchFile('/test/file.ts');
|
||||
|
||||
expect(result).toBe(false);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDiagnostics - 获取所有诊断', () => {
|
||||
it('未初始化时返回空 Map', async () => {
|
||||
vi.resetModules();
|
||||
const freshModule = await import('../../../src/lsp/index.js');
|
||||
|
||||
const result = freshModule.getDiagnostics();
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('返回管理器的诊断信息', () => {
|
||||
const mockDiagnostics = new Map([
|
||||
['/test/file.ts', [{ file: '/test/file.ts', line: 1, column: 1, severity: 'error', message: 'Test error' }]],
|
||||
]);
|
||||
mockGetDiagnostics.mockReturnValue(mockDiagnostics);
|
||||
lspModule.initLSP();
|
||||
|
||||
const result = lspModule.getDiagnostics();
|
||||
|
||||
expect(result).toBe(mockDiagnostics);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileDiagnostics - 获取单文件诊断', () => {
|
||||
it('未初始化时返回空数组', async () => {
|
||||
vi.resetModules();
|
||||
const freshModule = await import('../../../src/lsp/index.js');
|
||||
|
||||
const result = freshModule.getFileDiagnostics('/test/file.ts');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('返回指定文件的诊断', () => {
|
||||
const mockDiagnostics = [
|
||||
{ file: '/test/file.ts', line: 1, column: 1, severity: 'error' as const, message: 'Error' },
|
||||
];
|
||||
mockGetFileDiagnostics.mockReturnValue(mockDiagnostics);
|
||||
lspModule.initLSP();
|
||||
|
||||
const result = lspModule.getFileDiagnostics('/test/file.ts');
|
||||
|
||||
expect(result).toBe(mockDiagnostics);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDiagnostics - 格式化诊断信息', () => {
|
||||
it('空诊断返回空字符串', () => {
|
||||
const result = lspModule.formatDiagnostics([]);
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('格式化单个诊断', () => {
|
||||
const diagnostics = [
|
||||
{
|
||||
file: '/test/file.ts',
|
||||
line: 10,
|
||||
column: 5,
|
||||
severity: 'error' as const,
|
||||
message: 'Type error',
|
||||
code: 'TS2345',
|
||||
source: 'typescript',
|
||||
},
|
||||
];
|
||||
|
||||
const result = lspModule.formatDiagnostics(diagnostics);
|
||||
|
||||
expect(result).toContain('10:5');
|
||||
expect(result).toContain('ERROR');
|
||||
expect(result).toContain('TS2345');
|
||||
expect(result).toContain('Type error');
|
||||
expect(result).toContain('typescript');
|
||||
});
|
||||
|
||||
it('格式化多个诊断', () => {
|
||||
const diagnostics = [
|
||||
{ file: '/test/file.ts', line: 1, column: 1, severity: 'error' as const, message: 'Error 1' },
|
||||
{ file: '/test/file.ts', line: 2, column: 2, severity: 'warning' as const, message: 'Warning 1' },
|
||||
];
|
||||
|
||||
const result = lspModule.formatDiagnostics(diagnostics);
|
||||
|
||||
expect(result).toContain('Error 1');
|
||||
expect(result).toContain('Warning 1');
|
||||
expect(result).toContain('ERROR');
|
||||
expect(result).toContain('WARNING');
|
||||
});
|
||||
|
||||
it('无 code 时不显示 code', () => {
|
||||
const diagnostics = [
|
||||
{ file: '/test/file.ts', line: 1, column: 1, severity: 'error' as const, message: 'Error' },
|
||||
];
|
||||
|
||||
const result = lspModule.formatDiagnostics(diagnostics);
|
||||
|
||||
expect(result).not.toContain('[');
|
||||
});
|
||||
|
||||
it('无 source 时不显示 source', () => {
|
||||
const diagnostics = [
|
||||
{ file: '/test/file.ts', line: 1, column: 1, severity: 'error' as const, message: 'Error' },
|
||||
];
|
||||
|
||||
const result = lspModule.formatDiagnostics(diagnostics);
|
||||
|
||||
expect(result).not.toContain('(');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFormattedFileDiagnostics - 获取格式化的文件诊断', () => {
|
||||
it('无错误和警告时返回空字符串', async () => {
|
||||
mockGetFileDiagnostics.mockReturnValue([
|
||||
{ file: '/test/file.ts', line: 1, column: 1, severity: 'info', message: 'Info' },
|
||||
{ file: '/test/file.ts', line: 2, column: 2, severity: 'hint', message: 'Hint' },
|
||||
]);
|
||||
lspModule.initLSP();
|
||||
|
||||
const result = await lspModule.getFormattedFileDiagnostics('/test/file.ts');
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('只显示错误和警告', async () => {
|
||||
mockGetFileDiagnostics.mockReturnValue([
|
||||
{ file: '/test/file.ts', line: 1, column: 1, severity: 'error', message: 'Error' },
|
||||
{ file: '/test/file.ts', line: 2, column: 2, severity: 'warning', message: 'Warning' },
|
||||
{ file: '/test/file.ts', line: 3, column: 3, severity: 'info', message: 'Info' },
|
||||
]);
|
||||
lspModule.initLSP();
|
||||
|
||||
const result = await lspModule.getFormattedFileDiagnostics('/test/file.ts');
|
||||
|
||||
expect(result).toContain('Error');
|
||||
expect(result).toContain('Warning');
|
||||
expect(result).not.toContain('Info');
|
||||
});
|
||||
|
||||
it('包含 file_diagnostics 标签', async () => {
|
||||
mockGetFileDiagnostics.mockReturnValue([
|
||||
{ file: '/test/file.ts', line: 1, column: 1, severity: 'error', message: 'Error' },
|
||||
]);
|
||||
lspModule.initLSP();
|
||||
|
||||
const result = await lspModule.getFormattedFileDiagnostics('/test/file.ts');
|
||||
|
||||
expect(result).toContain('<file_diagnostics');
|
||||
expect(result).toContain('/test/file.ts');
|
||||
expect(result).toContain('</file_diagnostics>');
|
||||
});
|
||||
|
||||
it('显示错误和警告数量', async () => {
|
||||
mockGetFileDiagnostics.mockReturnValue([
|
||||
{ file: '/test/file.ts', line: 1, column: 1, severity: 'error', message: 'Error 1' },
|
||||
{ file: '/test/file.ts', line: 2, column: 2, severity: 'error', message: 'Error 2' },
|
||||
{ file: '/test/file.ts', line: 3, column: 3, severity: 'warning', message: 'Warning' },
|
||||
]);
|
||||
lspModule.initLSP();
|
||||
|
||||
const result = await lspModule.getFormattedFileDiagnostics('/test/file.ts');
|
||||
|
||||
expect(result).toContain('2 个错误');
|
||||
expect(result).toContain('1 个警告');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdownLSP - 关闭 LSP', () => {
|
||||
it('关闭管理器', async () => {
|
||||
lspModule.initLSP();
|
||||
|
||||
await lspModule.shutdownLSP();
|
||||
|
||||
expect(mockShutdown).toHaveBeenCalled();
|
||||
expect(lspModule.getLSPManager()).toBeNull();
|
||||
});
|
||||
|
||||
it('未初始化时安全调用', async () => {
|
||||
vi.resetModules();
|
||||
const freshModule = await import('../../../src/lsp/index.js');
|
||||
|
||||
await expect(freshModule.shutdownLSP()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('导出', () => {
|
||||
it('导出 getLanguageId', () => {
|
||||
expect(lspModule.getLanguageId).toBeDefined();
|
||||
});
|
||||
|
||||
it('导出 isLanguageSupported', () => {
|
||||
expect(lspModule.isLanguageSupported).toBeDefined();
|
||||
});
|
||||
|
||||
it('导出 getSupportedExtensions', () => {
|
||||
expect(lspModule.getSupportedExtensions).toBeDefined();
|
||||
});
|
||||
|
||||
it('导出 LSPClientManager', () => {
|
||||
expect(lspModule.LSPClientManager).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getLanguageId,
|
||||
isLanguageSupported,
|
||||
getSupportedExtensions,
|
||||
} from '../../../src/lsp/language.js';
|
||||
|
||||
describe('LSP Language - 语言识别', () => {
|
||||
describe('getLanguageId - 获取语言 ID', () => {
|
||||
describe('TypeScript/JavaScript', () => {
|
||||
it('识别 TypeScript 文件', () => {
|
||||
expect(getLanguageId('file.ts')).toBe('typescript');
|
||||
expect(getLanguageId('file.mts')).toBe('typescript');
|
||||
expect(getLanguageId('file.cts')).toBe('typescript');
|
||||
});
|
||||
|
||||
it('识别 TSX 文件', () => {
|
||||
expect(getLanguageId('file.tsx')).toBe('typescriptreact');
|
||||
});
|
||||
|
||||
it('识别 JavaScript 文件', () => {
|
||||
expect(getLanguageId('file.js')).toBe('javascript');
|
||||
expect(getLanguageId('file.mjs')).toBe('javascript');
|
||||
expect(getLanguageId('file.cjs')).toBe('javascript');
|
||||
});
|
||||
|
||||
it('识别 JSX 文件', () => {
|
||||
expect(getLanguageId('file.jsx')).toBe('javascriptreact');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Python', () => {
|
||||
it('识别 Python 文件', () => {
|
||||
expect(getLanguageId('script.py')).toBe('python');
|
||||
expect(getLanguageId('stub.pyi')).toBe('python');
|
||||
expect(getLanguageId('script.pyw')).toBe('python');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go', () => {
|
||||
it('识别 Go 文件', () => {
|
||||
expect(getLanguageId('main.go')).toBe('go');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rust', () => {
|
||||
it('识别 Rust 文件', () => {
|
||||
expect(getLanguageId('main.rs')).toBe('rust');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Java', () => {
|
||||
it('识别 Java 文件', () => {
|
||||
expect(getLanguageId('Main.java')).toBe('java');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C/C++', () => {
|
||||
it('识别 C 文件', () => {
|
||||
expect(getLanguageId('main.c')).toBe('c');
|
||||
expect(getLanguageId('header.h')).toBe('c');
|
||||
});
|
||||
|
||||
it('识别 C++ 文件', () => {
|
||||
expect(getLanguageId('main.cpp')).toBe('cpp');
|
||||
expect(getLanguageId('main.cc')).toBe('cpp');
|
||||
expect(getLanguageId('main.cxx')).toBe('cpp');
|
||||
expect(getLanguageId('header.hpp')).toBe('cpp');
|
||||
expect(getLanguageId('header.hh')).toBe('cpp');
|
||||
expect(getLanguageId('header.hxx')).toBe('cpp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('其他语言', () => {
|
||||
it('识别 C# 文件', () => {
|
||||
expect(getLanguageId('Program.cs')).toBe('csharp');
|
||||
});
|
||||
|
||||
it('识别 PHP 文件', () => {
|
||||
expect(getLanguageId('index.php')).toBe('php');
|
||||
});
|
||||
|
||||
it('识别 Ruby 文件', () => {
|
||||
expect(getLanguageId('app.rb')).toBe('ruby');
|
||||
expect(getLanguageId('task.rake')).toBe('ruby');
|
||||
});
|
||||
|
||||
it('识别 Swift 文件', () => {
|
||||
expect(getLanguageId('app.swift')).toBe('swift');
|
||||
});
|
||||
|
||||
it('识别 Kotlin 文件', () => {
|
||||
expect(getLanguageId('Main.kt')).toBe('kotlin');
|
||||
expect(getLanguageId('script.kts')).toBe('kotlin');
|
||||
});
|
||||
|
||||
it('识别 Scala 文件', () => {
|
||||
expect(getLanguageId('Main.scala')).toBe('scala');
|
||||
expect(getLanguageId('script.sc')).toBe('scala');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Web 技术', () => {
|
||||
it('识别 HTML 文件', () => {
|
||||
expect(getLanguageId('index.html')).toBe('html');
|
||||
expect(getLanguageId('page.htm')).toBe('html');
|
||||
});
|
||||
|
||||
it('识别 CSS 文件', () => {
|
||||
expect(getLanguageId('style.css')).toBe('css');
|
||||
expect(getLanguageId('style.scss')).toBe('scss');
|
||||
expect(getLanguageId('style.less')).toBe('less');
|
||||
});
|
||||
|
||||
it('识别框架文件', () => {
|
||||
expect(getLanguageId('App.vue')).toBe('vue');
|
||||
expect(getLanguageId('App.svelte')).toBe('svelte');
|
||||
});
|
||||
});
|
||||
|
||||
describe('数据格式', () => {
|
||||
it('识别 JSON 文件', () => {
|
||||
expect(getLanguageId('config.json')).toBe('json');
|
||||
});
|
||||
|
||||
it('识别 YAML 文件', () => {
|
||||
expect(getLanguageId('config.yaml')).toBe('yaml');
|
||||
expect(getLanguageId('config.yml')).toBe('yaml');
|
||||
});
|
||||
|
||||
it('识别 Markdown 文件', () => {
|
||||
expect(getLanguageId('README.md')).toBe('markdown');
|
||||
expect(getLanguageId('docs.markdown')).toBe('markdown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('边缘情况', () => {
|
||||
it('处理完整路径', () => {
|
||||
expect(getLanguageId('/path/to/file.ts')).toBe('typescript');
|
||||
expect(getLanguageId('./relative/path/file.py')).toBe('python');
|
||||
});
|
||||
|
||||
it('处理大写扩展名', () => {
|
||||
expect(getLanguageId('file.TS')).toBe('typescript');
|
||||
expect(getLanguageId('file.JS')).toBe('javascript');
|
||||
expect(getLanguageId('file.PY')).toBe('python');
|
||||
});
|
||||
|
||||
it('未知扩展名返回 undefined', () => {
|
||||
expect(getLanguageId('file.xyz')).toBeUndefined();
|
||||
expect(getLanguageId('file.unknown')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('无扩展名返回 undefined', () => {
|
||||
expect(getLanguageId('Makefile')).toBeUndefined();
|
||||
expect(getLanguageId('Dockerfile')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('处理多点文件名', () => {
|
||||
expect(getLanguageId('file.test.ts')).toBe('typescript');
|
||||
expect(getLanguageId('app.module.js')).toBe('javascript');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLanguageSupported - 检查语言支持', () => {
|
||||
it('支持的语言返回 true', () => {
|
||||
expect(isLanguageSupported('file.ts')).toBe(true);
|
||||
expect(isLanguageSupported('file.py')).toBe(true);
|
||||
expect(isLanguageSupported('file.go')).toBe(true);
|
||||
});
|
||||
|
||||
it('不支持的语言返回 false', () => {
|
||||
expect(isLanguageSupported('file.xyz')).toBe(false);
|
||||
expect(isLanguageSupported('Makefile')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSupportedExtensions - 获取支持的扩展名', () => {
|
||||
it('返回非空数组', () => {
|
||||
const extensions = getSupportedExtensions();
|
||||
expect(Array.isArray(extensions)).toBe(true);
|
||||
expect(extensions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('包含常见扩展名', () => {
|
||||
const extensions = getSupportedExtensions();
|
||||
expect(extensions).toContain('.ts');
|
||||
expect(extensions).toContain('.js');
|
||||
expect(extensions).toContain('.py');
|
||||
expect(extensions).toContain('.go');
|
||||
});
|
||||
|
||||
it('所有扩展名以点开头', () => {
|
||||
const extensions = getSupportedExtensions();
|
||||
for (const ext of extensions) {
|
||||
expect(ext.startsWith('.')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getServerConfig,
|
||||
hasServerConfig,
|
||||
getSupportedLanguages,
|
||||
getAllServerConfigs,
|
||||
getUniqueServers,
|
||||
} from '../../../src/lsp/server.js';
|
||||
|
||||
describe('LSP Server - 语言服务器配置', () => {
|
||||
describe('getServerConfig - 获取服务器配置', () => {
|
||||
it('返回 TypeScript 配置', () => {
|
||||
const config = getServerConfig('typescript');
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config?.command).toBe('typescript-language-server');
|
||||
expect(config?.args).toContain('--stdio');
|
||||
expect(config?.displayName).toBe('TypeScript');
|
||||
});
|
||||
|
||||
it('返回 JavaScript 配置(共用 TypeScript)', () => {
|
||||
const config = getServerConfig('javascript');
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config?.command).toBe('typescript-language-server');
|
||||
});
|
||||
|
||||
it('返回 Python 配置', () => {
|
||||
const config = getServerConfig('python');
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config?.command).toBe('pyright-langserver');
|
||||
expect(config?.install.npm).toBe('pyright');
|
||||
});
|
||||
|
||||
it('返回 Go 配置', () => {
|
||||
const config = getServerConfig('go');
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config?.command).toBe('gopls');
|
||||
expect(config?.install.go).toContain('gopls');
|
||||
});
|
||||
|
||||
it('返回 Rust 配置', () => {
|
||||
const config = getServerConfig('rust');
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config?.command).toBe('rust-analyzer');
|
||||
expect(config?.install.rustup).toBe('rust-analyzer');
|
||||
});
|
||||
|
||||
it('返回 C/C++ 配置', () => {
|
||||
const cConfig = getServerConfig('c');
|
||||
const cppConfig = getServerConfig('cpp');
|
||||
|
||||
expect(cConfig?.command).toBe('clangd');
|
||||
expect(cppConfig?.command).toBe('clangd');
|
||||
});
|
||||
|
||||
it('返回 Vue 配置', () => {
|
||||
const config = getServerConfig('vue');
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config?.command).toBe('vue-language-server');
|
||||
});
|
||||
|
||||
it('返回 HTML/CSS/JSON 配置', () => {
|
||||
expect(getServerConfig('html')?.command).toBe('vscode-html-language-server');
|
||||
expect(getServerConfig('css')?.command).toBe('vscode-css-language-server');
|
||||
expect(getServerConfig('json')?.command).toBe('vscode-json-language-server');
|
||||
});
|
||||
|
||||
it('不支持的语言返回 undefined', () => {
|
||||
const config = getServerConfig('unknown' as any);
|
||||
|
||||
expect(config).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasServerConfig - 检查服务器配置', () => {
|
||||
it('已配置的语言返回 true', () => {
|
||||
expect(hasServerConfig('typescript')).toBe(true);
|
||||
expect(hasServerConfig('python')).toBe(true);
|
||||
expect(hasServerConfig('go')).toBe(true);
|
||||
expect(hasServerConfig('rust')).toBe(true);
|
||||
});
|
||||
|
||||
it('未配置的语言返回 false', () => {
|
||||
expect(hasServerConfig('unknown' as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSupportedLanguages - 获取支持的语言', () => {
|
||||
it('返回所有支持的语言 ID', () => {
|
||||
const languages = getSupportedLanguages();
|
||||
|
||||
expect(Array.isArray(languages)).toBe(true);
|
||||
expect(languages.length).toBeGreaterThan(0);
|
||||
expect(languages).toContain('typescript');
|
||||
expect(languages).toContain('javascript');
|
||||
expect(languages).toContain('python');
|
||||
expect(languages).toContain('go');
|
||||
expect(languages).toContain('rust');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllServerConfigs - 获取所有配置', () => {
|
||||
it('返回所有服务器配置对象', () => {
|
||||
const configs = getAllServerConfigs();
|
||||
|
||||
expect(typeof configs).toBe('object');
|
||||
expect(configs.typescript).toBeDefined();
|
||||
expect(configs.python).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUniqueServers - 获取唯一服务器列表', () => {
|
||||
it('返回去重后的服务器列表', () => {
|
||||
const servers = getUniqueServers();
|
||||
|
||||
expect(Array.isArray(servers)).toBe(true);
|
||||
|
||||
// typescript-language-server 被多个语言共用
|
||||
const tsServer = servers.find((s) => s.id === 'typescript-language-server');
|
||||
expect(tsServer).toBeDefined();
|
||||
expect(tsServer?.languages).toContain('typescript');
|
||||
expect(tsServer?.languages).toContain('javascript');
|
||||
});
|
||||
|
||||
it('每个服务器包含必要字段', () => {
|
||||
const servers = getUniqueServers();
|
||||
|
||||
for (const server of servers) {
|
||||
expect(server.id).toBeDefined();
|
||||
expect(server.config).toBeDefined();
|
||||
expect(server.languages).toBeDefined();
|
||||
expect(Array.isArray(server.languages)).toBe(true);
|
||||
expect(server.languages.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('clangd 被 C 和 C++ 共用', () => {
|
||||
const servers = getUniqueServers();
|
||||
const clangd = servers.find((s) => s.id === 'clangd');
|
||||
|
||||
expect(clangd).toBeDefined();
|
||||
expect(clangd?.languages).toContain('c');
|
||||
expect(clangd?.languages).toContain('cpp');
|
||||
});
|
||||
|
||||
it('vscode-css-language-server 被 CSS/SCSS/Less 共用', () => {
|
||||
const servers = getUniqueServers();
|
||||
const cssServer = servers.find((s) => s.id === 'vscode-css-language-server');
|
||||
|
||||
expect(cssServer).toBeDefined();
|
||||
expect(cssServer?.languages).toContain('css');
|
||||
expect(cssServer?.languages).toContain('scss');
|
||||
expect(cssServer?.languages).toContain('less');
|
||||
});
|
||||
});
|
||||
|
||||
describe('安装配置', () => {
|
||||
it('TypeScript 服务器有 npm 安装配置', () => {
|
||||
const config = getServerConfig('typescript');
|
||||
|
||||
expect(config?.install.npm).toContain('typescript-language-server');
|
||||
});
|
||||
|
||||
it('Python 服务器有多种安装方式', () => {
|
||||
const config = getServerConfig('python');
|
||||
|
||||
expect(config?.install.npm).toBe('pyright');
|
||||
expect(config?.install.pip).toBe('pyright');
|
||||
});
|
||||
|
||||
it('Go 服务器有 go install 配置', () => {
|
||||
const config = getServerConfig('go');
|
||||
|
||||
expect(config?.install.go).toContain('gopls');
|
||||
});
|
||||
|
||||
it('Rust 服务器有 rustup 和 brew 安装配置', () => {
|
||||
const config = getServerConfig('rust');
|
||||
|
||||
expect(config?.install.rustup).toBe('rust-analyzer');
|
||||
expect(config?.install.brew).toBe('rust-analyzer');
|
||||
});
|
||||
|
||||
it('Ruby 服务器有 gem 安装配置', () => {
|
||||
const config = getServerConfig('ruby');
|
||||
|
||||
expect(config?.install.gem).toBe('solargraph');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user