Files
hermes-ui/tests/server/health-controller.test.ts
T
toor a54913efd2
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
fix false missing Hermes CLI detection after deployment
2026-10-24 20:59:08 +08:00

353 lines
12 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'fs'
import { resolve } from 'path'
function readRootPackage() {
return JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf-8')) as {
name: string
version: string
}
}
type LoadHealthControllerOptions = {
injectedVersion?: string
isDocker?: boolean
bridgeReadiness?: any
bridgeReadinessError?: Error
managerError?: Error
runtimeStateError?: Error
cliProbe?: ReturnType<typeof vi.fn>
}
const defaultBridgeReadiness = {
endpoint: 'tcp://127.0.0.1:8123',
endpointKind: 'tcp',
status: 'ready',
reachable: true,
ready: true,
running: true,
attached: false,
starting: false,
stopping: false,
restartScheduled: false,
restartAttempts: 0,
pid: 4321,
}
async function loadHealthController(options: LoadHealthControllerOptions = {}) {
vi.resetModules()
if (typeof options.injectedVersion === 'string') {
;(globalThis as any).__APP_VERSION__ = options.injectedVersion
} else {
delete (globalThis as any).__APP_VERSION__
}
vi.doMock('../../packages/server/src/services/hermes/hermes-cli', () => ({
getVersion: vi.fn().mockResolvedValue('Hermes Agent v0.11.0\n'),
}))
const checkReadiness = options.bridgeReadinessError
? vi.fn().mockRejectedValue(options.bridgeReadinessError)
: vi.fn().mockResolvedValue(options.bridgeReadiness || defaultBridgeReadiness)
const getRuntimeState = options.runtimeStateError
? vi.fn(() => { throw options.runtimeStateError })
: vi.fn(() => ({
endpoint: options.bridgeReadiness?.endpoint || 'ipc:///tmp/hermes-agent-bridge.sock',
}))
const getAgentBridgeManager = options.managerError
? vi.fn(() => { throw options.managerError })
: vi.fn(() => ({ checkReadiness, getRuntimeState }))
vi.doMock('../../packages/server/src/services/hermes/agent-bridge/manager', () => ({
getAgentBridgeManager,
}))
vi.doMock('../../packages/server/src/services/runtime-environment', () => ({
isDockerContainer: () => options.isDocker === true,
}))
const cliProbe = options.cliProbe || vi.fn().mockResolvedValue({ available: true, reason: 'ready' })
vi.doMock('../../packages/server/src/services/hermes/hermes-process', () => ({
probeHermesCli: cliProbe,
resolveHermesBin: () => '/opt/hermes/bin/hermes',
}))
const health = await import('../../packages/server/src/controllers/health')
return {
...health,
getAgentBridgeManager,
checkReadiness,
getRuntimeState,
}
}
async function loadHealthControllerWithoutInjectedVersion(options: Omit<LoadHealthControllerOptions, 'injectedVersion'> = {}) {
return loadHealthController(options)
}
async function loadHealthControllerWithInjectedVersion(version: string) {
return loadHealthController({ injectedVersion: version })
}
function createMockCtx() {
return {
body: null as any,
}
}
describe('health controller version metadata', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.resetModules()
;(globalThis as any).__APP_VERSION__ = 'test'
})
it('reads the root package version in ts-node/dev mode instead of falling back to 0.0.0', async () => {
const pkg = readRootPackage()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
const { healthCheck } = await loadHealthControllerWithoutInjectedVersion()
const ctx = createMockCtx()
await healthCheck(ctx)
expect(ctx.body.webui_version).toBe(pkg.version)
expect(ctx.body.webui_version).not.toBe('0.0.0')
})
it('uses the injected build version when available', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
const { healthCheck } = await loadHealthControllerWithInjectedVersion('9.9.9-test')
const ctx = createMockCtx()
await healthCheck(ctx)
expect(ctx.body.webui_version).toBe('9.9.9-test')
})
it('checks npm latest using the root package name', async () => {
vi.spyOn(console, 'log').mockImplementation(() => {})
const pkg = readRootPackage()
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ version: '99.99.99' }),
})
vi.stubGlobal('fetch', fetchMock)
const { checkLatestVersion, healthCheck } = await loadHealthControllerWithoutInjectedVersion()
await checkLatestVersion()
expect(fetchMock).toHaveBeenCalledWith(
`https://registry.npmjs.org/${pkg.name}/latest`,
expect.objectContaining({ signal: expect.any(AbortSignal) }),
)
const ctx = createMockCtx()
await healthCheck(ctx)
expect(ctx.body.webui_latest).toBe('99.99.99')
expect(ctx.body.webui_update_available).toBe(true)
})
it('does not report a registry version lower than the local build as an update', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ version: '0.6.17' }),
}))
const { checkLatestVersion, healthCheck } = await loadHealthControllerWithInjectedVersion('0.6.18')
await checkLatestVersion()
const ctx = createMockCtx()
await healthCheck(ctx)
expect(ctx.body.webui_latest).toBe('0.6.17')
expect(ctx.body.webui_update_available).toBe(false)
expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('Update available'))
})
it('does not throw when latest-version lookup fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
const { checkLatestVersion } = await loadHealthControllerWithoutInjectedVersion()
await expect(checkLatestVersion()).resolves.toBeUndefined()
})
it('reports Docker while retaining version checks for upgrade guidance', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ version: '0.6.29' }),
})
vi.stubGlobal('fetch', fetchMock)
const { checkLatestVersion, healthCheck } = await loadHealthController({
injectedVersion: '0.6.28',
isDocker: true,
})
await checkLatestVersion()
const ctx = createMockCtx()
await healthCheck(ctx)
expect(fetchMock).toHaveBeenCalledOnce()
expect(ctx.body).toEqual(expect.objectContaining({
is_docker: true,
webui_latest: '0.6.29',
webui_update_available: true,
}))
})
it('includes sanitized agent bridge readiness fields without leaking the endpoint path', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
const { healthCheck, getAgentBridgeManager, checkReadiness } = await loadHealthControllerWithoutInjectedVersion({
bridgeReadiness: {
endpoint: 'ipc:///tmp/hermes-agent-bridge.sock',
endpointKind: 'ipc',
status: 'unreachable',
reachable: false,
ready: false,
running: false,
attached: false,
starting: false,
stopping: false,
restartScheduled: true,
restartAttempts: 3,
pid: 9876,
error: 'connect ENOENT /tmp/hermes-agent-bridge.sock',
},
})
const ctx = createMockCtx()
await healthCheck(ctx)
expect(getAgentBridgeManager).toHaveBeenCalledTimes(1)
expect(checkReadiness).toHaveBeenCalledWith({ timeoutMs: 75, connectRetryMs: 0 })
expect(ctx.body.agent_bridge).toEqual({
status: 'unreachable',
reachable: false,
ready: false,
running: false,
attached: false,
starting: false,
stopping: false,
restart_scheduled: true,
restart_attempts: 3,
endpoint_kind: 'ipc',
pid: 9876,
error: 'connect ENOENT [redacted endpoint]',
})
expect(ctx.body.agent_bridge).not.toHaveProperty('endpoint')
expect(JSON.stringify(ctx.body.agent_bridge)).not.toContain('/tmp/hermes-agent-bridge.sock')
})
it('handles agent bridge readiness probe errors without failing the health check', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
const { healthCheck, checkReadiness, getRuntimeState } = await loadHealthControllerWithoutInjectedVersion({
bridgeReadinessError: new Error('bridge manager unavailable at ipc:///tmp/hermes-agent-bridge.sock (ENOENT /tmp/hermes-agent-bridge.sock)'),
})
const ctx = createMockCtx()
await expect(healthCheck(ctx)).resolves.toBeUndefined()
expect(checkReadiness).toHaveBeenCalledWith({ timeoutMs: 75, connectRetryMs: 0 })
expect(getRuntimeState).toHaveBeenCalledTimes(1)
expect(ctx.body.status).toBe('ok')
expect(ctx.body.gateway).toBe('running')
expect(ctx.body.agent_bridge).toEqual({
status: 'unknown',
reachable: false,
error: 'bridge manager unavailable at [redacted endpoint] (ENOENT [redacted endpoint])',
})
expect(JSON.stringify(ctx.body.agent_bridge)).not.toContain('/tmp/hermes-agent-bridge.sock')
expect(JSON.stringify(ctx.body.agent_bridge)).not.toContain('ipc:///tmp/hermes-agent-bridge.sock')
})
it('handles manager construction errors without failing the base health check', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
const { healthCheck, getAgentBridgeManager, checkReadiness } = await loadHealthControllerWithoutInjectedVersion({
managerError: new Error('bad bridge config /tmp/hermes-agent-bridge.sock'),
})
const ctx = createMockCtx()
await expect(healthCheck(ctx)).resolves.toBeUndefined()
expect(getAgentBridgeManager).toHaveBeenCalledTimes(1)
expect(checkReadiness).not.toHaveBeenCalled()
expect(ctx.body.status).toBe('ok')
expect(ctx.body.agent_bridge).toEqual({
status: 'unknown',
reachable: false,
error: 'bad bridge config [redacted endpoint]',
})
})
it('handles runtime-state errors without failing the base health check', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
const { healthCheck, getRuntimeState, checkReadiness } = await loadHealthControllerWithoutInjectedVersion({
runtimeStateError: new Error('runtime state unavailable'),
})
const ctx = createMockCtx()
await expect(healthCheck(ctx)).resolves.toBeUndefined()
expect(getRuntimeState).toHaveBeenCalledTimes(1)
expect(checkReadiness).not.toHaveBeenCalled()
expect(ctx.body.status).toBe('ok')
expect(ctx.body.agent_bridge).toEqual({
status: 'unknown',
reachable: false,
error: 'runtime state unavailable',
})
})
})
describe('CLI status cache', () => {
afterEach(() => { vi.useRealTimers(); vi.resetModules() })
it('coalesces concurrent requests and allows an explicit recheck after failure', async () => {
let finish!: (value: any) => void
const probe = vi.fn().mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
.mockResolvedValue({ available: true, reason: 'ready' })
const { hermesCliStatus } = await loadHealthController({ cliProbe: probe })
const first = createMockCtx()
const second = createMockCtx()
const pending = [hermesCliStatus(first), hermesCliStatus(second)]
expect(probe).toHaveBeenCalledOnce()
finish({ available: false, reason: 'timeout' })
await Promise.all(pending)
expect(first.body).toMatchObject({ available: false, reason: 'timeout' })
expect(second.body).toEqual(first.body)
await hermesCliStatus(createMockCtx())
expect(probe).toHaveBeenCalledOnce()
const retry = { ...createMockCtx(), query: { refresh: '1' } }
await hermesCliStatus(retry)
expect(retry.body).toMatchObject({ available: true, reason: 'ready' })
expect(probe).toHaveBeenCalledTimes(2)
})
it('expires failures quickly and starts successful cache lifetime after the probe completes', async () => {
vi.useFakeTimers()
const probe = vi.fn().mockResolvedValueOnce({ available: false, reason: 'permission_denied' })
.mockImplementationOnce(() => new Promise(resolve => setTimeout(() => resolve({ available: true, reason: 'ready' }), 8000)))
const { hermesCliStatus } = await loadHealthController({ cliProbe: probe })
await hermesCliStatus(createMockCtx())
await vi.advanceTimersByTimeAsync(5001)
const pending = hermesCliStatus(createMockCtx())
await vi.advanceTimersByTimeAsync(8000)
await pending
await vi.advanceTimersByTimeAsync(29000)
await hermesCliStatus(createMockCtx())
expect(probe).toHaveBeenCalledTimes(2)
})
})