Browse Source

test: migrate file watcher test to Effect (#27346)

Kit Langton 3 months ago
parent
commit
02b8b0ff93
1 changed files with 148 additions and 141 deletions
  1. 148 141
      packages/opencode/test/file/watcher.test.ts

+ 148 - 141
packages/opencode/test/file/watcher.test.ts

@@ -1,15 +1,13 @@
-import { $ } from "bun"
-import { afterEach, describe, expect, test } from "bun:test"
-import fs from "fs/promises"
+import { describe, expect } from "bun:test"
 import path from "path"
-import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect"
-import { disposeAllInstances, tmpdir } from "../fixture/fixture"
-import { Bus } from "../../src/bus"
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { ConfigProvider, Deferred, Effect, Layer, Option } from "effect"
+import { TestInstance, provideInstance } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
 import { Config } from "@/config/config"
 import { FileWatcher } from "../../src/file/watcher"
 import { Git } from "../../src/git"
-import { Instance } from "../../src/project/instance"
-import { WithInstance } from "../../src/project/with-instance"
 
 // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
 const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@@ -25,43 +23,43 @@ const watcherConfigLayer = ConfigProvider.layer(
   }),
 )
 
+const watcherLayer = FileWatcher.layer.pipe(
+  Layer.provide(Config.defaultLayer),
+  Layer.provide(Git.defaultLayer),
+  Layer.provide(watcherConfigLayer),
+)
+
+const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, Git.defaultLayer))
+
 type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
 
 /** Run `body` with a live FileWatcher service. */
-function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
-  return WithInstance.provide({
-    directory,
-    fn: async () => {
-      const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe(
-        Layer.provide(Config.defaultLayer),
-        Layer.provide(Git.defaultLayer),
-        Layer.provide(watcherConfigLayer),
-      )
-      const rt = ManagedRuntime.make(layer)
-      try {
-        await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
-        await Effect.runPromise(ready(directory))
-        await Effect.runPromise(body)
-      } finally {
-        await rt.dispose()
-      }
-    },
-  })
+function withWatcher<A, E, R>(directory: string, body: Effect.Effect<A, E, R>) {
+  return Effect.gen(function* () {
+    const watcher = yield* FileWatcher.Service
+    yield* watcher.init()
+    yield* ready(directory)
+    return yield* body
+  }).pipe(Effect.provide(watcherLayer), provideInstance(directory), Effect.scoped)
 }
 
 function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
   let done = false
 
-  const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
+  const on = (evt: GlobalEvent) => {
     if (done) return
-    if (!check(evt.properties)) return
-    hit(evt.properties)
-  })
+    if (evt.directory !== directory) return
+    if (evt.payload.type !== FileWatcher.Event.Updated.type) return
+    if (!check(evt.payload.properties)) return
+    hit(evt.payload.properties)
+  }
+
+  GlobalBus.on("event", on)
 
   return () => {
     if (done) return
     done = true
-    unsub()
+    GlobalBus.off("event", on)
   }
 }
 
@@ -72,7 +70,7 @@ function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
       let off = () => {}
       off = listen(directory, check, (evt) => {
         off()
-        Deferred.doneUnsafe(deferred, Effect.succeed(evt))
+        Effect.runFork(Deferred.succeed(deferred, evt))
       })
       return off
     })
@@ -86,7 +84,12 @@ function nextUpdate<E>(directory: string, check: (evt: WatcherEvent) => boolean,
     ({ deferred }) =>
       Effect.gen(function* () {
         yield* trigger
-        return yield* Deferred.await(deferred).pipe(Effect.timeout("5 seconds"))
+        return yield* Deferred.await(deferred).pipe(
+          Effect.timeoutOrElse({
+            duration: "5 seconds",
+            orElse: () => Effect.fail(new Error("timed out waiting for file watcher update")),
+          }),
+        )
       }),
     ({ cleanup }) => Effect.sync(cleanup),
   )
@@ -104,7 +107,11 @@ function noUpdate<E>(
     ({ deferred }) =>
       Effect.gen(function* () {
         yield* trigger
-        expect(yield* Deferred.await(deferred).pipe(Effect.timeoutOption(`${ms} millis`))).toEqual(Option.none())
+        const result = yield* Deferred.await(deferred).pipe(
+          Effect.map((evt) => Option.some(evt)),
+          Effect.timeoutOrElse({ duration: `${ms} millis`, orElse: () => Effect.succeed(Option.none()) }),
+        )
+        expect(result).toEqual(Option.none())
       }),
     ({ cleanup }) => Effect.sync(cleanup),
   )
@@ -115,29 +122,25 @@ function ready(directory: string) {
   const head = path.join(directory, ".git", "HEAD")
 
   return Effect.gen(function* () {
+    const fs = yield* AppFileSystem.Service
+    const git = yield* Git.Service
+
     yield* nextUpdate(
       directory,
       (evt) => evt.file === file && evt.event === "add",
-      Effect.promise(() => fs.writeFile(file, "ready")),
-    ).pipe(Effect.ensuring(Effect.promise(() => fs.rm(file, { force: true }).catch(() => undefined))), Effect.asVoid)
+      fs.writeFileString(file, "ready"),
+    ).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
 
-    const git = yield* Effect.promise(() =>
-      fs
-        .stat(head)
-        .then(() => true)
-        .catch(() => false),
-    )
-    if (!git) return
+    if (!(yield* fs.existsSafe(head))) return
 
     const branch = `watch-${Math.random().toString(36).slice(2)}`
-    const hash = yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(directory).quiet().text())
+    const hash = (yield* git.run(["rev-parse", "HEAD"], { cwd: directory })).text()
     yield* nextUpdate(
       directory,
       (evt) => evt.file === head && evt.event !== "unlink",
-      Effect.promise(async () => {
-        await fs.writeFile(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
-        await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
-      }),
+      fs
+        .writeFileString(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
+        .pipe(Effect.andThen(fs.writeFileString(head, `ref: refs/heads/${branch}\n`))),
     ).pipe(Effect.asVoid)
   })
 }
@@ -147,104 +150,108 @@ function ready(directory: string) {
 // ---------------------------------------------------------------------------
 
 describeWatcher("FileWatcher", () => {
-  afterEach(async () => {
-    await disposeAllInstances()
-  })
+  it.instance("publishes root create, update, and delete events", () =>
+    Effect.gen(function* () {
+      const test = yield* TestInstance
+      const fs = yield* AppFileSystem.Service
+      const file = path.join(test.directory, "watch.txt")
+      const cases = [
+        { event: "add" as const, trigger: fs.writeFileString(file, "a") },
+        { event: "change" as const, trigger: fs.writeFileString(file, "b") },
+        { event: "unlink" as const, trigger: fs.remove(file) },
+      ]
 
-  test("publishes root create, update, and delete events", async () => {
-    await using tmp = await tmpdir({ git: true })
-    const file = path.join(tmp.path, "watch.txt")
-    const dir = tmp.path
-    const cases = [
-      { event: "add" as const, trigger: Effect.promise(() => fs.writeFile(file, "a")) },
-      { event: "change" as const, trigger: Effect.promise(() => fs.writeFile(file, "b")) },
-      { event: "unlink" as const, trigger: Effect.promise(() => fs.unlink(file)) },
-    ]
-
-    await withWatcher(
-      dir,
-      Effect.forEach(cases, ({ event, trigger }) =>
-        nextUpdate(dir, (evt) => evt.file === file && evt.event === event, trigger).pipe(
-          Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))),
+      yield* withWatcher(
+        test.directory,
+        Effect.forEach(cases, ({ event, trigger }) =>
+          nextUpdate(test.directory, (evt) => evt.file === file && evt.event === event, trigger).pipe(
+            Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))),
+          ),
         ),
-      ),
-    )
-  })
+      )
+    }),
+    { git: true },
+  )
 
-  test("watches non-git roots", async () => {
-    await using tmp = await tmpdir()
-    const file = path.join(tmp.path, "plain.txt")
-    const dir = tmp.path
-
-    await withWatcher(
-      dir,
-      nextUpdate(
-        dir,
-        (e) => e.file === file && e.event === "add",
-        Effect.promise(() => fs.writeFile(file, "plain")),
-      ).pipe(Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" })))),
-    )
-  })
+  it.instance("watches non-git roots", () =>
+    Effect.gen(function* () {
+      const test = yield* TestInstance
+      const fs = yield* AppFileSystem.Service
+      const file = path.join(test.directory, "plain.txt")
 
-  test("cleanup stops publishing events", async () => {
-    await using tmp = await tmpdir({ git: true })
-    const file = path.join(tmp.path, "after-dispose.txt")
-
-    // Start and immediately stop the watcher (withWatcher disposes on exit)
-    await withWatcher(tmp.path, Effect.void)
-
-    // Now write a file — no watcher should be listening
-    await WithInstance.provide({
-      directory: tmp.path,
-      fn: () =>
-        Effect.runPromise(
-          noUpdate(
-            tmp.path,
-            (e) => e.file === file,
-            Effect.promise(() => fs.writeFile(file, "gone")),
-          ),
+      yield* withWatcher(
+        test.directory,
+        nextUpdate(
+          test.directory,
+          (e) => e.file === file && e.event === "add",
+          fs.writeFileString(file, "plain"),
+        ).pipe(Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" })))),
+      )
+    }),
+  )
+
+  it.instance("cleanup stops publishing events", () =>
+    Effect.gen(function* () {
+      const test = yield* TestInstance
+      const fs = yield* AppFileSystem.Service
+      const file = path.join(test.directory, "after-dispose.txt")
+
+      // Start and immediately stop the watcher (withWatcher disposes on exit).
+      yield* withWatcher(test.directory, Effect.void)
+
+      // Now write a file - no watcher should be listening.
+      yield* noUpdate(test.directory, (e) => e.file === file, fs.writeFileString(file, "gone")).pipe(
+        provideInstance(test.directory),
+      )
+    }),
+    { git: true },
+  )
+
+  it.instance("ignores .git/index changes", () =>
+    Effect.gen(function* () {
+      const test = yield* TestInstance
+      const fs = yield* AppFileSystem.Service
+      const git = yield* Git.Service
+      const gitIndex = path.join(test.directory, ".git", "index")
+      const edit = path.join(test.directory, "tracked.txt")
+
+      yield* withWatcher(
+        test.directory,
+        noUpdate(
+          test.directory,
+          (e) => e.file === gitIndex,
+          fs.writeFileString(edit, "a").pipe(Effect.andThen(git.run(["add", "."], { cwd: test.directory }))),
         ),
-    })
-  })
+      )
+    }),
+    { git: true },
+  )
 
-  test("ignores .git/index changes", async () => {
-    await using tmp = await tmpdir({ git: true })
-    const gitIndex = path.join(tmp.path, ".git", "index")
-    const edit = path.join(tmp.path, "tracked.txt")
-
-    await withWatcher(
-      tmp.path,
-      noUpdate(
-        tmp.path,
-        (e) => e.file === gitIndex,
-        Effect.promise(async () => {
-          await fs.writeFile(edit, "a")
-          await $`git add .`.cwd(tmp.path).quiet().nothrow()
-        }),
-      ),
-    )
-  })
+  it.instance("publishes .git/HEAD events", () =>
+    Effect.gen(function* () {
+      const test = yield* TestInstance
+      const fs = yield* AppFileSystem.Service
+      const git = yield* Git.Service
+      const head = path.join(test.directory, ".git", "HEAD")
+      const branch = `watch-${Math.random().toString(36).slice(2)}`
+      yield* git.run(["branch", branch], { cwd: test.directory })
 
-  test("publishes .git/HEAD events", async () => {
-    await using tmp = await tmpdir({ git: true })
-    const head = path.join(tmp.path, ".git", "HEAD")
-    const branch = `watch-${Math.random().toString(36).slice(2)}`
-    await $`git branch ${branch}`.cwd(tmp.path).quiet()
-
-    await withWatcher(
-      tmp.path,
-      nextUpdate(
-        tmp.path,
-        (evt) => evt.file === head && evt.event !== "unlink",
-        Effect.promise(() => fs.writeFile(head, `ref: refs/heads/${branch}\n`)),
-      ).pipe(
-        Effect.tap((evt) =>
-          Effect.sync(() => {
-            expect(evt.file).toBe(head)
-            expect(["add", "change"]).toContain(evt.event)
-          }),
+      yield* withWatcher(
+        test.directory,
+        nextUpdate(
+          test.directory,
+          (evt) => evt.file === head && evt.event !== "unlink",
+          fs.writeFileString(head, `ref: refs/heads/${branch}\n`),
+        ).pipe(
+          Effect.tap((evt) =>
+            Effect.sync(() => {
+              expect(evt.file).toBe(head)
+              expect(["add", "change"]).toContain(evt.event)
+            }),
+          ),
         ),
-      ),
-    )
-  })
+      )
+    }),
+    { git: true },
+  )
 })