watcher.test.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import { $ } from "bun"
  2. import { describe, expect } from "bun:test"
  3. import fs from "fs/promises"
  4. import path from "path"
  5. import { ConfigProvider, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
  6. import { Config } from "@opencode-ai/core/config"
  7. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  8. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  9. import { EventV2 } from "@opencode-ai/core/event"
  10. import { FSUtil } from "@opencode-ai/core/fs-util"
  11. import { Watcher } from "@opencode-ai/core/filesystem/watcher"
  12. import { Location } from "@opencode-ai/core/location"
  13. import { AbsolutePath } from "@opencode-ai/core/schema"
  14. import { location } from "../fixture/location"
  15. import { tmpdir } from "../fixture/tmpdir"
  16. import { testEffect } from "../lib/effect"
  17. const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
  18. type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
  19. const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))
  20. const configLayer = Layer.succeed(
  21. Config.Service,
  22. Config.Service.of({
  23. entries: () => Effect.succeed([]),
  24. }),
  25. )
  26. const flagsLayer = ConfigProvider.layer(
  27. ConfigProvider.fromUnknown({
  28. OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
  29. OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
  30. }),
  31. )
  32. function provide(directory: string, vcs?: Location.Interface["vcs"]) {
  33. const locationLayer = Layer.succeed(
  34. Location.Service,
  35. Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
  36. )
  37. return Effect.provide(
  38. AppNodeBuilder.build(Watcher.node, [
  39. [Config.node, configLayer],
  40. [Location.node, locationLayer],
  41. ]).pipe(Layer.provide(flagsLayer)),
  42. )
  43. }
  44. function withTmp<A, E, R>(
  45. f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
  46. options?: { git?: boolean; init?: (directory: string) => Promise<void> },
  47. ) {
  48. return Effect.acquireRelease(
  49. Effect.promise(async () => {
  50. const tmp = await tmpdir()
  51. if (!options?.git) return { tmp, vcs: undefined }
  52. await $`git init`.cwd(tmp.path).quiet()
  53. await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
  54. await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
  55. await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
  56. await $`git config user.name Test`.cwd(tmp.path).quiet()
  57. await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet()
  58. await options.init?.(tmp.path)
  59. return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
  60. }),
  61. ({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  62. ).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs))))
  63. }
  64. function wait(check: (event: WatcherEvent) => boolean) {
  65. return Effect.gen(function* () {
  66. const events = yield* EventV2.Service
  67. const deferred = yield* Deferred.make<WatcherEvent>()
  68. const fiber = yield* events.subscribe(Watcher.Event.Updated).pipe(
  69. Stream.runForEach((event) => {
  70. if (!check(event.data)) return Effect.void
  71. return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
  72. }),
  73. Effect.forkScoped,
  74. )
  75. yield* Effect.yieldNow
  76. return { deferred, fiber }
  77. })
  78. }
  79. function maybeNextUpdate<E>(
  80. check: (event: WatcherEvent) => boolean,
  81. trigger: Effect.Effect<void, E>,
  82. timeout: Duration.Input = "5 seconds",
  83. ) {
  84. return Effect.acquireUseRelease(
  85. wait(check),
  86. ({ deferred }) => trigger.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeoutOption(timeout)),
  87. ({ fiber }) => Fiber.interrupt(fiber),
  88. )
  89. }
  90. function nextUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
  91. return Effect.gen(function* () {
  92. const result = yield* maybeNextUpdate(check, trigger)
  93. if (Option.isSome(result)) return result.value
  94. return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
  95. })
  96. }
  97. function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: () => Effect.Effect<void, E>) {
  98. return Effect.gen(function* () {
  99. while (true) {
  100. const result = yield* maybeNextUpdate(check, trigger(), "250 millis")
  101. if (Option.isSome(result)) return result.value
  102. }
  103. }).pipe(
  104. Effect.timeoutOrElse({
  105. duration: "5 seconds",
  106. orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
  107. }),
  108. )
  109. }
  110. function noUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>, timeout = 500) {
  111. return Effect.acquireUseRelease(
  112. wait(check),
  113. ({ deferred }) =>
  114. trigger.pipe(
  115. Effect.andThen(Deferred.await(deferred)),
  116. Effect.timeoutOption(`${timeout} millis`),
  117. Effect.tap((result) => Effect.sync(() => expect(result).toEqual(Option.none()))),
  118. ),
  119. ({ fiber }) => Fiber.interrupt(fiber),
  120. )
  121. }
  122. function ready(directory: string) {
  123. const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
  124. return Effect.gen(function* () {
  125. const fs = yield* FSUtil.Service
  126. yield* eventuallyUpdate(
  127. (event) => event.file === file,
  128. () => fs.writeFileString(file, `ready-${Math.random()}`),
  129. ).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
  130. })
  131. }
  132. describeWatcher("Watcher", () => {
  133. it.live("publishes root create, update, and delete events", () =>
  134. withTmp(
  135. (directory) =>
  136. Effect.gen(function* () {
  137. const fs = yield* FSUtil.Service
  138. const file = path.join(directory, "watch.txt")
  139. yield* ready(directory)
  140. for (const item of [
  141. { event: "add" as const, trigger: fs.writeFileString(file, "a") },
  142. { event: "change" as const, trigger: fs.writeFileString(file, "b") },
  143. { event: "unlink" as const, trigger: fs.remove(file) },
  144. ]) {
  145. expect(
  146. yield* nextUpdate((event) => event.file === file && event.event === item.event, item.trigger),
  147. ).toEqual({
  148. file,
  149. event: item.event,
  150. })
  151. }
  152. }),
  153. { git: true },
  154. ),
  155. )
  156. it.live("skips non-git roots", () =>
  157. withTmp((directory) =>
  158. Effect.gen(function* () {
  159. const fs = yield* FSUtil.Service
  160. const file = path.join(directory, "plain.txt")
  161. yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))
  162. }),
  163. ),
  164. )
  165. it.live("cleanup stops publishing events", () =>
  166. Effect.gen(function* () {
  167. const events = yield* EventV2.Service
  168. const fs = yield* FSUtil.Service
  169. const tmp = yield* Effect.acquireRelease(
  170. Effect.promise(() => tmpdir()),
  171. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  172. )
  173. yield* ready(tmp.path).pipe(
  174. provide(tmp.path, { type: "git", store: AbsolutePath.make(path.join(tmp.path, ".git")) }),
  175. Effect.scoped,
  176. )
  177. const file = path.join(tmp.path, "after-dispose.txt")
  178. yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
  179. Effect.provideService(EventV2.Service, events),
  180. )
  181. }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))),
  182. )
  183. it.live("ignores .git/index changes", () =>
  184. withTmp(
  185. (directory) =>
  186. Effect.gen(function* () {
  187. const fs = yield* FSUtil.Service
  188. const index = path.join(directory, ".git", "index")
  189. yield* ready(directory)
  190. yield* noUpdate(
  191. (event) => event.file === index,
  192. fs
  193. .writeFileString(path.join(directory, "tracked.txt"), "a")
  194. .pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
  195. )
  196. }),
  197. { git: true },
  198. ),
  199. )
  200. it.live("publishes .git/HEAD events", () =>
  201. withTmp(
  202. (directory) =>
  203. Effect.gen(function* () {
  204. const fs = yield* FSUtil.Service
  205. const head = path.join(directory, ".git", "HEAD")
  206. const branch = `watch-${Math.random().toString(36).slice(2)}`
  207. yield* ready(directory)
  208. yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
  209. expect(
  210. yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
  211. ).toMatchObject({ file: head })
  212. }),
  213. { git: true },
  214. ),
  215. )
  216. const describeSymlink = process.platform !== "win32" ? describe : describe.skip
  217. describeSymlink("symlinked .git", () => {
  218. it.live("publishes .git/HEAD events through a symlinked .git directory", () =>
  219. withTmp(
  220. (directory) =>
  221. Effect.gen(function* () {
  222. const afs = yield* FSUtil.Service
  223. const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
  224. yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
  225. yield* ready(directory)
  226. const head = path.join(directory, ".git", "HEAD")
  227. const branch = `watch-${Math.random().toString(36).slice(2)}`
  228. yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
  229. expect(
  230. yield* nextUpdate(
  231. (event) => event.file === path.join(actual, "HEAD"),
  232. afs.writeFileString(head, `ref: refs/heads/${branch}\n`),
  233. ),
  234. ).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
  235. }),
  236. {
  237. git: true,
  238. init: async (directory) => {
  239. const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
  240. await fs.rename(path.join(directory, ".git"), actual)
  241. await fs.symlink(actual, path.join(directory, ".git"))
  242. },
  243. },
  244. ),
  245. )
  246. })
  247. })