patch.test.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. import { describe, test, expect, beforeEach, afterEach } from "bun:test"
  2. import { Patch } from "../../src/patch"
  3. import * as fs from "fs/promises"
  4. import * as path from "path"
  5. import { tmpdir } from "os"
  6. describe("Patch namespace", () => {
  7. let tempDir: string
  8. beforeEach(async () => {
  9. tempDir = await fs.mkdtemp(path.join(tmpdir(), "patch-test-"))
  10. })
  11. afterEach(async () => {
  12. // Clean up temp directory
  13. await fs.rm(tempDir, { recursive: true, force: true })
  14. })
  15. describe("parsePatch", () => {
  16. test("should parse simple add file patch", () => {
  17. const patchText = `*** Begin Patch
  18. *** Add File: test.txt
  19. +Hello World
  20. *** End Patch`
  21. const result = Patch.parsePatch(patchText)
  22. expect(result.hunks).toHaveLength(1)
  23. expect(result.hunks[0]).toEqual({
  24. type: "add",
  25. path: "test.txt",
  26. contents: "Hello World",
  27. })
  28. })
  29. test("should parse delete file patch", () => {
  30. const patchText = `*** Begin Patch
  31. *** Delete File: old.txt
  32. *** End Patch`
  33. const result = Patch.parsePatch(patchText)
  34. expect(result.hunks).toHaveLength(1)
  35. const hunk = result.hunks[0]
  36. expect(hunk.type).toBe("delete")
  37. expect(hunk.path).toBe("old.txt")
  38. })
  39. test("should parse patch with multiple hunks", () => {
  40. const patchText = `*** Begin Patch
  41. *** Add File: new.txt
  42. +This is a new file
  43. *** Update File: existing.txt
  44. @@
  45. old line
  46. -new line
  47. +updated line
  48. *** End Patch`
  49. const result = Patch.parsePatch(patchText)
  50. expect(result.hunks).toHaveLength(2)
  51. expect(result.hunks[0].type).toBe("add")
  52. expect(result.hunks[1].type).toBe("update")
  53. })
  54. test("should parse file move operation", () => {
  55. const patchText = `*** Begin Patch
  56. *** Update File: old-name.txt
  57. *** Move to: new-name.txt
  58. @@
  59. -Old content
  60. +New content
  61. *** End Patch`
  62. const result = Patch.parsePatch(patchText)
  63. expect(result.hunks).toHaveLength(1)
  64. const hunk = result.hunks[0]
  65. expect(hunk.type).toBe("update")
  66. expect(hunk.path).toBe("old-name.txt")
  67. if (hunk.type === "update") {
  68. expect(hunk.move_path).toBe("new-name.txt")
  69. }
  70. })
  71. test("should throw error for invalid patch format", () => {
  72. const invalidPatch = `This is not a valid patch`
  73. expect(() => Patch.parsePatch(invalidPatch)).toThrow("Invalid patch format")
  74. })
  75. })
  76. describe("maybeParseApplyPatch", () => {
  77. test("should parse direct apply_patch command", () => {
  78. const patchText = `*** Begin Patch
  79. *** Add File: test.txt
  80. +Content
  81. *** End Patch`
  82. const result = Patch.maybeParseApplyPatch(["apply_patch", patchText])
  83. expect(result.type).toBe(Patch.MaybeApplyPatch.Body)
  84. if (result.type === Patch.MaybeApplyPatch.Body) {
  85. expect(result.args.patch).toBe(patchText)
  86. expect(result.args.hunks).toHaveLength(1)
  87. }
  88. })
  89. test("should parse applypatch command", () => {
  90. const patchText = `*** Begin Patch
  91. *** Add File: test.txt
  92. +Content
  93. *** End Patch`
  94. const result = Patch.maybeParseApplyPatch(["applypatch", patchText])
  95. expect(result.type).toBe(Patch.MaybeApplyPatch.Body)
  96. })
  97. test("should handle bash heredoc format", () => {
  98. const script = `apply_patch <<'PATCH'
  99. *** Begin Patch
  100. *** Add File: test.txt
  101. +Content
  102. *** End Patch
  103. PATCH`
  104. const result = Patch.maybeParseApplyPatch(["bash", "-lc", script])
  105. expect(result.type).toBe(Patch.MaybeApplyPatch.Body)
  106. if (result.type === Patch.MaybeApplyPatch.Body) {
  107. expect(result.args.hunks).toHaveLength(1)
  108. }
  109. })
  110. test("should return NotApplyPatch for non-patch commands", () => {
  111. const result = Patch.maybeParseApplyPatch(["echo", "hello"])
  112. expect(result.type).toBe(Patch.MaybeApplyPatch.NotApplyPatch)
  113. })
  114. })
  115. describe("applyPatch", () => {
  116. test("should add a new file", async () => {
  117. const patchText = `*** Begin Patch
  118. *** Add File: ${tempDir}/new-file.txt
  119. +Hello World
  120. +This is a new file
  121. *** End Patch`
  122. const result = await Patch.applyPatch(patchText)
  123. expect(result.added).toHaveLength(1)
  124. expect(result.modified).toHaveLength(0)
  125. expect(result.deleted).toHaveLength(0)
  126. const content = await fs.readFile(result.added[0], "utf-8")
  127. expect(content).toBe("Hello World\nThis is a new file")
  128. })
  129. test("should delete an existing file", async () => {
  130. const filePath = path.join(tempDir, "to-delete.txt")
  131. await fs.writeFile(filePath, "This file will be deleted")
  132. const patchText = `*** Begin Patch
  133. *** Delete File: ${filePath}
  134. *** End Patch`
  135. const result = await Patch.applyPatch(patchText)
  136. expect(result.deleted).toHaveLength(1)
  137. expect(result.deleted[0]).toBe(filePath)
  138. const exists = await fs
  139. .access(filePath)
  140. .then(() => true)
  141. .catch(() => false)
  142. expect(exists).toBe(false)
  143. })
  144. test("should update an existing file", async () => {
  145. const filePath = path.join(tempDir, "to-update.txt")
  146. await fs.writeFile(filePath, "line 1\nline 2\nline 3\n")
  147. const patchText = `*** Begin Patch
  148. *** Update File: ${filePath}
  149. @@
  150. line 1
  151. -line 2
  152. +line 2 updated
  153. line 3
  154. *** End Patch`
  155. const result = await Patch.applyPatch(patchText)
  156. expect(result.modified).toHaveLength(1)
  157. expect(result.modified[0]).toBe(filePath)
  158. const content = await fs.readFile(filePath, "utf-8")
  159. expect(content).toBe("line 1\nline 2 updated\nline 3\n")
  160. })
  161. test("should move and update a file", async () => {
  162. const oldPath = path.join(tempDir, "old-name.txt")
  163. const newPath = path.join(tempDir, "new-name.txt")
  164. await fs.writeFile(oldPath, "old content\n")
  165. const patchText = `*** Begin Patch
  166. *** Update File: ${oldPath}
  167. *** Move to: ${newPath}
  168. @@
  169. -old content
  170. +new content
  171. *** End Patch`
  172. const result = await Patch.applyPatch(patchText)
  173. expect(result.modified).toHaveLength(1)
  174. expect(result.modified[0]).toBe(newPath)
  175. const oldExists = await fs
  176. .access(oldPath)
  177. .then(() => true)
  178. .catch(() => false)
  179. expect(oldExists).toBe(false)
  180. const newContent = await fs.readFile(newPath, "utf-8")
  181. expect(newContent).toBe("new content\n")
  182. })
  183. test("should handle multiple operations in one patch", async () => {
  184. const file1 = path.join(tempDir, "file1.txt")
  185. const file2 = path.join(tempDir, "file2.txt")
  186. const file3 = path.join(tempDir, "file3.txt")
  187. await fs.writeFile(file1, "content 1")
  188. await fs.writeFile(file2, "content 2")
  189. const patchText = `*** Begin Patch
  190. *** Add File: ${file3}
  191. +new file content
  192. *** Update File: ${file1}
  193. @@
  194. -content 1
  195. +updated content 1
  196. *** Delete File: ${file2}
  197. *** End Patch`
  198. const result = await Patch.applyPatch(patchText)
  199. expect(result.added).toHaveLength(1)
  200. expect(result.modified).toHaveLength(1)
  201. expect(result.deleted).toHaveLength(1)
  202. })
  203. test("should create parent directories when adding files", async () => {
  204. const nestedPath = path.join(tempDir, "deep", "nested", "file.txt")
  205. const patchText = `*** Begin Patch
  206. *** Add File: ${nestedPath}
  207. +Deep nested content
  208. *** End Patch`
  209. const result = await Patch.applyPatch(patchText)
  210. expect(result.added).toHaveLength(1)
  211. expect(result.added[0]).toBe(nestedPath)
  212. const exists = await fs
  213. .access(nestedPath)
  214. .then(() => true)
  215. .catch(() => false)
  216. expect(exists).toBe(true)
  217. })
  218. })
  219. describe("error handling", () => {
  220. test("should throw error when updating non-existent file", async () => {
  221. const nonExistent = path.join(tempDir, "does-not-exist.txt")
  222. const patchText = `*** Begin Patch
  223. *** Update File: ${nonExistent}
  224. @@
  225. -old line
  226. +new line
  227. *** End Patch`
  228. await expect(Patch.applyPatch(patchText)).rejects.toThrow()
  229. })
  230. test("should throw error when deleting non-existent file", async () => {
  231. const nonExistent = path.join(tempDir, "does-not-exist.txt")
  232. const patchText = `*** Begin Patch
  233. *** Delete File: ${nonExistent}
  234. *** End Patch`
  235. await expect(Patch.applyPatch(patchText)).rejects.toThrow()
  236. })
  237. })
  238. describe("edge cases", () => {
  239. test("should handle empty files", async () => {
  240. const emptyFile = path.join(tempDir, "empty.txt")
  241. await fs.writeFile(emptyFile, "")
  242. const patchText = `*** Begin Patch
  243. *** Update File: ${emptyFile}
  244. @@
  245. +First line
  246. *** End Patch`
  247. const result = await Patch.applyPatch(patchText)
  248. expect(result.modified).toHaveLength(1)
  249. const content = await fs.readFile(emptyFile, "utf-8")
  250. expect(content).toBe("First line\n")
  251. })
  252. test("should handle files with no trailing newline", async () => {
  253. const filePath = path.join(tempDir, "no-newline.txt")
  254. await fs.writeFile(filePath, "no newline")
  255. const patchText = `*** Begin Patch
  256. *** Update File: ${filePath}
  257. @@
  258. -no newline
  259. +has newline now
  260. *** End Patch`
  261. const result = await Patch.applyPatch(patchText)
  262. expect(result.modified).toHaveLength(1)
  263. const content = await fs.readFile(filePath, "utf-8")
  264. expect(content).toBe("has newline now\n")
  265. })
  266. test("should handle multiple update chunks in single file", async () => {
  267. const filePath = path.join(tempDir, "multi-chunk.txt")
  268. await fs.writeFile(filePath, "line 1\nline 2\nline 3\nline 4\n")
  269. const patchText = `*** Begin Patch
  270. *** Update File: ${filePath}
  271. @@
  272. line 1
  273. -line 2
  274. +LINE 2
  275. @@
  276. line 3
  277. -line 4
  278. +LINE 4
  279. *** End Patch`
  280. const result = await Patch.applyPatch(patchText)
  281. expect(result.modified).toHaveLength(1)
  282. const content = await fs.readFile(filePath, "utf-8")
  283. expect(content).toBe("line 1\nLINE 2\nline 3\nLINE 4\n")
  284. })
  285. })
  286. })