238 lines
7.0 KiB
TypeScript
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)
|
|
})
|
|
})
|