Merge tag 'v0.3.0' into chore/sync-gemini-cli-v0.3.0

This commit is contained in:
mingholy.lmh
2025-09-10 21:01:40 +08:00
583 changed files with 30160 additions and 10770 deletions

View File

@@ -4,75 +4,229 @@
* 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 { getIdeProcessInfo } 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');
describe('IdeClient', () => {
let mockClient: Mocked<Client>;
let mockHttpTransport: Mocked<StreamableHTTPClientTransport>;
let mockStdioTransport: Mocked<StdioClientTransport>;
beforeEach(async () => {
// Reset singleton instance for test isolation
(IdeClient as unknown as { instance: IdeClient | undefined }).instance =
undefined;
// Mock environment variables
process.env['QWEN_CODE_IDE_WORKSPACE_PATH'] = '/test/workspace';
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
delete process.env['QWEN_CODE_IDE_SERVER_STDIO_COMMAND'];
delete process.env['QWEN_CODE_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(getIdeProcessInfo).mockResolvedValue({
pid: 12345,
command: 'test-ide',
});
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>;
vi.mocked(Client).mockReturnValue(mockClient);
vi.mocked(StreamableHTTPClientTransport).mockReturnValue(mockHttpTransport);
vi.mocked(StdioClientTransport).mockReturnValue(mockStdioTransport);
await IdeClient.getInstance();
});
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');
afterEach(() => {
vi.restoreAllMocks();
});
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');
});
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));
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');
});
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
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);
});
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-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 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');
});
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));
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);
const ideClient = await 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 = await 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['QWEN_CODE_IDE_SERVER_PORT'] = '9090';
const ideClient = await 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['QWEN_CODE_IDE_SERVER_STDIO_COMMAND'] = 'env-cmd';
process.env['QWEN_CODE_IDE_SERVER_STDIO_ARGS'] = '["--bar"]';
const ideClient = await 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['QWEN_CODE_IDE_SERVER_PORT'] = '9090';
const ideClient = await 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 = await 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',
);
});
});
});