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/config/tui" import { Filesystem } from "@/util/filesystem" import { PluginLoader } from "../../../src/plugin/loader" const { allThemes, addTheme } = await import("@opencode-ai/tui/context/theme") const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime") type Row = Record 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 { return Filesystem.readJson(file) } async function load(): Promise { 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 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, 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 }] }, }, 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.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[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[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[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[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 }>>( 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 } })