plugin-loader-entrypoint.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. import { 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 { TuiConfig } from "../../../src/config/tui"
  8. import { BunProc } from "../../../src/bun"
  9. const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
  10. test("loads npm tui plugin from package ./tui export", async () => {
  11. await using tmp = await tmpdir({
  12. init: async (dir) => {
  13. const mod = path.join(dir, "mods", "acme-plugin")
  14. const marker = path.join(dir, "tui-called.txt")
  15. await fs.mkdir(mod, { recursive: true })
  16. await Bun.write(
  17. path.join(mod, "package.json"),
  18. JSON.stringify({
  19. name: "acme-plugin",
  20. type: "module",
  21. exports: { ".": "./index.js", "./server": "./server.js", "./tui": "./tui.js" },
  22. }),
  23. )
  24. await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
  25. await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
  26. await Bun.write(path.join(mod, "server.js"), "export default {}\n")
  27. await Bun.write(
  28. path.join(mod, "tui.js"),
  29. `export default {
  30. id: "demo.tui.export",
  31. tui: async (_api, options) => {
  32. if (!options?.marker) return
  33. await Bun.write(${JSON.stringify(marker)}, "called")
  34. },
  35. }
  36. `,
  37. )
  38. return { mod, marker, spec: "[email protected]" }
  39. },
  40. })
  41. process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  42. const get = spyOn(TuiConfig, "get").mockResolvedValue({
  43. plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
  44. plugin_records: [
  45. {
  46. item: [tmp.extra.spec, { marker: tmp.extra.marker }],
  47. scope: "local",
  48. source: path.join(tmp.path, "tui.json"),
  49. },
  50. ],
  51. })
  52. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  53. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  54. const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
  55. try {
  56. await TuiPluginRuntime.init(createTuiPluginApi())
  57. await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
  58. const hit = TuiPluginRuntime.list().find((item) => item.id === "demo.tui.export")
  59. expect(hit?.enabled).toBe(true)
  60. expect(hit?.active).toBe(true)
  61. expect(hit?.source).toBe("npm")
  62. } finally {
  63. await TuiPluginRuntime.dispose()
  64. install.mockRestore()
  65. cwd.mockRestore()
  66. get.mockRestore()
  67. wait.mockRestore()
  68. delete process.env.OPENCODE_PLUGIN_META_FILE
  69. }
  70. })
  71. test("does not use npm package exports dot for tui entry", async () => {
  72. await using tmp = await tmpdir({
  73. init: async (dir) => {
  74. const mod = path.join(dir, "mods", "acme-plugin")
  75. const marker = path.join(dir, "dot-called.txt")
  76. await fs.mkdir(mod, { recursive: true })
  77. await Bun.write(
  78. path.join(mod, "package.json"),
  79. JSON.stringify({
  80. name: "acme-plugin",
  81. type: "module",
  82. exports: { ".": "./index.js" },
  83. }),
  84. )
  85. await Bun.write(
  86. path.join(mod, "index.js"),
  87. `export default {
  88. id: "demo.dot",
  89. tui: async () => {
  90. await Bun.write(${JSON.stringify(marker)}, "called")
  91. },
  92. }
  93. `,
  94. )
  95. return { mod, marker, spec: "[email protected]" }
  96. },
  97. })
  98. process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  99. const get = spyOn(TuiConfig, "get").mockResolvedValue({
  100. plugin: [tmp.extra.spec],
  101. plugin_records: [
  102. {
  103. item: tmp.extra.spec,
  104. scope: "local",
  105. source: path.join(tmp.path, "tui.json"),
  106. },
  107. ],
  108. })
  109. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  110. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  111. const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
  112. try {
  113. await TuiPluginRuntime.init(createTuiPluginApi())
  114. await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
  115. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
  116. } finally {
  117. await TuiPluginRuntime.dispose()
  118. install.mockRestore()
  119. cwd.mockRestore()
  120. get.mockRestore()
  121. wait.mockRestore()
  122. delete process.env.OPENCODE_PLUGIN_META_FILE
  123. }
  124. })
  125. test("rejects npm tui export that resolves outside plugin directory", async () => {
  126. await using tmp = await tmpdir({
  127. init: async (dir) => {
  128. const mod = path.join(dir, "mods", "acme-plugin")
  129. const outside = path.join(dir, "outside")
  130. const marker = path.join(dir, "outside-called.txt")
  131. await fs.mkdir(mod, { recursive: true })
  132. await fs.mkdir(outside, { recursive: true })
  133. await Bun.write(
  134. path.join(mod, "package.json"),
  135. JSON.stringify({
  136. name: "acme-plugin",
  137. type: "module",
  138. exports: { ".": "./index.js", "./tui": "./escape/tui.js" },
  139. }),
  140. )
  141. await Bun.write(path.join(mod, "index.js"), "export default {}\n")
  142. await Bun.write(
  143. path.join(outside, "tui.js"),
  144. `export default {
  145. id: "demo.outside",
  146. tui: async () => {
  147. await Bun.write(${JSON.stringify(marker)}, "outside")
  148. },
  149. }
  150. `,
  151. )
  152. await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
  153. return { mod, marker, spec: "[email protected]" }
  154. },
  155. })
  156. process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  157. const get = spyOn(TuiConfig, "get").mockResolvedValue({
  158. plugin: [tmp.extra.spec],
  159. plugin_records: [
  160. {
  161. item: tmp.extra.spec,
  162. scope: "local",
  163. source: path.join(tmp.path, "tui.json"),
  164. },
  165. ],
  166. })
  167. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  168. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  169. const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
  170. try {
  171. await TuiPluginRuntime.init(createTuiPluginApi())
  172. // plugin code never ran
  173. await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
  174. // plugin not listed
  175. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
  176. } finally {
  177. await TuiPluginRuntime.dispose()
  178. install.mockRestore()
  179. cwd.mockRestore()
  180. get.mockRestore()
  181. wait.mockRestore()
  182. delete process.env.OPENCODE_PLUGIN_META_FILE
  183. }
  184. })
  185. test("rejects npm tui plugin that exports server and tui together", async () => {
  186. await using tmp = await tmpdir({
  187. init: async (dir) => {
  188. const mod = path.join(dir, "mods", "acme-plugin")
  189. const marker = path.join(dir, "mixed-called.txt")
  190. await fs.mkdir(mod, { recursive: true })
  191. await Bun.write(
  192. path.join(mod, "package.json"),
  193. JSON.stringify({
  194. name: "acme-plugin",
  195. type: "module",
  196. exports: { ".": "./index.js", "./tui": "./tui.js" },
  197. }),
  198. )
  199. await Bun.write(path.join(mod, "index.js"), "export default {}\n")
  200. await Bun.write(
  201. path.join(mod, "tui.js"),
  202. `export default {
  203. id: "demo.mixed",
  204. server: async () => ({}),
  205. tui: async () => {
  206. await Bun.write(${JSON.stringify(marker)}, "called")
  207. },
  208. }
  209. `,
  210. )
  211. return { mod, marker, spec: "[email protected]" }
  212. },
  213. })
  214. process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  215. const get = spyOn(TuiConfig, "get").mockResolvedValue({
  216. plugin: [tmp.extra.spec],
  217. plugin_records: [
  218. {
  219. item: tmp.extra.spec,
  220. scope: "local",
  221. source: path.join(tmp.path, "tui.json"),
  222. },
  223. ],
  224. })
  225. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  226. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  227. const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
  228. try {
  229. await TuiPluginRuntime.init(createTuiPluginApi())
  230. await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
  231. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
  232. } finally {
  233. await TuiPluginRuntime.dispose()
  234. install.mockRestore()
  235. cwd.mockRestore()
  236. get.mockRestore()
  237. wait.mockRestore()
  238. delete process.env.OPENCODE_PLUGIN_META_FILE
  239. }
  240. })
  241. test("does not use npm package main for tui entry", async () => {
  242. await using tmp = await tmpdir({
  243. init: async (dir) => {
  244. const mod = path.join(dir, "mods", "acme-plugin")
  245. const marker = path.join(dir, "main-called.txt")
  246. await fs.mkdir(mod, { recursive: true })
  247. await Bun.write(
  248. path.join(mod, "package.json"),
  249. JSON.stringify({
  250. name: "acme-plugin",
  251. type: "module",
  252. main: "./index.js",
  253. }),
  254. )
  255. await Bun.write(
  256. path.join(mod, "index.js"),
  257. `export default {
  258. id: "demo.main",
  259. tui: async () => {
  260. await Bun.write(${JSON.stringify(marker)}, "called")
  261. },
  262. }
  263. `,
  264. )
  265. return { mod, marker, spec: "[email protected]" }
  266. },
  267. })
  268. process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  269. const get = spyOn(TuiConfig, "get").mockResolvedValue({
  270. plugin: [tmp.extra.spec],
  271. plugin_records: [
  272. {
  273. item: tmp.extra.spec,
  274. scope: "local",
  275. source: path.join(tmp.path, "tui.json"),
  276. },
  277. ],
  278. })
  279. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  280. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  281. const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
  282. try {
  283. await TuiPluginRuntime.init(createTuiPluginApi())
  284. await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
  285. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
  286. } finally {
  287. await TuiPluginRuntime.dispose()
  288. install.mockRestore()
  289. cwd.mockRestore()
  290. get.mockRestore()
  291. wait.mockRestore()
  292. delete process.env.OPENCODE_PLUGIN_META_FILE
  293. }
  294. })
  295. test("does not use directory package main for tui entry", async () => {
  296. await using tmp = await tmpdir({
  297. init: async (dir) => {
  298. const mod = path.join(dir, "mods", "dir-plugin")
  299. const spec = pathToFileURL(mod).href
  300. const marker = path.join(dir, "dir-main-called.txt")
  301. await fs.mkdir(mod, { recursive: true })
  302. await Bun.write(
  303. path.join(mod, "package.json"),
  304. JSON.stringify({
  305. name: "dir-plugin",
  306. type: "module",
  307. main: "./main.js",
  308. }),
  309. )
  310. await Bun.write(
  311. path.join(mod, "main.js"),
  312. `export default {
  313. id: "demo.dir.main",
  314. tui: async () => {
  315. await Bun.write(${JSON.stringify(marker)}, "called")
  316. },
  317. }
  318. `,
  319. )
  320. return { marker, spec }
  321. },
  322. })
  323. process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  324. const get = spyOn(TuiConfig, "get").mockResolvedValue({
  325. plugin: [tmp.extra.spec],
  326. plugin_records: [
  327. {
  328. item: tmp.extra.spec,
  329. scope: "local",
  330. source: path.join(tmp.path, "tui.json"),
  331. },
  332. ],
  333. })
  334. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  335. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  336. try {
  337. await TuiPluginRuntime.init(createTuiPluginApi())
  338. await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
  339. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
  340. } finally {
  341. await TuiPluginRuntime.dispose()
  342. cwd.mockRestore()
  343. get.mockRestore()
  344. wait.mockRestore()
  345. delete process.env.OPENCODE_PLUGIN_META_FILE
  346. }
  347. })
  348. test("uses directory index fallback for tui when package.json is missing", async () => {
  349. await using tmp = await tmpdir({
  350. init: async (dir) => {
  351. const mod = path.join(dir, "mods", "dir-index")
  352. const spec = pathToFileURL(mod).href
  353. const marker = path.join(dir, "dir-index-called.txt")
  354. await fs.mkdir(mod, { recursive: true })
  355. await Bun.write(
  356. path.join(mod, "index.ts"),
  357. `export default {
  358. id: "demo.dir.index",
  359. tui: async () => {
  360. await Bun.write(${JSON.stringify(marker)}, "called")
  361. },
  362. }
  363. `,
  364. )
  365. return { marker, spec }
  366. },
  367. })
  368. process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  369. const get = spyOn(TuiConfig, "get").mockResolvedValue({
  370. plugin: [tmp.extra.spec],
  371. plugin_records: [
  372. {
  373. item: tmp.extra.spec,
  374. scope: "local",
  375. source: path.join(tmp.path, "tui.json"),
  376. },
  377. ],
  378. })
  379. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  380. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  381. try {
  382. await TuiPluginRuntime.init(createTuiPluginApi())
  383. await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
  384. expect(TuiPluginRuntime.list().find((item) => item.id === "demo.dir.index")?.active).toBe(true)
  385. } finally {
  386. await TuiPluginRuntime.dispose()
  387. cwd.mockRestore()
  388. get.mockRestore()
  389. wait.mockRestore()
  390. delete process.env.OPENCODE_PLUGIN_META_FILE
  391. }
  392. })
  393. test("uses npm package name when tui plugin id is omitted", async () => {
  394. await using tmp = await tmpdir({
  395. init: async (dir) => {
  396. const mod = path.join(dir, "mods", "acme-plugin")
  397. const marker = path.join(dir, "name-id-called.txt")
  398. await fs.mkdir(mod, { recursive: true })
  399. await Bun.write(
  400. path.join(mod, "package.json"),
  401. JSON.stringify({
  402. name: "acme-plugin",
  403. type: "module",
  404. exports: { ".": "./index.js", "./tui": "./tui.js" },
  405. }),
  406. )
  407. await Bun.write(path.join(mod, "index.js"), "export default {}\n")
  408. await Bun.write(
  409. path.join(mod, "tui.js"),
  410. `export default {
  411. tui: async (_api, options) => {
  412. if (!options?.marker) return
  413. await Bun.write(options.marker, "called")
  414. },
  415. }
  416. `,
  417. )
  418. return { mod, marker, spec: "[email protected]" }
  419. },
  420. })
  421. process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  422. const get = spyOn(TuiConfig, "get").mockResolvedValue({
  423. plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
  424. plugin_records: [
  425. {
  426. item: [tmp.extra.spec, { marker: tmp.extra.marker }],
  427. scope: "local",
  428. source: path.join(tmp.path, "tui.json"),
  429. },
  430. ],
  431. })
  432. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  433. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  434. const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
  435. try {
  436. await TuiPluginRuntime.init(createTuiPluginApi())
  437. await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
  438. expect(TuiPluginRuntime.list().find((item) => item.spec === tmp.extra.spec)?.id).toBe("acme-plugin")
  439. } finally {
  440. await TuiPluginRuntime.dispose()
  441. install.mockRestore()
  442. cwd.mockRestore()
  443. get.mockRestore()
  444. wait.mockRestore()
  445. delete process.env.OPENCODE_PLUGIN_META_FILE
  446. }
  447. })