plugin-loader.test.ts 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332
  1. import { beforeAll, describe, expect, spyOn, test } from "bun:test"
  2. import fs from "fs/promises"
  3. import path from "path"
  4. import { pathToFileURL } from "url"
  5. import { createTestKeymap } from "@opentui/keymap/testing"
  6. import type { TuiAttentionSoundPack } from "@kirincode-ai/plugin/tui"
  7. import { tmpdir } from "../../fixture/fixture"
  8. import { createTuiPluginApi } from "../../fixture/tui-plugin"
  9. import { createTuiResolvedConfig, mockTuiRuntime } from "../../fixture/tui-runtime"
  10. import { Global } from "@kirincode-ai/core/global"
  11. import { TuiConfig } from "../../../src/config/tui"
  12. import { Filesystem } from "@/util/filesystem"
  13. import { PluginLoader } from "../../../src/plugin/loader"
  14. const { allThemes, addTheme } = await import("@kirincode-ai/tui/context/theme")
  15. const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
  16. type Row = Record<string, unknown>
  17. test("does not retry permanent file plugin load errors", async () => {
  18. await using tmp = await tmpdir({
  19. init: async (dir) => {
  20. const file = path.join(dir, "binary-plugin")
  21. await Bun.write(file, new Uint8Array([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00, 0x00, 0x01]))
  22. return { spec: pathToFileURL(file).href }
  23. },
  24. })
  25. let waited = false
  26. const calls: Array<["start" | "error", boolean, string?]> = []
  27. const plugins = await PluginLoader.loadExternal({
  28. items: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
  29. kind: "tui",
  30. wait: async () => {
  31. waited = true
  32. },
  33. report: {
  34. start(_candidate, retry) {
  35. calls.push(["start", retry])
  36. },
  37. error(_candidate, retry, stage) {
  38. calls.push(["error", retry, stage])
  39. },
  40. },
  41. })
  42. expect(plugins).toEqual([])
  43. expect(waited).toBe(false)
  44. expect(calls).toEqual([
  45. ["start", false],
  46. ["error", false, "load"],
  47. ])
  48. })
  49. test("does not retry file plugin load errors caused by missing modules", async () => {
  50. await using tmp = await tmpdir({
  51. init: async (dir) => {
  52. const file = path.join(dir, "missing-dependency-plugin.ts")
  53. const dep = path.join(dir, "dep.ts")
  54. await Bun.write(
  55. file,
  56. `import value from "./dep"
  57. export default { id: "demo.retry.load", tui: async () => {}, value }
  58. `,
  59. )
  60. return { spec: pathToFileURL(file).href, dep }
  61. },
  62. })
  63. let waited = false
  64. const calls: Array<["start" | "error", boolean, string?]> = []
  65. const plugins = await PluginLoader.loadExternal({
  66. items: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
  67. kind: "tui",
  68. wait: async () => {
  69. waited = true
  70. await Bun.write(tmp.extra.dep, `export default "ready"\n`)
  71. },
  72. finish: async (loaded, _origin, retry) => ({
  73. retry,
  74. value: (loaded.mod.default as { value: string }).value,
  75. }),
  76. report: {
  77. start(_candidate, retry) {
  78. calls.push(["start", retry])
  79. },
  80. error(_candidate, retry, stage) {
  81. calls.push(["error", retry, stage])
  82. },
  83. },
  84. })
  85. expect(waited).toBe(false)
  86. expect(calls).toEqual([
  87. ["start", false],
  88. ["error", false, "load"],
  89. ])
  90. expect(plugins).toEqual([])
  91. })
  92. test("does not retry top-level plugin errors that look like resolver messages", async () => {
  93. await using tmp = await tmpdir({
  94. init: async (dir) => {
  95. const file = path.join(dir, "throwing-plugin.ts")
  96. await Bun.write(file, `throw new Error("Cannot find package intentional")\n`)
  97. return { spec: pathToFileURL(file).href }
  98. },
  99. })
  100. let waited = false
  101. const plugins = await PluginLoader.loadExternal({
  102. items: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
  103. kind: "tui",
  104. wait: async () => {
  105. waited = true
  106. },
  107. })
  108. expect(plugins).toEqual([])
  109. expect(waited).toBe(false)
  110. })
  111. type Data = {
  112. local: Row
  113. global: Row
  114. invalid: Row
  115. preloaded: Row
  116. fn_called: boolean
  117. local_installed: string
  118. global_installed: string
  119. preloaded_installed: string
  120. leaked_local_to_global: boolean
  121. leaked_global_to_local: boolean
  122. local_theme: string
  123. global_theme: string
  124. }
  125. async function row(file: string): Promise<Row> {
  126. return Filesystem.readJson<Row>(file)
  127. }
  128. async function load(): Promise<Data> {
  129. const stamp = Date.now()
  130. const globalConfigPath = path.join(Global.Path.config, "tui.json")
  131. const backup = await Bun.file(globalConfigPath)
  132. .text()
  133. .catch(() => undefined)
  134. await using tmp = await tmpdir({
  135. init: async (dir) => {
  136. const localPluginPath = path.join(dir, "local-plugin.ts")
  137. const invalidPluginPath = path.join(dir, "invalid-plugin.ts")
  138. const preloadedPluginPath = path.join(dir, "preloaded-plugin.ts")
  139. const globalPluginPath = path.join(dir, "global-plugin.ts")
  140. const localSpec = pathToFileURL(localPluginPath).href
  141. const invalidSpec = pathToFileURL(invalidPluginPath).href
  142. const preloadedSpec = pathToFileURL(preloadedPluginPath).href
  143. const globalSpec = pathToFileURL(globalPluginPath).href
  144. const localThemeFile = `local-theme-${stamp}.json`
  145. const invalidThemeFile = `invalid-theme-${stamp}.json`
  146. const globalThemeFile = `global-theme-${stamp}.json`
  147. const preloadedThemeFile = `preloaded-theme-${stamp}.json`
  148. const localThemeName = localThemeFile.replace(/\.json$/, "")
  149. const invalidThemeName = invalidThemeFile.replace(/\.json$/, "")
  150. const globalThemeName = globalThemeFile.replace(/\.json$/, "")
  151. const preloadedThemeName = preloadedThemeFile.replace(/\.json$/, "")
  152. const localThemePath = path.join(dir, localThemeFile)
  153. const invalidThemePath = path.join(dir, invalidThemeFile)
  154. const globalThemePath = path.join(dir, globalThemeFile)
  155. const preloadedThemePath = path.join(dir, preloadedThemeFile)
  156. const localDest = path.join(dir, ".kirincode", "themes", localThemeFile)
  157. const globalDest = path.join(Global.Path.config, "themes", globalThemeFile)
  158. const preloadedDest = path.join(dir, ".kirincode", "themes", preloadedThemeFile)
  159. const fnMarker = path.join(dir, "function-called.txt")
  160. const localMarker = path.join(dir, "local-called.json")
  161. const invalidMarker = path.join(dir, "invalid-called.json")
  162. const globalMarker = path.join(dir, "global-called.json")
  163. const preloadedMarker = path.join(dir, "preloaded-called.json")
  164. const localConfigPath = path.join(dir, "tui.json")
  165. await Bun.write(localThemePath, JSON.stringify({ theme: { primary: "#101010" } }, null, 2))
  166. await Bun.write(invalidThemePath, "{ invalid json }")
  167. await Bun.write(globalThemePath, JSON.stringify({ theme: { primary: "#202020" } }, null, 2))
  168. await Bun.write(preloadedThemePath, JSON.stringify({ theme: { primary: "#f0f0f0" } }, null, 2))
  169. await Bun.write(preloadedDest, JSON.stringify({ theme: { primary: "#303030" } }, null, 2))
  170. await Bun.write(
  171. localPluginPath,
  172. `import { createBindingLookup } from "@opentui/keymap/extras"
  173. import { useBindings } from "@opentui/keymap/solid"
  174. export const ignored = async (_input, options) => {
  175. if (!options?.fn_marker) return
  176. await Bun.write(options.fn_marker, "called")
  177. }
  178. export default {
  179. id: "demo.local",
  180. tui: async (api, options) => {
  181. if (!options?.marker) return
  182. const cfg_theme = api.tuiConfig.theme
  183. const cfg_diff = api.tuiConfig.diff_style
  184. const cfg_speed = api.tuiConfig.scroll_speed
  185. const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled
  186. const has_keys = typeof api.keys.formatBindings === "function"
  187. const keybinds = createBindingLookup(options.keybinds ?? {
  188. "plugin.loader.local": "ctrl+shift+m",
  189. "plugin.loader.close": "escape",
  190. })
  191. const bindings = keybinds.gather("plugin.loader", ["plugin.loader.local", "plugin.loader.close"])
  192. const key_modal = bindings.find((item) => item.cmd === "plugin.loader.local")?.key
  193. const key_close = bindings.find((item) => item.cmd === "plugin.loader.close")?.key
  194. const key_unknown = "ctrl+k"
  195. const off = api.keymap.registerLayer({
  196. commands: [{ name: "plugin.loader.local", run() {} }, { name: "plugin.loader.close", run() {} }],
  197. bindings,
  198. })
  199. off()
  200. const kv_before = api.kv.get(options.kv_key, "missing")
  201. api.kv.set(options.kv_key, "stored")
  202. const kv_after = api.kv.get(options.kv_key, "missing")
  203. const diff = api.state.session.diff(options.session_id)
  204. const todo = api.state.session.todo(options.session_id)
  205. const lsp = api.state.lsp()
  206. const mcp = api.state.mcp()
  207. const depth_before = api.ui.dialog.depth
  208. const open_before = api.ui.dialog.open
  209. const size_before = api.ui.dialog.size
  210. api.ui.dialog.setSize("large")
  211. const size_after = api.ui.dialog.size
  212. api.ui.dialog.replace(() => null)
  213. const depth_after = api.ui.dialog.depth
  214. const open_after = api.ui.dialog.open
  215. api.ui.dialog.clear()
  216. const open_clear = api.ui.dialog.open
  217. const before = api.theme.has(options.theme_name)
  218. const set_missing = api.theme.set(options.theme_name)
  219. await api.theme.install(options.theme_path)
  220. const after = api.theme.has(options.theme_name)
  221. const set_installed = api.theme.set(options.theme_name)
  222. const first = await Bun.file(options.dest).text()
  223. await Bun.write(options.source, JSON.stringify({ theme: { primary: "#fefefe" } }, null, 2))
  224. await api.theme.install(options.theme_path)
  225. const second = await Bun.file(options.dest).text()
  226. await Bun.write(
  227. options.marker,
  228. JSON.stringify({
  229. before,
  230. set_missing,
  231. after,
  232. set_installed,
  233. selected: api.theme.selected,
  234. same: first === second,
  235. key_modal,
  236. key_close,
  237. key_unknown,
  238. has_keys,
  239. has_keymap: typeof api.keymap.registerLayer === "function",
  240. has_create_binding_lookup: typeof createBindingLookup === "function",
  241. has_keymap_solid: typeof useBindings === "function",
  242. kv_before,
  243. kv_after,
  244. kv_ready: api.kv.ready,
  245. diff_count: diff.length,
  246. diff_file: diff[0]?.file,
  247. todo_count: todo.length,
  248. todo_first: todo[0]?.content,
  249. lsp_count: lsp.length,
  250. mcp_count: mcp.length,
  251. mcp_first: mcp[0]?.name,
  252. depth_before,
  253. open_before,
  254. size_before,
  255. size_after,
  256. depth_after,
  257. open_after,
  258. open_clear,
  259. cfg_theme,
  260. cfg_diff,
  261. cfg_speed,
  262. cfg_accel,
  263. }),
  264. )
  265. },
  266. }
  267. `,
  268. )
  269. await Bun.write(
  270. invalidPluginPath,
  271. `export default {
  272. id: "demo.invalid",
  273. tui: async (api, options) => {
  274. if (!options?.marker) return
  275. const before = api.theme.has(options.theme_name)
  276. const set_missing = api.theme.set(options.theme_name)
  277. await api.theme.install(options.theme_path)
  278. const after = api.theme.has(options.theme_name)
  279. const set_installed = api.theme.set(options.theme_name)
  280. await Bun.write(
  281. options.marker,
  282. JSON.stringify({
  283. before,
  284. set_missing,
  285. after,
  286. set_installed,
  287. }),
  288. )
  289. },
  290. }
  291. `,
  292. )
  293. await Bun.write(
  294. preloadedPluginPath,
  295. `export default {
  296. id: "demo.preloaded",
  297. tui: async (api, options) => {
  298. if (!options?.marker) return
  299. const before = api.theme.has(options.theme_name)
  300. await api.theme.install(options.theme_path)
  301. const after = api.theme.has(options.theme_name)
  302. const text = await Bun.file(options.dest).text()
  303. await Bun.write(
  304. options.marker,
  305. JSON.stringify({
  306. before,
  307. after,
  308. text,
  309. }),
  310. )
  311. },
  312. }
  313. `,
  314. )
  315. await Bun.write(
  316. globalPluginPath,
  317. `export default {
  318. id: "demo.global",
  319. tui: async (api, options) => {
  320. if (!options?.marker) return
  321. await api.theme.install(options.theme_path)
  322. const has = api.theme.has(options.theme_name)
  323. const set_installed = api.theme.set(options.theme_name)
  324. await Bun.write(
  325. options.marker,
  326. JSON.stringify({
  327. has,
  328. set_installed,
  329. selected: api.theme.selected,
  330. }),
  331. )
  332. },
  333. }
  334. `,
  335. )
  336. await Bun.write(
  337. globalConfigPath,
  338. JSON.stringify(
  339. {
  340. plugin: [
  341. [globalSpec, { marker: globalMarker, theme_path: `./${globalThemeFile}`, theme_name: globalThemeName }],
  342. ],
  343. },
  344. null,
  345. 2,
  346. ),
  347. )
  348. await Bun.write(
  349. localConfigPath,
  350. JSON.stringify(
  351. {
  352. plugin: [
  353. [
  354. localSpec,
  355. {
  356. fn_marker: fnMarker,
  357. marker: localMarker,
  358. source: localThemePath,
  359. dest: localDest,
  360. theme_path: `./${localThemeFile}`,
  361. theme_name: localThemeName,
  362. kv_key: "plugin_state_key",
  363. session_id: "ses_test",
  364. keybinds: {
  365. modal: "ctrl+alt+m",
  366. close: "q",
  367. },
  368. },
  369. ],
  370. [
  371. invalidSpec,
  372. {
  373. marker: invalidMarker,
  374. theme_path: `./${invalidThemeFile}`,
  375. theme_name: invalidThemeName,
  376. },
  377. ],
  378. [
  379. preloadedSpec,
  380. {
  381. marker: preloadedMarker,
  382. dest: preloadedDest,
  383. theme_path: `./${preloadedThemeFile}`,
  384. theme_name: preloadedThemeName,
  385. },
  386. ],
  387. ],
  388. },
  389. null,
  390. 2,
  391. ),
  392. )
  393. return {
  394. localThemeFile,
  395. invalidThemeFile,
  396. globalThemeFile,
  397. preloadedThemeFile,
  398. localThemeName,
  399. invalidThemeName,
  400. globalThemeName,
  401. preloadedThemeName,
  402. localDest,
  403. globalDest,
  404. preloadedDest,
  405. localPluginPath,
  406. invalidPluginPath,
  407. globalPluginPath,
  408. preloadedPluginPath,
  409. localSpec,
  410. invalidSpec,
  411. globalSpec,
  412. preloadedSpec,
  413. fnMarker,
  414. localMarker,
  415. invalidMarker,
  416. globalMarker,
  417. preloadedMarker,
  418. }
  419. },
  420. })
  421. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  422. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  423. try {
  424. expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
  425. const localOpts = {
  426. fn_marker: tmp.extra.fnMarker,
  427. marker: tmp.extra.localMarker,
  428. source: path.join(tmp.path, tmp.extra.localThemeFile),
  429. dest: tmp.extra.localDest,
  430. theme_path: `./${tmp.extra.localThemeFile}`,
  431. theme_name: tmp.extra.localThemeName,
  432. kv_key: "plugin_state_key",
  433. session_id: "ses_test",
  434. keybinds: {
  435. "plugin.loader.local": "ctrl+alt+m",
  436. "plugin.loader.close": "q",
  437. },
  438. }
  439. const invalidOpts = {
  440. marker: tmp.extra.invalidMarker,
  441. theme_path: `./${tmp.extra.invalidThemeFile}`,
  442. theme_name: tmp.extra.invalidThemeName,
  443. }
  444. const preloadedOpts = {
  445. marker: tmp.extra.preloadedMarker,
  446. dest: tmp.extra.preloadedDest,
  447. theme_path: `./${tmp.extra.preloadedThemeFile}`,
  448. theme_name: tmp.extra.preloadedThemeName,
  449. }
  450. const globalOpts = {
  451. marker: tmp.extra.globalMarker,
  452. theme_path: `./${tmp.extra.globalThemeFile}`,
  453. theme_name: tmp.extra.globalThemeName,
  454. }
  455. const config = createTuiResolvedConfig({
  456. plugin: [
  457. [tmp.extra.localSpec, localOpts],
  458. [tmp.extra.invalidSpec, invalidOpts],
  459. [tmp.extra.preloadedSpec, preloadedOpts],
  460. [tmp.extra.globalSpec, globalOpts],
  461. ],
  462. plugin_origins: [
  463. { spec: [tmp.extra.localSpec, localOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
  464. { spec: [tmp.extra.invalidSpec, invalidOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
  465. { spec: [tmp.extra.preloadedSpec, preloadedOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
  466. {
  467. spec: [tmp.extra.globalSpec, globalOpts],
  468. scope: "global",
  469. source: path.join(Global.Path.config, "tui.json"),
  470. },
  471. ],
  472. })
  473. await TuiPluginRuntime.init({
  474. api: createTuiPluginApi({
  475. tuiConfig: {
  476. theme: "smoke",
  477. diff_style: "stacked",
  478. scroll_speed: 1.5,
  479. scroll_acceleration: { enabled: true },
  480. },
  481. state: {
  482. session: {
  483. diff(sessionID) {
  484. if (sessionID !== "ses_test") return []
  485. return [{ file: "src/app.ts", additions: 3, deletions: 1 }]
  486. },
  487. todo(sessionID) {
  488. if (sessionID !== "ses_test") return []
  489. return [{ content: "ship it", status: "pending" }]
  490. },
  491. },
  492. lsp() {
  493. return [{ id: "ts", root: "/tmp/project", status: "connected" }]
  494. },
  495. mcp() {
  496. return [{ name: "github", status: "connected" }]
  497. },
  498. },
  499. theme: {
  500. has(name) {
  501. return allThemes()[name] !== undefined
  502. },
  503. },
  504. }),
  505. config,
  506. })
  507. const local = await row(tmp.extra.localMarker)
  508. const global = await row(tmp.extra.globalMarker)
  509. const invalid = await row(tmp.extra.invalidMarker)
  510. const preloaded = await row(tmp.extra.preloadedMarker)
  511. const fn_called = await fs
  512. .readFile(tmp.extra.fnMarker, "utf8")
  513. .then(() => true)
  514. .catch(() => false)
  515. const local_installed = await fs.readFile(tmp.extra.localDest, "utf8")
  516. const global_installed = await fs.readFile(tmp.extra.globalDest, "utf8")
  517. const preloaded_installed = await fs.readFile(tmp.extra.preloadedDest, "utf8")
  518. const leaked_local_to_global = await fs
  519. .stat(path.join(Global.Path.config, "themes", tmp.extra.localThemeFile))
  520. .then(() => true)
  521. .catch(() => false)
  522. const leaked_global_to_local = await fs
  523. .stat(path.join(tmp.path, ".kirincode", "themes", tmp.extra.globalThemeFile))
  524. .then(() => true)
  525. .catch(() => false)
  526. return {
  527. local,
  528. global,
  529. invalid,
  530. preloaded,
  531. fn_called,
  532. local_installed,
  533. global_installed,
  534. preloaded_installed,
  535. leaked_local_to_global,
  536. leaked_global_to_local,
  537. local_theme: tmp.extra.localThemeName,
  538. global_theme: tmp.extra.globalThemeName,
  539. }
  540. } finally {
  541. await TuiPluginRuntime.dispose()
  542. cwd.mockRestore()
  543. wait.mockRestore()
  544. if (backup === undefined) {
  545. await fs.rm(globalConfigPath, { force: true })
  546. } else {
  547. await Bun.write(globalConfigPath, backup)
  548. }
  549. await fs.rm(tmp.extra.globalDest, { force: true }).catch(() => {})
  550. }
  551. }
  552. test("continues loading when a plugin is missing config metadata", async () => {
  553. await using tmp = await tmpdir({
  554. init: async (dir) => {
  555. const bad = path.join(dir, "missing-meta-plugin.ts")
  556. const good = path.join(dir, "next-plugin.ts")
  557. const bare = path.join(dir, "plain-plugin.ts")
  558. const badSpec = pathToFileURL(bad).href
  559. const goodSpec = pathToFileURL(good).href
  560. const bareSpec = pathToFileURL(bare).href
  561. const goodMarker = path.join(dir, "next-called.txt")
  562. const bareMarker = path.join(dir, "plain-called.txt")
  563. for (const [file, id] of [
  564. [bad, "demo.missing-meta"],
  565. [good, "demo.next"],
  566. ] as const) {
  567. await Bun.write(
  568. file,
  569. `export default {
  570. id: "${id}",
  571. tui: async (_api, options) => {
  572. if (!options?.marker) return
  573. await Bun.write(options.marker, "called")
  574. },
  575. }
  576. `,
  577. )
  578. }
  579. await Bun.write(
  580. bare,
  581. `export default {
  582. id: "demo.plain",
  583. tui: async (_api, options) => {
  584. await Bun.write(${JSON.stringify(bareMarker)}, options === undefined ? "undefined" : "value")
  585. },
  586. }
  587. `,
  588. )
  589. return { badSpec, goodSpec, bareSpec, goodMarker, bareMarker }
  590. },
  591. })
  592. process.env.KIRINCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  593. const config = createTuiResolvedConfig({
  594. plugin: [
  595. [tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
  596. [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
  597. tmp.extra.bareSpec,
  598. ],
  599. plugin_origins: [
  600. {
  601. spec: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
  602. scope: "local",
  603. source: path.join(tmp.path, "tui.json"),
  604. },
  605. {
  606. spec: tmp.extra.bareSpec,
  607. scope: "local",
  608. source: path.join(tmp.path, "tui.json"),
  609. },
  610. ],
  611. })
  612. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  613. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  614. try {
  615. await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
  616. // bad plugin was skipped (no metadata entry)
  617. await expect(fs.readFile(path.join(tmp.path, "bad.txt"), "utf8")).rejects.toThrow()
  618. // good plugin loaded fine
  619. await expect(fs.readFile(tmp.extra.goodMarker, "utf8")).resolves.toBe("called")
  620. // bare string spec gets undefined options
  621. await expect(fs.readFile(tmp.extra.bareMarker, "utf8")).resolves.toBe("undefined")
  622. } finally {
  623. await TuiPluginRuntime.dispose()
  624. cwd.mockRestore()
  625. wait.mockRestore()
  626. delete process.env.KIRINCODE_PLUGIN_META_FILE
  627. }
  628. })
  629. test("does not wait on permanent tui plugin startup failures", async () => {
  630. await using tmp = await tmpdir({
  631. init: async (dir) => {
  632. const binary = path.join(dir, "binary-plugin")
  633. const invalidShape = path.join(dir, "invalid-shape-plugin.ts")
  634. const missingID = path.join(dir, "missing-id-plugin.ts")
  635. const good = path.join(dir, "good-plugin.ts")
  636. const marker = path.join(dir, "good-called.txt")
  637. await Bun.write(binary, new Uint8Array([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00, 0x00, 0x01]))
  638. await Bun.write(invalidShape, `export default { id: "demo.invalid.shape" }\n`)
  639. await Bun.write(missingID, `export default { tui: async () => {} }\n`)
  640. await Bun.write(
  641. good,
  642. `export default {
  643. id: "demo.good.after-bad",
  644. tui: async () => {
  645. await Bun.write(${JSON.stringify(marker)}, "called")
  646. },
  647. }
  648. `,
  649. )
  650. return {
  651. binarySpec: pathToFileURL(binary).href,
  652. invalidShapeSpec: pathToFileURL(invalidShape).href,
  653. missingIDSpec: pathToFileURL(missingID).href,
  654. goodSpec: pathToFileURL(good).href,
  655. marker,
  656. }
  657. },
  658. })
  659. process.env.KIRINCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  660. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  661. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  662. try {
  663. await TuiPluginRuntime.init({
  664. api: createTuiPluginApi(),
  665. config: createTuiResolvedConfig({
  666. plugin: [tmp.extra.binarySpec, tmp.extra.invalidShapeSpec, tmp.extra.missingIDSpec, tmp.extra.goodSpec],
  667. plugin_origins: [
  668. { spec: tmp.extra.binarySpec, scope: "local", source: path.join(tmp.path, "tui.json") },
  669. { spec: tmp.extra.invalidShapeSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
  670. { spec: tmp.extra.missingIDSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
  671. { spec: tmp.extra.goodSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
  672. ],
  673. }),
  674. })
  675. expect(wait).toHaveBeenCalledTimes(0)
  676. await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
  677. expect(TuiPluginRuntime.list().find((item) => item.id === "demo.good.after-bad")?.active).toBe(true)
  678. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.binarySpec)).toBe(false)
  679. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.invalidShapeSpec)).toBe(false)
  680. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.missingIDSpec)).toBe(false)
  681. } finally {
  682. await TuiPluginRuntime.dispose()
  683. cwd.mockRestore()
  684. wait.mockRestore()
  685. delete process.env.KIRINCODE_PLUGIN_META_FILE
  686. }
  687. })
  688. test("initializes external tui plugins in config order", async () => {
  689. const globalJson = path.join(Global.Path.config, "tui.json")
  690. const globalJsonc = path.join(Global.Path.config, "tui.jsonc")
  691. const backupJson = await Bun.file(globalJson)
  692. .text()
  693. .catch(() => undefined)
  694. const backupJsonc = await Bun.file(globalJsonc)
  695. .text()
  696. .catch(() => undefined)
  697. await fs.rm(globalJson, { force: true }).catch(() => {})
  698. await fs.rm(globalJsonc, { force: true }).catch(() => {})
  699. await using tmp = await tmpdir({
  700. init: async (dir) => {
  701. const a = path.join(dir, "order-a.ts")
  702. const b = path.join(dir, "order-b.ts")
  703. const aSpec = pathToFileURL(a).href
  704. const bSpec = pathToFileURL(b).href
  705. const marker = path.join(dir, "tui-order.txt")
  706. await Bun.write(
  707. a,
  708. `import fs from "fs/promises"
  709. export default {
  710. id: "demo.tui.order.a",
  711. tui: async () => {
  712. await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n")
  713. await Bun.sleep(25)
  714. await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n")
  715. },
  716. }
  717. `,
  718. )
  719. await Bun.write(
  720. b,
  721. `import fs from "fs/promises"
  722. export default {
  723. id: "demo.tui.order.b",
  724. tui: async () => {
  725. await fs.appendFile(${JSON.stringify(marker)}, "b\\n")
  726. },
  727. }
  728. `,
  729. )
  730. await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2))
  731. return { marker }
  732. },
  733. })
  734. process.env.KIRINCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  735. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  736. try {
  737. const a = path.join(tmp.path, "order-a.ts")
  738. const b = path.join(tmp.path, "order-b.ts")
  739. const aSpec = pathToFileURL(a).href
  740. const bSpec = pathToFileURL(b).href
  741. const config = createTuiResolvedConfig({
  742. plugin: [aSpec, bSpec],
  743. plugin_origins: [
  744. { spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
  745. { spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
  746. ],
  747. })
  748. await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
  749. const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
  750. expect(lines).toEqual(["a-start", "a-end", "b"])
  751. } finally {
  752. await TuiPluginRuntime.dispose()
  753. cwd.mockRestore()
  754. delete process.env.KIRINCODE_PLUGIN_META_FILE
  755. if (backupJson === undefined) {
  756. await fs.rm(globalJson, { force: true }).catch(() => {})
  757. } else {
  758. await Bun.write(globalJson, backupJson)
  759. }
  760. if (backupJsonc === undefined) {
  761. await fs.rm(globalJsonc, { force: true }).catch(() => {})
  762. } else {
  763. await Bun.write(globalJsonc, backupJsonc)
  764. }
  765. }
  766. })
  767. test("does not bootstrap server plugins while initializing tui plugins", async () => {
  768. await using tmp = await tmpdir({
  769. init: async (dir) => {
  770. const marker = path.join(dir, "server-plugin-called.txt")
  771. const plugin = path.join(dir, "server-plugin.ts")
  772. await Bun.write(
  773. plugin,
  774. [
  775. "export default async () => {",
  776. ` await Bun.write(${JSON.stringify(marker)}, "called")`,
  777. " return {}",
  778. "}",
  779. "",
  780. ].join("\n"),
  781. )
  782. await Bun.write(path.join(dir, "kirincode.json"), JSON.stringify({ plugin: [pathToFileURL(plugin).href] }))
  783. return { marker }
  784. },
  785. })
  786. const mock = mockTuiRuntime(tmp.path, [])
  787. try {
  788. await TuiPluginRuntime.init({ api: createTuiPluginApi(), config: mock.config })
  789. await expect(fs.stat(tmp.extra.marker)).rejects.toThrow()
  790. } finally {
  791. await TuiPluginRuntime.dispose()
  792. mock.restore()
  793. }
  794. })
  795. describe("tui.plugin.loader", () => {
  796. let data: Data
  797. beforeAll(async () => {
  798. data = await load()
  799. })
  800. test("passes keybind, kv, state, and dialog APIs to v1 plugins", () => {
  801. expect(data.local.key_modal).toBe("ctrl+alt+m")
  802. expect(data.local.key_close).toBe("q")
  803. expect(data.local.key_unknown).toBe("ctrl+k")
  804. expect(data.local.has_keys).toBe(true)
  805. expect(data.local.has_keymap).toBe(true)
  806. expect(data.local.has_create_binding_lookup).toBe(true)
  807. expect(data.local.has_keymap_solid).toBe(true)
  808. expect(data.local.kv_before).toBe("missing")
  809. expect(data.local.kv_after).toBe("stored")
  810. expect(data.local.kv_ready).toBe(true)
  811. expect(data.local.diff_count).toBe(1)
  812. expect(data.local.diff_file).toBe("src/app.ts")
  813. expect(data.local.todo_count).toBe(1)
  814. expect(data.local.todo_first).toBe("ship it")
  815. expect(data.local.lsp_count).toBe(1)
  816. expect(data.local.mcp_count).toBe(1)
  817. expect(data.local.mcp_first).toBe("github")
  818. expect(data.local.depth_before).toBe(0)
  819. expect(data.local.open_before).toBe(false)
  820. expect(data.local.size_before).toBe("medium")
  821. expect(data.local.size_after).toBe("large")
  822. expect(data.local.depth_after).toBe(1)
  823. expect(data.local.open_after).toBe(true)
  824. expect(data.local.open_clear).toBe(false)
  825. expect(data.local.cfg_theme).toBe("smoke")
  826. expect(data.local.cfg_diff).toBe("stacked")
  827. expect(data.local.cfg_speed).toBe(1.5)
  828. expect(data.local.cfg_accel).toBe(true)
  829. })
  830. test("installs themes in the correct scope and remains resilient", () => {
  831. expect(data.local.before).toBe(false)
  832. expect(data.local.set_missing).toBe(false)
  833. expect(data.local.after).toBe(true)
  834. expect(data.local.set_installed).toBe(true)
  835. expect(data.local.selected).toBe(data.local_theme)
  836. expect(data.local.same).toBe(true)
  837. expect(data.global.has).toBe(true)
  838. expect(data.global.set_installed).toBe(true)
  839. expect(data.global.selected).toBe(data.global_theme)
  840. expect(data.invalid.before).toBe(false)
  841. expect(data.invalid.set_missing).toBe(false)
  842. expect(data.invalid.after).toBe(false)
  843. expect(data.invalid.set_installed).toBe(false)
  844. expect(data.preloaded.before).toBe(true)
  845. expect(data.preloaded.after).toBe(true)
  846. expect(data.preloaded.text).toContain("#303030")
  847. expect(data.preloaded.text).not.toContain("#f0f0f0")
  848. expect(data.fn_called).toBe(false)
  849. expect(data.local_installed).toContain("#101010")
  850. expect(data.local_installed).not.toContain("#fefefe")
  851. expect(data.global_installed).toContain("#202020")
  852. expect(data.preloaded_installed).toContain("#303030")
  853. expect(data.preloaded_installed).not.toContain("#f0f0f0")
  854. expect(data.leaked_local_to_global).toBe(false)
  855. expect(data.leaked_global_to_local).toBe(false)
  856. })
  857. })
  858. test("auto-disposes plugin keymap layers", async () => {
  859. await using tmp = await tmpdir({
  860. init: async (dir) => {
  861. const file = path.join(dir, "keymap-cleanup-plugin.ts")
  862. const spec = pathToFileURL(file).href
  863. await Bun.write(
  864. file,
  865. `export default {
  866. id: "demo.keymap.cleanup",
  867. tui: async (api) => {
  868. api.keymap.registerLayer({
  869. commands: [{ name: "demo.keymap.cleanup", run() {} }],
  870. bindings: [{ key: "ctrl+g", cmd: "demo.keymap.cleanup" }],
  871. })
  872. },
  873. }
  874. `,
  875. )
  876. return { spec }
  877. },
  878. })
  879. let command_add = 0
  880. let command_drop = 0
  881. const keymap = {
  882. registerLayer(layer: { commands?: Array<{ name: string }> }) {
  883. const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup") ?? false
  884. if (tracked) command_add += 1
  885. return () => {
  886. if (!tracked) return
  887. command_drop += 1
  888. }
  889. },
  890. } as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
  891. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  892. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  893. try {
  894. await TuiPluginRuntime.init({
  895. api: createTuiPluginApi({ keymap }),
  896. config: createTuiResolvedConfig({
  897. plugin: [tmp.extra.spec],
  898. plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
  899. }),
  900. })
  901. expect(command_add).toBe(1)
  902. expect(command_drop).toBe(0)
  903. } finally {
  904. await TuiPluginRuntime.dispose()
  905. expect(command_drop).toBe(1)
  906. cwd.mockRestore()
  907. wait.mockRestore()
  908. }
  909. })
  910. test("plugin keymap proxy preserves real keymap receiver", async () => {
  911. await using tmp = await tmpdir({
  912. init: async (dir) => {
  913. const file = path.join(dir, "keymap-receiver-plugin.ts")
  914. const spec = pathToFileURL(file).href
  915. const marker = path.join(dir, "keymap-receiver.txt")
  916. await Bun.write(
  917. file,
  918. `export default {
  919. id: "demo.keymap.receiver",
  920. tui: async (api) => {
  921. api.keymap.setData("demo.receiver", "ok")
  922. await Bun.write(${JSON.stringify(marker)}, String(api.keymap.getData("demo.receiver")))
  923. },
  924. }
  925. `,
  926. )
  927. return { spec, marker }
  928. },
  929. })
  930. const harness = createTestKeymap({ defaultKeys: true })
  931. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  932. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  933. try {
  934. await TuiPluginRuntime.init({
  935. api: createTuiPluginApi({
  936. keymap: harness.keymap as unknown as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"],
  937. }),
  938. config: createTuiResolvedConfig({
  939. plugin: [tmp.extra.spec],
  940. plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
  941. }),
  942. })
  943. await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("ok")
  944. expect(harness.keymap.getData("demo.receiver")).toBe("ok")
  945. } finally {
  946. await TuiPluginRuntime.dispose()
  947. harness.cleanup()
  948. cwd.mockRestore()
  949. wait.mockRestore()
  950. }
  951. })
  952. test("auto-disposes plugin attention sound packs and resolves sound paths", async () => {
  953. await using tmp = await tmpdir({
  954. init: async (dir) => {
  955. const file = path.join(dir, "attention-soundpack-plugin.ts")
  956. const spec = pathToFileURL(file).href
  957. const absolute = path.join(dir, "sounds", "default.mp3")
  958. const url = pathToFileURL(path.join(dir, "sounds", "error.mp3")).href
  959. await Bun.write(
  960. file,
  961. `export default {
  962. id: "demo.attention.soundpack",
  963. tui: async (api) => {
  964. api.attention.soundboard.registerPack({
  965. id: "demo.pack",
  966. sounds: {
  967. default: ${JSON.stringify(absolute)},
  968. question: "sounds/question.mp3",
  969. done: " sounds/done.mp3 ",
  970. subagent_done: "sounds/subagent-done.mp3",
  971. error: ${JSON.stringify(url)},
  972. nope: "sounds/nope.mp3",
  973. permission: "",
  974. },
  975. })
  976. },
  977. }
  978. `,
  979. )
  980. return { spec }
  981. },
  982. })
  983. const packs: TuiAttentionSoundPack[] = []
  984. let dropped = 0
  985. const attention = {
  986. soundboard: {
  987. registerPack(pack: TuiAttentionSoundPack) {
  988. packs.push(pack)
  989. return () => {
  990. dropped += 1
  991. }
  992. },
  993. },
  994. }
  995. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  996. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  997. try {
  998. await TuiPluginRuntime.init({
  999. api: createTuiPluginApi({ attention }),
  1000. config: createTuiResolvedConfig({
  1001. plugin: [tmp.extra.spec],
  1002. plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
  1003. }),
  1004. })
  1005. expect(packs).toEqual([
  1006. {
  1007. id: "demo.pack",
  1008. sounds: {
  1009. default: path.join(tmp.path, "sounds", "default.mp3"),
  1010. question: path.join(tmp.path, "sounds", "question.mp3"),
  1011. done: path.join(tmp.path, "sounds", "done.mp3"),
  1012. subagent_done: path.join(tmp.path, "sounds", "subagent-done.mp3"),
  1013. error: path.join(tmp.path, "sounds", "error.mp3"),
  1014. },
  1015. },
  1016. ])
  1017. expect(dropped).toBe(0)
  1018. } finally {
  1019. await TuiPluginRuntime.dispose()
  1020. expect(dropped).toBe(1)
  1021. cwd.mockRestore()
  1022. wait.mockRestore()
  1023. }
  1024. })
  1025. test("auto-disposes plugin keymap transformers", async () => {
  1026. await using tmp = await tmpdir({
  1027. init: async (dir) => {
  1028. const file = path.join(dir, "keymap-transformer-cleanup-plugin.ts")
  1029. const spec = pathToFileURL(file).href
  1030. await Bun.write(
  1031. file,
  1032. `export default {
  1033. id: "demo.keymap.transformer.cleanup",
  1034. tui: async (api) => {
  1035. api.keymap.prependLayerBindingsTransformer((bindings) => bindings)
  1036. api.keymap.appendLayerBindingsTransformer((bindings) => bindings)
  1037. api.keymap.prependCommandTransformer(() => {})
  1038. api.keymap.appendCommandTransformer(() => {})
  1039. },
  1040. }
  1041. `,
  1042. )
  1043. return { spec }
  1044. },
  1045. })
  1046. let add = 0
  1047. let drop = 0
  1048. const track = () => {
  1049. add += 1
  1050. return () => {
  1051. drop += 1
  1052. }
  1053. }
  1054. const keymap = {
  1055. registerLayer: () => () => {},
  1056. prependLayerBindingsTransformer: track,
  1057. appendLayerBindingsTransformer: track,
  1058. prependCommandTransformer: track,
  1059. appendCommandTransformer: track,
  1060. } as unknown as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
  1061. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  1062. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  1063. try {
  1064. await TuiPluginRuntime.init({
  1065. api: createTuiPluginApi({ keymap }),
  1066. config: createTuiResolvedConfig({
  1067. plugin: [tmp.extra.spec],
  1068. plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
  1069. }),
  1070. })
  1071. expect(add).toBe(4)
  1072. expect(drop).toBe(0)
  1073. } finally {
  1074. await TuiPluginRuntime.dispose()
  1075. expect(drop).toBe(4)
  1076. cwd.mockRestore()
  1077. wait.mockRestore()
  1078. }
  1079. })
  1080. test("manual onDispose for plugin keymap layers stays idempotent", async () => {
  1081. await using tmp = await tmpdir({
  1082. init: async (dir) => {
  1083. const file = path.join(dir, "keymap-cleanup-manual-plugin.ts")
  1084. const spec = pathToFileURL(file).href
  1085. await Bun.write(
  1086. file,
  1087. `export default {
  1088. id: "demo.keymap.cleanup.manual",
  1089. tui: async (api) => {
  1090. const off = api.keymap.registerLayer({
  1091. commands: [{ name: "demo.keymap.cleanup.manual", run() {} }],
  1092. bindings: [{ key: "ctrl+h", cmd: "demo.keymap.cleanup.manual" }],
  1093. })
  1094. api.lifecycle.onDispose(off)
  1095. },
  1096. }
  1097. `,
  1098. )
  1099. return { spec }
  1100. },
  1101. })
  1102. let command_drop = 0
  1103. const keymap = {
  1104. registerLayer(layer: { commands?: Array<{ name: string }> }) {
  1105. const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup.manual") ?? false
  1106. return () => {
  1107. if (!tracked) return
  1108. command_drop += 1
  1109. }
  1110. },
  1111. } as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
  1112. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  1113. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  1114. try {
  1115. await TuiPluginRuntime.init({
  1116. api: createTuiPluginApi({ keymap }),
  1117. config: createTuiResolvedConfig({
  1118. plugin: [tmp.extra.spec],
  1119. plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
  1120. }),
  1121. })
  1122. } finally {
  1123. await TuiPluginRuntime.dispose()
  1124. expect(command_drop).toBe(1)
  1125. cwd.mockRestore()
  1126. wait.mockRestore()
  1127. }
  1128. })
  1129. test("updates installed theme when plugin metadata changes", async () => {
  1130. await using tmp = await tmpdir<{
  1131. spec: string
  1132. pluginPath: string
  1133. themePath: string
  1134. dest: string
  1135. themeName: string
  1136. }>({
  1137. init: async (dir) => {
  1138. const pluginPath = path.join(dir, "theme-update-plugin.ts")
  1139. const spec = pathToFileURL(pluginPath).href
  1140. const themeFile = "theme-update.json"
  1141. const themePath = path.join(dir, themeFile)
  1142. const dest = path.join(dir, ".kirincode", "themes", themeFile)
  1143. const themeName = themeFile.replace(/\.json$/, "")
  1144. const configPath = path.join(dir, "tui.json")
  1145. await Bun.write(themePath, JSON.stringify({ theme: { primary: "#111111" } }, null, 2))
  1146. await Bun.write(
  1147. pluginPath,
  1148. `export default {
  1149. id: "demo.theme-update",
  1150. tui: async (api, options) => {
  1151. if (!options?.theme_path) return
  1152. await api.theme.install(options.theme_path)
  1153. },
  1154. }
  1155. `,
  1156. )
  1157. await Bun.write(
  1158. configPath,
  1159. JSON.stringify(
  1160. {
  1161. plugin: [[spec, { theme_path: `./${themeFile}` }]],
  1162. },
  1163. null,
  1164. 2,
  1165. ),
  1166. )
  1167. return {
  1168. spec,
  1169. pluginPath,
  1170. themePath,
  1171. dest,
  1172. themeName,
  1173. }
  1174. },
  1175. })
  1176. process.env.KIRINCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  1177. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  1178. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  1179. const mkApi = () =>
  1180. createTuiPluginApi({
  1181. theme: {
  1182. has(name) {
  1183. return allThemes()[name] !== undefined
  1184. },
  1185. },
  1186. })
  1187. const mkConfig = () =>
  1188. createTuiResolvedConfig({
  1189. plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]],
  1190. plugin_origins: [
  1191. {
  1192. spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }],
  1193. scope: "local",
  1194. source: path.join(tmp.path, "tui.json"),
  1195. },
  1196. ],
  1197. })
  1198. try {
  1199. await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
  1200. await TuiPluginRuntime.dispose()
  1201. await expect(fs.readFile(tmp.extra.dest, "utf8")).resolves.toContain("#111111")
  1202. await Bun.write(tmp.extra.themePath, JSON.stringify({ theme: { primary: "#222222" } }, null, 2))
  1203. await Bun.write(
  1204. tmp.extra.pluginPath,
  1205. `export default {
  1206. id: "demo.theme-update",
  1207. tui: async (api, options) => {
  1208. if (!options?.theme_path) return
  1209. await api.theme.install(options.theme_path)
  1210. },
  1211. }
  1212. // v2
  1213. `,
  1214. )
  1215. const stamp = new Date(Date.now() + 10_000)
  1216. await fs.utimes(tmp.extra.pluginPath, stamp, stamp)
  1217. await fs.utimes(tmp.extra.themePath, stamp, stamp)
  1218. await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
  1219. const text = await fs.readFile(tmp.extra.dest, "utf8")
  1220. expect(text).toContain("#222222")
  1221. expect(text).not.toContain("#111111")
  1222. const list = await Filesystem.readJson<Record<string, { themes?: Record<string, { dest: string }> }>>(
  1223. process.env.KIRINCODE_PLUGIN_META_FILE!,
  1224. )
  1225. expect(list["demo.theme-update"]?.themes?.[tmp.extra.themeName]?.dest).toBe(tmp.extra.dest)
  1226. } finally {
  1227. await TuiPluginRuntime.dispose()
  1228. cwd.mockRestore()
  1229. wait.mockRestore()
  1230. delete process.env.KIRINCODE_PLUGIN_META_FILE
  1231. }
  1232. })