Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton
a8d6379e0e 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.
2026-04-14 21:35:28 -04:00
15 changed files with 181 additions and 133 deletions

View File

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

View File

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

View File

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

View File

@@ -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<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}

View File

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

View File

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

View File

@@ -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<ReturnType<typeof TuiConfig.get>> = {
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
}
})

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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<string, unknown>] = [
tmp.extra.globalSpec,
{
marker: tmp.extra.globalMarker,
theme_path: `./${tmp.extra.globalThemeFile}`,
theme_name: tmp.extra.globalThemeName,
},
]
const localPlugins: [string, Record<string, unknown>][] = [
[
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<string, unknown>] = [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
}
})

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown>]
/**
* 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<void> }) {
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
}
}