import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' const execFileCalls = vi.hoisted(() => [] as Array<{ command: string; args: string[]; options: any }>) const execResult = vi.hoisted(() => ({ error: null as any, delayMs: 0 })) const spawnCalls = vi.hoisted(() => [] as Array<{ command: string; args: string[]; options: any }>) vi.mock('child_process', () => ({ execFile: vi.fn((command: string, args: string[], options: any, callback: (error: Error | null, stdout: string, stderr: string) => void) => { execFileCalls.push({ command, args, options }) if (execResult.delayMs) setTimeout(() => callback(execResult.error, 'ok\n', ''), execResult.delayMs) else callback(execResult.error, 'ok\n', '') }), spawn: vi.fn((command: string, args: string[], options: any) => { spawnCalls.push({ command, args, options }) return {} as any }), })) const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { value: platform }) } afterEach(() => { execResult.error = null execResult.delayMs = 0 vi.useRealTimers() execFileCalls.length = 0 spawnCalls.length = 0 delete process.env.HERMES_AGENT_BRIDGE_PYTHON delete process.env.HERMES_AGENT_CLI_PYTHON if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform) vi.resetModules() }) describe('Hermes process invocation', () => { it('bypasses the uv hermes.exe trampoline on Windows packaged installs', async () => { setPlatform('win32') process.env.HERMES_AGENT_CLI_PYTHON = 'C:\\Users\\me\\AppData\\Local\\Programs\\Hermes Studio\\resources\\python\\python.exe' const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process') const result = await execHermesWithBin( 'C:\\Users\\me\\AppData\\Local\\Programs\\Hermes Studio\\resources\\python\\Scripts\\hermes.exe', ['kanban', '--board', 'default', 'create', 'demo', '--json'], { windowsHide: true }, ) expect(result.stdout).toBe('ok\n') expect(execFileCalls[0]).toMatchObject({ command: process.env.HERMES_AGENT_CLI_PYTHON, args: ['-m', 'hermes_cli.main', 'kanban', '--board', 'default', 'create', 'demo', '--json'], options: expect.objectContaining({ windowsHide: true }), }) }) it('discovers sibling python.exe for a Windows hermes.exe launcher', async () => { setPlatform('win32') const root = mkdtempSync(join(tmpdir(), 'hermes-process-')) try { const scripts = join(root, 'Scripts') mkdirSync(scripts) writeFileSync(join(root, 'python.exe'), '') writeFileSync(join(scripts, 'hermes.exe'), '') const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process') await execHermesWithBin(join(scripts, 'hermes.exe'), ['--version']) expect(execFileCalls[0]).toMatchObject({ command: join(root, 'python.exe'), args: ['-m', 'hermes_cli.main', '--version'], options: expect.objectContaining({ windowsHide: true }), }) } finally { rmSync(root, { recursive: true, force: true }) } }) it('discovers sibling python.exe for a Windows hermes.cmd launcher', async () => { setPlatform('win32') const root = mkdtempSync(join(tmpdir(), 'hermes-process-')) try { const scripts = join(root, 'Scripts') mkdirSync(scripts) writeFileSync(join(root, 'python.exe'), '') writeFileSync(join(scripts, 'hermes.cmd'), '') const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process') await execHermesWithBin(join(scripts, 'hermes.cmd'), ['--version']) expect(execFileCalls[0]).toMatchObject({ command: join(root, 'python.exe'), args: ['-m', 'hermes_cli.main', '--version'], options: expect.objectContaining({ windowsHide: true }), }) } finally { rmSync(root, { recursive: true, force: true }) } }) it('keeps normal Hermes command execution unchanged on non-Windows platforms', async () => { setPlatform('darwin') const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process') await execHermesWithBin('/opt/hermes/bin/hermes', ['--version'], { windowsHide: true }) expect(execFileCalls[0]).toMatchObject({ command: '/opt/hermes/bin/hermes', args: ['--version'], }) }) it('defaults spawned Windows Hermes processes to hidden windows', async () => { setPlatform('win32') process.env.HERMES_AGENT_CLI_PYTHON = 'C:\\Hermes Studio\\resources\\python\\python.exe' const { spawnHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process') spawnHermesWithBin('C:\\Hermes Studio\\resources\\python\\Scripts\\hermes.exe', ['gateway', 'run']) expect(spawnCalls[0]).toMatchObject({ command: process.env.HERMES_AGENT_CLI_PYTHON, args: ['-m', 'hermes_cli.main', 'gateway', 'run'], options: expect.objectContaining({ windowsHide: true }), }) }) }) describe('Hermes installation probe', () => { it('uses local help instead of the network-dependent version check and accepts slow startup', async () => { vi.useFakeTimers() execResult.delayMs = 7800 const { probeHermesCli } = await import('../../packages/server/src/services/hermes/hermes-process') const pending = probeHermesCli('/opt/hermes/bin/hermes') await vi.advanceTimersByTimeAsync(7800) await expect(pending).resolves.toEqual({ available: true, reason: 'ready' }) expect(execFileCalls[0]).toMatchObject({ args: ['--help'], options: { timeout: 15000 } }) }) it.each([ [{ code: 'ENOENT' }, 'missing'], [{ code: 'ENOTDIR' }, 'missing'], [{ code: 127 }, 'missing'], [{ code: 'EACCES' }, 'permission_denied'], [{ code: 'EPERM' }, 'permission_denied'], [{ code: 126 }, 'permission_denied'], [{ killed: true, signal: 'SIGTERM' }, 'timeout'], [{ code: 'ETIMEDOUT' }, 'timeout'], [{ code: 1 }, 'failed'], ])('distinguishes %j from a missing installation', async (error, reason) => { execResult.error = error const { probeHermesCli } = await import('../../packages/server/src/services/hermes/hermes-process') await expect(probeHermesCli('hermes')).resolves.toEqual({ available: false, reason }) }) })