file-mutation.test.ts 8.7 KB

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