file-mutation.test.ts 14 KB

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