opencode/packages/opencode/test/tool/apply_patch.test.ts
Aiden Cline 2ed18ea1fe wip
2026-01-17 20:48:09 -06:00

381 lines
12 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import path from "path"
import * as fs from "fs/promises"
import { ApplyPatchTool } from "../../src/tool/apply_patch"
import { Instance } from "../../src/project/instance"
import { FileTime } from "../../src/file/time"
import { tmpdir } from "../fixture/fixture"
const baseCtx = {
sessionID: "test",
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
}
type AskInput = {
permission: string
patterns: string[]
always: string[]
metadata: { diff: string }
}
type ToolCtx = typeof baseCtx & {
ask: (input: AskInput) => Promise<void>
}
const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
const tool = await ApplyPatchTool.init()
return tool.execute(params, ctx)
}
const makeCtx = () => {
const calls: AskInput[] = []
const ctx: ToolCtx = {
...baseCtx,
ask: async (input) => {
calls.push(input)
},
}
return { ctx, calls }
}
describe("tool.apply_patch freeform", () => {
test("requires patchText", async () => {
const { ctx } = makeCtx()
await expect(execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
})
test("rejects invalid patch format", async () => {
const { ctx } = makeCtx()
await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("apply_patch verification failed")
})
test("rejects empty patch", async () => {
const { ctx } = makeCtx()
const emptyPatch = "*** Begin Patch\n*** End Patch"
await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch")
})
test("applies add/update/delete in one patch", async () => {
await using fixture = await tmpdir()
const { ctx, calls } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const modifyPath = path.join(fixture.path, "modify.txt")
const deletePath = path.join(fixture.path, "delete.txt")
await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8")
await fs.writeFile(deletePath, "obsolete\n", "utf-8")
FileTime.read(ctx.sessionID, modifyPath)
FileTime.read(ctx.sessionID, deletePath)
const patchText =
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"
const result = await execute({ patchText }, ctx)
expect(result.title).toContain("Success. Updated the following files")
expect(result.output).toContain("Success. Updated the following files")
expect(result.metadata.diff).toContain("Index:")
expect(calls.length).toBe(1)
const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8")
expect(added).toBe("created\n")
expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n")
await expect(fs.readFile(deletePath, "utf-8")).rejects.toThrow()
},
})
})
test("applies multiple hunks to one file", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "multi.txt")
await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText =
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("line1\nchanged2\nline3\nchanged4\n")
},
})
})
test("inserts lines with insert-only hunk", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "insert_only.txt")
await fs.writeFile(target, "alpha\nomega\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("alpha\nbeta\nomega\n")
},
})
})
test("appends trailing newline on update", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "no_newline.txt")
await fs.writeFile(target, "no newline at end", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText =
"*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"
await execute({ patchText }, ctx)
const contents = await fs.readFile(target, "utf-8")
expect(contents.endsWith("\n")).toBe(true)
expect(contents).toBe("first line\nsecond line\n")
},
})
})
test("moves file to a new directory", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const original = path.join(fixture.path, "old", "name.txt")
await fs.mkdir(path.dirname(original), { recursive: true })
await fs.writeFile(original, "old content\n", "utf-8")
FileTime.read(ctx.sessionID, original)
const patchText =
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
await execute({ patchText }, ctx)
const moved = path.join(fixture.path, "renamed", "dir", "name.txt")
await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
expect(await fs.readFile(moved, "utf-8")).toBe("new content\n")
},
})
})
test("moves file overwriting existing destination", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const original = path.join(fixture.path, "old", "name.txt")
const destination = path.join(fixture.path, "renamed", "dir", "name.txt")
await fs.mkdir(path.dirname(original), { recursive: true })
await fs.mkdir(path.dirname(destination), { recursive: true })
await fs.writeFile(original, "from\n", "utf-8")
await fs.writeFile(destination, "existing\n", "utf-8")
FileTime.read(ctx.sessionID, original)
const patchText =
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"
await execute({ patchText }, ctx)
await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
expect(await fs.readFile(destination, "utf-8")).toBe("new\n")
},
})
})
test("adds file overwriting existing file", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "duplicate.txt")
await fs.writeFile(target, "old content\n", "utf-8")
const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("new content\n")
},
})
})
test("rejects update when target file is missing", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow(
"apply_patch verification failed: Failed to read file to update",
)
},
})
})
test("rejects delete when file is missing", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow()
},
})
})
test("rejects delete when target is a directory", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const dirPath = path.join(fixture.path, "dir")
await fs.mkdir(dirPath)
const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow()
},
})
})
test("rejects invalid hunk header", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
},
})
})
test("rejects update with missing context", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "modify.txt")
await fs.writeFile(target, "line1\nline2\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
expect(await fs.readFile(target, "utf-8")).toBe("line1\nline2\n")
},
})
})
test("verification failure leaves no side effects", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const patchText =
"*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow()
const createdPath = path.join(fixture.path, "created.txt")
await expect(fs.readFile(createdPath, "utf-8")).rejects.toThrow()
},
})
})
test("supports end of file anchor", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "tail.txt")
await fs.writeFile(target, "alpha\nlast\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("alpha\nend\n")
},
})
})
test("rejects missing second chunk context", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "two_chunks.txt")
await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
await expect(execute({ patchText }, ctx)).rejects.toThrow()
expect(await fs.readFile(target, "utf-8")).toBe("a\nb\nc\nd\n")
},
})
})
test("disambiguates change context with @@ header", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await Instance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "multi_ctx.txt")
await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
await execute({ patchText }, ctx)
expect(await fs.readFile(target, "utf-8")).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n")
},
})
})
})