plugin-loader.test.ts 24 KB

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