watcher.test.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. import { $ } from "bun"
  2. import { afterEach, describe, expect, test } from "bun:test"
  3. import fs from "fs/promises"
  4. import path from "path"
  5. import { Deferred, Effect, Option } from "effect"
  6. import { tmpdir } from "../fixture/fixture"
  7. import { watcherConfigLayer, withServices } from "../fixture/instance"
  8. import { FileWatcher } from "../../src/file/watcher"
  9. import { Instance } from "../../src/project/instance"
  10. import { GlobalBus } from "../../src/bus/global"
  11. // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
  12. const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
  13. // ---------------------------------------------------------------------------
  14. // Helpers
  15. // ---------------------------------------------------------------------------
  16. type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
  17. type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
  18. /** Run `body` with a live FileWatcher service. */
  19. function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
  20. return withServices(
  21. directory,
  22. FileWatcher.layer,
  23. async (rt) => {
  24. await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
  25. await Effect.runPromise(ready(directory))
  26. await Effect.runPromise(body)
  27. },
  28. { provide: [watcherConfigLayer] },
  29. )
  30. }
  31. function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
  32. let done = false
  33. function on(evt: BusUpdate) {
  34. if (done) return
  35. if (evt.directory !== directory) return
  36. if (evt.payload.type !== FileWatcher.Event.Updated.type) return
  37. if (!check(evt.payload.properties)) return
  38. hit(evt.payload.properties)
  39. }
  40. function cleanup() {
  41. if (done) return
  42. done = true
  43. GlobalBus.off("event", on)
  44. }
  45. GlobalBus.on("event", on)
  46. return cleanup
  47. }
  48. function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
  49. return Effect.gen(function* () {
  50. const deferred = yield* Deferred.make<WatcherEvent>()
  51. const cleanup = yield* Effect.sync(() => {
  52. let off = () => {}
  53. off = listen(directory, check, (evt) => {
  54. off()
  55. Deferred.doneUnsafe(deferred, Effect.succeed(evt))
  56. })
  57. return off
  58. })
  59. return { cleanup, deferred }
  60. })
  61. }
  62. function nextUpdate<E>(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
  63. return Effect.acquireUseRelease(
  64. wait(directory, check),
  65. ({ deferred }) =>
  66. Effect.gen(function* () {
  67. yield* trigger
  68. return yield* Deferred.await(deferred).pipe(Effect.timeout("5 seconds"))
  69. }),
  70. ({ cleanup }) => Effect.sync(cleanup),
  71. )
  72. }
  73. /** Effect that asserts no matching event arrives within `ms`. */
  74. function noUpdate<E>(
  75. directory: string,
  76. check: (evt: WatcherEvent) => boolean,
  77. trigger: Effect.Effect<void, E>,
  78. ms = 500,
  79. ) {
  80. return Effect.acquireUseRelease(
  81. wait(directory, check),
  82. ({ deferred }) =>
  83. Effect.gen(function* () {
  84. yield* trigger
  85. expect(yield* Deferred.await(deferred).pipe(Effect.timeoutOption(`${ms} millis`))).toEqual(Option.none())
  86. }),
  87. ({ cleanup }) => Effect.sync(cleanup),
  88. )
  89. }
  90. function ready(directory: string) {
  91. const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
  92. const head = path.join(directory, ".git", "HEAD")
  93. return Effect.gen(function* () {
  94. yield* nextUpdate(
  95. directory,
  96. (evt) => evt.file === file && evt.event === "add",
  97. Effect.promise(() => fs.writeFile(file, "ready")),
  98. ).pipe(Effect.ensuring(Effect.promise(() => fs.rm(file, { force: true }).catch(() => undefined))), Effect.asVoid)
  99. const git = yield* Effect.promise(() =>
  100. fs
  101. .stat(head)
  102. .then(() => true)
  103. .catch(() => false),
  104. )
  105. if (!git) return
  106. const branch = `watch-${Math.random().toString(36).slice(2)}`
  107. const hash = yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(directory).quiet().text())
  108. yield* nextUpdate(
  109. directory,
  110. (evt) => evt.file === head && evt.event !== "unlink",
  111. Effect.promise(async () => {
  112. await fs.writeFile(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
  113. await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
  114. }),
  115. ).pipe(Effect.asVoid)
  116. })
  117. }
  118. // ---------------------------------------------------------------------------
  119. // Tests
  120. // ---------------------------------------------------------------------------
  121. describeWatcher("FileWatcher", () => {
  122. afterEach(async () => {
  123. await Instance.disposeAll()
  124. })
  125. test("publishes root create, update, and delete events", async () => {
  126. await using tmp = await tmpdir({ git: true })
  127. const file = path.join(tmp.path, "watch.txt")
  128. const dir = tmp.path
  129. const cases = [
  130. { event: "add" as const, trigger: Effect.promise(() => fs.writeFile(file, "a")) },
  131. { event: "change" as const, trigger: Effect.promise(() => fs.writeFile(file, "b")) },
  132. { event: "unlink" as const, trigger: Effect.promise(() => fs.unlink(file)) },
  133. ]
  134. await withWatcher(
  135. dir,
  136. Effect.forEach(cases, ({ event, trigger }) =>
  137. nextUpdate(dir, (evt) => evt.file === file && evt.event === event, trigger).pipe(
  138. Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))),
  139. ),
  140. ),
  141. )
  142. })
  143. test("watches non-git roots", async () => {
  144. await using tmp = await tmpdir()
  145. const file = path.join(tmp.path, "plain.txt")
  146. const dir = tmp.path
  147. await withWatcher(
  148. dir,
  149. nextUpdate(
  150. dir,
  151. (e) => e.file === file && e.event === "add",
  152. Effect.promise(() => fs.writeFile(file, "plain")),
  153. ).pipe(Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" })))),
  154. )
  155. })
  156. test("cleanup stops publishing events", async () => {
  157. await using tmp = await tmpdir({ git: true })
  158. const file = path.join(tmp.path, "after-dispose.txt")
  159. // Start and immediately stop the watcher (withWatcher disposes on exit)
  160. await withWatcher(tmp.path, Effect.void)
  161. // Now write a file — no watcher should be listening
  162. await Effect.runPromise(
  163. noUpdate(
  164. tmp.path,
  165. (e) => e.file === file,
  166. Effect.promise(() => fs.writeFile(file, "gone")),
  167. ),
  168. )
  169. })
  170. test("ignores .git/index changes", async () => {
  171. await using tmp = await tmpdir({ git: true })
  172. const gitIndex = path.join(tmp.path, ".git", "index")
  173. const edit = path.join(tmp.path, "tracked.txt")
  174. await withWatcher(
  175. tmp.path,
  176. noUpdate(
  177. tmp.path,
  178. (e) => e.file === gitIndex,
  179. Effect.promise(async () => {
  180. await fs.writeFile(edit, "a")
  181. await $`git add .`.cwd(tmp.path).quiet().nothrow()
  182. }),
  183. ),
  184. )
  185. })
  186. test("publishes .git/HEAD events", async () => {
  187. await using tmp = await tmpdir({ git: true })
  188. const head = path.join(tmp.path, ".git", "HEAD")
  189. const branch = `watch-${Math.random().toString(36).slice(2)}`
  190. await $`git branch ${branch}`.cwd(tmp.path).quiet()
  191. await withWatcher(
  192. tmp.path,
  193. nextUpdate(
  194. tmp.path,
  195. (evt) => evt.file === head && evt.event !== "unlink",
  196. Effect.promise(() => fs.writeFile(head, `ref: refs/heads/${branch}\n`)),
  197. ).pipe(
  198. Effect.tap((evt) =>
  199. Effect.sync(() => {
  200. expect(evt.file).toBe(head)
  201. expect(["add", "change"]).toContain(evt.event)
  202. }),
  203. ),
  204. ),
  205. )
  206. })
  207. })