2
0

plugin-loader-entrypoint.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  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.KILO_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.KILO_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.KILO_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.KILO_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.KILO_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.KILO_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.KILO_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.KILO_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.KILO_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. const warn = spyOn(console, "warn").mockImplementation(() => {})
  283. const error = spyOn(console, "error").mockImplementation(() => {})
  284. try {
  285. await TuiPluginRuntime.init(createTuiPluginApi())
  286. await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
  287. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
  288. expect(error).not.toHaveBeenCalled()
  289. expect(warn.mock.calls.some((call) => String(call[0]).includes("tui plugin has no entrypoint"))).toBe(true)
  290. } finally {
  291. await TuiPluginRuntime.dispose()
  292. install.mockRestore()
  293. cwd.mockRestore()
  294. get.mockRestore()
  295. wait.mockRestore()
  296. warn.mockRestore()
  297. error.mockRestore()
  298. delete process.env.KILO_PLUGIN_META_FILE
  299. }
  300. })
  301. test("does not use directory package main for tui entry", async () => {
  302. await using tmp = await tmpdir({
  303. init: async (dir) => {
  304. const mod = path.join(dir, "mods", "dir-plugin")
  305. const spec = pathToFileURL(mod).href
  306. const marker = path.join(dir, "dir-main-called.txt")
  307. await fs.mkdir(mod, { recursive: true })
  308. await Bun.write(
  309. path.join(mod, "package.json"),
  310. JSON.stringify({
  311. name: "dir-plugin",
  312. type: "module",
  313. main: "./main.js",
  314. }),
  315. )
  316. await Bun.write(
  317. path.join(mod, "main.js"),
  318. `export default {
  319. id: "demo.dir.main",
  320. tui: async () => {
  321. await Bun.write(${JSON.stringify(marker)}, "called")
  322. },
  323. }
  324. `,
  325. )
  326. return { marker, spec }
  327. },
  328. })
  329. process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  330. const get = spyOn(TuiConfig, "get").mockResolvedValue({
  331. plugin: [tmp.extra.spec],
  332. plugin_records: [
  333. {
  334. item: tmp.extra.spec,
  335. scope: "local",
  336. source: path.join(tmp.path, "tui.json"),
  337. },
  338. ],
  339. })
  340. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  341. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  342. try {
  343. await TuiPluginRuntime.init(createTuiPluginApi())
  344. await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
  345. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
  346. } finally {
  347. await TuiPluginRuntime.dispose()
  348. cwd.mockRestore()
  349. get.mockRestore()
  350. wait.mockRestore()
  351. delete process.env.KILO_PLUGIN_META_FILE
  352. }
  353. })
  354. test("uses directory index fallback for tui when package.json is missing", async () => {
  355. await using tmp = await tmpdir({
  356. init: async (dir) => {
  357. const mod = path.join(dir, "mods", "dir-index")
  358. const spec = pathToFileURL(mod).href
  359. const marker = path.join(dir, "dir-index-called.txt")
  360. await fs.mkdir(mod, { recursive: true })
  361. await Bun.write(
  362. path.join(mod, "index.ts"),
  363. `export default {
  364. id: "demo.dir.index",
  365. tui: async () => {
  366. await Bun.write(${JSON.stringify(marker)}, "called")
  367. },
  368. }
  369. `,
  370. )
  371. return { marker, spec }
  372. },
  373. })
  374. process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  375. const get = spyOn(TuiConfig, "get").mockResolvedValue({
  376. plugin: [tmp.extra.spec],
  377. plugin_records: [
  378. {
  379. item: tmp.extra.spec,
  380. scope: "local",
  381. source: path.join(tmp.path, "tui.json"),
  382. },
  383. ],
  384. })
  385. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  386. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  387. try {
  388. await TuiPluginRuntime.init(createTuiPluginApi())
  389. await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
  390. expect(TuiPluginRuntime.list().find((item) => item.id === "demo.dir.index")?.active).toBe(true)
  391. } finally {
  392. await TuiPluginRuntime.dispose()
  393. cwd.mockRestore()
  394. get.mockRestore()
  395. wait.mockRestore()
  396. delete process.env.KILO_PLUGIN_META_FILE
  397. }
  398. })
  399. test("uses npm package name when tui plugin id is omitted", async () => {
  400. await using tmp = await tmpdir({
  401. init: async (dir) => {
  402. const mod = path.join(dir, "mods", "acme-plugin")
  403. const marker = path.join(dir, "name-id-called.txt")
  404. await fs.mkdir(mod, { recursive: true })
  405. await Bun.write(
  406. path.join(mod, "package.json"),
  407. JSON.stringify({
  408. name: "acme-plugin",
  409. type: "module",
  410. exports: { ".": "./index.js", "./tui": "./tui.js" },
  411. }),
  412. )
  413. await Bun.write(path.join(mod, "index.js"), "export default {}\n")
  414. await Bun.write(
  415. path.join(mod, "tui.js"),
  416. `export default {
  417. tui: async (_api, options) => {
  418. if (!options?.marker) return
  419. await Bun.write(options.marker, "called")
  420. },
  421. }
  422. `,
  423. )
  424. return { mod, marker, spec: "[email protected]" }
  425. },
  426. })
  427. process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  428. const get = spyOn(TuiConfig, "get").mockResolvedValue({
  429. plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
  430. plugin_records: [
  431. {
  432. item: [tmp.extra.spec, { marker: tmp.extra.marker }],
  433. scope: "local",
  434. source: path.join(tmp.path, "tui.json"),
  435. },
  436. ],
  437. })
  438. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  439. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  440. const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
  441. try {
  442. await TuiPluginRuntime.init(createTuiPluginApi())
  443. await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
  444. expect(TuiPluginRuntime.list().find((item) => item.spec === tmp.extra.spec)?.id).toBe("acme-plugin")
  445. } finally {
  446. await TuiPluginRuntime.dispose()
  447. install.mockRestore()
  448. cwd.mockRestore()
  449. get.mockRestore()
  450. wait.mockRestore()
  451. delete process.env.KILO_PLUGIN_META_FILE
  452. }
  453. })