spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: tests/workspace/processes.test.ts4 * Description: Unit tests for the ProcessManager — lifecycle, ring buffer + spill, stdin, group kill.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";11import { tmpdir } from "node:os";12import * as path from "node:path";13import { afterEach, beforeEach, describe, expect, it } from "vitest";14import { LocalProcessManager, isWorkspaceError } from "../../src/workspace/index.js";15import type { ManagedProcess } from "../../src/workspace/index.js";1617let dir: string;18let managers: LocalProcessManager[];1920beforeEach(() => {21 dir = mkdtempSync(path.join(tmpdir(), "khaelor-pm-"));22 managers = [];23});2425afterEach(async () => {26 await Promise.all(managers.map((m) => m.stopAll()));27 rmSync(dir, { recursive: true, force: true });28});2930function makeManager(overrides: Partial<ConstructorParameters<typeof LocalProcessManager>[0]> = {}) {31 const manager = new LocalProcessManager({32 logDir: path.join(dir, "logs"),33 graceMs: 500,34 ...overrides,35 });36 managers.push(manager);37 return manager;38}3940async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<void> {41 const deadline = Date.now() + timeoutMs;42 while (!predicate()) {43 if (Date.now() > deadline) throw new Error("waitFor: condition not met in time");44 await new Promise((resolve) => setTimeout(resolve, 25));45 }46}4748describe("ProcessManager lifecycle", () => {49 it("start → read → stop with a real process", async () => {50 const manager = makeManager();51 const proc = await manager.start(52 `node -e "console.log('hello'); setInterval(() => {}, 1000)"`,53 dir,54 );55 expect(proc.id).toBe("p1");56 expect(proc.pid).toBeGreaterThan(0);57 expect(proc.status).toBe("running");58 expect(existsSync(proc.logPath)).toBe(true);5960 await waitFor(() => manager.read("p1", { offset: 1 }).totalLines >= 1);61 const page = manager.read("p1");62 expect(page.lines).toContain("hello");63 expect(page.startLine).toBe(1);6465 const listed = manager.list();66 expect(listed).toHaveLength(1);67 expect(listed[0]?.status).toBe("running");6869 const { exitCode } = await manager.stop("p1");70 expect(exitCode).toBeNull(); // killed by signal71 expect(manager.list()[0]?.status).toBe("stopped");72 // The OS process must actually be gone.73 expect(() => process.kill(proc.pid, 0)).toThrow();74 });7576 it("records natural exit and surfaces it via the injected event sink", async () => {77 const exits: ManagedProcess[] = [];78 const starts: ManagedProcess[] = [];79 const manager = makeManager({80 eventSink: {81 onProcessStarted: (p) => starts.push(p),82 onProcessExited: (p) => exits.push(p),83 },84 });85 await manager.start(`sh -c 'echo bye'`, dir);86 await waitFor(() => exits.length === 1);87 expect(starts).toHaveLength(1);88 expect(exits[0]?.status).toBe("exited");89 expect(exits[0]?.exitCode).toBe(0);90 expect(manager.list()[0]?.status).toBe("exited");91 });9293 it("stop on an already-exited process returns its exit code without signalling", async () => {94 const manager = makeManager();95 const proc = await manager.start(`sh -c 'exit 7'`, dir);96 await waitFor(() => manager.list()[0]?.status === "exited");97 const { exitCode } = await manager.stop(proc.id);98 expect(exitCode).toBe(7);99 });100101 it("stop kills the whole process group including grandchildren", async () => {102 const manager = makeManager();103 await manager.start(`sh -c 'sleep 30 & echo CHILD:$!; wait'`, dir);104 await waitFor(() => manager.read("p1", { offset: 1 }).totalLines >= 1);105 const line = manager.read("p1").lines.find((l) => l.startsWith("CHILD:"));106 expect(line).toBeDefined();107 const grandchild = Number((line as string).slice("CHILD:".length));108 await manager.stop("p1");109 await new Promise((resolve) => setTimeout(resolve, 200));110 expect(() => process.kill(grandchild, 0)).toThrow();111 });112113 it("write sends stdin the process can react to", async () => {114 const manager = makeManager();115 await manager.start(116 `node -e "process.stdin.on('data', (d) => console.log('got:' + d.toString().trim()))"`,117 dir,118 );119 await manager.write("p1", "ping\n");120 await waitFor(() => manager.read("p1", { offset: 1 }).lines.includes("got:ping"));121 await manager.stop("p1");122 });123124 it("rejects unknown ids and writes to exited processes", async () => {125 const manager = makeManager();126 expect(() => manager.read("p9")).toThrowError(127 expect.objectContaining({ code: "process-unknown" }),128 );129 await manager.start(`sh -c 'true'`, dir);130 await waitFor(() => manager.list()[0]?.status === "exited");131 try {132 await manager.write("p1", "late\n");133 expect.unreachable("write on an exited process must throw");134 } catch (cause) {135 expect(isWorkspaceError(cause) && cause.code === "process-not-running").toBe(true);136 }137 });138});139140describe("ProcessManager output: ring buffer, spill log, cursor", () => {141 it("keeps the full output in the spill log when the ring evicts", async () => {142 const manager = makeManager({ maxRingLines: 10 });143 const proc = await manager.start(144 `node -e "for (let i = 1; i <= 50; i++) console.log('line' + i)"`,145 dir,146 );147 await waitFor(() => manager.list()[0]?.status === "exited");148149 // Ring holds only the tail…150 const tail = manager.read(proc.id, { offset: 41 });151 expect(tail.lines[0]).toBe("line41");152 expect(tail.totalLines).toBe(50);153154 // …but an offset read before the ring start is served from the log.155 const fromLog = manager.read(proc.id, { offset: 1 });156 expect(fromLog.lines[0]).toBe("line1");157158 // And the spill file itself contains everything, untruncated.159 const raw = readFileSync(proc.logPath, "utf8");160 expect(raw).toContain("line1\n");161 expect(raw).toContain("line50\n");162 });163164 it("cursor reads return only new output and advance", async () => {165 const manager = makeManager();166 await manager.start(`node -e "for (let i = 1; i <= 5; i++) console.log('n' + i)"`, dir);167 await waitFor(() => manager.list()[0]?.status === "exited");168169 const first = manager.read("p1");170 expect(first.lines).toEqual(["n1", "n2", "n3", "n4", "n5"]);171 const second = manager.read("p1");172 expect(second.lines).toEqual([]);173 expect(second.totalLines).toBe(5);174 });175176 it("caps read pages and reports truncation, continuing from the cursor", async () => {177 const manager = makeManager({ readPageLines: 20 });178 await manager.start(`node -e "for (let i = 1; i <= 50; i++) console.log('r' + i)"`, dir);179 await waitFor(() => manager.list()[0]?.status === "exited");180181 const first = manager.read("p1");182 expect(first.lines).toHaveLength(20);183 expect(first.truncated).toBe(true);184 expect(first.lines[0]).toBe("r1");185186 const second = manager.read("p1");187 expect(second.lines[0]).toBe("r21");188 expect(second.lines).toHaveLength(20);189190 const third = manager.read("p1");191 expect(third.lines).toEqual(192 Array.from({ length: 10 }, (_, i) => `r${41 + i}`),193 );194 expect(third.truncated).toBe(false);195 });196197 it("flushes a trailing unterminated line at exit", async () => {198 const manager = makeManager();199 await manager.start(`node -e "process.stdout.write('no-newline')"`, dir);200 await waitFor(() => manager.list()[0]?.status === "exited");201 expect(manager.read("p1").lines).toEqual(["no-newline"]);202 });203});204