Sfoglia il codice sorgente

test(core): add native watcher command reload test (#39216)

Kit Langton 2 settimane fa
parent
commit
775f24f049

+ 98 - 1
packages/core/test/config/command.test.ts

@@ -1,7 +1,7 @@
 import fs from "fs/promises"
 import path from "path"
 import { describe, expect } from "bun:test"
-import { Effect, Fiber, PubSub, Schema, Stream } from "effect"
+import { Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
 import { advance, drain } from "../lib/clock"
 import { Config as ConfigSchema } from "@opencode-ai/schema/config"
 import { Command } from "@opencode-ai/core/command"
@@ -12,12 +12,18 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { LayerNode } from "@opencode-ai/util/effect/layer-node"
 import { FSUtil } from "@opencode-ai/util/fs-util"
 import { Bus } from "@opencode-ai/core/bus"
+import { Credential } from "@opencode-ai/core/credential"
+import { WellKnown } from "@opencode-ai/core/wellknown"
+import { Global } from "@opencode-ai/util/global"
 import { Location } from "@opencode-ai/core/location"
 import { MCP } from "@opencode-ai/core/mcp/index"
 import { Model } from "@opencode-ai/core/model"
 import { Provider } from "@opencode-ai/core/provider"
 import { AbsolutePath } from "@opencode-ai/core/schema"
+import { Watcher } from "@opencode-ai/core/filesystem/watcher"
+import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
 import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp"
+import { location } from "../fixture/location"
 import { tmpdir } from "../fixture/tmpdir"
 import { testEffect } from "../lib/effect"
 import { host } from "../plugin/host"
@@ -235,6 +241,97 @@ Review files`,
   )
 })
 
+const describeNative = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
+
+// End-to-end proof for #37429: a real file edit reaches the command registry
+// through the native watcher, Config's watch topology, the source filter, and
+// the debounced reload — no mocked change feed.
+describeNative("ConfigCommandPlugin native watcher", () => {
+  it.live("reloads commands from real file edits", () =>
+    Effect.gen(function* () {
+      const fs = yield* FSUtil.Service
+      // Watcher events report real paths, so resolve the tempdir symlink up front.
+      const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-core-test-" }).pipe(Effect.flatMap(fs.realPath))
+      const global = path.join(tmp, "global")
+      yield* fs.makeDirectory(path.join(global, "commands"), { recursive: true })
+      yield* fs.makeDirectory(path.join(tmp, "project"))
+      yield* Effect.gen(function* () {
+        const command = yield* Command.Service
+        const config = yield* Config.Service
+        const bus = yield* Bus.Service
+        yield* ConfigCommandPlugin.Plugin.effect(
+          host({
+            command: {
+              list: () => Effect.die("unused command.list"),
+              transform: command.transform,
+              reload: command.reload,
+            },
+          }),
+        )
+        yield* watchReady(config, global)
+
+        const created = yield* nextCommandUpdate(bus)
+        yield* fs.writeFileString(path.join(global, "commands", "review.md"), "Review native")
+        yield* Fiber.join(created).pipe(Effect.timeout("10 seconds"))
+        expect((yield* command.get("review"))?.template).toBe("Review native")
+
+        const updated = yield* nextCommandUpdate(bus)
+        yield* fs.writeFileString(path.join(global, "commands", "review.md"), "Review native again")
+        yield* Fiber.join(updated).pipe(Effect.timeout("10 seconds"))
+        expect((yield* command.get("review"))?.template).toBe("Review native again")
+      }).pipe(
+        Effect.provide(
+          AppNodeBuilder.build(LayerNode.group([Command.node, Config.node, Bus.node, FSUtil.node]), [
+            [
+              Location.node,
+              Layer.succeed(
+                Location.Service,
+                Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
+              ),
+            ],
+            [Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
+            [Credential.node, emptyCredentialNode],
+            [WellKnown.node, emptyWellknownNode],
+          ]),
+        ),
+      )
+    }),
+  )
+})
+
+function nextCommandUpdate(bus: Bus.Interface) {
+  return bus
+    .subscribe(Command.Event.Updated)
+    .pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
+}
+
+// Native directory watches start asynchronously; probe with unrelated files
+// until the change feed delivers so command edits afterwards cannot be missed.
+function watchReady(config: Config.Interface, directory: string) {
+  return Effect.gen(function* () {
+    const fs = yield* FSUtil.Service
+    const seen = yield* Deferred.make<void>()
+    const listener = yield* config.changes().pipe(
+      Stream.runForEach(() => Deferred.succeed(seen, undefined).pipe(Effect.asVoid)),
+      Effect.forkScoped({ startImmediately: true }),
+    )
+    yield* Effect.yieldNow
+    const probe = path.join(directory, ".watch-probe")
+    while (true) {
+      yield* fs.writeFileString(probe, `ready-${Math.random()}`)
+      const result = yield* Deferred.await(seen).pipe(Effect.timeoutOption("250 millis"))
+      if (Option.isSome(result)) break
+    }
+    yield* Fiber.interrupt(listener)
+    yield* fs.remove(probe, { force: true })
+  }).pipe(
+    Effect.timeoutOrElse({
+      duration: "10 seconds",
+      orElse: () => Effect.fail(new Error("timed out waiting for the config watch to become ready")),
+    }),
+  )
+}
+
 function directoryEntry(directory: string) {
   return new Config.Directory({ type: "directory", path: AbsolutePath.make(directory) })
 }

+ 1 - 32
packages/core/test/config/config.test.ts

@@ -23,6 +23,7 @@ import { Provider } from "@opencode-ai/core/provider"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { WellKnown } from "@opencode-ai/core/wellknown"
 import { Integration } from "@opencode-ai/schema/integration"
+import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
 import { location } from "../fixture/location"
 import { tmpdir } from "../fixture/tmpdir"
 import { testEffect } from "../lib/effect"
@@ -30,38 +31,6 @@ import { testEffect } from "../lib/effect"
 const it = testEffect(Layer.empty)
 const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
 
-const emptyCredentialNode = makeGlobalNode({
-  service: Credential.Service,
-  layer: Layer.succeed(
-    Credential.Service,
-    Credential.Service.of({
-      all: () => Effect.succeed([]),
-      list: () => Effect.succeed([]),
-      get: () => Effect.succeed(undefined),
-      create: () => Effect.die("unused Credential.create"),
-      update: () => Effect.die("unused Credential.update"),
-      remove: () => Effect.die("unused Credential.remove"),
-    }),
-  ),
-  deps: [],
-})
-
-const emptyWellknownNode = makeGlobalNode({
-  service: WellKnown.Service,
-  layer: Layer.succeed(
-    WellKnown.Service,
-    WellKnown.Service.of({
-      entries: () => Effect.succeed([]),
-      snapshot: () => [],
-      refresh: () => Effect.succeed(false),
-      add: () => Effect.die("unused Wellknown.add"),
-      remove: () => Effect.die("unused Wellknown.remove"),
-      resolve: () => Effect.die("unused Wellknown.resolve"),
-    }),
-  ),
-  deps: [],
-})
-
 function testLayer(
   directory: string,
   globalDirectory = path.join(directory, "global"),

+ 36 - 0
packages/core/test/fixture/config-nodes.ts

@@ -0,0 +1,36 @@
+import { Effect, Layer } from "effect"
+import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
+import { Credential } from "@opencode-ai/core/credential"
+import { WellKnown } from "@opencode-ai/core/wellknown"
+
+export const emptyCredentialNode = makeGlobalNode({
+  service: Credential.Service,
+  layer: Layer.succeed(
+    Credential.Service,
+    Credential.Service.of({
+      all: () => Effect.succeed([]),
+      list: () => Effect.succeed([]),
+      get: () => Effect.succeed(undefined),
+      create: () => Effect.die("unused Credential.create"),
+      update: () => Effect.die("unused Credential.update"),
+      remove: () => Effect.die("unused Credential.remove"),
+    }),
+  ),
+  deps: [],
+})
+
+export const emptyWellknownNode = makeGlobalNode({
+  service: WellKnown.Service,
+  layer: Layer.succeed(
+    WellKnown.Service,
+    WellKnown.Service.of({
+      entries: () => Effect.succeed([]),
+      snapshot: () => [],
+      refresh: () => Effect.succeed(false),
+      add: () => Effect.die("unused Wellknown.add"),
+      remove: () => Effect.die("unused Wellknown.remove"),
+      resolve: () => Effect.die("unused Wellknown.resolve"),
+    }),
+  ),
+  deps: [],
+})