Files
anomalyco_opencode/packages/opencode/test/cli/tui/plugin-loader.test.ts
T
Sebastian Herrlinger 3341dba46e plugin meta
2026-03-09 22:33:16 +01:00

379 lines
12 KiB
TypeScript

import { expect, mock, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { CliRenderer } from "@opentui/core"
import { tmpdir } from "../../fixture/fixture"
import { Log } from "../../../src/util/log"
import { Global } from "../../../src/global"
import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/keybind-plugin"
mock.module("@opentui/solid/preload", () => ({}))
mock.module("@opentui/solid/jsx-runtime", () => ({
Fragment: Symbol.for("Fragment"),
jsx: () => null,
jsxs: () => null,
jsxDEV: () => null,
}))
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
const { TuiPlugin } = await import("../../../src/cli/cmd/tui/plugin")
async function waitForLog(text: string, timeout = 1000) {
const start = Date.now()
while (Date.now() - start < timeout) {
const file = Log.file()
if (file) {
const content = await Bun.file(file)
.text()
.catch(() => "")
if (content.includes(text)) return content
}
await Bun.sleep(25)
}
return Bun.file(Log.file())
.text()
.catch(() => "")
}
test("loads plugin theme and keybind APIs with scoped theme installation", async () => {
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 preloadedPluginPath = path.join(dir, "preloaded-plugin.ts")
const globalPluginPath = path.join(dir, "global-plugin.ts")
const localThemeFile = `local-theme-${stamp}.json`
const globalThemeFile = `global-theme-${stamp}.json`
const preloadedThemeFile = `preloaded-theme-${stamp}.json`
const localThemeName = localThemeFile.replace(/\.json$/, "")
const globalThemeName = globalThemeFile.replace(/\.json$/, "")
const preloadedThemeName = preloadedThemeFile.replace(/\.json$/, "")
const localThemePath = path.join(dir, localThemeFile)
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 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(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 default async (_input, options) => {
if (!options?.fn_marker) return
await Bun.write(options.fn_marker, "called")
}
export const object_plugin = {
tui: async (input, options) => {
if (!options?.marker) return
const key = input.api.keybind.create(
{ modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" },
options.keybinds,
)
const before = input.api.theme.has(options.theme_name)
const set_missing = input.api.theme.set(options.theme_name)
await input.api.theme.install(options.theme_path)
const after = input.api.theme.has(options.theme_name)
const set_installed = input.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 input.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: input.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"),
}),
)
},
}
`,
)
await Bun.write(
preloadedPluginPath,
`export default {
tui: async (input, options) => {
if (!options?.marker) return
const before = input.api.theme.has(options.theme_name)
await input.api.theme.install(options.theme_path)
const after = input.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 {
tui: async (input, options) => {
if (!options?.marker) return
await input.api.theme.install(options.theme_path)
const has = input.api.theme.has(options.theme_name)
const set_installed = input.api.theme.set(options.theme_name)
await Bun.write(options.marker, JSON.stringify({ has, set_installed, selected: input.api.theme.selected }))
},
}
`,
)
await Bun.write(
globalConfigPath,
JSON.stringify(
{
plugin: [
[
pathToFileURL(globalPluginPath).href,
{ marker: globalMarker, theme_path: `./${globalThemeFile}`, theme_name: globalThemeName },
],
],
},
null,
2,
),
)
await Bun.write(
localConfigPath,
JSON.stringify(
{
plugin: [
[
pathToFileURL(localPluginPath).href,
{
fn_marker: fnMarker,
marker: localMarker,
source: localThemePath,
dest: localDest,
theme_path: `./${localThemeFile}`,
theme_name: localThemeName,
keybinds: {
modal: "ctrl+alt+m",
close: "q",
},
},
],
[
pathToFileURL(preloadedPluginPath).href,
{
marker: preloadedMarker,
dest: preloadedDest,
theme_path: `./${preloadedThemeFile}`,
theme_name: preloadedThemeName,
},
],
],
},
null,
2,
),
)
return {
localThemeFile,
globalThemeFile,
preloadedThemeFile,
localThemeName,
globalThemeName,
preloadedThemeName,
localDest,
globalDest,
preloadedDest,
fnMarker,
localMarker,
globalMarker,
preloadedMarker,
localPluginPath,
}
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
let selected = "opencode"
const renderer = {
...Object.create(null),
once(this: CliRenderer) {
return this
},
} satisfies CliRenderer
const keybind = {
parse: (evt: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean; super?: boolean }) => ({
name: evt.name ?? "",
ctrl: evt.ctrl ?? false,
meta: evt.meta ?? false,
shift: evt.shift ?? false,
super: evt.super,
leader: false,
}),
match: () => false,
print: (key: string) => `print:${key}`,
}
try {
expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
await TuiPlugin.init({
client: createOpencodeClient({
baseUrl: "http://localhost:4096",
}),
event: {
on: () => () => {},
},
renderer,
api: {
command: {
register: () => {},
trigger: () => {},
},
route: {
register: () => () => {},
navigate: () => {},
get current() {
return { name: "home" as const }
},
},
ui: {
Dialog: () => null,
DialogAlert: () => null,
DialogConfirm: () => null,
DialogPrompt: () => null,
DialogSelect: () => null,
toast: () => {},
},
keybind: {
...keybind,
create(defaults, overrides) {
return createPluginKeybind(keybind, defaults, overrides)
},
},
theme: {
get current() {
return {}
},
get selected() {
return selected
},
has(name) {
return allThemes()[name] !== undefined
},
set(name) {
if (!allThemes()[name]) return false
selected = name
return true
},
async install() {
throw new Error("base theme.install should not run")
},
mode() {
return "dark" as const
},
get ready() {
return true
},
},
},
})
const local = JSON.parse(await fs.readFile(tmp.extra.localMarker, "utf8"))
expect(local.before).toBe(false)
expect(local.set_missing).toBe(false)
expect(local.after).toBe(true)
expect(local.set_installed).toBe(true)
expect(local.selected).toBe(tmp.extra.localThemeName)
expect(local.same).toBe(true)
expect(local.key_modal).toBe("ctrl+alt+m")
expect(local.key_close).toBe("q")
expect(local.key_unknown).toBe("ctrl+k")
expect(local.key_print).toBe("print:ctrl+alt+m")
const global = JSON.parse(await fs.readFile(tmp.extra.globalMarker, "utf8"))
expect(global.has).toBe(true)
expect(global.set_installed).toBe(true)
expect(global.selected).toBe(tmp.extra.globalThemeName)
const preloaded = JSON.parse(await fs.readFile(tmp.extra.preloadedMarker, "utf8"))
expect(preloaded.before).toBe(true)
expect(preloaded.after).toBe(true)
expect(preloaded.text).toContain("#303030")
expect(preloaded.text).not.toContain("#f0f0f0")
await expect(fs.readFile(tmp.extra.fnMarker, "utf8")).rejects.toThrow()
const localInstalled = await fs.readFile(tmp.extra.localDest, "utf8")
expect(localInstalled).toContain("#101010")
expect(localInstalled).not.toContain("#fefefe")
const globalInstalled = await fs.readFile(tmp.extra.globalDest, "utf8")
expect(globalInstalled).toContain("#202020")
const preloadedInstalled = await fs.readFile(tmp.extra.preloadedDest, "utf8")
expect(preloadedInstalled).toContain("#303030")
expect(preloadedInstalled).not.toContain("#f0f0f0")
expect(
await fs
.stat(path.join(Global.Path.config, "themes", tmp.extra.localThemeFile))
.then(() => true)
.catch(() => false),
).toBe(false)
expect(
await fs
.stat(path.join(tmp.path, ".opencode", "themes", tmp.extra.globalThemeFile))
.then(() => true)
.catch(() => false),
).toBe(false)
const log = await waitForLog("ignoring non-object tui plugin export")
expect(log).toContain("ignoring non-object tui plugin export")
expect(log).toContain("name=default")
expect(log).toContain("type=function")
const meta = JSON.parse(await fs.readFile(path.join(tmp.path, "plugin-meta.json"), "utf8")) as Record<
string,
{ spec: string; source: string; load_count: number }
>
const localSpec = pathToFileURL(tmp.extra.localPluginPath).href
const localRow = Object.values(meta).find((item) => item.spec === localSpec)
expect(localRow).toBeDefined()
expect(localRow?.source).toBe("file")
expect((localRow?.load_count ?? 0) > 0).toBe(true)
} finally {
cwd.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(() => {})
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})