| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332 |
- import { beforeAll, describe, expect, spyOn, test } from "bun:test"
- import fs from "fs/promises"
- import path from "path"
- import { pathToFileURL } from "url"
- import { createTestKeymap } from "@opentui/keymap/testing"
- import type { TuiAttentionSoundPack } from "@kirincode-ai/plugin/tui"
- import { tmpdir } from "../../fixture/fixture"
- import { createTuiPluginApi } from "../../fixture/tui-plugin"
- import { createTuiResolvedConfig, mockTuiRuntime } from "../../fixture/tui-runtime"
- import { Global } from "@kirincode-ai/core/global"
- import { TuiConfig } from "../../../src/config/tui"
- import { Filesystem } from "@/util/filesystem"
- import { PluginLoader } from "../../../src/plugin/loader"
- const { allThemes, addTheme } = await import("@kirincode-ai/tui/context/theme")
- const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
- type Row = Record<string, unknown>
- test("does not retry permanent file plugin load errors", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const file = path.join(dir, "binary-plugin")
- await Bun.write(file, new Uint8Array([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00, 0x00, 0x01]))
- return { spec: pathToFileURL(file).href }
- },
- })
- let waited = false
- const calls: Array<["start" | "error", boolean, string?]> = []
- const plugins = await PluginLoader.loadExternal({
- items: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
- kind: "tui",
- wait: async () => {
- waited = true
- },
- report: {
- start(_candidate, retry) {
- calls.push(["start", retry])
- },
- error(_candidate, retry, stage) {
- calls.push(["error", retry, stage])
- },
- },
- })
- expect(plugins).toEqual([])
- expect(waited).toBe(false)
- expect(calls).toEqual([
- ["start", false],
- ["error", false, "load"],
- ])
- })
- test("does not retry file plugin load errors caused by missing modules", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const file = path.join(dir, "missing-dependency-plugin.ts")
- const dep = path.join(dir, "dep.ts")
- await Bun.write(
- file,
- `import value from "./dep"
- export default { id: "demo.retry.load", tui: async () => {}, value }
- `,
- )
- return { spec: pathToFileURL(file).href, dep }
- },
- })
- let waited = false
- const calls: Array<["start" | "error", boolean, string?]> = []
- const plugins = await PluginLoader.loadExternal({
- items: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
- kind: "tui",
- wait: async () => {
- waited = true
- await Bun.write(tmp.extra.dep, `export default "ready"\n`)
- },
- finish: async (loaded, _origin, retry) => ({
- retry,
- value: (loaded.mod.default as { value: string }).value,
- }),
- report: {
- start(_candidate, retry) {
- calls.push(["start", retry])
- },
- error(_candidate, retry, stage) {
- calls.push(["error", retry, stage])
- },
- },
- })
- expect(waited).toBe(false)
- expect(calls).toEqual([
- ["start", false],
- ["error", false, "load"],
- ])
- expect(plugins).toEqual([])
- })
- test("does not retry top-level plugin errors that look like resolver messages", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const file = path.join(dir, "throwing-plugin.ts")
- await Bun.write(file, `throw new Error("Cannot find package intentional")\n`)
- return { spec: pathToFileURL(file).href }
- },
- })
- let waited = false
- const plugins = await PluginLoader.loadExternal({
- items: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
- kind: "tui",
- wait: async () => {
- waited = true
- },
- })
- expect(plugins).toEqual([])
- expect(waited).toBe(false)
- })
- 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, ".kirincode", "themes", localThemeFile)
- const globalDest = path.join(Global.Path.config, "themes", globalThemeFile)
- const preloadedDest = path.join(dir, ".kirincode", "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,
- `import { createBindingLookup } from "@opentui/keymap/extras"
- import { useBindings } from "@opentui/keymap/solid"
- export const ignored = async (_input, options) => {
- if (!options?.fn_marker) return
- await Bun.write(options.fn_marker, "called")
- }
- export default {
- id: "demo.local",
- 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 has_keys = typeof api.keys.formatBindings === "function"
- const keybinds = createBindingLookup(options.keybinds ?? {
- "plugin.loader.local": "ctrl+shift+m",
- "plugin.loader.close": "escape",
- })
- const bindings = keybinds.gather("plugin.loader", ["plugin.loader.local", "plugin.loader.close"])
- const key_modal = bindings.find((item) => item.cmd === "plugin.loader.local")?.key
- const key_close = bindings.find((item) => item.cmd === "plugin.loader.close")?.key
- const key_unknown = "ctrl+k"
- const off = api.keymap.registerLayer({
- commands: [{ name: "plugin.loader.local", run() {} }, { name: "plugin.loader.close", run() {} }],
- bindings,
- })
- off()
- 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_close,
- key_unknown,
- has_keys,
- has_keymap: typeof api.keymap.registerLayer === "function",
- has_create_binding_lookup: typeof createBindingLookup === "function",
- has_keymap_solid: typeof useBindings === "function",
- 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,
- }),
- )
- },
- }
- `,
- )
- await Bun.write(
- invalidPluginPath,
- `export default {
- id: "demo.invalid",
- 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 {
- id: "demo.preloaded",
- 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 {
- id: "demo.global",
- 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()
- try {
- expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
- const localOpts = {
- fn_marker: tmp.extra.fnMarker,
- marker: tmp.extra.localMarker,
- source: path.join(tmp.path, tmp.extra.localThemeFile),
- dest: tmp.extra.localDest,
- theme_path: `./${tmp.extra.localThemeFile}`,
- theme_name: tmp.extra.localThemeName,
- kv_key: "plugin_state_key",
- session_id: "ses_test",
- keybinds: {
- "plugin.loader.local": "ctrl+alt+m",
- "plugin.loader.close": "q",
- },
- }
- const invalidOpts = {
- marker: tmp.extra.invalidMarker,
- theme_path: `./${tmp.extra.invalidThemeFile}`,
- theme_name: tmp.extra.invalidThemeName,
- }
- const preloadedOpts = {
- marker: tmp.extra.preloadedMarker,
- dest: tmp.extra.preloadedDest,
- theme_path: `./${tmp.extra.preloadedThemeFile}`,
- theme_name: tmp.extra.preloadedThemeName,
- }
- const globalOpts = {
- marker: tmp.extra.globalMarker,
- theme_path: `./${tmp.extra.globalThemeFile}`,
- theme_name: tmp.extra.globalThemeName,
- }
- const config = createTuiResolvedConfig({
- plugin: [
- [tmp.extra.localSpec, localOpts],
- [tmp.extra.invalidSpec, invalidOpts],
- [tmp.extra.preloadedSpec, preloadedOpts],
- [tmp.extra.globalSpec, globalOpts],
- ],
- plugin_origins: [
- { spec: [tmp.extra.localSpec, localOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
- { spec: [tmp.extra.invalidSpec, invalidOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
- { spec: [tmp.extra.preloadedSpec, preloadedOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
- {
- spec: [tmp.extra.globalSpec, globalOpts],
- scope: "global",
- source: path.join(Global.Path.config, "tui.json"),
- },
- ],
- })
- await TuiPluginRuntime.init({
- api: createTuiPluginApi({
- tuiConfig: {
- theme: "smoke",
- diff_style: "stacked",
- scroll_speed: 1.5,
- scroll_acceleration: { enabled: true },
- },
- 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
- },
- },
- }),
- config,
- })
- 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, ".kirincode", "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()
- if (backup === undefined) {
- await fs.rm(globalConfigPath, { force: true })
- } else {
- await Bun.write(globalConfigPath, backup)
- }
- await fs.rm(tmp.extra.globalDest, { force: true }).catch(() => {})
- }
- }
- test("continues loading when a plugin is missing config metadata", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const bad = path.join(dir, "missing-meta-plugin.ts")
- const good = path.join(dir, "next-plugin.ts")
- const bare = path.join(dir, "plain-plugin.ts")
- const badSpec = pathToFileURL(bad).href
- const goodSpec = pathToFileURL(good).href
- const bareSpec = pathToFileURL(bare).href
- const goodMarker = path.join(dir, "next-called.txt")
- const bareMarker = path.join(dir, "plain-called.txt")
- for (const [file, id] of [
- [bad, "demo.missing-meta"],
- [good, "demo.next"],
- ] as const) {
- await Bun.write(
- file,
- `export default {
- id: "${id}",
- tui: async (_api, options) => {
- if (!options?.marker) return
- await Bun.write(options.marker, "called")
- },
- }
- `,
- )
- }
- await Bun.write(
- bare,
- `export default {
- id: "demo.plain",
- tui: async (_api, options) => {
- await Bun.write(${JSON.stringify(bareMarker)}, options === undefined ? "undefined" : "value")
- },
- }
- `,
- )
- return { badSpec, goodSpec, bareSpec, goodMarker, bareMarker }
- },
- })
- process.env.KIRINCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const config = createTuiResolvedConfig({
- plugin: [
- [tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
- [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
- tmp.extra.bareSpec,
- ],
- plugin_origins: [
- {
- spec: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
- scope: "local",
- source: path.join(tmp.path, "tui.json"),
- },
- {
- spec: tmp.extra.bareSpec,
- 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({ api: createTuiPluginApi(), config })
- // bad plugin was skipped (no metadata entry)
- await expect(fs.readFile(path.join(tmp.path, "bad.txt"), "utf8")).rejects.toThrow()
- // good plugin loaded fine
- await expect(fs.readFile(tmp.extra.goodMarker, "utf8")).resolves.toBe("called")
- // bare string spec gets undefined options
- await expect(fs.readFile(tmp.extra.bareMarker, "utf8")).resolves.toBe("undefined")
- } finally {
- await TuiPluginRuntime.dispose()
- cwd.mockRestore()
- wait.mockRestore()
- delete process.env.KIRINCODE_PLUGIN_META_FILE
- }
- })
- test("does not wait on permanent tui plugin startup failures", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const binary = path.join(dir, "binary-plugin")
- const invalidShape = path.join(dir, "invalid-shape-plugin.ts")
- const missingID = path.join(dir, "missing-id-plugin.ts")
- const good = path.join(dir, "good-plugin.ts")
- const marker = path.join(dir, "good-called.txt")
- await Bun.write(binary, new Uint8Array([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00, 0x00, 0x01]))
- await Bun.write(invalidShape, `export default { id: "demo.invalid.shape" }\n`)
- await Bun.write(missingID, `export default { tui: async () => {} }\n`)
- await Bun.write(
- good,
- `export default {
- id: "demo.good.after-bad",
- tui: async () => {
- await Bun.write(${JSON.stringify(marker)}, "called")
- },
- }
- `,
- )
- return {
- binarySpec: pathToFileURL(binary).href,
- invalidShapeSpec: pathToFileURL(invalidShape).href,
- missingIDSpec: pathToFileURL(missingID).href,
- goodSpec: pathToFileURL(good).href,
- marker,
- }
- },
- })
- process.env.KIRINCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
- const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- try {
- await TuiPluginRuntime.init({
- api: createTuiPluginApi(),
- config: createTuiResolvedConfig({
- plugin: [tmp.extra.binarySpec, tmp.extra.invalidShapeSpec, tmp.extra.missingIDSpec, tmp.extra.goodSpec],
- plugin_origins: [
- { spec: tmp.extra.binarySpec, scope: "local", source: path.join(tmp.path, "tui.json") },
- { spec: tmp.extra.invalidShapeSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
- { spec: tmp.extra.missingIDSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
- { spec: tmp.extra.goodSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
- ],
- }),
- })
- expect(wait).toHaveBeenCalledTimes(0)
- await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
- expect(TuiPluginRuntime.list().find((item) => item.id === "demo.good.after-bad")?.active).toBe(true)
- expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.binarySpec)).toBe(false)
- expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.invalidShapeSpec)).toBe(false)
- expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.missingIDSpec)).toBe(false)
- } finally {
- await TuiPluginRuntime.dispose()
- cwd.mockRestore()
- wait.mockRestore()
- delete process.env.KIRINCODE_PLUGIN_META_FILE
- }
- })
- test("initializes external tui plugins in config order", async () => {
- const globalJson = path.join(Global.Path.config, "tui.json")
- const globalJsonc = path.join(Global.Path.config, "tui.jsonc")
- const backupJson = await Bun.file(globalJson)
- .text()
- .catch(() => undefined)
- const backupJsonc = await Bun.file(globalJsonc)
- .text()
- .catch(() => undefined)
- await fs.rm(globalJson, { force: true }).catch(() => {})
- await fs.rm(globalJsonc, { force: true }).catch(() => {})
- await using tmp = await tmpdir({
- init: async (dir) => {
- const a = path.join(dir, "order-a.ts")
- const b = path.join(dir, "order-b.ts")
- const aSpec = pathToFileURL(a).href
- const bSpec = pathToFileURL(b).href
- const marker = path.join(dir, "tui-order.txt")
- await Bun.write(
- a,
- `import fs from "fs/promises"
- export default {
- id: "demo.tui.order.a",
- tui: async () => {
- await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n")
- await Bun.sleep(25)
- await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n")
- },
- }
- `,
- )
- await Bun.write(
- b,
- `import fs from "fs/promises"
- export default {
- id: "demo.tui.order.b",
- tui: async () => {
- await fs.appendFile(${JSON.stringify(marker)}, "b\\n")
- },
- }
- `,
- )
- await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2))
- return { marker }
- },
- })
- process.env.KIRINCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- try {
- const a = path.join(tmp.path, "order-a.ts")
- const b = path.join(tmp.path, "order-b.ts")
- const aSpec = pathToFileURL(a).href
- const bSpec = pathToFileURL(b).href
- const config = createTuiResolvedConfig({
- plugin: [aSpec, bSpec],
- plugin_origins: [
- { spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
- { spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
- ],
- })
- await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
- const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
- expect(lines).toEqual(["a-start", "a-end", "b"])
- } finally {
- await TuiPluginRuntime.dispose()
- cwd.mockRestore()
- delete process.env.KIRINCODE_PLUGIN_META_FILE
- if (backupJson === undefined) {
- await fs.rm(globalJson, { force: true }).catch(() => {})
- } else {
- await Bun.write(globalJson, backupJson)
- }
- if (backupJsonc === undefined) {
- await fs.rm(globalJsonc, { force: true }).catch(() => {})
- } else {
- await Bun.write(globalJsonc, backupJsonc)
- }
- }
- })
- test("does not bootstrap server plugins while initializing tui plugins", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const marker = path.join(dir, "server-plugin-called.txt")
- const plugin = path.join(dir, "server-plugin.ts")
- await Bun.write(
- plugin,
- [
- "export default async () => {",
- ` await Bun.write(${JSON.stringify(marker)}, "called")`,
- " return {}",
- "}",
- "",
- ].join("\n"),
- )
- await Bun.write(path.join(dir, "kirincode.json"), JSON.stringify({ plugin: [pathToFileURL(plugin).href] }))
- return { marker }
- },
- })
- const mock = mockTuiRuntime(tmp.path, [])
- try {
- await TuiPluginRuntime.init({ api: createTuiPluginApi(), config: mock.config })
- await expect(fs.stat(tmp.extra.marker)).rejects.toThrow()
- } finally {
- await TuiPluginRuntime.dispose()
- mock.restore()
- }
- })
- describe("tui.plugin.loader", () => {
- let data: Data
- beforeAll(async () => {
- data = await load()
- })
- test("passes keybind, kv, state, and dialog APIs to v1 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.has_keys).toBe(true)
- expect(data.local.has_keymap).toBe(true)
- expect(data.local.has_create_binding_lookup).toBe(true)
- expect(data.local.has_keymap_solid).toBe(true)
- 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)
- })
- 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)
- })
- })
- test("auto-disposes plugin keymap layers", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const file = path.join(dir, "keymap-cleanup-plugin.ts")
- const spec = pathToFileURL(file).href
- await Bun.write(
- file,
- `export default {
- id: "demo.keymap.cleanup",
- tui: async (api) => {
- api.keymap.registerLayer({
- commands: [{ name: "demo.keymap.cleanup", run() {} }],
- bindings: [{ key: "ctrl+g", cmd: "demo.keymap.cleanup" }],
- })
- },
- }
- `,
- )
- return { spec }
- },
- })
- let command_add = 0
- let command_drop = 0
- const keymap = {
- registerLayer(layer: { commands?: Array<{ name: string }> }) {
- const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup") ?? false
- if (tracked) command_add += 1
- return () => {
- if (!tracked) return
- command_drop += 1
- }
- },
- } as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
- const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
- const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- try {
- await TuiPluginRuntime.init({
- api: createTuiPluginApi({ keymap }),
- config: createTuiResolvedConfig({
- plugin: [tmp.extra.spec],
- plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
- }),
- })
- expect(command_add).toBe(1)
- expect(command_drop).toBe(0)
- } finally {
- await TuiPluginRuntime.dispose()
- expect(command_drop).toBe(1)
- cwd.mockRestore()
- wait.mockRestore()
- }
- })
- test("plugin keymap proxy preserves real keymap receiver", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const file = path.join(dir, "keymap-receiver-plugin.ts")
- const spec = pathToFileURL(file).href
- const marker = path.join(dir, "keymap-receiver.txt")
- await Bun.write(
- file,
- `export default {
- id: "demo.keymap.receiver",
- tui: async (api) => {
- api.keymap.setData("demo.receiver", "ok")
- await Bun.write(${JSON.stringify(marker)}, String(api.keymap.getData("demo.receiver")))
- },
- }
- `,
- )
- return { spec, marker }
- },
- })
- const harness = createTestKeymap({ defaultKeys: true })
- const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
- const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- try {
- await TuiPluginRuntime.init({
- api: createTuiPluginApi({
- keymap: harness.keymap as unknown as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"],
- }),
- config: createTuiResolvedConfig({
- plugin: [tmp.extra.spec],
- plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
- }),
- })
- await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("ok")
- expect(harness.keymap.getData("demo.receiver")).toBe("ok")
- } finally {
- await TuiPluginRuntime.dispose()
- harness.cleanup()
- cwd.mockRestore()
- wait.mockRestore()
- }
- })
- test("auto-disposes plugin attention sound packs and resolves sound paths", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const file = path.join(dir, "attention-soundpack-plugin.ts")
- const spec = pathToFileURL(file).href
- const absolute = path.join(dir, "sounds", "default.mp3")
- const url = pathToFileURL(path.join(dir, "sounds", "error.mp3")).href
- await Bun.write(
- file,
- `export default {
- id: "demo.attention.soundpack",
- tui: async (api) => {
- api.attention.soundboard.registerPack({
- id: "demo.pack",
- sounds: {
- default: ${JSON.stringify(absolute)},
- question: "sounds/question.mp3",
- done: " sounds/done.mp3 ",
- subagent_done: "sounds/subagent-done.mp3",
- error: ${JSON.stringify(url)},
- nope: "sounds/nope.mp3",
- permission: "",
- },
- })
- },
- }
- `,
- )
- return { spec }
- },
- })
- const packs: TuiAttentionSoundPack[] = []
- let dropped = 0
- const attention = {
- soundboard: {
- registerPack(pack: TuiAttentionSoundPack) {
- packs.push(pack)
- return () => {
- dropped += 1
- }
- },
- },
- }
- const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
- const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- try {
- await TuiPluginRuntime.init({
- api: createTuiPluginApi({ attention }),
- config: createTuiResolvedConfig({
- plugin: [tmp.extra.spec],
- plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
- }),
- })
- expect(packs).toEqual([
- {
- id: "demo.pack",
- sounds: {
- default: path.join(tmp.path, "sounds", "default.mp3"),
- question: path.join(tmp.path, "sounds", "question.mp3"),
- done: path.join(tmp.path, "sounds", "done.mp3"),
- subagent_done: path.join(tmp.path, "sounds", "subagent-done.mp3"),
- error: path.join(tmp.path, "sounds", "error.mp3"),
- },
- },
- ])
- expect(dropped).toBe(0)
- } finally {
- await TuiPluginRuntime.dispose()
- expect(dropped).toBe(1)
- cwd.mockRestore()
- wait.mockRestore()
- }
- })
- test("auto-disposes plugin keymap transformers", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const file = path.join(dir, "keymap-transformer-cleanup-plugin.ts")
- const spec = pathToFileURL(file).href
- await Bun.write(
- file,
- `export default {
- id: "demo.keymap.transformer.cleanup",
- tui: async (api) => {
- api.keymap.prependLayerBindingsTransformer((bindings) => bindings)
- api.keymap.appendLayerBindingsTransformer((bindings) => bindings)
- api.keymap.prependCommandTransformer(() => {})
- api.keymap.appendCommandTransformer(() => {})
- },
- }
- `,
- )
- return { spec }
- },
- })
- let add = 0
- let drop = 0
- const track = () => {
- add += 1
- return () => {
- drop += 1
- }
- }
- const keymap = {
- registerLayer: () => () => {},
- prependLayerBindingsTransformer: track,
- appendLayerBindingsTransformer: track,
- prependCommandTransformer: track,
- appendCommandTransformer: track,
- } as unknown as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
- const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
- const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- try {
- await TuiPluginRuntime.init({
- api: createTuiPluginApi({ keymap }),
- config: createTuiResolvedConfig({
- plugin: [tmp.extra.spec],
- plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
- }),
- })
- expect(add).toBe(4)
- expect(drop).toBe(0)
- } finally {
- await TuiPluginRuntime.dispose()
- expect(drop).toBe(4)
- cwd.mockRestore()
- wait.mockRestore()
- }
- })
- test("manual onDispose for plugin keymap layers stays idempotent", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const file = path.join(dir, "keymap-cleanup-manual-plugin.ts")
- const spec = pathToFileURL(file).href
- await Bun.write(
- file,
- `export default {
- id: "demo.keymap.cleanup.manual",
- tui: async (api) => {
- const off = api.keymap.registerLayer({
- commands: [{ name: "demo.keymap.cleanup.manual", run() {} }],
- bindings: [{ key: "ctrl+h", cmd: "demo.keymap.cleanup.manual" }],
- })
- api.lifecycle.onDispose(off)
- },
- }
- `,
- )
- return { spec }
- },
- })
- let command_drop = 0
- const keymap = {
- registerLayer(layer: { commands?: Array<{ name: string }> }) {
- const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup.manual") ?? false
- return () => {
- if (!tracked) return
- command_drop += 1
- }
- },
- } as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
- const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
- const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- try {
- await TuiPluginRuntime.init({
- api: createTuiPluginApi({ keymap }),
- config: createTuiResolvedConfig({
- plugin: [tmp.extra.spec],
- plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
- }),
- })
- } finally {
- await TuiPluginRuntime.dispose()
- expect(command_drop).toBe(1)
- cwd.mockRestore()
- wait.mockRestore()
- }
- })
- test("updates installed theme when plugin metadata changes", async () => {
- await using tmp = await tmpdir<{
- spec: string
- pluginPath: string
- themePath: string
- dest: string
- themeName: string
- }>({
- init: async (dir) => {
- const pluginPath = path.join(dir, "theme-update-plugin.ts")
- const spec = pathToFileURL(pluginPath).href
- const themeFile = "theme-update.json"
- const themePath = path.join(dir, themeFile)
- const dest = path.join(dir, ".kirincode", "themes", themeFile)
- const themeName = themeFile.replace(/\.json$/, "")
- const configPath = path.join(dir, "tui.json")
- await Bun.write(themePath, JSON.stringify({ theme: { primary: "#111111" } }, null, 2))
- await Bun.write(
- pluginPath,
- `export default {
- id: "demo.theme-update",
- tui: async (api, options) => {
- if (!options?.theme_path) return
- await api.theme.install(options.theme_path)
- },
- }
- `,
- )
- await Bun.write(
- configPath,
- JSON.stringify(
- {
- plugin: [[spec, { theme_path: `./${themeFile}` }]],
- },
- null,
- 2,
- ),
- )
- return {
- spec,
- pluginPath,
- themePath,
- dest,
- themeName,
- }
- },
- })
- process.env.KIRINCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
- const mkApi = () =>
- createTuiPluginApi({
- theme: {
- has(name) {
- return allThemes()[name] !== undefined
- },
- },
- })
- const mkConfig = () =>
- createTuiResolvedConfig({
- plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]],
- plugin_origins: [
- {
- spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }],
- scope: "local",
- source: path.join(tmp.path, "tui.json"),
- },
- ],
- })
- try {
- await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
- await TuiPluginRuntime.dispose()
- await expect(fs.readFile(tmp.extra.dest, "utf8")).resolves.toContain("#111111")
- await Bun.write(tmp.extra.themePath, JSON.stringify({ theme: { primary: "#222222" } }, null, 2))
- await Bun.write(
- tmp.extra.pluginPath,
- `export default {
- id: "demo.theme-update",
- tui: async (api, options) => {
- if (!options?.theme_path) return
- await api.theme.install(options.theme_path)
- },
- }
- // v2
- `,
- )
- const stamp = new Date(Date.now() + 10_000)
- await fs.utimes(tmp.extra.pluginPath, stamp, stamp)
- await fs.utimes(tmp.extra.themePath, stamp, stamp)
- await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
- const text = await fs.readFile(tmp.extra.dest, "utf8")
- expect(text).toContain("#222222")
- expect(text).not.toContain("#111111")
- const list = await Filesystem.readJson<Record<string, { themes?: Record<string, { dest: string }> }>>(
- process.env.KIRINCODE_PLUGIN_META_FILE!,
- )
- expect(list["demo.theme-update"]?.themes?.[tmp.extra.themeName]?.dest).toBe(tmp.extra.dest)
- } finally {
- await TuiPluginRuntime.dispose()
- cwd.mockRestore()
- wait.mockRestore()
- delete process.env.KIRINCODE_PLUGIN_META_FILE
- }
- })
|