squashed
This commit is contained in:
@@ -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")
|
||||
})
|
||||
Reference in New Issue
Block a user