import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' function spec(command: string, overrides: Partial = {}): SubprocessSpawnSpec { return { argv: ['bash', '-c', command], cwd: process.cwd(), stdio: { stdin: 'ignore', stdout: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } }, stderr: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } }, }, graceMs: 200, ...overrides, } } describe('LocalSubprocessService', () => { it('registers as ctx.subprocess and spawns managed handles', async () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessService) const handle = ctx.subprocess.spawn(spec('echo managed')) const result = await handle.done expect(result.exitCode).toBe(0) expect(handle.collected.stdout!.readFrom(0).text).toBe('managed\n') await fiber.dispose() }) it('disposal kills still-running processes and awaits their exit', async () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessService) const handle = ctx.subprocess.spawn(spec('sleep 60')) await fiber.dispose() const outcome = await handle.done expect(outcome.signal).toBe('SIGTERM') }) it('a settled process leaves the live set (disposal does not re-kill it)', async () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessService) const handle = ctx.subprocess.spawn(spec('true')) const outcome = await handle.done expect(outcome.exitCode).toBe(0) await fiber.dispose() }) it('disposal tolerates a handle whose spawn already failed', async () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessService) const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' })) await expect(handle.done).rejects.toThrow() await fiber.dispose() }) it('disposal contains a spawn-failure rejection that races teardown', async () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessService) // Dispose before the rejection continuation removes the handle from the // live set, so teardown itself must swallow the rejected done. const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' })) await fiber.dispose() await expect(handle.done).rejects.toThrow() }) it('loading a second implementation throws (one processes service per context — cordis standard)', async () => { const ctx = new Context() await ctx.plugin(LocalSubprocessService) class SecondManager extends LocalSubprocessService {} await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/) }) })