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); }); }); });