file-mutation.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  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 { FileMutation } from "@opencode-ai/core/file-mutation"
  6. import { FSUtil } from "@opencode-ai/core/fs-util"
  7. import { Location } from "@opencode-ai/core/location"
  8. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  9. import { AbsolutePath } from "@opencode-ai/core/schema"
  10. import { location } from "./fixture/location"
  11. import { tmpdir } from "./fixture/tmpdir"
  12. import { it } from "./lib/effect"
  13. function provide(directory: string, filesystem = FSUtil.defaultLayer) {
  14. const activeLocation = Layer.succeed(
  15. Location.Service,
  16. Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
  17. )
  18. const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
  19. const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
  20. return Effect.provide(Layer.mergeAll(resolution, mutation))
  21. }
  22. function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
  23. return Effect.acquireRelease(
  24. Effect.promise(() => tmpdir()),
  25. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  26. ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
  27. }
  28. describe("FileMutation", () => {
  29. it.live("writes an existing internal file and returns a stable result", () =>
  30. withTmp((directory) =>
  31. Effect.gen(function* () {
  32. const targetPath = path.join(directory, "hello.txt")
  33. yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
  34. const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
  35. expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
  36. operation: "write",
  37. target: target.canonical,
  38. resource: "hello.txt",
  39. existed: true,
  40. })
  41. expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
  42. }).pipe(provide(directory)),
  43. ),
  44. )
  45. it.live("writes a prospective internal file and creates parent directories", () =>
  46. withTmp((directory) =>
  47. Effect.gen(function* () {
  48. const target = yield* (yield* LocationMutation.Service).resolve({
  49. path: path.join("src", "nested", "hello.txt"),
  50. })
  51. const result = yield* (yield* FileMutation.Service).write({ target, content: "hello" })
  52. expect(result).toEqual({
  53. operation: "write",
  54. target: target.canonical,
  55. resource: "src/nested/hello.txt",
  56. existed: false,
  57. })
  58. expect(yield* Effect.promise(() => fs.readFile(result.target, "utf8"))).toBe("hello")
  59. }).pipe(provide(directory)),
  60. ),
  61. )
  62. it.live("preserves exactly one BOM for text writes and normalizes created text", () =>
  63. withTmp((directory) =>
  64. Effect.gen(function* () {
  65. const preservedPath = path.join(directory, "preserved.txt")
  66. yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore"))
  67. const preserved = yield* (yield* LocationMutation.Service).resolve({ path: "preserved.txt" })
  68. const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
  69. const files = yield* FileMutation.Service
  70. yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
  71. yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
  72. expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
  73. expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated")
  74. }).pipe(provide(directory)),
  75. ),
  76. )
  77. it.live("rejects create when a prospective target appears after resolution", () =>
  78. withTmp((directory) =>
  79. Effect.gen(function* () {
  80. const targetPath = path.join(directory, "appeared.txt")
  81. const target = yield* (yield* LocationMutation.Service).resolve({ path: "appeared.txt" })
  82. yield* Effect.promise(() => fs.writeFile(targetPath, "winner"))
  83. expect(
  84. yield* (yield* FileMutation.Service).create({ target, content: "replacement" }).pipe(Effect.flip),
  85. ).toMatchObject({
  86. _tag: "FileMutation.TargetExistsError",
  87. })
  88. expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("winner")
  89. }).pipe(provide(directory)),
  90. ),
  91. )
  92. it.live("creates when an existing target disappears after resolution", () =>
  93. withTmp((directory) =>
  94. Effect.gen(function* () {
  95. const targetPath = path.join(directory, "removed.txt")
  96. yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
  97. const target = yield* (yield* LocationMutation.Service).resolve({ path: "removed.txt" })
  98. yield* Effect.promise(() => fs.rm(targetPath))
  99. expect(yield* (yield* FileMutation.Service).create({ target, content: "after" })).toEqual({
  100. operation: "write",
  101. target: target.canonical,
  102. resource: "removed.txt",
  103. existed: false,
  104. })
  105. expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
  106. }).pipe(provide(directory)),
  107. ),
  108. )
  109. it.live("removes an existing internal file", () =>
  110. withTmp((directory) =>
  111. Effect.gen(function* () {
  112. const targetPath = path.join(directory, "remove.txt")
  113. yield* Effect.promise(() => fs.writeFile(targetPath, "remove"))
  114. const target = yield* (yield* LocationMutation.Service).resolve({ path: "remove.txt" })
  115. const result = yield* (yield* FileMutation.Service).remove({ target })
  116. expect(result).toEqual({
  117. operation: "remove",
  118. target: target.canonical,
  119. resource: "remove.txt",
  120. existed: true,
  121. })
  122. expect(
  123. yield* Effect.promise(() =>
  124. fs.stat(targetPath).then(
  125. () => true,
  126. () => false,
  127. ),
  128. ),
  129. ).toBe(false)
  130. }).pipe(provide(directory)),
  131. ),
  132. )
  133. it.live("writes an explicitly resolved external target", () =>
  134. withTmp((directory) =>
  135. withTmp((outside) =>
  136. Effect.gen(function* () {
  137. const targetPath = path.join(outside, "external.txt")
  138. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  139. const result = yield* (yield* FileMutation.Service).write({ target, content: "external" })
  140. expect(result).toEqual({
  141. operation: "write",
  142. target: target.canonical,
  143. resource: target.resource,
  144. existed: false,
  145. })
  146. expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external")
  147. }).pipe(provide(directory)),
  148. ),
  149. ),
  150. )
  151. it.live("removes an explicitly resolved external target", () =>
  152. withTmp((directory) =>
  153. withTmp((outside) =>
  154. Effect.gen(function* () {
  155. const targetPath = path.join(outside, "external.txt")
  156. yield* Effect.promise(() => fs.writeFile(targetPath, "external"))
  157. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  158. const result = yield* (yield* FileMutation.Service).remove({ target })
  159. expect(result).toEqual({
  160. operation: "remove",
  161. target: target.canonical,
  162. resource: target.resource,
  163. existed: true,
  164. })
  165. expect(
  166. yield* Effect.promise(() =>
  167. fs.stat(targetPath).then(
  168. () => true,
  169. () => false,
  170. ),
  171. ),
  172. ).toBe(false)
  173. }).pipe(provide(directory)),
  174. ),
  175. ),
  176. )
  177. it.live("reports a missing target as not removed without checking existence first", () =>
  178. withTmp((directory) =>
  179. Effect.gen(function* () {
  180. const target = yield* (yield* LocationMutation.Service).resolve({ path: "missing.txt" })
  181. expect(yield* (yield* FileMutation.Service).remove({ target })).toEqual({
  182. operation: "remove",
  183. target: target.canonical,
  184. resource: "missing.txt",
  185. existed: false,
  186. })
  187. }).pipe(provide(directory)),
  188. ),
  189. )
  190. it.live("serializes concurrent writes to the same canonical target", () =>
  191. withTmp((directory) =>
  192. Effect.gen(function* () {
  193. const targetPath = path.join(directory, "shared.txt")
  194. yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
  195. const firstStarted = yield* Deferred.make<void>()
  196. const releaseFirst = yield* Deferred.make<void>()
  197. const secondStarted = yield* Deferred.make<void>()
  198. let writes = 0
  199. const filesystem = instrumentWrites((write) =>
  200. Effect.gen(function* () {
  201. writes++
  202. if (writes === 1) {
  203. yield* Deferred.succeed(firstStarted, undefined)
  204. yield* Deferred.await(releaseFirst)
  205. } else {
  206. yield* Deferred.succeed(secondStarted, undefined)
  207. }
  208. yield* write
  209. }),
  210. )
  211. yield* Effect.gen(function* () {
  212. const mutation = yield* LocationMutation.Service
  213. const files = yield* FileMutation.Service
  214. const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
  215. const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
  216. const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
  217. yield* Deferred.await(firstStarted)
  218. const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
  219. yield* Effect.yieldNow
  220. expect(yield* Deferred.isDone(secondStarted)).toBe(false)
  221. yield* Deferred.succeed(releaseFirst, undefined)
  222. yield* Deferred.await(secondStarted)
  223. yield* Fiber.join(first)
  224. yield* Fiber.join(second)
  225. expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("second")
  226. }).pipe(provide(directory, filesystem))
  227. }),
  228. ),
  229. )
  230. it.live("allows only one concurrent conditional write based on the same bytes", () =>
  231. withTmp((directory) =>
  232. Effect.gen(function* () {
  233. const targetPath = path.join(directory, "shared.txt")
  234. yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
  235. const firstStarted = yield* Deferred.make<void>()
  236. const releaseFirst = yield* Deferred.make<void>()
  237. let writes = 0
  238. const filesystem = instrumentWrites((write) =>
  239. Effect.gen(function* () {
  240. writes++
  241. if (writes === 1) {
  242. yield* Deferred.succeed(firstStarted, undefined)
  243. yield* Deferred.await(releaseFirst)
  244. }
  245. yield* write
  246. }),
  247. )
  248. yield* Effect.gen(function* () {
  249. const mutation = yield* LocationMutation.Service
  250. const files = yield* FileMutation.Service
  251. const target = yield* mutation.resolve({ path: "shared.txt" })
  252. const expected = new TextEncoder().encode("initial")
  253. const first = yield* files.writeIfUnchanged({ target, expected, content: "first" }).pipe(Effect.forkChild)
  254. yield* Deferred.await(firstStarted)
  255. const second = yield* files
  256. .writeIfUnchanged({ target, expected, content: "second" })
  257. .pipe(Effect.flip, Effect.forkChild)
  258. yield* Deferred.succeed(releaseFirst, undefined)
  259. yield* Fiber.join(first)
  260. expect(yield* Fiber.join(second)).toMatchObject({ _tag: "FileMutation.StaleContentError" })
  261. expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("first")
  262. expect(writes).toBe(1)
  263. }).pipe(provide(directory, filesystem))
  264. }),
  265. ),
  266. )
  267. it.live("rejects a conditional write when target content is already stale", () =>
  268. withTmp((directory) =>
  269. Effect.gen(function* () {
  270. const targetPath = path.join(directory, "stale.txt")
  271. yield* Effect.promise(() => fs.writeFile(targetPath, "current"))
  272. const target = yield* (yield* LocationMutation.Service).resolve({ path: "stale.txt" })
  273. expect(
  274. yield* (yield* FileMutation.Service)
  275. .writeIfUnchanged({ target, expected: new TextEncoder().encode("older"), content: "replacement" })
  276. .pipe(Effect.flip),
  277. ).toMatchObject({ _tag: "FileMutation.StaleContentError", path: target.canonical })
  278. expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("current")
  279. }).pipe(provide(directory)),
  280. ),
  281. )
  282. it.live("allows distinct canonical targets to proceed independently", () =>
  283. withTmp((directory) =>
  284. Effect.gen(function* () {
  285. const firstStarted = yield* Deferred.make<void>()
  286. const releaseFirst = yield* Deferred.make<void>()
  287. const secondFinished = yield* Deferred.make<void>()
  288. const secondPath = path.join(directory, "second.txt")
  289. let writes = 0
  290. const filesystem = instrumentWrites((write) =>
  291. ++writes === 1
  292. ? Deferred.succeed(firstStarted, undefined).pipe(
  293. Effect.andThen(Deferred.await(releaseFirst)),
  294. Effect.andThen(write),
  295. )
  296. : write.pipe(Effect.andThen(Deferred.succeed(secondFinished, undefined))),
  297. )
  298. yield* Effect.gen(function* () {
  299. const mutation = yield* LocationMutation.Service
  300. const files = yield* FileMutation.Service
  301. const firstPlan = yield* mutation.resolve({ path: "first.txt" })
  302. const secondPlan = yield* mutation.resolve({ path: "second.txt" })
  303. const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
  304. yield* Deferred.await(firstStarted)
  305. const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
  306. yield* Deferred.await(secondFinished)
  307. expect(yield* Effect.promise(() => fs.readFile(secondPath, "utf8"))).toBe("second")
  308. yield* Deferred.succeed(releaseFirst, undefined)
  309. yield* Fiber.join(first)
  310. yield* Fiber.join(second)
  311. }).pipe(provide(directory, filesystem))
  312. }),
  313. ),
  314. )
  315. })
  316. function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>) {
  317. return Layer.effect(
  318. FSUtil.Service,
  319. Effect.gen(function* () {
  320. const filesystem = yield* FSUtil.Service
  321. return FSUtil.Service.of({
  322. ...filesystem,
  323. writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target),
  324. writeFile: (target, content, options) => run(filesystem.writeFile(target, content, options), target),
  325. writeFileString: (target, content, options) =>
  326. run(filesystem.writeFileString(target, content, options), target),
  327. })
  328. }),
  329. ).pipe(Layer.provide(FSUtil.defaultLayer))
  330. }