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

This commit is contained in:
Kit Langton 2026-07-27 21:08:10 -04:00 committed by GitHub
parent 470e360942
commit 775f24f049
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 135 additions and 33 deletions

View file

@ -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) })
}

View file

@ -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"),

View file

@ -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: [],
})