From 775f24f049a40d68a29abb9091f8296cf28c8b85 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Jul 2026 21:08:10 -0400 Subject: [PATCH] test(core): add native watcher command reload test (#39216) --- packages/core/test/config/command.test.ts | 99 +++++++++++++++++++++- packages/core/test/config/config.test.ts | 33 +------- packages/core/test/fixture/config-nodes.ts | 36 ++++++++ 3 files changed, 135 insertions(+), 33 deletions(-) create mode 100644 packages/core/test/fixture/config-nodes.ts diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index c908cfa3a7c..6aba385a4b3 100644 --- a/packages/core/test/config/command.test.ts +++ b/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() + 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) }) } diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index bdb9bde7d0b..a35e746b42c 100644 --- a/packages/core/test/config/config.test.ts +++ b/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"), diff --git a/packages/core/test/fixture/config-nodes.ts b/packages/core/test/fixture/config-nodes.ts new file mode 100644 index 00000000000..93a2e4e8c6e --- /dev/null +++ b/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: [], +})