plugin-loader-entrypoint.test.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. import { expect, spyOn, test } from "bun:test"
  2. import fs from "fs/promises"
  3. import path from "path"
  4. import { tmpdir } from "../../fixture/fixture"
  5. import { createTuiPluginApi } from "../../fixture/tui-plugin"
  6. import { TuiConfig } from "../../../src/config/tui"
  7. import { BunProc } from "../../../src/bun"
  8. const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
  9. test("loads npm tui plugin from package ./tui export", async () => {
  10. await using tmp = await tmpdir({
  11. init: async (dir) => {
  12. const mod = path.join(dir, "mods", "acme-plugin")
  13. const marker = path.join(dir, "tui-called.txt")
  14. await fs.mkdir(mod, { recursive: true })
  15. await Bun.write(
  16. path.join(mod, "package.json"),
  17. JSON.stringify({
  18. name: "acme-plugin",
  19. type: "module",
  20. exports: { ".": "./index.js", "./server": "./server.js", "./tui": "./tui.js" },
  21. }),
  22. )
  23. await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
  24. await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
  25. await Bun.write(path.join(mod, "server.js"), "export default {}\n")
  26. await Bun.write(
  27. path.join(mod, "tui.js"),
  28. `export default {
  29. id: "demo.tui.export",
  30. tui: async (_api, options) => {
  31. if (!options?.marker) return
  32. await Bun.write(${JSON.stringify(marker)}, "called")
  33. },
  34. }
  35. `,
  36. )
  37. return { mod, marker, spec: "acme-plugin@1.0.0" }
  38. },
  39. })
  40. process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  41. const get = spyOn(TuiConfig, "get").mockResolvedValue({
  42. plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
  43. plugin_meta: {
  44. [tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
  45. },
  46. })
  47. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  48. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  49. const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
  50. try {
  51. await TuiPluginRuntime.init(createTuiPluginApi())
  52. await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
  53. const hit = TuiPluginRuntime.list().find((item) => item.id === "demo.tui.export")
  54. expect(hit?.enabled).toBe(true)
  55. expect(hit?.active).toBe(true)
  56. expect(hit?.source).toBe("npm")
  57. } finally {
  58. await TuiPluginRuntime.dispose()
  59. install.mockRestore()
  60. cwd.mockRestore()
  61. get.mockRestore()
  62. wait.mockRestore()
  63. delete process.env.OPENCODE_PLUGIN_META_FILE
  64. }
  65. })
  66. test("rejects npm tui export that resolves outside plugin directory", async () => {
  67. await using tmp = await tmpdir({
  68. init: async (dir) => {
  69. const mod = path.join(dir, "mods", "acme-plugin")
  70. const outside = path.join(dir, "outside")
  71. const marker = path.join(dir, "outside-called.txt")
  72. await fs.mkdir(mod, { recursive: true })
  73. await fs.mkdir(outside, { recursive: true })
  74. await Bun.write(
  75. path.join(mod, "package.json"),
  76. JSON.stringify({
  77. name: "acme-plugin",
  78. type: "module",
  79. exports: { ".": "./index.js", "./tui": "./escape/tui.js" },
  80. }),
  81. )
  82. await Bun.write(path.join(mod, "index.js"), "export default {}\n")
  83. await Bun.write(
  84. path.join(outside, "tui.js"),
  85. `export default {
  86. id: "demo.outside",
  87. tui: async () => {
  88. await Bun.write(${JSON.stringify(marker)}, "outside")
  89. },
  90. }
  91. `,
  92. )
  93. await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
  94. return { mod, marker, spec: "acme-plugin@1.0.0" }
  95. },
  96. })
  97. process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  98. const get = spyOn(TuiConfig, "get").mockResolvedValue({
  99. plugin: [tmp.extra.spec],
  100. plugin_meta: {
  101. [tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
  102. },
  103. })
  104. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  105. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  106. const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
  107. try {
  108. await TuiPluginRuntime.init(createTuiPluginApi())
  109. // plugin code never ran
  110. await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
  111. // plugin not listed
  112. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
  113. } finally {
  114. await TuiPluginRuntime.dispose()
  115. install.mockRestore()
  116. cwd.mockRestore()
  117. get.mockRestore()
  118. wait.mockRestore()
  119. delete process.env.OPENCODE_PLUGIN_META_FILE
  120. }
  121. })
  122. test("rejects npm tui plugin that exports server and tui together", async () => {
  123. await using tmp = await tmpdir({
  124. init: async (dir) => {
  125. const mod = path.join(dir, "mods", "acme-plugin")
  126. const marker = path.join(dir, "mixed-called.txt")
  127. await fs.mkdir(mod, { recursive: true })
  128. await Bun.write(
  129. path.join(mod, "package.json"),
  130. JSON.stringify({
  131. name: "acme-plugin",
  132. type: "module",
  133. exports: { ".": "./index.js", "./tui": "./tui.js" },
  134. }),
  135. )
  136. await Bun.write(path.join(mod, "index.js"), "export default {}\n")
  137. await Bun.write(
  138. path.join(mod, "tui.js"),
  139. `export default {
  140. id: "demo.mixed",
  141. server: async () => ({}),
  142. tui: async () => {
  143. await Bun.write(${JSON.stringify(marker)}, "called")
  144. },
  145. }
  146. `,
  147. )
  148. return { mod, marker, spec: "acme-plugin@1.0.0" }
  149. },
  150. })
  151. process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
  152. const get = spyOn(TuiConfig, "get").mockResolvedValue({
  153. plugin: [tmp.extra.spec],
  154. plugin_meta: {
  155. [tmp.extra.spec]: { scope: "local", source: path.join(tmp.path, "tui.json") },
  156. },
  157. })
  158. const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
  159. const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
  160. const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
  161. try {
  162. await TuiPluginRuntime.init(createTuiPluginApi())
  163. await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
  164. expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
  165. } finally {
  166. await TuiPluginRuntime.dispose()
  167. install.mockRestore()
  168. cwd.mockRestore()
  169. get.mockRestore()
  170. wait.mockRestore()
  171. delete process.env.OPENCODE_PLUGIN_META_FILE
  172. }
  173. })