mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 15:43:57 +00:00
test(core): add native watcher command reload test (#39216)
This commit is contained in:
parent
470e360942
commit
775f24f049
3 changed files with 135 additions and 33 deletions
|
|
@ -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) })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
packages/core/test/fixture/config-nodes.ts
Normal file
36
packages/core/test/fixture/config-nodes.ts
Normal 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: [],
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue