| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214 |
- // @vitest-environment node
- import { expect, it, vi } from 'vitest'
- import { ServerDashboardController } from '../../src/server/dashboardController'
- const origin = new URL('http://127.0.0.1:9119')
- const target = 'gui/501/com.samien.hermes.dashboard.local'
- function fixture(options: { upstream?: URL; platform?: NodeJS.Platform; label?: string; command?: string; owned?: { canRestart: boolean; restart(): Promise<void>; suspendChecks?(): () => void } } = {}) {
- let pid = 1043, listener = 1043, replacement = 2043, available = true, health = true
- const service = options.label ? `gui/501/${options.label}` : target
- const run = vi.fn(async (command: string, args: string[]) => {
- if (command === '/bin/launchctl' && args[0] === 'print') {
- if (!available || args[1] !== service) throw new Error('job not found')
- 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}`
- }
- if (command === '/usr/sbin/lsof') return `p${listener}\n`
- if (command === '/bin/launchctl' && args[0] === 'list') return available ? `${pid}\t0\t${service.split('/').at(-1)}\n` : ''
- if (command === '/bin/launchctl' && args[0] === 'kickstart') { pid = replacement; listener = replacement; return '' }
- throw new Error('unexpected command')
- })
- const healthy = vi.fn(async () => health)
- const controller = new ServerDashboardController(options.upstream ?? origin, options.owned, {
- platform: options.platform ?? 'darwin', uid: 501, run, healthy, readyTimeoutMs: 15, pollIntervalMs: 1,
- })
- const mutations = () => run.mock.calls.filter(([, args]) => args[0] === 'kickstart')
- return { controller, run, healthy, mutations, set: (state: { available?: boolean; listener?: number; replacement?: number; healthy?: boolean }) => {
- if (state.available !== undefined) available = state.available
- if (state.listener !== undefined) listener = state.listener
- if (state.replacement !== undefined) replacement = state.replacement
- if (state.healthy !== undefined) health = state.healthy
- } }
- }
- it('discovers and restarts the server LaunchAgent, then verifies a new listener and HTTP health', async () => {
- const f = fixture()
- expect(f.controller.canRestart).toBe(false)
- await f.controller.refresh()
- expect(f.controller.canRestart).toBe(true)
- expect(f.mutations()).toEqual([])
- await f.controller.restart()
- expect(f.mutations()).toEqual([['/bin/launchctl', ['kickstart', '-k', target]]])
- expect(f.healthy).toHaveBeenCalledWith('http://127.0.0.1:9119')
- expect(f.controller.canRestart).toBe(true)
- })
- it('supports the standard Hermes LaunchAgent only when it owns the configured listener', async () => {
- const f = fixture({ label: 'ai.hermes.dashboard', command: '/server/.local/bin/hermes\n\t\tdashboard' })
- await f.controller.refresh(); await f.controller.restart()
- expect(f.mutations()[0]?.[1]).toEqual(['kickstart', '-k', 'gui/501/ai.hermes.dashboard'])
- })
- it.each([
- '/server/.hermes/venv/bin/python3\n\t\t-m\n\t\thermes_cli.main\n\t\tdashboard',
- '/server/.hermes/venv/bin/python\n\t\t/server/.hermes/hermes-agent/hermes_cli/main.py\n\t\tserve',
- '/server/.local/bin/hermes\n\t\t--profile\n\t\tyaoyao\n\t\tdashboard',
- ])('recognizes supported Hermes entry points under a custom LaunchAgent label: %s', async command => {
- const f = fixture({ label: 'local.custom.hermes', command })
- await f.controller.refresh(); expect(f.controller.canRestart).toBe(true)
- await f.controller.restart()
- expect(f.mutations()[0]?.[1]).toEqual(['kickstart', '-k', 'gui/501/local.custom.hermes'])
- })
- it.each([
- new URL('https://hermes.example'), new URL('http://192.168.1.5:9119'), new URL('http://127.0.0.1:9120'),
- ])('never runs local commands for an upstream on another server or port: %s', async upstream => {
- const owned = { canRestart: true, restart: vi.fn(async () => {}) }
- const f = fixture({ upstream, owned })
- await f.controller.refresh()
- expect(f.controller.canRestart).toBe(false)
- await expect(f.controller.restart()).rejects.toThrow('所在节点')
- expect(f.run).not.toHaveBeenCalled(); expect(owned.restart).not.toHaveBeenCalled()
- })
- it('does not run launchctl on an unsupported server OS', async () => {
- const f = fixture({ platform: 'linux' })
- await f.controller.refresh()
- await expect(f.controller.restart()).rejects.toThrow('尚未支持')
- expect(f.run).not.toHaveBeenCalled()
- })
- it.each(['missing', 'other-listener', 'other-command', 'multiple-listeners'])('refuses an unverified service: %s', async mode => {
- const f = fixture(mode === 'other-command' ? { command: '/usr/bin/node\n\t\tdashboard' } : {})
- if (mode === 'missing') f.set({ available: false })
- if (mode === 'other-listener') f.set({ listener: 999 })
- if (mode === 'multiple-listeners') {
- const original = f.run.getMockImplementation()!
- f.run.mockImplementation(async (command, args) => command === '/usr/sbin/lsof' ? 'p1043\np999\n' : original(command, args))
- }
- await f.controller.refresh()
- expect(f.controller.canRestart).toBe(false)
- await expect(f.controller.restart()).rejects.toThrow('未识别到')
- expect(f.mutations()).toEqual([])
- })
- it('revalidates ownership immediately before restarting, regardless of an earlier available status', async () => {
- const f = fixture()
- await f.controller.refresh(); expect(f.controller.canRestart).toBe(true)
- f.set({ listener: 999 })
- await expect(f.controller.restart()).rejects.toThrow('未识别到')
- expect(f.mutations()).toEqual([])
- expect(f.controller.canRestart).toBe(false)
- })
- it.each(['same-pid', 'unhealthy'])('does not report success until replacement is verified: %s', async mode => {
- const f = fixture()
- f.set(mode === 'same-pid' ? { replacement: 1043 } : { healthy: false })
- await expect(f.controller.restart()).rejects.toThrow('未恢复健康')
- expect(f.controller.canRestart).toBe(false)
- f.set({ replacement: 3043, healthy: true })
- await f.controller.restart()
- expect(f.controller.canRestart).toBe(true)
- })
- it('preserves owned-child restart support and never touches a system service in that case', async () => {
- const owned = { canRestart: true, restart: vi.fn(async () => {}) }
- const f = fixture({ owned })
- await f.controller.refresh(); await f.controller.restart()
- expect(owned.restart).toHaveBeenCalledTimes(1)
- expect(f.run).not.toHaveBeenCalled()
- expect(f.healthy).toHaveBeenCalledWith(origin.origin)
- })
- it.each([true, false])('suspends automatic starts during a system restart and releases them on success=%s', async success => {
- const resume = vi.fn()
- const owned = { canRestart: false, restart: vi.fn(async () => {}), suspendChecks: vi.fn(() => resume) }
- const f = fixture({ owned })
- f.healthy.mockImplementation(async () => {
- expect(owned.suspendChecks).toHaveBeenCalledTimes(1)
- expect(resume).not.toHaveBeenCalled()
- return success
- })
- if (success) await f.controller.restart()
- else await expect(f.controller.restart()).rejects.toThrow('未恢复健康')
- expect(owned.restart).not.toHaveBeenCalled()
- expect(resume).toHaveBeenCalledTimes(1)
- })
- it('serializes restarts and stops verification when the server shuts down', async () => {
- let release!: () => void
- const owned = { canRestart: true, restart: vi.fn(() => new Promise<void>(done => { release = done })) }
- const f = fixture({ owned })
- const pending = f.controller.restart()
- await expect(f.controller.restart()).rejects.toThrow('正在重启')
- f.controller.stop(); release()
- await expect(pending).rejects.toThrow('未恢复健康')
- expect(f.controller.canRestart).toBe(false)
- expect(f.run).not.toHaveBeenCalled()
- })
- function standaloneFixture() {
- let pid = 1043, running = true, uid = 501, parent = 99
- let command = '/server/venv/bin/python3 -m hermes_cli.main dashboard --host 0.0.0.0 --port 9119 --no-open'
- let jobs = '', stopped = true, cwd = '/server/hermes-agent'
- const run = vi.fn(async (program: string, args: string[]) => {
- if (program === '/usr/sbin/lsof') return args.includes('cwd') ? `p${pid}\nfcwd\nn${cwd}\n` : running ? `p${pid}\n` : ''
- if (program === '/bin/launchctl' && args[0] === 'list') return jobs
- if (program === '/bin/launchctl') throw new Error('no managed job')
- if (program === '/bin/ps') {
- if (args[1] === '99') return '501 98 /bin/zsh -l\n'
- if (running && args[1] === String(pid)) return `${uid} ${parent} ${command}\n`
- throw new Error('process exited')
- }
- if (program === '/bin/kill' && args[1] === String(pid)) { if (stopped) running = false; return '' }
- throw new Error('unexpected command')
- })
- 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 })
- const healthy = vi.fn(async () => true)
- const controller = new ServerDashboardController(origin, undefined, { platform: 'darwin', uid: 501, run, launch, healthy, readyTimeoutMs: 15, pollIntervalMs: 1 })
- return { controller, run, launch, healthy, set: (value: { uid?: number; command?: string; jobs?: string; stopped?: boolean; parent?: number; cwd?: string }) => {
- if (value.uid !== undefined) uid = value.uid
- if (value.command !== undefined) command = value.command
- if (value.jobs !== undefined) jobs = value.jobs
- if (value.stopped !== undefined) stopped = value.stopped
- if (value.parent !== undefined) parent = value.parent
- if (value.cwd !== undefined) cwd = value.cwd
- } }
- }
- it('restarts a verified terminal-launched Hermes listener while preserving its entry point and binding', async () => {
- const f = standaloneFixture()
- await f.controller.refresh(); expect(f.controller.canRestart).toBe(true)
- expect(f.launch).not.toHaveBeenCalled()
- await f.controller.restart()
- expect(f.run).toHaveBeenCalledWith('/bin/kill', ['-TERM', '1043'])
- 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')
- expect(f.healthy).toHaveBeenCalledWith(origin.origin)
- await f.controller.refresh(); expect(f.controller.canRestart).toBe(true)
- })
- it.each([
- { uid: 502 }, { command: '/usr/bin/node /server/dashboard.js' },
- { command: '/bin/sh -c /server/hermes dashboard' },
- { command: '/server/hermes dashboard --ssh-session-token-file /private/token' },
- { command: '/server/hermes dashboard --port 9120' },
- { jobs: '1043\t0\tlocal.unknown.service\n' },
- { jobs: '99\t0\tlocal.wrapper.service\n' }, { parent: 444 }, { cwd: '' },
- ])('refuses to stop an unverified standalone listener: %j', async state => {
- const f = standaloneFixture(); f.set(state)
- await f.controller.refresh(); expect(f.controller.canRestart).toBe(false)
- await expect(f.controller.restart()).rejects.toThrow('未识别到')
- expect(f.launch).not.toHaveBeenCalled(); expect(f.run.mock.calls.some(([program]) => program === '/bin/kill')).toBe(false)
- })
- it('rechecks a standalone process owner immediately before the restart', async () => {
- const f = standaloneFixture(); await f.controller.refresh(); expect(f.controller.canRestart).toBe(true)
- f.set({ uid: 502 }); await expect(f.controller.restart()).rejects.toThrow('未识别到')
- expect(f.launch).not.toHaveBeenCalled(); expect(f.run.mock.calls.some(([program]) => program === '/bin/kill')).toBe(false)
- })
- it('does not launch a competitor if the original standalone process ignores termination', async () => {
- const f = standaloneFixture(); f.set({ stopped: false })
- await expect(f.controller.restart()).rejects.toThrow('未能停止')
- expect(f.launch).not.toHaveBeenCalled(); expect(f.controller.canRestart).toBe(false)
- })
|