edit.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755
  1. import { afterAll, afterEach, describe, test, expect } from "bun:test"
  2. import path from "path"
  3. import fs from "fs/promises"
  4. import { Effect, Layer, ManagedRuntime } from "effect"
  5. import { EditTool } from "../../src/tool/edit"
  6. import { Instance } from "../../src/project/instance"
  7. import { WithInstance } from "../../src/project/with-instance"
  8. import { disposeAllInstances, tmpdir } from "../fixture/fixture"
  9. import { LSP } from "@/lsp/lsp"
  10. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  11. import { Format } from "../../src/format"
  12. import { Agent } from "../../src/agent/agent"
  13. import { Bus } from "../../src/bus"
  14. import { BusEvent } from "../../src/bus/bus-event"
  15. import { Truncate } from "@/tool/truncate"
  16. import { SessionID, MessageID } from "../../src/session/schema"
  17. const ctx = {
  18. sessionID: SessionID.make("ses_test-edit-session"),
  19. messageID: MessageID.make(""),
  20. callID: "",
  21. agent: "build",
  22. abort: AbortSignal.any([]),
  23. messages: [],
  24. metadata: () => Effect.void,
  25. ask: () => Effect.void,
  26. }
  27. afterEach(async () => {
  28. await disposeAllInstances()
  29. })
  30. const runtime = ManagedRuntime.make(
  31. Layer.mergeAll(
  32. LSP.defaultLayer,
  33. AppFileSystem.defaultLayer,
  34. Format.defaultLayer,
  35. Bus.layer,
  36. Truncate.defaultLayer,
  37. Agent.defaultLayer,
  38. ),
  39. )
  40. afterAll(async () => {
  41. await runtime.dispose()
  42. })
  43. const resolve = () =>
  44. runtime.runPromise(
  45. Effect.gen(function* () {
  46. const info = yield* EditTool
  47. return yield* info.init()
  48. }),
  49. )
  50. const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
  51. runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback)))
  52. async function onceBus<D extends BusEvent.Definition>(def: D) {
  53. const result = Promise.withResolvers<void>()
  54. const unsub = await subscribeBus(def, () => {
  55. unsub()
  56. result.resolve()
  57. })
  58. return {
  59. wait: result.promise,
  60. unsub,
  61. }
  62. }
  63. describe("tool.edit", () => {
  64. describe("creating new files", () => {
  65. test("creates new file when oldString is empty", async () => {
  66. await using tmp = await tmpdir()
  67. const filepath = path.join(tmp.path, "newfile.txt")
  68. await WithInstance.provide({
  69. directory: tmp.path,
  70. fn: async () => {
  71. const edit = await resolve()
  72. const result = await Effect.runPromise(
  73. edit.execute(
  74. {
  75. filePath: filepath,
  76. oldString: "",
  77. newString: "new content",
  78. },
  79. ctx,
  80. ),
  81. )
  82. expect(result.metadata.diff).toContain("new content")
  83. const content = await fs.readFile(filepath, "utf-8")
  84. expect(content).toBe("new content")
  85. },
  86. })
  87. })
  88. test("preserves BOM when oldString is empty on existing files", async () => {
  89. await using tmp = await tmpdir()
  90. const filepath = path.join(tmp.path, "existing.cs")
  91. const bom = String.fromCharCode(0xfeff)
  92. await fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")
  93. await WithInstance.provide({
  94. directory: tmp.path,
  95. fn: async () => {
  96. const edit = await resolve()
  97. const result = await Effect.runPromise(
  98. edit.execute(
  99. {
  100. filePath: filepath,
  101. oldString: "",
  102. newString: "using Up;\n",
  103. },
  104. ctx,
  105. ),
  106. )
  107. expect(result.metadata.diff).toContain("-using System;")
  108. expect(result.metadata.diff).toContain("+using Up;")
  109. const content = await fs.readFile(filepath, "utf-8")
  110. expect(content.charCodeAt(0)).toBe(0xfeff)
  111. expect(content.slice(1)).toBe("using Up;\n")
  112. },
  113. })
  114. })
  115. test("creates new file with nested directories", async () => {
  116. await using tmp = await tmpdir()
  117. const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
  118. await WithInstance.provide({
  119. directory: tmp.path,
  120. fn: async () => {
  121. const edit = await resolve()
  122. await Effect.runPromise(
  123. edit.execute(
  124. {
  125. filePath: filepath,
  126. oldString: "",
  127. newString: "nested file",
  128. },
  129. ctx,
  130. ),
  131. )
  132. const content = await fs.readFile(filepath, "utf-8")
  133. expect(content).toBe("nested file")
  134. },
  135. })
  136. })
  137. test("emits add event for new files", async () => {
  138. await using tmp = await tmpdir()
  139. const filepath = path.join(tmp.path, "new.txt")
  140. await WithInstance.provide({
  141. directory: tmp.path,
  142. fn: async () => {
  143. const { FileWatcher } = await import("../../src/file/watcher")
  144. const updated = await onceBus(FileWatcher.Event.Updated)
  145. try {
  146. const edit = await resolve()
  147. await Effect.runPromise(
  148. edit.execute(
  149. {
  150. filePath: filepath,
  151. oldString: "",
  152. newString: "content",
  153. },
  154. ctx,
  155. ),
  156. )
  157. await updated.wait
  158. } finally {
  159. updated.unsub()
  160. }
  161. },
  162. })
  163. })
  164. })
  165. describe("editing existing files", () => {
  166. test("replaces text in existing file", async () => {
  167. await using tmp = await tmpdir()
  168. const filepath = path.join(tmp.path, "existing.txt")
  169. await fs.writeFile(filepath, "old content here", "utf-8")
  170. await WithInstance.provide({
  171. directory: tmp.path,
  172. fn: async () => {
  173. const edit = await resolve()
  174. const result = await Effect.runPromise(
  175. edit.execute(
  176. {
  177. filePath: filepath,
  178. oldString: "old content",
  179. newString: "new content",
  180. },
  181. ctx,
  182. ),
  183. )
  184. expect(result.output).toContain("Edit applied successfully")
  185. const content = await fs.readFile(filepath, "utf-8")
  186. expect(content).toBe("new content here")
  187. },
  188. })
  189. })
  190. test("replaces the first visible line in BOM files", async () => {
  191. await using tmp = await tmpdir()
  192. const filepath = path.join(tmp.path, "existing.cs")
  193. const bom = String.fromCharCode(0xfeff)
  194. await fs.writeFile(filepath, `${bom}using System;\nclass Test {}\n`, "utf-8")
  195. await WithInstance.provide({
  196. directory: tmp.path,
  197. fn: async () => {
  198. const edit = await resolve()
  199. const result = await Effect.runPromise(
  200. edit.execute(
  201. {
  202. filePath: filepath,
  203. oldString: "using System;",
  204. newString: "using Up;",
  205. },
  206. ctx,
  207. ),
  208. )
  209. expect(result.metadata.diff).toContain("-using System;")
  210. expect(result.metadata.diff).toContain("+using Up;")
  211. expect(result.metadata.diff).not.toContain(bom)
  212. const content = await fs.readFile(filepath, "utf-8")
  213. expect(content.charCodeAt(0)).toBe(0xfeff)
  214. expect(content.slice(1)).toBe("using Up;\nclass Test {}\n")
  215. },
  216. })
  217. })
  218. test("throws error when file does not exist", async () => {
  219. await using tmp = await tmpdir()
  220. const filepath = path.join(tmp.path, "nonexistent.txt")
  221. await WithInstance.provide({
  222. directory: tmp.path,
  223. fn: async () => {
  224. const edit = await resolve()
  225. await expect(
  226. Effect.runPromise(
  227. edit.execute(
  228. {
  229. filePath: filepath,
  230. oldString: "old",
  231. newString: "new",
  232. },
  233. ctx,
  234. ),
  235. ),
  236. ).rejects.toThrow("not found")
  237. },
  238. })
  239. })
  240. test("throws error when oldString equals newString", async () => {
  241. await using tmp = await tmpdir()
  242. const filepath = path.join(tmp.path, "file.txt")
  243. await fs.writeFile(filepath, "content", "utf-8")
  244. await WithInstance.provide({
  245. directory: tmp.path,
  246. fn: async () => {
  247. const edit = await resolve()
  248. await expect(
  249. Effect.runPromise(
  250. edit.execute(
  251. {
  252. filePath: filepath,
  253. oldString: "same",
  254. newString: "same",
  255. },
  256. ctx,
  257. ),
  258. ),
  259. ).rejects.toThrow("identical")
  260. },
  261. })
  262. })
  263. test("throws error when oldString not found in file", async () => {
  264. await using tmp = await tmpdir()
  265. const filepath = path.join(tmp.path, "file.txt")
  266. await fs.writeFile(filepath, "actual content", "utf-8")
  267. await WithInstance.provide({
  268. directory: tmp.path,
  269. fn: async () => {
  270. const edit = await resolve()
  271. await expect(
  272. Effect.runPromise(
  273. edit.execute(
  274. {
  275. filePath: filepath,
  276. oldString: "not in file",
  277. newString: "replacement",
  278. },
  279. ctx,
  280. ),
  281. ),
  282. ).rejects.toThrow()
  283. },
  284. })
  285. })
  286. test("replaces all occurrences with replaceAll option", async () => {
  287. await using tmp = await tmpdir()
  288. const filepath = path.join(tmp.path, "file.txt")
  289. await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8")
  290. await WithInstance.provide({
  291. directory: tmp.path,
  292. fn: async () => {
  293. const edit = await resolve()
  294. await Effect.runPromise(
  295. edit.execute(
  296. {
  297. filePath: filepath,
  298. oldString: "foo",
  299. newString: "qux",
  300. replaceAll: true,
  301. },
  302. ctx,
  303. ),
  304. )
  305. const content = await fs.readFile(filepath, "utf-8")
  306. expect(content).toBe("qux bar qux baz qux")
  307. },
  308. })
  309. })
  310. test("emits change event for existing files", async () => {
  311. await using tmp = await tmpdir()
  312. const filepath = path.join(tmp.path, "file.txt")
  313. await fs.writeFile(filepath, "original", "utf-8")
  314. await WithInstance.provide({
  315. directory: tmp.path,
  316. fn: async () => {
  317. const { FileWatcher } = await import("../../src/file/watcher")
  318. const updated = await onceBus(FileWatcher.Event.Updated)
  319. try {
  320. const edit = await resolve()
  321. await Effect.runPromise(
  322. edit.execute(
  323. {
  324. filePath: filepath,
  325. oldString: "original",
  326. newString: "modified",
  327. },
  328. ctx,
  329. ),
  330. )
  331. await updated.wait
  332. } finally {
  333. updated.unsub()
  334. }
  335. },
  336. })
  337. })
  338. })
  339. describe("edge cases", () => {
  340. test("handles multiline replacements", async () => {
  341. await using tmp = await tmpdir()
  342. const filepath = path.join(tmp.path, "file.txt")
  343. await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
  344. await WithInstance.provide({
  345. directory: tmp.path,
  346. fn: async () => {
  347. const edit = await resolve()
  348. await Effect.runPromise(
  349. edit.execute(
  350. {
  351. filePath: filepath,
  352. oldString: "line2",
  353. newString: "new line 2\nextra line",
  354. },
  355. ctx,
  356. ),
  357. )
  358. const content = await fs.readFile(filepath, "utf-8")
  359. expect(content).toBe("line1\nnew line 2\nextra line\nline3")
  360. },
  361. })
  362. })
  363. test("handles CRLF line endings", async () => {
  364. await using tmp = await tmpdir()
  365. const filepath = path.join(tmp.path, "file.txt")
  366. await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8")
  367. await WithInstance.provide({
  368. directory: tmp.path,
  369. fn: async () => {
  370. const edit = await resolve()
  371. await Effect.runPromise(
  372. edit.execute(
  373. {
  374. filePath: filepath,
  375. oldString: "old",
  376. newString: "new",
  377. },
  378. ctx,
  379. ),
  380. )
  381. const content = await fs.readFile(filepath, "utf-8")
  382. expect(content).toBe("line1\r\nnew\r\nline3")
  383. },
  384. })
  385. })
  386. test("throws error when oldString equals newString", async () => {
  387. await using tmp = await tmpdir()
  388. const filepath = path.join(tmp.path, "file.txt")
  389. await fs.writeFile(filepath, "content", "utf-8")
  390. await WithInstance.provide({
  391. directory: tmp.path,
  392. fn: async () => {
  393. const edit = await resolve()
  394. await expect(
  395. Effect.runPromise(
  396. edit.execute(
  397. {
  398. filePath: filepath,
  399. oldString: "",
  400. newString: "",
  401. },
  402. ctx,
  403. ),
  404. ),
  405. ).rejects.toThrow("identical")
  406. },
  407. })
  408. })
  409. test("throws error when path is directory", async () => {
  410. await using tmp = await tmpdir()
  411. const dirpath = path.join(tmp.path, "adir")
  412. await fs.mkdir(dirpath)
  413. await WithInstance.provide({
  414. directory: tmp.path,
  415. fn: async () => {
  416. const edit = await resolve()
  417. await expect(
  418. Effect.runPromise(
  419. edit.execute(
  420. {
  421. filePath: dirpath,
  422. oldString: "old",
  423. newString: "new",
  424. },
  425. ctx,
  426. ),
  427. ),
  428. ).rejects.toThrow("directory")
  429. },
  430. })
  431. })
  432. test("tracks file diff statistics", async () => {
  433. await using tmp = await tmpdir()
  434. const filepath = path.join(tmp.path, "file.txt")
  435. await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
  436. await WithInstance.provide({
  437. directory: tmp.path,
  438. fn: async () => {
  439. const edit = await resolve()
  440. const result = await Effect.runPromise(
  441. edit.execute(
  442. {
  443. filePath: filepath,
  444. oldString: "line2",
  445. newString: "new line a\nnew line b",
  446. },
  447. ctx,
  448. ),
  449. )
  450. expect(result.metadata.filediff).toBeDefined()
  451. expect(result.metadata.filediff.file).toBe(filepath)
  452. expect(result.metadata.filediff.additions).toBeGreaterThan(0)
  453. },
  454. })
  455. })
  456. })
  457. describe("line endings", () => {
  458. const old = "alpha\nbeta\ngamma"
  459. const next = "alpha\nbeta-updated\ngamma"
  460. const alt = "alpha\nbeta\nomega"
  461. const normalize = (text: string, ending: "\n" | "\r\n") => {
  462. const normalized = text.replaceAll("\r\n", "\n")
  463. if (ending === "\n") return normalized
  464. return normalized.replaceAll("\n", "\r\n")
  465. }
  466. const count = (content: string) => {
  467. const crlf = content.match(/\r\n/g)?.length ?? 0
  468. const lf = content.match(/\n/g)?.length ?? 0
  469. return {
  470. crlf,
  471. lf: lf - crlf,
  472. }
  473. }
  474. const expectLf = (content: string) => {
  475. const counts = count(content)
  476. expect(counts.crlf).toBe(0)
  477. expect(counts.lf).toBeGreaterThan(0)
  478. }
  479. const expectCrlf = (content: string) => {
  480. const counts = count(content)
  481. expect(counts.lf).toBe(0)
  482. expect(counts.crlf).toBeGreaterThan(0)
  483. }
  484. type Input = {
  485. content: string
  486. oldString: string
  487. newString: string
  488. replaceAll?: boolean
  489. }
  490. const apply = async (input: Input) => {
  491. await using tmp = await tmpdir({
  492. init: async (dir) => {
  493. await Bun.write(path.join(dir, "test.txt"), input.content)
  494. },
  495. })
  496. return await WithInstance.provide({
  497. directory: tmp.path,
  498. fn: async () => {
  499. const edit = await resolve()
  500. const filePath = path.join(tmp.path, "test.txt")
  501. await Effect.runPromise(
  502. edit.execute(
  503. {
  504. filePath,
  505. oldString: input.oldString,
  506. newString: input.newString,
  507. replaceAll: input.replaceAll,
  508. },
  509. ctx,
  510. ),
  511. )
  512. return await Bun.file(filePath).text()
  513. },
  514. })
  515. }
  516. test("preserves LF with LF multi-line strings", async () => {
  517. const content = normalize(old + "\n", "\n")
  518. const output = await apply({
  519. content,
  520. oldString: normalize(old, "\n"),
  521. newString: normalize(next, "\n"),
  522. })
  523. expect(output).toBe(normalize(next + "\n", "\n"))
  524. expectLf(output)
  525. })
  526. test("preserves CRLF with CRLF multi-line strings", async () => {
  527. const content = normalize(old + "\n", "\r\n")
  528. const output = await apply({
  529. content,
  530. oldString: normalize(old, "\r\n"),
  531. newString: normalize(next, "\r\n"),
  532. })
  533. expect(output).toBe(normalize(next + "\n", "\r\n"))
  534. expectCrlf(output)
  535. })
  536. test("preserves LF when old/new use CRLF", async () => {
  537. const content = normalize(old + "\n", "\n")
  538. const output = await apply({
  539. content,
  540. oldString: normalize(old, "\r\n"),
  541. newString: normalize(next, "\r\n"),
  542. })
  543. expect(output).toBe(normalize(next + "\n", "\n"))
  544. expectLf(output)
  545. })
  546. test("preserves CRLF when old/new use LF", async () => {
  547. const content = normalize(old + "\n", "\r\n")
  548. const output = await apply({
  549. content,
  550. oldString: normalize(old, "\n"),
  551. newString: normalize(next, "\n"),
  552. })
  553. expect(output).toBe(normalize(next + "\n", "\r\n"))
  554. expectCrlf(output)
  555. })
  556. test("preserves LF when newString uses CRLF", async () => {
  557. const content = normalize(old + "\n", "\n")
  558. const output = await apply({
  559. content,
  560. oldString: normalize(old, "\n"),
  561. newString: normalize(next, "\r\n"),
  562. })
  563. expect(output).toBe(normalize(next + "\n", "\n"))
  564. expectLf(output)
  565. })
  566. test("preserves CRLF when newString uses LF", async () => {
  567. const content = normalize(old + "\n", "\r\n")
  568. const output = await apply({
  569. content,
  570. oldString: normalize(old, "\r\n"),
  571. newString: normalize(next, "\n"),
  572. })
  573. expect(output).toBe(normalize(next + "\n", "\r\n"))
  574. expectCrlf(output)
  575. })
  576. test("preserves LF with mixed old/new line endings", async () => {
  577. const content = normalize(old + "\n", "\n")
  578. const output = await apply({
  579. content,
  580. oldString: "alpha\nbeta\r\ngamma",
  581. newString: "alpha\r\nbeta\nomega",
  582. })
  583. expect(output).toBe(normalize(alt + "\n", "\n"))
  584. expectLf(output)
  585. })
  586. test("preserves CRLF with mixed old/new line endings", async () => {
  587. const content = normalize(old + "\n", "\r\n")
  588. const output = await apply({
  589. content,
  590. oldString: "alpha\r\nbeta\ngamma",
  591. newString: "alpha\nbeta\r\nomega",
  592. })
  593. expect(output).toBe(normalize(alt + "\n", "\r\n"))
  594. expectCrlf(output)
  595. })
  596. test("replaceAll preserves LF for multi-line blocks", async () => {
  597. const blockOld = "alpha\nbeta"
  598. const blockNew = "alpha\nbeta-updated"
  599. const content = normalize(blockOld + "\n" + blockOld + "\n", "\n")
  600. const output = await apply({
  601. content,
  602. oldString: normalize(blockOld, "\n"),
  603. newString: normalize(blockNew, "\n"),
  604. replaceAll: true,
  605. })
  606. expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n"))
  607. expectLf(output)
  608. })
  609. test("replaceAll preserves CRLF for multi-line blocks", async () => {
  610. const blockOld = "alpha\nbeta"
  611. const blockNew = "alpha\nbeta-updated"
  612. const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n")
  613. const output = await apply({
  614. content,
  615. oldString: normalize(blockOld, "\r\n"),
  616. newString: normalize(blockNew, "\r\n"),
  617. replaceAll: true,
  618. })
  619. expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n"))
  620. expectCrlf(output)
  621. })
  622. })
  623. describe("concurrent editing", () => {
  624. test("preserves concurrent edits to different sections of the same file", async () => {
  625. await using tmp = await tmpdir()
  626. const filepath = path.join(tmp.path, "file.txt")
  627. await fs.writeFile(filepath, "top = 0\nmiddle = keep\nbottom = 0\n", "utf-8")
  628. await WithInstance.provide({
  629. directory: tmp.path,
  630. fn: async () => {
  631. const edit = await resolve()
  632. let asks = 0
  633. const firstAsk = Promise.withResolvers<void>()
  634. const delayedCtx = {
  635. ...ctx,
  636. ask: () =>
  637. Effect.gen(function* () {
  638. asks++
  639. if (asks !== 1) return
  640. firstAsk.resolve()
  641. yield* Effect.promise(() => Bun.sleep(50))
  642. }),
  643. }
  644. const promise1 = Effect.runPromise(
  645. edit.execute(
  646. {
  647. filePath: filepath,
  648. oldString: "top = 0",
  649. newString: "top = 1",
  650. },
  651. delayedCtx,
  652. ),
  653. )
  654. await firstAsk.promise
  655. const promise2 = Effect.runPromise(
  656. edit.execute(
  657. {
  658. filePath: filepath,
  659. oldString: "bottom = 0",
  660. newString: "bottom = 2",
  661. },
  662. delayedCtx,
  663. ),
  664. )
  665. const results = await Promise.allSettled([promise1, promise2])
  666. expect(results[0]?.status).toBe("fulfilled")
  667. expect(results[1]?.status).toBe("fulfilled")
  668. expect(await fs.readFile(filepath, "utf-8")).toBe("top = 1\nmiddle = keep\nbottom = 2\n")
  669. },
  670. })
  671. })
  672. })
  673. })