2
0

edit.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. import { describe, test, expect } from "bun:test"
  2. import path from "path"
  3. import fs from "fs/promises"
  4. import { EditTool } from "../../src/tool/edit"
  5. import { Instance } from "../../src/project/instance"
  6. import { tmpdir } from "../fixture/fixture"
  7. import { FileTime } from "../../src/file/time"
  8. const ctx = {
  9. sessionID: "test-edit-session",
  10. messageID: "",
  11. callID: "",
  12. agent: "build",
  13. abort: AbortSignal.any([]),
  14. messages: [],
  15. metadata: () => {},
  16. ask: async () => {},
  17. }
  18. describe("tool.edit", () => {
  19. describe("creating new files", () => {
  20. test("creates new file when oldString is empty", async () => {
  21. await using tmp = await tmpdir()
  22. const filepath = path.join(tmp.path, "newfile.txt")
  23. await Instance.provide({
  24. directory: tmp.path,
  25. fn: async () => {
  26. const edit = await EditTool.init()
  27. const result = await edit.execute(
  28. {
  29. filePath: filepath,
  30. oldString: "",
  31. newString: "new content",
  32. },
  33. ctx,
  34. )
  35. expect(result.metadata.diff).toContain("new content")
  36. const content = await fs.readFile(filepath, "utf-8")
  37. expect(content).toBe("new content")
  38. },
  39. })
  40. })
  41. test("creates new file with nested directories", async () => {
  42. await using tmp = await tmpdir()
  43. const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
  44. await Instance.provide({
  45. directory: tmp.path,
  46. fn: async () => {
  47. const edit = await EditTool.init()
  48. await edit.execute(
  49. {
  50. filePath: filepath,
  51. oldString: "",
  52. newString: "nested file",
  53. },
  54. ctx,
  55. )
  56. const content = await fs.readFile(filepath, "utf-8")
  57. expect(content).toBe("nested file")
  58. },
  59. })
  60. })
  61. test("emits add event for new files", async () => {
  62. await using tmp = await tmpdir()
  63. const filepath = path.join(tmp.path, "new.txt")
  64. await Instance.provide({
  65. directory: tmp.path,
  66. fn: async () => {
  67. const { Bus } = await import("../../src/bus")
  68. const { File } = await import("../../src/file")
  69. const { FileWatcher } = await import("../../src/file/watcher")
  70. const events: string[] = []
  71. const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
  72. const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
  73. const edit = await EditTool.init()
  74. await edit.execute(
  75. {
  76. filePath: filepath,
  77. oldString: "",
  78. newString: "content",
  79. },
  80. ctx,
  81. )
  82. expect(events).toContain("edited")
  83. expect(events).toContain("updated")
  84. unsubEdited()
  85. unsubUpdated()
  86. },
  87. })
  88. })
  89. })
  90. describe("editing existing files", () => {
  91. test("replaces text in existing file", async () => {
  92. await using tmp = await tmpdir()
  93. const filepath = path.join(tmp.path, "existing.txt")
  94. await fs.writeFile(filepath, "old content here", "utf-8")
  95. await Instance.provide({
  96. directory: tmp.path,
  97. fn: async () => {
  98. FileTime.read(ctx.sessionID, filepath)
  99. const edit = await EditTool.init()
  100. const result = await edit.execute(
  101. {
  102. filePath: filepath,
  103. oldString: "old content",
  104. newString: "new content",
  105. },
  106. ctx,
  107. )
  108. expect(result.output).toContain("Edit applied successfully")
  109. const content = await fs.readFile(filepath, "utf-8")
  110. expect(content).toBe("new content here")
  111. },
  112. })
  113. })
  114. test("throws error when file does not exist", async () => {
  115. await using tmp = await tmpdir()
  116. const filepath = path.join(tmp.path, "nonexistent.txt")
  117. await Instance.provide({
  118. directory: tmp.path,
  119. fn: async () => {
  120. FileTime.read(ctx.sessionID, filepath)
  121. const edit = await EditTool.init()
  122. await expect(
  123. edit.execute(
  124. {
  125. filePath: filepath,
  126. oldString: "old",
  127. newString: "new",
  128. },
  129. ctx,
  130. ),
  131. ).rejects.toThrow("not found")
  132. },
  133. })
  134. })
  135. test("throws error when oldString equals newString", async () => {
  136. await using tmp = await tmpdir()
  137. const filepath = path.join(tmp.path, "file.txt")
  138. await fs.writeFile(filepath, "content", "utf-8")
  139. await Instance.provide({
  140. directory: tmp.path,
  141. fn: async () => {
  142. const edit = await EditTool.init()
  143. await expect(
  144. edit.execute(
  145. {
  146. filePath: filepath,
  147. oldString: "same",
  148. newString: "same",
  149. },
  150. ctx,
  151. ),
  152. ).rejects.toThrow("identical")
  153. },
  154. })
  155. })
  156. test("throws error when oldString not found in file", async () => {
  157. await using tmp = await tmpdir()
  158. const filepath = path.join(tmp.path, "file.txt")
  159. await fs.writeFile(filepath, "actual content", "utf-8")
  160. await Instance.provide({
  161. directory: tmp.path,
  162. fn: async () => {
  163. FileTime.read(ctx.sessionID, filepath)
  164. const edit = await EditTool.init()
  165. await expect(
  166. edit.execute(
  167. {
  168. filePath: filepath,
  169. oldString: "not in file",
  170. newString: "replacement",
  171. },
  172. ctx,
  173. ),
  174. ).rejects.toThrow()
  175. },
  176. })
  177. })
  178. test("throws error when file was not read first (FileTime)", async () => {
  179. await using tmp = await tmpdir()
  180. const filepath = path.join(tmp.path, "file.txt")
  181. await fs.writeFile(filepath, "content", "utf-8")
  182. await Instance.provide({
  183. directory: tmp.path,
  184. fn: async () => {
  185. const edit = await EditTool.init()
  186. await expect(
  187. edit.execute(
  188. {
  189. filePath: filepath,
  190. oldString: "content",
  191. newString: "modified",
  192. },
  193. ctx,
  194. ),
  195. ).rejects.toThrow("You must read file")
  196. },
  197. })
  198. })
  199. test("throws error when file has been modified since read", async () => {
  200. await using tmp = await tmpdir()
  201. const filepath = path.join(tmp.path, "file.txt")
  202. await fs.writeFile(filepath, "original content", "utf-8")
  203. await Instance.provide({
  204. directory: tmp.path,
  205. fn: async () => {
  206. // Read first
  207. FileTime.read(ctx.sessionID, filepath)
  208. // Wait a bit to ensure different timestamps
  209. await new Promise((resolve) => setTimeout(resolve, 100))
  210. // Simulate external modification
  211. await fs.writeFile(filepath, "modified externally", "utf-8")
  212. // Try to edit with the new content
  213. const edit = await EditTool.init()
  214. await expect(
  215. edit.execute(
  216. {
  217. filePath: filepath,
  218. oldString: "modified externally",
  219. newString: "edited",
  220. },
  221. ctx,
  222. ),
  223. ).rejects.toThrow("modified since it was last read")
  224. },
  225. })
  226. })
  227. test("replaces all occurrences with replaceAll option", async () => {
  228. await using tmp = await tmpdir()
  229. const filepath = path.join(tmp.path, "file.txt")
  230. await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8")
  231. await Instance.provide({
  232. directory: tmp.path,
  233. fn: async () => {
  234. FileTime.read(ctx.sessionID, filepath)
  235. const edit = await EditTool.init()
  236. await edit.execute(
  237. {
  238. filePath: filepath,
  239. oldString: "foo",
  240. newString: "qux",
  241. replaceAll: true,
  242. },
  243. ctx,
  244. )
  245. const content = await fs.readFile(filepath, "utf-8")
  246. expect(content).toBe("qux bar qux baz qux")
  247. },
  248. })
  249. })
  250. test("emits change event for existing files", async () => {
  251. await using tmp = await tmpdir()
  252. const filepath = path.join(tmp.path, "file.txt")
  253. await fs.writeFile(filepath, "original", "utf-8")
  254. await Instance.provide({
  255. directory: tmp.path,
  256. fn: async () => {
  257. FileTime.read(ctx.sessionID, filepath)
  258. const { Bus } = await import("../../src/bus")
  259. const { File } = await import("../../src/file")
  260. const { FileWatcher } = await import("../../src/file/watcher")
  261. const events: string[] = []
  262. const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
  263. const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
  264. const edit = await EditTool.init()
  265. await edit.execute(
  266. {
  267. filePath: filepath,
  268. oldString: "original",
  269. newString: "modified",
  270. },
  271. ctx,
  272. )
  273. expect(events).toContain("edited")
  274. expect(events).toContain("updated")
  275. unsubEdited()
  276. unsubUpdated()
  277. },
  278. })
  279. })
  280. })
  281. describe("edge cases", () => {
  282. test("handles multiline replacements", async () => {
  283. await using tmp = await tmpdir()
  284. const filepath = path.join(tmp.path, "file.txt")
  285. await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
  286. await Instance.provide({
  287. directory: tmp.path,
  288. fn: async () => {
  289. FileTime.read(ctx.sessionID, filepath)
  290. const edit = await EditTool.init()
  291. await edit.execute(
  292. {
  293. filePath: filepath,
  294. oldString: "line2",
  295. newString: "new line 2\nextra line",
  296. },
  297. ctx,
  298. )
  299. const content = await fs.readFile(filepath, "utf-8")
  300. expect(content).toBe("line1\nnew line 2\nextra line\nline3")
  301. },
  302. })
  303. })
  304. test("handles CRLF line endings", async () => {
  305. await using tmp = await tmpdir()
  306. const filepath = path.join(tmp.path, "file.txt")
  307. await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8")
  308. await Instance.provide({
  309. directory: tmp.path,
  310. fn: async () => {
  311. FileTime.read(ctx.sessionID, filepath)
  312. const edit = await EditTool.init()
  313. await edit.execute(
  314. {
  315. filePath: filepath,
  316. oldString: "old",
  317. newString: "new",
  318. },
  319. ctx,
  320. )
  321. const content = await fs.readFile(filepath, "utf-8")
  322. expect(content).toBe("line1\r\nnew\r\nline3")
  323. },
  324. })
  325. })
  326. test("throws error when oldString equals newString", async () => {
  327. await using tmp = await tmpdir()
  328. const filepath = path.join(tmp.path, "file.txt")
  329. await fs.writeFile(filepath, "content", "utf-8")
  330. await Instance.provide({
  331. directory: tmp.path,
  332. fn: async () => {
  333. const edit = await EditTool.init()
  334. await expect(
  335. edit.execute(
  336. {
  337. filePath: filepath,
  338. oldString: "",
  339. newString: "",
  340. },
  341. ctx,
  342. ),
  343. ).rejects.toThrow("identical")
  344. },
  345. })
  346. })
  347. test("throws error when path is directory", async () => {
  348. await using tmp = await tmpdir()
  349. const dirpath = path.join(tmp.path, "adir")
  350. await fs.mkdir(dirpath)
  351. await Instance.provide({
  352. directory: tmp.path,
  353. fn: async () => {
  354. FileTime.read(ctx.sessionID, dirpath)
  355. const edit = await EditTool.init()
  356. await expect(
  357. edit.execute(
  358. {
  359. filePath: dirpath,
  360. oldString: "old",
  361. newString: "new",
  362. },
  363. ctx,
  364. ),
  365. ).rejects.toThrow("directory")
  366. },
  367. })
  368. })
  369. test("tracks file diff statistics", async () => {
  370. await using tmp = await tmpdir()
  371. const filepath = path.join(tmp.path, "file.txt")
  372. await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
  373. await Instance.provide({
  374. directory: tmp.path,
  375. fn: async () => {
  376. FileTime.read(ctx.sessionID, filepath)
  377. const edit = await EditTool.init()
  378. const result = await edit.execute(
  379. {
  380. filePath: filepath,
  381. oldString: "line2",
  382. newString: "new line a\nnew line b",
  383. },
  384. ctx,
  385. )
  386. expect(result.metadata.filediff).toBeDefined()
  387. expect(result.metadata.filediff.file).toBe(filepath)
  388. expect(result.metadata.filediff.additions).toBeGreaterThan(0)
  389. },
  390. })
  391. })
  392. })
  393. describe("concurrent editing", () => {
  394. test("serializes concurrent edits to same file", async () => {
  395. await using tmp = await tmpdir()
  396. const filepath = path.join(tmp.path, "file.txt")
  397. await fs.writeFile(filepath, "0", "utf-8")
  398. await Instance.provide({
  399. directory: tmp.path,
  400. fn: async () => {
  401. FileTime.read(ctx.sessionID, filepath)
  402. const edit = await EditTool.init()
  403. // Two concurrent edits
  404. const promise1 = edit.execute(
  405. {
  406. filePath: filepath,
  407. oldString: "0",
  408. newString: "1",
  409. },
  410. ctx,
  411. )
  412. // Need to read again since FileTime tracks per-session
  413. FileTime.read(ctx.sessionID, filepath)
  414. const promise2 = edit.execute(
  415. {
  416. filePath: filepath,
  417. oldString: "0",
  418. newString: "2",
  419. },
  420. ctx,
  421. )
  422. // Both should complete without error (though one might fail due to content mismatch)
  423. const results = await Promise.allSettled([promise1, promise2])
  424. expect(results.some((r) => r.status === "fulfilled")).toBe(true)
  425. },
  426. })
  427. })
  428. })
  429. })