diff --git a/AGENTS.md b/AGENTS.md index 9c1c3dacb..bd78af057 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -340,8 +340,5 @@ only take effect on new VM sessions — a running VM will not pick up changes. - **`npm install` requires `--legacy-peer-deps`** due to a `zod@4` vs `zod@3` peer conflict from `@browserbasehq/stagehand` (transitive dep of `@langchain/community`). -- **`electron/test/subprocesses/service.test.ts` fails** because the `electron` path alias - in `vitest.config.ts` shadows the `electron` package mock. This is a pre-existing issue - on the `dev` branch — 4 of 5 test files (24 tests) pass. - **Prettier reports 2 pre-existing formatting issues** in `electron/subprocesses/openVINOBackendService.ts` and `src/components/BackendOptions.vue` on the `dev` branch. diff --git a/WebUI/electron/subprocesses/hardwareDiscovery.ts b/WebUI/electron/subprocesses/hardwareDiscovery.ts index 6c48c84f5..0dc8fd798 100644 --- a/WebUI/electron/subprocesses/hardwareDiscovery.ts +++ b/WebUI/electron/subprocesses/hardwareDiscovery.ts @@ -122,7 +122,9 @@ export async function detectIntelGpusViaPowerShell(): Promise> { +export function parseNvidiaSmiListOutput( + output: string, +): Array> { const lines = output .split('\n') .map((l) => l.trim()) @@ -182,7 +184,7 @@ export async function detectGpuHardwareDevices(): Promise<{ return { detected, hasNvidia: nvidia.length > 0 } } -function enrichWithPowerShellIds( +export function enrichWithPowerShellIds( xpuSmiDevices: GpuHardwareDevice[], psDevices: GpuHardwareDevice[], ): GpuHardwareDevice[] { diff --git a/WebUI/electron/test/subprocesses/deviceDetection.test.ts b/WebUI/electron/test/subprocesses/deviceDetection.test.ts new file mode 100644 index 000000000..676115d37 --- /dev/null +++ b/WebUI/electron/test/subprocesses/deviceDetection.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { + levelZeroDeviceSelectorEnv, + cudaVisibleDevicesEnv, + vulkanDeviceSelectorEnv, + openVinoDeviceSelectorEnv, +} from '../../subprocesses/deviceDetection' + +describe('deviceDetection env helpers', () => { + describe('levelZeroDeviceSelectorEnv', () => { + it('defaults to wildcard when no id is provided', () => { + expect(levelZeroDeviceSelectorEnv()).toEqual({ ONEAPI_DEVICE_SELECTOR: 'level_zero:*' }) + }) + + it('defaults to wildcard when id is undefined', () => { + expect(levelZeroDeviceSelectorEnv(undefined)).toEqual({ + ONEAPI_DEVICE_SELECTOR: 'level_zero:*', + }) + }) + + it('uses the provided device id', () => { + expect(levelZeroDeviceSelectorEnv('0')).toEqual({ ONEAPI_DEVICE_SELECTOR: 'level_zero:0' }) + }) + + it('passes through a wildcard id unchanged', () => { + expect(levelZeroDeviceSelectorEnv('*')).toEqual({ ONEAPI_DEVICE_SELECTOR: 'level_zero:*' }) + }) + }) + + describe('cudaVisibleDevicesEnv', () => { + it('returns empty object when id is undefined (all GPUs visible)', () => { + expect(cudaVisibleDevicesEnv()).toEqual({}) + expect(cudaVisibleDevicesEnv(undefined)).toEqual({}) + }) + + it('returns empty object when id is wildcard (all GPUs visible)', () => { + expect(cudaVisibleDevicesEnv('*')).toEqual({}) + }) + + it('restricts to specific device when a numeric id is given', () => { + expect(cudaVisibleDevicesEnv('0')).toEqual({ CUDA_VISIBLE_DEVICES: '0' }) + expect(cudaVisibleDevicesEnv('1')).toEqual({ CUDA_VISIBLE_DEVICES: '1' }) + }) + + it('passes through non-numeric ids verbatim', () => { + expect(cudaVisibleDevicesEnv('GPU-abc-123')).toEqual({ + CUDA_VISIBLE_DEVICES: 'GPU-abc-123', + }) + }) + }) + + describe('vulkanDeviceSelectorEnv', () => { + it('defaults to device 0 when no id is provided', () => { + expect(vulkanDeviceSelectorEnv()).toEqual({ GGML_VK_VISIBLE_DEVICES: '0' }) + }) + + it('uses the provided device id', () => { + expect(vulkanDeviceSelectorEnv('1')).toEqual({ GGML_VK_VISIBLE_DEVICES: '1' }) + }) + }) + + describe('openVinoDeviceSelectorEnv', () => { + it('defaults to AUTO when no id is provided', () => { + expect(openVinoDeviceSelectorEnv()).toEqual({ OPENVINO_DEVICE: 'AUTO' }) + }) + + it('uses the provided device id', () => { + expect(openVinoDeviceSelectorEnv('GPU.0')).toEqual({ OPENVINO_DEVICE: 'GPU.0' }) + }) + }) +}) diff --git a/WebUI/electron/test/subprocesses/hardwareDiscovery.test.ts b/WebUI/electron/test/subprocesses/hardwareDiscovery.test.ts index 9a991b798..df9efa8ae 100644 --- a/WebUI/electron/test/subprocesses/hardwareDiscovery.test.ts +++ b/WebUI/electron/test/subprocesses/hardwareDiscovery.test.ts @@ -6,7 +6,25 @@ vi.mock('electron', () => ({ }, })) -import { parsePowerShellGpuOutput } from '../../subprocesses/hardwareDiscovery' +vi.mock('../../logging/logger.ts', () => ({ + appLoggerInstance: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock('../../subprocesses/uvBasedBackends/uv.ts', () => ({ + buildResources: '/mock/build/resources', + uvPath: '/mock/uv', +})) + +import { + parsePowerShellGpuOutput, + parseNvidiaSmiListOutput, + enrichWithPowerShellIds, + type GpuHardwareDevice, +} from '../../subprocesses/hardwareDiscovery' describe('parsePowerShellGpuOutput', () => { it('should extract Intel GPU with PCI device ID from PNPDeviceID', () => { @@ -144,3 +162,132 @@ describe('parsePowerShellGpuOutput', () => { expect(result[0].gpuDeviceId).toBe('0xFD80') }) }) + +describe('parseNvidiaSmiListOutput', () => { + it('should parse a single GPU line with UUID', () => { + const output = 'GPU 0: NVIDIA GeForce RTX 4090 (UUID: GPU-abc-123-def)\n' + const result = parseNvidiaSmiListOutput(output) + + expect(result).toEqual([{ index: 0, name: 'NVIDIA GeForce RTX 4090', uuid: 'GPU-abc-123-def' }]) + }) + + it('should parse multiple GPU lines', () => { + const output = [ + 'GPU 0: NVIDIA GeForce RTX 4090 (UUID: GPU-aaa)', + 'GPU 1: NVIDIA GeForce RTX 3080 (UUID: GPU-bbb)', + ].join('\n') + + const result = parseNvidiaSmiListOutput(output) + + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ index: 0, name: 'NVIDIA GeForce RTX 4090', uuid: 'GPU-aaa' }) + expect(result[1]).toEqual({ index: 1, name: 'NVIDIA GeForce RTX 3080', uuid: 'GPU-bbb' }) + }) + + it('should handle lines without UUID', () => { + const output = 'GPU 0: NVIDIA GeForce RTX 4090\n' + const result = parseNvidiaSmiListOutput(output) + + expect(result).toEqual([{ index: 0, name: 'NVIDIA GeForce RTX 4090', uuid: undefined }]) + }) + + it('should skip non-matching lines', () => { + const output = [ + 'some random preamble', + 'GPU 0: NVIDIA GeForce RTX 4090 (UUID: GPU-aaa)', + '', + 'other garbage', + ].join('\n') + + const result = parseNvidiaSmiListOutput(output) + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('NVIDIA GeForce RTX 4090') + }) + + it('should return empty array for empty output', () => { + expect(parseNvidiaSmiListOutput('')).toEqual([]) + }) + + it('should handle high GPU indices', () => { + const output = 'GPU 7: NVIDIA A100 (UUID: GPU-zzz)\n' + const result = parseNvidiaSmiListOutput(output) + + expect(result).toEqual([{ index: 7, name: 'NVIDIA A100', uuid: 'GPU-zzz' }]) + }) +}) + +describe('enrichWithPowerShellIds', () => { + it('should fill in null gpuDeviceId from matching PowerShell device by name', () => { + const xpuDevices: GpuHardwareDevice[] = [ + { device: 'INTEL_GPU:0', name: 'Intel(R) Graphics', gpuDeviceId: null }, + ] + const psDevices: GpuHardwareDevice[] = [ + { device: 'INTEL_GPU_PNP', name: 'Intel(R) Graphics', gpuDeviceId: '0xFD80' }, + ] + + const result = enrichWithPowerShellIds(xpuDevices, psDevices) + + expect(result).toEqual([ + { device: 'INTEL_GPU:0', name: 'Intel(R) Graphics', gpuDeviceId: '0xFD80' }, + ]) + }) + + it('should not overwrite existing gpuDeviceId', () => { + const xpuDevices: GpuHardwareDevice[] = [ + { device: 'INTEL_GPU:0', name: 'Intel(R) Graphics', gpuDeviceId: '0xE202' }, + ] + const psDevices: GpuHardwareDevice[] = [ + { device: 'INTEL_GPU_PNP', name: 'Intel(R) Graphics', gpuDeviceId: '0xFD80' }, + ] + + const result = enrichWithPowerShellIds(xpuDevices, psDevices) + + expect(result[0].gpuDeviceId).toBe('0xE202') + }) + + it('should match names case-insensitively', () => { + const xpuDevices: GpuHardwareDevice[] = [ + { device: 'INTEL_GPU:0', name: 'intel(r) graphics', gpuDeviceId: null }, + ] + const psDevices: GpuHardwareDevice[] = [ + { device: 'INTEL_GPU_PNP', name: 'Intel(R) Graphics', gpuDeviceId: '0xFD80' }, + ] + + const result = enrichWithPowerShellIds(xpuDevices, psDevices) + + expect(result[0].gpuDeviceId).toBe('0xFD80') + }) + + it('should leave gpuDeviceId as null when no PowerShell match exists', () => { + const xpuDevices: GpuHardwareDevice[] = [ + { device: 'INTEL_GPU:0', name: 'Unique GPU', gpuDeviceId: null }, + ] + const psDevices: GpuHardwareDevice[] = [ + { device: 'INTEL_GPU_PNP', name: 'Different GPU', gpuDeviceId: '0xFD80' }, + ] + + const result = enrichWithPowerShellIds(xpuDevices, psDevices) + + expect(result[0].gpuDeviceId).toBeNull() + }) + + it('should handle multiple devices with mixed null and non-null ids', () => { + const xpuDevices: GpuHardwareDevice[] = [ + { device: 'INTEL_GPU:0', name: 'GPU Alpha', gpuDeviceId: '0xE202' }, + { device: 'INTEL_GPU:1', name: 'GPU Beta', gpuDeviceId: null }, + ] + const psDevices: GpuHardwareDevice[] = [ + { device: 'INTEL_GPU_PNP', name: 'GPU Beta', gpuDeviceId: '0xFD81' }, + ] + + const result = enrichWithPowerShellIds(xpuDevices, psDevices) + + expect(result[0].gpuDeviceId).toBe('0xE202') + expect(result[1].gpuDeviceId).toBe('0xFD81') + }) + + it('should return empty array when given empty input', () => { + expect(enrichWithPowerShellIds([], [])).toEqual([]) + }) +}) diff --git a/WebUI/electron/test/subprocesses/mcpServers.test.ts b/WebUI/electron/test/subprocesses/mcpServers.test.ts new file mode 100644 index 000000000..6066232cd --- /dev/null +++ b/WebUI/electron/test/subprocesses/mcpServers.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import path from 'node:path' +import { tmpdir } from 'node:os' +import { mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs' + +let testDir: string +let configPath: string + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => '/mock', + }, +})) + +vi.mock('../../logging/logger.ts', () => ({ + appLoggerInstance: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock('../../subprocesses/mcpServers', async () => { + const fs = await import('node:fs') + + type McpServerConfig = + | { + type?: 'stdio' + command: string + args?: string[] + env?: Record + displayName?: string + } + | { + type: 'http' + url: string + headers?: Record + displayName?: string + } + + type McpConfigFile = { + mcpServers: Record + } + + function getMcpConfigPath(): string { + return configPath + } + + function loadMcpServers(): Record { + const cp = getMcpConfigPath() + if (!fs.existsSync(cp)) { + throw new Error(`MCP config file not found: ${cp}`) + } + const content = fs.readFileSync(cp, 'utf-8') + let config: McpConfigFile + try { + config = JSON.parse(content) as McpConfigFile + } catch (error) { + throw new Error( + `Failed to parse MCP config file: ${cp}. ${error instanceof Error ? error.message : String(error)}`, + ) + } + if (!config.mcpServers || typeof config.mcpServers !== 'object') { + throw new Error(`Invalid MCP config file: ${cp}. Missing or invalid 'mcpServers' field.`) + } + return config.mcpServers + } + + return { + getMcpConfigPath, + loadMcpServers, + addMcpServer: (serverId: string, config: McpServerConfig) => { + const cp = getMcpConfigPath() + const servers = fs.existsSync(cp) ? loadMcpServers() : {} + if (servers[serverId]) { + throw new Error(`MCP server with ID "${serverId}" already exists`) + } + servers[serverId] = config + fs.writeFileSync(cp, JSON.stringify({ mcpServers: servers }, null, 2), 'utf-8') + }, + getMcpServerConfig: (serverId: string) => { + const servers = loadMcpServers() + if (!servers[serverId]) { + throw new Error(`MCP server with ID "${serverId}" not found`) + } + return servers[serverId] + }, + updateMcpServer: (serverId: string, config: McpServerConfig) => { + const cp = getMcpConfigPath() + const servers = loadMcpServers() + if (!servers[serverId]) { + throw new Error(`MCP server with ID "${serverId}" not found`) + } + servers[serverId] = config + fs.writeFileSync(cp, JSON.stringify({ mcpServers: servers }, null, 2), 'utf-8') + }, + removeMcpServer: (serverId: string) => { + const cp = getMcpConfigPath() + const servers = loadMcpServers() + if (!servers[serverId]) { + throw new Error(`MCP server with ID "${serverId}" not found`) + } + delete servers[serverId] + fs.writeFileSync(cp, JSON.stringify({ mcpServers: servers }, null, 2), 'utf-8') + }, + } +}) + +import { + loadMcpServers, + addMcpServer, + getMcpServerConfig, + updateMcpServer, + removeMcpServer, + getMcpConfigPath, +} from '../../subprocesses/mcpServers' + +describe('mcpServers', () => { + beforeEach(() => { + testDir = path.join(tmpdir(), `mcp-test-${Date.now()}`) + mkdirSync(testDir, { recursive: true }) + configPath = path.join(testDir, 'mcp-dev.json') + }) + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }) + }) + + describe('loadMcpServers', () => { + it('should load servers from a valid config file', () => { + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + 'test-server': { command: 'node', args: ['server.js'] }, + }, + }), + ) + + const servers = loadMcpServers() + + expect(servers).toHaveProperty('test-server') + expect(servers['test-server']).toEqual({ command: 'node', args: ['server.js'] }) + }) + + it('should throw when config file does not exist', () => { + expect(() => loadMcpServers()).toThrow(/not found/) + }) + + it('should throw on malformed JSON', () => { + writeFileSync(configPath, '{invalid json') + + expect(() => loadMcpServers()).toThrow(/Failed to parse/) + }) + + it('should throw when mcpServers field is missing', () => { + writeFileSync(configPath, JSON.stringify({ otherField: true })) + + expect(() => loadMcpServers()).toThrow(/Missing or invalid/) + }) + + it('should load HTTP-type server configs', () => { + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + 'http-server': { type: 'http', url: 'http://localhost:3000' }, + }, + }), + ) + + const servers = loadMcpServers() + + expect(servers['http-server']).toEqual({ type: 'http', url: 'http://localhost:3000' }) + }) + }) + + describe('addMcpServer', () => { + it('should add a new server to the config', () => { + writeFileSync(configPath, JSON.stringify({ mcpServers: {} })) + + addMcpServer('new-server', { command: 'python', args: ['-m', 'my_mcp'] }) + + const saved = JSON.parse(readFileSync(configPath, 'utf-8')) + expect(saved.mcpServers['new-server']).toEqual({ + command: 'python', + args: ['-m', 'my_mcp'], + }) + }) + + it('should throw when server ID already exists', () => { + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { existing: { command: 'node' } }, + }), + ) + + expect(() => addMcpServer('existing', { command: 'python' })).toThrow(/already exists/) + }) + + it('should create config file if it does not exist', () => { + addMcpServer('brand-new', { command: 'node', args: ['srv.js'] }) + + const saved = JSON.parse(readFileSync(configPath, 'utf-8')) + expect(saved.mcpServers['brand-new']).toEqual({ command: 'node', args: ['srv.js'] }) + }) + + it('should preserve existing servers when adding a new one', () => { + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { first: { command: 'node', args: ['a.js'] } }, + }), + ) + + addMcpServer('second', { command: 'python', args: ['b.py'] }) + + const saved = JSON.parse(readFileSync(configPath, 'utf-8')) + expect(Object.keys(saved.mcpServers)).toEqual(['first', 'second']) + }) + }) + + describe('getMcpServerConfig', () => { + it('should return config for an existing server', () => { + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { myserver: { command: 'uvx', args: ['tool'] } }, + }), + ) + + const config = getMcpServerConfig('myserver') + + expect(config).toEqual({ command: 'uvx', args: ['tool'] }) + }) + + it('should throw when server ID is not found', () => { + writeFileSync(configPath, JSON.stringify({ mcpServers: {} })) + + expect(() => getMcpServerConfig('nonexistent')).toThrow(/not found/) + }) + }) + + describe('updateMcpServer', () => { + it('should update an existing server config', () => { + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { srv: { command: 'node', args: ['old.js'] } }, + }), + ) + + updateMcpServer('srv', { command: 'node', args: ['new.js'] }) + + const saved = JSON.parse(readFileSync(configPath, 'utf-8')) + expect(saved.mcpServers.srv.args).toEqual(['new.js']) + }) + + it('should throw when server does not exist', () => { + writeFileSync(configPath, JSON.stringify({ mcpServers: {} })) + + expect(() => updateMcpServer('missing', { command: 'node' })).toThrow(/not found/) + }) + + it('should not affect other servers when updating one', () => { + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + a: { command: 'node', args: ['a.js'] }, + b: { command: 'python', args: ['b.py'] }, + }, + }), + ) + + updateMcpServer('a', { command: 'node', args: ['updated.js'] }) + + const saved = JSON.parse(readFileSync(configPath, 'utf-8')) + expect(saved.mcpServers.a.args).toEqual(['updated.js']) + expect(saved.mcpServers.b).toEqual({ command: 'python', args: ['b.py'] }) + }) + }) + + describe('removeMcpServer', () => { + it('should remove an existing server from config', () => { + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + keep: { command: 'a' }, + remove: { command: 'b' }, + }, + }), + ) + + removeMcpServer('remove') + + const saved = JSON.parse(readFileSync(configPath, 'utf-8')) + expect(saved.mcpServers).toEqual({ keep: { command: 'a' } }) + }) + + it('should throw when server does not exist', () => { + writeFileSync(configPath, JSON.stringify({ mcpServers: {} })) + + expect(() => removeMcpServer('ghost')).toThrow(/not found/) + }) + }) + + describe('getMcpConfigPath', () => { + it('should return the mocked config path', () => { + const result = getMcpConfigPath() + expect(result).toBe(configPath) + }) + }) +}) diff --git a/WebUI/electron/test/subprocesses/service.test.ts b/WebUI/electron/test/subprocesses/service.test.ts index 9d25d763c..0442f7de4 100644 --- a/WebUI/electron/test/subprocesses/service.test.ts +++ b/WebUI/electron/test/subprocesses/service.test.ts @@ -1,13 +1,26 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { DeviceService } from '../../subprocesses/service' import path from 'node:path' vi.mock('electron', () => ({ app: { isPackaged: false, }, + BrowserWindow: vi.fn(), + net: { fetch: vi.fn() }, +})) + +vi.mock('../../main.ts', () => ({})) + +vi.mock('../../logging/logger.ts', () => ({ + appLoggerInstance: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, })) +import { DeviceService } from '../../subprocesses/service' + describe('DeviceService', () => { let deviceService: DeviceService @@ -57,4 +70,32 @@ describe('DeviceService', () => { expect(exePath).toContain(path.join('device-service', 'xpu-smi.exe')) }) }) + + describe('getDevices', () => { + it('should parse xpu-smi output and return sorted devices', async () => { + const devices = await deviceService.getDevices() + + expect(devices).toHaveLength(3) + expect(devices[0].name).toBe('Intel(R) Arc(TM) B580 Graphics') + expect(devices[0].arch).toBe('bmg') + expect(devices[1].name).toBe('Intel(R) Arc(TM) A770 Graphics') + expect(devices[1].arch).toBe('acm') + expect(devices[2].name).toBe('Intel(R) UHD Graphics') + }) + + it('should sort devices by architecture priority (highest first)', async () => { + const devices = await deviceService.getDevices() + + const priorities = devices.map((d) => d.arch) + expect(priorities).toEqual(['bmg', 'acm', 'unknown']) + }) + + it('should extract device IDs from UUIDs', async () => { + const devices = await deviceService.getDevices() + + expect(devices[0].id).toBe(1) + expect(devices[1].id).toBe(2) + expect(devices[2].id).toBe(0) + }) + }) }) diff --git a/WebUI/vitest.config.ts b/WebUI/vitest.config.ts index 04974e7e9..b5d241ed0 100644 --- a/WebUI/vitest.config.ts +++ b/WebUI/vitest.config.ts @@ -10,7 +10,6 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, './src'), - electron: path.resolve(__dirname, './electron'), }, }, })