loader-shared.test.ts 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306
  1. import { afterEach, describe, expect, spyOn } from "bun:test"
  2. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  3. import { Effect, Layer } from "effect"
  4. import fs from "fs/promises"
  5. import path from "path"
  6. import { pathToFileURL } from "url"
  7. import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
  8. import { FSUtil } from "@kirincode-ai/core/fs-util"
  9. import { Config } from "@/config/config"
  10. import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
  11. import { testEffect } from "../lib/effect"
  12. const { Plugin } = await import("../../src/plugin/index")
  13. const { PluginLoader } = await import("../../src/plugin/loader")
  14. const { readPackageThemes } = await import("../../src/plugin/shared")
  15. const { Npm } = await import("@kirincode-ai/core/npm")
  16. const { TestConfig } = await import("../fixture/config")
  17. const { RuntimeFlags } = await import("../../src/effect/runtime-flags")
  18. afterEach(async () => {
  19. await disposeAllInstances()
  20. })
  21. const it = testEffect(
  22. Layer.mergeAll(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node])), testInstanceStoreLayer),
  23. )
  24. function withTmp<T, A, E, R>(
  25. init: (dir: string) => Promise<T>,
  26. body: (tmp: { path: string; extra: T }) => Effect.Effect<A, E, R>,
  27. ) {
  28. return Effect.gen(function* () {
  29. const dir = yield* tmpdirScoped()
  30. const extra = yield* Effect.promise(() => init(dir))
  31. return yield* body({ path: dir, extra })
  32. })
  33. }
  34. function load(dir: string, flags?: Parameters<typeof RuntimeFlags.layer>[0]) {
  35. const source = path.join(dir, "kirincode.json")
  36. return Effect.gen(function* () {
  37. const config = yield* Effect.promise(
  38. () => Bun.file(source).json() as Promise<{ plugin?: Array<string | [string, Record<string, unknown>]> }>,
  39. )
  40. const plugins = config.plugin ?? []
  41. return yield* Effect.gen(function* () {
  42. const plugin = yield* Plugin.Service
  43. yield* plugin.list()
  44. }).pipe(
  45. Effect.provide(
  46. LayerNode.compile(Plugin.node, [
  47. [
  48. Config.node,
  49. TestConfig.layer({
  50. get: () =>
  51. Effect.succeed({
  52. plugin: plugins,
  53. plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })),
  54. }),
  55. directories: () => Effect.succeed([dir]),
  56. }),
  57. ],
  58. [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })],
  59. ]),
  60. ),
  61. provideInstance(dir),
  62. )
  63. })
  64. }
  65. describe("plugin.loader.shared", () => {
  66. it.live("loads a file:// plugin function export", () =>
  67. withTmp(
  68. async (dir) => {
  69. const file = path.join(dir, "plugin.ts")
  70. const mark = path.join(dir, "called.txt")
  71. await Bun.write(
  72. file,
  73. [
  74. "export default async () => {",
  75. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  76. " return {}",
  77. "}",
  78. "",
  79. ].join("\n"),
  80. )
  81. await Bun.write(
  82. path.join(dir, "kirincode.json"),
  83. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  84. )
  85. return { mark }
  86. },
  87. (tmp) =>
  88. Effect.gen(function* () {
  89. yield* load(tmp.path)
  90. expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("called")
  91. }),
  92. ),
  93. )
  94. it.live("deduplicates same function exported as default and named", () =>
  95. withTmp(
  96. async (dir) => {
  97. const file = path.join(dir, "plugin.ts")
  98. const mark = path.join(dir, "count.txt")
  99. await Bun.write(mark, "")
  100. await Bun.write(
  101. file,
  102. [
  103. "const run = async () => {",
  104. ` const text = await Bun.file(${JSON.stringify(mark)}).text().catch(() => "")`,
  105. ` await Bun.write(${JSON.stringify(mark)}, text + "1")`,
  106. " return {}",
  107. "}",
  108. "export default run",
  109. "export const named = run",
  110. "",
  111. ].join("\n"),
  112. )
  113. await Bun.write(
  114. path.join(dir, "kirincode.json"),
  115. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  116. )
  117. return { mark }
  118. },
  119. (tmp) =>
  120. Effect.gen(function* () {
  121. yield* load(tmp.path)
  122. expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("1")
  123. }),
  124. ),
  125. )
  126. it.live("uses only default v1 server plugin when present", () =>
  127. withTmp(
  128. async (dir) => {
  129. const file = path.join(dir, "plugin.ts")
  130. const mark = path.join(dir, "count.txt")
  131. await Bun.write(
  132. file,
  133. [
  134. "export default {",
  135. ' id: "demo.v1-default",',
  136. " server: async () => {",
  137. ` await Bun.write(${JSON.stringify(mark)}, "default")`,
  138. " return {}",
  139. " },",
  140. "}",
  141. "export const named = async () => {",
  142. ` await Bun.write(${JSON.stringify(mark)}, "named")`,
  143. " return {}",
  144. "}",
  145. "",
  146. ].join("\n"),
  147. )
  148. await Bun.write(
  149. path.join(dir, "kirincode.json"),
  150. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  151. )
  152. return { mark }
  153. },
  154. (tmp) =>
  155. Effect.gen(function* () {
  156. yield* load(tmp.path)
  157. expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("default")
  158. }),
  159. ),
  160. )
  161. it.live("rejects v1 file server plugin without id", () =>
  162. withTmp(
  163. async (dir) => {
  164. const file = path.join(dir, "plugin.ts")
  165. const mark = path.join(dir, "called.txt")
  166. await Bun.write(
  167. file,
  168. [
  169. "export default {",
  170. " server: async () => {",
  171. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  172. " return {}",
  173. " },",
  174. "}",
  175. "",
  176. ].join("\n"),
  177. )
  178. await Bun.write(
  179. path.join(dir, "kirincode.json"),
  180. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  181. )
  182. return { mark }
  183. },
  184. (tmp) =>
  185. Effect.gen(function* () {
  186. yield* load(tmp.path)
  187. const called = yield* Effect.promise(() =>
  188. Bun.file(tmp.extra.mark)
  189. .text()
  190. .then(() => true)
  191. .catch(() => false),
  192. )
  193. expect(called).toBe(false)
  194. }),
  195. ),
  196. )
  197. it.live("rejects v1 plugin that exports server and tui together", () =>
  198. withTmp(
  199. async (dir) => {
  200. const file = path.join(dir, "plugin.ts")
  201. const mark = path.join(dir, "called.txt")
  202. await Bun.write(
  203. file,
  204. [
  205. "export default {",
  206. ' id: "demo.mixed",',
  207. " server: async () => {",
  208. ` await Bun.write(${JSON.stringify(mark)}, "server")`,
  209. " return {}",
  210. " },",
  211. " tui: async () => {},",
  212. "}",
  213. "",
  214. ].join("\n"),
  215. )
  216. await Bun.write(
  217. path.join(dir, "kirincode.json"),
  218. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  219. )
  220. return { mark }
  221. },
  222. (tmp) =>
  223. Effect.gen(function* () {
  224. yield* load(tmp.path)
  225. const called = yield* Effect.promise(() =>
  226. Bun.file(tmp.extra.mark)
  227. .text()
  228. .then(() => true)
  229. .catch(() => false),
  230. )
  231. expect(called).toBe(false)
  232. }),
  233. ),
  234. )
  235. it.live("resolves npm plugin specs with explicit and default versions", () =>
  236. withTmp(
  237. async (dir) => {
  238. const acme = path.join(dir, "node_modules", "acme-plugin")
  239. const scope = path.join(dir, "node_modules", "scope-plugin")
  240. await fs.mkdir(acme, { recursive: true })
  241. await fs.mkdir(scope, { recursive: true })
  242. await Bun.write(
  243. path.join(acme, "package.json"),
  244. JSON.stringify({ name: "acme-plugin", type: "module", main: "./index.js" }, null, 2),
  245. )
  246. await Bun.write(path.join(acme, "index.js"), "export default { server: async () => ({}) }\n")
  247. await Bun.write(
  248. path.join(scope, "package.json"),
  249. JSON.stringify({ name: "scope-plugin", type: "module", main: "./index.js" }, null, 2),
  250. )
  251. await Bun.write(path.join(scope, "index.js"), "export default { server: async () => ({}) }\n")
  252. await Bun.write(
  253. path.join(dir, "kirincode.json"),
  254. JSON.stringify({ plugin: ["acme-plugin", "scope-plugin@2.3.4"] }, null, 2),
  255. )
  256. return { acme, scope }
  257. },
  258. (tmp) =>
  259. Effect.gen(function* () {
  260. const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
  261. if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined }
  262. return { directory: tmp.extra.scope, entrypoint: undefined }
  263. })
  264. try {
  265. yield* load(tmp.path)
  266. expect(add.mock.calls).toContainEqual(["acme-plugin@latest"])
  267. expect(add.mock.calls).toContainEqual(["scope-plugin@2.3.4"])
  268. } finally {
  269. add.mockRestore()
  270. }
  271. }),
  272. ),
  273. )
  274. it.live("loads npm server plugin from package ./server export", () =>
  275. withTmp(
  276. async (dir) => {
  277. const mod = path.join(dir, "mods", "acme-plugin")
  278. const mark = path.join(dir, "server-called.txt")
  279. await fs.mkdir(mod, { recursive: true })
  280. await Bun.write(
  281. path.join(mod, "package.json"),
  282. JSON.stringify(
  283. {
  284. name: "acme-plugin",
  285. type: "module",
  286. exports: {
  287. ".": "./index.js",
  288. "./server": "./server.js",
  289. "./tui": "./tui.js",
  290. },
  291. },
  292. null,
  293. 2,
  294. ),
  295. )
  296. await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
  297. await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
  298. await Bun.write(
  299. path.join(mod, "server.js"),
  300. [
  301. "export default {",
  302. " server: async () => {",
  303. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  304. " return {}",
  305. " },",
  306. "}",
  307. "",
  308. ].join("\n"),
  309. )
  310. await Bun.write(path.join(mod, "tui.js"), "export default {}\n")
  311. await Bun.write(path.join(dir, "kirincode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
  312. return {
  313. mod,
  314. mark,
  315. }
  316. },
  317. (tmp) =>
  318. Effect.gen(function* () {
  319. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
  320. try {
  321. yield* load(tmp.path)
  322. expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called")
  323. } finally {
  324. install.mockRestore()
  325. }
  326. }),
  327. ),
  328. )
  329. it.live("loads npm server plugin from package server export without leading dot", () =>
  330. withTmp(
  331. async (dir) => {
  332. const mod = path.join(dir, "mods", "acme-plugin")
  333. const dist = path.join(mod, "dist")
  334. const mark = path.join(dir, "server-called.txt")
  335. await fs.mkdir(dist, { recursive: true })
  336. await Bun.write(
  337. path.join(mod, "package.json"),
  338. JSON.stringify(
  339. {
  340. name: "acme-plugin",
  341. type: "module",
  342. exports: {
  343. ".": "./index.js",
  344. "./server": "dist/server.js",
  345. },
  346. },
  347. null,
  348. 2,
  349. ),
  350. )
  351. await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
  352. await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
  353. await Bun.write(
  354. path.join(dist, "server.js"),
  355. [
  356. "export default {",
  357. " server: async () => {",
  358. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  359. " return {}",
  360. " },",
  361. "}",
  362. "",
  363. ].join("\n"),
  364. )
  365. await Bun.write(path.join(dir, "kirincode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
  366. return {
  367. mod,
  368. mark,
  369. }
  370. },
  371. (tmp) =>
  372. Effect.gen(function* () {
  373. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
  374. try {
  375. yield* load(tmp.path)
  376. expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called")
  377. } finally {
  378. install.mockRestore()
  379. }
  380. }),
  381. ),
  382. )
  383. it.live("loads npm server plugin from package main without leading dot", () =>
  384. withTmp(
  385. async (dir) => {
  386. const mod = path.join(dir, "mods", "acme-plugin")
  387. const dist = path.join(mod, "dist")
  388. const mark = path.join(dir, "main-called.txt")
  389. await fs.mkdir(dist, { recursive: true })
  390. await Bun.write(
  391. path.join(mod, "package.json"),
  392. JSON.stringify(
  393. {
  394. name: "acme-plugin",
  395. type: "module",
  396. main: "dist/index.js",
  397. },
  398. null,
  399. 2,
  400. ),
  401. )
  402. await Bun.write(
  403. path.join(dist, "index.js"),
  404. [
  405. "export default {",
  406. " server: async () => {",
  407. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  408. " return {}",
  409. " },",
  410. "}",
  411. "",
  412. ].join("\n"),
  413. )
  414. await Bun.write(path.join(dir, "kirincode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
  415. return {
  416. mod,
  417. mark,
  418. }
  419. },
  420. (tmp) =>
  421. Effect.gen(function* () {
  422. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
  423. try {
  424. yield* load(tmp.path)
  425. expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called")
  426. } finally {
  427. install.mockRestore()
  428. }
  429. }),
  430. ),
  431. )
  432. it.live("does not use npm package exports dot for server entry", () =>
  433. withTmp(
  434. async (dir) => {
  435. const mod = path.join(dir, "mods", "acme-plugin")
  436. const mark = path.join(dir, "dot-server.txt")
  437. await fs.mkdir(mod, { recursive: true })
  438. await Bun.write(
  439. path.join(mod, "package.json"),
  440. JSON.stringify({
  441. name: "acme-plugin",
  442. type: "module",
  443. exports: { ".": "./index.js" },
  444. }),
  445. )
  446. await Bun.write(
  447. path.join(mod, "index.js"),
  448. [
  449. "export default {",
  450. ' id: "demo.dot.server",',
  451. " server: async () => {",
  452. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  453. " return {}",
  454. " },",
  455. "}",
  456. "",
  457. ].join("\n"),
  458. )
  459. await Bun.write(path.join(dir, "kirincode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
  460. return { mod, mark }
  461. },
  462. (tmp) =>
  463. Effect.gen(function* () {
  464. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
  465. try {
  466. yield* load(tmp.path)
  467. const called = yield* Effect.promise(() =>
  468. Bun.file(tmp.extra.mark)
  469. .text()
  470. .then(() => true)
  471. .catch(() => false),
  472. )
  473. expect(called).toBe(false)
  474. } finally {
  475. install.mockRestore()
  476. }
  477. }),
  478. ),
  479. )
  480. it.live("rejects npm server export that resolves outside plugin directory", () =>
  481. withTmp(
  482. async (dir) => {
  483. const mod = path.join(dir, "mods", "acme-plugin")
  484. const outside = path.join(dir, "outside")
  485. const mark = path.join(dir, "outside-server.txt")
  486. await fs.mkdir(mod, { recursive: true })
  487. await fs.mkdir(outside, { recursive: true })
  488. await Bun.write(
  489. path.join(mod, "package.json"),
  490. JSON.stringify(
  491. {
  492. name: "acme-plugin",
  493. type: "module",
  494. exports: {
  495. ".": "./index.js",
  496. "./server": "./escape/server.js",
  497. },
  498. },
  499. null,
  500. 2,
  501. ),
  502. )
  503. await Bun.write(path.join(mod, "index.js"), "export default {}\n")
  504. await Bun.write(
  505. path.join(outside, "server.js"),
  506. [
  507. "export default {",
  508. " server: async () => {",
  509. ` await Bun.write(${JSON.stringify(mark)}, "outside")`,
  510. " return {}",
  511. " },",
  512. "}",
  513. "",
  514. ].join("\n"),
  515. )
  516. await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
  517. await Bun.write(path.join(dir, "kirincode.json"), JSON.stringify({ plugin: ["acme-plugin"] }, null, 2))
  518. return {
  519. mod,
  520. mark,
  521. }
  522. },
  523. (tmp) =>
  524. Effect.gen(function* () {
  525. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
  526. try {
  527. yield* load(tmp.path)
  528. const called = yield* Effect.promise(() =>
  529. Bun.file(tmp.extra.mark)
  530. .text()
  531. .then(() => true)
  532. .catch(() => false),
  533. )
  534. expect(called).toBe(false)
  535. } finally {
  536. install.mockRestore()
  537. }
  538. }),
  539. ),
  540. )
  541. it.live("skips legacy codex and copilot auth plugin specs", () =>
  542. withTmp(
  543. async (dir) => {
  544. await Bun.write(
  545. path.join(dir, "kirincode.json"),
  546. JSON.stringify(
  547. {
  548. plugin: ["opencode-openai-codex-auth@1.0.0", "opencode-copilot-auth@1.0.0", "regular-plugin@1.0.0"],
  549. },
  550. null,
  551. 2,
  552. ),
  553. )
  554. },
  555. (_tmp) =>
  556. Effect.gen(function* () {
  557. const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined })
  558. try {
  559. yield* load(_tmp.path)
  560. const pkgs = install.mock.calls.map((call) => call[0])
  561. expect(pkgs).toContain("regular-plugin@1.0.0")
  562. expect(pkgs).not.toContain("opencode-openai-codex-auth@1.0.0")
  563. expect(pkgs).not.toContain("opencode-copilot-auth@1.0.0")
  564. } finally {
  565. install.mockRestore()
  566. }
  567. }),
  568. ),
  569. )
  570. it.live("skips broken plugin when install fails", () =>
  571. withTmp(
  572. async (dir) => {
  573. const ok = path.join(dir, "ok.ts")
  574. const mark = path.join(dir, "ok.txt")
  575. await Bun.write(
  576. ok,
  577. [
  578. "export default {",
  579. ' id: "demo.ok",',
  580. " server: async () => {",
  581. ` await Bun.write(${JSON.stringify(mark)}, "ok")`,
  582. " return {}",
  583. " },",
  584. "}",
  585. "",
  586. ].join("\n"),
  587. )
  588. await Bun.write(
  589. path.join(dir, "kirincode.json"),
  590. JSON.stringify({ plugin: ["broken-plugin@9.9.9", pathToFileURL(ok).href] }, null, 2),
  591. )
  592. return { mark }
  593. },
  594. (tmp) =>
  595. Effect.gen(function* () {
  596. const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
  597. try {
  598. yield* load(tmp.path)
  599. expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9")
  600. expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
  601. } finally {
  602. install.mockRestore()
  603. }
  604. }),
  605. ),
  606. )
  607. it.live("continues loading plugins when plugin init throws", () =>
  608. withTmp(
  609. async (dir) => {
  610. const file = pathToFileURL(path.join(dir, "throws.ts")).href
  611. const ok = pathToFileURL(path.join(dir, "ok.ts")).href
  612. const mark = path.join(dir, "ok.txt")
  613. await Bun.write(
  614. path.join(dir, "throws.ts"),
  615. [
  616. "export default {",
  617. ' id: "demo.throws",',
  618. " server: async () => {",
  619. ' throw new Error("explode")',
  620. " },",
  621. "}",
  622. "",
  623. ].join("\n"),
  624. )
  625. await Bun.write(
  626. path.join(dir, "ok.ts"),
  627. [
  628. "export default {",
  629. ' id: "demo.ok",',
  630. " server: async () => {",
  631. ` await Bun.write(${JSON.stringify(mark)}, "ok")`,
  632. " return {}",
  633. " },",
  634. "}",
  635. "",
  636. ].join("\n"),
  637. )
  638. await Bun.write(path.join(dir, "kirincode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
  639. return { mark }
  640. },
  641. (tmp) =>
  642. Effect.gen(function* () {
  643. yield* load(tmp.path)
  644. expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
  645. }),
  646. ),
  647. )
  648. it.live("continues loading plugins when plugin module has invalid export", () =>
  649. withTmp(
  650. async (dir) => {
  651. const file = pathToFileURL(path.join(dir, "invalid.ts")).href
  652. const ok = pathToFileURL(path.join(dir, "ok.ts")).href
  653. const mark = path.join(dir, "ok.txt")
  654. await Bun.write(
  655. path.join(dir, "invalid.ts"),
  656. ["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
  657. )
  658. await Bun.write(
  659. path.join(dir, "ok.ts"),
  660. [
  661. "export default {",
  662. ' id: "demo.ok",',
  663. " server: async () => {",
  664. ` await Bun.write(${JSON.stringify(mark)}, "ok")`,
  665. " return {}",
  666. " },",
  667. "}",
  668. "",
  669. ].join("\n"),
  670. )
  671. await Bun.write(path.join(dir, "kirincode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
  672. return { mark }
  673. },
  674. (tmp) =>
  675. Effect.gen(function* () {
  676. yield* load(tmp.path)
  677. expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
  678. }),
  679. ),
  680. )
  681. it.live("continues loading plugins when plugin import fails", () =>
  682. withTmp(
  683. async (dir) => {
  684. const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
  685. const ok = pathToFileURL(path.join(dir, "ok.ts")).href
  686. const mark = path.join(dir, "ok.txt")
  687. await Bun.write(
  688. path.join(dir, "ok.ts"),
  689. [
  690. "export default {",
  691. ' id: "demo.ok",',
  692. " server: async () => {",
  693. ` await Bun.write(${JSON.stringify(mark)}, "ok")`,
  694. " return {}",
  695. " },",
  696. "}",
  697. "",
  698. ].join("\n"),
  699. )
  700. await Bun.write(path.join(dir, "kirincode.json"), JSON.stringify({ plugin: [missing, ok] }, null, 2))
  701. return { mark }
  702. },
  703. (tmp) =>
  704. Effect.gen(function* () {
  705. yield* load(tmp.path)
  706. expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
  707. }),
  708. ),
  709. )
  710. it.live("loads object plugin via plugin.server", () =>
  711. withTmp(
  712. async (dir) => {
  713. const file = path.join(dir, "object-plugin.ts")
  714. const mark = path.join(dir, "object-called.txt")
  715. await Bun.write(
  716. file,
  717. [
  718. "const plugin = {",
  719. ' id: "demo.object",',
  720. " server: async () => {",
  721. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  722. " return {}",
  723. " },",
  724. "}",
  725. "export default plugin",
  726. "",
  727. ].join("\n"),
  728. )
  729. await Bun.write(
  730. path.join(dir, "kirincode.json"),
  731. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  732. )
  733. return { mark }
  734. },
  735. (tmp) =>
  736. Effect.gen(function* () {
  737. yield* load(tmp.path)
  738. expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("called")
  739. }),
  740. ),
  741. )
  742. it.live("passes tuple plugin options into server plugin", () =>
  743. withTmp(
  744. async (dir) => {
  745. const file = path.join(dir, "options-plugin.ts")
  746. const mark = path.join(dir, "options.json")
  747. await Bun.write(
  748. file,
  749. [
  750. "const plugin = {",
  751. ' id: "demo.options",',
  752. " server: async (_input, options) => {",
  753. ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(options ?? null))`,
  754. " return {}",
  755. " },",
  756. "}",
  757. "export default plugin",
  758. "",
  759. ].join("\n"),
  760. )
  761. await Bun.write(
  762. path.join(dir, "kirincode.json"),
  763. JSON.stringify({ plugin: [[pathToFileURL(file).href, { source: "tuple", enabled: true }]] }, null, 2),
  764. )
  765. return { mark }
  766. },
  767. (tmp) =>
  768. Effect.gen(function* () {
  769. yield* load(tmp.path)
  770. expect(
  771. (yield* (yield* FSUtil.Service).readJson(tmp.extra.mark)) as { source: string; enabled: boolean },
  772. ).toEqual({
  773. source: "tuple",
  774. enabled: true,
  775. })
  776. }),
  777. ),
  778. )
  779. it.live("initializes server plugins in config order", () =>
  780. withTmp(
  781. async (dir) => {
  782. const a = path.join(dir, "a-plugin.ts")
  783. const b = path.join(dir, "b-plugin.ts")
  784. const marker = path.join(dir, "server-order.txt")
  785. const aSpec = pathToFileURL(a).href
  786. const bSpec = pathToFileURL(b).href
  787. await Bun.write(
  788. a,
  789. `import fs from "fs/promises"
  790. export default {
  791. id: "demo.order.a",
  792. server: async () => {
  793. await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n")
  794. await Bun.sleep(25)
  795. await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n")
  796. return {}
  797. },
  798. }
  799. `,
  800. )
  801. await Bun.write(
  802. b,
  803. `import fs from "fs/promises"
  804. export default {
  805. id: "demo.order.b",
  806. server: async () => {
  807. await fs.appendFile(${JSON.stringify(marker)}, "b\\n")
  808. return {}
  809. },
  810. }
  811. `,
  812. )
  813. await Bun.write(path.join(dir, "kirincode.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2))
  814. return { marker }
  815. },
  816. (tmp) =>
  817. Effect.gen(function* () {
  818. yield* load(tmp.path)
  819. const lines = (yield* Effect.promise(() => fs.readFile(tmp.extra.marker, "utf8"))).trim().split("\n")
  820. expect(lines).toEqual(["a-start", "a-end", "b"])
  821. }),
  822. ),
  823. )
  824. it.live("skips external plugins in pure mode", () =>
  825. withTmp(
  826. async (dir) => {
  827. const file = path.join(dir, "plugin.ts")
  828. const mark = path.join(dir, "called.txt")
  829. await Bun.write(
  830. file,
  831. [
  832. "export default {",
  833. ' id: "demo.pure",',
  834. " server: async () => {",
  835. ` await Bun.write(${JSON.stringify(mark)}, "called")`,
  836. " return {}",
  837. " },",
  838. "}",
  839. "",
  840. ].join("\n"),
  841. )
  842. await Bun.write(
  843. path.join(dir, "kirincode.json"),
  844. JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
  845. )
  846. return { mark }
  847. },
  848. (tmp) =>
  849. Effect.gen(function* () {
  850. yield* load(tmp.path, { pure: true })
  851. const called = yield* Effect.promise(() =>
  852. fs
  853. .readFile(tmp.extra.mark, "utf8")
  854. .then(() => true)
  855. .catch(() => false),
  856. )
  857. expect(called).toBe(false)
  858. }),
  859. ),
  860. )
  861. it.live("reads oc-themes from package manifest", () =>
  862. withTmp(
  863. async (dir) => {
  864. const mod = path.join(dir, "mod")
  865. await fs.mkdir(path.join(mod, "themes"), { recursive: true })
  866. await Bun.write(
  867. path.join(mod, "package.json"),
  868. JSON.stringify(
  869. {
  870. name: "acme-plugin",
  871. version: "1.0.0",
  872. "oc-themes": ["themes/one.json", "./themes/one.json", "themes/two.json"],
  873. },
  874. null,
  875. 2,
  876. ),
  877. )
  878. return { mod }
  879. },
  880. (tmp) =>
  881. Effect.gen(function* () {
  882. const file = path.join(tmp.extra.mod, "package.json")
  883. const fsys = yield* FSUtil.Service
  884. const json = (yield* fsys.readJson(file)) as Record<string, unknown>
  885. const list = readPackageThemes("acme-plugin", {
  886. dir: tmp.extra.mod,
  887. pkg: file,
  888. json,
  889. })
  890. expect(list).toEqual([
  891. FSUtil.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
  892. FSUtil.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
  893. ])
  894. }),
  895. ),
  896. )
  897. it.live("handles no-entrypoint tui packages via missing callback", () =>
  898. withTmp(
  899. async (dir) => {
  900. const mod = path.join(dir, "mods", "acme-plugin")
  901. await fs.mkdir(path.join(mod, "themes"), { recursive: true })
  902. await Bun.write(
  903. path.join(mod, "package.json"),
  904. JSON.stringify(
  905. {
  906. name: "acme-plugin",
  907. version: "1.0.0",
  908. "oc-themes": ["themes/night.json"],
  909. },
  910. null,
  911. 2,
  912. ),
  913. )
  914. await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
  915. return { mod }
  916. },
  917. (tmp) =>
  918. Effect.gen(function* () {
  919. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
  920. const missing: string[] = []
  921. try {
  922. const loaded = yield* Effect.promise(() =>
  923. PluginLoader.loadExternal({
  924. items: [
  925. {
  926. spec: "acme-plugin@1.0.0",
  927. scope: "local" as const,
  928. source: tmp.path,
  929. },
  930. ],
  931. kind: "tui",
  932. missing: async (item) => {
  933. if (!item.pkg) return
  934. const themes = readPackageThemes(item.spec, item.pkg)
  935. if (!themes.length) return
  936. return {
  937. spec: item.spec,
  938. target: item.target,
  939. themes,
  940. }
  941. },
  942. report: {
  943. missing(_candidate, _retry, message) {
  944. missing.push(message)
  945. },
  946. },
  947. }),
  948. )
  949. expect(loaded).toEqual([
  950. {
  951. spec: "acme-plugin@1.0.0",
  952. target: tmp.extra.mod,
  953. themes: [FSUtil.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
  954. },
  955. ])
  956. expect(missing).toHaveLength(0)
  957. } finally {
  958. install.mockRestore()
  959. }
  960. }),
  961. ),
  962. )
  963. it.live("passes package metadata for entrypoint tui plugins", () =>
  964. withTmp(
  965. async (dir) => {
  966. const mod = path.join(dir, "mods", "acme-plugin")
  967. await fs.mkdir(path.join(mod, "themes"), { recursive: true })
  968. await Bun.write(
  969. path.join(mod, "package.json"),
  970. JSON.stringify(
  971. {
  972. name: "acme-plugin",
  973. version: "1.0.0",
  974. exports: {
  975. "./tui": "./tui.js",
  976. },
  977. "oc-themes": ["themes/night.json"],
  978. },
  979. null,
  980. 2,
  981. ),
  982. )
  983. await Bun.write(path.join(mod, "tui.js"), 'export default { id: "demo", tui: async () => {} }\n')
  984. await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
  985. return { mod }
  986. },
  987. (tmp) =>
  988. Effect.gen(function* () {
  989. const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
  990. try {
  991. const loaded = yield* Effect.promise(() =>
  992. PluginLoader.loadExternal({
  993. items: [
  994. {
  995. spec: "acme-plugin@1.0.0",
  996. scope: "local" as const,
  997. source: tmp.path,
  998. },
  999. ],
  1000. kind: "tui",
  1001. finish: async (item) => {
  1002. if (!item.pkg) return
  1003. return {
  1004. spec: item.spec,
  1005. themes: readPackageThemes(item.spec, item.pkg),
  1006. }
  1007. },
  1008. }),
  1009. )
  1010. expect(loaded).toEqual([
  1011. {
  1012. spec: "acme-plugin@1.0.0",
  1013. themes: [FSUtil.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
  1014. },
  1015. ])
  1016. } finally {
  1017. install.mockRestore()
  1018. }
  1019. }),
  1020. ),
  1021. )
  1022. it.live("rejects oc-themes path traversal", () =>
  1023. withTmp(
  1024. async (dir) => {
  1025. const mod = path.join(dir, "mod")
  1026. await fs.mkdir(mod, { recursive: true })
  1027. const file = path.join(mod, "package.json")
  1028. await Bun.write(file, JSON.stringify({ name: "acme", "oc-themes": ["../escape.json"] }, null, 2))
  1029. return { mod, file }
  1030. },
  1031. (tmp) =>
  1032. Effect.gen(function* () {
  1033. const fsys = yield* FSUtil.Service
  1034. const json = (yield* fsys.readJson(tmp.extra.file)) as Record<string, unknown>
  1035. expect(() =>
  1036. readPackageThemes("acme", {
  1037. dir: tmp.extra.mod,
  1038. pkg: tmp.extra.file,
  1039. json,
  1040. }),
  1041. ).toThrow("outside plugin directory")
  1042. }),
  1043. ),
  1044. )
  1045. it.live("retries failed file plugins once after wait and keeps order", () =>
  1046. withTmp(
  1047. async (dir) => {
  1048. const a = path.join(dir, "a")
  1049. const b = path.join(dir, "b")
  1050. const aSpec = pathToFileURL(a).href
  1051. const bSpec = pathToFileURL(b).href
  1052. await fs.mkdir(a, { recursive: true })
  1053. await fs.mkdir(b, { recursive: true })
  1054. return { a, b, aSpec, bSpec }
  1055. },
  1056. (tmp) =>
  1057. Effect.gen(function* () {
  1058. let wait = 0
  1059. const calls: Array<[string, boolean]> = []
  1060. const loaded = yield* Effect.promise(() =>
  1061. PluginLoader.loadExternal({
  1062. items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({
  1063. spec,
  1064. scope: "local" as const,
  1065. source: tmp.path,
  1066. })),
  1067. kind: "tui",
  1068. wait: async () => {
  1069. wait += 1
  1070. await Bun.write(path.join(tmp.extra.a, "index.ts"), "export default {}\n")
  1071. await Bun.write(path.join(tmp.extra.b, "index.ts"), "export default {}\n")
  1072. },
  1073. report: {
  1074. start(candidate, retry) {
  1075. calls.push([candidate.plan.spec, retry])
  1076. },
  1077. },
  1078. }),
  1079. )
  1080. expect(wait).toBe(1)
  1081. expect(calls).toEqual([
  1082. [tmp.extra.aSpec, false],
  1083. [tmp.extra.bSpec, false],
  1084. [tmp.extra.aSpec, true],
  1085. [tmp.extra.bSpec, true],
  1086. ])
  1087. expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec])
  1088. }),
  1089. ),
  1090. )
  1091. it.live("does not retry permanent file plugin entry errors", () =>
  1092. withTmp(
  1093. async (dir) => {
  1094. const mod = path.join(dir, "bad-entry")
  1095. const spec = pathToFileURL(mod).href
  1096. await fs.mkdir(mod, { recursive: true })
  1097. await Bun.write(
  1098. path.join(mod, "package.json"),
  1099. JSON.stringify({ exports: { "./tui": "../outside.js" } }, null, 2),
  1100. )
  1101. return { spec }
  1102. },
  1103. (tmp) =>
  1104. Effect.gen(function* () {
  1105. let wait = 0
  1106. const errors: Array<[string, boolean]> = []
  1107. const loaded = yield* Effect.promise(() =>
  1108. PluginLoader.loadExternal({
  1109. items: [
  1110. {
  1111. spec: tmp.extra.spec,
  1112. scope: "local" as const,
  1113. source: tmp.path,
  1114. },
  1115. ],
  1116. kind: "tui",
  1117. wait: async () => {
  1118. wait += 1
  1119. },
  1120. report: {
  1121. error(_candidate, retry, stage) {
  1122. errors.push([stage, retry])
  1123. },
  1124. },
  1125. }),
  1126. )
  1127. expect(loaded).toEqual([])
  1128. expect(wait).toBe(0)
  1129. expect(errors).toEqual([["entry", false]])
  1130. }),
  1131. ),
  1132. )
  1133. it.live("does not retry file plugins when finish returns undefined", () =>
  1134. withTmp(
  1135. async (dir) => {
  1136. const file = path.join(dir, "plugin.ts")
  1137. const spec = pathToFileURL(file).href
  1138. await Bun.write(file, "export default {}\n")
  1139. return { spec }
  1140. },
  1141. (tmp) =>
  1142. Effect.gen(function* () {
  1143. let wait = 0
  1144. let count = 0
  1145. const loaded = yield* Effect.promise(() =>
  1146. PluginLoader.loadExternal({
  1147. items: [
  1148. {
  1149. spec: tmp.extra.spec,
  1150. scope: "local" as const,
  1151. source: tmp.path,
  1152. },
  1153. ],
  1154. kind: "tui",
  1155. wait: async () => {
  1156. wait += 1
  1157. },
  1158. finish: async () => {
  1159. count += 1
  1160. },
  1161. }),
  1162. )
  1163. expect(wait).toBe(0)
  1164. expect(count).toBe(1)
  1165. expect(loaded).toEqual([])
  1166. }),
  1167. ),
  1168. )
  1169. it.live("does not wait or retry npm plugin failures", () =>
  1170. Effect.gen(function* () {
  1171. const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
  1172. let wait = 0
  1173. const errors: Array<[string, boolean]> = []
  1174. try {
  1175. const loaded = yield* Effect.promise(() =>
  1176. PluginLoader.loadExternal({
  1177. items: [
  1178. {
  1179. spec: "acme-plugin@1.0.0",
  1180. scope: "local" as const,
  1181. source: "test",
  1182. },
  1183. ],
  1184. kind: "tui",
  1185. wait: async () => {
  1186. wait += 1
  1187. },
  1188. report: {
  1189. error(_candidate, retry, stage) {
  1190. errors.push([stage, retry])
  1191. },
  1192. },
  1193. }),
  1194. )
  1195. expect(loaded).toEqual([])
  1196. expect(wait).toBe(0)
  1197. expect(errors).toEqual([["install", false]])
  1198. } finally {
  1199. install.mockRestore()
  1200. }
  1201. }),
  1202. )
  1203. })