SPB Git

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%
7.4 KB · 204 lines typescript
Raw Blame History
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