mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 19:55:11 +00:00
1332 lines
42 KiB
TypeScript
1332 lines
42 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 { createTestKeymap } from "@opentui/keymap/testing"
|
|
import type { TuiAttentionSoundPack } from "@opencode-ai/plugin/tui"
|
|
import { tmpdir } from "../../fixture/fixture"
|
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
|
import { createTuiResolvedConfig, mockTuiRuntime } from "../../fixture/tui-runtime"
|
|
import { Global } from "@opencode-ai/core/global"
|
|
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
|
import { Filesystem } from "@/util/filesystem"
|
|
import { PluginLoader } from "../../../src/plugin/loader"
|
|
|
|
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>
|
|
|
|
test("does not retry permanent file plugin load errors", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const file = path.join(dir, "binary-plugin")
|
|
await Bun.write(file, new Uint8Array([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00, 0x00, 0x01]))
|
|
return { spec: pathToFileURL(file).href }
|
|
},
|
|
})
|
|
|
|
let waited = false
|
|
const calls: Array<["start" | "error", boolean, string?]> = []
|
|
const plugins = await PluginLoader.loadExternal({
|
|
items: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
|
|
kind: "tui",
|
|
wait: async () => {
|
|
waited = true
|
|
},
|
|
report: {
|
|
start(_candidate, retry) {
|
|
calls.push(["start", retry])
|
|
},
|
|
error(_candidate, retry, stage) {
|
|
calls.push(["error", retry, stage])
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(plugins).toEqual([])
|
|
expect(waited).toBe(false)
|
|
expect(calls).toEqual([
|
|
["start", false],
|
|
["error", false, "load"],
|
|
])
|
|
})
|
|
|
|
test("does not retry file plugin load errors caused by missing modules", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const file = path.join(dir, "missing-dependency-plugin.ts")
|
|
const dep = path.join(dir, "dep.ts")
|
|
await Bun.write(
|
|
file,
|
|
`import value from "./dep"
|
|
export default { id: "demo.retry.load", tui: async () => {}, value }
|
|
`,
|
|
)
|
|
return { spec: pathToFileURL(file).href, dep }
|
|
},
|
|
})
|
|
|
|
let waited = false
|
|
const calls: Array<["start" | "error", boolean, string?]> = []
|
|
const plugins = await PluginLoader.loadExternal({
|
|
items: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
|
|
kind: "tui",
|
|
wait: async () => {
|
|
waited = true
|
|
await Bun.write(tmp.extra.dep, `export default "ready"\n`)
|
|
},
|
|
finish: async (loaded, _origin, retry) => ({
|
|
retry,
|
|
value: (loaded.mod.default as { value: string }).value,
|
|
}),
|
|
report: {
|
|
start(_candidate, retry) {
|
|
calls.push(["start", retry])
|
|
},
|
|
error(_candidate, retry, stage) {
|
|
calls.push(["error", retry, stage])
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(waited).toBe(false)
|
|
expect(calls).toEqual([
|
|
["start", false],
|
|
["error", false, "load"],
|
|
])
|
|
expect(plugins).toEqual([])
|
|
})
|
|
|
|
test("does not retry top-level plugin errors that look like resolver messages", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const file = path.join(dir, "throwing-plugin.ts")
|
|
await Bun.write(file, `throw new Error("Cannot find package intentional")\n`)
|
|
return { spec: pathToFileURL(file).href }
|
|
},
|
|
})
|
|
|
|
let waited = false
|
|
const plugins = await PluginLoader.loadExternal({
|
|
items: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
|
|
kind: "tui",
|
|
wait: async () => {
|
|
waited = true
|
|
},
|
|
})
|
|
|
|
expect(plugins).toEqual([])
|
|
expect(waited).toBe(false)
|
|
})
|
|
|
|
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,
|
|
`import { createBindingLookup } from "@opentui/keymap/extras"
|
|
import { useBindings } from "@opentui/keymap/solid"
|
|
|
|
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 has_keys = typeof api.keys.formatBindings === "function"
|
|
const keybinds = createBindingLookup(options.keybinds ?? {
|
|
"plugin.loader.local": "ctrl+shift+m",
|
|
"plugin.loader.close": "escape",
|
|
})
|
|
const bindings = keybinds.gather("plugin.loader", ["plugin.loader.local", "plugin.loader.close"])
|
|
const key_modal = bindings.find((item) => item.cmd === "plugin.loader.local")?.key
|
|
const key_close = bindings.find((item) => item.cmd === "plugin.loader.close")?.key
|
|
const key_unknown = "ctrl+k"
|
|
const off = api.keymap.registerLayer({
|
|
commands: [{ name: "plugin.loader.local", run() {} }, { name: "plugin.loader.close", run() {} }],
|
|
bindings,
|
|
})
|
|
off()
|
|
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_close,
|
|
key_unknown,
|
|
has_keys,
|
|
has_keymap: typeof api.keymap.registerLayer === "function",
|
|
has_create_binding_lookup: typeof createBindingLookup === "function",
|
|
has_keymap_solid: typeof useBindings === "function",
|
|
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,
|
|
}),
|
|
)
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
|
|
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: {
|
|
"plugin.loader.local": "ctrl+alt+m",
|
|
"plugin.loader.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 = createTuiResolvedConfig({
|
|
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 },
|
|
},
|
|
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 = createTuiResolvedConfig({
|
|
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("does not wait on permanent tui plugin startup failures", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const binary = path.join(dir, "binary-plugin")
|
|
const invalidShape = path.join(dir, "invalid-shape-plugin.ts")
|
|
const missingID = path.join(dir, "missing-id-plugin.ts")
|
|
const good = path.join(dir, "good-plugin.ts")
|
|
const marker = path.join(dir, "good-called.txt")
|
|
|
|
await Bun.write(binary, new Uint8Array([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00, 0x00, 0x01]))
|
|
await Bun.write(invalidShape, `export default { id: "demo.invalid.shape" }\n`)
|
|
await Bun.write(missingID, `export default { tui: async () => {} }\n`)
|
|
await Bun.write(
|
|
good,
|
|
`export default {
|
|
id: "demo.good.after-bad",
|
|
tui: async () => {
|
|
await Bun.write(${JSON.stringify(marker)}, "called")
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
|
|
return {
|
|
binarySpec: pathToFileURL(binary).href,
|
|
invalidShapeSpec: pathToFileURL(invalidShape).href,
|
|
missingIDSpec: pathToFileURL(missingID).href,
|
|
goodSpec: pathToFileURL(good).href,
|
|
marker,
|
|
}
|
|
},
|
|
})
|
|
|
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
|
|
|
try {
|
|
await TuiPluginRuntime.init({
|
|
api: createTuiPluginApi(),
|
|
config: createTuiResolvedConfig({
|
|
plugin: [tmp.extra.binarySpec, tmp.extra.invalidShapeSpec, tmp.extra.missingIDSpec, tmp.extra.goodSpec],
|
|
plugin_origins: [
|
|
{ spec: tmp.extra.binarySpec, scope: "local", source: path.join(tmp.path, "tui.json") },
|
|
{ spec: tmp.extra.invalidShapeSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
|
|
{ spec: tmp.extra.missingIDSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
|
|
{ spec: tmp.extra.goodSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
|
|
],
|
|
}),
|
|
})
|
|
|
|
expect(wait).toHaveBeenCalledTimes(0)
|
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
|
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.good.after-bad")?.active).toBe(true)
|
|
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.binarySpec)).toBe(false)
|
|
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.invalidShapeSpec)).toBe(false)
|
|
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.missingIDSpec)).toBe(false)
|
|
} 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 = createTuiResolvedConfig({
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
|
|
test("does not bootstrap server plugins while initializing tui plugins", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const marker = path.join(dir, "server-plugin-called.txt")
|
|
const plugin = path.join(dir, "server-plugin.ts")
|
|
await Bun.write(
|
|
plugin,
|
|
[
|
|
"export default async () => {",
|
|
` await Bun.write(${JSON.stringify(marker)}, "called")`,
|
|
" return {}",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [pathToFileURL(plugin).href] }))
|
|
return { marker }
|
|
},
|
|
})
|
|
|
|
const mock = mockTuiRuntime(tmp.path, [])
|
|
try {
|
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config: mock.config })
|
|
await expect(fs.stat(tmp.extra.marker)).rejects.toThrow()
|
|
} finally {
|
|
await TuiPluginRuntime.dispose()
|
|
mock.restore()
|
|
}
|
|
})
|
|
|
|
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.has_keys).toBe(true)
|
|
expect(data.local.has_keymap).toBe(true)
|
|
expect(data.local.has_create_binding_lookup).toBe(true)
|
|
expect(data.local.has_keymap_solid).toBe(true)
|
|
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)
|
|
})
|
|
|
|
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("auto-disposes plugin keymap layers", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const file = path.join(dir, "keymap-cleanup-plugin.ts")
|
|
const spec = pathToFileURL(file).href
|
|
|
|
await Bun.write(
|
|
file,
|
|
`export default {
|
|
id: "demo.keymap.cleanup",
|
|
tui: async (api) => {
|
|
api.keymap.registerLayer({
|
|
commands: [{ name: "demo.keymap.cleanup", run() {} }],
|
|
bindings: [{ key: "ctrl+g", cmd: "demo.keymap.cleanup" }],
|
|
})
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
|
|
return { spec }
|
|
},
|
|
})
|
|
|
|
let command_add = 0
|
|
let command_drop = 0
|
|
const keymap = {
|
|
registerLayer(layer: { commands?: Array<{ name: string }> }) {
|
|
const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup") ?? false
|
|
if (tracked) command_add += 1
|
|
return () => {
|
|
if (!tracked) return
|
|
command_drop += 1
|
|
}
|
|
},
|
|
} as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
|
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
|
|
|
try {
|
|
await TuiPluginRuntime.init({
|
|
api: createTuiPluginApi({ keymap }),
|
|
config: createTuiResolvedConfig({
|
|
plugin: [tmp.extra.spec],
|
|
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
|
|
}),
|
|
})
|
|
|
|
expect(command_add).toBe(1)
|
|
expect(command_drop).toBe(0)
|
|
} finally {
|
|
await TuiPluginRuntime.dispose()
|
|
expect(command_drop).toBe(1)
|
|
cwd.mockRestore()
|
|
wait.mockRestore()
|
|
}
|
|
})
|
|
|
|
test("plugin keymap proxy preserves real keymap receiver", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const file = path.join(dir, "keymap-receiver-plugin.ts")
|
|
const spec = pathToFileURL(file).href
|
|
const marker = path.join(dir, "keymap-receiver.txt")
|
|
|
|
await Bun.write(
|
|
file,
|
|
`export default {
|
|
id: "demo.keymap.receiver",
|
|
tui: async (api) => {
|
|
api.keymap.setData("demo.receiver", "ok")
|
|
await Bun.write(${JSON.stringify(marker)}, String(api.keymap.getData("demo.receiver")))
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
|
|
return { spec, marker }
|
|
},
|
|
})
|
|
|
|
const harness = createTestKeymap({ defaultKeys: true })
|
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
|
|
|
try {
|
|
await TuiPluginRuntime.init({
|
|
api: createTuiPluginApi({
|
|
keymap: harness.keymap as unknown as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"],
|
|
}),
|
|
config: createTuiResolvedConfig({
|
|
plugin: [tmp.extra.spec],
|
|
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
|
|
}),
|
|
})
|
|
|
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("ok")
|
|
expect(harness.keymap.getData("demo.receiver")).toBe("ok")
|
|
} finally {
|
|
await TuiPluginRuntime.dispose()
|
|
harness.cleanup()
|
|
cwd.mockRestore()
|
|
wait.mockRestore()
|
|
}
|
|
})
|
|
|
|
test("auto-disposes plugin attention sound packs and resolves sound paths", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const file = path.join(dir, "attention-soundpack-plugin.ts")
|
|
const spec = pathToFileURL(file).href
|
|
const absolute = path.join(dir, "sounds", "default.mp3")
|
|
const url = pathToFileURL(path.join(dir, "sounds", "error.mp3")).href
|
|
|
|
await Bun.write(
|
|
file,
|
|
`export default {
|
|
id: "demo.attention.soundpack",
|
|
tui: async (api) => {
|
|
api.attention.soundboard.registerPack({
|
|
id: "demo.pack",
|
|
sounds: {
|
|
default: ${JSON.stringify(absolute)},
|
|
question: "sounds/question.mp3",
|
|
done: " sounds/done.mp3 ",
|
|
subagent_done: "sounds/subagent-done.mp3",
|
|
error: ${JSON.stringify(url)},
|
|
nope: "sounds/nope.mp3",
|
|
permission: "",
|
|
},
|
|
})
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
|
|
return { spec }
|
|
},
|
|
})
|
|
|
|
const packs: TuiAttentionSoundPack[] = []
|
|
let dropped = 0
|
|
const attention = {
|
|
soundboard: {
|
|
registerPack(pack: TuiAttentionSoundPack) {
|
|
packs.push(pack)
|
|
return () => {
|
|
dropped += 1
|
|
}
|
|
},
|
|
},
|
|
}
|
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
|
|
|
try {
|
|
await TuiPluginRuntime.init({
|
|
api: createTuiPluginApi({ attention }),
|
|
config: createTuiResolvedConfig({
|
|
plugin: [tmp.extra.spec],
|
|
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
|
|
}),
|
|
})
|
|
|
|
expect(packs).toEqual([
|
|
{
|
|
id: "demo.pack",
|
|
sounds: {
|
|
default: path.join(tmp.path, "sounds", "default.mp3"),
|
|
question: path.join(tmp.path, "sounds", "question.mp3"),
|
|
done: path.join(tmp.path, "sounds", "done.mp3"),
|
|
subagent_done: path.join(tmp.path, "sounds", "subagent-done.mp3"),
|
|
error: path.join(tmp.path, "sounds", "error.mp3"),
|
|
},
|
|
},
|
|
])
|
|
expect(dropped).toBe(0)
|
|
} finally {
|
|
await TuiPluginRuntime.dispose()
|
|
expect(dropped).toBe(1)
|
|
cwd.mockRestore()
|
|
wait.mockRestore()
|
|
}
|
|
})
|
|
|
|
test("auto-disposes plugin keymap transformers", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const file = path.join(dir, "keymap-transformer-cleanup-plugin.ts")
|
|
const spec = pathToFileURL(file).href
|
|
|
|
await Bun.write(
|
|
file,
|
|
`export default {
|
|
id: "demo.keymap.transformer.cleanup",
|
|
tui: async (api) => {
|
|
api.keymap.prependLayerBindingsTransformer((bindings) => bindings)
|
|
api.keymap.appendLayerBindingsTransformer((bindings) => bindings)
|
|
api.keymap.prependCommandTransformer(() => {})
|
|
api.keymap.appendCommandTransformer(() => {})
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
|
|
return { spec }
|
|
},
|
|
})
|
|
|
|
let add = 0
|
|
let drop = 0
|
|
const track = () => {
|
|
add += 1
|
|
return () => {
|
|
drop += 1
|
|
}
|
|
}
|
|
const keymap = {
|
|
registerLayer: () => () => {},
|
|
prependLayerBindingsTransformer: track,
|
|
appendLayerBindingsTransformer: track,
|
|
prependCommandTransformer: track,
|
|
appendCommandTransformer: track,
|
|
} as unknown as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
|
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
|
|
|
try {
|
|
await TuiPluginRuntime.init({
|
|
api: createTuiPluginApi({ keymap }),
|
|
config: createTuiResolvedConfig({
|
|
plugin: [tmp.extra.spec],
|
|
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
|
|
}),
|
|
})
|
|
|
|
expect(add).toBe(4)
|
|
expect(drop).toBe(0)
|
|
} finally {
|
|
await TuiPluginRuntime.dispose()
|
|
expect(drop).toBe(4)
|
|
cwd.mockRestore()
|
|
wait.mockRestore()
|
|
}
|
|
})
|
|
|
|
test("manual onDispose for plugin keymap layers stays idempotent", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const file = path.join(dir, "keymap-cleanup-manual-plugin.ts")
|
|
const spec = pathToFileURL(file).href
|
|
|
|
await Bun.write(
|
|
file,
|
|
`export default {
|
|
id: "demo.keymap.cleanup.manual",
|
|
tui: async (api) => {
|
|
const off = api.keymap.registerLayer({
|
|
commands: [{ name: "demo.keymap.cleanup.manual", run() {} }],
|
|
bindings: [{ key: "ctrl+h", cmd: "demo.keymap.cleanup.manual" }],
|
|
})
|
|
api.lifecycle.onDispose(off)
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
|
|
return { spec }
|
|
},
|
|
})
|
|
|
|
let command_drop = 0
|
|
const keymap = {
|
|
registerLayer(layer: { commands?: Array<{ name: string }> }) {
|
|
const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup.manual") ?? false
|
|
return () => {
|
|
if (!tracked) return
|
|
command_drop += 1
|
|
}
|
|
},
|
|
} as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
|
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
|
|
|
try {
|
|
await TuiPluginRuntime.init({
|
|
api: createTuiPluginApi({ keymap }),
|
|
config: createTuiResolvedConfig({
|
|
plugin: [tmp.extra.spec],
|
|
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
|
|
}),
|
|
})
|
|
} finally {
|
|
await TuiPluginRuntime.dispose()
|
|
expect(command_drop).toBe(1)
|
|
cwd.mockRestore()
|
|
wait.mockRestore()
|
|
}
|
|
})
|
|
|
|
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 = () =>
|
|
createTuiResolvedConfig({
|
|
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
|
|
}
|
|
})
|