mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
279 lines
8.4 KiB
TypeScript
279 lines
8.4 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { createServer, type Server, type Socket } from 'net'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
import { mkdtempSync, writeFileSync, rmSync } from 'fs'
|
|
import { DaemonClient } from './client'
|
|
import { encodeNdjson } from './ndjson'
|
|
import type { HelloMessage, DaemonRequest, DaemonEvent } from './types'
|
|
|
|
function createTestDir(): string {
|
|
return mkdtempSync(join(tmpdir(), 'daemon-client-test-'))
|
|
}
|
|
|
|
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
|
const start = Date.now()
|
|
while (!predicate()) {
|
|
if (Date.now() - start > timeoutMs) {
|
|
throw new Error('waitFor timed out')
|
|
}
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
}
|
|
}
|
|
|
|
describe('DaemonClient', () => {
|
|
let dir: string
|
|
let socketPath: string
|
|
let tokenPath: string
|
|
let server: Server
|
|
let client: DaemonClient
|
|
|
|
beforeEach(() => {
|
|
dir = createTestDir()
|
|
socketPath = join(dir, 'test.sock')
|
|
tokenPath = join(dir, 'test.token')
|
|
writeFileSync(tokenPath, 'test-token-123')
|
|
})
|
|
|
|
afterEach(async () => {
|
|
client?.disconnect()
|
|
await new Promise<void>((resolve) => {
|
|
if (server?.listening) {
|
|
server.close(() => resolve())
|
|
} else {
|
|
resolve()
|
|
}
|
|
})
|
|
rmSync(dir, { recursive: true, force: true })
|
|
})
|
|
|
|
function startMockDaemon(opts?: {
|
|
onControlMessage?: (msg: unknown) => string | null
|
|
onStreamHello?: (msg: HelloMessage) => void
|
|
rejectVersion?: boolean
|
|
}): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
server = createServer((socket) => {
|
|
let buffer = ''
|
|
socket.on('data', (chunk) => {
|
|
buffer += chunk.toString()
|
|
let newlineIdx: number
|
|
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
|
|
const line = buffer.slice(0, newlineIdx)
|
|
buffer = buffer.slice(newlineIdx + 1)
|
|
if (!line) {
|
|
continue
|
|
}
|
|
|
|
const msg = JSON.parse(line) as HelloMessage | DaemonRequest
|
|
|
|
if (msg.type === 'hello') {
|
|
const hello = msg as HelloMessage
|
|
if (opts?.rejectVersion) {
|
|
socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Version mismatch' }))
|
|
return
|
|
}
|
|
socket.write(encodeNdjson({ type: 'hello', ok: true }))
|
|
if (hello.role === 'stream') {
|
|
opts?.onStreamHello?.(hello)
|
|
}
|
|
} else if (opts?.onControlMessage) {
|
|
const response = opts.onControlMessage(msg)
|
|
if (response) {
|
|
socket.write(response)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
server.listen(socketPath, () => resolve())
|
|
})
|
|
}
|
|
|
|
describe('connect', () => {
|
|
it('establishes connection with hello handshake', async () => {
|
|
const hellos: HelloMessage[] = []
|
|
await startMockDaemon({
|
|
onStreamHello: (msg) => hellos.push(msg)
|
|
})
|
|
|
|
client = new DaemonClient({ socketPath, tokenPath })
|
|
await client.ensureConnected()
|
|
|
|
expect(client.isConnected()).toBe(true)
|
|
// Both control and stream sockets should have sent hello
|
|
await waitFor(() => hellos.length > 0)
|
|
})
|
|
|
|
it('rejects on version mismatch', async () => {
|
|
await startMockDaemon({ rejectVersion: true })
|
|
|
|
client = new DaemonClient({ socketPath, tokenPath })
|
|
await expect(client.ensureConnected()).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('RPC', () => {
|
|
it('sends request and receives response', async () => {
|
|
await startMockDaemon({
|
|
onControlMessage: (msg) => {
|
|
const req = msg as { id: string; type: string }
|
|
if (req.type === 'listSessions') {
|
|
return encodeNdjson({
|
|
id: req.id,
|
|
ok: true,
|
|
payload: { sessions: [] }
|
|
})
|
|
}
|
|
return null
|
|
}
|
|
})
|
|
|
|
client = new DaemonClient({ socketPath, tokenPath })
|
|
await client.ensureConnected()
|
|
|
|
const result = await client.request('listSessions', undefined)
|
|
expect(result).toEqual({ sessions: [] })
|
|
})
|
|
|
|
it('rejects on error response', async () => {
|
|
await startMockDaemon({
|
|
onControlMessage: (msg) => {
|
|
const req = msg as { id: string; type: string }
|
|
return encodeNdjson({
|
|
id: req.id,
|
|
ok: false,
|
|
error: 'Something went wrong'
|
|
})
|
|
}
|
|
})
|
|
|
|
client = new DaemonClient({ socketPath, tokenPath })
|
|
await client.ensureConnected()
|
|
|
|
await expect(client.request('listSessions', undefined)).rejects.toThrow(
|
|
'Something went wrong'
|
|
)
|
|
})
|
|
|
|
it('adds recovery hints to node-pty daemon diagnostics', async () => {
|
|
await startMockDaemon({
|
|
onControlMessage: (msg) => {
|
|
const req = msg as { id: string; type: string }
|
|
return encodeNdjson({
|
|
id: req.id,
|
|
ok: false,
|
|
error:
|
|
"node-pty: posix_spawn failed: ENOENT (errno 2, No such file or directory) - helper='/tmp/deleted/spawn-helper'"
|
|
})
|
|
}
|
|
})
|
|
|
|
client = new DaemonClient({ socketPath, tokenPath })
|
|
await client.ensureConnected()
|
|
|
|
await expect(client.request('listSessions', undefined)).rejects.toThrow(
|
|
"Daemon's node-pty install is gone (worktree deleted?). Restart Orca. node-pty: posix_spawn failed: ENOENT"
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('events', () => {
|
|
it('receives stream events', async () => {
|
|
let streamSocket: Socket | null = null
|
|
await startMockDaemon({
|
|
onStreamHello: () => {
|
|
// We need to capture the stream socket to send events on it
|
|
}
|
|
})
|
|
|
|
// Capture stream socket from server
|
|
const origListener = server.listeners('connection')[0] as (s: Socket) => void
|
|
server.removeAllListeners('connection')
|
|
let socketCount = 0
|
|
server.on('connection', (socket) => {
|
|
socketCount++
|
|
if (socketCount === 2) {
|
|
streamSocket = socket
|
|
}
|
|
origListener(socket)
|
|
})
|
|
|
|
const events: DaemonEvent[] = []
|
|
client = new DaemonClient({ socketPath, tokenPath })
|
|
client.onEvent((event) => events.push(event as DaemonEvent))
|
|
await client.ensureConnected()
|
|
|
|
await waitFor(() => streamSocket !== null)
|
|
|
|
// Send a data event on the stream socket
|
|
const event: DaemonEvent = {
|
|
type: 'event',
|
|
event: 'data',
|
|
sessionId: 'session-1',
|
|
payload: { data: 'hello from daemon' }
|
|
}
|
|
streamSocket!.write(encodeNdjson(event))
|
|
|
|
await waitFor(() => events.length > 0)
|
|
expect(events[0]).toMatchObject({
|
|
type: 'event',
|
|
event: 'data',
|
|
sessionId: 'session-1'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('disconnect', () => {
|
|
it('emits disconnected when server destroys sockets', async () => {
|
|
const serverSockets: Socket[] = []
|
|
await startMockDaemon()
|
|
server.on('connection', (socket) => serverSockets.push(socket))
|
|
|
|
client = new DaemonClient({ socketPath, tokenPath })
|
|
const disconnected = vi.fn()
|
|
client.onDisconnected(disconnected)
|
|
await client.ensureConnected()
|
|
|
|
// Wait for both sockets to be tracked
|
|
await waitFor(() => serverSockets.length >= 2)
|
|
|
|
// Destroy all server-side sockets to simulate daemon crash
|
|
for (const socket of serverSockets) {
|
|
socket.destroy()
|
|
}
|
|
|
|
await waitFor(() => disconnected.mock.calls.length > 0, 3000)
|
|
expect(client.isConnected()).toBe(false)
|
|
})
|
|
|
|
it('disconnect() can be called safely when not connected', () => {
|
|
client = new DaemonClient({ socketPath, tokenPath })
|
|
expect(() => client.disconnect()).not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('notify (fire-and-forget)', () => {
|
|
it('sends request with notify_ prefix without expecting response', async () => {
|
|
const received: unknown[] = []
|
|
await startMockDaemon({
|
|
onControlMessage: (msg) => {
|
|
received.push(msg)
|
|
return null // no response
|
|
}
|
|
})
|
|
|
|
client = new DaemonClient({ socketPath, tokenPath })
|
|
await client.ensureConnected()
|
|
|
|
client.notify('write', { sessionId: 'session-1', data: 'hello' })
|
|
|
|
await waitFor(() => received.length > 0)
|
|
const msg = received[0] as { id: string; type: string }
|
|
expect(msg.id).toMatch(/^notify_/)
|
|
expect(msg.type).toBe('write')
|
|
})
|
|
})
|
|
})
|