plugin-loader.test.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816
  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 { tmpdir } from "../../fixture/fixture"
  6. import { createTuiPluginApi } from "../../fixture/tui-plugin"
  7. import { Global } from "../../../src/global"
  8. import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
  9. import { Filesystem } from "../../../src/util/"
  10. const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
  11. const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
  12. type Row = Record<string, unknown>
  13. type Data = {
  14. local: Row
  15. global: Row
  16. invalid: Row
  17. preloaded: Row
  18. fn_called: boolean
  19. local_installed: string
  20. global_installed: string
  21. preloaded_installed: string
  22. leaked_local_to_global: boolean
  23. leaked_global_to_local: boolean
  24. local_theme: string
  25. global_theme: string
  26. }
  27. async function row(file: string): Promise<Row> {
  28. return Filesystem.readJson<Row>(file)
  29. }
  30. async function load(): Promise<Data> {
  31. const stamp = Date.now()
  32. const globalConfigPath = path.join(Global.Path.config, "tui.json")
  33. const backup = await Bun.file(globalConfigPath)
  34. .text()
  35. .catch(() => undefined)
  36. await using tmp = await tmpdir({
  37. init: async (dir) => {
  38. const localPluginPath = path.join(dir, "local-plugin.ts")
  39. const invalidPluginPath = path.join(dir, "invalid-plugin.ts")
  40. const preloadedPluginPath = path.join(dir, "preloaded-plugin.ts")
  41. const globalPluginPath = path.join(dir, "global-plugin.ts")
  42. const localSpec = pathToFileURL(localPluginPath).href
  43. const invalidSpec = pathToFileURL(invalidPluginPath).href
  44. const preloadedSpec = pathToFileURL(preloadedPluginPath).href
  45. const globalSpec = pathToFileURL(globalPluginPath).href
  46. const localThemeFile = `local-theme-${stamp}.json`
  47. const invalidThemeFile = `invalid-theme-${stamp}.json`
  48. const globalThemeFile = `global-theme-${stamp}.json`
  49. const preloadedThemeFile = `preloaded-theme-${stamp}.json`
  50. const localThemeName = localThemeFile.replace(/\.json$/, "")
  51. const invalidThemeName = invalidThemeFile.replace(/\.json$/, "")
  52. const globalThemeName = globalThemeFile.replace(/\.json$/, "")
  53. const preloadedThemeName = preloadedThemeFile.replace(/\.json$/, "")
  54. const localThemePath = path.join(dir, localThemeFile)
  55. const invalidThemePath = path.join(dir, invalidThemeFile)
  56. const globalThemePath = path.join(dir, globalThemeFile)
  57. const preloadedThemePath = path.join(dir, preloadedThemeFile)
  58. const localDest = path.join(dir, ".opencode", "themes", localThemeFile)
  59. const globalDest = path.join(Global.Path.config, "themes", globalThemeFile)
  60. const preloadedDest = path.join(dir, ".opencode", "themes", preloadedThemeFile)
  61. const fnMarker = path.join(dir, "function-called.txt")
  62. const localMarker = path.join(dir, "local-called.json")
  63. const invalidMarker = path.join(dir, "invalid-called.json")
  64. const globalMarker = path.join(dir, "global-called.json")
  65. const preloadedMarker = path.join(dir, "preloaded-called.json")
  66. const localConfigPath = path.join(dir, "tui.json")
  67. await Bun.write(localThemePath, JSON.stringify({ theme: { primary: "#101010" } }, null, 2))
  68. await Bun.write(invalidThemePath, "{ invalid json }")
  69. await Bun.write(globalThemePath, JSON.stringify({ theme: { primary: "#202020" } }, null, 2))
  70. await Bun.write(preloadedThemePath, JSON.stringify({ theme: { primary: "#f0f0f0" } }, null, 2))
  71. await Bun.write(preloadedDest, JSON.stringify({ theme: { primary: "#303030" } }, null, 2))
  72. await Bun.write(
  73. localPluginPath,
  74. `export const ignored = async (_input, options) => {
  75. if (!options?.fn_marker) return
  76. await Bun.write(options.fn_marker, "called")
  77. }
  78. export default {
  79. id: "demo.local",
  80. tui: async (api, options) => {
  81. if (!options?.marker) return
  82. const cfg_theme = api.tuiConfig.theme
  83. const cfg_diff = api.tuiConfig.diff_style
  84. const cfg_speed = api.tuiConfig.scroll_speed
  85. const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled
  86. const cfg_submit = api.tuiConfig.keybinds?.input_submit
  87. const key = api.keybind.create(
  88. { modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" },
  89. options.keybinds,
  90. )
  91. const kv_before = api.kv.get(options.kv_key, "missing")
  92. api.kv.set(options.kv_key, "stored")
  93. const kv_after = api.kv.get(options.kv_key, "missing")
  94. const diff = api.state.session.diff(options.session_id)
  95. const todo = api.state.session.todo(options.session_id)
  96. const lsp = api.state.lsp()
  97. const mcp = api.state.mcp()
  98. const depth_before = api.ui.dialog.depth
  99. const open_before = api.ui.dialog.open
  100. const size_before = api.ui.dialog.size
  101. api.ui.dialog.setSize("large")
  102. const size_after = api.ui.dialog.size
  103. api.ui.dialog.replace(() => null)
  104. const depth_after = api.ui.dialog.depth
  105. const open_after = api.ui.dialog.open
  106. api.ui.dialog.clear()
  107. const open_clear = api.ui.dialog.open
  108. const before = api.theme.has(options.theme_name)
  109. const set_missing = api.theme.set(options.theme_name)
  110. await api.theme.install(options.theme_path)
  111. const after = api.theme.has(options.theme_name)
  112. const set_installed = api.theme.set(options.theme_name)
  113. const first = await Bun.file(options.dest).text()
  114. await Bun.write(options.source, JSON.stringify({ theme: { primary: "#fefefe" } }, null, 2))
  115. await api.theme.install(options.theme_path)
  116. const second = await Bun.file(options.dest).text()
  117. await Bun.write(
  118. options.marker,
  119. JSON.stringify({
  120. before,
  121. set_missing,
  122. after,
  123. set_installed,
  124. selected: api.theme.selected,
  125. same: first === second,
  126. key_modal: key.get("modal"),
  127. key_close: key.get("close"),
  128. key_unknown: key.get("ctrl+k"),
  129. key_print: key.print("modal"),
  130. kv_before,
  131. kv_after,
  132. kv_ready: api.kv.ready,
  133. diff_count: diff.length,
  134. diff_file: diff[0]?.file,
  135. todo_count: todo.length,
  136. todo_first: todo[0]?.content,
  137. lsp_count: lsp.length,
  138. mcp_count: mcp.length,
  139. mcp_first: mcp[0]?.name,
  140. depth_before,
  141. open_before,
  142. size_before,
  143. size_after,
  144. depth_after,
  145. open_after,
  146. open_clear,
  147. cfg_theme,
  148. cfg_diff,
  149. cfg_speed,
  150. cfg_accel,
  151. cfg_submit,
  152. }),
  153. )
  154. },
  155. }
  156. `,
  157. )
  158. await Bun.write(
  159. invalidPluginPath,
  160. `export default {
  161. id: "demo.invalid",
  162. tui: async (api, options) => {
  163. if (!options?.marker) return
  164. const before = api.theme.has(options.theme_name)
  165. const set_missing = api.theme.set(options.theme_name)
  166. await api.theme.install(options.theme_path)
  167. const after = api.theme.has(options.theme_name)
  168. const set_installed = api.theme.set(options.theme_name)
  169. await Bun.write(
  170. options.marker,
  171. JSON.stringify({
  172. before,
  173. set_missing,
  174. after,
  175. set_installed,
  176. }),
  177. )
  178. },
  179. }
  180. `,
  181. )
  182. await Bun.write(
  183. preloadedPluginPath,
  184. `export default {
  185. id: "demo.preloaded",
  186. tui: async (api, options) => {
  187. if (!options?.marker) return
  188. const before = api.theme.has(options.theme_name)
  189. await api.theme.install(options.theme_path)
  190. const after = api.theme.has(options.theme_name)
  191. const text = await Bun.file(options.dest).text()
  192. await Bun.write(
  193. options.marker,
  194. JSON.stringify({
  195. before,
  196. after,
  197. text,
  198. }),
  199. )
  200. },
  201. }
  202. `,
  203. )
  204. await Bun.write(
  205. globalPluginPath,
  206. `export default {
  207. id: "demo.global",
  208. tui: async (api, options) => {
  209. if (!options?.marker) return
  210. await api.theme.install(options.theme_path)
  211. const has = api.theme.has(options.theme_name)
  212. const set_installed = api.theme.set(options.theme_name)
  213. await Bun.write(
  214. options.marker,
  215. JSON.stringify({
  216. has,
  217. set_installed,
  218. selected: api.theme.selected,
  219. }),
  220. )
  221. },
  222. }
  223. `,
  224. )
  225. await Bun.write(
  226. globalConfigPath,
  227. JSON.stringify(
  228. {
  229. plugin: [
  230. [globalSpec, { marker: globalMarker, theme_path: `./${globalThemeFile}`, theme_name: globalThemeName }],
  231. ],
  232. },
  233. null,
  234. 2,
  235. ),
  236. )
  237. await Bun.write(
  238. localConfigPath,
  239. JSON.stringify(
  240. {
  241. plugin: [
  242. [
  243. localSpec,
  244. {
  245. fn_marker: fnMarker,
  246. marker: localMarker,
  247. source: localThemePath,
  248. dest: localDest,
  249. theme_path: `./${localThemeFile}`,
  250. theme_name: localThemeName,
  251. kv_key: "plugin_state_key",
  252. session_id: "ses_test",
  253. keybinds: {
  254. modal: "ctrl+alt+m",
  255. close: "q",
  256. },
  257. },
  258. ],
  259. [
  260. invalidSpec,
  261. {
  262. marker: invalidMarker,
  263. theme_path: `./${invalidThemeFile}`,
  264. theme_name: invalidThemeName,
  265. },
  266. ],
  267. [
  268. preloadedSpec,
  269. {
  270. marker: preloadedMarker,
  271. dest: preloadedDest,
  272. theme_path: `./${preloadedThemeFile}`,
  273. theme_name: preloadedThemeName,
  274. },
  275. ],
  276. ],
  277. },
  278. null,
  279. 2,
  280. ),
  281. )
  282. return {
  283. localThemeFile,
  284. invalidThemeFile,
  285. globalThemeFile,
  286. preloadedThemeFile,
  287. localThemeName,
  288. invalidThemeName,
  289. globalThemeName,
  290. preloadedThemeName,
  291. localDest,
  292. globalDest,
  293. preloadedDest,
  294. localPluginPath,
  295. invalidPluginPath,
  296. globalPluginPath,
  297. preloadedPluginPath,
  298. localSpec,
  299. invalidSpec,
  300. globalSpec,
  301. preloadedSpec,
  302. fnMarker,
  303. localMarker,
  304. invalidMarker,
  305. globalMarker,
  306. preloadedMarker,
  307. }
  308. },
  309. })
  310. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  311. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  312. try {
  313. expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
  314. const localOpts = {
  315. fn_marker: tmp.extra.fnMarker,
  316. marker: tmp.extra.localMarker,
  317. source: path.join(tmp.path, tmp.extra.localThemeFile),
  318. dest: tmp.extra.localDest,
  319. theme_path: `./${tmp.extra.localThemeFile}`,
  320. theme_name: tmp.extra.localThemeName,
  321. kv_key: "plugin_state_key",
  322. session_id: "ses_test",
  323. keybinds: { modal: "ctrl+alt+m", close: "q" },
  324. }
  325. const invalidOpts = {
  326. marker: tmp.extra.invalidMarker,
  327. theme_path: `./${tmp.extra.invalidThemeFile}`,
  328. theme_name: tmp.extra.invalidThemeName,
  329. }
  330. const preloadedOpts = {
  331. marker: tmp.extra.preloadedMarker,
  332. dest: tmp.extra.preloadedDest,
  333. theme_path: `./${tmp.extra.preloadedThemeFile}`,
  334. theme_name: tmp.extra.preloadedThemeName,
  335. }
  336. const globalOpts = {
  337. marker: tmp.extra.globalMarker,
  338. theme_path: `./${tmp.extra.globalThemeFile}`,
  339. theme_name: tmp.extra.globalThemeName,
  340. }
  341. const config: TuiConfig.Info = {
  342. plugin: [
  343. [tmp.extra.localSpec, localOpts],
  344. [tmp.extra.invalidSpec, invalidOpts],
  345. [tmp.extra.preloadedSpec, preloadedOpts],
  346. [tmp.extra.globalSpec, globalOpts],
  347. ],
  348. plugin_origins: [
  349. { spec: [tmp.extra.localSpec, localOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
  350. { spec: [tmp.extra.invalidSpec, invalidOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
  351. { spec: [tmp.extra.preloadedSpec, preloadedOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
  352. {
  353. spec: [tmp.extra.globalSpec, globalOpts],
  354. scope: "global",
  355. source: path.join(Global.Path.config, "tui.json"),
  356. },
  357. ],
  358. }
  359. await TuiPluginRuntime.init({
  360. api: createTuiPluginApi({
  361. tuiConfig: {
  362. theme: "smoke",
  363. diff_style: "stacked",
  364. scroll_speed: 1.5,
  365. scroll_acceleration: { enabled: true },
  366. keybinds: {
  367. input_submit: "ctrl+enter",
  368. },
  369. },
  370. keybind: {
  371. print: (key) => `print:${key}`,
  372. },
  373. state: {
  374. session: {
  375. diff(sessionID) {
  376. if (sessionID !== "ses_test") return []
  377. return [{ file: "src/app.ts", additions: 3, deletions: 1 }]
  378. },
  379. todo(sessionID) {
  380. if (sessionID !== "ses_test") return []
  381. return [{ content: "ship it", status: "pending" }]
  382. },
  383. },
  384. lsp() {
  385. return [{ id: "ts", root: "/tmp/project", status: "connected" }]
  386. },
  387. mcp() {
  388. return [{ name: "github", status: "connected" }]
  389. },
  390. },
  391. theme: {
  392. has(name) {
  393. return allThemes()[name] !== undefined
  394. },
  395. },
  396. }),
  397. config,
  398. })
  399. const local = await row(tmp.extra.localMarker)
  400. const global = await row(tmp.extra.globalMarker)
  401. const invalid = await row(tmp.extra.invalidMarker)
  402. const preloaded = await row(tmp.extra.preloadedMarker)
  403. const fn_called = await fs
  404. .readFile(tmp.extra.fnMarker, "utf8")
  405. .then(() => true)
  406. .catch(() => false)
  407. const local_installed = await fs.readFile(tmp.extra.localDest, "utf8")
  408. const global_installed = await fs.readFile(tmp.extra.globalDest, "utf8")
  409. const preloaded_installed = await fs.readFile(tmp.extra.preloadedDest, "utf8")
  410. const leaked_local_to_global = await fs
  411. .stat(path.join(Global.Path.config, "themes", tmp.extra.localThemeFile))
  412. .then(() => true)
  413. .catch(() => false)
  414. const leaked_global_to_local = await fs
  415. .stat(path.join(tmp.path, ".opencode", "themes", tmp.extra.globalThemeFile))
  416. .then(() => true)
  417. .catch(() => false)
  418. return {
  419. local,
  420. global,
  421. invalid,
  422. preloaded,
  423. fn_called,
  424. local_installed,
  425. global_installed,
  426. preloaded_installed,
  427. leaked_local_to_global,
  428. leaked_global_to_local,
  429. local_theme: tmp.extra.localThemeName,
  430. global_theme: tmp.extra.globalThemeName,
  431. }
  432. } finally {
  433. await TuiPluginRuntime.dispose()
  434. cwd.mockRestore()
  435. wait.mockRestore()
  436. if (backup === undefined) {
  437. await fs.rm(globalConfigPath, { force: true })
  438. } else {
  439. await Bun.write(globalConfigPath, backup)
  440. }
  441. await fs.rm(tmp.extra.globalDest, { force: true }).catch(() => {})
  442. }
  443. }
  444. test("continues loading when a plugin is missing config metadata", async () => {
  445. await using tmp = await tmpdir({
  446. init: async (dir) => {
  447. const bad = path.join(dir, "missing-meta-plugin.ts")
  448. const good = path.join(dir, "next-plugin.ts")
  449. const bare = path.join(dir, "plain-plugin.ts")
  450. const badSpec = pathToFileURL(bad).href
  451. const goodSpec = pathToFileURL(good).href
  452. const bareSpec = pathToFileURL(bare).href
  453. const goodMarker = path.join(dir, "next-called.txt")
  454. const bareMarker = path.join(dir, "plain-called.txt")
  455. for (const [file, id] of [
  456. [bad, "demo.missing-meta"],
  457. [good, "demo.next"],
  458. ] as const) {
  459. await Bun.write(
  460. file,
  461. `export default {
  462. id: "${id}",
  463. tui: async (_api, options) => {
  464. if (!options?.marker) return
  465. await Bun.write(options.marker, "called")
  466. },
  467. }
  468. `,
  469. )
  470. }
  471. await Bun.write(
  472. bare,
  473. `export default {
  474. id: "demo.plain",
  475. tui: async (_api, options) => {
  476. await Bun.write(${JSON.stringify(bareMarker)}, options === undefined ? "undefined" : "value")
  477. },
  478. }
  479. `,
  480. )
  481. return { badSpec, goodSpec, bareSpec, goodMarker, bareMarker }
  482. },
  483. })
  484. process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  485. const config: TuiConfig.Info = {
  486. plugin: [
  487. [tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
  488. [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
  489. tmp.extra.bareSpec,
  490. ],
  491. plugin_origins: [
  492. {
  493. spec: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
  494. scope: "local",
  495. source: path.join(tmp.path, "tui.json"),
  496. },
  497. {
  498. spec: tmp.extra.bareSpec,
  499. scope: "local",
  500. source: path.join(tmp.path, "tui.json"),
  501. },
  502. ],
  503. }
  504. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  505. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  506. try {
  507. await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
  508. // bad plugin was skipped (no metadata entry)
  509. await expect(fs.readFile(path.join(tmp.path, "bad.txt"), "utf8")).rejects.toThrow()
  510. // good plugin loaded fine
  511. await expect(fs.readFile(tmp.extra.goodMarker, "utf8")).resolves.toBe("called")
  512. // bare string spec gets undefined options
  513. await expect(fs.readFile(tmp.extra.bareMarker, "utf8")).resolves.toBe("undefined")
  514. } finally {
  515. await TuiPluginRuntime.dispose()
  516. cwd.mockRestore()
  517. wait.mockRestore()
  518. delete process.env.KILO_PLUGIN_META_FILE
  519. }
  520. })
  521. test("initializes external tui plugins in config order", async () => {
  522. const globalJson = path.join(Global.Path.config, "tui.json")
  523. const globalJsonc = path.join(Global.Path.config, "tui.jsonc")
  524. const backupJson = await Bun.file(globalJson)
  525. .text()
  526. .catch(() => undefined)
  527. const backupJsonc = await Bun.file(globalJsonc)
  528. .text()
  529. .catch(() => undefined)
  530. await fs.rm(globalJson, { force: true }).catch(() => {})
  531. await fs.rm(globalJsonc, { force: true }).catch(() => {})
  532. await using tmp = await tmpdir({
  533. init: async (dir) => {
  534. const a = path.join(dir, "order-a.ts")
  535. const b = path.join(dir, "order-b.ts")
  536. const aSpec = pathToFileURL(a).href
  537. const bSpec = pathToFileURL(b).href
  538. const marker = path.join(dir, "tui-order.txt")
  539. await Bun.write(
  540. a,
  541. `import fs from "fs/promises"
  542. export default {
  543. id: "demo.tui.order.a",
  544. tui: async () => {
  545. await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n")
  546. await Bun.sleep(25)
  547. await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n")
  548. },
  549. }
  550. `,
  551. )
  552. await Bun.write(
  553. b,
  554. `import fs from "fs/promises"
  555. export default {
  556. id: "demo.tui.order.b",
  557. tui: async () => {
  558. await fs.appendFile(${JSON.stringify(marker)}, "b\\n")
  559. },
  560. }
  561. `,
  562. )
  563. await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2))
  564. return { marker }
  565. },
  566. })
  567. process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  568. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  569. try {
  570. const a = path.join(tmp.path, "order-a.ts")
  571. const b = path.join(tmp.path, "order-b.ts")
  572. const aSpec = pathToFileURL(a).href
  573. const bSpec = pathToFileURL(b).href
  574. const config: TuiConfig.Info = {
  575. plugin: [aSpec, bSpec],
  576. plugin_origins: [
  577. { spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
  578. { spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
  579. ],
  580. }
  581. await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
  582. const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
  583. expect(lines).toEqual(["a-start", "a-end", "b"])
  584. } finally {
  585. await TuiPluginRuntime.dispose()
  586. cwd.mockRestore()
  587. delete process.env.KILO_PLUGIN_META_FILE
  588. if (backupJson === undefined) {
  589. await fs.rm(globalJson, { force: true }).catch(() => {})
  590. } else {
  591. await Bun.write(globalJson, backupJson)
  592. }
  593. if (backupJsonc === undefined) {
  594. await fs.rm(globalJsonc, { force: true }).catch(() => {})
  595. } else {
  596. await Bun.write(globalJsonc, backupJsonc)
  597. }
  598. }
  599. })
  600. describe("tui.plugin.loader", () => {
  601. let data: Data
  602. beforeAll(async () => {
  603. data = await load()
  604. })
  605. test("passes keybind, kv, state, and dialog APIs to v1 plugins", () => {
  606. expect(data.local.key_modal).toBe("ctrl+alt+m")
  607. expect(data.local.key_close).toBe("q")
  608. expect(data.local.key_unknown).toBe("ctrl+k")
  609. expect(data.local.key_print).toBe("print:ctrl+alt+m")
  610. expect(data.local.kv_before).toBe("missing")
  611. expect(data.local.kv_after).toBe("stored")
  612. expect(data.local.kv_ready).toBe(true)
  613. expect(data.local.diff_count).toBe(1)
  614. expect(data.local.diff_file).toBe("src/app.ts")
  615. expect(data.local.todo_count).toBe(1)
  616. expect(data.local.todo_first).toBe("ship it")
  617. expect(data.local.lsp_count).toBe(1)
  618. expect(data.local.mcp_count).toBe(1)
  619. expect(data.local.mcp_first).toBe("github")
  620. expect(data.local.depth_before).toBe(0)
  621. expect(data.local.open_before).toBe(false)
  622. expect(data.local.size_before).toBe("medium")
  623. expect(data.local.size_after).toBe("large")
  624. expect(data.local.depth_after).toBe(1)
  625. expect(data.local.open_after).toBe(true)
  626. expect(data.local.open_clear).toBe(false)
  627. expect(data.local.cfg_theme).toBe("smoke")
  628. expect(data.local.cfg_diff).toBe("stacked")
  629. expect(data.local.cfg_speed).toBe(1.5)
  630. expect(data.local.cfg_accel).toBe(true)
  631. expect(data.local.cfg_submit).toBe("ctrl+enter")
  632. })
  633. test("installs themes in the correct scope and remains resilient", () => {
  634. expect(data.local.before).toBe(false)
  635. expect(data.local.set_missing).toBe(false)
  636. expect(data.local.after).toBe(true)
  637. expect(data.local.set_installed).toBe(true)
  638. expect(data.local.selected).toBe(data.local_theme)
  639. expect(data.local.same).toBe(true)
  640. expect(data.global.has).toBe(true)
  641. expect(data.global.set_installed).toBe(true)
  642. expect(data.global.selected).toBe(data.global_theme)
  643. expect(data.invalid.before).toBe(false)
  644. expect(data.invalid.set_missing).toBe(false)
  645. expect(data.invalid.after).toBe(false)
  646. expect(data.invalid.set_installed).toBe(false)
  647. expect(data.preloaded.before).toBe(true)
  648. expect(data.preloaded.after).toBe(true)
  649. expect(data.preloaded.text).toContain("#303030")
  650. expect(data.preloaded.text).not.toContain("#f0f0f0")
  651. expect(data.fn_called).toBe(false)
  652. expect(data.local_installed).toContain("#101010")
  653. expect(data.local_installed).not.toContain("#fefefe")
  654. expect(data.global_installed).toContain("#202020")
  655. expect(data.preloaded_installed).toContain("#303030")
  656. expect(data.preloaded_installed).not.toContain("#f0f0f0")
  657. expect(data.leaked_local_to_global).toBe(false)
  658. expect(data.leaked_global_to_local).toBe(false)
  659. })
  660. })
  661. test("updates installed theme when plugin metadata changes", async () => {
  662. await using tmp = await tmpdir<{
  663. spec: string
  664. pluginPath: string
  665. themePath: string
  666. dest: string
  667. themeName: string
  668. }>({
  669. init: async (dir) => {
  670. const pluginPath = path.join(dir, "theme-update-plugin.ts")
  671. const spec = pathToFileURL(pluginPath).href
  672. const themeFile = "theme-update.json"
  673. const themePath = path.join(dir, themeFile)
  674. const dest = path.join(dir, ".opencode", "themes", themeFile)
  675. const themeName = themeFile.replace(/\.json$/, "")
  676. const configPath = path.join(dir, "tui.json")
  677. await Bun.write(themePath, JSON.stringify({ theme: { primary: "#111111" } }, null, 2))
  678. await Bun.write(
  679. pluginPath,
  680. `export default {
  681. id: "demo.theme-update",
  682. tui: async (api, options) => {
  683. if (!options?.theme_path) return
  684. await api.theme.install(options.theme_path)
  685. },
  686. }
  687. `,
  688. )
  689. await Bun.write(
  690. configPath,
  691. JSON.stringify(
  692. {
  693. plugin: [[spec, { theme_path: `./${themeFile}` }]],
  694. },
  695. null,
  696. 2,
  697. ),
  698. )
  699. return {
  700. spec,
  701. pluginPath,
  702. themePath,
  703. dest,
  704. themeName,
  705. }
  706. },
  707. })
  708. process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  709. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  710. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  711. const mkApi = () =>
  712. createTuiPluginApi({
  713. theme: {
  714. has(name) {
  715. return allThemes()[name] !== undefined
  716. },
  717. },
  718. })
  719. const mkConfig = (): TuiConfig.Info => ({
  720. plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]],
  721. plugin_origins: [
  722. {
  723. spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }],
  724. scope: "local",
  725. source: path.join(tmp.path, "tui.json"),
  726. },
  727. ],
  728. })
  729. try {
  730. await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
  731. await TuiPluginRuntime.dispose()
  732. await expect(fs.readFile(tmp.extra.dest, "utf8")).resolves.toContain("#111111")
  733. await Bun.write(tmp.extra.themePath, JSON.stringify({ theme: { primary: "#222222" } }, null, 2))
  734. await Bun.write(
  735. tmp.extra.pluginPath,
  736. `export default {
  737. id: "demo.theme-update",
  738. tui: async (api, options) => {
  739. if (!options?.theme_path) return
  740. await api.theme.install(options.theme_path)
  741. },
  742. }
  743. // v2
  744. `,
  745. )
  746. const stamp = new Date(Date.now() + 10_000)
  747. await fs.utimes(tmp.extra.pluginPath, stamp, stamp)
  748. await fs.utimes(tmp.extra.themePath, stamp, stamp)
  749. await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
  750. const text = await fs.readFile(tmp.extra.dest, "utf8")
  751. expect(text).toContain("#222222")
  752. expect(text).not.toContain("#111111")
  753. const list = await Filesystem.readJson<Record<string, { themes?: Record<string, { dest: string }> }>>(
  754. process.env.KILO_PLUGIN_META_FILE!,
  755. )
  756. expect(list["demo.theme-update"]?.themes?.[tmp.extra.themeName]?.dest).toBe(tmp.extra.dest)
  757. } finally {
  758. await TuiPluginRuntime.dispose()
  759. cwd.mockRestore()
  760. wait.mockRestore()
  761. delete process.env.KILO_PLUGIN_META_FILE
  762. }
  763. })