Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 4 additions & 2 deletions WebUI/electron/subprocesses/hardwareDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ export async function detectIntelGpusViaPowerShell(): Promise<GpuHardwareDevice[
}
}

function parseNvidiaSmiListOutput(output: string): Array<z.infer<typeof NvidiaSmiLineSchema>> {
export function parseNvidiaSmiListOutput(
output: string,
): Array<z.infer<typeof NvidiaSmiLineSchema>> {
const lines = output
.split('\n')
.map((l) => l.trim())
Expand Down Expand Up @@ -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[] {
Expand Down
71 changes: 71 additions & 0 deletions WebUI/electron/test/subprocesses/deviceDetection.test.ts
Original file line number Diff line number Diff line change
@@ -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' })
})
})
})
149 changes: 148 additions & 1 deletion WebUI/electron/test/subprocesses/hardwareDiscovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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([])
})
})
Loading
Loading