apply_patch.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615
  1. import { describe, expect, test } from "bun:test"
  2. import path from "path"
  3. import * as fs from "fs/promises"
  4. import { Effect, ManagedRuntime, Layer } from "effect"
  5. import { ApplyPatchTool } from "../../src/tool/apply_patch"
  6. import { Instance } from "../../src/project/instance"
  7. import { WithInstance } from "../../src/project/with-instance"
  8. import { LSP } from "@/lsp/lsp"
  9. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  10. import { Format } from "../../src/format"
  11. import { Agent } from "../../src/agent/agent"
  12. import { Bus } from "../../src/bus"
  13. import { Truncate } from "@/tool/truncate"
  14. import { tmpdir } from "../fixture/fixture"
  15. import { SessionID, MessageID } from "../../src/session/schema"
  16. const runtime = ManagedRuntime.make(
  17. Layer.mergeAll(
  18. LSP.defaultLayer,
  19. AppFileSystem.defaultLayer,
  20. Format.defaultLayer,
  21. Bus.layer,
  22. Truncate.defaultLayer,
  23. Agent.defaultLayer,
  24. ),
  25. )
  26. const baseCtx = {
  27. sessionID: SessionID.make("ses_test"),
  28. messageID: MessageID.make("msg_test"),
  29. callID: "",
  30. agent: "build",
  31. abort: AbortSignal.any([]),
  32. messages: [],
  33. metadata: () => Effect.void,
  34. }
  35. type AskInput = {
  36. permission: string
  37. patterns: string[]
  38. always: string[]
  39. metadata: {
  40. diff: string
  41. filepath: string
  42. files: Array<{
  43. filePath: string
  44. relativePath: string
  45. type: "add" | "update" | "delete" | "move"
  46. patch: string
  47. additions: number
  48. deletions: number
  49. movePath?: string
  50. }>
  51. }
  52. }
  53. type ToolCtx = typeof baseCtx & {
  54. ask: (input: AskInput) => Effect.Effect<void>
  55. }
  56. const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
  57. const info = await runtime.runPromise(ApplyPatchTool)
  58. const tool = await runtime.runPromise(info.init())
  59. return Effect.runPromise(tool.execute(params, ctx))
  60. }
  61. const makeCtx = () => {
  62. const calls: AskInput[] = []
  63. const ctx: ToolCtx = {
  64. ...baseCtx,
  65. ask: (input) =>
  66. Effect.sync(() => {
  67. calls.push(input)
  68. }),
  69. }
  70. return { ctx, calls }
  71. }
  72. describe("tool.apply_patch freeform", () => {
  73. test("requires patchText", async () => {
  74. const { ctx } = makeCtx()
  75. await expect(execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
  76. })
  77. test("rejects invalid patch format", async () => {
  78. const { ctx } = makeCtx()
  79. await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("apply_patch verification failed")
  80. })
  81. test("rejects empty patch", async () => {
  82. const { ctx } = makeCtx()
  83. const emptyPatch = "*** Begin Patch\n*** End Patch"
  84. await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch")
  85. })
  86. test("applies add/update/delete in one patch", async () => {
  87. await using fixture = await tmpdir({ git: true })
  88. const { ctx, calls } = makeCtx()
  89. await WithInstance.provide({
  90. directory: fixture.path,
  91. fn: async () => {
  92. const modifyPath = path.join(fixture.path, "modify.txt")
  93. const deletePath = path.join(fixture.path, "delete.txt")
  94. await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8")
  95. await fs.writeFile(deletePath, "obsolete\n", "utf-8")
  96. const patchText =
  97. "*** 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"
  98. const result = await execute({ patchText }, ctx)
  99. expect(result.title).toContain("Success. Updated the following files")
  100. expect(result.output).toContain("Success. Updated the following files")
  101. // Strict formatting assertions for slashes
  102. expect(result.output).toMatch(/A nested\/new\.txt/)
  103. expect(result.output).toMatch(/D delete\.txt/)
  104. expect(result.output).toMatch(/M modify\.txt/)
  105. if (process.platform === "win32") {
  106. expect(result.output).not.toContain("\\")
  107. }
  108. expect(result.metadata.diff).toContain("Index:")
  109. expect(calls.length).toBe(1)
  110. // Verify permission metadata includes files array for UI rendering
  111. const permissionCall = calls[0]
  112. expect(permissionCall.metadata.files).toHaveLength(3)
  113. expect(permissionCall.metadata.files.map((f) => f.type).sort()).toEqual(["add", "delete", "update"])
  114. const addFile = permissionCall.metadata.files.find((f) => f.type === "add")
  115. expect(addFile).toBeDefined()
  116. expect(addFile!.relativePath).toBe("nested/new.txt")
  117. expect(addFile!.patch).toContain("+created")
  118. const updateFile = permissionCall.metadata.files.find((f) => f.type === "update")
  119. expect(updateFile).toBeDefined()
  120. expect(updateFile!.patch).toContain("-line2")
  121. expect(updateFile!.patch).toContain("+changed")
  122. const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8")
  123. expect(added).toBe("created\n")
  124. expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n")
  125. await expect(fs.readFile(deletePath, "utf-8")).rejects.toThrow()
  126. },
  127. })
  128. })
  129. test("permission metadata includes move file info", async () => {
  130. await using fixture = await tmpdir({ git: true })
  131. const { ctx, calls } = makeCtx()
  132. await WithInstance.provide({
  133. directory: fixture.path,
  134. fn: async () => {
  135. const original = path.join(fixture.path, "old", "name.txt")
  136. await fs.mkdir(path.dirname(original), { recursive: true })
  137. await fs.writeFile(original, "old content\n", "utf-8")
  138. const patchText =
  139. "*** 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"
  140. await execute({ patchText }, ctx)
  141. expect(calls.length).toBe(1)
  142. const permissionCall = calls[0]
  143. expect(permissionCall.metadata.files).toHaveLength(1)
  144. const moveFile = permissionCall.metadata.files[0]
  145. expect(moveFile.type).toBe("move")
  146. expect(moveFile.relativePath).toBe("renamed/dir/name.txt")
  147. expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt"))
  148. expect(moveFile.patch).toContain("-old content")
  149. expect(moveFile.patch).toContain("+new content")
  150. },
  151. })
  152. })
  153. test("applies multiple hunks to one file", async () => {
  154. await using fixture = await tmpdir()
  155. const { ctx } = makeCtx()
  156. await WithInstance.provide({
  157. directory: fixture.path,
  158. fn: async () => {
  159. const target = path.join(fixture.path, "multi.txt")
  160. await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8")
  161. const patchText =
  162. "*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
  163. await execute({ patchText }, ctx)
  164. expect(await fs.readFile(target, "utf-8")).toBe("line1\nchanged2\nline3\nchanged4\n")
  165. },
  166. })
  167. })
  168. test("does not invent a first-line diff for BOM files", async () => {
  169. await using fixture = await tmpdir()
  170. const { ctx, calls } = makeCtx()
  171. await WithInstance.provide({
  172. directory: fixture.path,
  173. fn: async () => {
  174. const bom = String.fromCharCode(0xfeff)
  175. const target = path.join(fixture.path, "example.cs")
  176. await fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`, "utf-8")
  177. const patchText =
  178. "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"
  179. await execute({ patchText }, ctx)
  180. expect(calls.length).toBe(1)
  181. const shown = calls[0].metadata.files[0]?.patch ?? ""
  182. expect(shown).not.toContain(bom)
  183. expect(shown).not.toContain("-using System;")
  184. expect(shown).not.toContain("+using System;")
  185. const content = await fs.readFile(target, "utf-8")
  186. expect(content.charCodeAt(0)).toBe(0xfeff)
  187. expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n")
  188. },
  189. })
  190. })
  191. test("inserts lines with insert-only hunk", async () => {
  192. await using fixture = await tmpdir()
  193. const { ctx } = makeCtx()
  194. await WithInstance.provide({
  195. directory: fixture.path,
  196. fn: async () => {
  197. const target = path.join(fixture.path, "insert_only.txt")
  198. await fs.writeFile(target, "alpha\nomega\n", "utf-8")
  199. const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
  200. await execute({ patchText }, ctx)
  201. expect(await fs.readFile(target, "utf-8")).toBe("alpha\nbeta\nomega\n")
  202. },
  203. })
  204. })
  205. test("appends trailing newline on update", async () => {
  206. await using fixture = await tmpdir()
  207. const { ctx } = makeCtx()
  208. await WithInstance.provide({
  209. directory: fixture.path,
  210. fn: async () => {
  211. const target = path.join(fixture.path, "no_newline.txt")
  212. await fs.writeFile(target, "no newline at end", "utf-8")
  213. const patchText =
  214. "*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"
  215. await execute({ patchText }, ctx)
  216. const contents = await fs.readFile(target, "utf-8")
  217. expect(contents.endsWith("\n")).toBe(true)
  218. expect(contents).toBe("first line\nsecond line\n")
  219. },
  220. })
  221. })
  222. test("moves file to a new directory", async () => {
  223. await using fixture = await tmpdir()
  224. const { ctx } = makeCtx()
  225. await WithInstance.provide({
  226. directory: fixture.path,
  227. fn: async () => {
  228. const original = path.join(fixture.path, "old", "name.txt")
  229. await fs.mkdir(path.dirname(original), { recursive: true })
  230. await fs.writeFile(original, "old content\n", "utf-8")
  231. const patchText =
  232. "*** 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"
  233. await execute({ patchText }, ctx)
  234. const moved = path.join(fixture.path, "renamed", "dir", "name.txt")
  235. await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
  236. expect(await fs.readFile(moved, "utf-8")).toBe("new content\n")
  237. },
  238. })
  239. })
  240. test("moves file overwriting existing destination", async () => {
  241. await using fixture = await tmpdir()
  242. const { ctx } = makeCtx()
  243. await WithInstance.provide({
  244. directory: fixture.path,
  245. fn: async () => {
  246. const original = path.join(fixture.path, "old", "name.txt")
  247. const destination = path.join(fixture.path, "renamed", "dir", "name.txt")
  248. await fs.mkdir(path.dirname(original), { recursive: true })
  249. await fs.mkdir(path.dirname(destination), { recursive: true })
  250. await fs.writeFile(original, "from\n", "utf-8")
  251. await fs.writeFile(destination, "existing\n", "utf-8")
  252. const patchText =
  253. "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"
  254. await execute({ patchText }, ctx)
  255. await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
  256. expect(await fs.readFile(destination, "utf-8")).toBe("new\n")
  257. },
  258. })
  259. })
  260. test("adds file overwriting existing file", async () => {
  261. await using fixture = await tmpdir()
  262. const { ctx } = makeCtx()
  263. await WithInstance.provide({
  264. directory: fixture.path,
  265. fn: async () => {
  266. const target = path.join(fixture.path, "duplicate.txt")
  267. await fs.writeFile(target, "old content\n", "utf-8")
  268. const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"
  269. await execute({ patchText }, ctx)
  270. expect(await fs.readFile(target, "utf-8")).toBe("new content\n")
  271. },
  272. })
  273. })
  274. test("rejects update when target file is missing", async () => {
  275. await using fixture = await tmpdir()
  276. const { ctx } = makeCtx()
  277. await WithInstance.provide({
  278. directory: fixture.path,
  279. fn: async () => {
  280. const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch"
  281. await expect(execute({ patchText }, ctx)).rejects.toThrow(
  282. "apply_patch verification failed: Failed to read file to update",
  283. )
  284. },
  285. })
  286. })
  287. test("rejects delete when file is missing", async () => {
  288. await using fixture = await tmpdir()
  289. const { ctx } = makeCtx()
  290. await WithInstance.provide({
  291. directory: fixture.path,
  292. fn: async () => {
  293. const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"
  294. await expect(execute({ patchText }, ctx)).rejects.toThrow()
  295. },
  296. })
  297. })
  298. test("rejects delete when target is a directory", async () => {
  299. await using fixture = await tmpdir()
  300. const { ctx } = makeCtx()
  301. await WithInstance.provide({
  302. directory: fixture.path,
  303. fn: async () => {
  304. const dirPath = path.join(fixture.path, "dir")
  305. await fs.mkdir(dirPath)
  306. const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"
  307. await expect(execute({ patchText }, ctx)).rejects.toThrow()
  308. },
  309. })
  310. })
  311. test("rejects invalid hunk header", async () => {
  312. await using fixture = await tmpdir()
  313. const { ctx } = makeCtx()
  314. await WithInstance.provide({
  315. directory: fixture.path,
  316. fn: async () => {
  317. const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"
  318. await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
  319. },
  320. })
  321. })
  322. test("rejects update with missing context", async () => {
  323. await using fixture = await tmpdir()
  324. const { ctx } = makeCtx()
  325. await WithInstance.provide({
  326. directory: fixture.path,
  327. fn: async () => {
  328. const target = path.join(fixture.path, "modify.txt")
  329. await fs.writeFile(target, "line1\nline2\n", "utf-8")
  330. const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
  331. await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
  332. expect(await fs.readFile(target, "utf-8")).toBe("line1\nline2\n")
  333. },
  334. })
  335. })
  336. test("verification failure leaves no side effects", async () => {
  337. await using fixture = await tmpdir()
  338. const { ctx } = makeCtx()
  339. await WithInstance.provide({
  340. directory: fixture.path,
  341. fn: async () => {
  342. const patchText =
  343. "*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"
  344. await expect(execute({ patchText }, ctx)).rejects.toThrow()
  345. const createdPath = path.join(fixture.path, "created.txt")
  346. await expect(fs.readFile(createdPath, "utf-8")).rejects.toThrow()
  347. },
  348. })
  349. })
  350. test("supports end of file anchor", async () => {
  351. await using fixture = await tmpdir()
  352. const { ctx } = makeCtx()
  353. await WithInstance.provide({
  354. directory: fixture.path,
  355. fn: async () => {
  356. const target = path.join(fixture.path, "tail.txt")
  357. await fs.writeFile(target, "alpha\nlast\n", "utf-8")
  358. const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
  359. await execute({ patchText }, ctx)
  360. expect(await fs.readFile(target, "utf-8")).toBe("alpha\nend\n")
  361. },
  362. })
  363. })
  364. test("rejects missing second chunk context", async () => {
  365. await using fixture = await tmpdir()
  366. const { ctx } = makeCtx()
  367. await WithInstance.provide({
  368. directory: fixture.path,
  369. fn: async () => {
  370. const target = path.join(fixture.path, "two_chunks.txt")
  371. await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8")
  372. const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
  373. await expect(execute({ patchText }, ctx)).rejects.toThrow()
  374. expect(await fs.readFile(target, "utf-8")).toBe("a\nb\nc\nd\n")
  375. },
  376. })
  377. })
  378. test("disambiguates change context with @@ header", async () => {
  379. await using fixture = await tmpdir()
  380. const { ctx } = makeCtx()
  381. await WithInstance.provide({
  382. directory: fixture.path,
  383. fn: async () => {
  384. const target = path.join(fixture.path, "multi_ctx.txt")
  385. await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8")
  386. const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
  387. await execute({ patchText }, ctx)
  388. expect(await fs.readFile(target, "utf-8")).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n")
  389. },
  390. })
  391. })
  392. test("EOF anchor matches from end of file first", async () => {
  393. await using fixture = await tmpdir()
  394. const { ctx } = makeCtx()
  395. await WithInstance.provide({
  396. directory: fixture.path,
  397. fn: async () => {
  398. const target = path.join(fixture.path, "eof_anchor.txt")
  399. // File has duplicate "marker" lines - one in middle, one at end
  400. await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8")
  401. // With EOF anchor, should match the LAST "marker" line, not the first
  402. const patchText =
  403. "*** 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"
  404. await execute({ patchText }, ctx)
  405. // First marker unchanged, second marker changed
  406. expect(await fs.readFile(target, "utf-8")).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n")
  407. },
  408. })
  409. })
  410. test("parses heredoc-wrapped patch", async () => {
  411. await using fixture = await tmpdir()
  412. const { ctx } = makeCtx()
  413. await WithInstance.provide({
  414. directory: fixture.path,
  415. fn: async () => {
  416. const patchText = `cat <<'EOF'
  417. *** Begin Patch
  418. *** Add File: heredoc_test.txt
  419. +heredoc content
  420. *** End Patch
  421. EOF`
  422. await execute({ patchText }, ctx)
  423. const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8")
  424. expect(content).toBe("heredoc content\n")
  425. },
  426. })
  427. })
  428. test("parses heredoc-wrapped patch without cat", async () => {
  429. await using fixture = await tmpdir()
  430. const { ctx } = makeCtx()
  431. await WithInstance.provide({
  432. directory: fixture.path,
  433. fn: async () => {
  434. const patchText = `<<EOF
  435. *** Begin Patch
  436. *** Add File: heredoc_no_cat.txt
  437. +no cat prefix
  438. *** End Patch
  439. EOF`
  440. await execute({ patchText }, ctx)
  441. const content = await fs.readFile(path.join(fixture.path, "heredoc_no_cat.txt"), "utf-8")
  442. expect(content).toBe("no cat prefix\n")
  443. },
  444. })
  445. })
  446. test("matches with trailing whitespace differences", async () => {
  447. await using fixture = await tmpdir()
  448. const { ctx } = makeCtx()
  449. await WithInstance.provide({
  450. directory: fixture.path,
  451. fn: async () => {
  452. const target = path.join(fixture.path, "trailing_ws.txt")
  453. // File has trailing spaces on some lines
  454. await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8")
  455. // Patch doesn't have trailing spaces - should still match via rstrip pass
  456. const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
  457. await execute({ patchText }, ctx)
  458. expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n")
  459. },
  460. })
  461. })
  462. test("matches with leading whitespace differences", async () => {
  463. await using fixture = await tmpdir()
  464. const { ctx } = makeCtx()
  465. await WithInstance.provide({
  466. directory: fixture.path,
  467. fn: async () => {
  468. const target = path.join(fixture.path, "leading_ws.txt")
  469. // File has leading spaces
  470. await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8")
  471. // Patch without leading spaces - should match via trim pass
  472. const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
  473. await execute({ patchText }, ctx)
  474. expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n")
  475. },
  476. })
  477. })
  478. test("matches with Unicode punctuation differences", async () => {
  479. await using fixture = await tmpdir()
  480. const { ctx } = makeCtx()
  481. await WithInstance.provide({
  482. directory: fixture.path,
  483. fn: async () => {
  484. const target = path.join(fixture.path, "unicode.txt")
  485. // File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014)
  486. const leftQuote = "\u201C"
  487. const rightQuote = "\u201D"
  488. const emDash = "\u2014"
  489. await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8")
  490. // Patch uses ASCII equivalents - should match via normalized pass
  491. // The replacement uses ASCII quotes from the patch (not preserving Unicode)
  492. const patchText =
  493. '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch'
  494. await execute({ patchText }, ctx)
  495. // Result has ASCII quotes because that's what the patch specifies
  496. expect(await fs.readFile(target, "utf-8")).toBe(`He said "hi"\nsome${emDash}dash\nend\n`)
  497. },
  498. })
  499. })
  500. })