Files
hermes-web-ui/tests/server/health-controller.test.ts

279 lines
9.2 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
bridgeReadiness?: any
bridgeReadinessError?: Error
managerError?: Error
runtimeStateError?: Error
}
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,
}))
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('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',
})
})
})