watcher.test.ts 12 KB

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