dashboardController.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  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] === 'list') return available ? `${pid}\t0\t${service.split('/').at(-1)}\n` : ''
  16. if (command === '/bin/launchctl' && args[0] === 'kickstart') { pid = replacement; listener = replacement; return '' }
  17. throw new Error('unexpected command')
  18. })
  19. const healthy = vi.fn(async () => health)
  20. const controller = new ServerDashboardController(options.upstream ?? origin, options.owned, {
  21. platform: options.platform ?? 'darwin', uid: 501, run, healthy, readyTimeoutMs: 15, pollIntervalMs: 1,
  22. })
  23. const mutations = () => run.mock.calls.filter(([, args]) => args[0] === 'kickstart')
  24. return { controller, run, healthy, mutations, set: (state: { available?: boolean; listener?: number; replacement?: number; healthy?: boolean }) => {
  25. if (state.available !== undefined) available = state.available
  26. if (state.listener !== undefined) listener = state.listener
  27. if (state.replacement !== undefined) replacement = state.replacement
  28. if (state.healthy !== undefined) health = state.healthy
  29. } }
  30. }
  31. it('discovers and restarts the server LaunchAgent, then verifies a new listener and HTTP health', async () => {
  32. const f = fixture()
  33. expect(f.controller.canRestart).toBe(false)
  34. await f.controller.refresh()
  35. expect(f.controller.canRestart).toBe(true)
  36. expect(f.mutations()).toEqual([])
  37. await f.controller.restart()
  38. expect(f.mutations()).toEqual([['/bin/launchctl', ['kickstart', '-k', target]]])
  39. expect(f.healthy).toHaveBeenCalledWith('http://127.0.0.1:9119')
  40. expect(f.controller.canRestart).toBe(true)
  41. })
  42. it('supports the standard Hermes LaunchAgent only when it owns the configured listener', async () => {
  43. const f = fixture({ label: 'ai.hermes.dashboard', command: '/server/.local/bin/hermes\n\t\tdashboard' })
  44. await f.controller.refresh(); await f.controller.restart()
  45. expect(f.mutations()[0]?.[1]).toEqual(['kickstart', '-k', 'gui/501/ai.hermes.dashboard'])
  46. })
  47. it.each([
  48. '/server/.hermes/venv/bin/python3\n\t\t-m\n\t\thermes_cli.main\n\t\tdashboard',
  49. '/server/.hermes/venv/bin/python\n\t\t/server/.hermes/hermes-agent/hermes_cli/main.py\n\t\tserve',
  50. '/server/.local/bin/hermes\n\t\t--profile\n\t\tyaoyao\n\t\tdashboard',
  51. ])('recognizes supported Hermes entry points under a custom LaunchAgent label: %s', async command => {
  52. const f = fixture({ label: 'local.custom.hermes', command })
  53. await f.controller.refresh(); expect(f.controller.canRestart).toBe(true)
  54. await f.controller.restart()
  55. expect(f.mutations()[0]?.[1]).toEqual(['kickstart', '-k', 'gui/501/local.custom.hermes'])
  56. })
  57. it.each([
  58. new URL('https://hermes.example'), new URL('http://192.168.1.5:9119'), new URL('http://127.0.0.1:9120'),
  59. ])('never runs local commands for an upstream on another server or port: %s', async upstream => {
  60. const owned = { canRestart: true, restart: vi.fn(async () => {}) }
  61. const f = fixture({ upstream, owned })
  62. await f.controller.refresh()
  63. expect(f.controller.canRestart).toBe(false)
  64. await expect(f.controller.restart()).rejects.toThrow('所在节点')
  65. expect(f.run).not.toHaveBeenCalled(); expect(owned.restart).not.toHaveBeenCalled()
  66. })
  67. it('does not run launchctl on an unsupported server OS', async () => {
  68. const f = fixture({ platform: 'linux' })
  69. await f.controller.refresh()
  70. await expect(f.controller.restart()).rejects.toThrow('尚未支持')
  71. expect(f.run).not.toHaveBeenCalled()
  72. })
  73. it.each(['missing', 'other-listener', 'other-command', 'multiple-listeners'])('refuses an unverified service: %s', async mode => {
  74. const f = fixture(mode === 'other-command' ? { command: '/usr/bin/node\n\t\tdashboard' } : {})
  75. if (mode === 'missing') f.set({ available: false })
  76. if (mode === 'other-listener') f.set({ listener: 999 })
  77. if (mode === 'multiple-listeners') {
  78. const original = f.run.getMockImplementation()!
  79. f.run.mockImplementation(async (command, args) => command === '/usr/sbin/lsof' ? 'p1043\np999\n' : original(command, args))
  80. }
  81. await f.controller.refresh()
  82. expect(f.controller.canRestart).toBe(false)
  83. await expect(f.controller.restart()).rejects.toThrow('未识别到')
  84. expect(f.mutations()).toEqual([])
  85. })
  86. it('revalidates ownership immediately before restarting, regardless of an earlier available status', async () => {
  87. const f = fixture()
  88. await f.controller.refresh(); expect(f.controller.canRestart).toBe(true)
  89. f.set({ listener: 999 })
  90. await expect(f.controller.restart()).rejects.toThrow('未识别到')
  91. expect(f.mutations()).toEqual([])
  92. expect(f.controller.canRestart).toBe(false)
  93. })
  94. it.each(['same-pid', 'unhealthy'])('does not report success until replacement is verified: %s', async mode => {
  95. const f = fixture()
  96. f.set(mode === 'same-pid' ? { replacement: 1043 } : { healthy: false })
  97. await expect(f.controller.restart()).rejects.toThrow('未恢复健康')
  98. expect(f.controller.canRestart).toBe(false)
  99. f.set({ replacement: 3043, healthy: true })
  100. await f.controller.restart()
  101. expect(f.controller.canRestart).toBe(true)
  102. })
  103. it('preserves owned-child restart support and never touches a system service in that case', async () => {
  104. const owned = { canRestart: true, restart: vi.fn(async () => {}) }
  105. const f = fixture({ owned })
  106. await f.controller.refresh(); await f.controller.restart()
  107. expect(owned.restart).toHaveBeenCalledTimes(1)
  108. expect(f.run).not.toHaveBeenCalled()
  109. expect(f.healthy).toHaveBeenCalledWith(origin.origin)
  110. })
  111. it.each([true, false])('suspends automatic starts during a system restart and releases them on success=%s', async success => {
  112. const resume = vi.fn()
  113. const owned = { canRestart: false, restart: vi.fn(async () => {}), suspendChecks: vi.fn(() => resume) }
  114. const f = fixture({ owned })
  115. f.healthy.mockImplementation(async () => {
  116. expect(owned.suspendChecks).toHaveBeenCalledTimes(1)
  117. expect(resume).not.toHaveBeenCalled()
  118. return success
  119. })
  120. if (success) await f.controller.restart()
  121. else await expect(f.controller.restart()).rejects.toThrow('未恢复健康')
  122. expect(owned.restart).not.toHaveBeenCalled()
  123. expect(resume).toHaveBeenCalledTimes(1)
  124. })
  125. it('serializes restarts and stops verification when the server shuts down', async () => {
  126. let release!: () => void
  127. const owned = { canRestart: true, restart: vi.fn(() => new Promise<void>(done => { release = done })) }
  128. const f = fixture({ owned })
  129. const pending = f.controller.restart()
  130. await expect(f.controller.restart()).rejects.toThrow('正在重启')
  131. f.controller.stop(); release()
  132. await expect(pending).rejects.toThrow('未恢复健康')
  133. expect(f.controller.canRestart).toBe(false)
  134. expect(f.run).not.toHaveBeenCalled()
  135. })
  136. function standaloneFixture() {
  137. let pid = 1043, running = true, uid = 501, parent = 99
  138. let command = '/server/venv/bin/python3 -m hermes_cli.main dashboard --host 0.0.0.0 --port 9119 --no-open'
  139. let jobs = '', stopped = true, cwd = '/server/hermes-agent'
  140. const run = vi.fn(async (program: string, args: string[]) => {
  141. if (program === '/usr/sbin/lsof') return args.includes('cwd') ? `p${pid}\nfcwd\nn${cwd}\n` : running ? `p${pid}\n` : ''
  142. if (program === '/bin/launchctl' && args[0] === 'list') return jobs
  143. if (program === '/bin/launchctl') throw new Error('no managed job')
  144. if (program === '/bin/ps') {
  145. if (args[1] === '99') return '501 98 /bin/zsh -l\n'
  146. if (running && args[1] === String(pid)) return `${uid} ${parent} ${command}\n`
  147. throw new Error('process exited')
  148. }
  149. if (program === '/bin/kill' && args[1] === String(pid)) { if (stopped) running = false; return '' }
  150. throw new Error('unexpected command')
  151. })
  152. const launch = vi.fn(async (program: string, args: string[], directory: string) => { pid = 2043; parent = 500; jobs = '500\t0\tcom.samien.hermes-yaoyao\n'; running = true; command = [program, ...args].join(' '); cwd = directory; return pid })
  153. const healthy = vi.fn(async () => true)
  154. const controller = new ServerDashboardController(origin, undefined, { platform: 'darwin', uid: 501, run, launch, healthy, readyTimeoutMs: 15, pollIntervalMs: 1 })
  155. return { controller, run, launch, healthy, set: (value: { uid?: number; command?: string; jobs?: string; stopped?: boolean; parent?: number; cwd?: string }) => {
  156. if (value.uid !== undefined) uid = value.uid
  157. if (value.command !== undefined) command = value.command
  158. if (value.jobs !== undefined) jobs = value.jobs
  159. if (value.stopped !== undefined) stopped = value.stopped
  160. if (value.parent !== undefined) parent = value.parent
  161. if (value.cwd !== undefined) cwd = value.cwd
  162. } }
  163. }
  164. it('restarts a verified terminal-launched Hermes listener while preserving its entry point and binding', async () => {
  165. const f = standaloneFixture()
  166. await f.controller.refresh(); expect(f.controller.canRestart).toBe(true)
  167. expect(f.launch).not.toHaveBeenCalled()
  168. await f.controller.restart()
  169. expect(f.run).toHaveBeenCalledWith('/bin/kill', ['-TERM', '1043'])
  170. expect(f.launch).toHaveBeenCalledWith('/server/venv/bin/python3', ['-m', 'hermes_cli.main', 'dashboard', '--host', '0.0.0.0', '--port', '9119', '--no-open'], '/server/hermes-agent')
  171. expect(f.healthy).toHaveBeenCalledWith(origin.origin)
  172. await f.controller.refresh(); expect(f.controller.canRestart).toBe(true)
  173. })
  174. it.each([
  175. { uid: 502 }, { command: '/usr/bin/node /server/dashboard.js' },
  176. { command: '/bin/sh -c /server/hermes dashboard' },
  177. { command: '/server/hermes dashboard --ssh-session-token-file /private/token' },
  178. { command: '/server/hermes dashboard --port 9120' },
  179. { jobs: '1043\t0\tlocal.unknown.service\n' },
  180. { jobs: '99\t0\tlocal.wrapper.service\n' }, { parent: 444 }, { cwd: '' },
  181. ])('refuses to stop an unverified standalone listener: %j', async state => {
  182. const f = standaloneFixture(); f.set(state)
  183. await f.controller.refresh(); expect(f.controller.canRestart).toBe(false)
  184. await expect(f.controller.restart()).rejects.toThrow('未识别到')
  185. expect(f.launch).not.toHaveBeenCalled(); expect(f.run.mock.calls.some(([program]) => program === '/bin/kill')).toBe(false)
  186. })
  187. it('rechecks a standalone process owner immediately before the restart', async () => {
  188. const f = standaloneFixture(); await f.controller.refresh(); expect(f.controller.canRestart).toBe(true)
  189. f.set({ uid: 502 }); await expect(f.controller.restart()).rejects.toThrow('未识别到')
  190. expect(f.launch).not.toHaveBeenCalled(); expect(f.run.mock.calls.some(([program]) => program === '/bin/kill')).toBe(false)
  191. })
  192. it('does not launch a competitor if the original standalone process ignores termination', async () => {
  193. const f = standaloneFixture(); f.set({ stopped: false })
  194. await expect(f.controller.restart()).rejects.toThrow('未能停止')
  195. expect(f.launch).not.toHaveBeenCalled(); expect(f.controller.canRestart).toBe(false)
  196. })