file-mutation.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect } from "bun:test"
  4. import { Deferred, Effect, Fiber, 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 { FileMutation } from "@opencode-ai/core/file-mutation"
  8. import { Environment } from "@opencode-ai/core/environment"
  9. import { Location } from "@opencode-ai/core/location"
  10. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  11. import { AbsolutePath } from "@opencode-ai/core/schema"
  12. import { type EnvironmentFilesTransform, transformEnvironmentFiles } from "./fixture/environment"
  13. import { location } from "./fixture/location"
  14. import { tmpdir } from "./fixture/tmpdir"
  15. import { it } from "./lib/effect"
  16. function provide(directory: string, transformFiles: EnvironmentFilesTransform = () => ({})) {
  17. const activeLocation = Layer.succeed(
  18. Location.Service,
  19. Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
  20. )
  21. return Effect.provide(
  22. AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
  23. [Location.node, activeLocation],
  24. [Environment.node, transformEnvironmentFiles(activeLocation, transformFiles)],
  25. ]),
  26. )
  27. }
  28. function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
  29. return Effect.acquireRelease(
  30. Effect.promise(() => tmpdir()),
  31. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  32. ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
  33. }
  34. describe("FileMutation", () => {
  35. it.live("writes an existing internal file and returns a stable result", () =>
  36. withTmp((directory) =>
  37. Effect.gen(function* () {
  38. const targetPath = path.join(directory, "hello.txt")
  39. yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
  40. const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
  41. expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
  42. operation: "write",
  43. target: target.absolute,
  44. resource: "hello.txt",
  45. existed: true,
  46. })
  47. expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
  48. }).pipe(provide(directory)),
  49. ),
  50. )
  51. it.live("writes a prospective internal file and creates parent directories", () =>
  52. withTmp((directory) =>
  53. Effect.gen(function* () {
  54. const target = yield* (yield* LocationMutation.Service).resolve({
  55. path: path.join("src", "nested", "hello.txt"),
  56. })
  57. const result = yield* (yield* FileMutation.Service).write({ target, content: "hello" })
  58. expect(result).toEqual({
  59. operation: "write",
  60. target: target.absolute,
  61. resource: "src/nested/hello.txt",
  62. existed: false,
  63. })
  64. expect(yield* Effect.promise(() => fs.readFile(target.absolute, "utf8"))).toBe("hello")
  65. }).pipe(provide(directory)),
  66. ),
  67. )
  68. it.live("preserves exactly one BOM for text writes and normalizes created text", () =>
  69. withTmp((directory) =>
  70. Effect.gen(function* () {
  71. const preservedPath = path.join(directory, "preserved.txt")
  72. yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore"))
  73. const preserved = yield* (yield* LocationMutation.Service).resolve({ path: "preserved.txt" })
  74. const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
  75. const files = yield* FileMutation.Service
  76. yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
  77. yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
  78. expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
  79. expect(yield* Effect.promise(() => fs.readFile(created.absolute, "utf8"))).toBe("\uFEFFcreated")
  80. }).pipe(provide(directory)),
  81. ),
  82. )
  83. it.live("writes an explicitly resolved external target", () =>
  84. withTmp((directory) =>
  85. withTmp((outside) =>
  86. Effect.gen(function* () {
  87. const targetPath = path.join(outside, "external.txt")
  88. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  89. const result = yield* (yield* FileMutation.Service).write({ target, content: "external" })
  90. expect(result).toEqual({
  91. operation: "write",
  92. target: target.absolute,
  93. resource: target.resource,
  94. existed: false,
  95. })
  96. expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external")
  97. }).pipe(provide(directory)),
  98. ),
  99. ),
  100. )
  101. it.live("serializes concurrent writes to the same absolute target", () =>
  102. withTmp((directory) =>
  103. Effect.gen(function* () {
  104. const targetPath = path.join(directory, "shared.txt")
  105. yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
  106. const firstStarted = yield* Deferred.make<void>()
  107. const releaseFirst = yield* Deferred.make<void>()
  108. const secondStarted = yield* Deferred.make<void>()
  109. let writes = 0
  110. const filesystem = instrumentWrites((write) =>
  111. Effect.gen(function* () {
  112. writes++
  113. if (writes === 1) {
  114. yield* Deferred.succeed(firstStarted, undefined)
  115. yield* Deferred.await(releaseFirst)
  116. } else {
  117. yield* Deferred.succeed(secondStarted, undefined)
  118. }
  119. yield* write
  120. }),
  121. )
  122. yield* Effect.gen(function* () {
  123. const mutation = yield* LocationMutation.Service
  124. const files = yield* FileMutation.Service
  125. const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
  126. const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
  127. const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
  128. yield* Deferred.await(firstStarted)
  129. const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
  130. yield* Effect.yieldNow
  131. expect(yield* Deferred.isDone(secondStarted)).toBe(false)
  132. yield* Deferred.succeed(releaseFirst, undefined)
  133. yield* Deferred.await(secondStarted)
  134. yield* Fiber.join(first)
  135. yield* Fiber.join(second)
  136. expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("second")
  137. }).pipe(provide(directory, filesystem))
  138. }),
  139. ),
  140. )
  141. it.live("shares transaction locks across Location service instances", () =>
  142. withTmp((directory) =>
  143. Effect.gen(function* () {
  144. const firstStarted = yield* Deferred.make<void>()
  145. const releaseFirst = yield* Deferred.make<void>()
  146. const secondStarted = yield* Deferred.make<void>()
  147. const target = path.join(directory, "shared.txt")
  148. const first = yield* Effect.gen(function* () {
  149. const files = yield* FileMutation.Service
  150. yield* files.withLock([target])(
  151. Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
  152. )
  153. }).pipe(provide(directory), Effect.forkChild)
  154. yield* Deferred.await(firstStarted)
  155. const second = yield* Effect.gen(function* () {
  156. const files = yield* FileMutation.Service
  157. yield* files.withLock([target])(Deferred.succeed(secondStarted, undefined))
  158. }).pipe(provide(directory), Effect.forkChild)
  159. yield* Effect.yieldNow
  160. expect(yield* Deferred.isDone(secondStarted)).toBe(false)
  161. yield* Deferred.succeed(releaseFirst, undefined)
  162. yield* Deferred.await(secondStarted)
  163. yield* Fiber.join(first)
  164. yield* Fiber.join(second)
  165. }),
  166. ),
  167. )
  168. it.live("allows transaction locks for distinct resolved paths to proceed independently", () =>
  169. withTmp((directory) =>
  170. Effect.gen(function* () {
  171. const firstStarted = yield* Deferred.make<void>()
  172. const releaseFirst = yield* Deferred.make<void>()
  173. const secondFinished = yield* Deferred.make<void>()
  174. const files = yield* FileMutation.Service
  175. const first = yield* files
  176. .withLock([path.join(directory, "first.txt")])(
  177. Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
  178. )
  179. .pipe(Effect.forkChild)
  180. yield* Deferred.await(firstStarted)
  181. yield* files.withLock([path.join(directory, "second.txt")])(Deferred.succeed(secondFinished, undefined))
  182. expect(yield* Deferred.isDone(secondFinished)).toBe(true)
  183. yield* Deferred.succeed(releaseFirst, undefined)
  184. yield* Fiber.join(first)
  185. }).pipe(provide(directory)),
  186. ),
  187. )
  188. it.live("allows distinct absolute targets to proceed independently", () =>
  189. withTmp((directory) =>
  190. Effect.gen(function* () {
  191. const firstStarted = yield* Deferred.make<void>()
  192. const releaseFirst = yield* Deferred.make<void>()
  193. const secondFinished = yield* Deferred.make<void>()
  194. const secondPath = path.join(directory, "second.txt")
  195. let writes = 0
  196. const filesystem = instrumentWrites((write) =>
  197. ++writes === 1
  198. ? Deferred.succeed(firstStarted, undefined).pipe(
  199. Effect.andThen(Deferred.await(releaseFirst)),
  200. Effect.andThen(write),
  201. )
  202. : write.pipe(Effect.andThen(Deferred.succeed(secondFinished, undefined))),
  203. )
  204. yield* Effect.gen(function* () {
  205. const mutation = yield* LocationMutation.Service
  206. const files = yield* FileMutation.Service
  207. const firstPlan = yield* mutation.resolve({ path: "first.txt" })
  208. const secondPlan = yield* mutation.resolve({ path: "second.txt" })
  209. const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
  210. yield* Deferred.await(firstStarted)
  211. const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
  212. yield* Deferred.await(secondFinished)
  213. expect(yield* Effect.promise(() => fs.readFile(secondPath, "utf8"))).toBe("second")
  214. yield* Deferred.succeed(releaseFirst, undefined)
  215. yield* Fiber.join(first)
  216. yield* Fiber.join(second)
  217. }).pipe(provide(directory, filesystem))
  218. }),
  219. ),
  220. )
  221. })
  222. function instrumentWrites(
  223. run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>,
  224. ): EnvironmentFilesTransform {
  225. return (files) => ({ write: (target, content) => run(files.write(target, content), target) })
  226. }