import { assert, assertEquals, assertMatch, assertRejects, assertStringIncludes, } from "@std/assert"; import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { localEnvironment } from "@berry/api/environment.ts"; import { rootedEnvironment, testExtension } from "@berry/api/testing.ts"; import code from "./mod.ts"; import { tempDir } from "@berry/util"; async function project(run: (root: string) => Promise) { await using rootDir = await tempDir({ prefix: "code-test-" }); const root = rootDir.path; await run(root); } type BashResult = { output: string; exitCode: number; fullOutputPath?: string }; Deno.test("files, commands, and project context all come from the conversation's environment", () => project(async (root) => { // nothing here is at these paths on this machine, so a path that bypasses the environment finds nothing await mkdir(join(root, "project", "src"), { recursive: true }); await mkdir(join(root, "home")); await mkdir(join(root, "bin")); await writeFile(join(root, "AGENTS.md"), "outer rules"); await writeFile(join(root, "project", "AGENTS.md"), "project rules"); await writeFile(join(root, "project", "notes.txt"), "hello\n"); await writeFile(join(root, "home", "todo.txt"), "at home\n"); await writeFile(join(root, "bin", "jj"), ""); const env = rootedEnvironment(root, { cwd: "/project", variables: { PATH: "/bin" } }); const ext = testExtension(code, { env }); assertMatch(await ext.call("read", { path: "notes.txt" }) as string, /│hello$/); assertMatch(await ext.call("read", { path: "/project/notes.txt" }) as string, /│hello$/); assertMatch(await ext.call("read", { path: "~/todo.txt" }) as string, /│at home$/); const [anchor] = (await ext.call("read", { path: "notes.txt" }) as string).split("│"); await ext.call("edit", { path: "notes.txt", edits: [{ old: [anchor], new: ["edited"] }] }); assertEquals(await readFile(join(root, "project", "notes.txt"), "utf8"), "edited\n"); await ext.call("write", { path: "/project/src/new.txt", content: "x" }); assertEquals(await readFile(join(root, "project", "src", "new.txt"), "utf8"), "x"); assertEquals(await ext.call("list", { path: "/project" }), "AGENTS.md\nnotes.txt\nsrc/"); assertEquals( (await ext.call("bash", { command: "cat notes.txt", timeout: 10 }) as BashResult).output, "edited\n", ); const prompt = await ext.prompt(); assertStringIncludes(prompt, "current working directory: /project"); assert(prompt.indexOf("## /AGENTS.md") < prompt.indexOf("## /project/AGENTS.md")); assertStringIncludes(prompt, "project rules"); // its default prose comes first, as the harness prepends it assertStringIncludes(prompt, "guidelines:"); // the commands it has, not this machine's assertStringIncludes(prompt, "available workflow commands: jj"); const commands = prompt.split("\n").find((line) => line.startsWith("available workflow commands:") )!; assert(!commands.includes("tmux")); })); Deno.test(".berry/AGENTS.md replaces only its directory's instructions, even when empty", () => project(async (root) => { await mkdir(join(root, "project", ".berry"), { recursive: true }); await mkdir(join(root, "project", "src")); await writeFile(join(root, "AGENTS.md"), "outer rules"); await writeFile(join(root, "project", "AGENTS.md"), "shared rules"); await writeFile(join(root, "project", "src", "AGENTS.md"), "inner rules"); const override = join(root, "project", ".berry", "AGENTS.md"); await writeFile(override, "berry rules"); const ext = testExtension(code, { env: rootedEnvironment(root, { cwd: "/project/src" }) }); const prompt = await ext.prompt(); assertStringIncludes(prompt, "outer rules"); assertStringIncludes(prompt, "/project/.berry/AGENTS.md"); assertStringIncludes(prompt, "berry rules"); assertStringIncludes(prompt, "inner rules"); assert(!prompt.includes("shared rules")); assert(prompt.indexOf("outer rules") < prompt.indexOf("berry rules")); assert(prompt.indexOf("berry rules") < prompt.indexOf("inner rules")); await writeFile(override, ""); const empty = await ext.prompt(); assert(!empty.includes("shared rules")); assert(!empty.includes("berry rules")); assertStringIncludes(empty, "outer rules"); assertStringIncludes(empty, "inner rules"); await rm(override); assertStringIncludes(await ext.prompt(), "shared rules"); })); Deno.test("list shows types, descends to a depth, and skips opaque directories", () => project(async (root) => { await mkdir(join(root, "src", "deep"), { recursive: true }); await mkdir(join(root, "node_modules", "pkg"), { recursive: true }); await writeFile(join(root, "src", "a.ts"), ""); await writeFile(join(root, "src", "deep", "b.ts"), ""); await symlink(join(root, "src"), join(root, "link")); const ext = testExtension(code, { env: localEnvironment(root) }); assertEquals(await ext.call("list", {}), "link@\nnode_modules/\nsrc/"); assertEquals( await ext.call("list", { depth: 3 }), ["link@", "node_modules/", "src/", "src/a.ts", "src/deep/", "src/deep/b.ts"].join("\n"), ); assertEquals(await ext.call("list", { path: "src" }), "a.ts\ndeep/"); })); Deno.test("list skips paths ignored by nested gitignores", () => project(async (root) => { await mkdir(join(root, "data"), { recursive: true }); await mkdir(join(root, "src"), { recursive: true }); await writeFile(join(root, ".gitignore"), "data/\n*.log\n"); await writeFile(join(root, "src/.gitignore"), "target.ts\n!keep.log\n"); await writeFile(join(root, "data/target.ts"), ""); await writeFile(join(root, "src/target.ts"), ""); await writeFile(join(root, "src/target.log"), ""); await writeFile(join(root, "src/keep.log"), ""); await writeFile(join(root, "target.ts"), ""); const ext = testExtension(code, { env: localEnvironment(root) }); assertEquals(await ext.call("list", {}), ".gitignore\nsrc/\ntarget.ts"); assertEquals( await ext.call("list", { depth: 3 }), [".gitignore", "src/", "src/.gitignore", "src/keep.log", "target.ts"].join("\n"), ); })); Deno.test("write creates parent directories and replaces files", () => project(async (root) => { const ext = testExtension(code, { env: localEnvironment(root) }); await ext.call("write", { path: "a/b/c.txt", content: "one" }); await ext.call("write", { path: "a/b/c.txt", content: "two" }); assertEquals(await readFile(join(root, "a/b/c.txt"), "utf8"), "two"); })); Deno.test("bash reports exit codes without rejecting and streams output", () => project(async (root) => { const ext = testExtension(code, { env: localEnvironment(root) }); const streamed: string[] = []; const result = await ext.call("bash", { command: "echo out; echo err >&2; exit 3", timeout: 10, }, { output: (chunk) => streamed.push(chunk), }) as BashResult; assertEquals(result.exitCode, 3); assertEquals(result.output.split("\n").filter(Boolean).sort(), ["err", "out"]); assertEquals(streamed.join(""), result.output); })); Deno.test("bash requires an explicit timeout before executing a command", () => project(async (root) => { const ext = testExtension(code, { env: localEnvironment(root) }); await assertRejects(() => ext.call("bash", { command: "touch ran" }), Error, "timeout"); assertEquals(await Deno.stat(join(root, "ran")).catch(() => null), null); })); Deno.test("bash rejects on timeout and abort", () => project(async (root) => { const ext = testExtension(code, { env: localEnvironment(root) }); await assertRejects( () => ext.call("bash", { command: "sleep 5", timeout: 0.2 }), Error, "timed out", ); const controller = new AbortController(); setTimeout(() => controller.abort(), 100); await assertRejects(() => ext.call("bash", { command: "sleep 5", timeout: 10 }, { signal: controller.signal }) ); })); Deno.test("long bash output keeps its tail and spills the whole of it", () => project(async (root) => { const ext = testExtension(code, { env: localEnvironment(root) }); const result = await ext.call("bash", { command: "seq 1 5000", timeout: 10 }) as BashResult; const lines = result.output.trim().split("\n"); assertEquals(lines.length, 2000); assertEquals(lines.at(-1), "5000"); assert(result.fullOutputPath); assertEquals((await readFile(result.fullOutputPath, "utf8")).trim().split("\n").length, 5000); }));