tool-edit.test.ts 27 KB

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