test / edit.test.ts
import { execFileSync } from "child_process";
import { promises as fs } from "fs";
import * as path from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { OUT_OF_SCOPE } from "../src/paths";
import { runEdit } from "../src/tools/edit";
import { makeRoot } from "./helpers";
let root: string;
beforeEach(async () => {
root = await makeRoot();
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
const put = (content: string) => fs.writeFile(path.join(root, "f.txt"), content);
const get = () => fs.readFile(path.join(root, "f.txt"), "utf8");
describe("runEdit", () => {
it("replaces a unique occurrence", async () => {
await put("alpha beta gamma");
expect(await runEdit(root, { path: "f.txt", old_string: "beta", new_string: "BETA" })).toBe(
"edited f.txt: 1 replacement(s)",
);
expect(await get()).toBe("alpha BETA gamma");
});
it("errors when old_string is not found", async () => {
await put("alpha");
expect(await runEdit(root, { path: "f.txt", old_string: "zzz", new_string: "y" })).toBe(
"Error: old_string not found in f.txt",
);
});
it("errors on multiple matches without replace_all and leaves the file unchanged", async () => {
await put("x x");
const out = await runEdit(root, { path: "f.txt", old_string: "x", new_string: "y" });
expect(out).toContain("old_string matches 2 places in f.txt");
expect(await get()).toBe("x x");
});
it("replaces all matches with replace_all", async () => {
await put("x x x");
expect(await runEdit(root, { path: "f.txt", old_string: "x", new_string: "y", replace_all: true })).toBe(
"edited f.txt: 3 replacement(s)",
);
expect(await get()).toBe("y y y");
});
it("rejects identical old and new strings", async () => {
await put("a");
expect(await runEdit(root, { path: "f.txt", old_string: "a", new_string: "a" })).toBe(
"Error: old_string and new_string are identical",
);
});
it("rejects an empty old_string", async () => {
await put("a");
expect(await runEdit(root, { path: "f.txt", old_string: "", new_string: "b" })).toBe(
"Error: old_string must not be empty",
);
});
it("errors on a missing file", async () => {
expect(await runEdit(root, { path: "nope.txt", old_string: "a", new_string: "b" })).toBe(
"Error: no such file: nope.txt",
);
});
it("preserves CRLF line endings and matches multi-line strings sent with LF", async () => {
await put("a\r\nb\r\nc\r\n");
await runEdit(root, { path: "f.txt", old_string: "a\nb", new_string: "x\ny" });
expect(await get()).toBe("x\r\ny\r\nc\r\n");
});
it("treats $ sequences in new_string literally", async () => {
await put("price: TBD");
await runEdit(root, { path: "f.txt", old_string: "TBD", new_string: "$& and $1" });
expect(await get()).toBe("price: $& and $1");
});
it("preserves the file mode", async () => {
await put("#!/bin/sh\necho old\n");
await fs.chmod(path.join(root, "f.txt"), 0o755);
await runEdit(root, { path: "f.txt", old_string: "old", new_string: "new" });
expect((await fs.stat(path.join(root, "f.txt"))).mode & 0o777).toBe(0o755);
});
it("rejects binary files", async () => {
await fs.writeFile(path.join(root, "b.bin"), Buffer.from([0x00, 0x01, 0x02]));
expect(await runEdit(root, { path: "b.bin", old_string: "a", new_string: "b" })).toBe(
"Error: binary file, cannot edit as text: b.bin",
);
});
it("rejects out-of-scope paths", async () => {
expect(await runEdit(root, { path: "../x", old_string: "a", new_string: "b" })).toBe(OUT_OF_SCOPE);
});
it("errors on a directory", async () => {
await fs.mkdir(path.join(root, "d"));
expect(await runEdit(root, { path: "d", old_string: "a", new_string: "b" })).toBe(
"Error: is a directory, not a file: d",
);
});
it("refuses non-UTF-8 files and leaves bytes unchanged", async () => {
const bytes = Buffer.from([0x63, 0x61, 0x66, 0xe9, 0x20, 0x54, 0x42, 0x44]);
await fs.writeFile(path.join(root, "f.txt"), bytes);
expect(await runEdit(root, { path: "f.txt", old_string: "TBD", new_string: "OK" })).toBe(
"Error: file is not valid UTF-8, cannot edit: f.txt",
);
expect((await fs.readFile(path.join(root, "f.txt"))).equals(bytes)).toBe(true);
});
it("refuses FIFOs instead of blocking", async () => {
try {
execFileSync("mkfifo", [path.join(root, "pipe")]);
} catch {
return;
}
expect(await runEdit(root, { path: "pipe", old_string: "a", new_string: "b" })).toBe(
"Error: not a regular file: pipe",
);
});
});
test / edit.test.ts
import { execFileSync } from "child_process";
import { promises as fs } from "fs";
import * as path from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { OUT_OF_SCOPE } from "../src/paths";
import { runEdit } from "../src/tools/edit";
import { makeRoot } from "./helpers";
let root: string;
beforeEach(async () => {
root = await makeRoot();
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
const put = (content: string) => fs.writeFile(path.join(root, "f.txt"), content);
const get = () => fs.readFile(path.join(root, "f.txt"), "utf8");
describe("runEdit", () => {
it("replaces a unique occurrence", async () => {
await put("alpha beta gamma");
expect(await runEdit(root, { path: "f.txt", old_string: "beta", new_string: "BETA" })).toBe(
"edited f.txt: 1 replacement(s)",
);
expect(await get()).toBe("alpha BETA gamma");
});
it("errors when old_string is not found", async () => {
await put("alpha");
expect(await runEdit(root, { path: "f.txt", old_string: "zzz", new_string: "y" })).toBe(
"Error: old_string not found in f.txt",
);
});
it("errors on multiple matches without replace_all and leaves the file unchanged", async () => {
await put("x x");
const out = await runEdit(root, { path: "f.txt", old_string: "x", new_string: "y" });
expect(out).toContain("old_string matches 2 places in f.txt");
expect(await get()).toBe("x x");
});
it("replaces all matches with replace_all", async () => {
await put("x x x");
expect(await runEdit(root, { path: "f.txt", old_string: "x", new_string: "y", replace_all: true })).toBe(
"edited f.txt: 3 replacement(s)",
);
expect(await get()).toBe("y y y");
});
it("rejects identical old and new strings", async () => {
await put("a");
expect(await runEdit(root, { path: "f.txt", old_string: "a", new_string: "a" })).toBe(
"Error: old_string and new_string are identical",
);
});
it("rejects an empty old_string", async () => {
await put("a");
expect(await runEdit(root, { path: "f.txt", old_string: "", new_string: "b" })).toBe(
"Error: old_string must not be empty",
);
});
it("errors on a missing file", async () => {
expect(await runEdit(root, { path: "nope.txt", old_string: "a", new_string: "b" })).toBe(
"Error: no such file: nope.txt",
);
});
it("preserves CRLF line endings and matches multi-line strings sent with LF", async () => {
await put("a\r\nb\r\nc\r\n");
await runEdit(root, { path: "f.txt", old_string: "a\nb", new_string: "x\ny" });
expect(await get()).toBe("x\r\ny\r\nc\r\n");
});
it("treats $ sequences in new_string literally", async () => {
await put("price: TBD");
await runEdit(root, { path: "f.txt", old_string: "TBD", new_string: "$& and $1" });
expect(await get()).toBe("price: $& and $1");
});
it("preserves the file mode", async () => {
await put("#!/bin/sh\necho old\n");
await fs.chmod(path.join(root, "f.txt"), 0o755);
await runEdit(root, { path: "f.txt", old_string: "old", new_string: "new" });
expect((await fs.stat(path.join(root, "f.txt"))).mode & 0o777).toBe(0o755);
});
it("rejects binary files", async () => {
await fs.writeFile(path.join(root, "b.bin"), Buffer.from([0x00, 0x01, 0x02]));
expect(await runEdit(root, { path: "b.bin", old_string: "a", new_string: "b" })).toBe(
"Error: binary file, cannot edit as text: b.bin",
);
});
it("rejects out-of-scope paths", async () => {
expect(await runEdit(root, { path: "../x", old_string: "a", new_string: "b" })).toBe(OUT_OF_SCOPE);
});
it("errors on a directory", async () => {
await fs.mkdir(path.join(root, "d"));
expect(await runEdit(root, { path: "d", old_string: "a", new_string: "b" })).toBe(
"Error: is a directory, not a file: d",
);
});
it("refuses non-UTF-8 files and leaves bytes unchanged", async () => {
const bytes = Buffer.from([0x63, 0x61, 0x66, 0xe9, 0x20, 0x54, 0x42, 0x44]);
await fs.writeFile(path.join(root, "f.txt"), bytes);
expect(await runEdit(root, { path: "f.txt", old_string: "TBD", new_string: "OK" })).toBe(
"Error: file is not valid UTF-8, cannot edit: f.txt",
);
expect((await fs.readFile(path.join(root, "f.txt"))).equals(bytes)).toBe(true);
});
it("refuses FIFOs instead of blocking", async () => {
try {
execFileSync("mkfifo", [path.join(root, "pipe")]);
} catch {
return;
}
expect(await runEdit(root, { path: "pipe", old_string: "a", new_string: "b" })).toBe(
"Error: not a regular file: pipe",
);
});
});