Files
anomalyco_opencode/packages/opencode/test/cli/tui/plugin-loader.test.ts
T
Sebastian Herrlinger ff2800e0e0 refactor
2026-03-09 22:33:17 +01:00

483 lines
16 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", () => ({
createSolidSlotRegistry: () => ({
register: () => () => {},
}),
createSlot: () => () => null,
useRenderer: () => ({
getPalette: async () => ({ palette: [] as string[] }),
clearPaletteCache: () => {},
}),
}))
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")
const { PluginMeta } = await import("../../../src/plugin/meta")
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 localSpec = pathToFileURL(localPluginPath).href
const preloadedSpec = pathToFileURL(preloadedPluginPath).href
const globalSpec = pathToFileURL(globalPluginPath).href
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, init) => {
if (!options?.marker) return
const key = input.api.keybind.create(
{ modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" },
options.keybinds,
)
const depth_before = input.api.ui.dialog.depth
const open_before = input.api.ui.dialog.open
const size_before = input.api.ui.dialog.size
input.api.ui.dialog.setSize("large")
const size_after = input.api.ui.dialog.size
input.api.ui.dialog.replace(() => null)
const depth_after = input.api.ui.dialog.depth
const open_after = input.api.ui.dialog.open
input.api.ui.dialog.clear()
const open_clear = input.api.ui.dialog.open
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()
const init_state = init.state
const init_source = init.entry.source
const init_load_count = init.entry.load_count
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"),
depth_before,
open_before,
size_before,
size_after,
depth_after,
open_after,
open_clear,
init_state,
init_source,
init_load_count,
}),
)
},
}
`,
)
await Bun.write(
preloadedPluginPath,
`export default {
tui: async (input, options, init) => {
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,
init_state: init.state,
init_source: init.entry.source,
init_load_count: init.entry.load_count,
}),
)
},
}
`,
)
await Bun.write(
globalPluginPath,
`export default {
tui: async (input, options, init) => {
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,
init_state: init.state,
init_source: init.entry.source,
init_load_count: init.entry.load_count,
}),
)
},
}
`,
)
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,
keybinds: {
modal: "ctrl+alt+m",
close: "q",
},
},
],
[
preloadedSpec,
{
marker: preloadedMarker,
dest: preloadedDest,
theme_path: `./${preloadedThemeFile}`,
theme_name: preloadedThemeName,
},
],
],
},
null,
2,
),
)
return {
localThemeFile,
globalThemeFile,
preloadedThemeFile,
localThemeName,
globalThemeName,
preloadedThemeName,
localDest,
globalDest,
preloadedDest,
localPluginPath,
globalPluginPath,
preloadedPluginPath,
localSpec,
globalSpec,
preloadedSpec,
fnMarker,
localMarker,
globalMarker,
preloadedMarker,
}
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
if (!process.env.OPENCODE_PLUGIN_META_FILE) throw new Error("missing meta file")
await PluginMeta.touch(tmp.extra.localSpec, tmp.extra.localSpec)
await PluginMeta.touch(tmp.extra.globalSpec, tmp.extra.globalSpec)
await PluginMeta.persist()
await Bun.sleep(20)
const text = await Bun.file(tmp.extra.globalPluginPath).text()
await Bun.write(tmp.extra.globalPluginPath, `${text}\n`)
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
let selected = "opencode"
let depth = 0
let size: "medium" | "large" = "medium"
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: () => {},
dialog: {
replace: () => {
depth = 1
},
clear: () => {
depth = 0
size = "medium"
},
setSize: (next) => {
size = next
},
get size() {
return size
},
get depth() {
return depth
},
get open() {
return depth > 0
},
},
},
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")
expect(local.depth_before).toBe(0)
expect(local.open_before).toBe(false)
expect(local.size_before).toBe("medium")
expect(local.size_after).toBe("large")
expect(local.depth_after).toBe(1)
expect(local.open_after).toBe(true)
expect(local.open_clear).toBe(false)
expect(local.init_state).toBe("same")
expect(local.init_source).toBe("file")
expect(local.init_load_count).toBe(2)
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)
expect(global.init_state).toBe("updated")
expect(global.init_source).toBe("file")
expect(global.init_load_count).toBe(2)
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")
expect(preloaded.init_state).toBe("first")
expect(preloaded.init_source).toBe("file")
expect(preloaded.init_load_count).toBe(1)
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,
{ name: string; load_count: number }
>
const rows = Object.values(meta)
expect(rows.find((item) => item.name === "local-plugin")?.load_count).toBe(2)
expect(rows.find((item) => item.name === "global-plugin")?.load_count).toBe(2)
expect(rows.find((item) => item.name === "preloaded-plugin")?.load_count).toBe(1)
} 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
}
})