Files
hermes-desktop/tests/server/gateway-autostart.test.ts
toor 01d9e301fc
Build / build (push) Canceled after 0s
Playwright / browser (push) Canceled after 0s
initialize Electron desktop app and remove unused files
2026-10-25 01:09:01 +08:00

332 lines
14 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import {
gatewayAutostartDisabledByEnv,
gatewayAutoStartManagementMode,
gatewayMultiplexConfigEnabledForDefaultProfile,
gatewayStatusLooksRuntimeLocked,
gatewayStatusLooksRunning,
gatewayStateLooksRunningForProfile,
parseGatewayStatusesFromProfileListOutput,
prepareGatewayForProfileDelete,
reconcileGatewayManagementTransition,
resolveGatewayTargetProfile,
selectGatewayProfilesForAutostart,
selectProfilesForGatewayAutostart,
shouldUseUnifiedGatewayManagement,
shouldUseManagedGatewayRun,
shouldUseManagedGatewayRunForAutostart,
} from '../../packages/server/src/services/hermes/gateway-autostart'
describe('gateway autostart status parsing', () => {
it('selects all profiles by default for gateway autostart', () => {
expect(selectProfilesForGatewayAutostart(['default', 'work', 'test'])).toEqual(['default', 'work', 'test'])
})
it('honors gateway autostart include, exclude, disabled, and unknown profiles', () => {
const profiles = ['default', 'work', 'reviewer', 'scratch']
expect(selectProfilesForGatewayAutostart(profiles, { include: ['work', 'missing', 'work', ' reviewer '] })).toEqual([
'work',
'reviewer',
])
expect(selectProfilesForGatewayAutostart(profiles, { exclude: ['scratch', 'missing'] })).toEqual([
'default',
'work',
'reviewer',
])
expect(selectProfilesForGatewayAutostart(profiles, { include: ['work', 'scratch'], exclude: ['scratch'] })).toEqual([
'work',
])
expect(selectProfilesForGatewayAutostart(profiles, { include: ['missing'] })).toEqual([])
expect(selectProfilesForGatewayAutostart(profiles, { include: [] })).toEqual([])
expect(selectProfilesForGatewayAutostart(profiles, { enabled: false, include: ['default'] })).toEqual([])
})
it('selects only the default gateway target in unified gateway management', () => {
const profiles = ['default', 'work', 'reviewer']
expect(selectGatewayProfilesForAutostart(profiles, undefined, true)).toEqual(['default'])
expect(selectGatewayProfilesForAutostart(profiles, { include: ['work'] }, true)).toEqual(['default'])
expect(selectGatewayProfilesForAutostart(profiles, { include: [] }, true)).toEqual([])
expect(selectGatewayProfilesForAutostart(profiles, { enabled: false }, true)).toEqual([])
expect(selectGatewayProfilesForAutostart(profiles, { include: ['work'] }, false)).toEqual(['work'])
})
it('resolves gateway target profile for unified management', () => {
expect(resolveGatewayTargetProfile('work', false)).toEqual({
requestedProfile: 'work',
targetProfile: 'work',
unified: false,
})
expect(resolveGatewayTargetProfile('work', true)).toEqual({
requestedProfile: 'work',
targetProfile: 'default',
unified: true,
})
expect(resolveGatewayTargetProfile('', true)).toEqual({
requestedProfile: 'default',
targetProfile: 'default',
unified: true,
})
})
it('detects Hermes Agent multiplex gateway config in the default profile', () => {
const home = mkdtempSync(join(tmpdir(), 'hermes-gateway-multiplex-'))
try {
expect(gatewayMultiplexConfigEnabledForDefaultProfile(home)).toBe(false)
writeFileSync(join(home, 'config.yaml'), 'gateway:\n multiplex_profiles: true\n', 'utf-8')
expect(gatewayMultiplexConfigEnabledForDefaultProfile(home)).toBe(true)
writeFileSync(join(home, 'config.yaml'), 'multiplex_profiles: "yes"\n', 'utf-8')
expect(gatewayMultiplexConfigEnabledForDefaultProfile(home)).toBe(true)
writeFileSync(join(home, 'config.yaml'), 'gateway:\n multiplex_profiles: false\n', 'utf-8')
expect(gatewayMultiplexConfigEnabledForDefaultProfile(home)).toBe(false)
} finally {
rmSync(home, { recursive: true, force: true })
}
})
it('honors Web UI gateway management overrides around Hermes Agent multiplex config', () => {
const home = mkdtempSync(join(tmpdir(), 'hermes-gateway-management-'))
try {
writeFileSync(join(home, 'config.yaml'), 'gateway:\n multiplex_profiles: true\n', 'utf-8')
expect(gatewayAutoStartManagementMode({ management: 'unified' })).toBe('unified')
expect(gatewayAutoStartManagementMode({ management: 'per_profile' })).toBe('per_profile')
expect(gatewayAutoStartManagementMode({ management: 'auto' })).toBe('auto')
expect(shouldUseUnifiedGatewayManagement({ management: 'auto' }, home)).toBe(true)
expect(shouldUseUnifiedGatewayManagement({ management: 'per_profile' }, home)).toBe(false)
expect(shouldUseUnifiedGatewayManagement({ management: 'unified' }, home)).toBe(true)
writeFileSync(join(home, 'config.yaml'), 'gateway:\n multiplex_profiles: false\n', 'utf-8')
expect(shouldUseUnifiedGatewayManagement({ management: 'auto' }, home)).toBe(false)
expect(shouldUseUnifiedGatewayManagement({ management: 'unified' }, home)).toBe(true)
} finally {
rmSync(home, { recursive: true, force: true })
}
})
it('detects the environment-level gateway autostart disable flag', () => {
expect(gatewayAutostartDisabledByEnv({ HERMES_WEB_UI_DISABLE_GATEWAY_AUTOSTART: '1' })).toBe(true)
expect(gatewayAutostartDisabledByEnv({ HERMES_WEB_UI_DISABLE_GATEWAY_AUTOSTART: 'true' })).toBe(true)
expect(gatewayAutostartDisabledByEnv({ HERMES_WEB_UI_DISABLE_GATEWAY_AUTOSTART: 'off' })).toBe(false)
expect(gatewayAutostartDisabledByEnv({})).toBe(false)
})
it('stops per-profile gateways and starts default when switching to unified management', async () => {
const events: string[] = []
const result = await reconcileGatewayManagementTransition(
{ management: 'per_profile' },
{ management: 'unified' },
{
profiles: ['default', 'work', 'reviewer'],
stopGateway: async profile => { events.push(`stop:${profile}`) },
startGateway: async profile => { events.push(`start:${profile}`) },
waitForGateway: async () => true,
},
)
expect(result).toMatchObject({
changed: true,
previousUnified: false,
nextUnified: true,
stoppedProfiles: ['default', 'work', 'reviewer'],
startedProfiles: ['default'],
})
expect(events).toEqual(['stop:default', 'stop:work', 'stop:reviewer', 'start:default'])
})
it('stops default and starts selected profile gateways when switching back to per-profile management', async () => {
const events: string[] = []
const result = await reconcileGatewayManagementTransition(
{ management: 'unified' },
{ management: 'per_profile', exclude: ['reviewer'] },
{
profiles: ['default', 'work', 'reviewer'],
stopGateway: async profile => { events.push(`stop:${profile}`) },
startGateway: async profile => { events.push(`start:${profile}`) },
waitForGateway: async () => true,
},
)
expect(result).toMatchObject({
changed: true,
previousUnified: true,
nextUnified: false,
stoppedProfiles: ['default'],
startedProfiles: ['default', 'work'],
})
expect(events).toEqual(['stop:default', 'start:default', 'start:work'])
})
it('treats runtime lock conflicts as an already-running gateway', () => {
expect(gatewayStatusLooksRuntimeLocked(
'Gateway runtime lock is already held by another instance. Exiting.',
)).toBe(true)
})
it('does not treat not-running status as running', () => {
expect(gatewayStatusLooksRunning('Gateway is not running')).toBe(false)
})
it('parses gateway status from hermes profile list output', () => {
const output = `
Profile Model Gateway Alias Distribution
─────────────── ─────────────────────────── ─────────── ─────────── ────────────────────
◆default glm-5-turbo running — —
akri glm-5-turbo running akri —
tester gpt-5.5 stopped tester —
`
const statuses = parseGatewayStatusesFromProfileListOutput(output, ['default', 'akri', 'tester'])
expect(statuses.get('default')).toBe('running')
expect(statuses.get('akri')).toBe('running')
expect(statuses.get('tester')).toBe('stopped')
})
it('parses gateway status when profile or model fills the table column', () => {
const output = `
Profile Model Gateway Alias Distribution
─────────────── ─────────────────────────── ─────────── ─────────── ────────────────────
daily_assistant deepseek-v4-flash running — —
long_model provider/model-name-that-fills-column stopped — —
`
const statuses = parseGatewayStatusesFromProfileListOutput(output, ['daily_assistant', 'long_model'])
expect(statuses.get('daily_assistant')).toBe('running')
expect(statuses.get('long_model')).toBe('stopped')
})
it('uses profile-list gateway status text for running checks', () => {
expect(gatewayStatusLooksRunning('running')).toBe(true)
expect(gatewayStatusLooksRunning('stopped')).toBe(false)
expect(gatewayStatusLooksRunning('not running')).toBe(false)
})
it('uses managed gateway mode by default', () => {
const previous = process.env.HERMES_WEB_UI_MANAGED_GATEWAY
try {
delete process.env.HERMES_WEB_UI_MANAGED_GATEWAY
expect(shouldUseManagedGatewayRun()).toBe(true)
expect(shouldUseManagedGatewayRunForAutostart('darwin')).toBe(true)
expect(shouldUseManagedGatewayRunForAutostart('linux')).toBe(true)
expect(shouldUseManagedGatewayRunForAutostart('win32')).toBe(true)
} finally {
if (previous === undefined) delete process.env.HERMES_WEB_UI_MANAGED_GATEWAY
else process.env.HERMES_WEB_UI_MANAGED_GATEWAY = previous
}
})
it('keeps managed gateway mode enabled when explicitly set', () => {
const previous = process.env.HERMES_WEB_UI_MANAGED_GATEWAY
process.env.HERMES_WEB_UI_MANAGED_GATEWAY = '1'
try {
expect(shouldUseManagedGatewayRun()).toBe(true)
expect(shouldUseManagedGatewayRunForAutostart()).toBe(true)
} finally {
if (previous === undefined) delete process.env.HERMES_WEB_UI_MANAGED_GATEWAY
else process.env.HERMES_WEB_UI_MANAGED_GATEWAY = previous
}
})
it('allows managed gateway mode to be disabled by environment', () => {
const previous = process.env.HERMES_WEB_UI_MANAGED_GATEWAY
try {
for (const value of ['0', 'false', 'no', 'off']) {
process.env.HERMES_WEB_UI_MANAGED_GATEWAY = value
expect(shouldUseManagedGatewayRun()).toBe(false)
expect(shouldUseManagedGatewayRunForAutostart('win32')).toBe(false)
expect(shouldUseManagedGatewayRunForAutostart('darwin')).toBe(false)
}
} finally {
if (previous === undefined) delete process.env.HERMES_WEB_UI_MANAGED_GATEWAY
else process.env.HERMES_WEB_UI_MANAGED_GATEWAY = previous
}
})
it('detects managed gateway state files with a live pid', () => {
const dir = mkdtempSync(join(tmpdir(), 'hermes-gateway-state-'))
try {
writeFileSync(
join(dir, 'gateway_state.json'),
JSON.stringify({ pid: process.pid, gateway_state: 'running' }),
'utf-8',
)
expect(gatewayStateLooksRunningForProfile(dir)).toBe(true)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('prepares a profile delete by marking the gateway desired stopped', async () => {
const previousHermesHome = process.env.HERMES_HOME
const previousHermesBin = process.env.HERMES_BIN
const home = mkdtempSync(join(tmpdir(), 'wui-delete-gateway-'))
const profileDir = join(home, 'profiles', 'work')
mkdirSync(profileDir, { recursive: true })
try {
process.env.HERMES_HOME = home
process.env.HERMES_BIN = '/definitely/missing/hermes'
await prepareGatewayForProfileDelete('work')
expect(JSON.parse(readFileSync(join(profileDir, 'gateway_state.json'), 'utf-8'))).toMatchObject({
gateway_state: 'stopped',
desired_state: 'stopped',
})
} finally {
if (previousHermesHome === undefined) delete process.env.HERMES_HOME
else process.env.HERMES_HOME = previousHermesHome
if (previousHermesBin === undefined) delete process.env.HERMES_BIN
else process.env.HERMES_BIN = previousHermesBin
rmSync(home, { recursive: true, force: true })
}
})
it('does not fail profile deletion prep when the gateway is already stopped', async () => {
const previousHermesHome = process.env.HERMES_HOME
const previousHermesBin = process.env.HERMES_BIN
const home = mkdtempSync(join(tmpdir(), 'wui-delete-gateway-'))
mkdirSync(join(home, 'profiles', 'work'), { recursive: true })
try {
process.env.HERMES_HOME = home
process.env.HERMES_BIN = '/definitely/missing/hermes'
await expect(prepareGatewayForProfileDelete('work')).resolves.toBeUndefined()
} finally {
if (previousHermesHome === undefined) delete process.env.HERMES_HOME
else process.env.HERMES_HOME = previousHermesHome
if (previousHermesBin === undefined) delete process.env.HERMES_BIN
else process.env.HERMES_BIN = previousHermesBin
rmSync(home, { recursive: true, force: true })
}
})
it('does not fall back to the default profile when delete prep sees a missing profile', async () => {
const previousHermesHome = process.env.HERMES_HOME
const previousHermesBin = process.env.HERMES_BIN
const home = mkdtempSync(join(tmpdir(), 'wui-delete-gateway-missing-'))
try {
process.env.HERMES_HOME = home
process.env.HERMES_BIN = '/definitely/missing/hermes'
await expect(prepareGatewayForProfileDelete('missing')).resolves.toBeUndefined()
expect(existsSync(join(home, 'gateway_state.json'))).toBe(false)
} finally {
if (previousHermesHome === undefined) delete process.env.HERMES_HOME
else process.env.HERMES_HOME = previousHermesHome
if (previousHermesBin === undefined) delete process.env.HERMES_BIN
else process.env.HERMES_BIN = previousHermesBin
rmSync(home, { recursive: true, force: true })
}
})
})