483 lines
16 KiB
TypeScript
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
|
|
}
|
|
})
|