plugin-hot-reload.test.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. import { expect, mock, test } from "bun:test"
  2. import { createTestRenderer } from "@opentui/core/testing"
  3. import { Effect, FileSystem } from "effect"
  4. import { Global } from "@opencode-ai/util/global"
  5. import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"
  6. import path from "node:path"
  7. import { createEventStream, createFetch, json } from "./fixture/tui-client"
  8. import { tmpdir } from "./fixture/fixture"
  9. function lifecycleSource(marker: string, id: string, version: string) {
  10. return `
  11. import { appendFile } from "node:fs/promises"
  12. export default {
  13. id: ${JSON.stringify(id)},
  14. setup: async () => {
  15. await appendFile(${JSON.stringify(marker)}, "${version}:setup\\n")
  16. return () => appendFile(${JSON.stringify(marker)}, "${version}:cleanup\\n")
  17. },
  18. }
  19. `
  20. }
  21. async function until(read: () => Promise<string>, expected: (value: string | undefined) => boolean) {
  22. let value: string | undefined
  23. for (let attempt = 0; attempt < 200; attempt++) {
  24. value = await read().catch(() => undefined)
  25. if (expected(value)) return value
  26. await Bun.sleep(50)
  27. }
  28. return value
  29. }
  30. async function bootApp(directory: string) {
  31. const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
  32. const core = await import("@opentui/core")
  33. mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
  34. const events = createEventStream()
  35. const calls = createFetch((url) => {
  36. if (url.pathname !== "/api/fs/list") return
  37. return json({
  38. location: {
  39. directory,
  40. project: { id: "proj_test", directory, canonical: directory },
  41. },
  42. data: [],
  43. })
  44. }, events)
  45. const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
  46. const cwd = process.cwd()
  47. process.chdir(directory)
  48. const { run } = await import("../src/app")
  49. const task = Effect.runPromise(
  50. run({
  51. app: { name: "test", version: "test", channel: "test" },
  52. server: { endpoint: { url: server.url.toString() } },
  53. config: { get: async () => ({}), update: async () => ({}) },
  54. packages: { resolve: async () => undefined },
  55. args: {},
  56. log: () => {},
  57. }).pipe(
  58. Effect.provide(Global.layerWith({ config: path.join(directory, ".global") })),
  59. Effect.provide(FileSystem.layerNoop({})),
  60. ),
  61. )
  62. return {
  63. task,
  64. async [Symbol.asyncDispose]() {
  65. process.chdir(cwd)
  66. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  67. await server.stop()
  68. mock.restore()
  69. },
  70. }
  71. }
  72. test("discovers an ancestor TUI plugin directory created after startup", async () => {
  73. await using tmp = await tmpdir()
  74. const cwd = path.join(tmp.path, "repo", "packages", "app")
  75. await mkdir(cwd, { recursive: true })
  76. await mkdir(path.join(tmp.path, "repo", ".git"))
  77. const ready = path.join(tmp.path, "ready.txt")
  78. const marker = path.join(tmp.path, "marker.txt")
  79. const initial = path.join(cwd, ".opencode", "plugins", "tui")
  80. await mkdir(initial, { recursive: true })
  81. await writeFile(path.join(initial, "ready.ts"), lifecycleSource(ready, "test.ready", "ready"))
  82. await using app = await bootApp(cwd)
  83. expect(await until(() => readFile(ready, "utf8"), (value) => value === "ready:setup\n")).toBe("ready:setup\n")
  84. const directory = path.join(tmp.path, "repo", ".opencode", "plugins", "tui")
  85. await mkdir(directory, { recursive: true })
  86. await writeFile(path.join(directory, "hot.ts"), lifecycleSource(marker, "test.hot", "v1"))
  87. expect(
  88. await until(
  89. () => readFile(marker, "utf8"),
  90. (value) => value === "v1:setup\n",
  91. ),
  92. ).toBe("v1:setup\n")
  93. process.emit("SIGHUP")
  94. await app.task
  95. })
  96. test("editing a discovered TUI plugin hot-reloads its fresh module", async () => {
  97. await using tmp = await tmpdir()
  98. const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
  99. await mkdir(directory, { recursive: true })
  100. const marker = path.join(tmp.path, "marker.txt")
  101. const source = path.join(directory, "hot.ts")
  102. await writeFile(source, lifecycleSource(marker, "test.hot", "v1"))
  103. await using app = await bootApp(tmp.path)
  104. const read = () => readFile(marker, "utf8")
  105. expect(await until(read, (value) => value === "v1:setup\n")).toBe("v1:setup\n")
  106. await writeFile(source, lifecycleSource(marker, "test.hot", "v2"))
  107. expect(await until(read, (value) => value?.includes("v2:setup") ?? false)).toBe("v1:setup\nv1:cleanup\nv2:setup\n")
  108. process.emit("SIGHUP")
  109. await app.task
  110. })
  111. test("a plugin whose slot render throws does not take down the TUI", async () => {
  112. await using tmp = await tmpdir()
  113. const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
  114. await mkdir(directory, { recursive: true })
  115. const markerA = path.join(tmp.path, "a.txt")
  116. const markerCrash = path.join(tmp.path, "crash.txt")
  117. const sourceA = path.join(directory, "a.ts")
  118. await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a1"))
  119. await writeFile(
  120. path.join(directory, "crash.ts"),
  121. `
  122. import { appendFile } from "node:fs/promises"
  123. export default {
  124. id: "test.crash",
  125. setup: async (context: any) => {
  126. context.ui.slot("home.footer", () => {
  127. throw new Error("boom")
  128. })
  129. await appendFile(${JSON.stringify(markerCrash)}, "setup\\n")
  130. },
  131. }
  132. `,
  133. )
  134. await using app = await bootApp(tmp.path)
  135. const readA = () => readFile(markerA, "utf8")
  136. expect(await until(readA, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
  137. // The crashing plugin genuinely loaded and registered its slot; without
  138. // this the rest of the test would pass even if it never imported.
  139. expect(await until(() => readFile(markerCrash, "utf8"), (value) => value === "setup\n")).toBe("setup\n")
  140. // The app survives the crashing slot: hot reload still works for others.
  141. // The render-time boundary itself (fallback + toast) is not exercisable
  142. // here: the test renderer never executes slot render bodies, so render
  143. // containment is verified in the real TUI (see PluginBoundary in
  144. // src/plugin/render.tsx and the demo runs on the PR).
  145. await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a2"))
  146. expect(await until(readA, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n")
  147. process.emit("SIGHUP")
  148. await app.task
  149. })
  150. test("editing one plugin leaves others untouched and a broken save keeps the last good version", async () => {
  151. await using tmp = await tmpdir()
  152. const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
  153. await mkdir(directory, { recursive: true })
  154. const markerA = path.join(tmp.path, "a.txt")
  155. const markerB = path.join(tmp.path, "b.txt")
  156. const sourceA = path.join(directory, "a.ts")
  157. const sourceB = path.join(directory, "b.ts")
  158. await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a1"))
  159. await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b1"))
  160. await using app = await bootApp(tmp.path)
  161. const readA = () => readFile(markerA, "utf8")
  162. const readB = () => readFile(markerB, "utf8")
  163. await until(readA, (value) => value === "a1:setup\n")
  164. await until(readB, (value) => value === "b1:setup\n")
  165. // Editing B restarts only B: A sees no cleanup and no second setup.
  166. await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b2"))
  167. expect(await until(readB, (value) => value?.includes("b2:setup") ?? false)).toBe("b1:setup\nb1:cleanup\nb2:setup\n")
  168. expect(await readA()).toBe("a1:setup\n")
  169. // A broken save keeps the last good version running: b2 is never cleaned
  170. // up. Editing A afterwards provides a positive completion signal — once
  171. // A's swap lands, the serialized reconcile has processed the broken save.
  172. await writeFile(sourceB, "export default {")
  173. await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a2"))
  174. expect(await until(readA, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n")
  175. expect(await readB()).toBe("b1:setup\nb1:cleanup\nb2:setup\n")
  176. // Fixing the file replaces the kept version and leaves A alone.
  177. await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b3"))
  178. expect(await until(readB, (value) => value?.includes("b3:setup") ?? false)).toBe(
  179. "b1:setup\nb1:cleanup\nb2:setup\nb2:cleanup\nb3:setup\n",
  180. )
  181. expect(await readA()).toBe("a1:setup\na1:cleanup\na2:setup\n")
  182. process.emit("SIGHUP")
  183. await app.task
  184. })
  185. test("a save whose setup throws restores the previous version", async () => {
  186. await using tmp = await tmpdir()
  187. const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
  188. await mkdir(directory, { recursive: true })
  189. const marker = path.join(tmp.path, "a.txt")
  190. const source = path.join(directory, "a.ts")
  191. await writeFile(source, lifecycleSource(marker, "test.a", "a1"))
  192. await using app = await bootApp(tmp.path)
  193. const read = () => readFile(marker, "utf8")
  194. expect(await until(read, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
  195. // The module imports fine but its setup throws — unlike an import failure,
  196. // the swap has already torn down a1, so keep-last-good means restoring it.
  197. await writeFile(
  198. source,
  199. `
  200. export default {
  201. id: "test.a",
  202. setup: async () => {
  203. throw new Error("setup boom")
  204. },
  205. }
  206. `,
  207. )
  208. expect(await until(read, (value) => value === "a1:setup\na1:cleanup\na1:setup\n")).toBe(
  209. "a1:setup\na1:cleanup\na1:setup\n",
  210. )
  211. // Fixing the file swaps out the restored version normally.
  212. await writeFile(source, lifecycleSource(marker, "test.a", "a2"))
  213. expect(await until(read, (value) => value?.includes("a2:setup") ?? false)).toBe(
  214. "a1:setup\na1:cleanup\na1:setup\na1:cleanup\na2:setup\n",
  215. )
  216. process.emit("SIGHUP")
  217. await app.task
  218. })
  219. test("editing a symlinked plugin's target hot-reloads it", async () => {
  220. await using tmp = await tmpdir()
  221. const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
  222. await mkdir(directory, { recursive: true })
  223. const marker = path.join(tmp.path, "a.txt")
  224. // The real source lives outside the discovery directory; only a symlink
  225. // is discovered. Edits land at the target, which emits no event in the
  226. // plugin directory itself.
  227. const target = path.join(tmp.path, "elsewhere", "a.ts")
  228. await mkdir(path.dirname(target), { recursive: true })
  229. await writeFile(target, lifecycleSource(marker, "test.a", "a1"))
  230. await symlink(target, path.join(directory, "a.ts"))
  231. await using app = await bootApp(tmp.path)
  232. const read = () => readFile(marker, "utf8")
  233. expect(await until(read, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
  234. await writeFile(target, lifecycleSource(marker, "test.a", "a2"))
  235. expect(await until(read, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n")
  236. process.emit("SIGHUP")
  237. await app.task
  238. })
  239. test("memory storage survives hot reload while disk storage persists", async () => {
  240. await using tmp = await tmpdir()
  241. const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
  242. await mkdir(directory, { recursive: true })
  243. const marker = path.join(tmp.path, "counter.txt")
  244. const source = path.join(directory, "counter.ts")
  245. const counterSource = (note: string) => `
  246. import { appendFile } from "node:fs/promises"
  247. // ${note}
  248. export default {
  249. id: "test.counter",
  250. setup: async (context: any) => {
  251. const [state, update] = context.storage.memory("counter", { initial: { count: 0 } })
  252. update((draft: any) => {
  253. draft.count += 1
  254. })
  255. await appendFile(${JSON.stringify(marker)}, "count:" + state.count + "\\n")
  256. },
  257. }
  258. `
  259. await writeFile(source, counterSource("v1"))
  260. await using app = await bootApp(tmp.path)
  261. const read = () => readFile(marker, "utf8")
  262. expect(await until(read, (value) => value === "count:1\n")).toBe("count:1\n")
  263. // The reloaded generation shares the same live store: the count continues.
  264. await writeFile(source, counterSource("v2"))
  265. expect(await until(read, (value) => value?.includes("count:2") ?? false)).toBe("count:1\ncount:2\n")
  266. process.emit("SIGHUP")
  267. await app.task
  268. })