2026-02-19 17:09:46 +09:00
|
|
|
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"
|
|
|
|
|
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
2026-02-16 16:32:33 +09:00
|
|
|
import { createHashlineEditTool } from "./tools"
|
2026-02-20 11:02:07 +09:00
|
|
|
import { computeLineHash } from "./hash-computation"
|
2026-02-16 16:32:33 +09:00
|
|
|
import * as fs from "node:fs"
|
|
|
|
|
import * as os from "node:os"
|
2026-02-20 11:02:07 +09:00
|
|
|
import * as path from "node:path"
|
2026-02-19 17:09:46 +09:00
|
|
|
|
2026-02-20 11:02:07 +09:00
|
|
|
function createMockContext(): ToolContext {
|
2026-02-19 17:09:46 +09:00
|
|
|
return {
|
|
|
|
|
sessionID: "test",
|
|
|
|
|
messageID: "test",
|
|
|
|
|
agent: "test",
|
|
|
|
|
directory: "/tmp",
|
|
|
|
|
worktree: "/tmp",
|
|
|
|
|
abort: new AbortController().signal,
|
2026-02-20 11:02:07 +09:00
|
|
|
metadata: mock(() => {}),
|
2026-02-19 17:09:46 +09:00
|
|
|
ask: async () => {},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 16:32:33 +09:00
|
|
|
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 })
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-20 11:02:07 +09:00
|
|
|
it("applies set_line with 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: [{ type: "set_line", line: `2#${hash}`, text: "modified line2" }],
|
|
|
|
|
},
|
|
|
|
|
createMockContext(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nmodified line2\nline3")
|
|
|
|
|
expect(result).toContain("Successfully")
|
|
|
|
|
expect(result).toContain("Updated file (LINE#ID:content)")
|
|
|
|
|
expect(result).toMatch(/2#[ZPMQVRWSNKTXJBYH]{2}:modified line2/)
|
2026-02-16 16:32:33 +09:00
|
|
|
})
|
|
|
|
|
|
2026-02-20 11:02:07 +09:00
|
|
|
it("applies replace_lines and insert_after", 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: [
|
|
|
|
|
{
|
|
|
|
|
type: "replace_lines",
|
|
|
|
|
start_line: `2#${line2Hash}`,
|
|
|
|
|
end_line: `3#${line3Hash}`,
|
|
|
|
|
text: "replaced",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
type: "insert_after",
|
|
|
|
|
line: `4#${line4Hash}`,
|
|
|
|
|
text: "inserted",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
createMockContext(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nreplaced\nline4\ninserted")
|
2026-02-19 17:09:46 +09:00
|
|
|
})
|
|
|
|
|
|
2026-02-20 11:02:07 +09:00
|
|
|
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: [{ type: "set_line", line: "1#ZZ", text: "new" }],
|
|
|
|
|
},
|
|
|
|
|
createMockContext(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(result).toContain("Error")
|
|
|
|
|
expect(result).toContain("hash")
|
|
|
|
|
})
|
2026-02-19 17:09:46 +09:00
|
|
|
|
2026-02-20 11:02:07 +09:00
|
|
|
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: [{ type: "set_line", line: `1#${line1Hash}`, text: "join(\\n)" }],
|
|
|
|
|
},
|
|
|
|
|
createMockContext(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await tool.execute(
|
|
|
|
|
{
|
|
|
|
|
filePath,
|
|
|
|
|
edits: [{ type: "insert_after", line: `1#${computeLineHash(1, "join(\\n)")}`, text: ["a", "b"] }],
|
|
|
|
|
},
|
|
|
|
|
createMockContext(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(fs.readFileSync(filePath, "utf-8")).toBe("join(\\n)\na\nb\nline2")
|
2026-02-16 16:32:33 +09:00
|
|
|
})
|
|
|
|
|
})
|