plugin-loader-entrypoint.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  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/cli/cmd/tui/config/tui"
  8. import { Npm } from "../../../src/npm"
  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 config: TuiConfig.Info = {
  43. plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
  44. plugin_origins: [
  45. {
  46. spec: [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(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
  55. try {
  56. await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
  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. wait.mockRestore()
  67. delete process.env.KILO_PLUGIN_META_FILE
  68. }
  69. })
  70. test("does not use npm package exports dot for tui entry", async () => {
  71. await using tmp = await tmpdir({
  72. init: async (dir) => {
  73. const mod = path.join(dir, "mods", "acme-plugin")
  74. const marker = path.join(dir, "dot-called.txt")
  75. await fs.mkdir(mod, { recursive: true })
  76. await Bun.write(
  77. path.join(mod, "package.json"),
  78. JSON.stringify({
  79. name: "acme-plugin",
  80. type: "module",
  81. exports: { ".": "./index.js" },
  82. }),
  83. )
  84. await Bun.write(
  85. path.join(mod, "index.js"),
  86. `export default {
  87. id: "demo.dot",
  88. tui: async () => {
  89. await Bun.write(${JSON.stringify(marker)}, "called")
  90. },
  91. }
  92. `,
  93. )
  94. return { mod, marker, spec: "[email protected]" }
  95. },
  96. })
  97. process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  98. const config: TuiConfig.Info = {
  99. plugin: [tmp.extra.spec],
  100. plugin_origins: [
  101. {
  102. spec: tmp.extra.spec,
  103. scope: "local",
  104. source: path.join(tmp.path, "tui.json"),
  105. },
  106. ],
  107. }
  108. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  109. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  110. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
  111. try {
  112. await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
  113. await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
  114. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
  115. } finally {
  116. await TuiPluginRuntime.dispose()
  117. install.mockRestore()
  118. cwd.mockRestore()
  119. wait.mockRestore()
  120. delete process.env.KILO_PLUGIN_META_FILE
  121. }
  122. })
  123. test("rejects npm tui export that resolves outside plugin directory", async () => {
  124. await using tmp = await tmpdir({
  125. init: async (dir) => {
  126. const mod = path.join(dir, "mods", "acme-plugin")
  127. const outside = path.join(dir, "outside")
  128. const marker = path.join(dir, "outside-called.txt")
  129. await fs.mkdir(mod, { recursive: true })
  130. await fs.mkdir(outside, { recursive: true })
  131. await Bun.write(
  132. path.join(mod, "package.json"),
  133. JSON.stringify({
  134. name: "acme-plugin",
  135. type: "module",
  136. exports: { ".": "./index.js", "./tui": "./escape/tui.js" },
  137. }),
  138. )
  139. await Bun.write(path.join(mod, "index.js"), "export default {}\n")
  140. await Bun.write(
  141. path.join(outside, "tui.js"),
  142. `export default {
  143. id: "demo.outside",
  144. tui: async () => {
  145. await Bun.write(${JSON.stringify(marker)}, "outside")
  146. },
  147. }
  148. `,
  149. )
  150. await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
  151. return { mod, marker, spec: "[email protected]" }
  152. },
  153. })
  154. process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  155. const config: TuiConfig.Info = {
  156. plugin: [tmp.extra.spec],
  157. plugin_origins: [
  158. {
  159. spec: tmp.extra.spec,
  160. scope: "local",
  161. source: path.join(tmp.path, "tui.json"),
  162. },
  163. ],
  164. }
  165. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  166. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  167. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
  168. try {
  169. await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
  170. // plugin code never ran
  171. await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
  172. // plugin not listed
  173. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
  174. } finally {
  175. await TuiPluginRuntime.dispose()
  176. install.mockRestore()
  177. cwd.mockRestore()
  178. wait.mockRestore()
  179. delete process.env.KILO_PLUGIN_META_FILE
  180. }
  181. })
  182. test("rejects npm tui plugin that exports server and tui together", async () => {
  183. await using tmp = await tmpdir({
  184. init: async (dir) => {
  185. const mod = path.join(dir, "mods", "acme-plugin")
  186. const marker = path.join(dir, "mixed-called.txt")
  187. await fs.mkdir(mod, { recursive: true })
  188. await Bun.write(
  189. path.join(mod, "package.json"),
  190. JSON.stringify({
  191. name: "acme-plugin",
  192. type: "module",
  193. exports: { ".": "./index.js", "./tui": "./tui.js" },
  194. }),
  195. )
  196. await Bun.write(path.join(mod, "index.js"), "export default {}\n")
  197. await Bun.write(
  198. path.join(mod, "tui.js"),
  199. `export default {
  200. id: "demo.mixed",
  201. server: async () => ({}),
  202. tui: async () => {
  203. await Bun.write(${JSON.stringify(marker)}, "called")
  204. },
  205. }
  206. `,
  207. )
  208. return { mod, marker, spec: "[email protected]" }
  209. },
  210. })
  211. process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  212. const config: TuiConfig.Info = {
  213. plugin: [tmp.extra.spec],
  214. plugin_origins: [
  215. {
  216. spec: tmp.extra.spec,
  217. scope: "local",
  218. source: path.join(tmp.path, "tui.json"),
  219. },
  220. ],
  221. }
  222. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  223. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  224. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
  225. try {
  226. await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
  227. await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
  228. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
  229. } finally {
  230. await TuiPluginRuntime.dispose()
  231. install.mockRestore()
  232. cwd.mockRestore()
  233. wait.mockRestore()
  234. delete process.env.KILO_PLUGIN_META_FILE
  235. }
  236. })
  237. test("does not use npm package main for tui entry", async () => {
  238. await using tmp = await tmpdir({
  239. init: async (dir) => {
  240. const mod = path.join(dir, "mods", "acme-plugin")
  241. const marker = path.join(dir, "main-called.txt")
  242. await fs.mkdir(mod, { recursive: true })
  243. await Bun.write(
  244. path.join(mod, "package.json"),
  245. JSON.stringify({
  246. name: "acme-plugin",
  247. type: "module",
  248. main: "./index.js",
  249. }),
  250. )
  251. await Bun.write(
  252. path.join(mod, "index.js"),
  253. `export default {
  254. id: "demo.main",
  255. tui: async () => {
  256. await Bun.write(${JSON.stringify(marker)}, "called")
  257. },
  258. }
  259. `,
  260. )
  261. return { mod, marker, spec: "[email protected]" }
  262. },
  263. })
  264. process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  265. const config: TuiConfig.Info = {
  266. plugin: [tmp.extra.spec],
  267. plugin_origins: [
  268. {
  269. spec: tmp.extra.spec,
  270. scope: "local",
  271. source: path.join(tmp.path, "tui.json"),
  272. },
  273. ],
  274. }
  275. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  276. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  277. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
  278. const warn = spyOn(console, "warn").mockImplementation(() => {})
  279. const error = spyOn(console, "error").mockImplementation(() => {})
  280. try {
  281. await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
  282. await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
  283. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
  284. expect(error).not.toHaveBeenCalled()
  285. expect(warn.mock.calls.some((call) => String(call[0]).includes("tui plugin has no entrypoint"))).toBe(true)
  286. } finally {
  287. await TuiPluginRuntime.dispose()
  288. install.mockRestore()
  289. cwd.mockRestore()
  290. wait.mockRestore()
  291. warn.mockRestore()
  292. error.mockRestore()
  293. delete process.env.KILO_PLUGIN_META_FILE
  294. }
  295. })
  296. test("does not use directory package main for tui entry", async () => {
  297. await using tmp = await tmpdir({
  298. init: async (dir) => {
  299. const mod = path.join(dir, "mods", "dir-plugin")
  300. const spec = pathToFileURL(mod).href
  301. const marker = path.join(dir, "dir-main-called.txt")
  302. await fs.mkdir(mod, { recursive: true })
  303. await Bun.write(
  304. path.join(mod, "package.json"),
  305. JSON.stringify({
  306. name: "dir-plugin",
  307. type: "module",
  308. main: "./main.js",
  309. }),
  310. )
  311. await Bun.write(
  312. path.join(mod, "main.js"),
  313. `export default {
  314. id: "demo.dir.main",
  315. tui: async () => {
  316. await Bun.write(${JSON.stringify(marker)}, "called")
  317. },
  318. }
  319. `,
  320. )
  321. return { marker, spec }
  322. },
  323. })
  324. process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  325. const config: TuiConfig.Info = {
  326. plugin: [tmp.extra.spec],
  327. plugin_origins: [
  328. {
  329. spec: tmp.extra.spec,
  330. scope: "local",
  331. source: path.join(tmp.path, "tui.json"),
  332. },
  333. ],
  334. }
  335. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  336. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  337. try {
  338. await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
  339. await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
  340. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
  341. } finally {
  342. await TuiPluginRuntime.dispose()
  343. cwd.mockRestore()
  344. wait.mockRestore()
  345. delete process.env.KILO_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.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  369. const config: TuiConfig.Info = {
  370. plugin: [tmp.extra.spec],
  371. plugin_origins: [
  372. {
  373. spec: 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({ api: createTuiPluginApi(), config })
  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. wait.mockRestore()
  389. delete process.env.KILO_PLUGIN_META_FILE
  390. }
  391. })
  392. test("uses npm package name when tui plugin id is omitted", async () => {
  393. await using tmp = await tmpdir({
  394. init: async (dir) => {
  395. const mod = path.join(dir, "mods", "acme-plugin")
  396. const marker = path.join(dir, "name-id-called.txt")
  397. await fs.mkdir(mod, { recursive: true })
  398. await Bun.write(
  399. path.join(mod, "package.json"),
  400. JSON.stringify({
  401. name: "acme-plugin",
  402. type: "module",
  403. exports: { ".": "./index.js", "./tui": "./tui.js" },
  404. }),
  405. )
  406. await Bun.write(path.join(mod, "index.js"), "export default {}\n")
  407. await Bun.write(
  408. path.join(mod, "tui.js"),
  409. `export default {
  410. tui: async (_api, options) => {
  411. if (!options?.marker) return
  412. await Bun.write(options.marker, "called")
  413. },
  414. }
  415. `,
  416. )
  417. return { mod, marker, spec: "[email protected]" }
  418. },
  419. })
  420. process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  421. const config: TuiConfig.Info = {
  422. plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
  423. plugin_origins: [
  424. {
  425. spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
  426. scope: "local",
  427. source: path.join(tmp.path, "tui.json"),
  428. },
  429. ],
  430. }
  431. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  432. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  433. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
  434. try {
  435. await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
  436. await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
  437. expect(TuiPluginRuntime.list().find((item) => item.spec === tmp.extra.spec)?.id).toBe("acme-plugin")
  438. } finally {
  439. await TuiPluginRuntime.dispose()
  440. install.mockRestore()
  441. cwd.mockRestore()
  442. wait.mockRestore()
  443. delete process.env.KILO_PLUGIN_META_FILE
  444. }
  445. })