patch.test.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  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.access(filePath).then(() => true).catch(() => false)
  139. expect(exists).toBe(false)
  140. })
  141. test("should update an existing file", async () => {
  142. const filePath = path.join(tempDir, "to-update.txt")
  143. await fs.writeFile(filePath, "line 1\nline 2\nline 3\n")
  144. const patchText = `*** Begin Patch
  145. *** Update File: ${filePath}
  146. @@
  147. line 1
  148. -line 2
  149. +line 2 updated
  150. line 3
  151. *** End Patch`
  152. const result = await Patch.applyPatch(patchText)
  153. expect(result.modified).toHaveLength(1)
  154. expect(result.modified[0]).toBe(filePath)
  155. const content = await fs.readFile(filePath, "utf-8")
  156. expect(content).toBe("line 1\nline 2 updated\nline 3\n")
  157. })
  158. test("should move and update a file", async () => {
  159. const oldPath = path.join(tempDir, "old-name.txt")
  160. const newPath = path.join(tempDir, "new-name.txt")
  161. await fs.writeFile(oldPath, "old content\n")
  162. const patchText = `*** Begin Patch
  163. *** Update File: ${oldPath}
  164. *** Move to: ${newPath}
  165. @@
  166. -old content
  167. +new content
  168. *** End Patch`
  169. const result = await Patch.applyPatch(patchText)
  170. expect(result.modified).toHaveLength(1)
  171. expect(result.modified[0]).toBe(newPath)
  172. const oldExists = await fs.access(oldPath).then(() => true).catch(() => false)
  173. expect(oldExists).toBe(false)
  174. const newContent = await fs.readFile(newPath, "utf-8")
  175. expect(newContent).toBe("new content\n")
  176. })
  177. test("should handle multiple operations in one patch", async () => {
  178. const file1 = path.join(tempDir, "file1.txt")
  179. const file2 = path.join(tempDir, "file2.txt")
  180. const file3 = path.join(tempDir, "file3.txt")
  181. await fs.writeFile(file1, "content 1")
  182. await fs.writeFile(file2, "content 2")
  183. const patchText = `*** Begin Patch
  184. *** Add File: ${file3}
  185. +new file content
  186. *** Update File: ${file1}
  187. @@
  188. -content 1
  189. +updated content 1
  190. *** Delete File: ${file2}
  191. *** End Patch`
  192. const result = await Patch.applyPatch(patchText)
  193. expect(result.added).toHaveLength(1)
  194. expect(result.modified).toHaveLength(1)
  195. expect(result.deleted).toHaveLength(1)
  196. })
  197. test("should create parent directories when adding files", async () => {
  198. const nestedPath = path.join(tempDir, "deep", "nested", "file.txt")
  199. const patchText = `*** Begin Patch
  200. *** Add File: ${nestedPath}
  201. +Deep nested content
  202. *** End Patch`
  203. const result = await Patch.applyPatch(patchText)
  204. expect(result.added).toHaveLength(1)
  205. expect(result.added[0]).toBe(nestedPath)
  206. const exists = await fs.access(nestedPath).then(() => true).catch(() => false)
  207. expect(exists).toBe(true)
  208. })
  209. })
  210. describe("error handling", () => {
  211. test("should throw error when updating non-existent file", async () => {
  212. const nonExistent = path.join(tempDir, "does-not-exist.txt")
  213. const patchText = `*** Begin Patch
  214. *** Update File: ${nonExistent}
  215. @@
  216. -old line
  217. +new line
  218. *** End Patch`
  219. await expect(Patch.applyPatch(patchText)).rejects.toThrow()
  220. })
  221. test("should throw error when deleting non-existent file", async () => {
  222. const nonExistent = path.join(tempDir, "does-not-exist.txt")
  223. const patchText = `*** Begin Patch
  224. *** Delete File: ${nonExistent}
  225. *** End Patch`
  226. await expect(Patch.applyPatch(patchText)).rejects.toThrow()
  227. })
  228. })
  229. describe("edge cases", () => {
  230. test("should handle empty files", async () => {
  231. const emptyFile = path.join(tempDir, "empty.txt")
  232. await fs.writeFile(emptyFile, "")
  233. const patchText = `*** Begin Patch
  234. *** Update File: ${emptyFile}
  235. @@
  236. +First line
  237. *** End Patch`
  238. const result = await Patch.applyPatch(patchText)
  239. expect(result.modified).toHaveLength(1)
  240. const content = await fs.readFile(emptyFile, "utf-8")
  241. expect(content).toBe("First line\n")
  242. })
  243. test("should handle files with no trailing newline", async () => {
  244. const filePath = path.join(tempDir, "no-newline.txt")
  245. await fs.writeFile(filePath, "no newline")
  246. const patchText = `*** Begin Patch
  247. *** Update File: ${filePath}
  248. @@
  249. -no newline
  250. +has newline now
  251. *** End Patch`
  252. const result = await Patch.applyPatch(patchText)
  253. expect(result.modified).toHaveLength(1)
  254. const content = await fs.readFile(filePath, "utf-8")
  255. expect(content).toBe("has newline now\n")
  256. })
  257. test("should handle multiple update chunks in single file", async () => {
  258. const filePath = path.join(tempDir, "multi-chunk.txt")
  259. await fs.writeFile(filePath, "line 1\nline 2\nline 3\nline 4\n")
  260. const patchText = `*** Begin Patch
  261. *** Update File: ${filePath}
  262. @@
  263. line 1
  264. -line 2
  265. +LINE 2
  266. @@
  267. line 3
  268. -line 4
  269. +LINE 4
  270. *** End Patch`
  271. const result = await Patch.applyPatch(patchText)
  272. expect(result.modified).toHaveLength(1)
  273. const content = await fs.readFile(filePath, "utf-8")
  274. expect(content).toBe("line 1\nLINE 2\nline 3\nLINE 4\n")
  275. })
  276. })
  277. })