orca/src/main/daemon/daemon-spawner.test.ts

238 lines
7.0 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import {
DaemonSpawner,
getDaemonPidPath,
getDaemonSocketPath,
getDaemonTokenPath,
restoreClaimedDaemonArtifact
} from './daemon-spawner'
import { startDaemon, type DaemonHandle } from './daemon-main'
import { DaemonClient } from './client'
import type { SubprocessHandle } from './session'
import { PROTOCOL_VERSION } from './types'
function createTestDir(): string {
return mkdtempSync(join(tmpdir(), 'daemon-spawner-test-'))
}
function createMockSubprocess(): SubprocessHandle {
let onExitCb: ((code: number) => void) | null = null
return {
pid: 88888,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)),
forceKill: vi.fn(() => setTimeout(() => onExitCb?.(137), 5)),
signal: vi.fn(),
onData(_cb: (data: string) => void) {},
onExit(cb: (code: number) => void) {
onExitCb = cb
},
dispose: vi.fn()
}
}
describe('DaemonSpawner', () => {
let dir: string
let spawner: DaemonSpawner
let activeDaemons: DaemonHandle[]
beforeEach(() => {
dir = createTestDir()
activeDaemons = []
})
afterEach(async () => {
await spawner?.shutdown()
for (const d of activeDaemons) {
await d.shutdown().catch(() => {})
}
rmSync(dir, { recursive: true, force: true })
})
function createSpawner(): DaemonSpawner {
spawner = new DaemonSpawner({
runtimeDir: dir,
launcher: async (socketPath, tokenPath) => {
const handle = await startDaemon({
socketPath,
tokenPath,
spawnSubprocess: () => createMockSubprocess()
})
activeDaemons.push(handle)
return { shutdown: () => handle.shutdown() }
}
})
return spawner
}
describe('ensureRunning', () => {
it('passes the scoped PID path and a fresh launch nonce to the launcher', async () => {
const launcher = vi.fn(async () => ({ shutdown: vi.fn(async () => {}) }))
spawner = new DaemonSpawner({ runtimeDir: dir, launcher })
await spawner.ensureRunning()
expect(launcher).toHaveBeenCalledWith(
getDaemonSocketPath(dir),
getDaemonTokenPath(dir),
getDaemonPidPath(dir),
expect.stringMatching(/^[0-9a-f-]{36}$/)
)
})
it('uses protocol-scoped socket and token paths', () => {
const socketPath = getDaemonSocketPath(dir)
const tokenPath = getDaemonTokenPath(dir)
const pidPath = getDaemonPidPath(dir)
if (process.platform === 'win32') {
expect(socketPath).toContain(`orca-terminal-host-v${PROTOCOL_VERSION}`)
} else {
expect(socketPath).toBe(join(dir, `daemon-v${PROTOCOL_VERSION}.sock`))
}
expect(tokenPath).toBe(join(dir, `daemon-v${PROTOCOL_VERSION}.token`))
expect(pidPath).toBe(join(dir, `daemon-v${PROTOCOL_VERSION}.pid`))
})
it('starts daemon and returns connection info', async () => {
const s = createSpawner()
const info = await s.ensureRunning()
if (process.platform === 'win32') {
expect(info.socketPath).toContain(`orca-terminal-host-v${PROTOCOL_VERSION}`)
} else {
expect(info.socketPath).toContain(dir)
}
expect(info.tokenPath).toContain(dir)
})
it('returns same info on subsequent calls', async () => {
const s = createSpawner()
const info1 = await s.ensureRunning()
const info2 = await s.ensureRunning()
expect(info1.socketPath).toBe(info2.socketPath)
expect(info1.tokenPath).toBe(info2.tokenPath)
})
it('daemon is connectable after ensureRunning', async () => {
const s = createSpawner()
const info = await s.ensureRunning()
const client = new DaemonClient({
socketPath: info.socketPath,
tokenPath: info.tokenPath
})
await client.ensureConnected()
expect(client.isConnected()).toBe(true)
client.disconnect()
})
it('daemon can create sessions', async () => {
const s = createSpawner()
const info = await s.ensureRunning()
const client = new DaemonClient({
socketPath: info.socketPath,
tokenPath: info.tokenPath
})
await client.ensureConnected()
const result = await client.request<{ isNew: boolean }>('createOrAttach', {
sessionId: 'test-session',
cols: 80,
rows: 24
})
expect(result.isNew).toBe(true)
client.disconnect()
})
})
describe('shutdown', () => {
it('stops the daemon', async () => {
const s = createSpawner()
const info = await s.ensureRunning()
await s.shutdown()
const client = new DaemonClient({
socketPath: info.socketPath,
tokenPath: info.tokenPath
})
await expect(client.ensureConnected()).rejects.toThrow()
})
it('can be called when daemon is not running', async () => {
const s = createSpawner()
await expect(s.shutdown()).resolves.toBeUndefined()
})
it('allows re-start after shutdown', async () => {
const s = createSpawner()
await s.ensureRunning()
await s.shutdown()
const info = await s.ensureRunning()
const client = new DaemonClient({
socketPath: info.socketPath,
tokenPath: info.tokenPath
})
await client.ensureConnected()
expect(client.isConnected()).toBe(true)
client.disconnect()
})
})
})
describe('restoreClaimedDaemonArtifact', () => {
it('retains the unique claim when restoration fails without a replacement', () => {
expect(
restoreClaimedDaemonArtifact('/claimed', '/canonical', {
copyExclusive: () => {
throw new Error('injected ENOSPC')
},
canonicalExists: () => false
})
).toBe(false)
})
it('retains the unique claim when a failed copy leaves a partial canonical file', () => {
const restoreDir = createTestDir()
const canonicalPath = join(restoreDir, 'partial-canonical')
try {
expect(
restoreClaimedDaemonArtifact('/claimed', canonicalPath, {
copyExclusive: () => {
writeFileSync(canonicalPath, 'partial')
throw Object.assign(new Error('injected ENOSPC'), { code: 'ENOSPC' })
},
canonicalExists: () => true
})
).toBe(false)
} finally {
rmSync(restoreDir, { recursive: true, force: true })
}
})
it('allows claim cleanup after successful restore or a confirmed replacement', () => {
expect(
restoreClaimedDaemonArtifact('/claimed', '/canonical', {
copyExclusive: () => {},
canonicalExists: () => false
})
).toBe(true)
expect(
restoreClaimedDaemonArtifact('/claimed', '/canonical', {
copyExclusive: () => {
throw Object.assign(new Error('injected EEXIST'), { code: 'EEXIST' })
},
canonicalExists: () => true
})
).toBe(true)
})
})