CLI perf: reduce deps (#22652)

This commit is contained in:
Dax
2026-04-16 02:03:03 -04:00
committed by GitHub
parent 150ab07a83
commit 675a46e23e
84 changed files with 1415 additions and 1011 deletions

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"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -31,15 +31,18 @@ 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 config: TuiConfig.Info = {
plugin: [],
plugin_origins: undefined,
})
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await TuiPluginRuntime.init({
api: createTuiPluginApi(),
config,
})
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
@@ -54,7 +57,6 @@ test("adds tui plugin at runtime from spec", async () => {
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -72,10 +74,10 @@ 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({
const config: TuiConfig.Info = {
plugin: [],
plugin_origins: undefined,
})
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => {
await Bun.write(
path.join(tmp.extra.mod, "index.ts"),
@@ -91,7 +93,10 @@ test("retries runtime add for file plugins after dependency wait", async () => {
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await TuiPluginRuntime.init({
api: createTuiPluginApi(),
config,
})
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
@@ -100,7 +105,6 @@ test("retries runtime add for file plugins after dependency wait", async () => {
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
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"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -50,11 +50,10 @@ 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 config: TuiConfig.Info = {
plugin: [],
plugin_origins: undefined,
}
const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi({
@@ -69,7 +68,7 @@ test("installs plugin without loading it", async () => {
})
try {
await TuiPluginRuntime.init(api)
await TuiPluginRuntime.init({ api, config })
const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
expect(out).toMatchObject({
ok: true,
@@ -82,7 +81,6 @@ test("installs plugin without loading it", async () => {
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}

View File

@@ -39,10 +39,10 @@ test("runs onDispose callbacks with aborted signal and is idempotent", async ()
},
})
const restore = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]])
const { config, restore } = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]])
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await TuiPluginRuntime.dispose()
const marker = await fs.readFile(tmp.extra.marker, "utf8")
@@ -99,13 +99,13 @@ test("rolls back failed plugin and continues loading next", async () => {
},
})
const restore = mockTuiRuntime(tmp.path, [
const { config, restore } = mockTuiRuntime(tmp.path, [
[tmp.extra.badSpec, { bad_marker: tmp.extra.badMarker }],
[tmp.extra.goodSpec, { good_marker: tmp.extra.goodMarker }],
])
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
// bad plugin's onDispose ran during rollback
await expect(fs.readFile(tmp.extra.badMarker, "utf8")).resolves.toBe("cleaned")
// good plugin still loaded
@@ -155,11 +155,11 @@ export default {
},
})
const restore = mockTuiRuntime(tmp.path, [tmp.extra.spec])
const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
const err = spyOn(console, "error").mockImplementation(() => {})
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
const marker = await fs.readFile(tmp.extra.marker, "utf8")
expect(marker).toContain("one")
@@ -202,10 +202,10 @@ test(
},
})
const restore = mockTuiRuntime(tmp.path, [tmp.extra.spec])
const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
const done = await new Promise<string>((resolve) => {
const timer = setTimeout(() => resolve("timeout"), 7000)

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"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
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 config: TuiConfig.Info = {
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_origins: [
{
@@ -53,13 +53,13 @@ test("loads npm tui plugin from package ./tui export", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
})
}
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 })
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
const hit = TuiPluginRuntime.list().find((item) => item.id === "demo.tui.export")
expect(hit?.enabled).toBe(true)
@@ -69,7 +69,6 @@ test("loads npm tui plugin from package ./tui export", async () => {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -106,7 +105,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 config: TuiConfig.Info = {
plugin: [tmp.extra.spec],
plugin_origins: [
{
@@ -115,20 +114,19 @@ test("does not use npm package exports dot for tui entry", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
})
}
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 })
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -169,7 +167,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 config: TuiConfig.Info = {
plugin: [tmp.extra.spec],
plugin_origins: [
{
@@ -178,13 +176,13 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
source: path.join(tmp.path, "tui.json"),
},
],
})
}
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 })
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
// plugin code never ran
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
// plugin not listed
@@ -193,7 +191,6 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -232,7 +229,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 config: TuiConfig.Info = {
plugin: [tmp.extra.spec],
plugin_origins: [
{
@@ -241,20 +238,19 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
source: path.join(tmp.path, "tui.json"),
},
],
})
}
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 })
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -291,7 +287,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 config: TuiConfig.Info = {
plugin: [tmp.extra.spec],
plugin_origins: [
{
@@ -300,7 +296,7 @@ test("does not use npm package main for tui entry", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
})
}
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 })
@@ -308,7 +304,7 @@ test("does not use npm package main for tui entry", async () => {
const error = spyOn(console, "error").mockImplementation(() => {})
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
expect(error).not.toHaveBeenCalled()
@@ -317,7 +313,6 @@ test("does not use npm package main for tui entry", async () => {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
warn.mockRestore()
error.mockRestore()
@@ -357,7 +352,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 config: TuiConfig.Info = {
plugin: [tmp.extra.spec],
plugin_origins: [
{
@@ -366,18 +361,17 @@ test("does not use directory package main for tui entry", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
})
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -405,7 +399,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 config: TuiConfig.Info = {
plugin: [tmp.extra.spec],
plugin_origins: [
{
@@ -414,18 +408,17 @@ test("uses directory index fallback for tui when package.json is missing", async
source: path.join(tmp.path, "tui.json"),
},
],
})
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.dir.index")?.active).toBe(true)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -463,7 +456,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 config: TuiConfig.Info = {
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_origins: [
{
@@ -472,20 +465,19 @@ test("uses npm package name when tui plugin id is omitted", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
})
}
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 })
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
expect(TuiPluginRuntime.list().find((item) => item.spec === tmp.extra.spec)?.id).toBe("acme-plugin")
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
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"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
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 config: TuiConfig.Info = {
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_origins: [
{
@@ -46,17 +46,16 @@ test("skips external tui plugins in pure mode", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
})
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
if (pure === undefined) {
delete process.env.OPENCODE_PURE

View File

@@ -5,8 +5,8 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { Global } from "../../../src/global"
import { TuiConfig } from "../../../src/config"
import { Filesystem } from "../../../src/util"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
import { Filesystem } from "../../../src/util/"
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -328,8 +328,55 @@ export default {
try {
expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
await TuiPluginRuntime.init(
createTuiPluginApi({
const localOpts = {
fn_marker: tmp.extra.fnMarker,
marker: tmp.extra.localMarker,
source: tmp.extra.localDest.replace(".opencode/themes/", ""),
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" },
}
const invalidOpts = {
marker: tmp.extra.invalidMarker,
theme_path: `./${tmp.extra.invalidThemeFile}`,
theme_name: tmp.extra.invalidThemeName,
}
const preloadedOpts = {
marker: tmp.extra.preloadedMarker,
dest: tmp.extra.preloadedDest,
theme_path: `./${tmp.extra.preloadedThemeFile}`,
theme_name: tmp.extra.preloadedThemeName,
}
const globalOpts = {
marker: tmp.extra.globalMarker,
theme_path: `./${tmp.extra.globalThemeFile}`,
theme_name: tmp.extra.globalThemeName,
}
const config: TuiConfig.Info = {
plugin: [
[tmp.extra.localSpec, localOpts],
[tmp.extra.invalidSpec, invalidOpts],
[tmp.extra.preloadedSpec, preloadedOpts],
[tmp.extra.globalSpec, globalOpts],
],
plugin_origins: [
{ spec: [tmp.extra.localSpec, localOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
{ spec: [tmp.extra.invalidSpec, invalidOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
{ spec: [tmp.extra.preloadedSpec, preloadedOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
{
spec: [tmp.extra.globalSpec, globalOpts],
scope: "global",
source: path.join(Global.Path.config, "tui.json"),
},
],
}
await TuiPluginRuntime.init({
api: createTuiPluginApi({
tuiConfig: {
theme: "smoke",
diff_style: "stacked",
@@ -366,7 +413,8 @@ export default {
},
},
}),
)
config,
})
const local = await row(tmp.extra.localMarker)
const global = await row(tmp.extra.globalMarker)
const invalid = await row(tmp.extra.invalidMarker)
@@ -459,7 +507,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 config: TuiConfig.Info = {
plugin: [
[tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
[tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
@@ -477,12 +525,12 @@ test("continues loading when a plugin is missing config metadata", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
})
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init(createTuiPluginApi())
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
// bad plugin was skipped (no metadata entry)
await expect(fs.readFile(path.join(tmp.path, "bad.txt"), "utf8")).rejects.toThrow()
// good plugin loaded fine
@@ -492,7 +540,6 @@ test("continues loading when a plugin is missing config metadata", async () => {
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -555,7 +602,18 @@ export default {
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init(createTuiPluginApi())
const a = path.join(tmp.path, "order-a.ts")
const b = path.join(tmp.path, "order-b.ts")
const aSpec = pathToFileURL(a).href
const bSpec = pathToFileURL(b).href
const config: TuiConfig.Info = {
plugin: [aSpec, bSpec],
plugin_origins: [
{ spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
{ spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
],
}
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
expect(lines).toEqual(["a-start", "a-end", "b"])
} finally {
@@ -699,7 +757,7 @@ test("updates installed theme when plugin metadata changes", async () => {
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const api = () =>
const mkApi = () =>
createTuiPluginApi({
theme: {
has(name) {
@@ -708,8 +766,19 @@ test("updates installed theme when plugin metadata changes", async () => {
},
})
const mkConfig = (): TuiConfig.Info => ({
plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]],
plugin_origins: [
{
spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
try {
await TuiPluginRuntime.init(api())
await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
await TuiPluginRuntime.dispose()
await expect(fs.readFile(tmp.extra.dest, "utf8")).resolves.toContain("#111111")
@@ -730,7 +799,7 @@ test("updates installed theme when plugin metadata changes", async () => {
await fs.utimes(tmp.extra.pluginPath, stamp, stamp)
await fs.utimes(tmp.extra.themePath, stamp, stamp)
await TuiPluginRuntime.init(api())
await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
const text = await fs.readFile(tmp.extra.dest, "utf8")
expect(text).toContain("#222222")
expect(text).not.toContain("#111111")

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"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
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 config: TuiConfig.Info = {
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_enabled: {
"demo.toggle": false,
@@ -51,13 +51,13 @@ test("toggles plugin runtime state by exported id", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
})
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi()
try {
await TuiPluginRuntime.init(api)
await TuiPluginRuntime.init({ api, config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.toggle")).toEqual({
@@ -85,7 +85,6 @@ test("toggles plugin runtime state by exported id", async () => {
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
@@ -117,7 +116,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 config: TuiConfig.Info = {
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_enabled: {
"demo.startup": false,
@@ -129,7 +128,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
source: path.join(tmp.path, "tui.json"),
},
],
})
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi()
@@ -138,7 +137,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
})
try {
await TuiPluginRuntime.init(api)
await TuiPluginRuntime.init({ api, config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("on")
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.startup")).toEqual({
@@ -152,7 +151,6 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}

View File

@@ -8,13 +8,11 @@ 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"
import { Instance } from "../../../src/project/instance"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const stop = new Error("stop")
const seen = {
tui: [] as string[],
inst: [] as string[],
}
function setup() {
@@ -42,11 +40,6 @@ function setup() {
})
spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {})
spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined)
spyOn(TuiConfig, "get").mockResolvedValue({})
spyOn(Instance, "provide").mockImplementation(async (input) => {
seen.inst.push(input.directory)
return input.fn()
})
}
describe("tui thread", () => {
@@ -86,7 +79,6 @@ describe("tui thread", () => {
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
const type = process.platform === "win32" ? "junction" : "dir"
seen.tui.length = 0
seen.inst.length = 0
await fs.symlink(tmp.path, link, type)
Object.defineProperty(process.stdin, "isTTY", {
@@ -105,7 +97,6 @@ describe("tui thread", () => {
process.chdir(tmp.path)
process.env.PWD = link
await expect(call(project)).rejects.toBe(stop)
expect(seen.inst[0]).toBe(tmp.path)
expect(seen.tui[0]).toBe(tmp.path)
} finally {
process.chdir(cwd)

View File

@@ -26,6 +26,7 @@ import { ProjectID } from "../../src/project/schema"
import { Filesystem } from "../../src/util"
import * as Network from "../../src/util/network"
import { Npm } from "../../src/npm"
import { ConfigPlugin } from "@/config/plugin"
const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
@@ -1256,7 +1257,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
const cfg = await load()
const plugins = cfg.plugin ?? []
const origins = cfg.plugin_origins ?? []
const names = plugins.map((item) => Config.pluginSpecifier(item))
const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item))
expect(names).toContain("shared-plugin@2.0.0")
expect(names).not.toContain("shared-plugin@1.0.0")
@@ -1264,7 +1265,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
expect(names).toContain("local-only@1.0.0")
expect(origins.map((item) => item.spec)).toEqual(plugins)
const hit = origins.find((item) => Config.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")
const hit = origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")
expect(hit?.scope).toBe("local")
},
})
@@ -1909,8 +1910,8 @@ describe("resolvePluginSpec", () => {
test("keeps package specs unchanged", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "opencode.json")
expect(await Config.resolvePluginSpec("oh-my-opencode@2.4.3", file)).toBe("oh-my-opencode@2.4.3")
expect(await Config.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
expect(await ConfigPlugin.resolvePluginSpec("oh-my-opencode@2.4.3", file)).toBe("oh-my-opencode@2.4.3")
expect(await ConfigPlugin.resolvePluginSpec("@scope/pkg", file)).toBe("@scope/pkg")
})
test("resolves windows-style relative plugin directory specs", async () => {
@@ -1925,8 +1926,8 @@ describe("resolvePluginSpec", () => {
})
const file = path.join(tmp.path, "opencode.json")
const hit = await Config.resolvePluginSpec(".\\plugin", file)
expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
const hit = await ConfigPlugin.resolvePluginSpec(".\\plugin", file)
expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
})
test("resolves relative file plugin paths to file urls", async () => {
@@ -1937,8 +1938,8 @@ describe("resolvePluginSpec", () => {
})
const file = path.join(tmp.path, "opencode.json")
const hit = await Config.resolvePluginSpec("./plugin.ts", file)
expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href)
const hit = await ConfigPlugin.resolvePluginSpec("./plugin.ts", file)
expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin.ts")).href)
})
test("resolves plugin directory paths to directory urls", async () => {
@@ -1956,8 +1957,8 @@ describe("resolvePluginSpec", () => {
})
const file = path.join(tmp.path, "opencode.json")
const hit = await Config.resolvePluginSpec("./plugin", file)
expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href)
const hit = await ConfigPlugin.resolvePluginSpec("./plugin", file)
expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin")).href)
})
test("resolves plugin directories without package.json to index.ts", async () => {

View File

@@ -3,13 +3,15 @@ import path from "path"
import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
import { Config } from "../../src/config"
import { TuiConfig } from "../../src/config"
import { Global } from "../../src/global"
import { Filesystem } from "../../src/util"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Effect, Layer } from "effect"
import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd"
import { ConfigPlugin } from "@/config/plugin"
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()))
@@ -18,6 +20,13 @@ beforeEach(async () => {
await clear(true)
})
const getTuiConfig = async (directory: string) =>
Effect.runPromise(
TuiConfig.Service.use((svc) => svc.get()).pipe(
Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))),
),
)
afterEach(async () => {
delete process.env.OPENCODE_CONFIG
delete process.env.OPENCODE_TUI_CONFIG
@@ -25,7 +34,6 @@ afterEach(async () => {
await fs.rm(path.join(Global.Path.config, "opencode.jsonc"), { force: true }).catch(() => {})
await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
await clear(true)
})
@@ -83,9 +91,9 @@ 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 serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item))
const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item))
const tui = await getTuiConfig(tmp.path)
const serverPlugins = (server.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
const tuiPlugins = (tui.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
expect(serverPlugins).toEqual(tuiPlugins)
expect(serverPlugins).toContain("shared-plugin@2.0.0")
@@ -93,8 +101,8 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
const serverOrigins = server.plugin_origins ?? []
const tuiOrigins = tui.plugin_origins ?? []
expect(serverOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(serverPlugins)
expect(tuiOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(tuiPlugins)
expect(serverOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(serverPlugins)
expect(tuiOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(tuiPlugins)
expect(serverOrigins.map((item) => item.scope)).toEqual(tuiOrigins.map((item) => item.scope))
},
})
@@ -113,14 +121,9 @@ 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()
expect(config.theme).toBe("local")
expect(config.diff_style).toBe("stacked")
},
})
const config = await getTuiConfig(tmp.path)
expect(config.theme).toBe("local")
expect(config.diff_style).toBe("stacked")
})
test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
@@ -141,26 +144,21 @@ 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()
expect(config.theme).toBe("migrated-theme")
expect(config.scroll_speed).toBe(5)
expect(config.keybinds?.app_exit).toBe("ctrl+q")
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
expect(JSON.parse(text)).toMatchObject({
theme: "migrated-theme",
scroll_speed: 5,
})
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
expect(server.theme).toBeUndefined()
expect(server.keybinds).toBeUndefined()
expect(server.tui).toBeUndefined()
expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
},
const config = await getTuiConfig(tmp.path)
expect(config.theme).toBe("migrated-theme")
expect(config.scroll_speed).toBe(5)
expect(config.keybinds?.app_exit).toBe("ctrl+q")
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
expect(JSON.parse(text)).toMatchObject({
theme: "migrated-theme",
scroll_speed: 5,
})
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
expect(server.theme).toBeUndefined()
expect(server.keybinds).toBeUndefined()
expect(server.tui).toBeUndefined()
expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
})
test("migrates project legacy tui keys even when global tui.json already exists", async () => {
@@ -181,19 +179,14 @@ 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()
expect(config.theme).toBe("project-migrated")
expect(config.scroll_speed).toBe(2)
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
const config = await getTuiConfig(tmp.path)
expect(config.theme).toBe("project-migrated")
expect(config.scroll_speed).toBe(2)
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
expect(server.theme).toBeUndefined()
expect(server.tui).toBeUndefined()
},
})
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
expect(server.theme).toBeUndefined()
expect(server.tui).toBeUndefined()
})
test("drops unknown legacy tui keys during migration", async () => {
@@ -213,19 +206,14 @@ test("drops unknown legacy tui keys during migration", async () => {
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("migrated-theme")
expect(config.scroll_speed).toBe(2)
const config = await getTuiConfig(tmp.path)
expect(config.theme).toBe("migrated-theme")
expect(config.scroll_speed).toBe(2)
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
const migrated = JSON.parse(text)
expect(migrated.scroll_speed).toBe(2)
expect(migrated.foo).toBeUndefined()
},
})
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
const migrated = JSON.parse(text)
expect(migrated.scroll_speed).toBe(2)
expect(migrated.foo).toBeUndefined()
})
test("skips migration when opencode.jsonc is syntactically invalid", async () => {
@@ -242,19 +230,14 @@ test("skips migration when opencode.jsonc is syntactically invalid", async () =>
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBeUndefined()
expect(config.scroll_speed).toBeUndefined()
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false)
const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc"))
expect(source).toContain('"theme": "broken-theme"')
expect(source).toContain('"tui": { "scroll_speed": 2 }')
},
})
const config = await getTuiConfig(tmp.path)
expect(config.theme).toBeUndefined()
expect(config.scroll_speed).toBeUndefined()
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false)
const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc"))
expect(source).toContain('"theme": "broken-theme"')
expect(source).toContain('"tui": { "scroll_speed": 2 }')
})
test("skips migration when tui.json already exists", async () => {
@@ -265,18 +248,13 @@ test("skips migration when tui.json already exists", async () => {
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.diff_style).toBe("stacked")
expect(config.theme).toBeUndefined()
const config = await getTuiConfig(tmp.path)
expect(config.diff_style).toBe("stacked")
expect(config.theme).toBeUndefined()
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
expect(server.theme).toBe("legacy")
expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false)
},
})
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
expect(server.theme).toBe("legacy")
expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false)
})
test("continues loading tui config when legacy source cannot be stripped", async () => {
@@ -290,17 +268,12 @@ test("continues loading tui config when legacy source cannot be stripped", async
await fs.chmod(source, 0o444)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("readonly-theme")
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
const config = await getTuiConfig(tmp.path)
expect(config.theme).toBe("readonly-theme")
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
const server = JSON.parse(await Filesystem.readText(source))
expect(server.theme).toBe("readonly-theme")
},
})
const server = JSON.parse(await Filesystem.readText(source))
expect(server.theme).toBe("readonly-theme")
} finally {
await fs.chmod(source, 0o644)
}
@@ -323,17 +296,12 @@ test("migration backup preserves JSONC comments", async () => {
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
await TuiConfig.get()
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")
expect(backup).toContain('"theme": "jsonc-theme"')
expect(backup).toContain('"scroll_speed": 1.5')
},
})
await getTuiConfig(tmp.path)
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")
expect(backup).toContain('"theme": "jsonc-theme"')
expect(backup).toContain('"scroll_speed": 1.5')
})
test("migrates legacy tui keys across multiple opencode.json levels", async () => {
@@ -345,16 +313,10 @@ test("migrates legacy tui keys across multiple opencode.json levels", async () =
await Bun.write(path.join(nested, "opencode.json"), JSON.stringify({ theme: "nested-theme" }, null, 2))
},
})
await Instance.provide({
directory: path.join(tmp.path, "apps", "client"),
fn: async () => {
const config = await TuiConfig.get()
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)
},
})
const config = await getTuiConfig(path.join(tmp.path, "apps", "client"))
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)
})
test("flattens nested tui key inside tui.json", async () => {
@@ -370,16 +332,11 @@ test("flattens nested tui key inside tui.json", async () => {
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.scroll_speed).toBe(3)
expect(config.diff_style).toBe("stacked")
// top-level keys take precedence over nested tui keys
expect(config.theme).toBe("outer")
},
})
const config = await getTuiConfig(tmp.path)
expect(config.scroll_speed).toBe(3)
expect(config.diff_style).toBe("stacked")
// top-level keys take precedence over nested tui keys
expect(config.theme).toBe("outer")
})
test("top-level keys in tui.json take precedence over nested tui key", async () => {
@@ -395,14 +352,9 @@ 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()
expect(config.diff_style).toBe("auto")
expect(config.scroll_speed).toBe(2)
},
})
const config = await getTuiConfig(tmp.path)
expect(config.diff_style).toBe("auto")
expect(config.scroll_speed).toBe(2)
})
test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", async () => {
@@ -415,16 +367,11 @@ test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
// 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
expect(config.diff_style).toBe("auto")
},
})
const config = await getTuiConfig(tmp.path)
// 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
expect(config.diff_style).toBe("auto")
})
test("merges keybind overrides across precedence layers", async () => {
@@ -434,28 +381,16 @@ test("merges keybind overrides across precedence layers", async () => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } }))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.keybinds?.app_exit).toBe("ctrl+q")
expect(config.keybinds?.theme_list).toBe("ctrl+k")
},
})
const config = await getTuiConfig(tmp.path)
expect(config.keybinds?.app_exit).toBe("ctrl+q")
expect(config.keybinds?.theme_list).toBe("ctrl+k")
})
wintest("defaults Ctrl+Z to input undo on Windows", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.keybinds?.terminal_suspend).toBe("none")
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
},
})
const config = await getTuiConfig(tmp.path)
expect(config.keybinds?.terminal_suspend).toBe("none")
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
})
wintest("keeps explicit input undo overrides on Windows", async () => {
@@ -464,15 +399,9 @@ wintest("keeps explicit input undo overrides on Windows", async () => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_undo: "ctrl+y" } }))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.keybinds?.terminal_suspend).toBe("none")
expect(config.keybinds?.input_undo).toBe("ctrl+y")
},
})
const config = await getTuiConfig(tmp.path)
expect(config.keybinds?.terminal_suspend).toBe("none")
expect(config.keybinds?.input_undo).toBe("ctrl+y")
})
wintest("ignores terminal suspend bindings on Windows", async () => {
@@ -482,14 +411,9 @@ wintest("ignores terminal suspend bindings on Windows", async () => {
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.keybinds?.terminal_suspend).toBe("none")
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
},
})
const config = await getTuiConfig(tmp.path)
expect(config.keybinds?.terminal_suspend).toBe("none")
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
})
test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
@@ -500,15 +424,9 @@ test("OPENCODE_TUI_CONFIG provides settings when no project config exists", asyn
process.env.OPENCODE_TUI_CONFIG = custom
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("from-env")
expect(config.diff_style).toBe("stacked")
},
})
const config = await getTuiConfig(tmp.path)
expect(config.theme).toBe("from-env")
expect(config.diff_style).toBe("stacked")
})
test("does not derive tui path from OPENCODE_CONFIG", async () => {
@@ -521,14 +439,8 @@ test("does not derive tui path from OPENCODE_CONFIG", async () => {
process.env.OPENCODE_CONFIG = path.join(customDir, "opencode.json")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBeUndefined()
},
})
const config = await getTuiConfig(tmp.path)
expect(config.theme).toBeUndefined()
})
test("applies env and file substitutions in tui.json", async () => {
@@ -547,15 +459,9 @@ test("applies env and file substitutions in tui.json", async () => {
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("env-theme")
expect(config.keybinds?.app_exit).toBe("ctrl+q")
},
})
const config = await getTuiConfig(tmp.path)
expect(config.theme).toBe("env-theme")
expect(config.keybinds?.app_exit).toBe("ctrl+q")
} finally {
if (original === undefined) delete process.env.TUI_THEME_TEST
else process.env.TUI_THEME_TEST = original
@@ -575,46 +481,8 @@ 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()
expect(config.theme).toBe("resolved-theme")
},
})
})
test("loads managed tui config and gives it highest precedence", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({ theme: "project-theme", plugin: ["shared-plugin@1.0.0"] }, null, 2),
)
await fs.mkdir(managedConfigDir, { recursive: true })
await Bun.write(
path.join(managedConfigDir, "tui.json"),
JSON.stringify({ theme: "managed-theme", plugin: ["shared-plugin@2.0.0"] }, null, 2),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("managed-theme")
expect(config.plugin).toEqual(["shared-plugin@2.0.0"])
expect(config.plugin_origins).toEqual([
{
spec: "shared-plugin@2.0.0",
scope: "global",
source: path.join(managedConfigDir, "tui.json"),
},
])
},
})
const config = await getTuiConfig(tmp.path)
expect(config.theme).toBe("resolved-theme")
})
test("loads .opencode/tui.json", async () => {
@@ -624,33 +492,8 @@ test("loads .opencode/tui.json", async () => {
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.diff_style).toBe("stacked")
},
})
})
test("gracefully falls back when tui.json has invalid JSON", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), "{ invalid json }")
await fs.mkdir(managedConfigDir, { recursive: true })
await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-fallback" }, null, 2))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("managed-fallback")
expect(config.keybinds).toBeDefined()
},
})
const config = await getTuiConfig(tmp.path)
expect(config.diff_style).toBe("stacked")
})
test("supports tuple plugin specs with options in tui.json", async () => {
@@ -665,20 +508,15 @@ test("supports tuple plugin specs with options in tui.json", async () => {
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
expect(config.plugin_origins).toEqual([
{
spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
])
const config = await getTuiConfig(tmp.path)
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
expect(config.plugin_origins).toEqual([
{
spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
})
])
})
test("deduplicates tuple plugin specs by name with higher precedence winning", async () => {
@@ -702,28 +540,23 @@ 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()
expect(config.plugin).toEqual([
["acme-plugin@2.0.0", { source: "project" }],
["second-plugin@3.0.0", { source: "project" }],
])
expect(config.plugin_origins).toEqual([
{
spec: ["acme-plugin@2.0.0", { source: "project" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
{
spec: ["second-plugin@3.0.0", { source: "project" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
])
const config = await getTuiConfig(tmp.path)
expect(config.plugin).toEqual([
["acme-plugin@2.0.0", { source: "project" }],
["second-plugin@3.0.0", { source: "project" }],
])
expect(config.plugin_origins).toEqual([
{
spec: ["acme-plugin@2.0.0", { source: "project" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
})
{
spec: ["second-plugin@3.0.0", { source: "project" }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
])
})
test("tracks global and local plugin metadata in merged tui config", async () => {
@@ -744,25 +577,20 @@ 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()
expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
expect(config.plugin_origins).toEqual([
{
spec: "global-plugin@1.0.0",
scope: "global",
source: path.join(Global.Path.config, "tui.json"),
},
{
spec: "local-plugin@2.0.0",
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
])
const config = await getTuiConfig(tmp.path)
expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
expect(config.plugin_origins).toEqual([
{
spec: "global-plugin@1.0.0",
scope: "global",
source: path.join(Global.Path.config, "tui.json"),
},
})
{
spec: "local-plugin@2.0.0",
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
])
})
test("merges plugin_enabled flags across config layers", async () => {
@@ -789,15 +617,10 @@ test("merges plugin_enabled flags across config layers", async () => {
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.plugin_enabled).toEqual({
"internal:sidebar-context": false,
"demo.plugin": false,
"local.plugin": true,
})
},
const config = await getTuiConfig(tmp.path)
expect(config.plugin_enabled).toEqual({
"internal:sidebar-context": false,
"demo.plugin": false,
"local.plugin": true,
})
})

View File

@@ -140,7 +140,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
expect(Filesystem.mimeType(filepath)).toContain("application/json")
expect(await Filesystem.mimeType(filepath)).toContain("application/json")
const result = await read("test.json")
expect(result.type).toBe("text")
@@ -164,7 +164,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
expect(Filesystem.mimeType(filepath)).toContain(mime)
expect(await Filesystem.mimeType(filepath)).toContain(mime)
},
})
}

View File

@@ -1,27 +1,31 @@
import { spyOn } from "bun:test"
import path from "path"
import { TuiConfig } from "../../src/config"
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
type PluginSpec = string | [string, Record<string, unknown>]
export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugin_enabled?: Record<string, boolean> }) {
process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
const plugin_origins = plugin.map((spec) => ({
spec,
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 cwd = spyOn(process, "cwd").mockImplementation(() => dir)
return () => {
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
const config: TuiConfig.Info = {
plugin,
plugin_origins,
...(opts?.plugin_enabled && { plugin_enabled: opts.plugin_enabled }),
}
return {
config,
restore: () => {
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
},
}
}

View File

@@ -1,14 +1,14 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Global } from "../../src/global"
import { Installation } from "../../src/installation"
import { InstallationChannel } from "../../src/installation/version"
import { Database } from "../../src/storage"
describe("Database.Path", () => {
test("returns database path for the current channel", () => {
const expected = ["latest", "beta"].includes(Installation.CHANNEL)
const expected = ["latest", "beta"].includes(InstallationChannel)
? path.join(Global.Path.data, "opencode.db")
: path.join(Global.Path.data, `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
: path.join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
expect(Database.getChannelPath()).toBe(expected)
})
})

View File

@@ -16,6 +16,7 @@ import { Tool } from "../../src/tool"
import { Filesystem } from "../../src/util"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Npm } from "@opencode-ai/shared/npm"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")

View File

@@ -347,31 +347,31 @@ describe("filesystem", () => {
})
describe("mimeType()", () => {
test("returns correct MIME type for JSON", () => {
expect(Filesystem.mimeType("test.json")).toContain("application/json")
test("returns correct MIME type for JSON", async () => {
expect(await Filesystem.mimeType("test.json")).toContain("application/json")
})
test("returns correct MIME type for JavaScript", () => {
expect(Filesystem.mimeType("test.js")).toContain("javascript")
test("returns correct MIME type for JavaScript", async () => {
expect(await Filesystem.mimeType("test.js")).toContain("javascript")
})
test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", () => {
const mime = Filesystem.mimeType("test.ts")
test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", async () => {
const mime = await Filesystem.mimeType("test.ts")
// .ts is ambiguous: TypeScript vs MPEG-2 TS video
expect(mime === "video/mp2t" || mime === "application/typescript" || mime === "text/typescript").toBe(true)
})
test("returns correct MIME type for images", () => {
expect(Filesystem.mimeType("test.png")).toContain("image/png")
expect(Filesystem.mimeType("test.jpg")).toContain("image/jpeg")
test("returns correct MIME type for images", async () => {
expect(await Filesystem.mimeType("test.png")).toContain("image/png")
expect(await Filesystem.mimeType("test.jpg")).toContain("image/jpeg")
})
test("returns default for unknown extension", () => {
expect(Filesystem.mimeType("test.unknown")).toBe("application/octet-stream")
test("returns default for unknown extension", async () => {
expect(await Filesystem.mimeType("test.unknown")).toBe("application/octet-stream")
})
test("handles files without extension", () => {
expect(Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
test("handles files without extension", async () => {
expect(await Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
})
})