/** * KHAELOR * File: tests/workspace/processes.test.ts * Description: Unit tests for the ProcessManager — lifecycle, ring buffer + spill, stdin, group kill. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { LocalProcessManager, isWorkspaceError } from "../../src/workspace/index.js"; import type { ManagedProcess } from "../../src/workspace/index.js"; let dir: string; let managers: LocalProcessManager[]; beforeEach(() => { dir = mkdtempSync(path.join(tmpdir(), "khaelor-pm-")); managers = []; }); afterEach(async () => { await Promise.all(managers.map((m) => m.stopAll())); rmSync(dir, { recursive: true, force: true }); }); function makeManager(overrides: Partial[0]> = {}) { const manager = new LocalProcessManager({ logDir: path.join(dir, "logs"), graceMs: 500, ...overrides, }); managers.push(manager); return manager; } async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { const deadline = Date.now() + timeoutMs; while (!predicate()) { if (Date.now() > deadline) throw new Error("waitFor: condition not met in time"); await new Promise((resolve) => setTimeout(resolve, 25)); } } describe("ProcessManager lifecycle", () => { it("start → read → stop with a real process", async () => { const manager = makeManager(); const proc = await manager.start( `node -e "console.log('hello'); setInterval(() => {}, 1000)"`, dir, ); expect(proc.id).toBe("p1"); expect(proc.pid).toBeGreaterThan(0); expect(proc.status).toBe("running"); expect(existsSync(proc.logPath)).toBe(true); await waitFor(() => manager.read("p1", { offset: 1 }).totalLines >= 1); const page = manager.read("p1"); expect(page.lines).toContain("hello"); expect(page.startLine).toBe(1); const listed = manager.list(); expect(listed).toHaveLength(1); expect(listed[0]?.status).toBe("running"); const { exitCode } = await manager.stop("p1"); expect(exitCode).toBeNull(); // killed by signal expect(manager.list()[0]?.status).toBe("stopped"); // The OS process must actually be gone. expect(() => process.kill(proc.pid, 0)).toThrow(); }); it("records natural exit and surfaces it via the injected event sink", async () => { const exits: ManagedProcess[] = []; const starts: ManagedProcess[] = []; const manager = makeManager({ eventSink: { onProcessStarted: (p) => starts.push(p), onProcessExited: (p) => exits.push(p), }, }); await manager.start(`sh -c 'echo bye'`, dir); await waitFor(() => exits.length === 1); expect(starts).toHaveLength(1); expect(exits[0]?.status).toBe("exited"); expect(exits[0]?.exitCode).toBe(0); expect(manager.list()[0]?.status).toBe("exited"); }); it("stop on an already-exited process returns its exit code without signalling", async () => { const manager = makeManager(); const proc = await manager.start(`sh -c 'exit 7'`, dir); await waitFor(() => manager.list()[0]?.status === "exited"); const { exitCode } = await manager.stop(proc.id); expect(exitCode).toBe(7); }); it("stop kills the whole process group including grandchildren", async () => { const manager = makeManager(); await manager.start(`sh -c 'sleep 30 & echo CHILD:$!; wait'`, dir); await waitFor(() => manager.read("p1", { offset: 1 }).totalLines >= 1); const line = manager.read("p1").lines.find((l) => l.startsWith("CHILD:")); expect(line).toBeDefined(); const grandchild = Number((line as string).slice("CHILD:".length)); await manager.stop("p1"); await new Promise((resolve) => setTimeout(resolve, 200)); expect(() => process.kill(grandchild, 0)).toThrow(); }); it("write sends stdin the process can react to", async () => { const manager = makeManager(); await manager.start( `node -e "process.stdin.on('data', (d) => console.log('got:' + d.toString().trim()))"`, dir, ); await manager.write("p1", "ping\n"); await waitFor(() => manager.read("p1", { offset: 1 }).lines.includes("got:ping")); await manager.stop("p1"); }); it("rejects unknown ids and writes to exited processes", async () => { const manager = makeManager(); expect(() => manager.read("p9")).toThrowError( expect.objectContaining({ code: "process-unknown" }), ); await manager.start(`sh -c 'true'`, dir); await waitFor(() => manager.list()[0]?.status === "exited"); try { await manager.write("p1", "late\n"); expect.unreachable("write on an exited process must throw"); } catch (cause) { expect(isWorkspaceError(cause) && cause.code === "process-not-running").toBe(true); } }); }); describe("ProcessManager output: ring buffer, spill log, cursor", () => { it("keeps the full output in the spill log when the ring evicts", async () => { const manager = makeManager({ maxRingLines: 10 }); const proc = await manager.start( `node -e "for (let i = 1; i <= 50; i++) console.log('line' + i)"`, dir, ); await waitFor(() => manager.list()[0]?.status === "exited"); // Ring holds only the tail… const tail = manager.read(proc.id, { offset: 41 }); expect(tail.lines[0]).toBe("line41"); expect(tail.totalLines).toBe(50); // …but an offset read before the ring start is served from the log. const fromLog = manager.read(proc.id, { offset: 1 }); expect(fromLog.lines[0]).toBe("line1"); // And the spill file itself contains everything, untruncated. const raw = readFileSync(proc.logPath, "utf8"); expect(raw).toContain("line1\n"); expect(raw).toContain("line50\n"); }); it("cursor reads return only new output and advance", async () => { const manager = makeManager(); await manager.start(`node -e "for (let i = 1; i <= 5; i++) console.log('n' + i)"`, dir); await waitFor(() => manager.list()[0]?.status === "exited"); const first = manager.read("p1"); expect(first.lines).toEqual(["n1", "n2", "n3", "n4", "n5"]); const second = manager.read("p1"); expect(second.lines).toEqual([]); expect(second.totalLines).toBe(5); }); it("caps read pages and reports truncation, continuing from the cursor", async () => { const manager = makeManager({ readPageLines: 20 }); await manager.start(`node -e "for (let i = 1; i <= 50; i++) console.log('r' + i)"`, dir); await waitFor(() => manager.list()[0]?.status === "exited"); const first = manager.read("p1"); expect(first.lines).toHaveLength(20); expect(first.truncated).toBe(true); expect(first.lines[0]).toBe("r1"); const second = manager.read("p1"); expect(second.lines[0]).toBe("r21"); expect(second.lines).toHaveLength(20); const third = manager.read("p1"); expect(third.lines).toEqual( Array.from({ length: 10 }, (_, i) => `r${41 + i}`), ); expect(third.truncated).toBe(false); }); it("flushes a trailing unterminated line at exit", async () => { const manager = makeManager(); await manager.start(`node -e "process.stdout.write('no-newline')"`, dir); await waitFor(() => manager.list()[0]?.status === "exited"); expect(manager.read("p1").lines).toEqual(["no-newline"]); }); });