file-mutation.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  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/core/effect/layer-node"
  7. import { FileMutation } from "@opencode-ai/core/file-mutation"
  8. import { FSUtil } from "@opencode-ai/core/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("rejects create when a prospective target appears after resolution", () =>
  83. withTmp((directory) =>
  84. Effect.gen(function* () {
  85. const targetPath = path.join(directory, "appeared.txt")
  86. const target = yield* (yield* LocationMutation.Service).resolve({ path: "appeared.txt" })
  87. yield* Effect.promise(() => fs.writeFile(targetPath, "winner"))
  88. expect(
  89. yield* (yield* FileMutation.Service).create({ target, content: "replacement" }).pipe(Effect.flip),
  90. ).toMatchObject({
  91. _tag: "FileMutation.TargetExistsError",
  92. })
  93. expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("winner")
  94. }).pipe(provide(directory)),
  95. ),
  96. )
  97. it.live("creates when an existing target disappears after resolution", () =>
  98. withTmp((directory) =>
  99. Effect.gen(function* () {
  100. const targetPath = path.join(directory, "removed.txt")
  101. yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
  102. const target = yield* (yield* LocationMutation.Service).resolve({ path: "removed.txt" })
  103. yield* Effect.promise(() => fs.rm(targetPath))
  104. expect(yield* (yield* FileMutation.Service).create({ target, content: "after" })).toEqual({
  105. operation: "write",
  106. target: target.canonical,
  107. resource: "removed.txt",
  108. existed: false,
  109. })
  110. expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
  111. }).pipe(provide(directory)),
  112. ),
  113. )
  114. it.live("removes an existing internal file", () =>
  115. withTmp((directory) =>
  116. Effect.gen(function* () {
  117. const targetPath = path.join(directory, "remove.txt")
  118. yield* Effect.promise(() => fs.writeFile(targetPath, "remove"))
  119. const target = yield* (yield* LocationMutation.Service).resolve({ path: "remove.txt" })
  120. const result = yield* (yield* FileMutation.Service).remove({ target })
  121. expect(result).toEqual({
  122. operation: "remove",
  123. target: target.canonical,
  124. resource: "remove.txt",
  125. existed: true,
  126. })
  127. expect(
  128. yield* Effect.promise(() =>
  129. fs.stat(targetPath).then(
  130. () => true,
  131. () => false,
  132. ),
  133. ),
  134. ).toBe(false)
  135. }).pipe(provide(directory)),
  136. ),
  137. )
  138. it.live("writes an explicitly resolved external target", () =>
  139. withTmp((directory) =>
  140. withTmp((outside) =>
  141. Effect.gen(function* () {
  142. const targetPath = path.join(outside, "external.txt")
  143. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  144. const result = yield* (yield* FileMutation.Service).write({ target, content: "external" })
  145. expect(result).toEqual({
  146. operation: "write",
  147. target: target.canonical,
  148. resource: target.resource,
  149. existed: false,
  150. })
  151. expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external")
  152. }).pipe(provide(directory)),
  153. ),
  154. ),
  155. )
  156. it.live("removes an explicitly resolved external target", () =>
  157. withTmp((directory) =>
  158. withTmp((outside) =>
  159. Effect.gen(function* () {
  160. const targetPath = path.join(outside, "external.txt")
  161. yield* Effect.promise(() => fs.writeFile(targetPath, "external"))
  162. const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
  163. const result = yield* (yield* FileMutation.Service).remove({ target })
  164. expect(result).toEqual({
  165. operation: "remove",
  166. target: target.canonical,
  167. resource: target.resource,
  168. existed: true,
  169. })
  170. expect(
  171. yield* Effect.promise(() =>
  172. fs.stat(targetPath).then(
  173. () => true,
  174. () => false,
  175. ),
  176. ),
  177. ).toBe(false)
  178. }).pipe(provide(directory)),
  179. ),
  180. ),
  181. )
  182. it.live("reports a missing target as not removed without checking existence first", () =>
  183. withTmp((directory) =>
  184. Effect.gen(function* () {
  185. const target = yield* (yield* LocationMutation.Service).resolve({ path: "missing.txt" })
  186. expect(yield* (yield* FileMutation.Service).remove({ target })).toEqual({
  187. operation: "remove",
  188. target: target.canonical,
  189. resource: "missing.txt",
  190. existed: false,
  191. })
  192. }).pipe(provide(directory)),
  193. ),
  194. )
  195. it.live("serializes concurrent writes to the same canonical target", () =>
  196. withTmp((directory) =>
  197. Effect.gen(function* () {
  198. const targetPath = path.join(directory, "shared.txt")
  199. yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
  200. const firstStarted = yield* Deferred.make<void>()
  201. const releaseFirst = yield* Deferred.make<void>()
  202. const secondStarted = yield* Deferred.make<void>()
  203. let writes = 0
  204. const filesystem = instrumentWrites((write) =>
  205. Effect.gen(function* () {
  206. writes++
  207. if (writes === 1) {
  208. yield* Deferred.succeed(firstStarted, undefined)
  209. yield* Deferred.await(releaseFirst)
  210. } else {
  211. yield* Deferred.succeed(secondStarted, undefined)
  212. }
  213. yield* write
  214. }),
  215. )
  216. yield* Effect.gen(function* () {
  217. const mutation = yield* LocationMutation.Service
  218. const files = yield* FileMutation.Service
  219. const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
  220. const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
  221. const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
  222. yield* Deferred.await(firstStarted)
  223. const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
  224. yield* Effect.yieldNow
  225. expect(yield* Deferred.isDone(secondStarted)).toBe(false)
  226. yield* Deferred.succeed(releaseFirst, undefined)
  227. yield* Deferred.await(secondStarted)
  228. yield* Fiber.join(first)
  229. yield* Fiber.join(second)
  230. expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("second")
  231. }).pipe(provide(directory, filesystem))
  232. }),
  233. ),
  234. )
  235. it.live("allows only one concurrent conditional write based on the same bytes", () =>
  236. withTmp((directory) =>
  237. Effect.gen(function* () {
  238. const targetPath = path.join(directory, "shared.txt")
  239. yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
  240. const firstStarted = yield* Deferred.make<void>()
  241. const releaseFirst = yield* Deferred.make<void>()
  242. let writes = 0
  243. const filesystem = instrumentWrites((write) =>
  244. Effect.gen(function* () {
  245. writes++
  246. if (writes === 1) {
  247. yield* Deferred.succeed(firstStarted, undefined)
  248. yield* Deferred.await(releaseFirst)
  249. }
  250. yield* write
  251. }),
  252. )
  253. yield* Effect.gen(function* () {
  254. const mutation = yield* LocationMutation.Service
  255. const files = yield* FileMutation.Service
  256. const target = yield* mutation.resolve({ path: "shared.txt" })
  257. const expected = new TextEncoder().encode("initial")
  258. const first = yield* files.writeIfUnchanged({ target, expected, content: "first" }).pipe(Effect.forkChild)
  259. yield* Deferred.await(firstStarted)
  260. const second = yield* files
  261. .writeIfUnchanged({ target, expected, content: "second" })
  262. .pipe(Effect.flip, Effect.forkChild)
  263. yield* Deferred.succeed(releaseFirst, undefined)
  264. yield* Fiber.join(first)
  265. expect(yield* Fiber.join(second)).toMatchObject({ _tag: "FileMutation.StaleContentError" })
  266. expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("first")
  267. expect(writes).toBe(1)
  268. }).pipe(provide(directory, filesystem))
  269. }),
  270. ),
  271. )
  272. it.live("rejects a conditional write when target content is already stale", () =>
  273. withTmp((directory) =>
  274. Effect.gen(function* () {
  275. const targetPath = path.join(directory, "stale.txt")
  276. yield* Effect.promise(() => fs.writeFile(targetPath, "current"))
  277. const target = yield* (yield* LocationMutation.Service).resolve({ path: "stale.txt" })
  278. expect(
  279. yield* (yield* FileMutation.Service)
  280. .writeIfUnchanged({ target, expected: new TextEncoder().encode("older"), content: "replacement" })
  281. .pipe(Effect.flip),
  282. ).toMatchObject({ _tag: "FileMutation.StaleContentError", path: target.canonical })
  283. expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("current")
  284. }).pipe(provide(directory)),
  285. ),
  286. )
  287. it.live("allows distinct canonical targets to proceed independently", () =>
  288. withTmp((directory) =>
  289. Effect.gen(function* () {
  290. const firstStarted = yield* Deferred.make<void>()
  291. const releaseFirst = yield* Deferred.make<void>()
  292. const secondFinished = yield* Deferred.make<void>()
  293. const secondPath = path.join(directory, "second.txt")
  294. let writes = 0
  295. const filesystem = instrumentWrites((write) =>
  296. ++writes === 1
  297. ? Deferred.succeed(firstStarted, undefined).pipe(
  298. Effect.andThen(Deferred.await(releaseFirst)),
  299. Effect.andThen(write),
  300. )
  301. : write.pipe(Effect.andThen(Deferred.succeed(secondFinished, undefined))),
  302. )
  303. yield* Effect.gen(function* () {
  304. const mutation = yield* LocationMutation.Service
  305. const files = yield* FileMutation.Service
  306. const firstPlan = yield* mutation.resolve({ path: "first.txt" })
  307. const secondPlan = yield* mutation.resolve({ path: "second.txt" })
  308. const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
  309. yield* Deferred.await(firstStarted)
  310. const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
  311. yield* Deferred.await(secondFinished)
  312. expect(yield* Effect.promise(() => fs.readFile(secondPath, "utf8"))).toBe("second")
  313. yield* Deferred.succeed(releaseFirst, undefined)
  314. yield* Fiber.join(first)
  315. yield* Fiber.join(second)
  316. }).pipe(provide(directory, filesystem))
  317. }),
  318. ),
  319. )
  320. })
  321. function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>) {
  322. return Layer.effect(
  323. FSUtil.Service,
  324. Effect.gen(function* () {
  325. const filesystem = yield* FSUtil.Service
  326. return FSUtil.Service.of({
  327. ...filesystem,
  328. writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target),
  329. writeFile: (target, content, options) => run(filesystem.writeFile(target, content, options), target),
  330. writeFileString: (target, content, options) =>
  331. run(filesystem.writeFileString(target, content, options), target),
  332. })
  333. }),
  334. ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
  335. }