Files
hermes-ui/tests/server/gateway-autostart.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

402 lines
18 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,
recoverWindowsDesktopGatewayOrphans,
reconcileGatewayManagementTransition,
resolveGatewayTargetProfile,
selectGatewayProfilesForAutostart,
selectProfilesForGatewayAutostart,
shouldRecoverWindowsDesktopGatewayOrphans,
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('only recovers Windows desktop gateway orphans when enabled', () => {
expect(shouldRecoverWindowsDesktopGatewayOrphans('win32', { HERMES_DESKTOP: 'true' })).toBe(true)
expect(shouldRecoverWindowsDesktopGatewayOrphans('darwin', { HERMES_DESKTOP: 'true' })).toBe(false)
expect(shouldRecoverWindowsDesktopGatewayOrphans('win32', {})).toBe(false)
expect(shouldRecoverWindowsDesktopGatewayOrphans('win32', {
HERMES_DESKTOP: 'true',
HERMES_WEB_UI_DISABLE_GATEWAY_STARTUP_RECOVERY: '1',
})).toBe(false)
})
it('kills Windows desktop gateway runtime PIDs and removes stale runtime files', async () => {
const home = mkdtempSync(join(tmpdir(), 'hermes-gateway-recovery-'))
const workHome = join(home, 'profiles', 'work')
mkdirSync(workHome, { recursive: true })
const killed: number[] = []
const stopped: string[] = []
try {
writeFileSync(join(home, 'gateway.pid'), JSON.stringify({ pid: 11111 }), 'utf-8')
writeFileSync(join(home, 'gateway_state.json'), JSON.stringify({ pid: 99999, gateway_state: 'stopped' }), 'utf-8')
writeFileSync(join(workHome, 'gateway.lock'), JSON.stringify({ pid: '22222' }), 'utf-8')
writeFileSync(join(workHome, 'gateway_state.json'), JSON.stringify({ pid: 33333, gateway_state: 'running' }), 'utf-8')
const result = await recoverWindowsDesktopGatewayOrphans({
platform: 'win32',
env: { HERMES_DESKTOP: 'true' },
hermesHome: home,
isAlive: pid => pid !== 99999,
stopGateway: async profileDir => { stopped.push(profileDir) },
execTaskkill: async pid => { killed.push(pid) },
})
expect(result.attempted).toBe(true)
expect(stopped.sort()).toEqual([home, workHome].sort())
expect(result.stoppedProfileDirs.sort()).toEqual([home, workHome].sort())
expect(killed.sort((a, b) => a - b)).toEqual([11111, 22222, 33333])
expect(result.killedPids.sort((a, b) => a - b)).toEqual([11111, 22222, 33333])
expect(existsSync(join(home, 'gateway.pid'))).toBe(false)
expect(existsSync(join(home, 'gateway_state.json'))).toBe(false)
expect(existsSync(join(workHome, 'gateway.lock'))).toBe(false)
expect(existsSync(join(workHome, 'gateway_state.json'))).toBe(false)
} finally {
rmSync(home, { recursive: true, force: true })
}
})
it('skips recovery outside Windows desktop mode without deleting runtime files', async () => {
const home = mkdtempSync(join(tmpdir(), 'hermes-gateway-recovery-skip-'))
try {
const pidPath = join(home, 'gateway.pid')
writeFileSync(pidPath, JSON.stringify({ pid: 11111 }), 'utf-8')
const result = await recoverWindowsDesktopGatewayOrphans({
platform: 'linux',
env: { HERMES_DESKTOP: 'true' },
hermesHome: home,
isAlive: () => {
throw new Error('should not check liveness')
},
})
expect(result.attempted).toBe(false)
expect(existsSync(pidPath)).toBe(true)
} finally {
rmSync(home, { recursive: true, force: true })
}
})
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 })
}
})
})