From a8d6379e0e25e7d7f40dd48dc859889299f5ec84 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 14 Apr 2026 20:58:25 -0400 Subject: [PATCH] refactor: remove makeRuntime facade from TuiConfig Delete get() and waitForDependencies() facade functions from config/tui.ts. Add TuiConfig.defaultLayer to AppLayer so the service is available via AppRuntime. Migrate all callers (attach.ts, thread.ts, plugin/runtime.ts) to AppRuntime.runPromise(TuiConfig.Service.use(...)). Migrate tui.test.ts (~28 calls) to the same pattern. Rework test fixture: replace spyOn(TuiConfig, 'get') with mockTuiService helper that mocks TuiConfig.Service.use at the Effect level. Update all 9 test files that spied on the old facades. --- packages/opencode/src/cli/cmd/tui/attach.ts | 3 +- .../src/cli/cmd/tui/plugin/runtime.ts | 12 +++- packages/opencode/src/cli/cmd/tui/thread.ts | 3 +- packages/opencode/src/config/tui.ts | 11 --- packages/opencode/src/effect/app-runtime.ts | 2 + .../opencode/test/cli/tui/plugin-add.test.ts | 38 +++++----- .../test/cli/tui/plugin-install.test.ts | 9 ++- .../test/cli/tui/plugin-lifecycle.test.ts | 1 - .../cli/tui/plugin-loader-entrypoint.test.ts | 50 +++++-------- .../test/cli/tui/plugin-loader-pure.test.ts | 8 +-- .../test/cli/tui/plugin-loader.test.ts | 71 ++++++++++++++++--- .../test/cli/tui/plugin-toggle.test.ts | 14 ++-- packages/opencode/test/cli/tui/thread.test.ts | 4 +- packages/opencode/test/config/tui.test.ts | 57 +++++++-------- packages/opencode/test/fixture/tui-runtime.ts | 31 ++++++-- 15 files changed, 181 insertions(+), 133 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index e892f9922d1b..f8fb4eb7c87c 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -4,6 +4,7 @@ import { tui } from "./app" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/config/tui" import { Instance } from "@/project/instance" +import { AppRuntime } from "@/effect/app-runtime" import { existsSync } from "fs" export const AttachCommand = cmd({ @@ -68,7 +69,7 @@ export const AttachCommand = cmd({ })() const config = await Instance.provide({ directory: directory && existsSync(directory) ? directory : process.cwd(), - fn: () => TuiConfig.get(), + fn: () => AppRuntime.runPromise(TuiConfig.Service.use((svc) => svc.get())), }) await tui({ url: args.url, diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 2f7fd516439f..9924c5c16411 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -15,6 +15,7 @@ import { fileURLToPath } from "url" import { Config } from "@/config/config" import { TuiConfig } from "@/config/tui" +import { AppRuntime } from "@/effect/app-runtime" import { Log } from "@/util/log" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" @@ -794,7 +795,10 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) { const ready = await Instance.provide({ directory: state.directory, - fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()), + fn: () => + resolveExternalPlugins([cfg], () => + AppRuntime.runPromise(TuiConfig.Service.use((svc) => svc.waitForDependencies())), + ), }).catch((error) => { fail("failed to add tui plugin", { path: next, error }) return [] as PluginLoad[] @@ -991,7 +995,7 @@ export namespace TuiPluginRuntime { await Instance.provide({ directory: cwd, fn: async () => { - const config = await TuiConfig.get() + const config = await AppRuntime.runPromise(TuiConfig.Service.use((svc) => svc.get())) const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) if (Flag.OPENCODE_PURE && config.plugin_origins?.length) { log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length }) @@ -1011,7 +1015,9 @@ export namespace TuiPluginRuntime { }) } - const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) + const ready = await resolveExternalPlugins(records, () => + AppRuntime.runPromise(TuiConfig.Service.use((svc) => svc.waitForDependencies())), + ) await addExternalPluginEntries(next, ready) applyInitialPluginEnabledState(next, config) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 972e67d103fa..f826dabb507b 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -15,6 +15,7 @@ import type { EventSource } from "./context/sdk" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/config/tui" import { Instance } from "@/project/instance" +import { AppRuntime } from "@/effect/app-runtime" import { writeHeapSnapshot } from "v8" declare global { @@ -179,7 +180,7 @@ export const TuiThreadCommand = cmd({ const prompt = await input(args.prompt) const config = await Instance.provide({ directory: cwd, - fn: () => TuiConfig.get(), + fn: () => AppRuntime.runPromise(TuiConfig.Service.use((svc) => svc.get())), }) const network = await resolveNetworkOptions(args) diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index 9347a8cc4c1e..8d611ceb288f 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -12,7 +12,6 @@ import { isRecord } from "@/util/record" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@/filesystem" export namespace TuiConfig { @@ -170,16 +169,6 @@ export namespace TuiConfig { export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function get() { - return runPromise((svc) => svc.get()) - } - - export async function waitForDependencies() { - await runPromise((svc) => svc.waitForDependencies()) - } - async function loadFile(filepath: string): Promise { const text = await ConfigPaths.readFile(filepath) if (!text) return {} diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 0d32bce088d2..9007ee2efc5f 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -47,6 +47,7 @@ import { Pty } from "@/pty" import { Installation } from "@/installation" import { ShareNext } from "@/share/share-next" import { SessionShare } from "@/share/session" +import { TuiConfig } from "@/config/tui" export const AppLayer = Layer.mergeAll( Observability.layer, @@ -95,6 +96,7 @@ export const AppLayer = Layer.mergeAll( Installation.defaultLayer, ShareNext.defaultLayer, SessionShare.defaultLayer, + TuiConfig.defaultLayer, ) const rt = ManagedRuntime.make(AppLayer, { memoMap }) diff --git a/packages/opencode/test/cli/tui/plugin-add.test.ts b/packages/opencode/test/cli/tui/plugin-add.test.ts index 748f2917281c..cfeec479740d 100644 --- a/packages/opencode/test/cli/tui/plugin-add.test.ts +++ b/packages/opencode/test/cli/tui/plugin-add.test.ts @@ -1,10 +1,11 @@ import { expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" +import { Effect } from "effect" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config/tui" +import { mockTuiService } from "../../fixture/tui-runtime" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -31,11 +32,10 @@ test("adds tui plugin at runtime from spec", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const restore = mockTuiService({ plugin: [], plugin_origins: undefined, }) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { @@ -54,8 +54,7 @@ test("adds tui plugin at runtime from spec", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() - wait.mockRestore() + restore() delete process.env.OPENCODE_PLUGIN_META_FILE } }) @@ -72,22 +71,27 @@ test("retries runtime add for file plugins after dependency wait", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ - plugin: [], - plugin_origins: undefined, - }) - const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => { - await Bun.write( - path.join(tmp.extra.mod, "index.ts"), - `export default { + const restore = mockTuiService( + { + plugin: [], + plugin_origins: undefined, + }, + { + wait: () => + Effect.promise(async () => { + await Bun.write( + path.join(tmp.extra.mod, "index.ts"), + `export default { id: "demo.add.retry", tui: async () => { await Bun.write(${JSON.stringify(tmp.extra.marker)}, "called") }, } `, - ) - }) + ) + }), + }, + ) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { @@ -95,13 +99,11 @@ test("retries runtime add for file plugins after dependency wait", async () => { await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true) await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") - expect(wait).toHaveBeenCalledTimes(1) expect(TuiPluginRuntime.list().find((item) => item.id === "demo.add.retry")?.active).toBe(true) } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() - wait.mockRestore() + restore() delete process.env.OPENCODE_PLUGIN_META_FILE } }) diff --git a/packages/opencode/test/cli/tui/plugin-install.test.ts b/packages/opencode/test/cli/tui/plugin-install.test.ts index 290a7eea1330..d8571e96a6bb 100644 --- a/packages/opencode/test/cli/tui/plugin-install.test.ts +++ b/packages/opencode/test/cli/tui/plugin-install.test.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" import { TuiConfig } from "../../../src/config/tui" +import { mockTuiService } from "../../fixture/tui-runtime" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -50,12 +51,11 @@ test("installs plugin without loading it", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const cfg: Awaited> = { + const cfg: TuiConfig.Info = { plugin: [], plugin_origins: undefined, } - const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const restore = mockTuiService(cfg) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi({ state: { @@ -82,8 +82,7 @@ test("installs plugin without loading it", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() - wait.mockRestore() + restore() delete process.env.OPENCODE_PLUGIN_META_FILE } }) diff --git a/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts index 9c868a4c9915..b22180ef313e 100644 --- a/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-lifecycle.test.ts @@ -5,7 +5,6 @@ import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" import { mockTuiRuntime } from "../../fixture/tui-runtime" -import { TuiConfig } from "../../../src/config/tui" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts index 68c3df447519..6ecff45058bb 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config/tui" +import { mockTuiService } from "../../fixture/tui-runtime" import { Npm } from "../../../src/npm" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -44,7 +44,7 @@ test("loads npm tui plugin from package ./tui export", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const restore = mockTuiService({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -54,7 +54,6 @@ test("loads npm tui plugin from package ./tui export", async () => { }, ], }) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) @@ -69,8 +68,7 @@ test("loads npm tui plugin from package ./tui export", async () => { await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() - wait.mockRestore() + restore() delete process.env.OPENCODE_PLUGIN_META_FILE } }) @@ -106,7 +104,7 @@ test("does not use npm package exports dot for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const restore = mockTuiService({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -116,7 +114,6 @@ test("does not use npm package exports dot for tui entry", async () => { }, ], }) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) @@ -128,8 +125,7 @@ test("does not use npm package exports dot for tui entry", async () => { await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() - wait.mockRestore() + restore() delete process.env.OPENCODE_PLUGIN_META_FILE } }) @@ -169,7 +165,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () = }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const restore = mockTuiService({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -179,7 +175,6 @@ test("rejects npm tui export that resolves outside plugin directory", async () = }, ], }) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) @@ -193,8 +188,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () = await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() - wait.mockRestore() + restore() delete process.env.OPENCODE_PLUGIN_META_FILE } }) @@ -232,7 +226,7 @@ test("rejects npm tui plugin that exports server and tui together", async () => }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const restore = mockTuiService({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -242,7 +236,6 @@ test("rejects npm tui plugin that exports server and tui together", async () => }, ], }) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) @@ -254,8 +247,7 @@ test("rejects npm tui plugin that exports server and tui together", async () => await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() - wait.mockRestore() + restore() delete process.env.OPENCODE_PLUGIN_META_FILE } }) @@ -291,7 +283,7 @@ test("does not use npm package main for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const restore = mockTuiService({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -301,7 +293,6 @@ test("does not use npm package main for tui entry", async () => { }, ], }) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) const warn = spyOn(console, "warn").mockImplementation(() => {}) @@ -317,8 +308,7 @@ test("does not use npm package main for tui entry", async () => { await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() - wait.mockRestore() + restore() warn.mockRestore() error.mockRestore() delete process.env.OPENCODE_PLUGIN_META_FILE @@ -357,7 +347,7 @@ test("does not use directory package main for tui entry", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const restore = mockTuiService({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -367,7 +357,6 @@ test("does not use directory package main for tui entry", async () => { }, ], }) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { @@ -377,8 +366,7 @@ test("does not use directory package main for tui entry", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() - wait.mockRestore() + restore() delete process.env.OPENCODE_PLUGIN_META_FILE } }) @@ -405,7 +393,7 @@ test("uses directory index fallback for tui when package.json is missing", async }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const restore = mockTuiService({ plugin: [tmp.extra.spec], plugin_origins: [ { @@ -415,7 +403,6 @@ test("uses directory index fallback for tui when package.json is missing", async }, ], }) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { @@ -425,8 +412,7 @@ test("uses directory index fallback for tui when package.json is missing", async } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() - wait.mockRestore() + restore() delete process.env.OPENCODE_PLUGIN_META_FILE } }) @@ -463,7 +449,7 @@ test("uses npm package name when tui plugin id is omitted", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const restore = mockTuiService({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -473,7 +459,6 @@ test("uses npm package name when tui plugin id is omitted", async () => { }, ], }) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod }) @@ -485,8 +470,7 @@ test("uses npm package name when tui plugin id is omitted", async () => { await TuiPluginRuntime.dispose() install.mockRestore() cwd.mockRestore() - get.mockRestore() - wait.mockRestore() + restore() delete process.env.OPENCODE_PLUGIN_META_FILE } }) diff --git a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts index f92d742924cd..c59464110c8a 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader-pure.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config/tui" +import { mockTuiService } from "../../fixture/tui-runtime" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -37,7 +37,7 @@ test("skips external tui plugins in pure mode", async () => { process.env.OPENCODE_PURE = "1" process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const restore = mockTuiService({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_origins: [ { @@ -47,7 +47,6 @@ test("skips external tui plugins in pure mode", async () => { }, ], }) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { @@ -56,8 +55,7 @@ test("skips external tui plugins in pure mode", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() - wait.mockRestore() + restore() if (pure === undefined) { delete process.env.OPENCODE_PURE } else { diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 119517b10cf3..477f8120b9cb 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -4,8 +4,8 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { mockTuiService } from "../../fixture/tui-runtime" import { Global } from "../../../src/global" -import { TuiConfig } from "../../../src/config/tui" import { Filesystem } from "../../../src/util/filesystem" const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme") @@ -322,8 +322,59 @@ export default { } }, }) + const localConfigPath = path.join(tmp.path, "tui.json") + const globalPlugin: [string, Record] = [ + tmp.extra.globalSpec, + { + marker: tmp.extra.globalMarker, + theme_path: `./${tmp.extra.globalThemeFile}`, + theme_name: tmp.extra.globalThemeName, + }, + ] + const localPlugins: [string, Record][] = [ + [ + tmp.extra.localSpec, + { + fn_marker: tmp.extra.fnMarker, + marker: tmp.extra.localMarker, + source: path.join(tmp.path, tmp.extra.localThemeFile), + dest: tmp.extra.localDest, + theme_path: `./${tmp.extra.localThemeFile}`, + theme_name: tmp.extra.localThemeName, + kv_key: "plugin_state_key", + session_id: "ses_test", + keybinds: { + modal: "ctrl+alt+m", + close: "q", + }, + }, + ], + [ + tmp.extra.invalidSpec, + { + marker: tmp.extra.invalidMarker, + theme_path: `./${tmp.extra.invalidThemeFile}`, + theme_name: tmp.extra.invalidThemeName, + }, + ], + [ + tmp.extra.preloadedSpec, + { + marker: tmp.extra.preloadedMarker, + dest: tmp.extra.preloadedDest, + theme_path: `./${tmp.extra.preloadedThemeFile}`, + theme_name: tmp.extra.preloadedThemeName, + }, + ], + ] + const restore = mockTuiService({ + plugin: [globalPlugin, ...localPlugins], + plugin_origins: [ + { spec: globalPlugin, scope: "global", source: globalConfigPath }, + ...localPlugins.map((spec) => ({ spec, scope: "local" as const, source: localConfigPath })), + ], + }) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() try { expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true) @@ -404,7 +455,7 @@ export default { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - wait.mockRestore() + restore() if (backup === undefined) { await fs.rm(globalConfigPath, { force: true }) } else { @@ -459,7 +510,7 @@ test("continues loading when a plugin is missing config metadata", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const restore = mockTuiService({ plugin: [ [tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }], [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }], @@ -478,7 +529,6 @@ test("continues loading when a plugin is missing config metadata", async () => { }, ], }) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) try { @@ -492,8 +542,7 @@ test("continues loading when a plugin is missing config metadata", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() - wait.mockRestore() + restore() delete process.env.OPENCODE_PLUGIN_META_FILE } }) @@ -696,8 +745,12 @@ test("updates installed theme when plugin metadata changes", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const plugin: [string, Record] = [tmp.extra.spec, { theme_path: "./theme-update.json" }] + const restore = mockTuiService({ + plugin: [plugin], + plugin_origins: [{ spec: plugin, scope: "local", source: path.join(tmp.path, "tui.json") }], + }) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const api = () => createTuiPluginApi({ @@ -741,7 +794,7 @@ test("updates installed theme when plugin metadata changes", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - wait.mockRestore() + restore() delete process.env.OPENCODE_PLUGIN_META_FILE } }) diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts index 10ddfe8e1c4f..588704d1f59c 100644 --- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts @@ -4,7 +4,7 @@ import path from "path" import { pathToFileURL } from "url" import { tmpdir } from "../../fixture/fixture" import { createTuiPluginApi } from "../../fixture/tui-plugin" -import { TuiConfig } from "../../../src/config/tui" +import { mockTuiService } from "../../fixture/tui-runtime" const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") @@ -39,7 +39,7 @@ test("toggles plugin runtime state by exported id", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const restore = mockTuiService({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_enabled: { "demo.toggle": false, @@ -52,7 +52,6 @@ test("toggles plugin runtime state by exported id", async () => { }, ], }) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi() @@ -85,8 +84,7 @@ test("toggles plugin runtime state by exported id", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() - wait.mockRestore() + restore() delete process.env.OPENCODE_PLUGIN_META_FILE } }) @@ -117,7 +115,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => { }) process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") - const get = spyOn(TuiConfig, "get").mockResolvedValue({ + const restore = mockTuiService({ plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin_enabled: { "demo.startup": false, @@ -130,7 +128,6 @@ test("kv plugin_enabled overrides tui config on startup", async () => { }, ], }) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const api = createTuiPluginApi() api.kv.set("plugin_enabled", { @@ -152,8 +149,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => { } finally { await TuiPluginRuntime.dispose() cwd.mockRestore() - get.mockRestore() - wait.mockRestore() + restore() delete process.env.OPENCODE_PLUGIN_META_FILE } }) diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 176c2575a308..75131bd05570 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -8,7 +8,7 @@ import { UI } from "../../../src/cli/ui" import * as Timeout from "../../../src/util/timeout" import * as Network from "../../../src/cli/network" import * as Win32 from "../../../src/cli/cmd/tui/win32" -import { TuiConfig } from "../../../src/config/tui" +import { mockTuiService } from "../../fixture/tui-runtime" import { Instance } from "../../../src/project/instance" const stop = new Error("stop") @@ -42,7 +42,7 @@ function setup() { }) spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {}) spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined) - spyOn(TuiConfig, "get").mockResolvedValue({}) + mockTuiService({}) spyOn(Instance, "provide").mockImplementation(async (input) => { seen.inst.push(input.directory) return input.fn() diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 529d88bce1a6..cfa64a3ca294 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -13,6 +13,7 @@ const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const wintest = process.platform === "win32" ? test : test.skip const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait))) const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) +const tuiGet = () => AppRuntime.runPromise(TuiConfig.Service.use((svc) => svc.get())) beforeEach(async () => { await clear(true) @@ -83,7 +84,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => { directory: tmp.path, fn: async () => { const server = await load() - const tui = await TuiConfig.get() + const tui = await tuiGet() const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item)) const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item)) @@ -116,7 +117,7 @@ test("loads tui config with the same precedence order as server config paths", a await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.theme).toBe("local") expect(config.diff_style).toBe("stacked") }, @@ -144,7 +145,7 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.theme).toBe("migrated-theme") expect(config.scroll_speed).toBe(5) expect(config.keybinds?.app_exit).toBe("ctrl+q") @@ -184,7 +185,7 @@ test("migrates project legacy tui keys even when global tui.json already exists" await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.theme).toBe("project-migrated") expect(config.scroll_speed).toBe(2) expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) @@ -216,7 +217,7 @@ test("drops unknown legacy tui keys during migration", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.theme).toBe("migrated-theme") expect(config.scroll_speed).toBe(2) @@ -245,7 +246,7 @@ test("skips migration when opencode.jsonc is syntactically invalid", async () => await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.theme).toBeUndefined() expect(config.scroll_speed).toBeUndefined() expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false) @@ -268,7 +269,7 @@ test("skips migration when tui.json already exists", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.diff_style).toBe("stacked") expect(config.theme).toBeUndefined() @@ -293,7 +294,7 @@ test("continues loading tui config when legacy source cannot be stripped", async await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.theme).toBe("readonly-theme") expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) @@ -326,7 +327,7 @@ test("migration backup preserves JSONC comments", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await TuiConfig.get() + await tuiGet() const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak")) expect(backup).toContain("// top-level comment") expect(backup).toContain("// nested comment") @@ -349,7 +350,7 @@ test("migrates legacy tui keys across multiple opencode.json levels", async () = await Instance.provide({ directory: path.join(tmp.path, "apps", "client"), fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.theme).toBe("nested-theme") expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true) expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true) @@ -373,7 +374,7 @@ test("flattens nested tui key inside tui.json", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.scroll_speed).toBe(3) expect(config.diff_style).toBe("stacked") // top-level keys take precedence over nested tui keys @@ -398,7 +399,7 @@ test("top-level keys in tui.json take precedence over nested tui key", async () await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.diff_style).toBe("auto") expect(config.scroll_speed).toBe(2) }, @@ -418,7 +419,7 @@ test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() // project tui.json overrides the custom path, same as server config precedence expect(config.theme).toBe("project") // project also set diff_style, so that wins @@ -438,7 +439,7 @@ test("merges keybind overrides across precedence layers", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.keybinds?.app_exit).toBe("ctrl+q") expect(config.keybinds?.theme_list).toBe("ctrl+k") }, @@ -451,7 +452,7 @@ wintest("defaults Ctrl+Z to input undo on Windows", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.keybinds?.terminal_suspend).toBe("none") expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") }, @@ -468,7 +469,7 @@ wintest("keeps explicit input undo overrides on Windows", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.keybinds?.terminal_suspend).toBe("none") expect(config.keybinds?.input_undo).toBe("ctrl+y") }, @@ -485,7 +486,7 @@ wintest("ignores terminal suspend bindings on Windows", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.keybinds?.terminal_suspend).toBe("none") expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") }, @@ -504,7 +505,7 @@ test("OPENCODE_TUI_CONFIG provides settings when no project config exists", asyn await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.theme).toBe("from-env") expect(config.diff_style).toBe("stacked") }, @@ -525,7 +526,7 @@ test("does not derive tui path from OPENCODE_CONFIG", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.theme).toBeUndefined() }, }) @@ -551,7 +552,7 @@ test("applies env and file substitutions in tui.json", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.theme).toBe("env-theme") expect(config.keybinds?.app_exit).toBe("ctrl+q") }, @@ -579,7 +580,7 @@ test("applies file substitutions when first identical token is in a commented li await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.theme).toBe("resolved-theme") }, }) @@ -603,7 +604,7 @@ test("loads managed tui config and gives it highest precedence", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.theme).toBe("managed-theme") expect(config.plugin).toEqual(["shared-plugin@2.0.0"]) expect(config.plugin_origins).toEqual([ @@ -628,7 +629,7 @@ test("loads .opencode/tui.json", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.diff_style).toBe("stacked") }, }) @@ -646,7 +647,7 @@ test("gracefully falls back when tui.json has invalid JSON", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.theme).toBe("managed-fallback") expect(config.keybinds).toBeDefined() }, @@ -668,7 +669,7 @@ test("supports tuple plugin specs with options in tui.json", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]]) expect(config.plugin_origins).toEqual([ { @@ -705,7 +706,7 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.plugin).toEqual([ ["acme-plugin@2.0.0", { source: "project" }], ["second-plugin@3.0.0", { source: "project" }], @@ -747,7 +748,7 @@ test("tracks global and local plugin metadata in merged tui config", async () => await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"]) expect(config.plugin_origins).toEqual([ { @@ -792,7 +793,7 @@ test("merges plugin_enabled flags across config layers", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await TuiConfig.get() + const config = await tuiGet() expect(config.plugin_enabled).toEqual({ "internal:sidebar-context": false, "demo.plugin": false, diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index fdd3b6cfffbb..23494671decf 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -1,9 +1,31 @@ import { spyOn } from "bun:test" import path from "path" +import { Effect } from "effect" import { TuiConfig } from "../../src/config/tui" type PluginSpec = string | [string, Record] +/** + * Mock `TuiConfig.Service.use` so callers that do + * `AppRuntime.runPromise(TuiConfig.Service.use(svc => svc.get()))` receive + * the provided config object instead of loading from disk. + * + * Returns a restore function. + */ +export function mockTuiService(config: TuiConfig.Info, opts?: { wait?: () => Effect.Effect }) { + const mock: TuiConfig.Interface = { + get: () => Effect.succeed(config), + waitForDependencies: () => opts?.wait?.() ?? Effect.void, + } + const spy = spyOn(TuiConfig.Service, "use" as never).mockImplementation(((fn: (svc: TuiConfig.Interface) => any) => + fn(mock)) as never) + return () => spy.mockRestore() +} + +/** + * Full mock: sets OPENCODE_PLUGIN_META_FILE, mocks cwd, and mocks + * TuiConfig.Service with the given plugins. + */ export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) { process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json") const plugin_origins = plugin.map((spec) => ({ @@ -11,17 +33,12 @@ export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) { scope: "local" as const, source: path.join(dir, "tui.json"), })) - const get = spyOn(TuiConfig, "get").mockResolvedValue({ - plugin, - plugin_origins, - }) - const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const restore = mockTuiService({ plugin, plugin_origins }) const cwd = spyOn(process, "cwd").mockImplementation(() => dir) return () => { cwd.mockRestore() - get.mockRestore() - wait.mockRestore() + restore() delete process.env.OPENCODE_PLUGIN_META_FILE } }