dashboardController.test.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. // @vitest-environment node
  2. import { expect, it, vi } from 'vitest'
  3. import { ServerDashboardController } from '../../src/server/dashboardController'
  4. const origin = new URL('http://127.0.0.1:9119')
  5. const target = 'gui/501/com.samien.hermes.dashboard.local'
  6. function fixture(options: { upstream?: URL; platform?: NodeJS.Platform; label?: string; command?: string; owned?: { canRestart: boolean; restart(): Promise<void>; suspendChecks?(): () => void } } = {}) {
  7. let pid = 1043, listener = 1043, replacement = 2043, available = true, health = true
  8. const service = options.label ? `gui/501/${options.label}` : target
  9. const run = vi.fn(async (command: string, args: string[]) => {
  10. if (command === '/bin/launchctl' && args[0] === 'print') {
  11. if (!available || args[1] !== service) throw new Error('job not found')
  12. return `${service} = {\n\targuments = {\n\t\t${options.command ?? '/server/.hermes/venv/bin/python\n\t\t/server/.hermes/hermes-agent/hermes\n\t\tdashboard'}\n\t\t--host\n\t\t127.0.0.1\n\t}\n\tpid = ${pid}\n}`
  13. }
  14. if (command === '/usr/sbin/lsof') return `p${listener}\n`
  15. if (command === '/bin/launchctl' && args[0] === 'kickstart') { pid = replacement; listener = replacement; return '' }
  16. throw new Error('unexpected command')
  17. })
  18. const healthy = vi.fn(async () => health)
  19. const controller = new ServerDashboardController(options.upstream ?? origin, options.owned, {
  20. platform: options.platform ?? 'darwin', uid: 501, run, healthy, readyTimeoutMs: 15, pollIntervalMs: 1,
  21. })
  22. const mutations = () => run.mock.calls.filter(([, args]) => args[0] === 'kickstart')
  23. return { controller, run, healthy, mutations, set: (state: { available?: boolean; listener?: number; replacement?: number; healthy?: boolean }) => {
  24. if (state.available !== undefined) available = state.available
  25. if (state.listener !== undefined) listener = state.listener
  26. if (state.replacement !== undefined) replacement = state.replacement
  27. if (state.healthy !== undefined) health = state.healthy
  28. } }
  29. }
  30. it('discovers and restarts the server LaunchAgent, then verifies a new listener and HTTP health', async () => {
  31. const f = fixture()
  32. expect(f.controller.canRestart).toBe(false)
  33. await f.controller.refresh()
  34. expect(f.controller.canRestart).toBe(true)
  35. expect(f.mutations()).toEqual([])
  36. await f.controller.restart()
  37. expect(f.mutations()).toEqual([['/bin/launchctl', ['kickstart', '-k', target]]])
  38. expect(f.healthy).toHaveBeenCalledWith('http://127.0.0.1:9119')
  39. expect(f.controller.canRestart).toBe(true)
  40. })
  41. it('supports the standard Hermes LaunchAgent only when it owns the configured listener', async () => {
  42. const f = fixture({ label: 'ai.hermes.dashboard', command: '/server/.local/bin/hermes\n\t\tdashboard' })
  43. await f.controller.refresh(); await f.controller.restart()
  44. expect(f.mutations()[0]?.[1]).toEqual(['kickstart', '-k', 'gui/501/ai.hermes.dashboard'])
  45. })
  46. it.each([
  47. new URL('https://hermes.example'), new URL('http://192.168.1.5:9119'), new URL('http://127.0.0.1:9120'),
  48. ])('never runs local commands for an upstream on another server or port: %s', async upstream => {
  49. const owned = { canRestart: true, restart: vi.fn(async () => {}) }
  50. const f = fixture({ upstream, owned })
  51. await f.controller.refresh()
  52. expect(f.controller.canRestart).toBe(false)
  53. await expect(f.controller.restart()).rejects.toThrow('所在节点')
  54. expect(f.run).not.toHaveBeenCalled(); expect(owned.restart).not.toHaveBeenCalled()
  55. })
  56. it('does not run launchctl on an unsupported server OS', async () => {
  57. const f = fixture({ platform: 'linux' })
  58. await f.controller.refresh()
  59. await expect(f.controller.restart()).rejects.toThrow('尚未支持')
  60. expect(f.run).not.toHaveBeenCalled()
  61. })
  62. it.each(['missing', 'other-listener', 'other-command', 'multiple-listeners'])('refuses an unverified service: %s', async mode => {
  63. const f = fixture(mode === 'other-command' ? { command: '/usr/bin/node\n\t\tdashboard' } : {})
  64. if (mode === 'missing') f.set({ available: false })
  65. if (mode === 'other-listener') f.set({ listener: 999 })
  66. if (mode === 'multiple-listeners') {
  67. const original = f.run.getMockImplementation()!
  68. f.run.mockImplementation(async (command, args) => command === '/usr/sbin/lsof' ? 'p1043\np999\n' : original(command, args))
  69. }
  70. await f.controller.refresh()
  71. expect(f.controller.canRestart).toBe(false)
  72. await expect(f.controller.restart()).rejects.toThrow('未识别到')
  73. expect(f.mutations()).toEqual([])
  74. })
  75. it('revalidates ownership immediately before restarting, regardless of an earlier available status', async () => {
  76. const f = fixture()
  77. await f.controller.refresh(); expect(f.controller.canRestart).toBe(true)
  78. f.set({ listener: 999 })
  79. await expect(f.controller.restart()).rejects.toThrow('未识别到')
  80. expect(f.mutations()).toEqual([])
  81. expect(f.controller.canRestart).toBe(false)
  82. })
  83. it.each(['same-pid', 'unhealthy'])('does not report success until replacement is verified: %s', async mode => {
  84. const f = fixture()
  85. f.set(mode === 'same-pid' ? { replacement: 1043 } : { healthy: false })
  86. await expect(f.controller.restart()).rejects.toThrow('未恢复健康')
  87. expect(f.controller.canRestart).toBe(false)
  88. f.set({ replacement: 3043, healthy: true })
  89. await f.controller.restart()
  90. expect(f.controller.canRestart).toBe(true)
  91. })
  92. it('preserves owned-child restart support and never touches a system service in that case', async () => {
  93. const owned = { canRestart: true, restart: vi.fn(async () => {}) }
  94. const f = fixture({ owned })
  95. await f.controller.refresh(); await f.controller.restart()
  96. expect(owned.restart).toHaveBeenCalledTimes(1)
  97. expect(f.run).not.toHaveBeenCalled()
  98. expect(f.healthy).toHaveBeenCalledWith(origin.origin)
  99. })
  100. it.each([true, false])('suspends automatic starts during a system restart and releases them on success=%s', async success => {
  101. const resume = vi.fn()
  102. const owned = { canRestart: false, restart: vi.fn(async () => {}), suspendChecks: vi.fn(() => resume) }
  103. const f = fixture({ owned })
  104. f.healthy.mockImplementation(async () => {
  105. expect(owned.suspendChecks).toHaveBeenCalledTimes(1)
  106. expect(resume).not.toHaveBeenCalled()
  107. return success
  108. })
  109. if (success) await f.controller.restart()
  110. else await expect(f.controller.restart()).rejects.toThrow('未恢复健康')
  111. expect(owned.restart).not.toHaveBeenCalled()
  112. expect(resume).toHaveBeenCalledTimes(1)
  113. })
  114. it('serializes restarts and stops verification when the server shuts down', async () => {
  115. let release!: () => void
  116. const owned = { canRestart: true, restart: vi.fn(() => new Promise<void>(done => { release = done })) }
  117. const f = fixture({ owned })
  118. const pending = f.controller.restart()
  119. await expect(f.controller.restart()).rejects.toThrow('正在重启')
  120. f.controller.stop(); release()
  121. await expect(pending).rejects.toThrow('未恢复健康')
  122. expect(f.controller.canRestart).toBe(false)
  123. expect(f.run).not.toHaveBeenCalled()
  124. })