- Canonicalize anchors in dedupe keys to handle whitespace variants - Make lines field required in edit operations - Only allow unanchored append/prepend to create missing files - Reorder delete/rename validation to prevent edge cases - Add allow_non_gpt_model and max_prompt_tokens to config schema ```
422 lines
11 KiB
TypeScript
422 lines
11 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"
|
|
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
|
import { createHashlineEditTool } from "./tools"
|
|
import { computeLineHash } from "./hash-computation"
|
|
import { canonicalizeFileText } from "./file-text-canonicalization"
|
|
import * as fs from "node:fs"
|
|
import * as os from "node:os"
|
|
import * as path from "node:path"
|
|
|
|
function createMockContext(): ToolContext {
|
|
return {
|
|
sessionID: "test",
|
|
messageID: "test",
|
|
agent: "test",
|
|
abort: new AbortController().signal,
|
|
metadata: mock(() => {}),
|
|
ask: async () => {},
|
|
} as unknown as ToolContext
|
|
}
|
|
|
|
describe("createHashlineEditTool", () => {
|
|
let tempDir: string
|
|
let tool: ReturnType<typeof createHashlineEditTool>
|
|
|
|
beforeEach(() => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-edit-test-"))
|
|
tool = createHashlineEditTool()
|
|
})
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
})
|
|
|
|
it("applies replace with single LINE#ID anchor", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "test.txt")
|
|
fs.writeFileSync(filePath, "line1\nline2\nline3")
|
|
const hash = computeLineHash(2, "line2")
|
|
|
|
//#when
|
|
const result = await tool.execute(
|
|
{
|
|
filePath,
|
|
edits: [{ op: "replace", pos: `2#${hash}`, lines: "modified line2" }],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nmodified line2\nline3")
|
|
expect(result).toBe(`Updated ${filePath}`)
|
|
})
|
|
|
|
it("applies ranged replace and anchored append", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "test.txt")
|
|
fs.writeFileSync(filePath, "line1\nline2\nline3\nline4")
|
|
const line2Hash = computeLineHash(2, "line2")
|
|
const line3Hash = computeLineHash(3, "line3")
|
|
const line4Hash = computeLineHash(4, "line4")
|
|
|
|
//#when
|
|
await tool.execute(
|
|
{
|
|
filePath,
|
|
edits: [
|
|
{
|
|
op: "replace",
|
|
pos: `2#${line2Hash}`,
|
|
end: `3#${line3Hash}`,
|
|
lines: "replaced",
|
|
},
|
|
{
|
|
op: "append",
|
|
pos: `4#${line4Hash}`,
|
|
lines: "inserted",
|
|
},
|
|
],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nreplaced\nline4\ninserted")
|
|
})
|
|
|
|
it("returns mismatch error on stale anchor", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "test.txt")
|
|
fs.writeFileSync(filePath, "line1\nline2")
|
|
|
|
//#when
|
|
const result = await tool.execute(
|
|
{
|
|
filePath,
|
|
edits: [{ op: "replace", pos: "1#ZZ", lines: "new" }],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
expect(result).toContain("Error")
|
|
expect(result).toContain(">>>")
|
|
})
|
|
|
|
it("does not classify invalid pos format as hash mismatch", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "invalid-format.txt")
|
|
fs.writeFileSync(filePath, "line1\nline2")
|
|
|
|
//#when
|
|
const result = await tool.execute(
|
|
{
|
|
filePath,
|
|
edits: [{ op: "replace", pos: "42", lines: "updated" }],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
expect(result).toContain("Error")
|
|
expect(result.toLowerCase()).not.toContain("hash mismatch")
|
|
})
|
|
|
|
it("preserves literal backslash-n and supports string[] payload", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "test.txt")
|
|
fs.writeFileSync(filePath, "line1\nline2")
|
|
const line1Hash = computeLineHash(1, "line1")
|
|
|
|
//#when
|
|
await tool.execute(
|
|
{
|
|
filePath,
|
|
edits: [{ op: "replace", pos: `1#${line1Hash}`, lines: "join(\\n)" }],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
await tool.execute(
|
|
{
|
|
filePath,
|
|
edits: [{ op: "append", pos: `1#${computeLineHash(1, "join(\\n)")}`, lines: ["a", "b"] }],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
expect(fs.readFileSync(filePath, "utf-8")).toBe("join(\\n)\na\nb\nline2")
|
|
})
|
|
|
|
it("supports anchored prepend and anchored append", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "test.txt")
|
|
fs.writeFileSync(filePath, "line1\nline2\nline3")
|
|
const line1 = computeLineHash(1, "line1")
|
|
const line3 = computeLineHash(3, "line3")
|
|
|
|
//#when
|
|
await tool.execute(
|
|
{
|
|
filePath,
|
|
edits: [
|
|
{ op: "prepend", pos: `3#${line3}`, lines: ["before3"] },
|
|
{ op: "append", pos: `1#${line1}`, lines: ["between"] },
|
|
],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nbetween\nline2\nbefore3\nline3")
|
|
})
|
|
|
|
it("returns error when insert text is empty array", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "test.txt")
|
|
fs.writeFileSync(filePath, "line1\nline2")
|
|
const line1 = computeLineHash(1, "line1")
|
|
|
|
//#when
|
|
const result = await tool.execute(
|
|
{
|
|
filePath,
|
|
edits: [{ op: "append", pos: `1#${line1}`, lines: [] }],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
expect(result).toContain("Error")
|
|
expect(result).toContain("non-empty")
|
|
})
|
|
|
|
it("supports file rename with edits", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "source.txt")
|
|
const renamedPath = path.join(tempDir, "renamed.txt")
|
|
fs.writeFileSync(filePath, "line1\nline2")
|
|
const line2 = computeLineHash(2, "line2")
|
|
|
|
//#when
|
|
const result = await tool.execute(
|
|
{
|
|
filePath,
|
|
rename: renamedPath,
|
|
edits: [{ op: "replace", pos: `2#${line2}`, lines: "line2-updated" }],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
expect(fs.existsSync(filePath)).toBe(false)
|
|
expect(fs.readFileSync(renamedPath, "utf-8")).toBe("line1\nline2-updated")
|
|
expect(result).toBe(`Moved ${filePath} to ${renamedPath}`)
|
|
})
|
|
|
|
it("supports file delete mode", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "delete-me.txt")
|
|
fs.writeFileSync(filePath, "line1")
|
|
|
|
//#when
|
|
const result = await tool.execute(
|
|
{
|
|
filePath,
|
|
delete: true,
|
|
edits: [],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
expect(fs.existsSync(filePath)).toBe(false)
|
|
expect(result).toContain("Successfully deleted")
|
|
})
|
|
|
|
it("creates missing file with append and prepend", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "created.txt")
|
|
|
|
//#when
|
|
const result = await tool.execute(
|
|
{
|
|
filePath,
|
|
edits: [
|
|
{ op: "append", lines: ["line2"] },
|
|
{ op: "prepend", lines: ["line1"] },
|
|
],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
expect(fs.existsSync(filePath)).toBe(true)
|
|
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nline2")
|
|
expect(result).toBe(`Updated ${filePath}`)
|
|
})
|
|
|
|
it("accepts replace with one anchor", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "degrade.txt")
|
|
fs.writeFileSync(filePath, "line1\nline2\nline3")
|
|
const line2Hash = computeLineHash(2, "line2")
|
|
|
|
//#when
|
|
const result = await tool.execute(
|
|
{
|
|
filePath,
|
|
edits: [{ op: "replace", pos: `2#${line2Hash}`, lines: ["line2-updated"] }],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nline2-updated\nline3")
|
|
expect(result).toBe(`Updated ${filePath}`)
|
|
})
|
|
|
|
it("accepts anchored append using end alias", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "alias.txt")
|
|
fs.writeFileSync(filePath, "line1\nline2")
|
|
const line1Hash = computeLineHash(1, "line1")
|
|
|
|
//#when
|
|
await tool.execute(
|
|
{
|
|
filePath,
|
|
edits: [{ op: "append", end: `1#${line1Hash}`, lines: ["inserted"] }],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\ninserted\nline2")
|
|
})
|
|
|
|
it("preserves BOM and CRLF through hashline_edit", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "crlf-bom.txt")
|
|
const bomCrLf = "\uFEFFline1\r\nline2\r\n"
|
|
fs.writeFileSync(filePath, bomCrLf)
|
|
const line2Hash = computeLineHash(2, "line2")
|
|
|
|
//#when
|
|
await tool.execute(
|
|
{
|
|
filePath,
|
|
edits: [{ op: "replace", pos: `2#${line2Hash}`, lines: "line2-updated" }],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
const bytes = fs.readFileSync(filePath)
|
|
expect(bytes[0]).toBe(0xef)
|
|
expect(bytes[1]).toBe(0xbb)
|
|
expect(bytes[2]).toBe(0xbf)
|
|
expect(bytes.toString("utf-8")).toBe("\uFEFFline1\r\nline2-updated\r\n")
|
|
})
|
|
|
|
it("detects LF as line ending when LF appears before CRLF", () => {
|
|
//#given
|
|
const content = "line1\nline2\r\nline3"
|
|
|
|
//#when
|
|
const envelope = canonicalizeFileText(content)
|
|
|
|
//#then
|
|
expect(envelope.lineEnding).toBe("\n")
|
|
})
|
|
|
|
it("detects CRLF as line ending when CRLF appears before LF", () => {
|
|
//#given
|
|
const content = "line1\r\nline2\nline3"
|
|
|
|
//#when
|
|
const envelope = canonicalizeFileText(content)
|
|
|
|
//#then
|
|
expect(envelope.lineEnding).toBe("\r\n")
|
|
})
|
|
|
|
it("rejects delete=true with non-empty edits before normalization", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "delete-reject.txt")
|
|
fs.writeFileSync(filePath, "line1")
|
|
|
|
//#when
|
|
const result = await tool.execute(
|
|
{
|
|
filePath,
|
|
delete: true,
|
|
edits: [{ op: "replace", pos: "1#ZZ", lines: "bad" }],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
expect(result).toContain("delete mode requires edits to be an empty array")
|
|
expect(fs.existsSync(filePath)).toBe(true)
|
|
})
|
|
|
|
it("rejects delete=true combined with rename", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "delete-rename.txt")
|
|
fs.writeFileSync(filePath, "line1")
|
|
|
|
//#when
|
|
const result = await tool.execute(
|
|
{
|
|
filePath,
|
|
delete: true,
|
|
rename: path.join(tempDir, "new-name.txt"),
|
|
edits: [],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
expect(result).toContain("delete and rename cannot be used together")
|
|
expect(fs.existsSync(filePath)).toBe(true)
|
|
})
|
|
|
|
it("rejects missing file creation with anchored append", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "nonexistent.txt")
|
|
|
|
//#when
|
|
const result = await tool.execute(
|
|
{
|
|
filePath,
|
|
edits: [{ op: "append", pos: "1#ZZ", lines: ["bad"] }],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
expect(result).toContain("File not found")
|
|
})
|
|
|
|
it("allows missing file creation with unanchored append", async () => {
|
|
//#given
|
|
const filePath = path.join(tempDir, "newfile.txt")
|
|
|
|
//#when
|
|
const result = await tool.execute(
|
|
{
|
|
filePath,
|
|
edits: [{ op: "append", lines: ["created"] }],
|
|
},
|
|
createMockContext(),
|
|
)
|
|
|
|
//#then
|
|
expect(fs.existsSync(filePath)).toBe(true)
|
|
expect(fs.readFileSync(filePath, "utf-8")).toBe("created")
|
|
expect(result).toBe(`Updated ${filePath}`)
|
|
})
|
|
})
|