file-mutation.test.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  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("shares transaction locks across Location service instances", () =>
  102. withTmp((directory) =>
  103. Effect.gen(function* () {
  104. const firstStarted = yield* Deferred.make<void>()
  105. const releaseFirst = yield* Deferred.make<void>()
  106. const secondStarted = yield* Deferred.make<void>()
  107. const target = path.join(directory, "shared.txt")
  108. const first = yield* Effect.gen(function* () {
  109. const files = yield* FileMutation.Service
  110. yield* files.withLock([target])(
  111. Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
  112. )
  113. }).pipe(provide(directory), Effect.forkChild)
  114. yield* Deferred.await(firstStarted)
  115. const second = yield* Effect.gen(function* () {
  116. const files = yield* FileMutation.Service
  117. yield* files.withLock([target])(Deferred.succeed(secondStarted, undefined))
  118. }).pipe(provide(directory), Effect.forkChild)
  119. yield* Effect.yieldNow
  120. expect(yield* Deferred.isDone(secondStarted)).toBe(false)
  121. yield* Deferred.succeed(releaseFirst, undefined)
  122. yield* Deferred.await(secondStarted)
  123. yield* Fiber.join(first)
  124. yield* Fiber.join(second)
  125. }),
  126. ),
  127. )
  128. it.live("allows transaction locks for distinct resolved paths to proceed independently", () =>
  129. withTmp((directory) =>
  130. Effect.gen(function* () {
  131. const firstStarted = yield* Deferred.make<void>()
  132. const releaseFirst = yield* Deferred.make<void>()
  133. const secondFinished = yield* Deferred.make<void>()
  134. const files = yield* FileMutation.Service
  135. const first = yield* files
  136. .withLock([path.join(directory, "first.txt")])(
  137. Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
  138. )
  139. .pipe(Effect.forkChild)
  140. yield* Deferred.await(firstStarted)
  141. yield* files.withLock([path.join(directory, "second.txt")])(Deferred.succeed(secondFinished, undefined))
  142. expect(yield* Deferred.isDone(secondFinished)).toBe(true)
  143. yield* Deferred.succeed(releaseFirst, undefined)
  144. yield* Fiber.join(first)
  145. }).pipe(provide(directory)),
  146. ),
  147. )
  148. })