import assert from "node:assert/strict"; import { mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test, type TestContext } from "node:test"; import { stripVTControlCharacters } from "node:util"; import { type ExtensionAPI, type ExtensionContext, initTheme, ToolExecutionComponent, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; import type { TUI } from "@earendil-works/pi-tui"; import hashline from "./hashline.ts"; async function fixture(t: TestContext, content: string | Buffer) { const cwd = await mkdtemp(join(tmpdir(), "hashline-test-")); t.after(() => rm(cwd, { recursive: true, force: true })); const path = join(cwd, "file.txt"); await writeFile(path, content); const tools = new Map(); const api = { registerTool: (tool: ToolDefinition) => tools.set(tool.name, tool) } as unknown as ExtensionAPI; hashline(api); return { path, tools, reload: () => hashline(api), async call(name: string, args: object = {}, signal?: AbortSignal) { const result = await tools.get(name)!.execute( "test", { path: "file.txt", ...args }, signal, undefined, { cwd } as ExtensionContext, ); return result.content.filter((part) => part.type === "text").map((part) => part.text).join("\n"); }, }; } function anchors(output: string): string[] { return output.split("\n").filter((row) => row.includes("│")).map((row) => row.split("│")[0]); } test("edit rendering has one layer of padding for calls, results, and errors", async (t) => { const f = await fixture(t, ""); initTheme("dark", false); const component = new ToolExecutionComponent( "edit", "test", { path: "file.txt", edits: [] }, {}, f.tools.get("edit"), { requestRender() {} } as TUI, tmpdir(), ); assert.deepEqual(component.render(80).map((line) => stripVTControlCharacters(line).trimEnd()), [ "", "", " edit file.txt", "", ]); component.updateResult({ content: [{ type: "text", text: "Applied.\n-old\n+new" }], details: {}, isError: false, }); assert.deepEqual(component.render(80).map((line) => stripVTControlCharacters(line).trimEnd()), [ "", "", " edit file.txt", " Applied.", " -old", " +new", "", ]); component.updateResult({ content: [{ type: "text", text: "Ambiguous hashes." }], details: {}, isError: true, }); assert.deepEqual(component.render(80).map((line) => stripVTControlCharacters(line).trimEnd()), [ "", "", " edit file.txt", " Ambiguous hashes.", "", ]); }); test("diff anchors stay in model output but not the visible diff", async (t) => { const f = await fixture(t, "original\n"); const [old] = anchors(await f.call("read")); assert.match(old, /^[A-Za-z]{4}$/); const args = { path: f.path, edits: [{ old: [old], new: ["ABCD│literal"] }] }; const tool = f.tools.get("edit")!; const result = await tool.execute("test", args, undefined, undefined, { cwd: tmpdir() } as ExtensionContext); const modelOutput = result.content.filter((part) => part.type === "text").map((part) => part.text).join("\n"); assert.ok(modelOutput.includes(`-${old}│original`)); assert.match(modelOutput, /^\+[A-Za-z]{4}│ABCD│literal$/m); const snapshot = structuredClone(result); initTheme("dark", false); const component = new ToolExecutionComponent( "edit", "test", args, {}, tool, { requestRender() {} } as TUI, tmpdir(), ); component.updateResult({ ...result, isError: false }); for (const expanded of [false, true]) { component.setExpanded(expanded); const visible = component.render(120).map((line) => stripVTControlCharacters(line).trimEnd()); assert.ok(visible.includes(" -original")); assert.ok(visible.includes(" +ABCD│literal")); assert.ok(!visible.some((line) => line.includes(`-${old}│`))); } assert.deepEqual(result, snapshot); component.updateResult({ content: [{ type: "text", text: "-0123456789abcdef│old session\n+fedcba9876543210│restored" }], details: {}, isError: false, }); const history = stripVTControlCharacters(component.render(120).join("\n")); assert.match(history, /-old session/); assert.match(history, /\+restored/); assert.doesNotMatch(history, /│/); }); test("long edit results are collapsed until expanded", async (t) => { const f = await fixture(t, ""); initTheme("dark", false); const component = new ToolExecutionComponent( "edit", "test", { path: "file.txt", edits: [] }, {}, f.tools.get("edit"), { requestRender() {} } as TUI, tmpdir(), ); component.updateResult({ content: [{ type: "text", text: Array.from({ length: 12 }, (_, i) => `line ${i}`).join("\n") }], details: {}, isError: false, }); const collapsed = stripVTControlCharacters(component.render(80).join("\n")); assert.match(collapsed, /2 more lines/); assert.doesNotMatch(collapsed, /line 11/); component.setExpanded(true); const expanded = stripVTControlCharacters(component.render(80).join("\n")); assert.match(expanded, /line 11/); assert.doesNotMatch(expanded, /more lines/); }); test("references survive external line movement and a fresh extension instance", async (t) => { const f = await fixture(t, "first\ntarget\nlast\n"); const [first, target, last] = anchors(await f.call("read")); await writeFile(f.path, "inserted\nlast\nfirst\ntarget\n"); f.reload(); const moved = anchors(await f.call("read")); assert.deepEqual(moved.slice(1), [last, first, target]); await f.call("edit", { edits: [{ old: [target], new: ["changed"] }] }); assert.equal(await readFile(f.path, "utf8"), "inserted\nlast\nfirst\nchanged\n"); }); test("duplicate lines need unique adjacent context, which is preserved", async (t) => { const original = "left\nsame\nright\nleft\nsame\nother\n"; const f = await fixture(t, original); const [left, same, right, leftAgain, sameAgain] = anchors(await f.call("read")); assert.equal(left, leftAgain); assert.equal(same, sameAgain); await assert.rejects(f.call("edit", { edits: [{ old: [same], new: ["changed"] }] }), /ambiguous/); await assert.rejects(f.call("edit", { edits: [{ before: [left], old: [same], new: ["changed"] }], }), /ambiguous/); assert.equal(await readFile(f.path, "utf8"), original); await f.call("edit", { edits: [{ before: [left], old: [same], after: [right], new: ["changed"] }], }); assert.equal(await readFile(f.path, "utf8"), "left\nchanged\nright\nleft\nsame\nother\n"); }); test("different lines with colliding hashes are resolved by context", async (t) => { const candidates = Array.from({ length: 10_000 }, (_, i) => `collision candidate ${i}`); const f = await fixture(t, candidates.join("\n")); const seen = new Map(); let collision: string[] | undefined; for (let offset = 1; offset <= candidates.length && !collision;) { const page = anchors(await f.call("read", { offset })); for (const [i, anchor] of page.entries()) { assert.match(anchor, /^[A-Za-z]{4}$/); const line = candidates[offset - 1 + i]; const previous = seen.get(anchor); if (previous !== undefined) { collision = [previous, line]; break; } seen.set(anchor, line); } offset += page.length; } assert.ok(collision, "expected a collision in the four-letter anchor space"); const [first, second] = collision; assert.notEqual(first, second); const original = `first context\n${first}\nsecond context\n${second}\n`; await writeFile(f.path, original); const [, firstHash, context, secondHash] = anchors(await f.call("read")); assert.equal(firstHash, secondHash); await assert.rejects(f.call("edit", { edits: [{ old: [secondHash], new: ["changed"] }] }), /ambiguous/); assert.equal(await readFile(f.path, "utf8"), original); await f.call("edit", { edits: [{ before: [context], old: [secondHash], new: ["changed"] }] }); assert.equal(await readFile(f.path, "utf8"), `first context\n${first}\nsecond context\nchanged\n`); }); test("batches resolve against the original, regardless of order or line count changes", async (t) => { const f = await fixture(t, "one\ntwo\nthree\nfour\n"); const [one, two, three, four] = anchors(await f.call("read")); await f.call("edit", { edits: [ { old: [four], new: ["last"] }, { old: [one], new: ["first", "extra"] }, ] }); assert.equal(await readFile(f.path, "utf8"), "first\nextra\ntwo\nthree\nlast\n"); assert.deepEqual(anchors(await f.call("read")).slice(2, 4), [two, three]); }); test("a stale interior line refuses the entire batch without writing", async (t) => { const f = await fixture(t, "one\ntwo\nthree\nfour\n"); const [one, two, three, four] = anchors(await f.call("read")); const external = "one\nexternal\nthree\nfour\n"; await writeFile(f.path, external); await assert.rejects(f.call("edit", { edits: [ { old: [four], new: ["would change"] }, { old: [one, two, three], new: ["replacement"] }, ] }), /stale/); assert.equal(await readFile(f.path, "utf8"), external); }); test("hashes include trailing whitespace and the entire line", async (t) => { const long = "x".repeat(1000); const original = `text\ntext \n${long}a\n${long}b\n`; const f = await fixture(t, original); const hashes = anchors(await f.call("read")); assert.equal(new Set(hashes).size, 4); await writeFile(f.path, original.replace("text \n", "text \n")); await assert.rejects(f.call("edit", { edits: [{ old: [hashes[1]], new: ["changed"] }] }), /stale/); }); test("overlapping ranges and same-position insertions are refused", async (t) => { const original = "one\ntwo\nthree\n"; const f = await fixture(t, original); const [one, two, three] = anchors(await f.call("read")); for (const edits of [ [{ old: [one, two], new: [] }, { old: [two, three], new: [] }], [{ before: [one], old: [], new: ["x"] }, { after: [two], old: [], new: ["y"] }], ]) { await assert.rejects(f.call("edit", { edits }), /Overlapping/); assert.equal(await readFile(f.path, "utf8"), original); } }); test("context may overlap other edits, but is always checked against the original", async (t) => { const f = await fixture(t, "one\ntwo\nthree\n"); const [one, two, three] = anchors(await f.call("read")); await f.call("edit", { edits: [ { old: [one], after: [two], new: ["first"] }, { before: [one], old: [two], after: [three], new: ["second"] }, ] }); assert.equal(await readFile(f.path, "utf8"), "first\nsecond\nthree\n"); }); test("insert before/after, delete, and reuse hashes returned by edit", async (t) => { const f = await fixture(t, "middle\n"); const [middle] = anchors(await f.call("read")); const diff = await f.call("edit", { edits: [ { old: [], after: [middle], new: ["first"] }, { before: [middle], old: [], new: ["last"] }, ] }); assert.equal(await readFile(f.path, "utf8"), "first\nmiddle\nlast\n"); const [first, last] = anchors(diff).map((anchor) => anchor.slice(1)); await f.call("edit", { edits: [{ old: [first, middle, last], new: [] }] }); assert.equal(await readFile(f.path, "utf8"), ""); }); test("empty files can be seeded, but nonempty files require a selector", async (t) => { const f = await fixture(t, ""); assert.match(await f.call("read"), /Empty file/); await f.call("edit", { edits: [{ old: [], new: ["seed"] }] }); assert.equal(await readFile(f.path, "utf8"), "seed\n"); await assert.rejects(f.call("edit", { edits: [{ old: [], new: ["unsafe"] }] }), /context/); }); test("BOM, mixed line endings, and missing final newline survive edits", async (t) => { const f = await fixture(t, "\uFEFFone\r\ntwo\nthree"); const [one, two, three] = anchors(await f.call("read")); await f.call("edit", { edits: [{ old: [one], new: ["first"] }] }); assert.equal(await readFile(f.path, "utf8"), "\uFEFFfirst\r\ntwo\nthree"); await f.call("edit", { edits: [{ before: [three], old: [], new: ["last"] }] }); assert.equal(await readFile(f.path, "utf8"), "\uFEFFfirst\r\ntwo\nthree\r\nlast"); assert.equal(anchors(await f.call("read"))[1], two); }); test("no-op replacements preserve mixed line endings byte-for-byte", async (t) => { const original = "\uFEFFone\r\ntwo\nthree"; const f = await fixture(t, original); const old = anchors(await f.call("read")); assert.match(await f.call("edit", { edits: [{ old, new: ["one", "two", "three"] }] }), /No changes/); assert.equal(await readFile(f.path, "utf8"), original); }); test("blank replacement lines are not deletion, including at unterminated EOF", async (t) => { const f = await fixture(t, "one\ntwo"); const [, two] = anchors(await f.call("read")); await f.call("edit", { edits: [{ old: [two], new: [""] }] }); assert.equal(await readFile(f.path, "utf8"), "one\n\n"); }); test("no-op edits do not write, and replacement text is literal", async (t) => { const f = await fixture(t, "one\n"); const [one] = anchors(await f.call("read")); const before = await stat(f.path); assert.match(await f.call("edit", { edits: [{ old: [one], new: ["one"] }] }), /No changes/); assert.equal((await stat(f.path)).mtimeMs, before.mtimeMs); await f.call("edit", { edits: [{ old: [one], new: [`${one}│one`, "one", "one"] }] }); assert.equal(await readFile(f.path, "utf8"), `${one}│one\none\none\n`); }); test("invalid text and embedded newlines are refused without changing bytes", async (t) => { for (const content of [Buffer.from([0xff, 0xfe, 65, 0]), Buffer.from([97, 0, 98]), Buffer.from([0xff])]) { const f = await fixture(t, content); await assert.rejects(f.call("read"), /UTF-8/); await assert.rejects(f.call("edit", { edits: [{ old: [], new: ["unsafe"] }] }), /UTF-8/); assert.deepEqual(await readFile(f.path), content); } const f = await fixture(t, "one\n"); const [one] = anchors(await f.call("read")); for (const line of ["two\nthree", "two\rthree", "two\0three"]) { await assert.rejects(f.call("edit", { edits: [{ old: [one], new: [line] }] }), /CR\/LF/); assert.equal(await readFile(f.path, "utf8"), "one\n"); } }); test("paged reads provide the same anchors and respect line and byte limits", async (t) => { const f = await fixture(t, Array.from({ length: 2005 }, (_, i) => i.toString(36)).join("\n")); const page = await f.call("read"); assert.equal(anchors(page).length, 2000); assert.match(page, /offset=2001/); const small = await f.call("read", { offset: 2, limit: 2 }); assert.deepEqual(anchors(small), anchors(page).slice(1, 3)); assert.match(small, /offset=4/); assert.equal(anchors(await f.call("read", { offset: 2001 })).length, 5); await assert.rejects(f.call("read", { offset: 2006 }), /beyond/); await writeFile(f.path, ("x".repeat(30_000) + "\n").repeat(3)); const bytes = await f.call("read"); assert.equal(anchors(bytes).length, 1); assert.match(bytes, /offset=2/); await writeFile(f.path, "x".repeat(60_000)); await assert.rejects(f.call("read"), /exceeds/); }); test("parallel edits through symlink aliases do not overwrite each other", async (t) => { const f = await fixture(t, "one\ntwo\n"); const alias = f.path + ".link"; await symlink(f.path, alias); const [one, two] = anchors(await f.call("read")); await Promise.all([ f.call("edit", { edits: [{ old: [one], new: ["first"] }] }), f.call("edit", { path: alias, edits: [{ old: [two], new: ["second"] }] }), ]); assert.equal(await readFile(f.path, "utf8"), "first\nsecond\n"); }); test("image reads delegate to Pi's built-in reader", async (t) => { const png = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jX1sAAAAASUVORK5CYII=", "base64", ); const f = await fixture(t, png); const path = f.path + ".png"; await writeFile(path, png); assert.match(await f.call("read", { path }), /Read image file/); }); test("aborted calls do not write", async (t) => { const f = await fixture(t, "one\n"); const [one] = anchors(await f.call("read")); await assert.rejects(f.call("edit", { edits: [{ old: [one], new: ["changed"] }] }, AbortSignal.abort())); assert.equal(await readFile(f.path, "utf8"), "one\n"); });