test(tool): migrate apply patch tests to Effect runner (#27100)

This commit is contained in:
Kit Langton
2026-05-12 16:43:33 +00:00
committed by GitHub
parent 8115004c73
commit a16789dfdd
+392 -479
View File
@@ -1,20 +1,19 @@
import { describe, expect, test } from "bun:test" import { describe, expect } from "bun:test"
import path from "path" import path from "path"
import * as fs from "fs/promises" import * as fs from "fs/promises"
import { Effect, ManagedRuntime, Layer } from "effect" import { Cause, Effect, Exit, Layer } from "effect"
import { ApplyPatchTool } from "../../src/tool/apply_patch" import { ApplyPatchTool } from "../../src/tool/apply_patch"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { LSP } from "@/lsp/lsp" import { LSP } from "@/lsp/lsp"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Format } from "../../src/format" import { Format } from "../../src/format"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncate"
import { tmpdir } from "../fixture/fixture" import { TestInstance } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { testEffect } from "../lib/effect"
const runtime = ManagedRuntime.make( const it = testEffect(
Layer.mergeAll( Layer.mergeAll(
LSP.defaultLayer, LSP.defaultLayer,
AppFileSystem.defaultLayer, AppFileSystem.defaultLayer,
@@ -58,11 +57,11 @@ type ToolCtx = typeof baseCtx & {
ask: (input: AskInput) => Effect.Effect<void> ask: (input: AskInput) => Effect.Effect<void>
} }
const execute = async (params: { patchText: string }, ctx: ToolCtx) => { const execute = Effect.fn("ApplyPatchToolTest.execute")(function* (params: { patchText: string }, ctx: ToolCtx) {
const info = await runtime.runPromise(ApplyPatchTool) const info = yield* ApplyPatchTool
const tool = await runtime.runPromise(info.init()) const tool = yield* info.init()
return Effect.runPromise(tool.execute(params, ctx)) return yield* tool.execute(params, ctx)
} })
const makeCtx = () => { const makeCtx = () => {
const calls: AskInput[] = [] const calls: AskInput[] = []
@@ -77,39 +76,56 @@ const makeCtx = () => {
return { ctx, calls } return { ctx, calls }
} }
const readText = (filepath: string) => Effect.promise(() => fs.readFile(filepath, "utf-8"))
const writeText = (filepath: string, content: string) => Effect.promise(() => fs.writeFile(filepath, content, "utf-8"))
const makeDir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
const expectFailure = <A, E, R>(effect: Effect.Effect<A, E, R>, message?: string) =>
Effect.gen(function* () {
const exit = yield* Effect.exit(effect)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit) && message) expect(Cause.pretty(exit.cause)).toContain(message)
})
const expectReadFailure = (filepath: string) => expectFailure(readText(filepath))
describe("tool.apply_patch freeform", () => { describe("tool.apply_patch freeform", () => {
test("requires patchText", async () => { it.live("requires patchText", () =>
const { ctx } = makeCtx() Effect.gen(function* () {
await expect(execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required") const { ctx } = makeCtx()
}) yield* expectFailure(execute({ patchText: "" }, ctx), "patchText is required")
}),
)
test("rejects invalid patch format", async () => { it.live("rejects invalid patch format", () =>
const { ctx } = makeCtx() Effect.gen(function* () {
await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("apply_patch verification failed") const { ctx } = makeCtx()
}) yield* expectFailure(execute({ patchText: "invalid patch" }, ctx), "apply_patch verification failed")
}),
)
test("rejects empty patch", async () => { it.live("rejects empty patch", () =>
const { ctx } = makeCtx() Effect.gen(function* () {
const emptyPatch = "*** Begin Patch\n*** End Patch" const { ctx } = makeCtx()
await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch") yield* expectFailure(execute({ patchText: "*** Begin Patch\n*** End Patch" }, ctx), "patch rejected: empty patch")
}) }),
)
test("applies add/update/delete in one patch", async () => { it.instance(
await using fixture = await tmpdir({ git: true }) "applies add/update/delete in one patch",
const { ctx, calls } = makeCtx() () =>
Effect.gen(function* () {
await WithInstance.provide({ const test = yield* TestInstance
directory: fixture.path, const { ctx, calls } = makeCtx()
fn: async () => { const modifyPath = path.join(test.directory, "modify.txt")
const modifyPath = path.join(fixture.path, "modify.txt") const deletePath = path.join(test.directory, "delete.txt")
const deletePath = path.join(fixture.path, "delete.txt") yield* writeText(modifyPath, "line1\nline2\n")
await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8") yield* writeText(deletePath, "obsolete\n")
await fs.writeFile(deletePath, "obsolete\n", "utf-8")
const patchText = 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" "*** 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) const result = yield* execute({ patchText }, ctx)
expect(result.title).toContain("Success. Updated the following files") expect(result.title).toContain("Success. Updated the following files")
expect(result.output).toContain("Success. Updated the following files") expect(result.output).toContain("Success. Updated the following files")
@@ -129,38 +145,34 @@ describe("tool.apply_patch freeform", () => {
expect(permissionCall.metadata.files.map((f) => f.type).sort()).toEqual(["add", "delete", "update"]) expect(permissionCall.metadata.files.map((f) => f.type).sort()).toEqual(["add", "delete", "update"])
const addFile = permissionCall.metadata.files.find((f) => f.type === "add") const addFile = permissionCall.metadata.files.find((f) => f.type === "add")
expect(addFile).toBeDefined() expect(addFile?.relativePath).toBe("nested/new.txt")
expect(addFile!.relativePath).toBe("nested/new.txt") expect(addFile?.patch).toContain("+created")
expect(addFile!.patch).toContain("+created")
const updateFile = permissionCall.metadata.files.find((f) => f.type === "update") const updateFile = permissionCall.metadata.files.find((f) => f.type === "update")
expect(updateFile).toBeDefined() expect(updateFile?.patch).toContain("-line2")
expect(updateFile!.patch).toContain("-line2") expect(updateFile?.patch).toContain("+changed")
expect(updateFile!.patch).toContain("+changed")
const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8") expect(yield* readText(path.join(test.directory, "nested", "new.txt"))).toBe("created\n")
expect(added).toBe("created\n") expect(yield* readText(modifyPath)).toBe("line1\nchanged\n")
expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n") yield* expectReadFailure(deletePath)
await expect(fs.readFile(deletePath, "utf-8")).rejects.toThrow() }),
}, { git: true },
}) )
})
test("permission metadata includes move file info", async () => { it.instance(
await using fixture = await tmpdir({ git: true }) "permission metadata includes move file info",
const { ctx, calls } = makeCtx() () =>
Effect.gen(function* () {
await WithInstance.provide({ const test = yield* TestInstance
directory: fixture.path, const { ctx, calls } = makeCtx()
fn: async () => { const original = path.join(test.directory, "old", "name.txt")
const original = path.join(fixture.path, "old", "name.txt") yield* makeDir(path.dirname(original))
await fs.mkdir(path.dirname(original), { recursive: true }) yield* writeText(original, "old content\n")
await fs.writeFile(original, "old content\n", "utf-8")
const patchText = 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" "*** 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) yield* execute({ patchText }, ctx)
expect(calls.length).toBe(1) expect(calls.length).toBe(1)
const permissionCall = calls[0] const permissionCall = calls[0]
@@ -169,447 +181,348 @@ describe("tool.apply_patch freeform", () => {
const moveFile = permissionCall.metadata.files[0] const moveFile = permissionCall.metadata.files[0]
expect(moveFile.type).toBe("move") expect(moveFile.type).toBe("move")
expect(moveFile.relativePath).toBe("renamed/dir/name.txt") expect(moveFile.relativePath).toBe("renamed/dir/name.txt")
expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt")) expect(moveFile.movePath).toBe(path.join(test.directory, "renamed/dir/name.txt"))
expect(moveFile.patch).toContain("-old content") expect(moveFile.patch).toContain("-old content")
expect(moveFile.patch).toContain("+new content") expect(moveFile.patch).toContain("+new content")
}, }),
}) { git: true },
}) )
test("applies multiple hunks to one file", async () => { it.instance("applies multiple hunks to one file", () =>
await using fixture = await tmpdir() Effect.gen(function* () {
const { ctx } = makeCtx() const test = yield* TestInstance
const { ctx } = makeCtx()
await WithInstance.provide({ const target = path.join(test.directory, "multi.txt")
directory: fixture.path, yield* writeText(target, "line1\nline2\nline3\nline4\n")
fn: async () => {
const target = path.join(fixture.path, "multi.txt") const patchText =
await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8") "*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
const patchText = yield* execute({ patchText }, ctx)
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
expect(yield* readText(target)).toBe("line1\nchanged2\nline3\nchanged4\n")
await execute({ patchText }, ctx) }),
)
expect(await fs.readFile(target, "utf-8")).toBe("line1\nchanged2\nline3\nchanged4\n")
}, it.instance("does not invent a first-line diff for BOM files", () =>
}) Effect.gen(function* () {
}) const test = yield* TestInstance
const { ctx, calls } = makeCtx()
test("does not invent a first-line diff for BOM files", async () => { const bom = String.fromCharCode(0xfeff)
await using fixture = await tmpdir() const target = path.join(test.directory, "example.cs")
const { ctx, calls } = makeCtx() yield* writeText(target, `${bom}using System;\n\nclass Test {}\n`)
await WithInstance.provide({ const patchText = "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"
directory: fixture.path,
fn: async () => { yield* execute({ patchText }, ctx)
const bom = String.fromCharCode(0xfeff)
const target = path.join(fixture.path, "example.cs") expect(calls.length).toBe(1)
await fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`, "utf-8") const shown = calls[0].metadata.files[0]?.patch ?? ""
expect(shown).not.toContain(bom)
const patchText = expect(shown).not.toContain("-using System;")
"*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" expect(shown).not.toContain("+using System;")
await execute({ patchText }, ctx) const content = yield* readText(target)
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(calls.length).toBe(1) expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n")
const shown = calls[0].metadata.files[0]?.patch ?? "" }),
expect(shown).not.toContain(bom) )
expect(shown).not.toContain("-using System;")
expect(shown).not.toContain("+using System;") it.instance("inserts lines with insert-only hunk", () =>
Effect.gen(function* () {
const content = await fs.readFile(target, "utf-8") const test = yield* TestInstance
expect(content.charCodeAt(0)).toBe(0xfeff) const { ctx } = makeCtx()
expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n") const target = path.join(test.directory, "insert_only.txt")
}, yield* writeText(target, "alpha\nomega\n")
})
}) const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
test("inserts lines with insert-only hunk", async () => { yield* execute({ patchText }, ctx)
await using fixture = await tmpdir()
const { ctx } = makeCtx() expect(yield* readText(target)).toBe("alpha\nbeta\nomega\n")
}),
await WithInstance.provide({ )
directory: fixture.path,
fn: async () => { it.instance("appends trailing newline on update", () =>
const target = path.join(fixture.path, "insert_only.txt") Effect.gen(function* () {
await fs.writeFile(target, "alpha\nomega\n", "utf-8") const test = yield* TestInstance
const { ctx } = makeCtx()
const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch" const target = path.join(test.directory, "no_newline.txt")
yield* writeText(target, "no newline at end")
await execute({ patchText }, ctx)
const patchText =
expect(await fs.readFile(target, "utf-8")).toBe("alpha\nbeta\nomega\n") "*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"
},
}) yield* execute({ patchText }, ctx)
})
const contents = yield* readText(target)
test("appends trailing newline on update", async () => { expect(contents.endsWith("\n")).toBe(true)
await using fixture = await tmpdir() expect(contents).toBe("first line\nsecond line\n")
const { ctx } = makeCtx() }),
)
await WithInstance.provide({
directory: fixture.path, it.instance("moves file to a new directory", () =>
fn: async () => { Effect.gen(function* () {
const target = path.join(fixture.path, "no_newline.txt") const test = yield* TestInstance
await fs.writeFile(target, "no newline at end", "utf-8") const { ctx } = makeCtx()
const original = path.join(test.directory, "old", "name.txt")
const patchText = yield* makeDir(path.dirname(original))
"*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch" yield* writeText(original, "old content\n")
await execute({ patchText }, ctx) 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"
const contents = await fs.readFile(target, "utf-8")
expect(contents.endsWith("\n")).toBe(true) yield* execute({ patchText }, ctx)
expect(contents).toBe("first line\nsecond line\n")
}, const moved = path.join(test.directory, "renamed", "dir", "name.txt")
}) yield* expectReadFailure(original)
}) expect(yield* readText(moved)).toBe("new content\n")
}),
test("moves file to a new directory", async () => { )
await using fixture = await tmpdir()
const { ctx } = makeCtx() it.instance("moves file overwriting existing destination", () =>
Effect.gen(function* () {
await WithInstance.provide({ const test = yield* TestInstance
directory: fixture.path, const { ctx } = makeCtx()
fn: async () => { const original = path.join(test.directory, "old", "name.txt")
const original = path.join(fixture.path, "old", "name.txt") const destination = path.join(test.directory, "renamed", "dir", "name.txt")
await fs.mkdir(path.dirname(original), { recursive: true }) yield* makeDir(path.dirname(original))
await fs.writeFile(original, "old content\n", "utf-8") yield* makeDir(path.dirname(destination))
yield* writeText(original, "from\n")
const patchText = yield* writeText(destination, "existing\n")
"*** 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"
const patchText =
await execute({ patchText }, ctx) "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"
const moved = path.join(fixture.path, "renamed", "dir", "name.txt") yield* execute({ patchText }, ctx)
await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
expect(await fs.readFile(moved, "utf-8")).toBe("new content\n") yield* expectReadFailure(original)
}, expect(yield* readText(destination)).toBe("new\n")
}) }),
}) )
test("moves file overwriting existing destination", async () => { it.instance("adds file overwriting existing file", () =>
await using fixture = await tmpdir() Effect.gen(function* () {
const { ctx } = makeCtx() const test = yield* TestInstance
const { ctx } = makeCtx()
await WithInstance.provide({ const target = path.join(test.directory, "duplicate.txt")
directory: fixture.path, yield* writeText(target, "old content\n")
fn: async () => {
const original = path.join(fixture.path, "old", "name.txt") const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"
const destination = path.join(fixture.path, "renamed", "dir", "name.txt")
await fs.mkdir(path.dirname(original), { recursive: true }) yield* execute({ patchText }, ctx)
await fs.mkdir(path.dirname(destination), { recursive: true }) expect(yield* readText(target)).toBe("new content\n")
await fs.writeFile(original, "from\n", "utf-8") }),
await fs.writeFile(destination, "existing\n", "utf-8") )
const patchText = it.instance("rejects update when target file is missing", () =>
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch" Effect.gen(function* () {
const { ctx } = makeCtx()
await execute({ patchText }, ctx) const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch"
await expect(fs.readFile(original, "utf-8")).rejects.toThrow() yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed: Failed to read file to update")
expect(await fs.readFile(destination, "utf-8")).toBe("new\n") }),
}, )
})
}) it.instance("rejects delete when file is missing", () =>
Effect.gen(function* () {
test("adds file overwriting existing file", async () => { const { ctx } = makeCtx()
await using fixture = await tmpdir() const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"
const { ctx } = makeCtx()
yield* expectFailure(execute({ patchText }, ctx))
await WithInstance.provide({ }),
directory: fixture.path, )
fn: async () => {
const target = path.join(fixture.path, "duplicate.txt") it.instance("rejects delete when target is a directory", () =>
await fs.writeFile(target, "old content\n", "utf-8") Effect.gen(function* () {
const test = yield* TestInstance
const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch" const { ctx } = makeCtx()
const dirPath = path.join(test.directory, "dir")
await execute({ patchText }, ctx) yield* makeDir(dirPath)
expect(await fs.readFile(target, "utf-8")).toBe("new content\n")
}, const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"
})
}) yield* expectFailure(execute({ patchText }, ctx))
}),
test("rejects update when target file is missing", async () => { )
await using fixture = await tmpdir()
const { ctx } = makeCtx() it.instance("rejects invalid hunk header", () =>
Effect.gen(function* () {
await WithInstance.provide({ const { ctx } = makeCtx()
directory: fixture.path, const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"
fn: async () => {
const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch" yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed")
}),
await expect(execute({ patchText }, ctx)).rejects.toThrow( )
"apply_patch verification failed: Failed to read file to update",
) it.instance("rejects update with missing context", () =>
}, Effect.gen(function* () {
}) const test = yield* TestInstance
}) const { ctx } = makeCtx()
const target = path.join(test.directory, "modify.txt")
test("rejects delete when file is missing", async () => { yield* writeText(target, "line1\nline2\n")
await using fixture = await tmpdir()
const { ctx } = makeCtx() const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
await WithInstance.provide({ yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed")
directory: fixture.path, expect(yield* readText(target)).toBe("line1\nline2\n")
fn: async () => { }),
const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch" )
await expect(execute({ patchText }, ctx)).rejects.toThrow() it.instance("verification failure leaves no side effects", () =>
}, Effect.gen(function* () {
}) const test = yield* TestInstance
}) const { ctx } = makeCtx()
const patchText =
test("rejects delete when target is a directory", async () => { "*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"
await using fixture = await tmpdir()
const { ctx } = makeCtx() yield* expectFailure(execute({ patchText }, ctx))
yield* expectReadFailure(path.join(test.directory, "created.txt"))
await WithInstance.provide({ }),
directory: fixture.path, )
fn: async () => {
const dirPath = path.join(fixture.path, "dir") it.instance("supports end of file anchor", () =>
await fs.mkdir(dirPath) Effect.gen(function* () {
const test = yield* TestInstance
const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch" const { ctx } = makeCtx()
const target = path.join(test.directory, "tail.txt")
await expect(execute({ patchText }, ctx)).rejects.toThrow() yield* writeText(target, "alpha\nlast\n")
},
}) const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
})
yield* execute({ patchText }, ctx)
test("rejects invalid hunk header", async () => { expect(yield* readText(target)).toBe("alpha\nend\n")
await using fixture = await tmpdir() }),
const { ctx } = makeCtx() )
await WithInstance.provide({ it.instance("rejects missing second chunk context", () =>
directory: fixture.path, Effect.gen(function* () {
fn: async () => { const test = yield* TestInstance
const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch" const { ctx } = makeCtx()
const target = path.join(test.directory, "two_chunks.txt")
await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed") yield* writeText(target, "a\nb\nc\nd\n")
},
}) const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
})
yield* expectFailure(execute({ patchText }, ctx))
test("rejects update with missing context", async () => { expect(yield* readText(target)).toBe("a\nb\nc\nd\n")
await using fixture = await tmpdir() }),
const { ctx } = makeCtx() )
await WithInstance.provide({ it.instance("disambiguates change context with @@ header", () =>
directory: fixture.path, Effect.gen(function* () {
fn: async () => { const test = yield* TestInstance
const target = path.join(fixture.path, "modify.txt") const { ctx } = makeCtx()
await fs.writeFile(target, "line1\nline2\n", "utf-8") const target = path.join(test.directory, "multi_ctx.txt")
yield* writeText(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n")
const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\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") yield* execute({ patchText }, ctx)
}, expect(yield* readText(target)).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n")
}) }),
}) )
test("verification failure leaves no side effects", async () => { it.instance("EOF anchor matches from end of file first", () =>
await using fixture = await tmpdir() Effect.gen(function* () {
const { ctx } = makeCtx() const test = yield* TestInstance
const { ctx } = makeCtx()
await WithInstance.provide({ const target = path.join(test.directory, "eof_anchor.txt")
directory: fixture.path, // File has duplicate "marker" lines - one in middle, one at end
fn: async () => { yield* writeText(target, "start\nmarker\nmiddle\nmarker\nend\n")
const patchText =
"*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch" // With EOF anchor, should match the LAST "marker" line, not the first
const patchText =
await expect(execute({ patchText }, ctx)).rejects.toThrow() "*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch"
const createdPath = path.join(fixture.path, "created.txt") yield* execute({ patchText }, ctx)
await expect(fs.readFile(createdPath, "utf-8")).rejects.toThrow() // First marker unchanged, second marker changed
}, expect(yield* readText(target)).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n")
}) }),
}) )
test("supports end of file anchor", async () => { it.instance("parses heredoc-wrapped patch", () =>
await using fixture = await tmpdir() Effect.gen(function* () {
const { ctx } = makeCtx() const test = yield* TestInstance
const { ctx } = makeCtx()
await WithInstance.provide({ const patchText = `cat <<'EOF'
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "tail.txt")
await fs.writeFile(target, "alpha\nlast\n", "utf-8")
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 WithInstance.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")
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 WithInstance.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")
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")
},
})
})
test("EOF anchor matches from end of file first", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "eof_anchor.txt")
// File has duplicate "marker" lines - one in middle, one at end
await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8")
// With EOF anchor, should match the LAST "marker" line, not the first
const patchText =
"*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch"
await execute({ patchText }, ctx)
// First marker unchanged, second marker changed
expect(await fs.readFile(target, "utf-8")).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n")
},
})
})
test("parses heredoc-wrapped patch", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
await WithInstance.provide({
directory: fixture.path,
fn: async () => {
const patchText = `cat <<'EOF'
*** Begin Patch *** Begin Patch
*** Add File: heredoc_test.txt *** Add File: heredoc_test.txt
+heredoc content +heredoc content
*** End Patch *** End Patch
EOF` EOF`
await execute({ patchText }, ctx) yield* execute({ patchText }, ctx)
const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8") expect(yield* readText(path.join(test.directory, "heredoc_test.txt"))).toBe("heredoc content\n")
expect(content).toBe("heredoc content\n") }),
}, )
})
})
test("parses heredoc-wrapped patch without cat", async () => { it.instance("parses heredoc-wrapped patch without cat", () =>
await using fixture = await tmpdir() Effect.gen(function* () {
const { ctx } = makeCtx() const test = yield* TestInstance
const { ctx } = makeCtx()
await WithInstance.provide({ const patchText = `<<EOF
directory: fixture.path,
fn: async () => {
const patchText = `<<EOF
*** Begin Patch *** Begin Patch
*** Add File: heredoc_no_cat.txt *** Add File: heredoc_no_cat.txt
+no cat prefix +no cat prefix
*** End Patch *** End Patch
EOF` EOF`
await execute({ patchText }, ctx) yield* execute({ patchText }, ctx)
const content = await fs.readFile(path.join(fixture.path, "heredoc_no_cat.txt"), "utf-8") expect(yield* readText(path.join(test.directory, "heredoc_no_cat.txt"))).toBe("no cat prefix\n")
expect(content).toBe("no cat prefix\n") }),
}, )
})
})
test("matches with trailing whitespace differences", async () => { it.instance("matches with trailing whitespace differences", () =>
await using fixture = await tmpdir() Effect.gen(function* () {
const { ctx } = makeCtx() const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "trailing_ws.txt")
// File has trailing spaces on some lines
yield* writeText(target, "line1 \nline2\nline3 \n")
await WithInstance.provide({ // Patch doesn't have trailing spaces - should still match via rstrip pass
directory: fixture.path, const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
fn: async () => {
const target = path.join(fixture.path, "trailing_ws.txt")
// File has trailing spaces on some lines
await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8")
// Patch doesn't have trailing spaces - should still match via rstrip pass yield* execute({ patchText }, ctx)
const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch" expect(yield* readText(target)).toBe("line1 \nchanged\nline3 \n")
}),
)
await execute({ patchText }, ctx) it.instance("matches with leading whitespace differences", () =>
expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n") Effect.gen(function* () {
}, const test = yield* TestInstance
}) const { ctx } = makeCtx()
}) const target = path.join(test.directory, "leading_ws.txt")
// File has leading spaces
yield* writeText(target, " line1\nline2\n line3\n")
test("matches with leading whitespace differences", async () => { // Patch without leading spaces - should match via trim pass
await using fixture = await tmpdir() const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
const { ctx } = makeCtx()
await WithInstance.provide({ yield* execute({ patchText }, ctx)
directory: fixture.path, expect(yield* readText(target)).toBe(" line1\nchanged\n line3\n")
fn: async () => { }),
const target = path.join(fixture.path, "leading_ws.txt") )
// File has leading spaces
await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8")
// Patch without leading spaces - should match via trim pass it.instance("matches with Unicode punctuation differences", () =>
const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch" Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "unicode.txt")
// File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014)
const leftQuote = "\u201C"
const rightQuote = "\u201D"
const emDash = "\u2014"
yield* writeText(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`)
await execute({ patchText }, ctx) // Patch uses ASCII equivalents - should match via normalized pass
expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n") // The replacement uses ASCII quotes from the patch (not preserving Unicode)
}, const patchText = '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch'
})
})
test("matches with Unicode punctuation differences", async () => { yield* execute({ patchText }, ctx)
await using fixture = await tmpdir() // Result has ASCII quotes because that's what the patch specifies
const { ctx } = makeCtx() expect(yield* readText(target)).toBe(`He said "hi"\nsome${emDash}dash\nend\n`)
}),
await WithInstance.provide({ )
directory: fixture.path,
fn: async () => {
const target = path.join(fixture.path, "unicode.txt")
// File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014)
const leftQuote = "\u201C"
const rightQuote = "\u201D"
const emDash = "\u2014"
await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8")
// Patch uses ASCII equivalents - should match via normalized pass
// The replacement uses ASCII quotes from the patch (not preserving Unicode)
const patchText =
'*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch'
await execute({ patchText }, ctx)
// Result has ASCII quotes because that's what the patch specifies
expect(await fs.readFile(target, "utf-8")).toBe(`He said "hi"\nsome${emDash}dash\nend\n`)
},
})
})
}) })