diff options
| author | Billy Biggs <[email protected]> | 2025-08-21 15:00:05 -0700 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-08-21 22:00:05 +0000 |
| commit | 2dd15572ea8a60ad572165d5eb796315998db7d9 (patch) | |
| tree | 3482e48dbdb48f76cb78f5d086e09076651a9234 /packages/core/src/ide/ide-client.test.ts | |
| parent | ec41b8db8e714867ea354c29c07f009cd837ac23 (diff) | |
Support IDE connections via stdio MCP (#6417)
Diffstat (limited to 'packages/core/src/ide/ide-client.test.ts')
| -rw-r--r-- | packages/core/src/ide/ide-client.test.ts | 271 |
1 files changed, 210 insertions, 61 deletions
diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index f061156d..7ad71ba3 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -4,75 +4,224 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; -import * as path from 'path'; -import { IdeClient } from './ide-client.js'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mocked, +} from 'vitest'; +import { IdeClient, IDEConnectionStatus } from './ide-client.js'; +import * as fs from 'node:fs'; +import { getIdeProcessId } from './process-utils.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { + detectIde, + DetectedIde, + getIdeInfo, + type IdeInfo, +} from './detect-ide.js'; +import * as os from 'node:os'; +import * as path from 'node:path'; -describe('IdeClient.validateWorkspacePath', () => { - it('should return valid if cwd is a subpath of the IDE workspace path', () => { - const result = IdeClient.validateWorkspacePath( - '/Users/person/gemini-cli', - 'VS Code', - '/Users/person/gemini-cli/sub-dir', - ); - expect(result.isValid).toBe(true); - }); +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + promises: { + readFile: vi.fn(), + }, + realpathSync: (p: string) => p, + existsSync: () => false, + }; +}); +vi.mock('./process-utils.js'); +vi.mock('@modelcontextprotocol/sdk/client/index.js'); +vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js'); +vi.mock('@modelcontextprotocol/sdk/client/stdio.js'); +vi.mock('./detect-ide.js'); +vi.mock('node:os'); - it('should return invalid if GEMINI_CLI_IDE_WORKSPACE_PATH is undefined', () => { - const result = IdeClient.validateWorkspacePath( - undefined, - 'VS Code', - '/Users/person/gemini-cli/sub-dir', - ); - expect(result.isValid).toBe(false); - expect(result.error).toContain('Failed to connect'); - }); +describe('IdeClient', () => { + let mockClient: Mocked<Client>; + let mockHttpTransport: Mocked<StreamableHTTPClientTransport>; + let mockStdioTransport: Mocked<StdioClientTransport>; - it('should return invalid if GEMINI_CLI_IDE_WORKSPACE_PATH is empty', () => { - const result = IdeClient.validateWorkspacePath( - '', - 'VS Code', - '/Users/person/gemini-cli/sub-dir', - ); - expect(result.isValid).toBe(false); - expect(result.error).toContain('please open a workspace folder'); - }); + beforeEach(() => { + // Reset singleton instance for test isolation + (IdeClient as unknown as { instance: IdeClient | undefined }).instance = + undefined; - it('should return invalid if cwd is not within the IDE workspace path', () => { - const result = IdeClient.validateWorkspacePath( - '/some/other/path', - 'VS Code', - '/Users/person/gemini-cli/sub-dir', - ); - expect(result.isValid).toBe(false); - expect(result.error).toContain('Directory mismatch'); - }); + // Mock environment variables + process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'] = '/test/workspace'; + delete process.env['GEMINI_CLI_IDE_SERVER_PORT']; + delete process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND']; + delete process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS']; + + // Mock dependencies + vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir'); + vi.mocked(detectIde).mockReturnValue(DetectedIde.VSCode); + vi.mocked(getIdeInfo).mockReturnValue({ + displayName: 'VS Code', + } as IdeInfo); + vi.mocked(getIdeProcessId).mockResolvedValue(12345); + vi.mocked(os.tmpdir).mockReturnValue('/tmp'); + + // Mock MCP client and transports + mockClient = { + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn(), + setNotificationHandler: vi.fn(), + callTool: vi.fn(), + } as unknown as Mocked<Client>; + mockHttpTransport = { + close: vi.fn(), + } as unknown as Mocked<StreamableHTTPClientTransport>; + mockStdioTransport = { + close: vi.fn(), + } as unknown as Mocked<StdioClientTransport>; - it('should handle multiple workspace paths and return valid', () => { - const result = IdeClient.validateWorkspacePath( - ['/some/other/path', '/Users/person/gemini-cli'].join(path.delimiter), - 'VS Code', - '/Users/person/gemini-cli/sub-dir', - ); - expect(result.isValid).toBe(true); + vi.mocked(Client).mockReturnValue(mockClient); + vi.mocked(StreamableHTTPClientTransport).mockReturnValue(mockHttpTransport); + vi.mocked(StdioClientTransport).mockReturnValue(mockStdioTransport); }); - it('should return invalid if cwd is not in any of the multiple workspace paths', () => { - const result = IdeClient.validateWorkspacePath( - ['/some/other/path', '/another/path'].join(path.delimiter), - 'VS Code', - '/Users/person/gemini-cli/sub-dir', - ); - expect(result.isValid).toBe(false); - expect(result.error).toContain('Directory mismatch'); + afterEach(() => { + vi.restoreAllMocks(); }); - it.skipIf(process.platform !== 'win32')('should handle windows paths', () => { - const result = IdeClient.validateWorkspacePath( - 'c:/some/other/path;d:/Users/person/gemini-cli', - 'VS Code', - 'd:/Users/person/gemini-cli/sub-dir', - ); - expect(result.isValid).toBe(true); + describe('connect', () => { + it('should connect using HTTP when port is provided in config file', async () => { + const config = { port: '8080' }; + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + + const ideClient = IdeClient.getInstance(); + await ideClient.connect(); + + expect(fs.promises.readFile).toHaveBeenCalledWith( + path.join('/tmp', 'gemini-ide-server-12345.json'), + 'utf8', + ); + expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( + new URL('http://localhost:8080/mcp'), + expect.any(Object), + ); + expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport); + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Connected, + ); + }); + + it('should connect using stdio when stdio config is provided in file', async () => { + const config = { stdio: { command: 'test-cmd', args: ['--foo'] } }; + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + + const ideClient = IdeClient.getInstance(); + await ideClient.connect(); + + expect(StdioClientTransport).toHaveBeenCalledWith({ + command: 'test-cmd', + args: ['--foo'], + }); + expect(mockClient.connect).toHaveBeenCalledWith(mockStdioTransport); + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Connected, + ); + }); + + it('should prioritize port over stdio when both are in config file', async () => { + const config = { + port: '8080', + stdio: { command: 'test-cmd', args: ['--foo'] }, + }; + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + + const ideClient = IdeClient.getInstance(); + await ideClient.connect(); + + expect(StreamableHTTPClientTransport).toHaveBeenCalled(); + expect(StdioClientTransport).not.toHaveBeenCalled(); + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Connected, + ); + }); + + it('should connect using HTTP when port is provided in environment variables', async () => { + vi.mocked(fs.promises.readFile).mockRejectedValue( + new Error('File not found'), + ); + process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090'; + + const ideClient = IdeClient.getInstance(); + await ideClient.connect(); + + expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( + new URL('http://localhost:9090/mcp'), + expect.any(Object), + ); + expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport); + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Connected, + ); + }); + + it('should connect using stdio when stdio config is in environment variables', async () => { + vi.mocked(fs.promises.readFile).mockRejectedValue( + new Error('File not found'), + ); + process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'] = 'env-cmd'; + process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'] = '["--bar"]'; + + const ideClient = IdeClient.getInstance(); + await ideClient.connect(); + + expect(StdioClientTransport).toHaveBeenCalledWith({ + command: 'env-cmd', + args: ['--bar'], + }); + expect(mockClient.connect).toHaveBeenCalledWith(mockStdioTransport); + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Connected, + ); + }); + + it('should prioritize file config over environment variables', async () => { + const config = { port: '8080' }; + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090'; + + const ideClient = IdeClient.getInstance(); + await ideClient.connect(); + + expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( + new URL('http://localhost:8080/mcp'), + expect.any(Object), + ); + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Connected, + ); + }); + + it('should be disconnected if no config is found', async () => { + vi.mocked(fs.promises.readFile).mockRejectedValue( + new Error('File not found'), + ); + + const ideClient = IdeClient.getInstance(); + await ideClient.connect(); + + expect(StreamableHTTPClientTransport).not.toHaveBeenCalled(); + expect(StdioClientTransport).not.toHaveBeenCalled(); + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Disconnected, + ); + expect(ideClient.getConnectionStatus().details).toContain( + 'Failed to connect', + ); + }); }); }); |
