1b5ef0737f
* chore: details * fix(tui): add visual feedback for diff wrap and conceal toggles (#10655) * fix: await SessionRevert.cleanup for shell (#10669) * fix: Make diff wrapping toggle always available in command_list and correct a type error (resolves #10682) (#10683) * feat(app): default servers on web * fix(app): handle non-tool call permissions * fix(app): line selection waits on ready * wip(app): model settings * feat(app): model settings * chore: generate * wip(app): sidebar hover full * wip(app): full-height sidebar * wip(app): full-height sidebar * feat(app): better sidebar hover when collapsed * fix(app): query selector with non-latin chars * chore: stale-while-revalidate headers for changelog * chore: generate * feat(opencode): add agent description (#10680) * feat: dynamically resolve AGENTS.md files from subdirectories as agent explores them (#10678) * chore: gen changelog page off changelog json * upgrade opentui to v0.1.75 * chore: update nix node_modules hashes * fix: move changelog footer outside content div to fix padding (#10712) * fix(app): Order themes alphabetically (#10698) * fix(app): restore external link opening in system browser (#10697) * fix(ui): use focus-visible instead of focus to prevent sticky hover effect on click (#10651) * fix: add 44px top padding to sticky version header on changelog (#10715) * feat(app): file tree * wip(app): file tree mode * wip(app): file tree mode * chore: cleanup * chore: cleanup * chore(app): createStore over signals * fix(enterprise): add message navigation to share page desktop view (#10071) Co-authored-by: Claude <noreply@anthropic.com> * chore: fix changelog page * Revert "fix(app): restore external link opening in system browser (#10697)" This reverts commit984518b1c0. * ignore: tweak ai deps * feat: use anthropic compat messages api for anthropic models through copilot * chore: debug changelog * fix: ensure openai 404 errors are retried (#10590) * chore: generate * test(app): e2e test for sidebar nav * chore: refactor changelog * fix(app): session load cap * chore(app): missing i18n strings * chore: cleanup * fix(app): session sync issue * chore: generate * fix(ui): update button styles and disconnect button size * fix(app): set provider row height to 56px * fix(app): add hover text for env-connected providers * fix(app): use default cursor for env provider text * feat(ui): add providers icon and use in settings * feat(ui): add models icon and use in settings * fix(app): style view all button with interactive color and margin * feat(ui): add optional transition animations to dialog * fix(ui): prevent double-close and fix dialog replacement * feat(app): add transition to select provider dialog * feat(app): add transition to command palette * fix(ui): reduce dialog transition in time to 150ms * feat(app): add provider descriptions to settings * feat(app): add Vercel AI Gateway provider description * fix(ui): scope filetree pill tabs styling * fix(app): adjust filetree panel padding * fix(app): tighten filetree row spacing * fix(app): use chevron icons for filetree folders * fix(app): use medium font for filetree items * fix(app): refine filetree row spacing and indent * fix(app): reduce filetree folder indent * fix(app): dim non-deep filetree guide lines * fix(app): fade filetree guide lines on hover * fix(app): move file tree toggle to right * fix(app): disable tooltips in filetree tabs * test(app): fix e2e test * ci: add stale pr job * ci: add dry-run option to stale PR closer workflow Allows testing stale PR closure without actually closing PRs * tweak: retry logic to catch certain provider problems * fix(app): disconnect zen provider * fix(app): zen disconnect not working * chore: generate * chore: cleanup * fix: search clear icon * wip: new release modal - highlight key updates or new features - needs some transition love - all copy including text and video placeholder * chore: cleanup * wip: highlights * wip: highlights * wip: highlights * tweak: when using messages api for copilot, attach anthropic beta headers * chore: don't flip github draft release automatically * fix(app): file tree performance * fix(app): reactive file tree * chore: generate * chore: cleanup * fix(app): enable file watcher * chore: cleanup * chore: cleanup * test(app): file tree spec * feat(release): add highlights template to draft releases (#10745) * docs: add Italian README (#10732) * chore: generate * fix(app): add connect provier in model selector (#10706) * fix(desktop): disable magnification gestures on macOS (#10605) * feat: add /learn command to extract session learnings to scoped AGENTS.md files (#10717) * fix: use Instance.directory instead of process.cwd() in read tool * fix(core): expose Instance.directory to custom tools * fix(app): only show files in select dialog when clicking + tab * desktop: deduplicate tauri configs * ignore: update download stats 2026-01-27 * fix(app): auto-scroll button sometimes sticks * chore(app): missing tooltips * feat(app): forward and back buttons * chore: cleanup * feat(core): add worktree to plugin tool context * fix(app): agent fallback colors * fix(app): non-fatal error handling * docs(core): plugin tool context updates * fix(ui): set filetree tablist height to 48px with centered content * fix(app): center filetree empty state with 32px top margin * fix(app): update review empty states to 14px and align select file empty state * fix(app): only show left border on plus button when sticky * fix(app): use smaller close icon on tabs to match comment cards * fix(ui): add max-width 280px to tabs with text truncation * fix(app): don't show session skeleton after workspace reset * perf(app): don't keep parts in memory * perf(app): cleanup connect provider timers * perf(app): shared terminal ghostty-web instance * fix(app): select model anchor * Revert "fix(app): select model anchor" This reverts commit 36babf5fc390fd9a892ad3742690740296d94a23. * fix(app): open markdown links in external browser * fix(app): navigate to tabs when opening file * chore(app): cleanup tailwind vs pure css * fix(app): tooltips causing getComputedStyle errors in model select * fix(app): swallow file search errors * feat(opencode): Handle Venice cache creation tokens (#10735) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> * chore: generate * chore(docs): Better explanation on how to allow tools in external directories (#10862) * tweak: adjust retry check to be more defensive * ci: make tests passing a requirement pre-release * zen: kimi k2.5 and minimax m2.1 * feat(app): update settings in general settings * chore(app): translations * test(app): new e2e smoke tests * feat(app): show loaded agents.md files * fix(app): session diffs not always loading * feat(app): improved layout * chore: generate * tweak: add recommended topP/temp for kimi k2.5 * Set temperature for kimi k2.5 * Revert "ci: make tests passing a requirement pre-release" This reverts commit8c00818108. * Revert "Set temperature for kimi k2.5" This reverts commitbb63d16fa8. * feat(app): color filetree change dots by diff kind * feat(app): add filetree tooltips with diff labels * fix(app): shorten nav tooltips * fix(app): adjust titlebar left spacing * fix(app): delay nav tooltips * fix(app): align filetree change styling * fix(app): set filetree padding to 6px * fix(app): highlight selected change Track clicked file in the Changes tree and apply selection styling to the matching review diff. * chore(app): show 5 highlights * fix(app): auto-scroll * fix(app): don't connect to localhost through vpn * perf(app): better memory management * chore: cleanup * feat(desktop): i18n for tauri side * feat(desktop): tauri locales * fix(app): not auto-navigating to last project * chore: update nix node_modules hashes * fix(tui): handle 4-5 codes too in c to copy logic * fix(app): spinner color * fix(app): file tree not always loading * test(app): fix outdated e2e test * test(app): fix outdated e2e test * fix(app): file tree not always loading * fix(app): terminal corruption * feat(app): add 'connect provider' button to the manage models dialog (#10887) * fix(app): auto-scroll * feat: support headless authentication for chatgpt/codex (#10890) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> * fix(app): reintroduce review tab * release: v1.1.37 * feat(app): use opentui markdown component behind experimental flag (#10900) * chore: generate * docs: add Daytona OpenCode plugin to ecosystem (#10917) * chore: generate * fix(cli): restore brand integrity of CLI wordmark (#10912) * zen: kimi k2.5 * fix(opencode): ensure unsub(PartUpdated) is always called in TaskTool (#9992) * chore: generate * ignore: adjust flag * bump plugin version * ci * fix: ensure parallel tool calls dont double load AGENTS.md * release: v1.1.38 * fix: ensure copilot plugin properly sets headers for new messages api * chore: generate * release: v1.1.39 * bump: plugins * ignore: update download stats 2026-01-28 * chore(app): global config changes * feat(app): custom provider * chore: cleanup * wip(app): global config * chore: cleanup * chore: cleanup * chore: generate * feat(i18n): add th locale support (#10809) * chore: generate * fix(desktop): enable ctrl+n and ctrl+p for popover navigation (#10777) * fix: add SubtaskPart with metadata reference (#10990) Co-authored-by: yangtianzhe <yangtianzhe@corp.netease.com> * chore: generate * fix(app): close review pane * fix(markdown): Add streaming prop to markdown element (#11025) * release: v1.1.40 * fix(script): remove highlights template from release notes (#11052) * chore: generate * fix(ui): allow KaTeX inline math to be followed by punctuation (#11033) * fix(app): alignment and padding in dialogs (#10866) * fix(app): fill missing zh keys to avoid English fallback (#10841) * chore: generate * fix(opencode): normalize zh punctuation for Chinese UI (#10842) * fix(ui): improve zh duration display formatting (#10844) * fix(opencode): add `AbortSignal` support to `Ripgrep.files()` and `GlobTool` (#10833) * fix(typescript errors): remove duplicate keys causing typescript failures (#11071) * fix(app): file tabs - auto scroll on open & scroll via mouse wheel (#11070) * tweak: add ctx.abort to grep tool * fix(app): types * docs: update experimental environment variables in CLI docs (#11030) * fix(app): make settings more responsive for mobile and small web/desktop (#10775) * chore: generate * fix(opencode): allow media-src data: URL for small audio files (#11082) * fix(provider): include providerID in SDK cache key (#11020) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> * fix: maxOutputTokens was accidentally hardcoded to undefined (#10995) * fix: ensure variants for copilot models work w/ maxTokens being set * release: v1.1.41 * test: fix test * chore: generate * feat(cli): include cache tokens in stats (#10582) * ci: adjust team * guard destroyed input field in timeout * feat: Sequential numbering for forked session titles (Issue #10105) (#10321) * more timeout race guards * fix: ensure that kimi doesnt have fake variants available * fix: frontmatter was adding newlines in some cases causing invalid model ids (#11095) Co-authored-by: aptdnfapt <aptdnfapt@users.noreply.github.com> * fix: undo change that used anthropic messages endpoint for anthropic models on copilot due to ratelimiting issues, go back to completions endpoint instead * release: v1.1.42 * fix: fix types * fix: fix kilocode/sdk type * chore: update nix node_modules hashes --------- Co-authored-by: adamelmore <2363879+adamdottv@users.noreply.github.com> Co-authored-by: Idris Gadi <85882535+IdrisGit@users.noreply.github.com> Co-authored-by: Noam Bressler <noamzbr@gmail.com> Co-authored-by: Ariane Emory <97994360+ariane-emory@users.noreply.github.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Steffen Deusch <steffen@deusch.me> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Sebastian Herrlinger <hasta84@gmail.com> Co-authored-by: Ryan Vogel <ryan@mandarin3d.com> Co-authored-by: Sam Huckaby <samhuckaby@gmail.com> Co-authored-by: Alex Yaroshuk <34632190+alexyaroshuk@users.noreply.github.com> Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: zerone0x <hi@trine.dev> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: Tim <hello@timsmart.co> Co-authored-by: David Hill <iamdavidhill@gmail.com> Co-authored-by: Fabio Martino <fabiomartino@users.noreply.github.com> Co-authored-by: Chris Yang <18487241+ysm-dev@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: dpuyosa <dpuyosa@users.noreply.github.com> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Rohan Godha <git@rohangodha.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Tommy D. Rossi <beats.by.morse@gmail.com> Co-authored-by: James Murdza <james@jamesmurdza.com> Co-authored-by: Tito <github@mynameistito.com> Co-authored-by: Goni Zahavy <goni1993@gmail.com> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Nattawee Phantawong <crozzio@gmail.com> Co-authored-by: Sairaj <sairaj2119@gmail.com> Co-authored-by: RenegadeE <37529469+YangTianz@users.noreply.github.com> Co-authored-by: yangtianzhe <yangtianzhe@corp.netease.com> Co-authored-by: Max Kong <kongzheyuan@outlook.com> Co-authored-by: Julian Coy <julian@juliancoy.com> Co-authored-by: Babou <42550761+bbabou@users.noreply.github.com> Co-authored-by: Devin Griffin <31415269+DNGriffin@users.noreply.github.com> Co-authored-by: tan <zerotanapok@gmail.com> Co-authored-by: Saba Tchikhinashvili <38084512+saba-ch@users.noreply.github.com> Co-authored-by: ideallove <20013535+ideallove@users.noreply.github.com> Co-authored-by: Benjamin Oldenburg <benjamin.oldenburg@ordis.co.th> Co-authored-by: aptdnfapt <aptdnfapt@users.noreply.github.com> Co-authored-by: marius-kilocode <marius@kilocode.ai>
560 lines
19 KiB
TypeScript
560 lines
19 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 { tmpdir } from "../fixture/fixture"
|
|
|
|
const baseCtx = {
|
|
sessionID: "test",
|
|
messageID: "",
|
|
callID: "",
|
|
agent: "build",
|
|
abort: AbortSignal.any([]),
|
|
messages: [],
|
|
metadata: () => {},
|
|
}
|
|
|
|
type AskInput = {
|
|
permission: string
|
|
patterns: string[]
|
|
always: string[]
|
|
metadata: {
|
|
diff: string
|
|
filepath: string
|
|
files: Array<{
|
|
filePath: string
|
|
relativePath: string
|
|
type: "add" | "update" | "delete" | "move"
|
|
diff: string
|
|
before: string
|
|
after: string
|
|
additions: number
|
|
deletions: number
|
|
movePath?: 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({ git: true })
|
|
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")
|
|
|
|
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)
|
|
|
|
// Verify permission metadata includes files array for UI rendering
|
|
const permissionCall = calls[0]
|
|
expect(permissionCall.metadata.files).toHaveLength(3)
|
|
expect(permissionCall.metadata.files.map((f) => f.type).sort()).toEqual(["add", "delete", "update"])
|
|
|
|
const addFile = permissionCall.metadata.files.find((f) => f.type === "add")
|
|
expect(addFile).toBeDefined()
|
|
expect(addFile!.relativePath).toBe("nested/new.txt")
|
|
expect(addFile!.after).toBe("created\n")
|
|
|
|
const updateFile = permissionCall.metadata.files.find((f) => f.type === "update")
|
|
expect(updateFile).toBeDefined()
|
|
expect(updateFile!.before).toContain("line2")
|
|
expect(updateFile!.after).toContain("changed")
|
|
|
|
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("permission metadata includes move file info", async () => {
|
|
await using fixture = await tmpdir({ git: true })
|
|
const { ctx, calls } = 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")
|
|
|
|
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)
|
|
|
|
expect(calls.length).toBe(1)
|
|
const permissionCall = calls[0]
|
|
expect(permissionCall.metadata.files).toHaveLength(1)
|
|
|
|
const moveFile = permissionCall.metadata.files[0]
|
|
expect(moveFile.type).toBe("move")
|
|
expect(moveFile.relativePath).toBe("renamed/dir/name.txt")
|
|
expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt"))
|
|
expect(moveFile.before).toBe("old content\n")
|
|
expect(moveFile.after).toBe("new content\n")
|
|
},
|
|
})
|
|
})
|
|
|
|
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")
|
|
|
|
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")
|
|
|
|
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")
|
|
|
|
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")
|
|
|
|
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")
|
|
|
|
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")
|
|
|
|
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")
|
|
|
|
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")
|
|
|
|
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")
|
|
|
|
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 Instance.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 Instance.provide({
|
|
directory: fixture.path,
|
|
fn: async () => {
|
|
const patchText = `cat <<'EOF'
|
|
*** Begin Patch
|
|
*** Add File: heredoc_test.txt
|
|
+heredoc content
|
|
*** End Patch
|
|
EOF`
|
|
|
|
await execute({ patchText }, ctx)
|
|
const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8")
|
|
expect(content).toBe("heredoc content\n")
|
|
},
|
|
})
|
|
})
|
|
|
|
test("parses heredoc-wrapped patch without cat", async () => {
|
|
await using fixture = await tmpdir()
|
|
const { ctx } = makeCtx()
|
|
|
|
await Instance.provide({
|
|
directory: fixture.path,
|
|
fn: async () => {
|
|
const patchText = `<<EOF
|
|
*** Begin Patch
|
|
*** Add File: heredoc_no_cat.txt
|
|
+no cat prefix
|
|
*** End Patch
|
|
EOF`
|
|
|
|
await execute({ patchText }, ctx)
|
|
const content = await fs.readFile(path.join(fixture.path, "heredoc_no_cat.txt"), "utf-8")
|
|
expect(content).toBe("no cat prefix\n")
|
|
},
|
|
})
|
|
})
|
|
|
|
test("matches with trailing whitespace differences", async () => {
|
|
await using fixture = await tmpdir()
|
|
const { ctx } = makeCtx()
|
|
|
|
await Instance.provide({
|
|
directory: fixture.path,
|
|
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
|
|
const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
|
|
|
|
await execute({ patchText }, ctx)
|
|
expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n")
|
|
},
|
|
})
|
|
})
|
|
|
|
test("matches with leading whitespace differences", async () => {
|
|
await using fixture = await tmpdir()
|
|
const { ctx } = makeCtx()
|
|
|
|
await Instance.provide({
|
|
directory: fixture.path,
|
|
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
|
|
const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
|
|
|
|
await execute({ patchText }, ctx)
|
|
expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n")
|
|
},
|
|
})
|
|
})
|
|
|
|
test("matches with Unicode punctuation differences", async () => {
|
|
await using fixture = await tmpdir()
|
|
const { ctx } = makeCtx()
|
|
|
|
await Instance.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`)
|
|
},
|
|
})
|
|
})
|
|
})
|