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%
5.1 KB · 137 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: tests/workspace/local.test.ts4 * Description: Unit tests for LocalWorkspace — reads, atomic writes, binary/size guards, exec with group kill.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";11import { tmpdir } from "node:os";12import * as path from "node:path";13import { afterEach, beforeEach, describe, expect, it } from "vitest";14import { LocalWorkspace, isWorkspaceError } from "../../src/workspace/index.js";1516let dir: string;17let ws: LocalWorkspace;1819beforeEach(() => {20  dir = mkdtempSync(path.join(tmpdir(), "khaelor-ws-"));21  ws = new LocalWorkspace(dir);22});2324afterEach(() => {25  rmSync(dir, { recursive: true, force: true });26});2728async function errorCode(promise: Promise<unknown>): Promise<string> {29  try {30    await promise;31  } catch (cause) {32    if (isWorkspaceError(cause)) return cause.code;33    throw cause;34  }35  throw new Error("expected the promise to reject");36}3738describe("LocalWorkspace files", () => {39  it("reads a file via a relative path resolved against cwd", async () => {40    writeFileSync(path.join(dir, "a.txt"), "hello\n");41    expect(ws.cwd()).toBe(dir);42    expect(await ws.readFile("a.txt")).toBe("hello\n");43  });4445  it("reports missing files and directories with typed errors", async () => {46    expect(await errorCode(ws.readFile("nope.txt"))).toBe("file-not-found");47    expect(await errorCode(ws.readFile("."))).toBe("file-is-directory");48  });4950  it("detects binary files via null-byte sniff", async () => {51    writeFileSync(path.join(dir, "bin.dat"), Buffer.from([0x89, 0x50, 0x00, 0x47, 0x0d]));52    expect(await errorCode(ws.readFile("bin.dat"))).toBe("file-binary");53  });5455  it("refuses files larger than the size guard", async () => {56    const small = new LocalWorkspace(dir, { maxReadBytes: 16 });57    writeFileSync(path.join(dir, "big.txt"), "x".repeat(64));58    expect(await errorCode(small.readFile("big.txt"))).toBe("file-too-large");59  });6061  it("writes atomically: correct content, no temp files left behind", async () => {62    await ws.writeFile("out.txt", "first version\n");63    await ws.writeFile("out.txt", "second version\n");64    expect(readFileSync(path.join(dir, "out.txt"), "utf8")).toBe("second version\n");65    const leftovers = readdirSync(dir).filter((name) => name.includes("khaelor-tmp"));66    expect(leftovers).toEqual([]);67  });6869  it("preserves the mode bits of an existing file across overwrite", async () => {70    const file = path.join(dir, "script.sh");71    writeFileSync(file, "#!/bin/sh\n");72    chmodSync(file, 0o755);73    await ws.writeFile("script.sh", "#!/bin/sh\necho updated\n");74    expect(statSync(file).mode & 0o777).toBe(0o755);75    expect(readFileSync(file, "utf8")).toBe("#!/bin/sh\necho updated\n");76  });7778  it("creates parent directories on write", async () => {79    await ws.writeFile("deep/nested/file.txt", "content");80    expect(readFileSync(path.join(dir, "deep/nested/file.txt"), "utf8")).toBe("content");81  });82});8384describe("LocalWorkspace exec", () => {85  it("captures stdout and stderr separately with the exit code", async () => {86    const result = await ws.exec({ cmd: "echo out; echo err 1>&2; exit 3", timeoutMs: 5000 });87    expect(result.stdout).toBe("out\n");88    expect(result.stderr).toBe("err\n");89    expect(result.exitCode).toBe(3);90    expect(result.truncated).toBe(false);91    expect(result.durationMs).toBeGreaterThanOrEqual(0);92  });9394  it("honours cwd and extra env", async () => {95    const result = await ws.exec({96      cmd: "pwd; printf '%s\\n' \"$KHAELOR_TEST_VAR\"",97      cwd: dir,98      env: { KHAELOR_TEST_VAR: "wired" },99      timeoutMs: 5000,100    });101    expect(result.stdout).toContain("wired");102    expect(result.exitCode).toBe(0);103  });104105  it("kills the whole process group on timeout", async () => {106    const started = Date.now();107    // The shell prints the pid of a background sleep, then waits on it.108    const result = await ws.exec({109      cmd: "sleep 30 & echo CHILD:$!; wait",110      timeoutMs: 400,111    });112    expect(Date.now() - started).toBeLessThan(5000);113    expect(result.exitCode).toBeNull();114    const match = /CHILD:(\d+)/.exec(result.stdout);115    expect(match).not.toBeNull();116    const childPid = Number((match as RegExpExecArray)[1]);117    // The grandchild sleep must be dead too (group kill), possibly after a beat.118    await new Promise((resolve) => setTimeout(resolve, 200));119    expect(() => process.kill(childPid, 0)).toThrow();120  });121122  it("kills the process group when the abort signal fires", async () => {123    const controller = new AbortController();124    setTimeout(() => controller.abort(), 200);125    const result = await ws.exec({ cmd: "sleep 30", timeoutMs: 30_000, signal: controller.signal });126    expect(result.exitCode).toBeNull();127  });128129  it("caps captured output and reports truncation", async () => {130    const capped = new LocalWorkspace(dir, { maxExecStreamBytes: 32 });131    const result = await capped.exec({ cmd: "printf 'a%.0s' $(seq 1 200)", timeoutMs: 5000 });132    expect(result.truncated).toBe(true);133    expect(result.stdout.length).toBeLessThanOrEqual(32);134    expect(result.exitCode).toBe(0);135  });136});137