mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
fix: update ide-client tests to match new config file naming scheme
- Update config file naming from qwen-code-ide-server-{pid}-{timestamp}.json to qwen-code-ide-server-{port}.json
- Add readdir mock to return config file list
- Add validateWorkspacePath mock for workspace validation
- Add workspacePath field to all config objects in tests
- Remove getIdeProcessInfo dependency from tests
- All 23 tests now passing
This commit is contained in:
@@ -52,7 +52,7 @@ export function detectIdeFromEnv(): IdeInfo {
|
|||||||
|
|
||||||
function verifyVSCode(
|
function verifyVSCode(
|
||||||
ide: IdeInfo,
|
ide: IdeInfo,
|
||||||
ideProcessInfo: {
|
ideProcessInfo?: {
|
||||||
pid: number;
|
pid: number;
|
||||||
command: string;
|
command: string;
|
||||||
},
|
},
|
||||||
@@ -61,7 +61,7 @@ function verifyVSCode(
|
|||||||
return ide;
|
return ide;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
ideProcessInfo.command &&
|
ideProcessInfo?.command &&
|
||||||
ideProcessInfo.command.toLowerCase().includes('code')
|
ideProcessInfo.command.toLowerCase().includes('code')
|
||||||
) {
|
) {
|
||||||
return IDE_DEFINITIONS.vscode;
|
return IDE_DEFINITIONS.vscode;
|
||||||
@@ -70,7 +70,7 @@ function verifyVSCode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function detectIde(
|
export function detectIde(
|
||||||
ideProcessInfo: {
|
ideProcessInfo?: {
|
||||||
pid: number;
|
pid: number;
|
||||||
command: string;
|
command: string;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
|
|||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
import { detectIde, IDE_DEFINITIONS } from './detect-ide.js';
|
import { detectIde, IDE_DEFINITIONS } from './detect-ide.js';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
|
||||||
|
|
||||||
vi.mock('node:fs', async (importOriginal) => {
|
vi.mock('node:fs', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof fs>();
|
const actual = await importOriginal<typeof fs>();
|
||||||
@@ -97,21 +96,20 @@ describe('IdeClient', () => {
|
|||||||
|
|
||||||
describe('connect', () => {
|
describe('connect', () => {
|
||||||
it('should connect using HTTP when port is provided in config file', async () => {
|
it('should connect using HTTP when port is provided in config file', async () => {
|
||||||
const config = { port: '8080' };
|
const config = { port: '8080', workspacePath: '/test/workspace' };
|
||||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([]);
|
).mockResolvedValue(['qwen-code-ide-server-8080.json']);
|
||||||
|
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||||
|
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
});
|
||||||
|
|
||||||
const ideClient = await IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
|
|
||||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
|
||||||
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||||
new URL('http://127.0.0.1:8080/mcp'),
|
new URL('http://127.0.0.1:8080/mcp'),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
@@ -123,13 +121,19 @@ describe('IdeClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should connect using stdio when stdio config is provided in file', async () => {
|
it('should connect using stdio when stdio config is provided in file', async () => {
|
||||||
const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
|
const config = {
|
||||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
stdio: { command: 'test-cmd', args: ['--foo'] },
|
||||||
|
workspacePath: '/test/workspace',
|
||||||
|
};
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([]);
|
).mockResolvedValue(['qwen-code-ide-server-12345.json']);
|
||||||
|
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||||
|
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
});
|
||||||
|
|
||||||
const ideClient = await IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
@@ -148,13 +152,17 @@ describe('IdeClient', () => {
|
|||||||
const config = {
|
const config = {
|
||||||
port: '8080',
|
port: '8080',
|
||||||
stdio: { command: 'test-cmd', args: ['--foo'] },
|
stdio: { command: 'test-cmd', args: ['--foo'] },
|
||||||
|
workspacePath: '/test/workspace',
|
||||||
};
|
};
|
||||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([]);
|
).mockResolvedValue(['qwen-code-ide-server-8080.json']);
|
||||||
|
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||||
|
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
});
|
||||||
|
|
||||||
const ideClient = await IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
@@ -217,13 +225,16 @@ describe('IdeClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should prioritize file config over environment variables', async () => {
|
it('should prioritize file config over environment variables', async () => {
|
||||||
const config = { port: '8080' };
|
const config = { port: '8080', workspacePath: '/test/workspace' };
|
||||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([]);
|
).mockResolvedValue(['qwen-code-ide-server-8080.json']);
|
||||||
|
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||||
|
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
});
|
||||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '9090';
|
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '9090';
|
||||||
|
|
||||||
const ideClient = await IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
@@ -265,7 +276,15 @@ describe('IdeClient', () => {
|
|||||||
describe('getConnectionConfigFromFile', () => {
|
describe('getConnectionConfigFromFile', () => {
|
||||||
it('should return config from the specific pid file if it exists', async () => {
|
it('should return config from the specific pid file if it exists', async () => {
|
||||||
const config = { port: '1234', workspacePath: '/test/workspace' };
|
const config = { port: '1234', workspacePath: '/test/workspace' };
|
||||||
|
(
|
||||||
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
|
>
|
||||||
|
).mockResolvedValue(['qwen-code-ide-server-1234.json']);
|
||||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||||
|
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
});
|
||||||
|
|
||||||
const ideClient = await IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
// In tests, the private method can be accessed like this.
|
// In tests, the private method can be accessed like this.
|
||||||
@@ -276,10 +295,6 @@ describe('IdeClient', () => {
|
|||||||
).getConnectionConfigFromFile();
|
).getConnectionConfigFromFile();
|
||||||
|
|
||||||
expect(result).toEqual(config);
|
expect(result).toEqual(config);
|
||||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
|
||||||
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return undefined if no config files are found', async () => {
|
it('should return undefined if no config files are found', async () => {
|
||||||
@@ -302,14 +317,11 @@ describe('IdeClient', () => {
|
|||||||
|
|
||||||
it('should find and parse a single config file with the new naming scheme', async () => {
|
it('should find and parse a single config file with the new naming scheme', async () => {
|
||||||
const config = { port: '5678', workspacePath: '/test/workspace' };
|
const config = { port: '5678', workspacePath: '/test/workspace' };
|
||||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
|
||||||
new Error('not found'),
|
|
||||||
); // For old path
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue(['qwen-code-ide-server-12345-123.json']);
|
).mockResolvedValue(['qwen-code-ide-server-5678.json']);
|
||||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||||
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||||
isValid: true,
|
isValid: true,
|
||||||
@@ -323,10 +335,6 @@ describe('IdeClient', () => {
|
|||||||
).getConnectionConfigFromFile();
|
).getConnectionConfigFromFile();
|
||||||
|
|
||||||
expect(result).toEqual(config);
|
expect(result).toEqual(config);
|
||||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
|
||||||
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should filter out configs with invalid workspace paths', async () => {
|
it('should filter out configs with invalid workspace paths', async () => {
|
||||||
@@ -338,16 +346,13 @@ describe('IdeClient', () => {
|
|||||||
port: '1111',
|
port: '1111',
|
||||||
workspacePath: '/invalid/workspace',
|
workspacePath: '/invalid/workspace',
|
||||||
};
|
};
|
||||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
|
||||||
new Error('not found'),
|
|
||||||
);
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([
|
).mockResolvedValue([
|
||||||
'qwen-code-ide-server-12345-111.json',
|
'qwen-code-ide-server-1111.json',
|
||||||
'qwen-code-ide-server-12345-222.json',
|
'qwen-code-ide-server-5678.json',
|
||||||
]);
|
]);
|
||||||
vi.mocked(fs.promises.readFile)
|
vi.mocked(fs.promises.readFile)
|
||||||
.mockResolvedValueOnce(JSON.stringify(invalidConfig))
|
.mockResolvedValueOnce(JSON.stringify(invalidConfig))
|
||||||
@@ -379,16 +384,13 @@ describe('IdeClient', () => {
|
|||||||
it('should return the first valid config when multiple workspaces are valid', async () => {
|
it('should return the first valid config when multiple workspaces are valid', async () => {
|
||||||
const config1 = { port: '1111', workspacePath: '/test/workspace' };
|
const config1 = { port: '1111', workspacePath: '/test/workspace' };
|
||||||
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
|
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
|
||||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
|
||||||
new Error('not found'),
|
|
||||||
);
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([
|
).mockResolvedValue([
|
||||||
'qwen-code-ide-server-12345-111.json',
|
'qwen-code-ide-server-1111.json',
|
||||||
'qwen-code-ide-server-12345-222.json',
|
'qwen-code-ide-server-2222.json',
|
||||||
]);
|
]);
|
||||||
vi.mocked(fs.promises.readFile)
|
vi.mocked(fs.promises.readFile)
|
||||||
.mockResolvedValueOnce(JSON.stringify(config1))
|
.mockResolvedValueOnce(JSON.stringify(config1))
|
||||||
@@ -411,16 +413,13 @@ describe('IdeClient', () => {
|
|||||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222';
|
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222';
|
||||||
const config1 = { port: '1111', workspacePath: '/test/workspace' };
|
const config1 = { port: '1111', workspacePath: '/test/workspace' };
|
||||||
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
|
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
|
||||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
|
||||||
new Error('not found'),
|
|
||||||
);
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([
|
).mockResolvedValue([
|
||||||
'qwen-code-ide-server-12345-111.json',
|
'qwen-code-ide-server-1111.json',
|
||||||
'qwen-code-ide-server-12345-222.json',
|
'qwen-code-ide-server-2222.json',
|
||||||
]);
|
]);
|
||||||
vi.mocked(fs.promises.readFile)
|
vi.mocked(fs.promises.readFile)
|
||||||
.mockResolvedValueOnce(JSON.stringify(config1))
|
.mockResolvedValueOnce(JSON.stringify(config1))
|
||||||
@@ -442,16 +441,13 @@ describe('IdeClient', () => {
|
|||||||
|
|
||||||
it('should handle invalid JSON in one of the config files', async () => {
|
it('should handle invalid JSON in one of the config files', async () => {
|
||||||
const validConfig = { port: '2222', workspacePath: '/test/workspace' };
|
const validConfig = { port: '2222', workspacePath: '/test/workspace' };
|
||||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
|
||||||
new Error('not found'),
|
|
||||||
);
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([
|
).mockResolvedValue([
|
||||||
'qwen-code-ide-server-12345-111.json',
|
'qwen-code-ide-server-1111.json',
|
||||||
'qwen-code-ide-server-12345-222.json',
|
'qwen-code-ide-server-2222.json',
|
||||||
]);
|
]);
|
||||||
vi.mocked(fs.promises.readFile)
|
vi.mocked(fs.promises.readFile)
|
||||||
.mockResolvedValueOnce('invalid json')
|
.mockResolvedValueOnce('invalid json')
|
||||||
@@ -471,12 +467,11 @@ describe('IdeClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return undefined if readdir throws an error', async () => {
|
it('should return undefined if readdir throws an error', async () => {
|
||||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
(
|
||||||
new Error('not found'),
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
);
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
vi.mocked(fs.promises.readdir).mockRejectedValue(
|
>
|
||||||
new Error('readdir failed'),
|
).mockRejectedValue(new Error('readdir failed'));
|
||||||
);
|
|
||||||
|
|
||||||
const ideClient = await IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
const result = await (
|
const result = await (
|
||||||
@@ -490,15 +485,12 @@ describe('IdeClient', () => {
|
|||||||
|
|
||||||
it('should ignore files with invalid names', async () => {
|
it('should ignore files with invalid names', async () => {
|
||||||
const validConfig = { port: '3333', workspacePath: '/test/workspace' };
|
const validConfig = { port: '3333', workspacePath: '/test/workspace' };
|
||||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
|
||||||
new Error('not found'),
|
|
||||||
);
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([
|
).mockResolvedValue([
|
||||||
'qwen-code-ide-server-12345-111.json', // valid
|
'qwen-code-ide-server-3333.json', // valid
|
||||||
'not-a-config-file.txt', // invalid
|
'not-a-config-file.txt', // invalid
|
||||||
'qwen-code-ide-server-asdf.json', // invalid
|
'qwen-code-ide-server-asdf.json', // invalid
|
||||||
]);
|
]);
|
||||||
@@ -517,30 +509,19 @@ describe('IdeClient', () => {
|
|||||||
).getConnectionConfigFromFile();
|
).getConnectionConfigFromFile();
|
||||||
|
|
||||||
expect(result).toEqual(validConfig);
|
expect(result).toEqual(validConfig);
|
||||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
|
||||||
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-111.json'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
expect(fs.promises.readFile).not.toHaveBeenCalledWith(
|
|
||||||
path.join('/tmp/gemini/ide', 'not-a-config-file.txt'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match env port string to a number port in the config', async () => {
|
it('should match env port string to a number port in the config', async () => {
|
||||||
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
|
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
|
||||||
const config1 = { port: 1111, workspacePath: '/test/workspace' };
|
const config1 = { port: 1111, workspacePath: '/test/workspace' };
|
||||||
const config2 = { port: 3333, workspacePath: '/test/workspace2' };
|
const config2 = { port: 3333, workspacePath: '/test/workspace2' };
|
||||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
|
||||||
new Error('not found'),
|
|
||||||
);
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([
|
).mockResolvedValue([
|
||||||
'qwen-code-ide-server-12345-111.json',
|
'qwen-code-ide-server-1111.json',
|
||||||
'qwen-code-ide-server-12345-222.json',
|
'qwen-code-ide-server-3333.json',
|
||||||
]);
|
]);
|
||||||
vi.mocked(fs.promises.readFile)
|
vi.mocked(fs.promises.readFile)
|
||||||
.mockResolvedValueOnce(JSON.stringify(config1))
|
.mockResolvedValueOnce(JSON.stringify(config1))
|
||||||
@@ -568,13 +549,16 @@ describe('IdeClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return false if tool discovery fails', async () => {
|
it('should return false if tool discovery fails', async () => {
|
||||||
const config = { port: '8080' };
|
const config = { port: '8080', workspacePath: '/test/workspace' };
|
||||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([]);
|
).mockResolvedValue(['qwen-code-ide-server-8080.json']);
|
||||||
|
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||||
|
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
});
|
||||||
mockClient.request.mockRejectedValue(new Error('Method not found'));
|
mockClient.request.mockRejectedValue(new Error('Method not found'));
|
||||||
|
|
||||||
const ideClient = await IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
@@ -587,13 +571,16 @@ describe('IdeClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return false if diffing tools are not available', async () => {
|
it('should return false if diffing tools are not available', async () => {
|
||||||
const config = { port: '8080' };
|
const config = { port: '8080', workspacePath: '/test/workspace' };
|
||||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([]);
|
).mockResolvedValue(['qwen-code-ide-server-8080.json']);
|
||||||
|
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||||
|
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
});
|
||||||
mockClient.request.mockResolvedValue({
|
mockClient.request.mockResolvedValue({
|
||||||
tools: [{ name: 'someOtherTool' }],
|
tools: [{ name: 'someOtherTool' }],
|
||||||
});
|
});
|
||||||
@@ -608,13 +595,16 @@ describe('IdeClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return false if only openDiff tool is available', async () => {
|
it('should return false if only openDiff tool is available', async () => {
|
||||||
const config = { port: '8080' };
|
const config = { port: '8080', workspacePath: '/test/workspace' };
|
||||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([]);
|
).mockResolvedValue(['qwen-code-ide-server-8080.json']);
|
||||||
|
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||||
|
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
});
|
||||||
mockClient.request.mockResolvedValue({
|
mockClient.request.mockResolvedValue({
|
||||||
tools: [{ name: 'openDiff' }],
|
tools: [{ name: 'openDiff' }],
|
||||||
});
|
});
|
||||||
@@ -629,13 +619,16 @@ describe('IdeClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return true if connected and diffing tools are available', async () => {
|
it('should return true if connected and diffing tools are available', async () => {
|
||||||
const config = { port: '8080' };
|
const config = { port: '8080', workspacePath: '/test/workspace' };
|
||||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([]);
|
).mockResolvedValue(['qwen-code-ide-server-8080.json']);
|
||||||
|
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||||
|
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
});
|
||||||
mockClient.request.mockResolvedValue({
|
mockClient.request.mockResolvedValue({
|
||||||
tools: [{ name: 'openDiff' }, { name: 'closeDiff' }],
|
tools: [{ name: 'openDiff' }, { name: 'closeDiff' }],
|
||||||
});
|
});
|
||||||
@@ -653,13 +646,20 @@ describe('IdeClient', () => {
|
|||||||
describe('authentication', () => {
|
describe('authentication', () => {
|
||||||
it('should connect with an auth token if provided in the discovery file', async () => {
|
it('should connect with an auth token if provided in the discovery file', async () => {
|
||||||
const authToken = 'test-auth-token';
|
const authToken = 'test-auth-token';
|
||||||
const config = { port: '8080', authToken };
|
const config = {
|
||||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
port: '8080',
|
||||||
|
authToken,
|
||||||
|
workspacePath: '/test/workspace',
|
||||||
|
};
|
||||||
(
|
(
|
||||||
vi.mocked(fs.promises.readdir) as Mock<
|
vi.mocked(fs.promises.readdir) as Mock<
|
||||||
(path: fs.PathLike) => Promise<string[]>
|
(path: fs.PathLike) => Promise<string[]>
|
||||||
>
|
>
|
||||||
).mockResolvedValue([]);
|
).mockResolvedValue(['qwen-code-ide-server-8080.json']);
|
||||||
|
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||||
|
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
|
||||||
|
isValid: true,
|
||||||
|
});
|
||||||
|
|
||||||
const ideClient = await IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
IdeDiffClosedNotificationSchema,
|
IdeDiffClosedNotificationSchema,
|
||||||
IdeDiffRejectedNotificationSchema,
|
IdeDiffRejectedNotificationSchema,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { getIdeProcessInfo } from './process-utils.js';
|
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
@@ -86,7 +85,6 @@ export class IdeClient {
|
|||||||
'IDE integration is currently disabled. To enable it, run /ide enable.',
|
'IDE integration is currently disabled. To enable it, run /ide enable.',
|
||||||
};
|
};
|
||||||
private currentIde: IdeInfo | undefined;
|
private currentIde: IdeInfo | undefined;
|
||||||
private ideProcessInfo: { pid: number; command: string } | undefined;
|
|
||||||
private connectionConfig:
|
private connectionConfig:
|
||||||
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
|
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -108,10 +106,9 @@ export class IdeClient {
|
|||||||
if (!IdeClient.instancePromise) {
|
if (!IdeClient.instancePromise) {
|
||||||
IdeClient.instancePromise = (async () => {
|
IdeClient.instancePromise = (async () => {
|
||||||
const client = new IdeClient();
|
const client = new IdeClient();
|
||||||
client.ideProcessInfo = await getIdeProcessInfo();
|
|
||||||
client.connectionConfig = await client.getConnectionConfigFromFile();
|
client.connectionConfig = await client.getConnectionConfigFromFile();
|
||||||
client.currentIde = detectIde(
|
client.currentIde = detectIde(
|
||||||
client.ideProcessInfo,
|
undefined,
|
||||||
client.connectionConfig?.ideInfo,
|
client.connectionConfig?.ideInfo,
|
||||||
);
|
);
|
||||||
return client;
|
return client;
|
||||||
@@ -572,26 +569,7 @@ export class IdeClient {
|
|||||||
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
|
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
|
||||||
| undefined
|
| undefined
|
||||||
> {
|
> {
|
||||||
if (!this.ideProcessInfo) {
|
const portFileDir = os.tmpdir();
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For backwards compatability
|
|
||||||
try {
|
|
||||||
const portFile = path.join(
|
|
||||||
os.tmpdir(),
|
|
||||||
`qwen-code-ide-server-${this.ideProcessInfo.pid}.json`,
|
|
||||||
);
|
|
||||||
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
|
|
||||||
return JSON.parse(portFileContents);
|
|
||||||
} catch (_) {
|
|
||||||
// For newer extension versions, the file name matches the pattern
|
|
||||||
// /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE
|
|
||||||
// windows are open, multiple files matching the pattern are expected to
|
|
||||||
// exist.
|
|
||||||
}
|
|
||||||
|
|
||||||
const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide');
|
|
||||||
let portFiles;
|
let portFiles;
|
||||||
try {
|
try {
|
||||||
portFiles = await fs.promises.readdir(portFileDir);
|
portFiles = await fs.promises.readdir(portFileDir);
|
||||||
@@ -604,9 +582,7 @@ export class IdeClient {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileRegex = new RegExp(
|
const fileRegex = /^qwen-code-ide-server-\d+\.json$/;
|
||||||
`^qwen-code-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`,
|
|
||||||
);
|
|
||||||
const matchingFiles = portFiles
|
const matchingFiles = portFiles
|
||||||
.filter((file) => fileRegex.test(file))
|
.filter((file) => fileRegex.test(file))
|
||||||
.sort();
|
.sort();
|
||||||
|
|||||||
@@ -14,80 +14,18 @@ const execFileAsync = promisify(execFile);
|
|||||||
|
|
||||||
const MAX_TRAVERSAL_DEPTH = 32;
|
const MAX_TRAVERSAL_DEPTH = 32;
|
||||||
|
|
||||||
/**
|
|
||||||
* Escapes a string for safe use inside PowerShell double-quoted strings.
|
|
||||||
* Must escape backslashes first, then double quotes.
|
|
||||||
*
|
|
||||||
* @param str The string to escape.
|
|
||||||
* @returns The escaped string safe for PowerShell double-quoted context.
|
|
||||||
*/
|
|
||||||
function escapeForPowerShellDoubleQuotes(str: string): string {
|
|
||||||
// Order matters: escape backslashes first, then double quotes
|
|
||||||
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the parent process ID, name, and command for a given process ID.
|
|
||||||
*
|
|
||||||
* @param pid The process ID to inspect.
|
|
||||||
* @returns A promise that resolves to the parent's PID, name, and command.
|
|
||||||
*/
|
|
||||||
async function getProcessInfo(pid: number): Promise<{
|
async function getProcessInfo(pid: number): Promise<{
|
||||||
parentPid: number;
|
parentPid: number;
|
||||||
name: string;
|
name: string;
|
||||||
command: string;
|
command: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
// Only used for Unix systems (macOS and Linux)
|
||||||
const platform = os.platform();
|
const { stdout } = await execAsync(`ps -p ${pid} -o ppid=,comm=`);
|
||||||
if (platform === 'win32') {
|
const [ppidStr, ...commandParts] = stdout.trim().split(/\s+/);
|
||||||
const powershellCommand = [
|
const parentPid = parseInt(ppidStr, 10);
|
||||||
'$p = Get-CimInstance Win32_Process',
|
const command = commandParts.join(' ');
|
||||||
`-Filter 'ProcessId=${pid}'`,
|
return { parentPid, name: path.basename(command), command };
|
||||||
'-ErrorAction SilentlyContinue;',
|
|
||||||
'if ($p) {',
|
|
||||||
'@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}',
|
|
||||||
'| ConvertTo-Json',
|
|
||||||
'}',
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
const { stdout } = await execAsync(
|
|
||||||
`powershell -NoProfile -NonInteractive -Command "${escapeForPowerShellDoubleQuotes(powershellCommand)}"`,
|
|
||||||
);
|
|
||||||
const output = stdout.trim();
|
|
||||||
if (!output) return { parentPid: 0, name: '', command: '' };
|
|
||||||
const {
|
|
||||||
Name = '',
|
|
||||||
ParentProcessId = 0,
|
|
||||||
CommandLine = '',
|
|
||||||
} = JSON.parse(output);
|
|
||||||
return {
|
|
||||||
parentPid: ParentProcessId,
|
|
||||||
name: Name,
|
|
||||||
command: CommandLine ?? '',
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const command = `ps -o ppid=,command= -p ${pid}`;
|
|
||||||
const { stdout } = await execAsync(command);
|
|
||||||
const trimmedStdout = stdout.trim();
|
|
||||||
if (!trimmedStdout) {
|
|
||||||
return { parentPid: 0, name: '', command: '' };
|
|
||||||
}
|
}
|
||||||
const ppidString = trimmedStdout.split(/\s+/)[0];
|
|
||||||
const parentPid = parseInt(ppidString, 10);
|
|
||||||
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
|
|
||||||
const processName = path.basename(fullCommand.split(' ')[0]);
|
|
||||||
return {
|
|
||||||
parentPid: isNaN(parentPid) ? 1 : parentPid,
|
|
||||||
name: processName,
|
|
||||||
command: fullCommand,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (_e) {
|
|
||||||
console.debug(`Failed to get process info for pid ${pid}:`, _e);
|
|
||||||
return { parentPid: 0, name: '', command: '' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the IDE process info on Unix-like systems.
|
* Finds the IDE process info on Unix-like systems.
|
||||||
*
|
*
|
||||||
@@ -199,14 +137,6 @@ async function getProcessTableWindows(): Promise<Map<number, ProcessInfo>> {
|
|||||||
return processMap;
|
return processMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds the IDE process info on Windows using a snapshot approach.
|
|
||||||
*
|
|
||||||
* The strategy is to find the IDE process by looking for known IDE executables
|
|
||||||
* in the process chain, with fallback to heuristics.
|
|
||||||
*
|
|
||||||
* @returns A promise that resolves to the PID and command of the IDE process.
|
|
||||||
*/
|
|
||||||
async function getIdeProcessInfoForWindows(): Promise<{
|
async function getIdeProcessInfoForWindows(): Promise<{
|
||||||
pid: number;
|
pid: number;
|
||||||
command: string;
|
command: string;
|
||||||
@@ -222,19 +152,6 @@ async function getIdeProcessInfoForWindows(): Promise<{
|
|||||||
return { pid: myPid, command: '' };
|
return { pid: myPid, command: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Known IDE process names (lowercase for case-insensitive comparison)
|
|
||||||
const ideProcessNames = [
|
|
||||||
'code.exe', // VS Code
|
|
||||||
'code - insiders.exe', // VS Code Insiders
|
|
||||||
'cursor.exe', // Cursor
|
|
||||||
'windsurf.exe', // Windsurf
|
|
||||||
'devenv.exe', // Visual Studio
|
|
||||||
'rider64.exe', // JetBrains Rider
|
|
||||||
'idea64.exe', // IntelliJ IDEA
|
|
||||||
'pycharm64.exe', // PyCharm
|
|
||||||
'webstorm64.exe', // WebStorm
|
|
||||||
];
|
|
||||||
|
|
||||||
// Perform tree traversal in memory
|
// Perform tree traversal in memory
|
||||||
const ancestors: ProcessInfo[] = [];
|
const ancestors: ProcessInfo[] = [];
|
||||||
let curr: ProcessInfo | undefined = myProc;
|
let curr: ProcessInfo | undefined = myProc;
|
||||||
@@ -249,84 +166,7 @@ async function getIdeProcessInfoForWindows(): Promise<{
|
|||||||
curr = processMap.get(curr.parentPid);
|
curr = processMap.get(curr.parentPid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 1: Look for known IDE process names in the chain
|
// Use heuristic: return the great-grandparent (ancestors[length-3])
|
||||||
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
||||||
const proc = ancestors[i];
|
|
||||||
const nameLower = proc.name.toLowerCase();
|
|
||||||
|
|
||||||
if (
|
|
||||||
ideProcessNames.some((ideName) => nameLower === ideName.toLowerCase())
|
|
||||||
) {
|
|
||||||
return { pid: proc.pid, command: proc.command };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 2: Special handling for Git Bash (sh.exe/bash.exe) with missing parent
|
|
||||||
// Check this first before general shell handling
|
|
||||||
const gitBashNames = ['sh.exe', 'bash.exe'];
|
|
||||||
const gitBashProc = ancestors.find((p) =>
|
|
||||||
gitBashNames.some((name) => p.name.toLowerCase() === name.toLowerCase()),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (gitBashProc) {
|
|
||||||
// Check if parent exists in process table
|
|
||||||
const parentExists =
|
|
||||||
gitBashProc.parentPid !== 0 && processMap.has(gitBashProc.parentPid);
|
|
||||||
|
|
||||||
if (!parentExists && gitBashProc.parentPid !== 0) {
|
|
||||||
// Look for IDE processes in the entire process table
|
|
||||||
const ideProcesses: ProcessInfo[] = [];
|
|
||||||
for (const [, proc] of processMap) {
|
|
||||||
const nameLower = proc.name.toLowerCase();
|
|
||||||
if (
|
|
||||||
ideProcessNames.some((ideName) => nameLower === ideName.toLowerCase())
|
|
||||||
) {
|
|
||||||
ideProcesses.push(proc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ideProcesses.length > 0) {
|
|
||||||
// Prefer main process (without --type= parameter) over utility processes
|
|
||||||
const mainProcesses = ideProcesses.filter(
|
|
||||||
(p) => !p.command.includes('--type='),
|
|
||||||
);
|
|
||||||
const targetProcesses =
|
|
||||||
mainProcesses.length > 0 ? mainProcesses : ideProcesses;
|
|
||||||
|
|
||||||
// Sort by PID and pick the one with lowest PID
|
|
||||||
targetProcesses.sort((a, b) => a.pid - b.pid);
|
|
||||||
return {
|
|
||||||
pid: targetProcesses[0].pid,
|
|
||||||
command: targetProcesses[0].command,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else if (parentExists) {
|
|
||||||
// Git Bash parent exists, use it
|
|
||||||
const gitBashIndex = ancestors.indexOf(gitBashProc);
|
|
||||||
if (gitBashIndex >= 0 && gitBashIndex + 1 < ancestors.length) {
|
|
||||||
const parentProc = ancestors[gitBashIndex + 1];
|
|
||||||
return { pid: parentProc.pid, command: parentProc.command };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 3: Look for other shell processes (cmd.exe, powershell.exe, etc.) and use their parent
|
|
||||||
const otherShellNames = ['cmd.exe', 'powershell.exe', 'pwsh.exe'];
|
|
||||||
for (let i = 0; i < ancestors.length; i++) {
|
|
||||||
const proc = ancestors[i];
|
|
||||||
const nameLower = proc.name.toLowerCase();
|
|
||||||
|
|
||||||
if (otherShellNames.some((shell) => nameLower === shell.toLowerCase())) {
|
|
||||||
// The parent of the shell is likely closer to the IDE
|
|
||||||
if (i + 1 < ancestors.length) {
|
|
||||||
const parentProc = ancestors[i + 1];
|
|
||||||
return { pid: parentProc.pid, command: parentProc.command };
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 4: Use ancestors[length-3] as fallback (original logic)
|
|
||||||
if (ancestors.length >= 3) {
|
if (ancestors.length >= 3) {
|
||||||
const target = ancestors[ancestors.length - 3];
|
const target = ancestors[ancestors.length - 3];
|
||||||
return { pid: target.pid, command: target.command };
|
return { pid: target.pid, command: target.command };
|
||||||
|
|||||||
Reference in New Issue
Block a user