import { test, expect } from "vitest"; import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createDefaultIO, createRecordingIO, createReadOnlyIO, execOrThrow, KernelMetaError, } from "../workspace-io.ts"; import type { ExecResult, WorkspaceIO } from "../workspace-io.ts"; /* Unit tests for workspace io covering its documented public contract and regression-sensitive edge cases. Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only. RFC-1311: Compass header sweep — declares the workspace suite's contract for test-file scaffold coverage. */ test("createDefaultIO: writeFile creates the file with exact content, no leftover temp file", async () => { const root = await mkdtemp(join(tmpdir(), "workspace-io-default-")); try { const { io } = createDefaultIO(); const target = join(root, "nested", "file.txt"); await io.writeFile(target, "hello world"); const content = await readFile(target, "utf8"); expect(content).toBe("hello world"); const entries = await import("node:fs/promises").then((fs) => fs.readdir(join(root, "nested"))); expect(entries).toEqual(["file.txt"]); } finally { await rm(root, { recursive: true, force: true }); } }); test("createDefaultIO: exists/readFile/mkdir/rm round-trip", async () => { const root = await mkdtemp(join(tmpdir(), "workspace-io-default2-")); try { const { io } = createDefaultIO(); expect(await io.exists(join(root, "missing.txt"))).toBe(false); await io.mkdir(join(root, "dir")); expect(await io.exists(join(root, "dir"))).toBe(true); await io.writeFile(join(root, "dir", "a.txt"), "a"); expect(await io.readFile(join(root, "dir", "a.txt"))).toBe("a"); await io.rm(join(root, "dir"), { recursive: true }); expect(await io.exists(join(root, "dir"))).toBe(false); } finally { await rm(root, { recursive: true, force: true }); } }); test("createDefaultIO: readdir returns port-neutral DirEntry[] with isFile/isDirectory booleans", async () => { const root = await mkdtemp(join(tmpdir(), "workspace-io-readdir-")); try { const { io } = createDefaultIO(); await io.writeFile(join(root, "a.md"), "content"); await io.mkdir(join(root, "subdir")); const entries = (await io.readdir(root)).sort((x, y) => x.name.localeCompare(y.name)); expect(entries).toEqual([ { name: "a.md", isFile: true, isDirectory: false }, { name: "subdir", isFile: false, isDirectory: true }, ]); } finally { await rm(root, { recursive: true, force: true }); } }); test("createReadOnlyIO and createRecordingIO: readdir passes through (a read, not a mutation)", async () => { const root = await mkdtemp(join(tmpdir(), "workspace-io-readdir-passthrough-")); try { const { io: base } = createDefaultIO(); await base.writeFile(join(root, "x.txt"), "x"); const readOnly = createReadOnlyIO(base, "fixture.readonly.command"); expect((await readOnly.readdir(root))[0]?.name).toBe("x.txt"); const { io: recording } = createRecordingIO(base); expect((await recording.readdir(root))[0]?.name).toBe("x.txt"); } finally { await rm(root, { recursive: true, force: true }); } }); test("createRecordingIO: captures write/mkdir/rm intents and never touches disk", async () => { const root = await mkdtemp(join(tmpdir(), "workspace-io-recording-")); try { const { io: base } = createDefaultIO(); const { io, intents } = createRecordingIO(base); const target = join(root, "would-write.txt"); await io.writeFile(target, "some content"); await io.mkdir(join(root, "would-mkdir")); await io.rm(join(root, "would-rm")); expect(intents.length).toBe(3); expect(intents.map((i) => i.kind)).toEqual(["write", "mkdir", "rm"]); expect(intents[0]?.path).toBe(target); expect(intents[0]?.bytes).toBe(Buffer.byteLength("some content", "utf8")); // Nothing was actually written. await expect(() => stat(target)).rejects.toThrow(); const rootEntries = await import("node:fs/promises").then((fs) => fs.readdir(root)); expect(rootEntries).toEqual([]); } finally { await rm(root, { recursive: true, force: true }); } }); test("createRecordingIO: reads pass through to the base adapter", async () => { const root = await mkdtemp(join(tmpdir(), "workspace-io-recording-reads-")); try { const { io: base } = createDefaultIO(); await base.writeFile(join(root, "existing.txt"), "real content"); const { io } = createRecordingIO(base); expect(await io.exists(join(root, "existing.txt"))).toBe(true); expect(await io.readFile(join(root, "existing.txt"))).toBe("real content"); } finally { await rm(root, { recursive: true, force: true }); } }); test("createReadOnlyIO: throws KERNEL-META-01 naming the command and path on writeFile", async () => { const io = createReadOnlyIO(createDefaultIO().io, "fixture.readonly.command"); const target = join(process.cwd(), "readonly-guard-should-not-write.txt"); try { await io.writeFile(target, "x"); expect.fail("should have thrown"); } catch (error) { expect(error).toBeInstanceOf(KernelMetaError); expect((error as Error).message).toMatch(/KERNEL-META-01/); expect((error as Error).message).toMatch(/fixture\.readonly\.command/); expect((error as Error).message).toMatch(/readonly-guard-should-not-write\.txt/); } }); test("createReadOnlyIO: throws on mkdir, rm, and exec too", async () => { const io = createReadOnlyIO(createDefaultIO().io, "fixture.readonly.command"); const target = join(process.cwd(), "readonly-guard-nope"); await expect(() => io.mkdir(target)).rejects.toThrow(KernelMetaError); await expect(() => io.rm(target)).rejects.toThrow(KernelMetaError); await expect(() => io.exec("rm", ["-rf", target])).rejects.toThrow(KernelMetaError); }); test("createReadOnlyIO: reports readonly and passes tmpdir-scoped mutations through", async () => { const root = await mkdtemp(join(tmpdir(), "workspace-io-readonly-tmp-")); try { const io = createReadOnlyIO(createDefaultIO().io, "fixture.readonly.command"); expect(io.readonly).toBe(true); // RFC-1176: scratch space under os.tmpdir() is not workspace state — // mutations there pass through instead of throwing. const target = join(root, "scratch.txt"); await io.writeFile(target, "scratch"); expect(await io.readFile(target)).toBe("scratch"); const tmpDir = await io.mkdtemp(join(tmpdir(), "workspace-io-mkdtemp-")); await io.rm(tmpDir, { recursive: true }); } finally { await rm(root, { recursive: true, force: true }); } }); test("createReadOnlyIO: allowExec binds subprocess calls through to the base adapter", async () => { const io = createReadOnlyIO(createDefaultIO().io, "fixture.readonly.command", { allowExec: true, }); const result = await io.exec("echo", ["hello"]); expect(result.exitCode).toBe(0); expect(result.stdout.trim()).toBe("hello"); // Filesystem mutations still throw even with allowExec. await expect(() => io.writeFile(join(process.cwd(), "readonly-guard-no.txt"), "x"), ).rejects.toThrow(KernelMetaError); }); // RFC-1303: cacheWrites carve-out — declared prefixes delegate to the base // adapter even on a read-only invocation; everything else still throws. test("createReadOnlyIO: cacheWrites prefixes delegate writes to the base adapter (RFC-1303)", async () => { const root = await mkdtemp(join(tmpdir(), "workspace-io-cachewrites-")); try { const { io: base } = createDefaultIO(); const io = createReadOnlyIO(base, "fixture.cache.command", { workspaceRoot: root, cacheWrites: [".cache/"], }); await io.mkdir(join(root, ".cache")); await io.writeFile(join(root, ".cache", "lt.json"), "{}"); expect(await io.readFile(join(root, ".cache", "lt.json"))).toBe("{}"); } finally { await rm(root, { recursive: true, force: true }); } }); test("createReadOnlyIO: writes outside declared cacheWrites prefixes still throw KERNEL-META-01", async () => { const root = await mkdtemp(join(tmpdir(), "workspace-io-cachewrites-deny-")); try { const { io: base } = createDefaultIO(); const io = createReadOnlyIO(base, "fixture.cache.command", { workspaceRoot: root, cacheWrites: [".cache/"], }); await expect(() => io.writeFile(join(root, "src", "x.txt"), "x")).rejects.toThrow( KernelMetaError, ); await expect(() => io.mkdir(join(root, "src"))).rejects.toThrow(KernelMetaError); await expect(() => io.rm(join(root, "src"))).rejects.toThrow(KernelMetaError); // A sibling prefix that merely shares a leading segment does not match. await expect(() => io.writeFile(join(root, ".cache-other", "x.txt"), "x")).rejects.toThrow( KernelMetaError, ); } finally { await rm(root, { recursive: true, force: true }); } }); test("createReadOnlyIO: absolute cacheWrites prefixes pass through unchanged (app-scope expansion)", async () => { const root = await mkdtemp(join(tmpdir(), "workspace-io-cachewrites-abs-")); try { const { io: base } = createDefaultIO(); // Simulates the executor expanding "/.cache/" to an absolute site dir. // workspaceRoot covers the whole fixture tree so tmpdir passthrough does // not mask the prefix check. const io = createReadOnlyIO(base, "fixture.cache.command", { workspaceRoot: root, cacheWrites: [join(root, "site", ".cache") + "/"], }); await io.writeFile(join(root, "site", ".cache", "lt.json"), "{}"); expect(await io.readFile(join(root, "site", ".cache", "lt.json"))).toBe("{}"); await expect(() => io.writeFile(join(root, "site", "src", "x.ts"), "x")).rejects.toThrow( KernelMetaError, ); } finally { await rm(root, { recursive: true, force: true }); } }); test("createReadOnlyIO: reads still work", async () => { const root = await mkdtemp(join(tmpdir(), "workspace-io-readonly-reads-")); try { const { io: base } = createDefaultIO(); await base.writeFile(join(root, "readable.txt"), "readable content"); const io = createReadOnlyIO(base, "fixture.readonly.command"); expect(await io.exists(join(root, "readable.txt"))).toBe(true); expect(await io.readFile(join(root, "readable.txt"))).toBe("readable content"); } finally { await rm(root, { recursive: true, force: true }); } }); // RFC-0326: createDefaultIO now returns { io, intents } — the intents array // captures every mutation (write/mkdir/rm) for filesModified reporting. test("createDefaultIO: captures write/mkdir/rm intents while writing to real disk (RFC-0326)", async () => { const root = await mkdtemp(join(tmpdir(), "workspace-io-default-intents-")); try { const { io, intents } = createDefaultIO(); await io.writeFile(join(root, "file.txt"), "content"); await io.mkdir(join(root, "subdir")); await io.rm(join(root, "subdir"), { recursive: true }); expect(intents.length).toBe(3); expect(intents.map((i) => i.kind)).toEqual(["write", "mkdir", "rm"]); expect(intents[0]?.bytes).toBe(Buffer.byteLength("content", "utf8")); // File was actually written to disk (this is the real adapter, not recording). expect(await io.readFile(join(root, "file.txt"))).toBe("content"); } finally { await rm(root, { recursive: true, force: true }); } }); // RFC-1159: execOrThrow — canonical exit-code check on io.exec (DNA-106). function stubIo(result: ExecResult): WorkspaceIO { return { exec: async () => result } as unknown as WorkspaceIO; } test("execOrThrow: returns the ExecResult on exit 0", async () => { const expected: ExecResult = { exitCode: 0, stdout: "ok", stderr: "" }; const result = await execOrThrow(stubIo(expected), "git", ["status"]); expect(result).toBe(expected); }); test("execOrThrow: throws carrying the exit code and stderr on non-zero", async () => { const io = stubIo({ exitCode: 128, stdout: "", stderr: "fatal: not a git repo" }); await expect(execOrThrow(io, "git", ["commit", "-m", "x"])).rejects.toThrow( /git commit failed \(exit 128\): fatal: not a git repo/, ); }); test("execOrThrow: allowExitCodes passes through a listed non-zero exit", async () => { const expected: ExecResult = { exitCode: 1, stdout: "", stderr: "" }; const result = await execOrThrow(stubIo(expected), "git", ["diff", "--quiet"], { allowExitCodes: [1], }); expect(result).toBe(expected); }); test("execOrThrow: a non-zero exit not in allowExitCodes still throws", async () => { const io = stubIo({ exitCode: 2, stdout: "", stderr: "boom" }); await expect( execOrThrow(io, "git", ["diff", "--quiet"], { allowExitCodes: [1] }), ).rejects.toThrow(/git diff failed \(exit 2\)/); }); test("execOrThrow: null exitCode (signal kill) throws", async () => { const io = stubIo({ exitCode: null, stdout: "", stderr: "killed" }); await expect(execOrThrow(io, "git", ["status"])).rejects.toThrow(/exit null/); }); test("execOrThrow: forwards ExecOptions minus allowExitCodes to io.exec", async () => { let seenOpts: unknown; const io = { exec: async (_c: string, _a: string[], opts?: unknown) => { seenOpts = opts; return { exitCode: 0, stdout: "", stderr: "" }; }, } as unknown as WorkspaceIO; await execOrThrow(io, "git", ["status"], { cwd: "/w", timeoutMs: 5, allowExitCodes: [1] }); expect(seenOpts).toEqual({ cwd: "/w", timeoutMs: 5 }); });