Move the Global module from packages/opencode/src/global to packages/core/src/global to provide a unified location for managing XDG directories and application paths. This eliminates duplicate path definitions across packages and ensures consistent access to data, config, cache, state, log, and bin directories throughout the codebase.
817 lines
26 KiB
TypeScript
817 lines
26 KiB
TypeScript
import { beforeAll, describe, expect, spyOn, test } from "bun:test"
|
|
import fs from "fs/promises"
|
|
import path from "path"
|
|
import { pathToFileURL } from "url"
|
|
import { tmpdir } from "../../fixture/fixture"
|
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
|
import { Global } from "@opencode-ai/core/global"
|
|
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")
|
|
|
|
type Row = Record<string, unknown>
|
|
|
|
type Data = {
|
|
local: Row
|
|
global: Row
|
|
invalid: Row
|
|
preloaded: Row
|
|
fn_called: boolean
|
|
local_installed: string
|
|
global_installed: string
|
|
preloaded_installed: string
|
|
leaked_local_to_global: boolean
|
|
leaked_global_to_local: boolean
|
|
local_theme: string
|
|
global_theme: string
|
|
}
|
|
|
|
async function row(file: string): Promise<Row> {
|
|
return Filesystem.readJson<Row>(file)
|
|
}
|
|
|
|
async function load(): Promise<Data> {
|
|
const stamp = Date.now()
|
|
const globalConfigPath = path.join(Global.Path.config, "tui.json")
|
|
const backup = await Bun.file(globalConfigPath)
|
|
.text()
|
|
.catch(() => undefined)
|
|
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const localPluginPath = path.join(dir, "local-plugin.ts")
|
|
const invalidPluginPath = path.join(dir, "invalid-plugin.ts")
|
|
const preloadedPluginPath = path.join(dir, "preloaded-plugin.ts")
|
|
const globalPluginPath = path.join(dir, "global-plugin.ts")
|
|
const localSpec = pathToFileURL(localPluginPath).href
|
|
const invalidSpec = pathToFileURL(invalidPluginPath).href
|
|
const preloadedSpec = pathToFileURL(preloadedPluginPath).href
|
|
const globalSpec = pathToFileURL(globalPluginPath).href
|
|
const localThemeFile = `local-theme-${stamp}.json`
|
|
const invalidThemeFile = `invalid-theme-${stamp}.json`
|
|
const globalThemeFile = `global-theme-${stamp}.json`
|
|
const preloadedThemeFile = `preloaded-theme-${stamp}.json`
|
|
const localThemeName = localThemeFile.replace(/\.json$/, "")
|
|
const invalidThemeName = invalidThemeFile.replace(/\.json$/, "")
|
|
const globalThemeName = globalThemeFile.replace(/\.json$/, "")
|
|
const preloadedThemeName = preloadedThemeFile.replace(/\.json$/, "")
|
|
const localThemePath = path.join(dir, localThemeFile)
|
|
const invalidThemePath = path.join(dir, invalidThemeFile)
|
|
const globalThemePath = path.join(dir, globalThemeFile)
|
|
const preloadedThemePath = path.join(dir, preloadedThemeFile)
|
|
const localDest = path.join(dir, ".opencode", "themes", localThemeFile)
|
|
const globalDest = path.join(Global.Path.config, "themes", globalThemeFile)
|
|
const preloadedDest = path.join(dir, ".opencode", "themes", preloadedThemeFile)
|
|
const fnMarker = path.join(dir, "function-called.txt")
|
|
const localMarker = path.join(dir, "local-called.json")
|
|
const invalidMarker = path.join(dir, "invalid-called.json")
|
|
const globalMarker = path.join(dir, "global-called.json")
|
|
const preloadedMarker = path.join(dir, "preloaded-called.json")
|
|
const localConfigPath = path.join(dir, "tui.json")
|
|
|
|
await Bun.write(localThemePath, JSON.stringify({ theme: { primary: "#101010" } }, null, 2))
|
|
await Bun.write(invalidThemePath, "{ invalid json }")
|
|
await Bun.write(globalThemePath, JSON.stringify({ theme: { primary: "#202020" } }, null, 2))
|
|
await Bun.write(preloadedThemePath, JSON.stringify({ theme: { primary: "#f0f0f0" } }, null, 2))
|
|
await Bun.write(preloadedDest, JSON.stringify({ theme: { primary: "#303030" } }, null, 2))
|
|
|
|
await Bun.write(
|
|
localPluginPath,
|
|
`export const ignored = async (_input, options) => {
|
|
if (!options?.fn_marker) return
|
|
await Bun.write(options.fn_marker, "called")
|
|
}
|
|
|
|
export default {
|
|
id: "demo.local",
|
|
tui: async (api, options) => {
|
|
if (!options?.marker) return
|
|
const cfg_theme = api.tuiConfig.theme
|
|
const cfg_diff = api.tuiConfig.diff_style
|
|
const cfg_speed = api.tuiConfig.scroll_speed
|
|
const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled
|
|
const cfg_submit = api.tuiConfig.keybinds?.input_submit
|
|
const key = api.keybind.create(
|
|
{ modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" },
|
|
options.keybinds,
|
|
)
|
|
const kv_before = api.kv.get(options.kv_key, "missing")
|
|
api.kv.set(options.kv_key, "stored")
|
|
const kv_after = api.kv.get(options.kv_key, "missing")
|
|
const diff = api.state.session.diff(options.session_id)
|
|
const todo = api.state.session.todo(options.session_id)
|
|
const lsp = api.state.lsp()
|
|
const mcp = api.state.mcp()
|
|
const depth_before = api.ui.dialog.depth
|
|
const open_before = api.ui.dialog.open
|
|
const size_before = api.ui.dialog.size
|
|
api.ui.dialog.setSize("large")
|
|
const size_after = api.ui.dialog.size
|
|
api.ui.dialog.replace(() => null)
|
|
const depth_after = api.ui.dialog.depth
|
|
const open_after = api.ui.dialog.open
|
|
api.ui.dialog.clear()
|
|
const open_clear = api.ui.dialog.open
|
|
const before = api.theme.has(options.theme_name)
|
|
const set_missing = api.theme.set(options.theme_name)
|
|
await api.theme.install(options.theme_path)
|
|
const after = api.theme.has(options.theme_name)
|
|
const set_installed = api.theme.set(options.theme_name)
|
|
const first = await Bun.file(options.dest).text()
|
|
await Bun.write(options.source, JSON.stringify({ theme: { primary: "#fefefe" } }, null, 2))
|
|
await api.theme.install(options.theme_path)
|
|
const second = await Bun.file(options.dest).text()
|
|
await Bun.write(
|
|
options.marker,
|
|
JSON.stringify({
|
|
before,
|
|
set_missing,
|
|
after,
|
|
set_installed,
|
|
selected: api.theme.selected,
|
|
same: first === second,
|
|
key_modal: key.get("modal"),
|
|
key_close: key.get("close"),
|
|
key_unknown: key.get("ctrl+k"),
|
|
key_print: key.print("modal"),
|
|
kv_before,
|
|
kv_after,
|
|
kv_ready: api.kv.ready,
|
|
diff_count: diff.length,
|
|
diff_file: diff[0]?.file,
|
|
todo_count: todo.length,
|
|
todo_first: todo[0]?.content,
|
|
lsp_count: lsp.length,
|
|
mcp_count: mcp.length,
|
|
mcp_first: mcp[0]?.name,
|
|
depth_before,
|
|
open_before,
|
|
size_before,
|
|
size_after,
|
|
depth_after,
|
|
open_after,
|
|
open_clear,
|
|
cfg_theme,
|
|
cfg_diff,
|
|
cfg_speed,
|
|
cfg_accel,
|
|
cfg_submit,
|
|
}),
|
|
)
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
|
|
await Bun.write(
|
|
invalidPluginPath,
|
|
`export default {
|
|
id: "demo.invalid",
|
|
tui: async (api, options) => {
|
|
if (!options?.marker) return
|
|
const before = api.theme.has(options.theme_name)
|
|
const set_missing = api.theme.set(options.theme_name)
|
|
await api.theme.install(options.theme_path)
|
|
const after = api.theme.has(options.theme_name)
|
|
const set_installed = api.theme.set(options.theme_name)
|
|
await Bun.write(
|
|
options.marker,
|
|
JSON.stringify({
|
|
before,
|
|
set_missing,
|
|
after,
|
|
set_installed,
|
|
}),
|
|
)
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
|
|
await Bun.write(
|
|
preloadedPluginPath,
|
|
`export default {
|
|
id: "demo.preloaded",
|
|
tui: async (api, options) => {
|
|
if (!options?.marker) return
|
|
const before = api.theme.has(options.theme_name)
|
|
await api.theme.install(options.theme_path)
|
|
const after = api.theme.has(options.theme_name)
|
|
const text = await Bun.file(options.dest).text()
|
|
await Bun.write(
|
|
options.marker,
|
|
JSON.stringify({
|
|
before,
|
|
after,
|
|
text,
|
|
}),
|
|
)
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
|
|
await Bun.write(
|
|
globalPluginPath,
|
|
`export default {
|
|
id: "demo.global",
|
|
tui: async (api, options) => {
|
|
if (!options?.marker) return
|
|
await api.theme.install(options.theme_path)
|
|
const has = api.theme.has(options.theme_name)
|
|
const set_installed = api.theme.set(options.theme_name)
|
|
await Bun.write(
|
|
options.marker,
|
|
JSON.stringify({
|
|
has,
|
|
set_installed,
|
|
selected: api.theme.selected,
|
|
}),
|
|
)
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
|
|
await Bun.write(
|
|
globalConfigPath,
|
|
JSON.stringify(
|
|
{
|
|
plugin: [
|
|
[globalSpec, { marker: globalMarker, theme_path: `./${globalThemeFile}`, theme_name: globalThemeName }],
|
|
],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
|
|
await Bun.write(
|
|
localConfigPath,
|
|
JSON.stringify(
|
|
{
|
|
plugin: [
|
|
[
|
|
localSpec,
|
|
{
|
|
fn_marker: fnMarker,
|
|
marker: localMarker,
|
|
source: localThemePath,
|
|
dest: localDest,
|
|
theme_path: `./${localThemeFile}`,
|
|
theme_name: localThemeName,
|
|
kv_key: "plugin_state_key",
|
|
session_id: "ses_test",
|
|
keybinds: {
|
|
modal: "ctrl+alt+m",
|
|
close: "q",
|
|
},
|
|
},
|
|
],
|
|
[
|
|
invalidSpec,
|
|
{
|
|
marker: invalidMarker,
|
|
theme_path: `./${invalidThemeFile}`,
|
|
theme_name: invalidThemeName,
|
|
},
|
|
],
|
|
[
|
|
preloadedSpec,
|
|
{
|
|
marker: preloadedMarker,
|
|
dest: preloadedDest,
|
|
theme_path: `./${preloadedThemeFile}`,
|
|
theme_name: preloadedThemeName,
|
|
},
|
|
],
|
|
],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
|
|
return {
|
|
localThemeFile,
|
|
invalidThemeFile,
|
|
globalThemeFile,
|
|
preloadedThemeFile,
|
|
localThemeName,
|
|
invalidThemeName,
|
|
globalThemeName,
|
|
preloadedThemeName,
|
|
localDest,
|
|
globalDest,
|
|
preloadedDest,
|
|
localPluginPath,
|
|
invalidPluginPath,
|
|
globalPluginPath,
|
|
preloadedPluginPath,
|
|
localSpec,
|
|
invalidSpec,
|
|
globalSpec,
|
|
preloadedSpec,
|
|
fnMarker,
|
|
localMarker,
|
|
invalidMarker,
|
|
globalMarker,
|
|
preloadedMarker,
|
|
}
|
|
},
|
|
})
|
|
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)
|
|
|
|
const localOpts = {
|
|
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" },
|
|
}
|
|
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",
|
|
scroll_speed: 1.5,
|
|
scroll_acceleration: { enabled: true },
|
|
keybinds: {
|
|
input_submit: "ctrl+enter",
|
|
},
|
|
},
|
|
keybind: {
|
|
print: (key) => `print:${key}`,
|
|
},
|
|
state: {
|
|
session: {
|
|
diff(sessionID) {
|
|
if (sessionID !== "ses_test") return []
|
|
return [{ file: "src/app.ts", additions: 3, deletions: 1 }]
|
|
},
|
|
todo(sessionID) {
|
|
if (sessionID !== "ses_test") return []
|
|
return [{ content: "ship it", status: "pending" }]
|
|
},
|
|
},
|
|
lsp() {
|
|
return [{ id: "ts", root: "/tmp/project", status: "connected" }]
|
|
},
|
|
mcp() {
|
|
return [{ name: "github", status: "connected" }]
|
|
},
|
|
},
|
|
theme: {
|
|
has(name) {
|
|
return allThemes()[name] !== undefined
|
|
},
|
|
},
|
|
}),
|
|
config,
|
|
})
|
|
const local = await row(tmp.extra.localMarker)
|
|
const global = await row(tmp.extra.globalMarker)
|
|
const invalid = await row(tmp.extra.invalidMarker)
|
|
const preloaded = await row(tmp.extra.preloadedMarker)
|
|
const fn_called = await fs
|
|
.readFile(tmp.extra.fnMarker, "utf8")
|
|
.then(() => true)
|
|
.catch(() => false)
|
|
const local_installed = await fs.readFile(tmp.extra.localDest, "utf8")
|
|
const global_installed = await fs.readFile(tmp.extra.globalDest, "utf8")
|
|
const preloaded_installed = await fs.readFile(tmp.extra.preloadedDest, "utf8")
|
|
const leaked_local_to_global = await fs
|
|
.stat(path.join(Global.Path.config, "themes", tmp.extra.localThemeFile))
|
|
.then(() => true)
|
|
.catch(() => false)
|
|
const leaked_global_to_local = await fs
|
|
.stat(path.join(tmp.path, ".opencode", "themes", tmp.extra.globalThemeFile))
|
|
.then(() => true)
|
|
.catch(() => false)
|
|
|
|
return {
|
|
local,
|
|
global,
|
|
invalid,
|
|
preloaded,
|
|
fn_called,
|
|
local_installed,
|
|
global_installed,
|
|
preloaded_installed,
|
|
leaked_local_to_global,
|
|
leaked_global_to_local,
|
|
local_theme: tmp.extra.localThemeName,
|
|
global_theme: tmp.extra.globalThemeName,
|
|
}
|
|
} finally {
|
|
await TuiPluginRuntime.dispose()
|
|
cwd.mockRestore()
|
|
wait.mockRestore()
|
|
if (backup === undefined) {
|
|
await fs.rm(globalConfigPath, { force: true })
|
|
} else {
|
|
await Bun.write(globalConfigPath, backup)
|
|
}
|
|
await fs.rm(tmp.extra.globalDest, { force: true }).catch(() => {})
|
|
}
|
|
}
|
|
|
|
test("continues loading when a plugin is missing config metadata", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const bad = path.join(dir, "missing-meta-plugin.ts")
|
|
const good = path.join(dir, "next-plugin.ts")
|
|
const bare = path.join(dir, "plain-plugin.ts")
|
|
const badSpec = pathToFileURL(bad).href
|
|
const goodSpec = pathToFileURL(good).href
|
|
const bareSpec = pathToFileURL(bare).href
|
|
const goodMarker = path.join(dir, "next-called.txt")
|
|
const bareMarker = path.join(dir, "plain-called.txt")
|
|
|
|
for (const [file, id] of [
|
|
[bad, "demo.missing-meta"],
|
|
[good, "demo.next"],
|
|
] as const) {
|
|
await Bun.write(
|
|
file,
|
|
`export default {
|
|
id: "${id}",
|
|
tui: async (_api, options) => {
|
|
if (!options?.marker) return
|
|
await Bun.write(options.marker, "called")
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
}
|
|
|
|
await Bun.write(
|
|
bare,
|
|
`export default {
|
|
id: "demo.plain",
|
|
tui: async (_api, options) => {
|
|
await Bun.write(${JSON.stringify(bareMarker)}, options === undefined ? "undefined" : "value")
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
|
|
return { badSpec, goodSpec, bareSpec, goodMarker, bareMarker }
|
|
},
|
|
})
|
|
|
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
|
const config: TuiConfig.Info = {
|
|
plugin: [
|
|
[tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
|
|
[tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
|
|
tmp.extra.bareSpec,
|
|
],
|
|
plugin_origins: [
|
|
{
|
|
spec: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
|
|
scope: "local",
|
|
source: path.join(tmp.path, "tui.json"),
|
|
},
|
|
{
|
|
spec: tmp.extra.bareSpec,
|
|
scope: "local",
|
|
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({ 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
|
|
await expect(fs.readFile(tmp.extra.goodMarker, "utf8")).resolves.toBe("called")
|
|
// bare string spec gets undefined options
|
|
await expect(fs.readFile(tmp.extra.bareMarker, "utf8")).resolves.toBe("undefined")
|
|
} finally {
|
|
await TuiPluginRuntime.dispose()
|
|
cwd.mockRestore()
|
|
wait.mockRestore()
|
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
|
}
|
|
})
|
|
|
|
test("initializes external tui plugins in config order", async () => {
|
|
const globalJson = path.join(Global.Path.config, "tui.json")
|
|
const globalJsonc = path.join(Global.Path.config, "tui.jsonc")
|
|
const backupJson = await Bun.file(globalJson)
|
|
.text()
|
|
.catch(() => undefined)
|
|
const backupJsonc = await Bun.file(globalJsonc)
|
|
.text()
|
|
.catch(() => undefined)
|
|
|
|
await fs.rm(globalJson, { force: true }).catch(() => {})
|
|
await fs.rm(globalJsonc, { force: true }).catch(() => {})
|
|
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const a = path.join(dir, "order-a.ts")
|
|
const b = path.join(dir, "order-b.ts")
|
|
const aSpec = pathToFileURL(a).href
|
|
const bSpec = pathToFileURL(b).href
|
|
const marker = path.join(dir, "tui-order.txt")
|
|
|
|
await Bun.write(
|
|
a,
|
|
`import fs from "fs/promises"
|
|
|
|
export default {
|
|
id: "demo.tui.order.a",
|
|
tui: async () => {
|
|
await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n")
|
|
await Bun.sleep(25)
|
|
await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n")
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
await Bun.write(
|
|
b,
|
|
`import fs from "fs/promises"
|
|
|
|
export default {
|
|
id: "demo.tui.order.b",
|
|
tui: async () => {
|
|
await fs.appendFile(${JSON.stringify(marker)}, "b\\n")
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2))
|
|
|
|
return { marker }
|
|
},
|
|
})
|
|
|
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
|
|
|
try {
|
|
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 {
|
|
await TuiPluginRuntime.dispose()
|
|
cwd.mockRestore()
|
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
|
|
|
if (backupJson === undefined) {
|
|
await fs.rm(globalJson, { force: true }).catch(() => {})
|
|
} else {
|
|
await Bun.write(globalJson, backupJson)
|
|
}
|
|
if (backupJsonc === undefined) {
|
|
await fs.rm(globalJsonc, { force: true }).catch(() => {})
|
|
} else {
|
|
await Bun.write(globalJsonc, backupJsonc)
|
|
}
|
|
}
|
|
})
|
|
|
|
describe("tui.plugin.loader", () => {
|
|
let data: Data
|
|
|
|
beforeAll(async () => {
|
|
data = await load()
|
|
})
|
|
|
|
test("passes keybind, kv, state, and dialog APIs to v1 plugins", () => {
|
|
expect(data.local.key_modal).toBe("ctrl+alt+m")
|
|
expect(data.local.key_close).toBe("q")
|
|
expect(data.local.key_unknown).toBe("ctrl+k")
|
|
expect(data.local.key_print).toBe("print:ctrl+alt+m")
|
|
expect(data.local.kv_before).toBe("missing")
|
|
expect(data.local.kv_after).toBe("stored")
|
|
expect(data.local.kv_ready).toBe(true)
|
|
expect(data.local.diff_count).toBe(1)
|
|
expect(data.local.diff_file).toBe("src/app.ts")
|
|
expect(data.local.todo_count).toBe(1)
|
|
expect(data.local.todo_first).toBe("ship it")
|
|
expect(data.local.lsp_count).toBe(1)
|
|
expect(data.local.mcp_count).toBe(1)
|
|
expect(data.local.mcp_first).toBe("github")
|
|
expect(data.local.depth_before).toBe(0)
|
|
expect(data.local.open_before).toBe(false)
|
|
expect(data.local.size_before).toBe("medium")
|
|
expect(data.local.size_after).toBe("large")
|
|
expect(data.local.depth_after).toBe(1)
|
|
expect(data.local.open_after).toBe(true)
|
|
expect(data.local.open_clear).toBe(false)
|
|
expect(data.local.cfg_theme).toBe("smoke")
|
|
expect(data.local.cfg_diff).toBe("stacked")
|
|
expect(data.local.cfg_speed).toBe(1.5)
|
|
expect(data.local.cfg_accel).toBe(true)
|
|
expect(data.local.cfg_submit).toBe("ctrl+enter")
|
|
})
|
|
|
|
test("installs themes in the correct scope and remains resilient", () => {
|
|
expect(data.local.before).toBe(false)
|
|
expect(data.local.set_missing).toBe(false)
|
|
expect(data.local.after).toBe(true)
|
|
expect(data.local.set_installed).toBe(true)
|
|
expect(data.local.selected).toBe(data.local_theme)
|
|
expect(data.local.same).toBe(true)
|
|
|
|
expect(data.global.has).toBe(true)
|
|
expect(data.global.set_installed).toBe(true)
|
|
expect(data.global.selected).toBe(data.global_theme)
|
|
|
|
expect(data.invalid.before).toBe(false)
|
|
expect(data.invalid.set_missing).toBe(false)
|
|
expect(data.invalid.after).toBe(false)
|
|
expect(data.invalid.set_installed).toBe(false)
|
|
|
|
expect(data.preloaded.before).toBe(true)
|
|
expect(data.preloaded.after).toBe(true)
|
|
expect(data.preloaded.text).toContain("#303030")
|
|
expect(data.preloaded.text).not.toContain("#f0f0f0")
|
|
|
|
expect(data.fn_called).toBe(false)
|
|
expect(data.local_installed).toContain("#101010")
|
|
expect(data.local_installed).not.toContain("#fefefe")
|
|
expect(data.global_installed).toContain("#202020")
|
|
expect(data.preloaded_installed).toContain("#303030")
|
|
expect(data.preloaded_installed).not.toContain("#f0f0f0")
|
|
expect(data.leaked_local_to_global).toBe(false)
|
|
expect(data.leaked_global_to_local).toBe(false)
|
|
})
|
|
})
|
|
|
|
test("updates installed theme when plugin metadata changes", async () => {
|
|
await using tmp = await tmpdir<{
|
|
spec: string
|
|
pluginPath: string
|
|
themePath: string
|
|
dest: string
|
|
themeName: string
|
|
}>({
|
|
init: async (dir) => {
|
|
const pluginPath = path.join(dir, "theme-update-plugin.ts")
|
|
const spec = pathToFileURL(pluginPath).href
|
|
const themeFile = "theme-update.json"
|
|
const themePath = path.join(dir, themeFile)
|
|
const dest = path.join(dir, ".opencode", "themes", themeFile)
|
|
const themeName = themeFile.replace(/\.json$/, "")
|
|
const configPath = path.join(dir, "tui.json")
|
|
|
|
await Bun.write(themePath, JSON.stringify({ theme: { primary: "#111111" } }, null, 2))
|
|
await Bun.write(
|
|
pluginPath,
|
|
`export default {
|
|
id: "demo.theme-update",
|
|
tui: async (api, options) => {
|
|
if (!options?.theme_path) return
|
|
await api.theme.install(options.theme_path)
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
await Bun.write(
|
|
configPath,
|
|
JSON.stringify(
|
|
{
|
|
plugin: [[spec, { theme_path: `./${themeFile}` }]],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
|
|
return {
|
|
spec,
|
|
pluginPath,
|
|
themePath,
|
|
dest,
|
|
themeName,
|
|
}
|
|
},
|
|
})
|
|
|
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
|
|
|
const mkApi = () =>
|
|
createTuiPluginApi({
|
|
theme: {
|
|
has(name) {
|
|
return allThemes()[name] !== undefined
|
|
},
|
|
},
|
|
})
|
|
|
|
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: mkApi(), config: mkConfig() })
|
|
await TuiPluginRuntime.dispose()
|
|
await expect(fs.readFile(tmp.extra.dest, "utf8")).resolves.toContain("#111111")
|
|
|
|
await Bun.write(tmp.extra.themePath, JSON.stringify({ theme: { primary: "#222222" } }, null, 2))
|
|
await Bun.write(
|
|
tmp.extra.pluginPath,
|
|
`export default {
|
|
id: "demo.theme-update",
|
|
tui: async (api, options) => {
|
|
if (!options?.theme_path) return
|
|
await api.theme.install(options.theme_path)
|
|
},
|
|
}
|
|
// v2
|
|
`,
|
|
)
|
|
const stamp = new Date(Date.now() + 10_000)
|
|
await fs.utimes(tmp.extra.pluginPath, stamp, stamp)
|
|
await fs.utimes(tmp.extra.themePath, stamp, stamp)
|
|
|
|
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")
|
|
const list = await Filesystem.readJson<Record<string, { themes?: Record<string, { dest: string }> }>>(
|
|
process.env.OPENCODE_PLUGIN_META_FILE!,
|
|
)
|
|
expect(list["demo.theme-update"]?.themes?.[tmp.extra.themeName]?.dest).toBe(tmp.extra.dest)
|
|
} finally {
|
|
await TuiPluginRuntime.dispose()
|
|
cwd.mockRestore()
|
|
wait.mockRestore()
|
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
|
}
|
|
})
|