tool-edit.test.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Layer } from "effect"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  7. import { Environment } from "@opencode-ai/core/environment"
  8. import { FileMutation } from "@opencode-ai/core/file-mutation"
  9. import { Formatter } from "@opencode-ai/core/formatter"
  10. import { Location } from "@opencode-ai/core/location"
  11. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  12. import { Permission } from "@opencode-ai/core/permission"
  13. import { AbsolutePath } from "@opencode-ai/core/schema"
  14. import { Session } from "@opencode-ai/core/session"
  15. import { Tool } from "@opencode-ai/core/tool"
  16. import { EditTool } from "@opencode-ai/core/tool/plugin/edit"
  17. import { location } from "./fixture/location"
  18. import { tmpdir } from "./fixture/tmpdir"
  19. import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
  20. import { testEffect } from "./lib/effect"
  21. import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
  22. const editToolNode = makeLocationNode({
  23. name: "test/edit-tool-plugin",
  24. layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
  25. deps: [
  26. Tool.node,
  27. LocationMutation.node,
  28. FileMutation.node,
  29. Environment.node,
  30. Formatter.node,
  31. Location.node,
  32. Permission.node,
  33. ],
  34. })
  35. const sessionID = Session.ID.make("ses_edit_tool_test")
  36. const assertions: Permission.AssertInput[] = []
  37. const writes: string[] = []
  38. let reads = 0
  39. let denyAction: string | undefined
  40. let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
  41. let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
  42. const permission = Layer.succeed(
  43. Permission.Service,
  44. Permission.Service.of({
  45. assert: (input) =>
  46. Effect.sync(() => assertions.push(input)).pipe(
  47. Effect.andThen(
  48. input.action === denyAction
  49. ? Effect.fail(
  50. new Permission.BlockedError({
  51. rules: [],
  52. permission: input.action,
  53. resources: input.resources,
  54. }),
  55. )
  56. : Effect.void,
  57. ),
  58. ),
  59. ask: () => Effect.die("unused"),
  60. reply: () => Effect.die("unused"),
  61. get: () => Effect.die("unused"),
  62. forSession: () => Effect.die("unused"),
  63. list: () => Effect.die("unused"),
  64. }),
  65. )
  66. const formatter = Layer.mock(Formatter.Service, {
  67. file: (target) => formatFile(target),
  68. })
  69. const reset = () => {
  70. assertions.length = 0
  71. writes.length = 0
  72. reads = 0
  73. denyAction = undefined
  74. afterRead = () => Effect.void
  75. formatFile = () => Effect.succeed(false)
  76. }
  77. const environment = Layer.effect(
  78. Environment.Service,
  79. Effect.gen(function* () {
  80. const current = yield* Environment.Service
  81. return Environment.Service.of({
  82. ...current,
  83. files: {
  84. ...current.files,
  85. read: (target, range) =>
  86. current.files
  87. .read(target, range)
  88. .pipe(
  89. Effect.tap((result) =>
  90. Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, result.bytes)))),
  91. ),
  92. ),
  93. write: (target, content) =>
  94. Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
  95. },
  96. })
  97. }),
  98. ).pipe(Layer.provide(LayerNode.compile(Environment.node)))
  99. const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
  100. const activeLocation = Layer.succeed(
  101. Location.Service,
  102. Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
  103. )
  104. return Effect.gen(function* () {
  105. return yield* body(yield* Tool.Service)
  106. }).pipe(
  107. Effect.provide(
  108. AppNodeBuilder.build(
  109. LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]),
  110. [
  111. [Environment.node, environment],
  112. [Location.node, activeLocation],
  113. [Formatter.node, formatter],
  114. [Permission.node, permission],
  115. ],
  116. ),
  117. ),
  118. )
  119. }
  120. const call = (input: typeof EditTool.Input.Type, id = "call-edit") => ({
  121. sessionID,
  122. ...toolIdentity,
  123. call: { type: "tool-call" as const, id, name: "edit", input },
  124. })
  125. const it = testEffect(Layer.empty)
  126. describe("EditTool", () => {
  127. it.live("registers and replaces relative exact text through FileMutation once", () =>
  128. Effect.acquireUseRelease(
  129. Effect.promise(() => tmpdir()),
  130. (tmp) => {
  131. reset()
  132. const target = path.join(tmp.path, "hello.txt")
  133. return Effect.promise(() => fs.writeFile(target, "before\nrest\n")).pipe(
  134. Effect.andThen(
  135. withTool(tmp.path, (registry) =>
  136. Effect.gen(function* () {
  137. expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit", "execute"])
  138. expect(
  139. (yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).map(
  140. (tool) => tool.name,
  141. ),
  142. ).toEqual(["execute"])
  143. const settled = yield* executeTool(
  144. registry,
  145. call({ path: "hello.txt", oldString: "before", newString: "after" }),
  146. )
  147. expect(settled.status).toBe("completed")
  148. if (settled.status !== "completed") return
  149. expect(settled.content).toEqual([
  150. {
  151. type: "text",
  152. text: "Edited hello.txt (1 replacement)",
  153. },
  154. ])
  155. // Compact UI metadata carries the file diffs the TUI renders.
  156. expect(settled.metadata).toMatchObject({
  157. files: [{ file: "hello.txt", status: "modified", additions: 1, deletions: 1 }],
  158. })
  159. expect(settled.output).toEqual({
  160. replacements: 1,
  161. files: [
  162. {
  163. file: "hello.txt",
  164. status: "modified",
  165. additions: 1,
  166. deletions: 1,
  167. patch: expect.stringContaining("-before\n+after"),
  168. },
  169. ],
  170. })
  171. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
  172. expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
  173. expect(assertions[0]?.metadata).toMatchObject({
  174. files: [
  175. {
  176. file: "hello.txt",
  177. status: "modified",
  178. additions: 1,
  179. deletions: 1,
  180. patch: expect.stringContaining("-before\n+after"),
  181. },
  182. ],
  183. })
  184. expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
  185. }),
  186. ),
  187. ),
  188. )
  189. },
  190. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  191. ),
  192. )
  193. it.live("returns the diff for final formatted content", () =>
  194. Effect.acquireUseRelease(
  195. Effect.promise(() => tmpdir()),
  196. (tmp) => {
  197. reset()
  198. const target = path.join(tmp.path, "formatted.txt")
  199. formatFile = (file) =>
  200. Effect.promise(async () => {
  201. await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("after", "AFTER"))
  202. return true
  203. })
  204. return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
  205. Effect.andThen(
  206. withTool(tmp.path, (registry) =>
  207. Effect.gen(function* () {
  208. const settled = yield* executeTool(
  209. registry,
  210. call({ path: "formatted.txt", oldString: "before", newString: "after" }),
  211. )
  212. expect(settled.status).toBe("completed")
  213. if (settled.status !== "completed") return
  214. expect(settled.output.files[0]?.patch).toContain("-before\n+AFTER")
  215. expect(settled.metadata?.files?.[0]?.patch).toContain("-before\n+AFTER")
  216. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("AFTER\n")
  217. }),
  218. ),
  219. ),
  220. )
  221. },
  222. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  223. ),
  224. )
  225. it.live("accepts an absolute file path inside the active Location", () =>
  226. Effect.acquireUseRelease(
  227. Effect.promise(() => tmpdir()),
  228. (tmp) => {
  229. reset()
  230. const target = path.join(tmp.path, "absolute.txt")
  231. return Effect.promise(() => fs.writeFile(target, "before")).pipe(
  232. Effect.andThen(
  233. withTool(tmp.path, (registry) =>
  234. executeTool(registry, call({ path: target, oldString: "before", newString: "after" })),
  235. ),
  236. ),
  237. Effect.andThen((result) =>
  238. Effect.gen(function* () {
  239. expect(result.status).toBe("completed")
  240. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  241. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after")
  242. }),
  243. ),
  244. )
  245. },
  246. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  247. ),
  248. )
  249. it.live("edits an external symlink target with only its in-location permission", () =>
  250. Effect.acquireUseRelease(
  251. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  252. ([active, outside]) => {
  253. reset()
  254. if (process.platform === "win32") return Effect.void
  255. const target = path.join(outside.path, "external.txt")
  256. const link = path.join(active.path, "link.txt")
  257. return Effect.promise(async () => {
  258. await fs.writeFile(target, "before")
  259. await fs.symlink(target, link)
  260. }).pipe(
  261. Effect.andThen(
  262. withTool(active.path, (registry) =>
  263. executeTool(registry, call({ path: "link.txt", oldString: "before", newString: "after" })),
  264. ),
  265. ),
  266. Effect.andThen((result) =>
  267. Effect.sync(() => {
  268. expect(result.status).toBe("completed")
  269. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  270. expect(assertions[0]?.resources).toEqual(["link.txt"])
  271. }),
  272. ),
  273. Effect.andThen(Effect.promise(() => fs.readFile(target, "utf8"))),
  274. Effect.tap((content) => Effect.sync(() => expect(content).toBe("after"))),
  275. )
  276. },
  277. ([active, outside]) =>
  278. Effect.promise(() =>
  279. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  280. ),
  281. ),
  282. )
  283. it.live("approves an explicit external absolute path before edit", () =>
  284. Effect.acquireUseRelease(
  285. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  286. ([active, outside]) => {
  287. reset()
  288. const target = path.join(outside.path, "external.txt")
  289. return Effect.promise(() => fs.writeFile(target, "before")).pipe(
  290. Effect.andThen(
  291. withTool(active.path, (registry) =>
  292. executeTool(registry, call({ path: target, oldString: "before", newString: "after" })),
  293. ),
  294. ),
  295. Effect.andThen((result) =>
  296. Effect.gen(function* () {
  297. expect(result.status).toBe("completed")
  298. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
  299. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after")
  300. expect(writes).toHaveLength(1)
  301. }),
  302. ),
  303. )
  304. },
  305. ([active, outside]) =>
  306. Effect.promise(() =>
  307. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  308. ),
  309. ),
  310. )
  311. it.live("does not write when external_directory or edit approval is denied", () =>
  312. Effect.acquireUseRelease(
  313. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  314. ([active, outside]) =>
  315. Effect.gen(function* () {
  316. const external = path.join(outside.path, "denied.txt")
  317. yield* Effect.promise(() => fs.writeFile(external, "before"))
  318. reset()
  319. denyAction = "external_directory"
  320. expect(
  321. yield* withTool(active.path, (registry) =>
  322. executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
  323. ),
  324. ).toEqual({
  325. status: "error",
  326. error: { type: "permission.rejected", message: "Permission denied: external_directory" },
  327. })
  328. expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
  329. expect(reads).toBe(0)
  330. expect(writes).toEqual([])
  331. reset()
  332. denyAction = "edit"
  333. expect(
  334. yield* withTool(active.path, (registry) =>
  335. executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
  336. ),
  337. ).toEqual({
  338. status: "error",
  339. error: { type: "permission.rejected", message: "Permission denied: edit" },
  340. })
  341. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
  342. expect(reads).toBe(1)
  343. expect(writes).toEqual([])
  344. expect(yield* Effect.promise(() => fs.readFile(external, "utf8"))).toBe("before")
  345. }),
  346. ([active, outside]) =>
  347. Effect.promise(() =>
  348. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  349. ),
  350. ),
  351. )
  352. it.live("denied edit does not disclose whether oldString matches", () =>
  353. Effect.acquireUseRelease(
  354. Effect.promise(() => tmpdir()),
  355. (tmp) => {
  356. reset()
  357. denyAction = "edit"
  358. const target = path.join(tmp.path, "secret.txt")
  359. return Effect.promise(() => fs.writeFile(target, "secret content")).pipe(
  360. Effect.andThen(
  361. withTool(tmp.path, (registry) =>
  362. Effect.gen(function* () {
  363. const matching = yield* executeTool(
  364. registry,
  365. call({ path: "secret.txt", oldString: "secret content", newString: "replacement" }),
  366. )
  367. const missing = yield* executeTool(
  368. registry,
  369. call({ path: "secret.txt", oldString: "not present", newString: "replacement" }),
  370. )
  371. expect(matching).toEqual({
  372. status: "error",
  373. error: { type: "permission.rejected", message: "Permission denied: edit" },
  374. })
  375. expect(missing).toEqual(matching)
  376. expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"])
  377. expect(reads).toBe(2)
  378. expect(writes).toEqual([])
  379. }),
  380. ),
  381. ),
  382. )
  383. },
  384. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  385. ),
  386. )
  387. it.live("rejects no-op, empty, missing, and ambiguous exact replacements", () =>
  388. Effect.acquireUseRelease(
  389. Effect.promise(() => tmpdir()),
  390. (tmp) => {
  391. reset()
  392. const target = path.join(tmp.path, "matches.txt")
  393. return Effect.promise(() => fs.writeFile(target, "same same")).pipe(
  394. Effect.andThen(
  395. withTool(tmp.path, (registry) =>
  396. Effect.gen(function* () {
  397. expect(
  398. yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "same" })),
  399. ).toEqual({
  400. status: "error",
  401. error: {
  402. type: "tool.execution",
  403. message: "No changes to apply: oldString and newString are identical.",
  404. },
  405. })
  406. expect(
  407. yield* executeTool(registry, call({ path: "matches.txt", oldString: "", newString: "after" })),
  408. ).toEqual({
  409. status: "error",
  410. error: {
  411. type: "tool.execution",
  412. message: "oldString must not be empty. Use write to create or overwrite a file.",
  413. },
  414. })
  415. expect(
  416. yield* executeTool(registry, call({ path: "matches.txt", oldString: "missing", newString: "after" })),
  417. ).toEqual({
  418. status: "error",
  419. error: {
  420. type: "tool.execution",
  421. message:
  422. "Could not find oldString in matches.txt. It must match exactly, including whitespace and indentation.",
  423. },
  424. })
  425. expect(
  426. yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "after" })),
  427. ).toEqual({
  428. status: "error",
  429. error: {
  430. type: "tool.execution",
  431. message:
  432. "Found 2 matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.",
  433. },
  434. })
  435. expect(writes).toEqual([])
  436. }),
  437. ),
  438. ),
  439. )
  440. },
  441. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  442. ),
  443. )
  444. it.live("returns specific missing file and directory errors", () =>
  445. Effect.acquireUseRelease(
  446. Effect.promise(() => tmpdir()),
  447. (tmp) => {
  448. reset()
  449. const directory = path.join(tmp.path, "src")
  450. return Effect.promise(() => fs.mkdir(directory)).pipe(
  451. Effect.andThen(
  452. withTool(tmp.path, (registry) =>
  453. Effect.gen(function* () {
  454. expect(
  455. yield* executeTool(registry, call({ path: "missing.ts", oldString: "before", newString: "after" })),
  456. ).toEqual({
  457. status: "error",
  458. error: { type: "tool.execution", message: "File not found: missing.ts" },
  459. })
  460. expect(
  461. yield* executeTool(registry, call({ path: "src", oldString: "before", newString: "after" })),
  462. ).toEqual({
  463. status: "error",
  464. error: { type: "tool.execution", message: "Path is a directory, not a file: src" },
  465. })
  466. expect(writes).toEqual([])
  467. }),
  468. ),
  469. ),
  470. )
  471. },
  472. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  473. ),
  474. )
  475. it.live("replaces every exact occurrence when replaceAll is true", () =>
  476. Effect.acquireUseRelease(
  477. Effect.promise(() => tmpdir()),
  478. (tmp) => {
  479. reset()
  480. const target = path.join(tmp.path, "all.txt")
  481. return Effect.promise(() => fs.writeFile(target, "same same same")).pipe(
  482. Effect.andThen(
  483. withTool(tmp.path, (registry) =>
  484. executeTool(registry, call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
  485. ),
  486. ),
  487. Effect.andThen((settled) =>
  488. Effect.gen(function* () {
  489. expect(settled.status).toBe("completed")
  490. if (settled.status !== "completed") return
  491. expect(settled.output).toMatchObject({ replacements: 3 })
  492. expect(settled.content).toEqual([{ type: "text", text: "Edited all.txt (3 replacements)" }])
  493. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after after after")
  494. expect(writes).toHaveLength(1)
  495. }),
  496. ),
  497. )
  498. },
  499. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  500. ),
  501. )
  502. it.live("normalizes Unicode typography only after exact matching fails", () =>
  503. Effect.acquireUseRelease(
  504. Effect.promise(() => tmpdir()),
  505. (tmp) => {
  506. reset()
  507. const target = path.join(tmp.path, "unicode.txt")
  508. return Effect.promise(() =>
  509. fs.writeFile(target, "exact - match\ncurly “quotes”\nminus − one\nspace\u00A0here\nexact − match\n"),
  510. ).pipe(
  511. Effect.andThen(
  512. withTool(tmp.path, (registry) =>
  513. Effect.gen(function* () {
  514. const normalized = yield* executeTool(
  515. registry,
  516. call({
  517. path: "unicode.txt",
  518. oldString: 'curly "quotes"\nminus - one\nspace here',
  519. newString: "normalized",
  520. }),
  521. )
  522. expect(normalized.status).toBe("completed")
  523. const exact = yield* executeTool(
  524. registry,
  525. call({ path: "unicode.txt", oldString: "exact - match", newString: "selected" }),
  526. )
  527. expect(exact.status).toBe("completed")
  528. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(
  529. "selected\nnormalized\nexact − match\n",
  530. )
  531. }),
  532. ),
  533. ),
  534. )
  535. },
  536. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  537. ),
  538. )
  539. it.live("ignores trailing whitespace while preserving untouched lines", () =>
  540. Effect.acquireUseRelease(
  541. Effect.promise(() => tmpdir()),
  542. (tmp) => {
  543. reset()
  544. const target = path.join(tmp.path, "whitespace.txt")
  545. return Effect.promise(() => fs.writeFile(target, "before \nmatch \nnext\t\nafter \n")).pipe(
  546. Effect.andThen(
  547. withTool(tmp.path, (registry) =>
  548. executeTool(registry, call({ path: "whitespace.txt", oldString: "match\nnext", newString: "changed" })),
  549. ),
  550. ),
  551. Effect.tap((result) => Effect.sync(() => expect(result.status).toBe("completed"))),
  552. Effect.andThen(Effect.promise(() => fs.readFile(target, "utf8"))),
  553. Effect.tap((content) => Effect.sync(() => expect(content).toBe("before \nchanged\nafter \n"))),
  554. )
  555. },
  556. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  557. ),
  558. )
  559. it.live("uses non-overlapping trailing-whitespace matches and preserves CRLF", () =>
  560. Effect.acquireUseRelease(
  561. Effect.promise(() => tmpdir()),
  562. (tmp) => {
  563. reset()
  564. const overlap = path.join(tmp.path, "overlap.txt")
  565. const windows = path.join(tmp.path, "windows.txt")
  566. return Effect.promise(() =>
  567. Promise.all([fs.writeFile(overlap, "a \na \na \n"), fs.writeFile(windows, "a \r\nb\t\r\n")]),
  568. ).pipe(
  569. Effect.andThen(
  570. withTool(tmp.path, (registry) =>
  571. Effect.gen(function* () {
  572. const replaced = yield* executeTool(
  573. registry,
  574. call({ path: "overlap.txt", oldString: "a\na", newString: "x", replaceAll: true }),
  575. )
  576. expect(replaced).toMatchObject({ status: "completed", output: { replacements: 1 } })
  577. yield* executeTool(registry, call({ path: "windows.txt", oldString: "a\nb", newString: "x" }))
  578. }),
  579. ),
  580. ),
  581. Effect.andThen(
  582. Effect.promise(() => Promise.all([fs.readFile(overlap, "utf8"), fs.readFile(windows, "utf8")])),
  583. ),
  584. Effect.tap(([overlapContent, windowsContent]) =>
  585. Effect.sync(() => {
  586. expect(overlapContent).toBe("x\na \n")
  587. expect(windowsContent).toBe("x\r\n")
  588. }),
  589. ),
  590. )
  591. },
  592. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  593. ),
  594. )
  595. it.live("preserves BOM and CRLF line endings", () =>
  596. Effect.acquireUseRelease(
  597. Effect.promise(() => tmpdir()),
  598. (tmp) => {
  599. reset()
  600. const target = path.join(tmp.path, "windows.txt")
  601. formatFile = (file) =>
  602. Effect.promise(async () => {
  603. await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, ""))
  604. return true
  605. })
  606. return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe(
  607. Effect.andThen(
  608. withTool(tmp.path, (registry) =>
  609. executeTool(registry, call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })),
  610. ),
  611. ),
  612. Effect.andThen(() => Effect.promise(() => fs.readFile(target, "utf8"))),
  613. Effect.tap((content) => Effect.sync(() => expect(content).toBe("\uFEFFafter\r\nrest\r\n"))),
  614. )
  615. },
  616. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  617. ),
  618. )
  619. it.live("serializes concurrent edit transactions", () =>
  620. Effect.acquireUseRelease(
  621. Effect.promise(() => tmpdir()),
  622. (tmp) => {
  623. reset()
  624. const target = path.join(tmp.path, "concurrent.txt")
  625. afterRead = () => (reads === 1 ? Effect.sleep("50 millis") : Effect.void)
  626. return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
  627. Effect.andThen(
  628. withTool(tmp.path, (registry) =>
  629. Effect.all(
  630. [
  631. executeTool(
  632. registry,
  633. call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"),
  634. ),
  635. executeTool(
  636. registry,
  637. call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"),
  638. ),
  639. ],
  640. { concurrency: "unbounded" },
  641. ),
  642. ),
  643. ),
  644. Effect.andThen((results) =>
  645. Effect.gen(function* () {
  646. expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
  647. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
  648. }),
  649. ),
  650. )
  651. },
  652. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  653. ),
  654. )
  655. it.live("applies the edit when content changes after matching", () =>
  656. Effect.acquireUseRelease(
  657. Effect.promise(() => tmpdir()),
  658. (tmp) => {
  659. reset()
  660. const target = path.join(tmp.path, "concurrent.txt")
  661. afterRead = () => (reads === 1 ? Effect.promise(() => fs.writeFile(target, "newer\n")) : Effect.void)
  662. return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
  663. Effect.andThen(
  664. withTool(tmp.path, (registry) =>
  665. executeTool(registry, call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
  666. ),
  667. ),
  668. Effect.andThen((result) =>
  669. Effect.gen(function* () {
  670. expect(result).toMatchObject({ status: "completed", output: { replacements: 1 } })
  671. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
  672. expect(writes).toEqual([target])
  673. }),
  674. ),
  675. )
  676. },
  677. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  678. ),
  679. )
  680. })