mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-11 11:10:21 +00:00
1161 lines
40 KiB
TypeScript
1161 lines
40 KiB
TypeScript
import { EventEmitter } from 'events'
|
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
import { createServer, type Server } from 'net'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
type MockManagedChild = EventEmitter & {
|
|
pid: number
|
|
killed: boolean
|
|
kill: ReturnType<typeof vi.fn>
|
|
unref: ReturnType<typeof vi.fn>
|
|
}
|
|
|
|
function createMockManagedChild(pid: number): MockManagedChild {
|
|
const child = new EventEmitter() as MockManagedChild
|
|
child.pid = pid
|
|
child.killed = false
|
|
child.kill = vi.fn((signal: string) => {
|
|
child.killed = signal === 'SIGTERM' || signal === 'SIGKILL'
|
|
return true
|
|
})
|
|
child.unref = vi.fn()
|
|
return child
|
|
}
|
|
|
|
describe('agent bridge manager command resolution', () => {
|
|
const originalEnv = { ...process.env }
|
|
let tempDir = ''
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
tempDir = mkdtempSync(join(tmpdir(), 'hermes-agent-bridge-manager-'))
|
|
process.env = { ...originalEnv }
|
|
delete process.env.HERMES_AGENT_ROOT
|
|
delete process.env.HERMES_AGENT_BRIDGE_PYTHON
|
|
delete process.env.HERMES_AGENT_BRIDGE_UV
|
|
delete process.env.UV
|
|
})
|
|
|
|
afterEach(() => {
|
|
process.env = { ...originalEnv }
|
|
if (tempDir) rmSync(tempDir, { recursive: true, force: true })
|
|
})
|
|
|
|
it('uses the installed hermes command Python when no source root exists', async () => {
|
|
const binDir = join(tempDir, 'bin')
|
|
const homeDir = join(tempDir, 'home')
|
|
const fakePython = join(binDir, 'python')
|
|
const fakeHermes = join(binDir, 'hermes')
|
|
mkdirSync(binDir, { recursive: true })
|
|
mkdirSync(homeDir, { recursive: true })
|
|
writeFileSync(fakePython, '#!/bin/sh\n')
|
|
chmodSync(fakePython, 0o755)
|
|
writeFileSync(fakeHermes, `#!${fakePython}\n`)
|
|
chmodSync(fakeHermes, 0o755)
|
|
process.env.HERMES_HOME = homeDir
|
|
process.env.HERMES_BIN = fakeHermes
|
|
|
|
const { resolveAgentBridgeCommand } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const command = resolveAgentBridgeCommand()
|
|
|
|
expect(command).toEqual({
|
|
command: fakePython,
|
|
argsPrefix: [],
|
|
agentRoot: undefined,
|
|
hermesHome: homeDir,
|
|
})
|
|
})
|
|
|
|
it('discovers hermes-agent from a global lib install next to the hermes command', async () => {
|
|
const installDir = join(tempDir, 'usr', 'local')
|
|
const binDir = join(installDir, 'bin')
|
|
const agentRoot = join(installDir, 'lib', 'hermes-agent')
|
|
const fakePython = join(binDir, 'python')
|
|
const fakeHermes = join(binDir, 'hermes')
|
|
const homeDir = join(tempDir, 'home')
|
|
mkdirSync(binDir, { recursive: true })
|
|
mkdirSync(agentRoot, { recursive: true })
|
|
mkdirSync(homeDir, { recursive: true })
|
|
writeFileSync(join(agentRoot, 'run_agent.py'), '')
|
|
writeFileSync(fakePython, '#!/bin/sh\n')
|
|
chmodSync(fakePython, 0o755)
|
|
writeFileSync(fakeHermes, `#!${fakePython}\n`)
|
|
chmodSync(fakeHermes, 0o755)
|
|
process.env.HERMES_HOME = homeDir
|
|
process.env.HERMES_BIN = fakeHermes
|
|
|
|
const { resolveAgentBridgeCommand } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const command = resolveAgentBridgeCommand()
|
|
|
|
expect(command.agentRoot).toBe(agentRoot)
|
|
})
|
|
|
|
it('falls back to system Python instead of uv when no source root exists', async () => {
|
|
const homeDir = join(tempDir, 'home')
|
|
const fakePython = join(tempDir, 'python3')
|
|
mkdirSync(homeDir, { recursive: true })
|
|
writeFileSync(fakePython, '#!/bin/sh\n')
|
|
chmodSync(fakePython, 0o755)
|
|
process.env.HERMES_HOME = homeDir
|
|
process.env.HERMES_BIN = join(tempDir, 'missing-hermes')
|
|
process.env.PYTHON = fakePython
|
|
|
|
const { resolveAgentBridgeCommand } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const command = resolveAgentBridgeCommand()
|
|
|
|
expect(command).toEqual({
|
|
command: fakePython,
|
|
argsPrefix: [],
|
|
agentRoot: undefined,
|
|
hermesHome: homeDir,
|
|
})
|
|
})
|
|
|
|
it('injects Web UI OpenRouter attribution into the bridge process env by default', async () => {
|
|
const { buildAgentBridgeProcessEnv } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const env = buildAgentBridgeProcessEnv('ipc:///tmp/test.sock', '/tmp/hermes-home', '/tmp/hermes-agent')
|
|
|
|
expect(env.HERMES_OPENROUTER_APP_REFERER).toBe('https://hermes-studio.ai')
|
|
expect(env.HERMES_OPENROUTER_APP_TITLE).toBe('Hermes Studio')
|
|
expect(env.HERMES_OPENROUTER_APP_CATEGORIES).toBe('cli-agent,personal-agent')
|
|
})
|
|
|
|
it('keeps explicit OpenRouter attribution env values when starting the bridge', async () => {
|
|
process.env.HERMES_OPENROUTER_APP_REFERER = 'https://example.invalid/app'
|
|
process.env.HERMES_OPENROUTER_APP_TITLE = 'Custom App'
|
|
process.env.HERMES_OPENROUTER_APP_CATEGORIES = 'custom-category'
|
|
|
|
const { buildAgentBridgeProcessEnv } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const env = buildAgentBridgeProcessEnv('ipc:///tmp/test.sock', '/tmp/hermes-home', undefined)
|
|
|
|
expect(env.HERMES_OPENROUTER_APP_REFERER).toBe('https://example.invalid/app')
|
|
expect(env.HERMES_OPENROUTER_APP_TITLE).toBe('Custom App')
|
|
expect(env.HERMES_OPENROUTER_APP_CATEGORIES).toBe('custom-category')
|
|
})
|
|
|
|
it('removes inherited Anthropic auth token from the bridge process env', async () => {
|
|
process.env.ANTHROPIC_AUTH_TOKEN = 'stale-bearer-token'
|
|
|
|
const { buildAgentBridgeProcessEnv } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const env = buildAgentBridgeProcessEnv('ipc:///tmp/test.sock', '/tmp/hermes-home', undefined)
|
|
|
|
expect(env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN')
|
|
})
|
|
|
|
it('uses an isolated default bridge endpoint while running under Vitest', async () => {
|
|
const { DEFAULT_AGENT_BRIDGE_ENDPOINT } = await import('../../packages/server/src/services/hermes/agent-bridge/client')
|
|
|
|
expect(DEFAULT_AGENT_BRIDGE_ENDPOINT).toContain(`hermes-agent-bridge-test-${process.pid}`)
|
|
expect(DEFAULT_AGENT_BRIDGE_ENDPOINT).not.toBe('ipc:///tmp/hermes-agent-bridge.sock')
|
|
})
|
|
|
|
it('honors the bridge connect retry environment override', async () => {
|
|
process.env.HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS = '120000'
|
|
|
|
const { AgentBridgeClient } = await import('../../packages/server/src/services/hermes/agent-bridge/client')
|
|
const client = new AgentBridgeClient({ endpoint: 'tcp://127.0.0.1:1' })
|
|
|
|
expect(client.connectRetryMs).toBe(120000)
|
|
})
|
|
|
|
it('waits briefly for a restarting bridge socket before failing', async () => {
|
|
const endpoint = `tcp://127.0.0.1:${32000 + (process.pid % 10000)}`
|
|
let server: Server | undefined
|
|
|
|
const ready = new Promise<void>((resolve) => {
|
|
setTimeout(() => {
|
|
server = createServer((socket) => {
|
|
socket.once('data', () => {
|
|
socket.end(`${JSON.stringify({ ok: true, pong: true })}\n`)
|
|
})
|
|
})
|
|
if (endpoint.startsWith('ipc://')) {
|
|
server.listen(endpoint.slice('ipc://'.length), resolve)
|
|
} else {
|
|
const url = new URL(endpoint)
|
|
server.listen(Number(url.port), url.hostname, resolve)
|
|
}
|
|
}, 150)
|
|
})
|
|
|
|
try {
|
|
const { AgentBridgeClient } = await import('../../packages/server/src/services/hermes/agent-bridge/client')
|
|
const client = new AgentBridgeClient({ endpoint, connectRetryMs: 1000, timeoutMs: 1000 })
|
|
await expect(client.ping()).resolves.toMatchObject({ ok: true, pong: true })
|
|
await ready
|
|
} finally {
|
|
await new Promise<void>((resolve) => server?.close(() => resolve()) ?? resolve())
|
|
}
|
|
})
|
|
|
|
it('reports readiness when a fake TCP server answers ping with pong', async () => {
|
|
const endpoint = `tcp://127.0.0.1:${33000 + (process.pid % 10000)}`
|
|
const server = createServer((socket) => {
|
|
socket.once('data', () => {
|
|
socket.end(`${JSON.stringify({ ok: true, pong: true })}\n`)
|
|
})
|
|
})
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const url = new URL(endpoint)
|
|
server.listen(Number(url.port), url.hostname, resolve)
|
|
})
|
|
|
|
try {
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager({ endpoint })
|
|
|
|
await expect(manager.checkReadiness({ timeoutMs: 250, connectRetryMs: 0 })).resolves.toMatchObject({
|
|
endpoint,
|
|
endpointKind: 'tcp',
|
|
status: 'ready',
|
|
reachable: true,
|
|
ready: true,
|
|
running: true,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
})
|
|
} finally {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
}
|
|
})
|
|
|
|
it('reports unreachable instead of throwing when endpoint is missing', async () => {
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager()
|
|
manager.endpoint = ''
|
|
|
|
await expect(manager.checkReadiness()).resolves.toMatchObject({
|
|
endpoint: '',
|
|
endpointKind: 'unknown',
|
|
status: 'unreachable',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
error: 'agent bridge endpoint is not configured',
|
|
})
|
|
})
|
|
|
|
it('reports starting readiness without pinging the bridge', async () => {
|
|
const { AgentBridgeClient } = await import('../../packages/server/src/services/hermes/agent-bridge/client')
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const pingSpy = vi.spyOn(AgentBridgeClient.prototype, 'ping')
|
|
const manager = new AgentBridgeManager({ endpoint: 'tcp://127.0.0.1:6553' })
|
|
|
|
;(manager as any).starting = Promise.resolve()
|
|
|
|
await expect(manager.checkReadiness()).resolves.toMatchObject({
|
|
endpoint: 'tcp://127.0.0.1:6553',
|
|
endpointKind: 'tcp',
|
|
status: 'starting',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: true,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
})
|
|
expect(pingSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('reports stopping readiness without pinging the bridge', async () => {
|
|
const { AgentBridgeClient } = await import('../../packages/server/src/services/hermes/agent-bridge/client')
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const pingSpy = vi.spyOn(AgentBridgeClient.prototype, 'ping')
|
|
const manager = new AgentBridgeManager({ endpoint: 'tcp://127.0.0.1:6554' })
|
|
|
|
;(manager as any).attached = true
|
|
;(manager as any).ready = true
|
|
;(manager as any).stopping = true
|
|
|
|
await expect(manager.checkReadiness()).resolves.toMatchObject({
|
|
endpoint: 'tcp://127.0.0.1:6554',
|
|
endpointKind: 'tcp',
|
|
status: 'stopping',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: true,
|
|
starting: false,
|
|
stopping: true,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
})
|
|
expect(pingSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('reports restarting readiness without pinging the bridge', async () => {
|
|
const { AgentBridgeClient } = await import('../../packages/server/src/services/hermes/agent-bridge/client')
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const pingSpy = vi.spyOn(AgentBridgeClient.prototype, 'ping')
|
|
const manager = new AgentBridgeManager({ endpoint: 'tcp://127.0.0.1:6555' })
|
|
|
|
;(manager as any).restartAttempts = 2
|
|
;(manager as any).restartTimer = setTimeout(() => undefined, 1000)
|
|
|
|
try {
|
|
await expect(manager.checkReadiness()).resolves.toMatchObject({
|
|
endpoint: 'tcp://127.0.0.1:6555',
|
|
endpointKind: 'tcp',
|
|
status: 'restarting',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: true,
|
|
restartAttempts: 2,
|
|
})
|
|
} finally {
|
|
clearTimeout((manager as any).restartTimer)
|
|
;(manager as any).restartTimer = null
|
|
}
|
|
|
|
expect(pingSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('attaches to an already running bridge instead of spawning a replacement', async () => {
|
|
const endpoint = `tcp://127.0.0.1:${34000 + (process.pid % 10000)}`
|
|
const actions: string[] = []
|
|
const server = createServer((socket) => {
|
|
socket.once('data', (chunk) => {
|
|
const request = JSON.parse(chunk.toString('utf8').trim())
|
|
actions.push(request.action)
|
|
socket.end(`${JSON.stringify({ ok: true, pong: request.action === 'ping' })}\n`)
|
|
})
|
|
})
|
|
|
|
await new Promise<void>((resolve) => {
|
|
if (endpoint.startsWith('ipc://')) {
|
|
server.listen(endpoint.slice('ipc://'.length), resolve)
|
|
} else {
|
|
const url = new URL(endpoint)
|
|
server.listen(Number(url.port), url.hostname, resolve)
|
|
}
|
|
})
|
|
|
|
try {
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager({ endpoint, startupTimeoutMs: 100 })
|
|
|
|
await manager.start()
|
|
|
|
expect(actions).toEqual(['ping'])
|
|
expect(manager.getRuntimeState()).toMatchObject({
|
|
endpoint,
|
|
ready: true,
|
|
running: true,
|
|
attached: true,
|
|
pid: undefined,
|
|
})
|
|
await manager.stop()
|
|
expect(actions).toEqual(['ping', 'shutdown'])
|
|
expect(manager.getRuntimeState()).toMatchObject({
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
})
|
|
} finally {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
}
|
|
})
|
|
|
|
it('requests shutdown when stopping an attached bridge', async () => {
|
|
const endpoint = `tcp://127.0.0.1:${35000 + (process.pid % 10000)}`
|
|
const actions: string[] = []
|
|
const server = createServer((socket) => {
|
|
socket.once('data', (chunk) => {
|
|
const request = JSON.parse(chunk.toString('utf8').trim())
|
|
actions.push(request.action)
|
|
socket.end(`${JSON.stringify({ ok: true, pong: request.action === 'ping' })}\n`)
|
|
})
|
|
})
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const url = new URL(endpoint)
|
|
server.listen(Number(url.port), url.hostname, resolve)
|
|
})
|
|
|
|
try {
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager({ endpoint, startupTimeoutMs: 100 })
|
|
|
|
await manager.start()
|
|
await manager.stop()
|
|
|
|
expect(actions).toEqual(['ping', 'shutdown'])
|
|
expect(manager.getRuntimeState()).toMatchObject({
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
})
|
|
} finally {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
}
|
|
})
|
|
|
|
it('clears stopping after stop completes for an attached bridge', async () => {
|
|
const endpoint = `tcp://127.0.0.1:${36000 + (process.pid % 10000)}`
|
|
const actions: string[] = []
|
|
const server = createServer((socket) => {
|
|
socket.once('data', (chunk) => {
|
|
const request = JSON.parse(chunk.toString('utf8').trim())
|
|
actions.push(request.action)
|
|
socket.end(`${JSON.stringify({ ok: true, pong: request.action === 'ping' })}\n`, () => {
|
|
if (request.action === 'shutdown') {
|
|
server.close()
|
|
}
|
|
})
|
|
})
|
|
})
|
|
const serverClosed = new Promise<void>((resolve) => server.once('close', () => resolve()))
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const url = new URL(endpoint)
|
|
server.listen(Number(url.port), url.hostname, resolve)
|
|
})
|
|
|
|
try {
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager({ endpoint, startupTimeoutMs: 100 })
|
|
|
|
await manager.start()
|
|
await manager.stop()
|
|
await serverClosed
|
|
|
|
expect(actions).toEqual(['ping', 'shutdown'])
|
|
expect(manager.getRuntimeState()).toMatchObject({
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
stopping: false,
|
|
})
|
|
await expect(manager.checkReadiness({ timeoutMs: 250, connectRetryMs: 0 })).resolves.toMatchObject({
|
|
endpoint,
|
|
endpointKind: 'tcp',
|
|
status: 'unreachable',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
})
|
|
} finally {
|
|
if (server.listening) {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
}
|
|
}
|
|
})
|
|
|
|
it('returns unreachable without attempting recovery when recover is false', async () => {
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager({ endpoint: 'tcp://127.0.0.1:6556' })
|
|
const readiness = {
|
|
endpoint: 'tcp://127.0.0.1:6556',
|
|
endpointKind: 'tcp' as const,
|
|
status: 'unreachable' as const,
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
error: 'connect ECONNREFUSED',
|
|
}
|
|
const checkReadinessSpy = vi.spyOn(manager, 'checkReadiness').mockResolvedValue(readiness)
|
|
const startSpy = vi.spyOn(manager, 'start').mockResolvedValue()
|
|
|
|
await expect(manager.ensureReady({ recover: false })).resolves.toEqual(readiness)
|
|
expect(checkReadinessSpy).toHaveBeenCalledTimes(1)
|
|
expect(startSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns immediately when the bridge is already reachable', async () => {
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager({ endpoint: 'tcp://127.0.0.1:6560' })
|
|
const readiness = {
|
|
endpoint: 'tcp://127.0.0.1:6560',
|
|
endpointKind: 'tcp' as const,
|
|
status: 'ready' as const,
|
|
reachable: true,
|
|
ready: true,
|
|
running: true,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
}
|
|
const checkReadinessSpy = vi.spyOn(manager, 'checkReadiness').mockResolvedValue(readiness)
|
|
const startSpy = vi.spyOn(manager, 'start').mockResolvedValue()
|
|
|
|
await expect(manager.ensureReady()).resolves.toEqual(readiness)
|
|
expect(checkReadinessSpy).toHaveBeenCalledTimes(1)
|
|
expect(startSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('waits for an in-flight start before re-checking readiness', async () => {
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager({ endpoint: 'tcp://127.0.0.1:6561' })
|
|
let resolveStarting: (() => void) | undefined
|
|
;(manager as any).starting = new Promise<void>((resolve) => {
|
|
resolveStarting = resolve
|
|
})
|
|
|
|
const checkReadinessSpy = vi.spyOn(manager, 'checkReadiness')
|
|
.mockResolvedValueOnce({
|
|
endpoint: 'tcp://127.0.0.1:6561',
|
|
endpointKind: 'tcp',
|
|
status: 'starting',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: true,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
endpoint: 'tcp://127.0.0.1:6561',
|
|
endpointKind: 'tcp',
|
|
status: 'ready',
|
|
reachable: true,
|
|
ready: true,
|
|
running: true,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
})
|
|
const startSpy = vi.spyOn(manager, 'start').mockResolvedValue()
|
|
|
|
const ensureReadyPromise = manager.ensureReady()
|
|
await Promise.resolve()
|
|
|
|
expect(checkReadinessSpy).toHaveBeenCalledTimes(1)
|
|
expect(startSpy).not.toHaveBeenCalled()
|
|
|
|
resolveStarting?.()
|
|
|
|
await expect(ensureReadyPromise).resolves.toMatchObject({
|
|
endpoint: 'tcp://127.0.0.1:6561',
|
|
status: 'ready',
|
|
reachable: true,
|
|
})
|
|
expect(checkReadinessSpy).toHaveBeenCalledTimes(2)
|
|
expect(startSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('bounds an in-flight start by caller timeout', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager({ endpoint: 'tcp://127.0.0.1:6565' })
|
|
;(manager as any).starting = new Promise<void>(() => {})
|
|
const checkReadinessSpy = vi.spyOn(manager, 'checkReadiness')
|
|
.mockResolvedValueOnce({
|
|
endpoint: 'tcp://127.0.0.1:6565',
|
|
endpointKind: 'tcp',
|
|
status: 'starting',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: true,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
})
|
|
|
|
const ensureReadyPromise = manager.ensureReady({ timeoutMs: 25 })
|
|
await vi.advanceTimersByTimeAsync(25)
|
|
|
|
await expect(ensureReadyPromise).resolves.toMatchObject({
|
|
endpoint: 'tcp://127.0.0.1:6565',
|
|
status: 'starting',
|
|
reachable: false,
|
|
})
|
|
expect(checkReadinessSpy).toHaveBeenCalledTimes(1)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('coalesces callers onto an existing managed recovery', async () => {
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager({ endpoint: 'tcp://127.0.0.1:6566' })
|
|
const child = createMockManagedChild(45666)
|
|
;(manager as any).child = child
|
|
;(manager as any).ready = true
|
|
;(manager as any).attached = false
|
|
|
|
const unreachable = {
|
|
endpoint: 'tcp://127.0.0.1:6566',
|
|
endpointKind: 'tcp' as const,
|
|
status: 'unreachable' as const,
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
pid: 45666,
|
|
error: 'connect ECONNREFUSED',
|
|
}
|
|
const recovered = {
|
|
...unreachable,
|
|
status: 'ready' as const,
|
|
reachable: true,
|
|
ready: true,
|
|
running: true,
|
|
error: undefined,
|
|
}
|
|
const checkReadinessSpy = vi.spyOn(manager, 'checkReadiness').mockResolvedValue(unreachable)
|
|
let resolveRecovery: ((value: typeof recovered) => void) | undefined
|
|
const recoveryDeferred = new Promise<typeof recovered>((resolve) => {
|
|
resolveRecovery = resolve
|
|
})
|
|
const performRecoverySpy = vi.spyOn(manager as any, 'performManagedRecovery').mockReturnValue(recoveryDeferred)
|
|
|
|
const first = manager.ensureReady()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
const second = manager.ensureReady()
|
|
await Promise.resolve()
|
|
resolveRecovery?.(recovered)
|
|
|
|
await expect(first).resolves.toBe(recovered)
|
|
await expect(second).resolves.toBe(recovered)
|
|
expect(checkReadinessSpy).toHaveBeenCalledTimes(2)
|
|
expect(performRecoverySpy).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not run destructive managed recovery on legacy global default endpoints', async () => {
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager({ endpoint: 'ipc:///tmp/hermes-agent-bridge.sock' })
|
|
const child = createMockManagedChild(45667)
|
|
;(manager as any).child = child
|
|
;(manager as any).ready = true
|
|
;(manager as any).attached = false
|
|
|
|
vi.spyOn(manager, 'checkReadiness').mockResolvedValue({
|
|
endpoint: 'ipc:///tmp/hermes-agent-bridge.sock',
|
|
endpointKind: 'ipc',
|
|
status: 'unreachable',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
pid: 45667,
|
|
error: 'connect ENOENT /tmp/hermes-agent-bridge.sock',
|
|
})
|
|
const performRecoverySpy = vi.spyOn(manager as any, 'performManagedRecovery')
|
|
const startSpy = vi.spyOn(manager, 'start').mockResolvedValue()
|
|
|
|
await expect(manager.ensureReady()).resolves.toMatchObject({
|
|
endpoint: 'ipc:///tmp/hermes-agent-bridge.sock',
|
|
status: 'unreachable',
|
|
reachable: false,
|
|
error: expect.stringContaining('merge endpoint scoping before enabling recovery'),
|
|
})
|
|
expect(child.kill).not.toHaveBeenCalled()
|
|
expect(performRecoverySpy).not.toHaveBeenCalled()
|
|
expect(startSpy).not.toHaveBeenCalled()
|
|
expect((manager as any).child).toBe(child)
|
|
})
|
|
|
|
it('waits for the old managed child to exit before starting replacement recovery', async () => {
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager({ endpoint: 'tcp://127.0.0.1:6557' })
|
|
const child = createMockManagedChild(12345)
|
|
|
|
;(manager as any).child = child
|
|
;(manager as any).ready = true
|
|
;(manager as any).attached = false
|
|
|
|
const checkReadinessSpy = vi.spyOn(manager as any, 'checkReadinessInternal')
|
|
.mockResolvedValueOnce({
|
|
endpoint: 'tcp://127.0.0.1:6557',
|
|
endpointKind: 'tcp',
|
|
status: 'unreachable',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
pid: 12345,
|
|
error: 'connect ECONNREFUSED',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
endpoint: 'tcp://127.0.0.1:6557',
|
|
endpointKind: 'tcp',
|
|
status: 'ready',
|
|
reachable: true,
|
|
ready: true,
|
|
running: true,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
})
|
|
const startSpy = vi.spyOn(manager, 'start').mockResolvedValue()
|
|
|
|
const ensureReadyPromise = manager.ensureReady()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
|
|
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
|
|
expect(startSpy).not.toHaveBeenCalled()
|
|
|
|
child.emit('exit', 0, 'SIGTERM')
|
|
|
|
await expect(ensureReadyPromise).resolves.toMatchObject({
|
|
endpoint: 'tcp://127.0.0.1:6557',
|
|
status: 'ready',
|
|
reachable: true,
|
|
})
|
|
expect(startSpy).toHaveBeenCalledTimes(1)
|
|
expect(checkReadinessSpy).toHaveBeenCalledTimes(2)
|
|
expect((manager as any).child).toBeNull()
|
|
})
|
|
|
|
it('does not restart managed recovery after an explicit stop wins the race', async () => {
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager({ endpoint: 'tcp://127.0.0.1:6564' })
|
|
const child = createMockManagedChild(12347)
|
|
|
|
;(manager as any).child = child
|
|
;(manager as any).ready = true
|
|
;(manager as any).attached = false
|
|
|
|
const checkReadinessSpy = vi.spyOn(manager as any, 'checkReadinessInternal')
|
|
.mockResolvedValueOnce({
|
|
endpoint: 'tcp://127.0.0.1:6564',
|
|
endpointKind: 'tcp',
|
|
status: 'unreachable',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
pid: 12347,
|
|
error: 'connect ECONNREFUSED',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
endpoint: 'tcp://127.0.0.1:6564',
|
|
endpointKind: 'tcp',
|
|
status: 'unreachable',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
error: 'connect ECONNREFUSED',
|
|
})
|
|
const startSpy = vi.spyOn(manager, 'start').mockResolvedValue()
|
|
|
|
const ensureReadyPromise = manager.ensureReady()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
|
|
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
|
|
expect(startSpy).not.toHaveBeenCalled()
|
|
|
|
const stopPromise = manager.stop()
|
|
await Promise.resolve()
|
|
child.emit('exit', 0, 'SIGTERM')
|
|
await stopPromise
|
|
|
|
await expect(ensureReadyPromise).resolves.toMatchObject({
|
|
endpoint: 'tcp://127.0.0.1:6564',
|
|
status: 'unreachable',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
})
|
|
expect(startSpy).not.toHaveBeenCalled()
|
|
expect(checkReadinessSpy).toHaveBeenCalledTimes(2)
|
|
expect(checkReadinessSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({ connectRetryMs: 0 }), true)
|
|
expect((manager as any).child).toBeNull()
|
|
expect(manager.getRuntimeState()).toMatchObject({
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
stopping: false,
|
|
})
|
|
})
|
|
|
|
it('bounds managed-child recovery by escalating to SIGKILL before starting replacement', async () => {
|
|
vi.useFakeTimers()
|
|
|
|
try {
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager({ endpoint: 'tcp://127.0.0.1:6563' })
|
|
const child = createMockManagedChild(12346)
|
|
|
|
;(manager as any).child = child
|
|
;(manager as any).ready = true
|
|
;(manager as any).attached = false
|
|
|
|
const checkReadinessSpy = vi.spyOn(manager as any, 'checkReadinessInternal')
|
|
.mockResolvedValueOnce({
|
|
endpoint: 'tcp://127.0.0.1:6563',
|
|
endpointKind: 'tcp',
|
|
status: 'unreachable',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
pid: 12346,
|
|
error: 'connect ECONNREFUSED',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
endpoint: 'tcp://127.0.0.1:6563',
|
|
endpointKind: 'tcp',
|
|
status: 'unreachable',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
pid: 12346,
|
|
error: 'connect ECONNREFUSED',
|
|
})
|
|
const startSpy = vi.spyOn(manager, 'start').mockResolvedValue()
|
|
|
|
const ensureReadyPromise = manager.ensureReady()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
|
|
expect(child.kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
|
|
expect(startSpy).not.toHaveBeenCalled()
|
|
|
|
await vi.advanceTimersByTimeAsync(5000)
|
|
expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
|
|
expect(startSpy).not.toHaveBeenCalled()
|
|
|
|
await vi.advanceTimersByTimeAsync(250)
|
|
|
|
await expect(ensureReadyPromise).resolves.toMatchObject({
|
|
endpoint: 'tcp://127.0.0.1:6563',
|
|
status: 'unreachable',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
error: expect.stringContaining('did not exit after SIGTERM/SIGKILL'),
|
|
})
|
|
expect(startSpy).not.toHaveBeenCalled()
|
|
expect(checkReadinessSpy).toHaveBeenCalledTimes(2)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('ignores the exiting stale managed child while delaying replacement until exit', async () => {
|
|
const oldChild = createMockManagedChild(45671)
|
|
const replacementChild = createMockManagedChild(45672)
|
|
const spawnMock = vi.fn()
|
|
.mockReturnValueOnce(oldChild)
|
|
.mockReturnValueOnce(replacementChild)
|
|
|
|
vi.doMock('child_process', async () => {
|
|
const actual = await vi.importActual<typeof import('child_process')>('child_process')
|
|
return {
|
|
...actual,
|
|
spawn: spawnMock,
|
|
}
|
|
})
|
|
|
|
const { AgentBridgeClient } = await import('../../packages/server/src/services/hermes/agent-bridge/client')
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const pingSpy = vi.spyOn(AgentBridgeClient.prototype, 'ping')
|
|
let pingCalls = 0
|
|
pingSpy.mockImplementation(async () => {
|
|
pingCalls += 1
|
|
if (pingCalls === 1 || pingCalls === 3) {
|
|
throw new Error('bridge offline')
|
|
}
|
|
return { ok: true, pong: true } as any
|
|
})
|
|
|
|
const manager = new AgentBridgeManager({ endpoint: 'tcp://127.0.0.1:6562', startupTimeoutMs: 100 })
|
|
await manager.start()
|
|
|
|
expect(spawnMock).toHaveBeenCalledTimes(1)
|
|
expect(manager.getRuntimeState()).toMatchObject({
|
|
ready: true,
|
|
running: true,
|
|
attached: false,
|
|
pid: 45671,
|
|
})
|
|
|
|
const checkReadinessSpy = vi.spyOn(manager as any, 'checkReadinessInternal')
|
|
.mockResolvedValueOnce({
|
|
endpoint: 'tcp://127.0.0.1:6562',
|
|
endpointKind: 'tcp',
|
|
status: 'unreachable',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
pid: 45671,
|
|
error: 'connect ECONNREFUSED',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
endpoint: 'tcp://127.0.0.1:6562',
|
|
endpointKind: 'tcp',
|
|
status: 'ready',
|
|
reachable: true,
|
|
ready: true,
|
|
running: true,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
pid: 45672,
|
|
})
|
|
|
|
const ensureReadyPromise = manager.ensureReady()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
|
|
expect(oldChild.kill).toHaveBeenCalledWith('SIGTERM')
|
|
expect(spawnMock).toHaveBeenCalledTimes(1)
|
|
|
|
oldChild.emit('exit', 0, 'SIGTERM')
|
|
|
|
await expect(ensureReadyPromise).resolves.toMatchObject({
|
|
endpoint: 'tcp://127.0.0.1:6562',
|
|
status: 'ready',
|
|
reachable: true,
|
|
pid: 45672,
|
|
})
|
|
expect(checkReadinessSpy).toHaveBeenCalledTimes(2)
|
|
expect(spawnMock).toHaveBeenCalledTimes(2)
|
|
expect((manager as any).child).toBe(replacementChild)
|
|
expect(manager.getRuntimeState()).toMatchObject({
|
|
ready: true,
|
|
running: true,
|
|
attached: false,
|
|
pid: 45672,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
})
|
|
})
|
|
|
|
it('returns follow-up reachable readiness when restart fails after the bridge comes up', async () => {
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager({ endpoint: 'tcp://127.0.0.1:6558' })
|
|
const child = createMockManagedChild(23456)
|
|
const recoveredReadiness = {
|
|
endpoint: 'tcp://127.0.0.1:6558',
|
|
endpointKind: 'tcp' as const,
|
|
status: 'ready' as const,
|
|
reachable: true,
|
|
ready: true,
|
|
running: true,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
pid: 23456,
|
|
}
|
|
|
|
;(manager as any).child = child
|
|
;(manager as any).ready = true
|
|
;(manager as any).attached = false
|
|
|
|
const checkReadinessSpy = vi.spyOn(manager as any, 'checkReadinessInternal')
|
|
.mockResolvedValueOnce({
|
|
endpoint: 'tcp://127.0.0.1:6558',
|
|
endpointKind: 'tcp',
|
|
status: 'unreachable',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
pid: 23456,
|
|
error: 'connect ECONNREFUSED',
|
|
})
|
|
.mockResolvedValueOnce(recoveredReadiness)
|
|
const startSpy = vi.spyOn(manager, 'start').mockRejectedValue(new Error('spawn EADDRINUSE'))
|
|
|
|
const ensureReadyPromise = manager.ensureReady()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
child.emit('exit', 0, 'SIGTERM')
|
|
|
|
await expect(ensureReadyPromise).resolves.toEqual(recoveredReadiness)
|
|
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
|
|
expect(startSpy).toHaveBeenCalledTimes(1)
|
|
expect(checkReadinessSpy).toHaveBeenCalledTimes(2)
|
|
expect(checkReadinessSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({ connectRetryMs: 0 }), true)
|
|
expect((manager as any).child).toBeNull()
|
|
})
|
|
|
|
it('keeps follow-up unreachable readiness while adding restart failure context', async () => {
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager({ endpoint: 'tcp://127.0.0.1:6559' })
|
|
const child = createMockManagedChild(34567)
|
|
|
|
;(manager as any).child = child
|
|
;(manager as any).ready = true
|
|
;(manager as any).attached = false
|
|
|
|
const checkReadinessSpy = vi.spyOn(manager as any, 'checkReadinessInternal')
|
|
.mockResolvedValueOnce({
|
|
endpoint: 'tcp://127.0.0.1:6559',
|
|
endpointKind: 'tcp',
|
|
status: 'unreachable',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
pid: 34567,
|
|
error: 'connect ECONNREFUSED',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
endpoint: 'tcp://127.0.0.1:6559',
|
|
endpointKind: 'tcp',
|
|
status: 'starting',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: false,
|
|
starting: true,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 1,
|
|
pid: 34568,
|
|
error: 'bridge ping timed out',
|
|
})
|
|
const startSpy = vi.spyOn(manager, 'start').mockRejectedValue(new Error('spawn ENOENT'))
|
|
|
|
const ensureReadyPromise = manager.ensureReady()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
child.emit('exit', 0, 'SIGTERM')
|
|
|
|
await expect(ensureReadyPromise).resolves.toMatchObject({
|
|
endpoint: 'tcp://127.0.0.1:6559',
|
|
status: 'starting',
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
error: 'bridge ping timed out; start failed: spawn ENOENT',
|
|
})
|
|
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
|
|
expect(startSpy).toHaveBeenCalledTimes(1)
|
|
expect(checkReadinessSpy).toHaveBeenCalledTimes(2)
|
|
expect(checkReadinessSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({ connectRetryMs: 0 }), true)
|
|
expect((manager as any).child).toBeNull()
|
|
})
|
|
|
|
it('preserves attached external bridge state when readiness becomes unreachable', async () => {
|
|
const { AgentBridgeClient } = await import('../../packages/server/src/services/hermes/agent-bridge/client')
|
|
const { AgentBridgeManager } = await import('../../packages/server/src/services/hermes/agent-bridge/manager')
|
|
const manager = new AgentBridgeManager({ endpoint: 'tcp://127.0.0.1:6558' })
|
|
const readiness = {
|
|
endpoint: 'tcp://127.0.0.1:6558',
|
|
endpointKind: 'tcp' as const,
|
|
status: 'unreachable' as const,
|
|
reachable: false,
|
|
ready: false,
|
|
running: false,
|
|
attached: true,
|
|
starting: false,
|
|
stopping: false,
|
|
restartScheduled: false,
|
|
restartAttempts: 0,
|
|
error: 'connect ECONNREFUSED',
|
|
}
|
|
|
|
;(manager as any).child = null
|
|
;(manager as any).attached = true
|
|
;(manager as any).ready = true
|
|
|
|
const checkReadinessSpy = vi.spyOn(manager as any, 'checkReadinessInternal').mockResolvedValue(readiness)
|
|
const startSpy = vi.spyOn(manager, 'start').mockResolvedValue()
|
|
const shutdownSpy = vi.spyOn(AgentBridgeClient.prototype, 'shutdown').mockResolvedValue({ ok: true })
|
|
|
|
await expect(manager.ensureReady()).resolves.toEqual(readiness)
|
|
expect(startSpy).not.toHaveBeenCalled()
|
|
expect(checkReadinessSpy).toHaveBeenCalledTimes(1)
|
|
expect(shutdownSpy).not.toHaveBeenCalled()
|
|
expect((manager as any).child).toBeNull()
|
|
expect(manager.getRuntimeState()).toMatchObject({
|
|
ready: false,
|
|
running: false,
|
|
attached: true,
|
|
pid: undefined,
|
|
})
|
|
|
|
await manager.stop()
|
|
|
|
expect(shutdownSpy).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|