Files
hermes-web-ui/tests/server/health-controller.test.ts
ekko c4082ee661 fix: guide Docker users through image upgrades (#2033)
* fix(docker): guard update button in Docker with structured guidance

Docker 环境下 UI 升级按钮不可用的问题修复:

后端:
- handleUpdate 中添加 Docker 环境检测,返回结构化的 Docker 升级指引
  (docker compose pull && docker compose up -d --force-recreate)
- health 接口添加 is_docker 字段,告知前端当前运行环境
- Docker 环境下自动禁用 npm registry 版本检查,消除每 30 分钟的外网请求
  (性能优化:减少不必要的网络 I/O 和重复的版本检查)

前端:
- AppSidebar 中在 Docker 模式下隐藏原有升级按钮,改为显示 Docker 专属
  升级按钮,点击后弹出包含 docker compose 命令的指引弹窗
- VersionManagementModal 的 isDesktopShell 条件保持不变
- 添加 9 种语言的 i18n 翻译键 (dockerUpdateTitle / dockerUpdateGuide
  / dockerUpdateNote)

Co-Authored-By: AtomCode (deepseek-v4-flash) <noreply@atomgit.com>

* fix docker update guidance tests

* reuse update button for Docker guidance

* test Docker update routing

---------

Co-authored-by: suantea <suantea@users.noreply.github.com>
Co-authored-by: AtomCode (deepseek-v4-flash) <noreply@atomgit.com>
2026-07-11 10:24:39 +08:00

306 lines
10 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
}
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 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',
})
})
})