This commit is contained in:
Sebastian Herrlinger
2026-03-24 21:29:42 +01:00
parent b7a06e1939
commit e89f5084d3
73 changed files with 7968 additions and 641 deletions
@@ -0,0 +1,134 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Process } from "../../src/util/process"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
const root = path.join(import.meta.dir, "../..")
const worker = path.join(import.meta.dir, "../fixture/plug-worker.ts")
type Msg = {
dir: string
target: string
mod: string
holdMs?: number
}
function run(msg: Msg) {
return Process.run([process.execPath, worker, JSON.stringify(msg)], {
cwd: root,
nothrow: true,
})
}
async function plugin(dir: string, kinds: Array<"server" | "tui">) {
const p = path.join(dir, "plugin")
await fs.mkdir(p, { recursive: true })
await Bun.write(
path.join(p, "package.json"),
JSON.stringify(
{
name: "acme",
version: "1.0.0",
"oc-plugin": kinds,
},
null,
2,
),
)
return p
}
async function read(file: string) {
return Filesystem.readJson<{ plugin?: unknown[] }>(file)
}
function mods(prefix: string, n: number) {
return Array.from({ length: n }, (_, i) => `${prefix}-${i}@1.0.0`)
}
function expectPlugins(list: unknown[] | undefined, expectMods: string[]) {
expect(Array.isArray(list)).toBe(true)
const hit = (list ?? []).filter((item): item is string => typeof item === "string")
expect(hit.length).toBe(expectMods.length)
expect(new Set(hit)).toEqual(new Set(expectMods))
}
describe("cli.plug.concurrent", () => {
test("serializes concurrent server config updates across processes", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const all = mods("mod-server", 12)
const out = await Promise.all(
all.map((mod) =>
run({
dir: tmp.path,
target,
mod,
holdMs: 30,
}),
),
)
expect(out.map((x) => x.code)).toEqual(Array.from({ length: all.length }, () => 0))
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
const cfg = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
expectPlugins(cfg.plugin, all)
}, 25_000)
test("serializes concurrent server+tui config updates across processes", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server", "tui"])
const all = mods("mod-both", 10)
const out = await Promise.all(
all.map((mod) =>
run({
dir: tmp.path,
target,
mod,
holdMs: 30,
}),
),
)
expect(out.map((x) => x.code)).toEqual(Array.from({ length: all.length }, () => 0))
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
expectPlugins(server.plugin, all)
expectPlugins(tui.plugin, all)
}, 25_000)
test("preserves updates when existing config uses .json", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(cfg, JSON.stringify({ plugin: ["seed@1.0.0"] }, null, 2))
const next = mods("mod-json", 8)
const out = await Promise.all(
next.map((mod) =>
run({
dir: tmp.path,
target,
mod,
holdMs: 30,
}),
),
)
expect(out.map((x) => x.code)).toEqual(Array.from({ length: next.length }, () => 0))
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
const json = await read(cfg)
expectPlugins(json.plugin, ["seed@1.0.0", ...next])
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
}, 25_000)
})
@@ -0,0 +1,351 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Filesystem } from "../../src/util/filesystem"
import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug"
import { tmpdir } from "../fixture/fixture"
function deps(global: string, target: string | Error): PlugDeps {
return {
spinner: () => ({
start() {},
stop() {},
}),
log: {
error() {},
info() {},
success() {},
},
mkdir: async (dir, opts) => {
await fs.mkdir(dir, opts)
},
resolve: async () => {
if (target instanceof Error) throw target
return target
},
stat: Filesystem.stat,
readJson: (file) => Filesystem.readJson(file),
readText: (file) => Filesystem.readText(file),
write: async (file, text) => {
await Filesystem.write(file, text)
},
exists: (file) => Filesystem.exists(file),
files: (dir, name) => [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)],
global,
}
}
function ctx(dir: string): PlugCtx {
return {
vcs: "git",
worktree: dir,
directory: dir,
}
}
function ctxDir(dir: string, worktree: string): PlugCtx {
return {
vcs: "none",
worktree,
directory: dir,
}
}
async function plugin(dir: string, kinds?: unknown) {
const p = path.join(dir, "plugin")
await fs.mkdir(p, { recursive: true })
await Bun.write(
path.join(p, "package.json"),
JSON.stringify(
{
name: "acme",
version: "1.0.0",
...(kinds === undefined ? {} : { "oc-plugin": kinds }),
},
null,
2,
),
)
return p
}
async function read(file: string) {
return Filesystem.readJson<{
plugin?: unknown[]
}>(file)
}
describe("cli.plug.task", () => {
test("writes both server and tui config entries", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server", "tui"])
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
expect(server.plugin).toEqual(["acme@1.2.3"])
expect(tui.plugin).toEqual(["acme@1.2.3"])
})
test("supports resolver target pointing to a file", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const file = path.join(target, "index.js")
await Bun.write(file, "export {}")
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), file),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
expect(server.plugin).toEqual(["acme@1.2.3"])
})
test("does not change configured package version without force", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(cfg, JSON.stringify({ plugin: ["acme@1.0.0"] }, null, 2))
const run = createPlugTask(
{
mod: "acme@2.0.0",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const json = await read(cfg)
expect(json.plugin).toEqual(["acme@1.0.0"])
})
test("does not change scoped package version without force", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(cfg, JSON.stringify({ plugin: ["@scope/acme@1.0.0"] }, null, 2))
const run = createPlugTask(
{
mod: "@scope/acme@2.0.0",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const json = await read(cfg)
expect(json.plugin).toEqual(["@scope/acme@1.0.0"])
})
test("keeps file plugin entries and still adds npm plugin", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(cfg, JSON.stringify({ plugin: ["file:///tmp/acme.ts"] }, null, 2))
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const json = await read(cfg)
expect(json.plugin).toEqual(["file:///tmp/acme.ts", "acme@1.2.3"])
})
test("force replaces configured package version and keeps tuple options", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(
cfg,
JSON.stringify(
{
plugin: [["acme@1.0.0", { mode: "safe" }], "acme@1.1.0", "other@1.0.0"],
},
null,
2,
),
)
const run = createPlugTask(
{
mod: "acme@2.0.0",
force: true,
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const json = await read(cfg)
expect(json.plugin).toEqual([["acme@2.0.0", { mode: "safe" }], "other@1.0.0"])
})
test("writes to global scope when global flag is set", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const global = path.join(tmp.path, "global")
const run = createPlugTask(
{
mod: "acme@1.2.3",
global: true,
},
deps(global, target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
expect(await Filesystem.exists(path.join(global, "opencode.jsonc"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
})
test("writes local scope under directory when vcs is not git", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const directory = path.join(tmp.path, "dir")
const worktree = path.join(tmp.path, "worktree")
await fs.mkdir(directory, { recursive: true })
await fs.mkdir(worktree, { recursive: true })
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctxDir(directory, worktree))
expect(ok).toBe(true)
expect(await Filesystem.exists(path.join(directory, ".opencode", "opencode.jsonc"))).toBe(true)
expect(await Filesystem.exists(path.join(worktree, ".opencode", "opencode.jsonc"))).toBe(false)
})
test("writes only tui config for tui-only plugins", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["tui"])
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
})
test("force replaces version in both server and tui configs", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server", "tui"])
const server = path.join(tmp.path, ".opencode", "opencode.json")
const tui = path.join(tmp.path, ".opencode", "tui.json")
await fs.mkdir(path.dirname(server), { recursive: true })
await Bun.write(server, JSON.stringify({ plugin: ["acme@1.0.0", "other@1.0.0"] }, null, 2))
await Bun.write(tui, JSON.stringify({ plugin: [["acme@1.0.0", { mode: "safe" }], "other@1.0.0"] }, null, 2))
const run = createPlugTask(
{
mod: "acme@2.0.0",
force: true,
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const serverJson = await read(server)
const tuiJson = await read(tui)
expect(serverJson.plugin).toEqual(["acme@2.0.0", "other@1.0.0"])
expect(tuiJson.plugin).toEqual([["acme@2.0.0", { mode: "safe" }], "other@1.0.0"])
})
test("returns false and keeps config unchanged for invalid JSONC", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.jsonc")
await fs.mkdir(path.dirname(cfg), { recursive: true })
const bad = '{"plugin": ["acme@1.0.0",}'
await Bun.write(cfg, bad)
const run = createPlugTask(
{
mod: "acme@2.0.0",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(false)
expect(await fs.readFile(cfg, "utf8")).toBe(bad)
})
test("returns false when manifest declares no supported targets", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path)
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(false)
})
test("returns false when manifest cannot be read", async () => {
await using tmp = await tmpdir()
const target = path.join(tmp.path, "plugin")
await fs.mkdir(target, { recursive: true })
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
})
test("returns false when install fails", async () => {
await using tmp = await tmpdir()
const run = createPlugTask(
{
mod: "acme@9.9.9",
},
deps(path.join(tmp.path, "global"), new Error("boom")),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
})
})
@@ -0,0 +1,90 @@
import { describe, expect, test } from "bun:test"
import type { ParsedKey } from "@opentui/core"
import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/plugin-keybinds"
describe("createPluginKeybind", () => {
const defaults = {
open: "ctrl+o",
close: "escape",
}
test("uses defaults when overrides are missing", () => {
const api = {
match: () => false,
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults)
expect(bind.all).toEqual(defaults)
expect(bind.get("open")).toBe("ctrl+o")
expect(bind.get("close")).toBe("escape")
})
test("applies valid overrides", () => {
const api = {
match: () => false,
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults, {
open: "ctrl+alt+o",
close: "q",
})
expect(bind.all).toEqual({
open: "ctrl+alt+o",
close: "q",
})
})
test("ignores invalid overrides", () => {
const api = {
match: () => false,
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults, {
open: " ",
close: 1,
extra: "ctrl+x",
})
expect(bind.all).toEqual(defaults)
expect(bind.get("extra")).toBe("extra")
})
test("resolves names for match", () => {
const list: string[] = []
const api = {
match: (key: string) => {
list.push(key)
return true
},
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults, {
open: "ctrl+shift+o",
})
bind.match("open", { name: "x" } as ParsedKey)
bind.match("ctrl+k", { name: "x" } as ParsedKey)
expect(list).toEqual(["ctrl+shift+o", "ctrl+k"])
})
test("resolves names for print", () => {
const list: string[] = []
const api = {
match: () => false,
print: (key: string) => {
list.push(key)
return `print:${key}`
},
}
const bind = createPluginKeybind(api, defaults, {
close: "q",
})
expect(bind.print("close")).toBe("print:q")
expect(bind.print("ctrl+p")).toBe("print:ctrl+p")
expect(list).toEqual(["q", "ctrl+p"])
})
})
@@ -0,0 +1,359 @@
import { expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
type Count = {
event_add: number
event_drop: number
route_add: number
route_drop: number
command_add: number
command_drop: number
}
test("disposes tracked event, route, and command hooks", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginPath = path.join(dir, "lifecycle-plugin.ts")
const pluginSpec = pathToFileURL(pluginPath).href
const marker = path.join(dir, "dispose-marker.txt")
await Bun.write(
pluginPath,
`export default {
tui: async (api, options) => {
api.event.on("event.test", () => {})
api.route.register([{ name: "lifecycle.route", render: () => null }])
const off = api.command.register(() => [])
off()
api.lifecycle.onDispose(async () => {
const prev = await Bun.file(options.marker).text().catch(() => "")
await Bun.write(options.marker, prev + "custom\\n")
})
api.lifecycle.onDispose(async () => {
const prev = await Bun.file(options.marker).text().catch(() => "")
await Bun.write(options.marker, prev + "aborted:" + String(api.lifecycle.signal.aborted) + "\\n")
})
},
}
`,
)
return {
marker,
pluginSpec,
}
},
})
const count: Count = {
event_add: 0,
event_drop: 0,
route_add: 0,
route_drop: 0,
command_add: 0,
command_drop: 0,
}
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const name = path.parse(new URL(tmp.extra.pluginSpec).pathname).name
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [[tmp.extra.pluginSpec, { marker: tmp.extra.marker }]],
plugin_meta: {
[name]: {
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(createTuiPluginApi({ count }))
expect(count.event_add).toBe(1)
expect(count.event_drop).toBe(0)
expect(count.route_add).toBe(1)
expect(count.route_drop).toBe(0)
expect(count.command_add).toBe(1)
expect(count.command_drop).toBe(1)
await TuiPluginRuntime.dispose()
expect(count.event_drop).toBe(1)
expect(count.route_drop).toBe(1)
expect(count.command_drop).toBe(1)
await TuiPluginRuntime.dispose()
expect(count.event_drop).toBe(1)
expect(count.route_drop).toBe(1)
expect(count.command_drop).toBe(1)
const marker = await fs.readFile(tmp.extra.marker, "utf8")
expect(marker).toContain("custom")
expect(marker).toContain("aborted:true")
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("rolls back failed plugin exports and continues loading", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const badPath = path.join(dir, "bad-plugin.ts")
const badSpec = pathToFileURL(badPath).href
const goodPath = path.join(dir, "good-plugin.ts")
const goodSpec = pathToFileURL(goodPath).href
const badMarker = path.join(dir, "bad-cleanup.txt")
const goodMarker = path.join(dir, "good-called.txt")
await Bun.write(
badPath,
`export default {
tui: async (api, options) => {
api.route.register([{ name: "bad.route", render: () => null }])
api.lifecycle.onDispose(async () => {
await Bun.write(options.bad_marker, "cleaned")
})
throw new Error("bad plugin")
},
}
`,
)
await Bun.write(
goodPath,
`export default {
tui: async (_api, options) => {
await Bun.write(options.good_marker, "called")
},
}
`,
)
return {
badSpec,
goodSpec,
badMarker,
goodMarker,
}
},
})
const count: Count = {
event_add: 0,
event_drop: 0,
route_add: 0,
route_drop: 0,
command_add: 0,
command_drop: 0,
}
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const badName = path.parse(new URL(tmp.extra.badSpec).pathname).name
const goodName = path.parse(new URL(tmp.extra.goodSpec).pathname).name
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [
[tmp.extra.badSpec, { bad_marker: tmp.extra.badMarker }],
[tmp.extra.goodSpec, { good_marker: tmp.extra.goodMarker }],
],
plugin_meta: {
[badName]: {
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
[goodName]: {
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(createTuiPluginApi({ count }))
await expect(fs.readFile(tmp.extra.badMarker, "utf8")).resolves.toBe("cleaned")
await expect(fs.readFile(tmp.extra.goodMarker, "utf8")).resolves.toBe("called")
expect(count.route_add).toBe(1)
expect(count.route_drop).toBe(1)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("registers slots via api and ignores manual slot plugin id", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginPath = path.join(dir, "slot-plugin.ts")
const pluginSpec = pathToFileURL(pluginPath).href
const marker = path.join(dir, "slot-setup.txt")
await Bun.write(
pluginPath,
`import fs from "fs"
const mark = (label) => {
fs.appendFileSync(${JSON.stringify(marker)}, label + "\\n")
}
export default {
tui: async (api) => {
const one = api.slots.register({
id: 1,
setup: () => {
mark("one")
},
slots: {
home_logo() {
return null
},
},
})
const two = api.slots.register({
id: 2,
setup: () => {
mark("two")
},
slots: {
home_tips() {
return null
},
},
})
mark("id:" + one)
mark("id:" + two)
},
}
`,
)
return {
pluginSpec,
marker,
}
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const name = path.parse(new URL(tmp.extra.pluginSpec).pathname).name
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [tmp.extra.pluginSpec],
plugin_meta: {
[name]: {
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
},
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const err = spyOn(console, "error").mockImplementation(() => {})
try {
await TuiPluginRuntime.init(createTuiPluginApi())
const marker = await fs.readFile(tmp.extra.marker, "utf8")
expect(marker).toContain("one")
expect(marker).toContain("two")
expect(marker).toContain(`id:${name}`)
expect(marker).toContain(`id:${name}:1`)
const hit = err.mock.calls.find(
(item) => typeof item[0] === "string" && item[0].includes("failed to initialize tui plugin export"),
)
expect(hit).toBeUndefined()
} finally {
await TuiPluginRuntime.dispose()
err.mockRestore()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test(
"times out hanging plugin cleanup on dispose",
async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginPath = path.join(dir, "timeout-plugin.ts")
const pluginSpec = pathToFileURL(pluginPath).href
await Bun.write(
pluginPath,
`export default {
tui: async (api) => {
api.lifecycle.onDispose(() => new Promise(() => {}))
},
}
`,
)
return {
pluginSpec,
}
},
})
const count: Count = {
event_add: 0,
event_drop: 0,
route_add: 0,
route_drop: 0,
command_add: 0,
command_drop: 0,
}
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const name = path.parse(new URL(tmp.extra.pluginSpec).pathname).name
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [tmp.extra.pluginSpec],
plugin_meta: {
[name]: {
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(createTuiPluginApi({ count }))
const done = await new Promise<string>((resolve) => {
const timer = setTimeout(() => {
resolve("timeout")
}, 7000)
TuiPluginRuntime.dispose().then(() => {
clearTimeout(timer)
resolve("done")
})
})
expect(done).toBe("done")
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
},
{ timeout: 15000 },
)
@@ -0,0 +1,84 @@
import { expect, spyOn, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
function rec(value: unknown) {
if (!value || typeof value !== "object") return
return Object.fromEntries(Object.entries(value))
}
test("logs useful details when a tui plugin import fails", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const bad = path.join(dir, "bad-plugin.ts")
const spec = pathToFileURL(bad).href
await Bun.write(
bad,
`import "./missing-module.ts"
export default {
tui: async () => {},
}
`,
)
return { spec }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const name = path.parse(new URL(tmp.extra.spec).pathname).name
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [tmp.extra.spec],
plugin_meta: {
[name]: {
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
},
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const err = spyOn(console, "error").mockImplementation(() => {})
try {
await TuiPluginRuntime.init(createTuiPluginApi())
const call = err.mock.calls.find(
(item) => typeof item[0] === "string" && item[0].includes("failed to load tui plugin"),
)
expect(call).toBeDefined()
if (!call) return
expect(String(call[0])).toContain("failed to load tui plugin:")
const data = rec(call[1])
expect(data).toBeDefined()
if (!data) return
expect(data.path).toBe(tmp.extra.spec)
expect(data.target).toBe(tmp.extra.spec)
expect(data.retry).toBe(false)
expect(data.error).toBeObject()
const info = rec(data.error)
expect(info).toBeDefined()
if (!info) return
expect(typeof info.message).toBe("string")
const message = typeof info.message === "string" ? info.message : ""
expect(message.length).toBeGreaterThan(0)
expect(typeof info.formatted).toBe("string")
const formatted = typeof info.formatted === "string" ? info.formatted : ""
expect(formatted.length).toBeGreaterThan(0)
expect(formatted).not.toBe("{}")
} finally {
await TuiPluginRuntime.dispose()
err.mockRestore()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
@@ -0,0 +1,104 @@
import { expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
test("continues loading tui plugins when a plugin is missing config metadata", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const badPluginPath = path.join(dir, "missing-meta-plugin.ts")
const nextPluginPath = path.join(dir, "next-plugin.ts")
const plainPluginPath = path.join(dir, "plain-plugin.ts")
const badSpec = pathToFileURL(badPluginPath).href
const nextSpec = pathToFileURL(nextPluginPath).href
const plainSpec = pathToFileURL(plainPluginPath).href
const badMarker = path.join(dir, "missing-meta-called.txt")
const nextMarker = path.join(dir, "next-called.txt")
const plainMarker = path.join(dir, "plain-called.txt")
await Bun.write(
badPluginPath,
`export default {
tui: async (_api, options) => {
if (!options?.marker) return
await Bun.write(options.marker, "called")
},
}
`,
)
await Bun.write(
nextPluginPath,
`export default {
tui: async (_api, options) => {
if (!options?.marker) return
await Bun.write(options.marker, "called")
},
}
`,
)
await Bun.write(
plainPluginPath,
`export default {
tui: async (_api, options) => {
await Bun.write(${JSON.stringify(plainMarker)}, options === undefined ? "undefined" : options === null ? "null" : "value")
},
}
`,
)
return {
badSpec,
nextSpec,
plainSpec,
badMarker,
nextMarker,
plainMarker,
}
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const next = path.parse(new URL(tmp.extra.nextSpec).pathname).name
const plain = path.parse(new URL(tmp.extra.plainSpec).pathname).name
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [
[tmp.extra.badSpec, { marker: tmp.extra.badMarker }],
[tmp.extra.nextSpec, { marker: tmp.extra.nextMarker }],
tmp.extra.plainSpec,
],
plugin_meta: {
[next]: {
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
[plain]: {
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(createTuiPluginApi())
await expect(fs.readFile(tmp.extra.badMarker, "utf8")).rejects.toThrow()
await expect(fs.readFile(tmp.extra.nextMarker, "utf8")).resolves.toBe("called")
await expect(fs.readFile(tmp.extra.plainMarker, "utf8")).resolves.toBe("undefined")
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
@@ -0,0 +1,71 @@
import { expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
test("skips external tui plugins in pure mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "called.txt")
const name = path.parse(file).name
const meta = path.join(dir, "plugin-meta.json")
await Bun.write(
file,
`export default {
tui: async (_api, options) => {
if (!options?.marker) return
await Bun.write(options.marker, "called")
},
}
`,
)
return { spec, marker, name, meta }
},
})
const pure = process.env.OPENCODE_PURE
const meta = process.env.OPENCODE_PLUGIN_META_FILE
process.env.OPENCODE_PURE = "1"
process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta
const get = spyOn(TuiConfig, "get").mockResolvedValue({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_meta: {
[tmp.extra.name]: {
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(createTuiPluginApi())
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
get.mockRestore()
wait.mockRestore()
if (pure === undefined) {
delete process.env.OPENCODE_PURE
} else {
process.env.OPENCODE_PURE = pure
}
if (meta === undefined) {
delete process.env.OPENCODE_PLUGIN_META_FILE
} else {
process.env.OPENCODE_PLUGIN_META_FILE = meta
}
}
})
@@ -0,0 +1,483 @@
import { beforeAll, describe, expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { Global } from "../../../src/global"
import { TuiConfig } from "../../../src/config/tui"
import { Config } from "../../../src/config/config"
import { Filesystem } from "../../../src/util/filesystem"
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>
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,
`export default async (_input, options) => {
if (!options?.fn_marker) return
await Bun.write(options.fn_marker, "called")
}
export const object_plugin = {
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 cfg_submit = api.tuiConfig.keybinds?.input_submit
const key = api.keybind.create(
{ modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" },
options.keybinds,
)
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.get("modal"),
key_close: key.get("close"),
key_unknown: key.get("ctrl+k"),
key_print: key.print("modal"),
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,
cfg_submit,
}),
)
},
}
`,
)
await Bun.write(
invalidPluginPath,
`export default {
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 {
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 {
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()
const install = spyOn(Config, "installDependencies").mockResolvedValue()
try {
expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
await TuiPluginRuntime.init(
createTuiPluginApi({
tuiConfig: {
theme: "smoke",
diff_style: "stacked",
scroll_speed: 1.5,
scroll_acceleration: { enabled: true },
keybinds: {
input_submit: "ctrl+enter",
},
},
keybind: {
print: (key) => `print:${key}`,
},
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
},
},
}),
)
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()
install.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(() => {})
}
}
describe("tui.plugin.loader", () => {
let data: Data
beforeAll(async () => {
data = await load()
})
test("passes keybind, kv, state, and dialog APIs to object 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.key_print).toBe("print:ctrl+alt+m")
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)
expect(data.local.cfg_submit).toBe("ctrl+enter")
})
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)
})
})
@@ -0,0 +1,50 @@
import { expect, test } from "bun:test"
const { DEFAULT_THEMES, allThemes, addTheme, hasTheme, resolveTheme } =
await import("../../../src/cli/cmd/tui/context/theme")
test("addTheme writes into module theme store", () => {
const name = `plugin-theme-${Date.now()}`
expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
expect(allThemes()[name]).toBeDefined()
})
test("addTheme keeps first theme for duplicate names", () => {
const name = `plugin-theme-keep-${Date.now()}`
const one = structuredClone(DEFAULT_THEMES.opencode)
const two = structuredClone(DEFAULT_THEMES.opencode)
one.theme.primary = "#101010"
two.theme.primary = "#fefefe"
expect(addTheme(name, one)).toBe(true)
expect(addTheme(name, two)).toBe(false)
expect(allThemes()[name]).toBeDefined()
expect(allThemes()[name]!.theme.primary).toBe("#101010")
})
test("addTheme ignores entries without a theme object", () => {
const name = `plugin-theme-invalid-${Date.now()}`
expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false)
expect(allThemes()[name]).toBeUndefined()
})
test("hasTheme checks theme presence", () => {
const name = `plugin-theme-has-${Date.now()}`
expect(hasTheme(name)).toBe(false)
expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
expect(hasTheme(name)).toBe(true)
})
test("resolveTheme rejects circular color refs", () => {
const item = structuredClone(DEFAULT_THEMES.opencode)
item.defs = {
...(item.defs ?? {}),
one: "two",
two: "one",
}
item.theme.primary = "one"
expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
})