/** * KHAELOR * File: tests/workspace/local.test.ts * Description: Unit tests for LocalWorkspace — reads, atomic writes, binary/size guards, exec with group kill. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs"; import { tmpdir } from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { LocalWorkspace, isWorkspaceError } from "../../src/workspace/index.js"; let dir: string; let ws: LocalWorkspace; beforeEach(() => { dir = mkdtempSync(path.join(tmpdir(), "khaelor-ws-")); ws = new LocalWorkspace(dir); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); async function errorCode(promise: Promise): Promise { try { await promise; } catch (cause) { if (isWorkspaceError(cause)) return cause.code; throw cause; } throw new Error("expected the promise to reject"); } describe("LocalWorkspace files", () => { it("reads a file via a relative path resolved against cwd", async () => { writeFileSync(path.join(dir, "a.txt"), "hello\n"); expect(ws.cwd()).toBe(dir); expect(await ws.readFile("a.txt")).toBe("hello\n"); }); it("reports missing files and directories with typed errors", async () => { expect(await errorCode(ws.readFile("nope.txt"))).toBe("file-not-found"); expect(await errorCode(ws.readFile("."))).toBe("file-is-directory"); }); it("detects binary files via null-byte sniff", async () => { writeFileSync(path.join(dir, "bin.dat"), Buffer.from([0x89, 0x50, 0x00, 0x47, 0x0d])); expect(await errorCode(ws.readFile("bin.dat"))).toBe("file-binary"); }); it("refuses files larger than the size guard", async () => { const small = new LocalWorkspace(dir, { maxReadBytes: 16 }); writeFileSync(path.join(dir, "big.txt"), "x".repeat(64)); expect(await errorCode(small.readFile("big.txt"))).toBe("file-too-large"); }); it("writes atomically: correct content, no temp files left behind", async () => { await ws.writeFile("out.txt", "first version\n"); await ws.writeFile("out.txt", "second version\n"); expect(readFileSync(path.join(dir, "out.txt"), "utf8")).toBe("second version\n"); const leftovers = readdirSync(dir).filter((name) => name.includes("khaelor-tmp")); expect(leftovers).toEqual([]); }); it("preserves the mode bits of an existing file across overwrite", async () => { const file = path.join(dir, "script.sh"); writeFileSync(file, "#!/bin/sh\n"); chmodSync(file, 0o755); await ws.writeFile("script.sh", "#!/bin/sh\necho updated\n"); expect(statSync(file).mode & 0o777).toBe(0o755); expect(readFileSync(file, "utf8")).toBe("#!/bin/sh\necho updated\n"); }); it("creates parent directories on write", async () => { await ws.writeFile("deep/nested/file.txt", "content"); expect(readFileSync(path.join(dir, "deep/nested/file.txt"), "utf8")).toBe("content"); }); }); describe("LocalWorkspace exec", () => { it("captures stdout and stderr separately with the exit code", async () => { const result = await ws.exec({ cmd: "echo out; echo err 1>&2; exit 3", timeoutMs: 5000 }); expect(result.stdout).toBe("out\n"); expect(result.stderr).toBe("err\n"); expect(result.exitCode).toBe(3); expect(result.truncated).toBe(false); expect(result.durationMs).toBeGreaterThanOrEqual(0); }); it("honours cwd and extra env", async () => { const result = await ws.exec({ cmd: "pwd; printf '%s\\n' \"$KHAELOR_TEST_VAR\"", cwd: dir, env: { KHAELOR_TEST_VAR: "wired" }, timeoutMs: 5000, }); expect(result.stdout).toContain("wired"); expect(result.exitCode).toBe(0); }); it("kills the whole process group on timeout", async () => { const started = Date.now(); // The shell prints the pid of a background sleep, then waits on it. const result = await ws.exec({ cmd: "sleep 30 & echo CHILD:$!; wait", timeoutMs: 400, }); expect(Date.now() - started).toBeLessThan(5000); expect(result.exitCode).toBeNull(); const match = /CHILD:(\d+)/.exec(result.stdout); expect(match).not.toBeNull(); const childPid = Number((match as RegExpExecArray)[1]); // The grandchild sleep must be dead too (group kill), possibly after a beat. await new Promise((resolve) => setTimeout(resolve, 200)); expect(() => process.kill(childPid, 0)).toThrow(); }); it("kills the process group when the abort signal fires", async () => { const controller = new AbortController(); setTimeout(() => controller.abort(), 200); const result = await ws.exec({ cmd: "sleep 30", timeoutMs: 30_000, signal: controller.signal }); expect(result.exitCode).toBeNull(); }); it("caps captured output and reports truncation", async () => { const capped = new LocalWorkspace(dir, { maxExecStreamBytes: 32 }); const result = await capped.exec({ cmd: "printf 'a%.0s' $(seq 1 200)", timeoutMs: 5000 }); expect(result.truncated).toBe(true); expect(result.stdout.length).toBeLessThanOrEqual(32); expect(result.exitCode).toBe(0); }); });