basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import {8 describe,9 it,10 expect,11 vi,12 beforeEach,13 afterEach,14 type Mocked,15 type Mock,16} from 'vitest';17 18const { mockUndiciFetch, mockProxyAgent, mockEnvHttpProxyAgent } = vi.hoisted(19 () => {20 const proxyAgent = { kind: 'env-proxy-agent' };21 return {22 mockUndiciFetch: vi.fn(),23 mockProxyAgent: proxyAgent,24 mockEnvHttpProxyAgent: vi.fn(() => proxyAgent),25 };26 },27);28const { mockDebugLogger } = vi.hoisted(() => ({29 mockDebugLogger: {30 debug: vi.fn(),31 error: vi.fn(),32 },33}));34 35vi.mock('undici', () => ({36 EnvHttpProxyAgent: mockEnvHttpProxyAgent,37 fetch: mockUndiciFetch,38}));39vi.mock('../utils/debugLogger.js', () => ({40 createDebugLogger: () => mockDebugLogger,41}));42 43import {44 IdeClient,45 IDEConnectionStatus,46 getIdeServerHost,47 _resetCachedIdeServerHost,48} from './ide-client.js';49import * as fs from 'node:fs';50import type { FileHandle } from 'node:fs/promises';51import * as dns from 'node:dns';52import { getIdeProcessInfo } from './process-utils.js';53import { Client } from '@modelcontextprotocol/sdk/client/index.js';54import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';55import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';56import { detectIde, IDE_DEFINITIONS } from './detect-ide.js';57import * as os from 'node:os';58import * as path from 'node:path';59 60vi.mock('node:fs', async (importOriginal) => {61 const actual = await importOriginal<typeof fs>();62 return {63 ...(actual as object),64 promises: {65 ...actual.promises,66 readFile: vi.fn(),67 readdir: vi.fn(),68 stat: vi.fn(),69 unlink: vi.fn(),70 },71 realpathSync: (p: string) => p,72 existsSync: vi.fn().mockReturnValue(false),73 };74});75vi.mock('node:dns', async (importOriginal) => {76 const actual = await importOriginal<typeof dns>();77 return {78 ...(actual as object),79 lookup: vi.fn(),80 };81});82vi.mock('./process-utils.js');83vi.mock('@modelcontextprotocol/sdk/client/index.js');84vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js');85vi.mock('@modelcontextprotocol/sdk/client/stdio.js');86vi.mock('./detect-ide.js');87vi.mock('node:os');88 89describe('IdeClient', () => {90 let mockClient: Mocked<Client>;91 let mockHttpTransport: Mocked<StreamableHTTPClientTransport>;92 let mockStdioTransport: Mocked<StdioClientTransport>;93 94 beforeEach(async () => {95 // Reset singleton instance and cached host for test isolation96 (97 IdeClient as unknown as {98 instancePromise: Promise<IdeClient> | null;99 }100 ).instancePromise = null;101 _resetCachedIdeServerHost();102 103 // Mock environment variables104 process.env['QWEN_CODE_IDE_WORKSPACE_PATH'] = '/test/workspace';105 delete process.env['QWEN_CODE_IDE_SERVER_PORT'];106 delete process.env['QWEN_CODE_IDE_SERVER_STDIO_COMMAND'];107 delete process.env['QWEN_CODE_IDE_SERVER_STDIO_ARGS'];108 109 // Mock dependencies110 vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir');111 vi.mocked(fs.existsSync).mockImplementation((filePath: fs.PathLike) => {112 const file = String(filePath);113 return file !== '/.dockerenv' && file !== '/run/.containerenv';114 });115 vi.mocked(detectIde).mockReturnValue(IDE_DEFINITIONS.vscode);116 vi.mocked(getIdeProcessInfo).mockResolvedValue({117 pid: 12345,118 command: 'test-ide',119 });120 vi.mocked(os.tmpdir).mockReturnValue('/tmp');121 vi.mocked(os.homedir).mockReturnValue('/home/test');122 mockDebugLogger.debug.mockClear();123 mockDebugLogger.error.mockClear();124 125 // Mock MCP client and transports126 mockClient = {127 connect: vi.fn().mockResolvedValue(undefined),128 close: vi.fn(),129 setNotificationHandler: vi.fn(),130 callTool: vi.fn(),131 request: vi.fn(),132 } as unknown as Mocked<Client>;133 mockHttpTransport = {134 close: vi.fn(),135 } as unknown as Mocked<StreamableHTTPClientTransport>;136 mockStdioTransport = {137 close: vi.fn(),138 } as unknown as Mocked<StdioClientTransport>;139 140 vi.mocked(Client).mockReturnValue(mockClient);141 vi.mocked(StreamableHTTPClientTransport).mockReturnValue(mockHttpTransport);142 vi.mocked(StdioClientTransport).mockReturnValue(mockStdioTransport);143 mockUndiciFetch.mockReset();144 mockEnvHttpProxyAgent.mockClear();145 146 await IdeClient.getInstance();147 });148 149 afterEach(() => {150 vi.useRealTimers();151 vi.restoreAllMocks();152 });153 154 describe('createProxyAwareFetch', () => {155 it('uses undici fetch with the proxy-aware dispatcher', async () => {156 mockUndiciFetch.mockResolvedValue(157 new Response('ok', {158 status: 201,159 statusText: 'Created',160 headers: { 'x-test': 'yes' },161 }),162 );163 const ideClient = await IdeClient.getInstance();164 const fetch = (165 ideClient as unknown as {166 createProxyAwareFetch: (167 host: string,168 ) => (url: string, init?: RequestInit) => Promise<Response>;169 }170 ).createProxyAwareFetch('127.0.0.1');171 172 const response = await fetch('http://127.0.0.1:8080/mcp', {173 method: 'POST',174 });175 176 expect(response.status).toBe(201);177 expect(response.headers.get('x-test')).toBe('yes');178 expect(mockEnvHttpProxyAgent).toHaveBeenCalled();179 expect(mockUndiciFetch).toHaveBeenCalledWith(180 'http://127.0.0.1:8080/mcp',181 expect.objectContaining({182 method: 'POST',183 dispatcher: mockProxyAgent,184 }),185 );186 });187 });188 189 describe('connect', () => {190 it('should connect using HTTP when port is provided in config file', async () => {191 process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';192 const config = { port: '8080' };193 vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));194 195 const ideClient = await IdeClient.getInstance();196 await ideClient.connect();197 198 expect(fs.promises.readFile).toHaveBeenCalledWith(199 path.join('/home/test', '.qwen', 'ide', '8080.lock'),200 'utf8',201 );202 expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(203 new URL('http://127.0.0.1:8080/mcp'),204 expect.any(Object),205 );206 expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);207 expect(ideClient.getConnectionStatus().status).toBe(208 IDEConnectionStatus.Connected,209 );210 delete process.env['QWEN_CODE_IDE_SERVER_PORT'];211 });212 213 it('should connect using stdio when stdio config is provided in file', async () => {214 process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';215 const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };216 vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));217 218 const ideClient = await IdeClient.getInstance();219 await ideClient.connect();220 221 expect(StdioClientTransport).toHaveBeenCalledWith({222 command: 'test-cmd',223 args: ['--foo'],224 });225 expect(mockClient.connect).toHaveBeenCalledWith(mockStdioTransport);226 expect(ideClient.getConnectionStatus().status).toBe(227 IDEConnectionStatus.Connected,228 );229 delete process.env['QWEN_CODE_IDE_SERVER_PORT'];230 });231 232 it('should prioritize port over stdio when both are in config file', async () => {233 process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';234 const config = {235 port: '8080',236 stdio: { command: 'test-cmd', args: ['--foo'] },237 };238 vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));239 240 const ideClient = await IdeClient.getInstance();241 await ideClient.connect();242 243 expect(StreamableHTTPClientTransport).toHaveBeenCalled();244 expect(StdioClientTransport).not.toHaveBeenCalled();245 expect(ideClient.getConnectionStatus().status).toBe(246 IDEConnectionStatus.Connected,247 );248 delete process.env['QWEN_CODE_IDE_SERVER_PORT'];249 });250 251 it('should connect using HTTP when port is provided in environment variables', async () => {252 vi.mocked(fs.promises.readFile).mockRejectedValue(253 new Error('File not found'),254 );255 (256 vi.mocked(fs.promises.readdir) as Mock<257 (path: fs.PathLike) => Promise<string[]>258 >259 ).mockResolvedValue([]);260 process.env['QWEN_CODE_IDE_SERVER_PORT'] = '9090';261 262 const ideClient = await IdeClient.getInstance();263 await ideClient.connect();264 265 expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(266 new URL('http://127.0.0.1:9090/mcp'),267 expect.any(Object),268 );269 expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);270 expect(ideClient.getConnectionStatus().status).toBe(271 IDEConnectionStatus.Connected,272 );273 });274 275 it('should fall back to host.docker.internal when localhost fails in container', async () => {276 process.env['QWEN_CODE_IDE_SERVER_PORT'] = '9090';277 vi.mocked(fs.promises.readFile).mockRejectedValue(278 new Error('File not found'),279 );280 (281 vi.mocked(fs.promises.readdir) as Mock<282 (path: fs.PathLike) => Promise<string[]>283 >284 ).mockResolvedValue([]);285 vi.mocked(fs.existsSync).mockImplementation(286 (filePath: fs.PathLike) => filePath === '/.dockerenv',287 );288 (dns.lookup as unknown as Mock).mockImplementation(289 (290 _hostname: string,291 callback: (292 err: Error | null,293 address?: string,294 family?: number,295 ) => void,296 ) => {297 callback(null, '192.168.65.254', 4);298 },299 );300 mockClient.connect301 .mockRejectedValueOnce(new Error('localhost unreachable'))302 .mockResolvedValueOnce(undefined);303 304 const ideClient = await IdeClient.getInstance();305 await ideClient.connect();306 307 // Localhost is always tried first.308 expect(StreamableHTTPClientTransport).toHaveBeenNthCalledWith(309 1,310 new URL('http://127.0.0.1:9090/mcp'),311 expect.any(Object),312 );313 // In a container, host.docker.internal is used as fallback.314 expect(StreamableHTTPClientTransport).toHaveBeenNthCalledWith(315 2,316 new URL('http://host.docker.internal:9090/mcp'),317 expect.any(Object),318 );319 expect(ideClient.getConnectionStatus().status).toBe(320 IDEConnectionStatus.Connected,321 );322 323 delete process.env['QWEN_CODE_IDE_SERVER_PORT'];324 });325 326 it('should try a newer lock-file port when the configured port is stale', async () => {327 process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1111';328 const primaryConfig = {329 port: '1111',330 authToken: 'stale-token',331 workspacePath: '/test/workspace',332 };333 const fallbackConfig = {334 port: '2222',335 authToken: 'fresh-token',336 workspacePath: '/test/workspace',337 };338 vi.mocked(fs.promises.readFile).mockImplementation(339 async (filePath: fs.PathLike | FileHandle) => {340 const file = String(filePath);341 if (file === path.join('/home/test', '.qwen', 'ide', '1111.lock')) {342 return JSON.stringify(primaryConfig);343 }344 if (file === path.join('/home/test', '.qwen', 'ide', '2222.lock')) {345 return JSON.stringify(fallbackConfig);346 }347 throw new Error(`unexpected path: ${file}`);348 },349 );350 (351 vi.mocked(fs.promises.readdir) as Mock<352 (path: fs.PathLike) => Promise<string[]>353 >354 ).mockResolvedValue(['1111.lock', '2222.lock']);355 (356 vi.mocked(fs.promises.stat) as Mock<357 (path: fs.PathLike) => Promise<fs.Stats>358 >359 ).mockImplementation(async (filePath: fs.PathLike) => {360 const now = Date.now();361 const file = String(filePath);362 return {363 mtimeMs: file.endsWith('2222.lock') ? now : now - 1000,364 } as fs.Stats;365 });366 vi.mocked(fs.existsSync).mockImplementation(367 (filePath: fs.PathLike) => String(filePath) === '/test/workspace',368 );369 mockClient.request.mockResolvedValue({ tools: [] });370 mockClient.connect371 .mockRejectedValueOnce(new Error('stale port'))372 .mockResolvedValueOnce(undefined);373 374 const ideClient = await IdeClient.getInstance();375 await ideClient.connect();376 377 expect(StreamableHTTPClientTransport).toHaveBeenNthCalledWith(378 1,379 new URL('http://127.0.0.1:1111/mcp'),380 expect.objectContaining({381 requestInit: {382 headers: {383 Authorization: 'Bearer stale-token',384 },385 },386 }),387 );388 expect(StreamableHTTPClientTransport).toHaveBeenNthCalledWith(389 2,390 new URL('http://127.0.0.1:2222/mcp'),391 expect.objectContaining({392 requestInit: {393 headers: {394 Authorization: 'Bearer fresh-token',395 },396 },397 }),398 );399 expect(ideClient.getConnectionStatus().status).toBe(400 IDEConnectionStatus.Connected,401 );402 delete process.env['QWEN_CODE_IDE_SERVER_PORT'];403 });404 405 it('should not retry raw env port when its lock belongs to another workspace', async () => {406 process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234';407 const envConfig = {408 port: '1234',409 workspacePath: '/other/workspace',410 };411 vi.mocked(fs.promises.readFile).mockImplementation(412 async (filePath: fs.PathLike | FileHandle) => {413 const file = String(filePath);414 if (file === path.join('/home/test', '.qwen', 'ide', '1234.lock')) {415 return JSON.stringify(envConfig);416 }417 if (file === path.join('/tmp', 'qwen-code-ide-server-12345.json')) {418 throw new Error('not found');419 }420 if (file === path.join('/tmp', 'qwen-code-ide-server-1234.json')) {421 throw new Error('not found');422 }423 throw new Error(`unexpected path: ${file}`);424 },425 );426 (427 vi.mocked(fs.promises.readdir) as Mock<428 (path: fs.PathLike) => Promise<string[]>429 >430 ).mockResolvedValue([]);431 432 const ideClient = await IdeClient.getInstance();433 await ideClient.connect();434 435 expect(StreamableHTTPClientTransport).not.toHaveBeenCalled();436 expect(mockClient.connect).not.toHaveBeenCalled();437 expect(ideClient.getConnectionStatus().status).toBe(438 IDEConnectionStatus.Disconnected,439 );440 expect(ideClient.getConnectionStatus().details).toContain(441 'workspace does not match',442 );443 delete process.env['QWEN_CODE_IDE_SERVER_PORT'];444 });445 446 it('should skip explicit workspace mismatches when trying fallback ports', async () => {447 const primaryConfig = {448 port: '1111',449 workspacePath: '/test/workspace',450 ppid: 12345,451 };452 const otherWorkspaceConfig = {453 port: '2222',454 workspacePath: '/other/workspace',455 };456 const legacyConfig = {457 port: '3333',458 };459 vi.mocked(fs.promises.readFile).mockImplementation(460 async (filePath: fs.PathLike | FileHandle) => {461 const file = String(filePath);462 if (file === path.join('/tmp', 'qwen-code-ide-server-12345.json')) {463 return JSON.stringify(primaryConfig);464 }465 if (file === path.join('/home/test', '.qwen', 'ide', '2222.lock')) {466 return JSON.stringify(otherWorkspaceConfig);467 }468 if (file === path.join('/home/test', '.qwen', 'ide', '3333.lock')) {469 return JSON.stringify(legacyConfig);470 }471 throw new Error(`unexpected path: ${file}`);472 },473 );474 (475 vi.mocked(fs.promises.readdir) as Mock<476 (path: fs.PathLike) => Promise<string[]>477 >478 ).mockResolvedValue(['2222.lock', '3333.lock']);479 (480 vi.mocked(fs.promises.stat) as Mock<481 (path: fs.PathLike) => Promise<fs.Stats>482 >483 ).mockImplementation(async (filePath: fs.PathLike) => {484 const now = Date.now();485 const file = String(filePath);486 return {487 mtimeMs: file.endsWith('2222.lock') ? now : now - 1000,488 } as fs.Stats;489 });490 mockClient.request.mockResolvedValue({ tools: [] });491 mockClient.connect492 .mockRejectedValueOnce(new Error('primary port failed'))493 .mockResolvedValueOnce(undefined);494 495 const ideClient = await IdeClient.getInstance();496 await ideClient.connect();497 498 expect(StreamableHTTPClientTransport).toHaveBeenNthCalledWith(499 1,500 new URL('http://127.0.0.1:1111/mcp'),501 expect.any(Object),502 );503 expect(StreamableHTTPClientTransport).toHaveBeenNthCalledWith(504 2,505 new URL('http://127.0.0.1:3333/mcp'),506 expect.any(Object),507 );508 expect(StreamableHTTPClientTransport).not.toHaveBeenCalledWith(509 new URL('http://127.0.0.1:2222/mcp'),510 expect.any(Object),511 );512 expect(ideClient.getConnectionStatus().status).toBe(513 IDEConnectionStatus.Connected,514 );515 });516 517 it('should connect using stdio when stdio config is in environment variables', async () => {518 vi.mocked(fs.promises.readFile).mockRejectedValue(519 new Error('File not found'),520 );521 522 (523 vi.mocked(fs.promises.readdir) as Mock<524 (path: fs.PathLike) => Promise<string[]>525 >526 ).mockResolvedValue([]);527 process.env['QWEN_CODE_IDE_SERVER_STDIO_COMMAND'] = 'env-cmd';528 process.env['QWEN_CODE_IDE_SERVER_STDIO_ARGS'] = '["--bar"]';529 530 const ideClient = await IdeClient.getInstance();531 await ideClient.connect();532 533 expect(StdioClientTransport).toHaveBeenCalledWith({534 command: 'env-cmd',535 args: ['--bar'],536 });537 expect(mockClient.connect).toHaveBeenCalledWith(mockStdioTransport);538 expect(ideClient.getConnectionStatus().status).toBe(539 IDEConnectionStatus.Connected,540 );541 });542 543 it('should prioritize file config over environment variables', async () => {544 const config = { port: '8080' };545 vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));546 (547 vi.mocked(fs.promises.readdir) as Mock<548 (path: fs.PathLike) => Promise<string[]>549 >550 ).mockResolvedValue([]);551 process.env['QWEN_CODE_IDE_SERVER_PORT'] = '9090';552 553 const ideClient = await IdeClient.getInstance();554 await ideClient.connect();555 556 expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(557 new URL('http://127.0.0.1:8080/mcp'),558 expect.any(Object),559 );560 expect(ideClient.getConnectionStatus().status).toBe(561 IDEConnectionStatus.Connected,562 );563 });564 565 it('should be disconnected if no config is found', async () => {566 vi.mocked(fs.promises.readFile).mockRejectedValue(567 new Error('File not found'),568 );569 (570 vi.mocked(fs.promises.readdir) as Mock<571 (path: fs.PathLike) => Promise<string[]>572 >573 ).mockResolvedValue([]);574 575 const ideClient = await IdeClient.getInstance();576 await ideClient.connect();577 578 expect(StreamableHTTPClientTransport).not.toHaveBeenCalled();579 expect(StdioClientTransport).not.toHaveBeenCalled();580 expect(ideClient.getConnectionStatus().status).toBe(581 IDEConnectionStatus.Disconnected,582 );583 expect(ideClient.getConnectionStatus().details).toContain(584 'Failed to connect',585 );586 });587 588 it('should report workspace mismatch when discovered locks belong to another workspace', async () => {589 vi.mocked(fs.promises.readFile).mockImplementation(590 async (filePath: fs.PathLike | FileHandle) => {591 const file = String(filePath);592 if (file === path.join('/tmp', 'qwen-code-ide-server-12345.json')) {593 throw new Error('not found');594 }595 if (file === path.join('/home/test', '.qwen', 'ide', '2222.lock')) {596 return JSON.stringify({597 port: '2222',598 workspacePath: '/other/workspace',599 });600 }601 throw new Error(`unexpected path: ${file}`);602 },603 );604 (605 vi.mocked(fs.promises.readdir) as Mock<606 (path: fs.PathLike) => Promise<string[]>607 >608 ).mockResolvedValue(['2222.lock']);609 (610 vi.mocked(fs.promises.stat) as Mock<611 (path: fs.PathLike) => Promise<fs.Stats>612 >613 ).mockResolvedValue({ mtimeMs: Date.now() } as fs.Stats);614 615 const ideClient = await IdeClient.getInstance();616 await ideClient.connect();617 618 expect(StreamableHTTPClientTransport).not.toHaveBeenCalled();619 expect(ideClient.getConnectionStatus().status).toBe(620 IDEConnectionStatus.Disconnected,621 );622 expect(ideClient.getConnectionStatus().details).toContain(623 'workspace does not match',624 );625 });626 });627 628 describe('validateWorkspacePath', () => {629 it('accepts JSON encoded multi-root workspace paths', () => {630 const result = IdeClient.validateWorkspacePath(631 JSON.stringify(['/test/other', '/test/workspace']),632 '/test/workspace/sub-dir',633 );634 635 expect(result.isValid).toBe(true);636 });637 638 it('ignores relative workspace entries in IDE env parsing', () => {639 const result = IdeClient.validateWorkspacePath(640 JSON.stringify(['relative/path', '/test/workspace']),641 '/test/workspace/sub-dir',642 );643 644 expect(result.isValid).toBe(true);645 });646 647 it('keeps delimiter encoded workspace paths working', () => {648 const result = IdeClient.validateWorkspacePath(649 ['/test/other', '/test/workspace'].join(path.delimiter),650 '/test/workspace/sub-dir',651 );652 653 expect(result.isValid).toBe(true);654 });655 });656 657 describe('getPortFromEnv', () => {658 const invalidPorts = [659 undefined,660 '',661 '0',662 '65536',663 '99999',664 '../evil',665 '12345/../../etc',666 'abc',667 ' 8080 ',668 '8080.0',669 ];670 671 it.each(['1', '12345', '65535'])(672 'should return valid env port %s',673 async (port) => {674 process.env['QWEN_CODE_IDE_SERVER_PORT'] = port;675 676 const ideClient = await IdeClient.getInstance();677 const result = (678 ideClient as unknown as {679 getPortFromEnv: () => string | undefined;680 }681 ).getPortFromEnv();682 683 expect(result).toBe(port);684 },685 );686 687 it.each(invalidPorts)('should ignore invalid env port %s', async (port) => {688 if (port === undefined) {689 delete process.env['QWEN_CODE_IDE_SERVER_PORT'];690 } else {691 process.env['QWEN_CODE_IDE_SERVER_PORT'] = port;692 }693 694 const ideClient = await IdeClient.getInstance();695 const result = (696 ideClient as unknown as {697 getPortFromEnv: () => string | undefined;698 }699 ).getPortFromEnv();700 701 expect(result).toBeUndefined();702 });703 });704 705 describe('getConnectionConfigFromFile', () => {706 it('should return config from the env port lock file if it exists', async () => {707 process.env['QWEN_CODE_IDE_SERVER_PORT'] = '12345';708 const config = { port: '12345', workspacePath: '/test/workspace' };709 vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));710 711 const ideClient = await IdeClient.getInstance();712 // In tests, the private method can be accessed like this.713 const result = await (714 ideClient as unknown as {715 getConnectionConfigFromFile: () => Promise<unknown>;716 }717 ).getConnectionConfigFromFile();718 719 expect(result).toEqual(config);720 expect(fs.promises.readFile).toHaveBeenCalledWith(721 path.join('/home/test', '.qwen', 'ide', '12345.lock'),722 'utf8',723 );724 delete process.env['QWEN_CODE_IDE_SERVER_PORT'];725 });726 727 it('should not scan the lock directory when the env port lock file exists', async () => {728 process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234';729 const config = { port: '1234', workspacePath: '/test/workspace' };730 vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));731 732 const ideClient = await IdeClient.getInstance();733 vi.mocked(fs.promises.readdir).mockClear();734 const result = await (735 ideClient as unknown as {736 getConnectionConfigFromFile: () => Promise<unknown>;737 }738 ).getConnectionConfigFromFile();739 740 expect(result).toEqual(config);741 expect(fs.promises.readdir).not.toHaveBeenCalled();742 delete process.env['QWEN_CODE_IDE_SERVER_PORT'];743 });744 745 it('should fall back to scanned locks when the env port lock belongs to another workspace', async () => {746 process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234';747 const envConfig = {748 port: '1234',749 workspacePath: '/other/workspace',750 };751 const matchingConfig = {752 port: '5678',753 workspacePath: '/test/workspace',754 };755 vi.mocked(fs.promises.readFile).mockImplementation(756 async (filePath: fs.PathLike | FileHandle) => {757 const file = String(filePath);758 if (file === path.join('/home/test', '.qwen', 'ide', '1234.lock')) {759 return JSON.stringify(envConfig);760 }761 if (file === path.join('/tmp', 'qwen-code-ide-server-12345.json')) {762 throw new Error('not found');763 }764 if (file === path.join('/tmp', 'qwen-code-ide-server-1234.json')) {765 throw new Error('not found');766 }767 if (file === path.join('/home/test', '.qwen', 'ide', '5678.lock')) {768 return JSON.stringify(matchingConfig);769 }770 throw new Error(`unexpected path: ${file}`);771 },772 );773 (774 vi.mocked(fs.promises.readdir) as Mock<775 (path: fs.PathLike) => Promise<string[]>776 >777 ).mockResolvedValue(['1234.lock', '5678.lock']);778 (779 vi.mocked(fs.promises.stat) as Mock<780 (path: fs.PathLike) => Promise<fs.Stats>781 >782 ).mockImplementation(async (filePath: fs.PathLike) => {783 const now = Date.now();784 const file = String(filePath);785 return {786 mtimeMs: file.endsWith('1234.lock') ? now : now - 1000,787 } as fs.Stats;788 });789 790 const ideClient = await IdeClient.getInstance();791 const result = await (792 ideClient as unknown as {793 getConnectionConfigFromFile: () => Promise<unknown>;794 }795 ).getConnectionConfigFromFile();796 797 expect(result).toEqual(matchingConfig);798 expect(fs.promises.readFile).toHaveBeenCalledWith(799 path.join('/home/test', '.qwen', 'ide', '1234.lock'),800 'utf8',801 );802 expect(fs.promises.readdir).toHaveBeenCalledWith(803 path.join('/home/test', '.qwen', 'ide'),804 );805 expect(mockDebugLogger.debug).toHaveBeenCalledWith(806 'Ignoring IDE env lock file: workspace "/other/workspace" does not match cwd "/test/workspace/sub-dir".',807 );808 delete process.env['QWEN_CODE_IDE_SERVER_PORT'];809 });810 811 it('should accept env lock config when workspacePath is undefined', async () => {812 process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234';813 const config = { port: '1234' };814 vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));815 vi.mocked(fs.promises.readdir).mockClear();816 817 const ideClient = await IdeClient.getInstance();818 const result = await (819 ideClient as unknown as {820 getConnectionConfigFromFile: () => Promise<unknown>;821 }822 ).getConnectionConfigFromFile();823 824 expect(result).toEqual(config);825 expect(fs.promises.readdir).not.toHaveBeenCalled();826 delete process.env['QWEN_CODE_IDE_SERVER_PORT'];827 });828 829 it('should return legacy config when workspacePath is undefined', async () => {830 const config = { port: '1111', ppid: 12345 };831 vi.mocked(fs.promises.readFile).mockResolvedValueOnce(832 JSON.stringify(config),833 );834 vi.mocked(fs.promises.readdir).mockClear();835 836 const ideClient = await IdeClient.getInstance();837 const result = await (838 ideClient as unknown as {839 getConnectionConfigFromFile: () => Promise<unknown>;840 }841 ).getConnectionConfigFromFile();842 843 expect(result).toEqual(config);844 expect(fs.promises.readFile).toHaveBeenCalledWith(845 path.join('/tmp', 'qwen-code-ide-server-12345.json'),846 'utf8',847 );848 expect(fs.promises.readdir).not.toHaveBeenCalled();849 });850 851 it('should return legacy config when env lock belongs to another workspace', async () => {852 process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234';853 const envConfig = {854 port: '1234',855 workspacePath: '/other/workspace',856 };857 const legacyConfig = {858 port: '1111',859 workspacePath: '/test/workspace',860 ppid: 12345,861 };862 vi.mocked(fs.promises.readFile).mockImplementation(863 async (filePath: fs.PathLike | FileHandle) => {864 const file = String(filePath);865 if (file === path.join('/home/test', '.qwen', 'ide', '1234.lock')) {866 return JSON.stringify(envConfig);867 }868 if (file === path.join('/tmp', 'qwen-code-ide-server-12345.json')) {869 return JSON.stringify(legacyConfig);870 }871 throw new Error(`unexpected path: ${file}`);872 },873 );874 vi.mocked(fs.promises.readdir).mockClear();875 876 const ideClient = await IdeClient.getInstance();877 const result = await (878 ideClient as unknown as {879 getConnectionConfigFromFile: () => Promise<unknown>;880 }881 ).getConnectionConfigFromFile();882 883 expect(result).toEqual(legacyConfig);884 expect(fs.promises.readdir).not.toHaveBeenCalled();885 expect(mockDebugLogger.debug).toHaveBeenCalledWith(886 'Ignoring IDE env lock file: workspace "/other/workspace" does not match cwd "/test/workspace/sub-dir".',887 );888 delete process.env['QWEN_CODE_IDE_SERVER_PORT'];889 });890 891 it('should reject env-port legacy config from another workspace', async () => {892 process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234';893 const legacyConfig = {894 port: '9999',895 workspacePath: '/other/workspace',896 };897 vi.mocked(fs.promises.readFile).mockImplementation(898 async (filePath: fs.PathLike | FileHandle) => {899 const file = String(filePath);900 if (file === path.join('/home/test', '.qwen', 'ide', '1234.lock')) {901 throw new Error('not found');902 }903 if (file === path.join('/tmp', 'qwen-code-ide-server-12345.json')) {904 throw new Error('not found');905 }906 if (file === path.join('/tmp', 'qwen-code-ide-server-1234.json')) {907 return JSON.stringify(legacyConfig);908 }909 throw new Error(`unexpected path: ${file}`);910 },911 );912 (913 vi.mocked(fs.promises.readdir) as Mock<914 (path: fs.PathLike) => Promise<string[]>915 >916 ).mockResolvedValue([]);917 918 const ideClient = await IdeClient.getInstance();919 const result = await (920 ideClient as unknown as {921 getConnectionConfigFromFile: () => Promise<unknown>;922 }923 ).getConnectionConfigFromFile();924 const rejectedPorts = (925 ideClient as unknown as {926 workspaceRejectedPorts: Set<string>;927 }928 ).workspaceRejectedPorts;929 930 expect(result).toBeUndefined();931 expect(rejectedPorts.has('9999')).toBe(true);932 expect(rejectedPorts.has('1234')).toBe(true);933 expect(fs.promises.readdir).toHaveBeenCalledWith(934 path.join('/home/test', '.qwen', 'ide'),935 );936 expect(mockDebugLogger.debug).toHaveBeenCalledWith(937 'Ignoring legacy IDE connection config: workspace "/other/workspace" does not match cwd "/test/workspace/sub-dir".',938 );939 delete process.env['QWEN_CODE_IDE_SERVER_PORT'];940 });941 942 it.each(['../evil', '12345/../../etc', 'abc', ' 8080 ', '8080.0'])(943 'should scan the lock directory when env port is invalid: %s',944 async (port) => {945 process.env['QWEN_CODE_IDE_SERVER_PORT'] = port;946 const ideDir = path.join('/home/test', '.qwen', 'ide');947 const config = { port: '2345', workspacePath: '/test/workspace' };948 vi.mocked(fs.promises.readFile).mockImplementation(949 async (filePath: fs.PathLike | FileHandle) => {950 const file = String(filePath);951 if (file === path.join('/tmp', 'qwen-code-ide-server-12345.json')) {952 throw new Error('not found');953 }954 if (file === path.join(ideDir, '2345.lock')) {955 return JSON.stringify(config);956 }957 throw new Error(`unexpected path: ${file}`);958 },959 );960 (961 vi.mocked(fs.promises.readdir) as Mock<962 (path: fs.PathLike) => Promise<string[]>963 >964 ).mockResolvedValue(['2345.lock']);965 (966 vi.mocked(fs.promises.stat) as Mock<967 (path: fs.PathLike) => Promise<fs.Stats>968 >969 ).mockResolvedValue({ mtimeMs: Date.now() } as fs.Stats);970 vi.mocked(fs.promises.readFile).mockClear();971 972 const ideClient = await IdeClient.getInstance();973 const result = await (974 ideClient as unknown as {975 getConnectionConfigFromFile: () => Promise<unknown>;976 }977 ).getConnectionConfigFromFile();978 979 expect(result).toEqual(config);980 expect(fs.promises.readFile).not.toHaveBeenCalledWith(981 path.join(ideDir, `${port}.lock`),982 'utf8',983 );984 expect(fs.promises.readFile).not.toHaveBeenCalledWith(985 path.join('/tmp', `qwen-code-ide-server-${port}.json`),986 'utf8',987 );988 expect(fs.promises.readdir).toHaveBeenCalledWith(ideDir);989 },990 );991 992 it('should return undefined if no config files are found', async () => {993 vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found'));994 995 const ideClient = await IdeClient.getInstance();996 const result = await (997 ideClient as unknown as {998 getConnectionConfigFromFile: () => Promise<unknown>;999 }1000 ).getConnectionConfigFromFile();1001 1002 expect(result).toBeUndefined();1003 });1004 1005 it('should read legacy pid config when available', async () => {1006 const config = {1007 port: '5678',1008 workspacePath: '/test/workspace',1009 ppid: 12345,1010 };1011 vi.mocked(fs.promises.readFile).mockResolvedValueOnce(1012 JSON.stringify(config),1013 );1014 1015 const ideClient = await IdeClient.getInstance();1016 const result = await (1017 ideClient as unknown as {1018 getConnectionConfigFromFile: () => Promise<unknown>;1019 }1020 ).getConnectionConfigFromFile();1021 1022 expect(result).toEqual(config);1023 expect(fs.promises.readFile).toHaveBeenCalledWith(1024 path.join('/tmp', 'qwen-code-ide-server-12345.json'),1025 'utf8',1026 );1027 });1028 1029 it('should fall back to scanned locks when the legacy config belongs to another workspace', async () => {1030 const legacyConfig = {1031 port: '1111',1032 workspacePath: '/other/workspace',1033 ppid: 12345,1034 };1035 const matchingConfig = {1036 port: '5678',1037 workspacePath: '/test/workspace',1038 };1039 vi.mocked(fs.promises.readFile).mockImplementation(1040 async (filePath: fs.PathLike | FileHandle) => {1041 const file = String(filePath);1042 if (file === path.join('/tmp', 'qwen-code-ide-server-12345.json')) {1043 return JSON.stringify(legacyConfig);1044 }1045 if (file === path.join('/home/test', '.qwen', 'ide', '5678.lock')) {1046 return JSON.stringify(matchingConfig);1047 }1048 throw new Error(`unexpected path: ${file}`);1049 },1050 );1051 (1052 vi.mocked(fs.promises.readdir) as Mock<1053 (path: fs.PathLike) => Promise<string[]>1054 >1055 ).mockResolvedValue(['5678.lock']);1056 (1057 vi.mocked(fs.promises.stat) as Mock<1058 (path: fs.PathLike) => Promise<fs.Stats>1059 >1060 ).mockResolvedValue({1061 mtimeMs: Date.now(),1062 } as fs.Stats);1063 1064 const ideClient = await IdeClient.getInstance();1065 const result = await (1066 ideClient as unknown as {1067 getConnectionConfigFromFile: () => Promise<unknown>;1068 }1069 ).getConnectionConfigFromFile();1070 1071 expect(result).toEqual(matchingConfig);1072 expect(fs.promises.readdir).toHaveBeenCalledWith(1073 path.join('/home/test', '.qwen', 'ide'),1074 );1075 expect(mockDebugLogger.debug).toHaveBeenCalledWith(1076 'Ignoring legacy IDE connection config: workspace "/other/workspace" does not match cwd "/test/workspace/sub-dir".',1077 );1078 });1079 1080 it('should fall back to legacy port file when pid file is missing', async () => {1081 process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222';1082 const config2 = { port: '2222', workspacePath: '/test/workspace' };1083 vi.mocked(fs.promises.readFile)1084 .mockRejectedValueOnce(new Error('not found')) // ~/.qwen/ide/<port>.lock1085 .mockRejectedValueOnce(new Error('not found')) // legacy pid file1086 .mockResolvedValueOnce(JSON.stringify(config2));1087 1088 const ideClient = await IdeClient.getInstance();1089 const result = await (1090 ideClient as unknown as {1091 getConnectionConfigFromFile: () => Promise<unknown>;1092 }1093 ).getConnectionConfigFromFile();1094 1095 expect(result).toEqual(config2);1096 expect(fs.promises.readFile).toHaveBeenCalledWith(1097 path.join('/tmp', 'qwen-code-ide-server-12345.json'),1098 'utf8',1099 );1100 expect(fs.promises.readFile).toHaveBeenCalledWith(1101 path.join('/tmp', 'qwen-code-ide-server-2222.json'),1102 'utf8',1103 );1104 delete process.env['QWEN_CODE_IDE_SERVER_PORT'];1105 });1106 1107 it('should fall back to legacy config when env lock file has invalid JSON', async () => {1108 process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';1109 const config = { port: '1111', workspacePath: '/test/workspace' };1110 vi.mocked(fs.promises.readFile)1111 .mockResolvedValueOnce('invalid json')1112 .mockResolvedValueOnce(JSON.stringify(config));1113 1114 const ideClient = await IdeClient.getInstance();1115 const result = await (1116 ideClient as unknown as {1117 getConnectionConfigFromFile: () => Promise<unknown>;1118 }1119 ).getConnectionConfigFromFile();1120 1121 expect(result).toEqual(config);1122 delete process.env['QWEN_CODE_IDE_SERVER_PORT'];1123 });1124 1125 it('should keep a live lock file even when it is older than 7 days', async () => {1126 const liveConfig = {1127 port: '1000',1128 workspacePath: '/test/workspace',1129 ppid: 4242,1130 };1131 const oldTime = Date.now() - 8 * 24 * 60 * 60 * 1000;1132 1133 vi.mocked(fs.promises.readFile).mockImplementation(1134 async (filePath: fs.PathLike | FileHandle) => {1135 const file = String(filePath);1136 if (file === path.join('/tmp', 'qwen-code-ide-server-12345.json')) {1137 throw new Error('not found');1138 }1139 if (file === path.join('/home/test', '.qwen', 'ide', '1000.lock')) {1140 return JSON.stringify(liveConfig);1141 }1142 throw new Error(`unexpected path: ${file}`);1143 },1144 );1145 (1146 vi.mocked(fs.promises.readdir) as Mock<1147 (path: fs.PathLike) => Promise<string[]>1148 >1149 ).mockResolvedValue(['1000.lock']);1150 (1151 vi.mocked(fs.promises.stat) as Mock<1152 (path: fs.PathLike) => Promise<fs.Stats>1153 >1154 ).mockResolvedValue({ mtimeMs: oldTime } as fs.Stats);1155 vi.spyOn(process, 'kill').mockImplementation(() => true);1156 1157 const ideClient = await IdeClient.getInstance();1158 const result = await (1159 ideClient as unknown as {1160 getConnectionConfigFromFile: () => Promise<unknown>;1161 }1162 ).getConnectionConfigFromFile();1163 1164 expect(result).toEqual(liveConfig);1165 expect(fs.promises.unlink).not.toHaveBeenCalled();1166 });1167 1168 it('should keep incomplete old lock files when there is no stronger stale signal', async () => {1169 const latestConfig = {1170 port: '2000',1171 workspacePath: '/test/workspace',1172 };1173 const now = Date.now();1174 const staleTime = now - 7 * 24 * 60 * 60 * 1000 - 1000;1175 1176 vi.mocked(fs.promises.readFile).mockImplementation(1177 async (filePath: fs.PathLike | FileHandle) => {1178 const file = String(filePath);1179 if (file === path.join('/tmp', 'qwen-code-ide-server-12345.json')) {1180 throw new Error('not found');1181 }1182 if (file === path.join('/home/test', '.qwen', 'ide', '1000.lock')) {1183 return JSON.stringify({ port: '1000' });1184 }1185 if (file === path.join('/home/test', '.qwen', 'ide', '2000.lock')) {1186 return JSON.stringify(latestConfig);1187 }1188 throw new Error(`unexpected path: ${file}`);1189 },1190 );1191 (1192 vi.mocked(fs.promises.readdir) as Mock<1193 (path: fs.PathLike) => Promise<string[]>1194 >1195 ).mockResolvedValue(['1000.lock', '2000.lock']);1196 (1197 vi.mocked(fs.promises.stat) as Mock<1198 (path: fs.PathLike) => Promise<fs.Stats>1199 >1200 ).mockImplementation(async (filePath: fs.PathLike) => {