plugin-hot-reload.test.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  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(
  84. await until(
  85. () => readFile(ready, "utf8"),
  86. (value) => value === "ready:setup\n",
  87. ),
  88. ).toBe("ready:setup\n")
  89. const directory = path.join(tmp.path, "repo", ".opencode", "plugins", "tui")
  90. await mkdir(directory, { recursive: true })
  91. await writeFile(path.join(directory, "hot.ts"), lifecycleSource(marker, "test.hot", "v1"))
  92. expect(
  93. await until(
  94. () => readFile(marker, "utf8"),
  95. (value) => value === "v1:setup\n",
  96. ),
  97. ).toBe("v1:setup\n")
  98. process.emit("SIGHUP")
  99. await app.task
  100. })
  101. test("editing a discovered TUI plugin hot-reloads its fresh module", async () => {
  102. await using tmp = await tmpdir()
  103. const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
  104. await mkdir(directory, { recursive: true })
  105. const marker = path.join(tmp.path, "marker.txt")
  106. const source = path.join(directory, "hot.ts")
  107. await writeFile(source, lifecycleSource(marker, "test.hot", "v1"))
  108. await using app = await bootApp(tmp.path)
  109. const read = () => readFile(marker, "utf8")
  110. expect(await until(read, (value) => value === "v1:setup\n")).toBe("v1:setup\n")
  111. await writeFile(source, lifecycleSource(marker, "test.hot", "v2"))
  112. expect(await until(read, (value) => value?.includes("v2:setup") ?? false)).toBe("v1:setup\nv1:cleanup\nv2:setup\n")
  113. process.emit("SIGHUP")
  114. await app.task
  115. })
  116. test("a plugin whose slot render throws does not take down the TUI", async () => {
  117. await using tmp = await tmpdir()
  118. const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
  119. await mkdir(directory, { recursive: true })
  120. const markerA = path.join(tmp.path, "a.txt")
  121. const markerCrash = path.join(tmp.path, "crash.txt")
  122. const sourceA = path.join(directory, "a.ts")
  123. await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a1"))
  124. await writeFile(
  125. path.join(directory, "crash.ts"),
  126. `
  127. import { appendFile } from "node:fs/promises"
  128. export default {
  129. id: "test.crash",
  130. setup: async (context: any) => {
  131. context.ui.slot({
  132. replace: "home.footer",
  133. render: () => {
  134. throw new Error("boom")
  135. },
  136. })
  137. await appendFile(${JSON.stringify(markerCrash)}, "setup\\n")
  138. },
  139. }
  140. `,
  141. )
  142. await using app = await bootApp(tmp.path)
  143. const readA = () => readFile(markerA, "utf8")
  144. expect(await until(readA, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
  145. // The crashing plugin genuinely loaded and registered its slot; without
  146. // this the rest of the test would pass even if it never imported.
  147. expect(
  148. await until(
  149. () => readFile(markerCrash, "utf8"),
  150. (value) => value === "setup\n",
  151. ),
  152. ).toBe("setup\n")
  153. // The app survives the crashing slot: hot reload still works for others.
  154. // The render-time boundary itself (fallback + toast) is not exercisable
  155. // here: the test renderer never executes slot render bodies, so render
  156. // containment is verified in the real TUI (see PluginBoundary in
  157. // src/plugin/render.tsx and the demo runs on the PR).
  158. await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a2"))
  159. expect(await until(readA, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n")
  160. process.emit("SIGHUP")
  161. await app.task
  162. })
  163. test("editing one plugin leaves others untouched and a broken save keeps the last good version", async () => {
  164. await using tmp = await tmpdir()
  165. const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
  166. await mkdir(directory, { recursive: true })
  167. const markerA = path.join(tmp.path, "a.txt")
  168. const markerB = path.join(tmp.path, "b.txt")
  169. const sourceA = path.join(directory, "a.ts")
  170. const sourceB = path.join(directory, "b.ts")
  171. await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a1"))
  172. await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b1"))
  173. await using app = await bootApp(tmp.path)
  174. const readA = () => readFile(markerA, "utf8")
  175. const readB = () => readFile(markerB, "utf8")
  176. await until(readA, (value) => value === "a1:setup\n")
  177. await until(readB, (value) => value === "b1:setup\n")
  178. // Editing B restarts only B: A sees no cleanup and no second setup.
  179. await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b2"))
  180. expect(await until(readB, (value) => value?.includes("b2:setup") ?? false)).toBe("b1:setup\nb1:cleanup\nb2:setup\n")
  181. expect(await readA()).toBe("a1:setup\n")
  182. // A broken save keeps the last good version running: b2 is never cleaned
  183. // up. Editing A afterwards provides a positive completion signal — once
  184. // A's swap lands, the serialized reconcile has processed the broken save.
  185. await writeFile(sourceB, "export default {")
  186. await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a2"))
  187. expect(await until(readA, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n")
  188. expect(await readB()).toBe("b1:setup\nb1:cleanup\nb2:setup\n")
  189. // Fixing the file replaces the kept version and leaves A alone.
  190. await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b3"))
  191. expect(await until(readB, (value) => value?.includes("b3:setup") ?? false)).toBe(
  192. "b1:setup\nb1:cleanup\nb2:setup\nb2:cleanup\nb3:setup\n",
  193. )
  194. expect(await readA()).toBe("a1:setup\na1:cleanup\na2:setup\n")
  195. process.emit("SIGHUP")
  196. await app.task
  197. })
  198. test("a save whose setup throws restores the previous version", async () => {
  199. await using tmp = await tmpdir()
  200. const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
  201. await mkdir(directory, { recursive: true })
  202. const marker = path.join(tmp.path, "a.txt")
  203. const source = path.join(directory, "a.ts")
  204. await writeFile(source, lifecycleSource(marker, "test.a", "a1"))
  205. await using app = await bootApp(tmp.path)
  206. const read = () => readFile(marker, "utf8")
  207. expect(await until(read, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
  208. // The module imports fine but its setup throws — unlike an import failure,
  209. // the swap has already torn down a1, so keep-last-good means restoring it.
  210. await writeFile(
  211. source,
  212. `
  213. export default {
  214. id: "test.a",
  215. setup: async () => {
  216. throw new Error("setup boom")
  217. },
  218. }
  219. `,
  220. )
  221. expect(await until(read, (value) => value === "a1:setup\na1:cleanup\na1:setup\n")).toBe(
  222. "a1:setup\na1:cleanup\na1:setup\n",
  223. )
  224. // Fixing the file swaps out the restored version normally.
  225. await writeFile(source, lifecycleSource(marker, "test.a", "a2"))
  226. expect(await until(read, (value) => value?.includes("a2:setup") ?? false)).toBe(
  227. "a1:setup\na1:cleanup\na1:setup\na1:cleanup\na2:setup\n",
  228. )
  229. process.emit("SIGHUP")
  230. await app.task
  231. })
  232. test("editing a symlinked plugin's target hot-reloads it", async () => {
  233. await using tmp = await tmpdir()
  234. const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
  235. await mkdir(directory, { recursive: true })
  236. const marker = path.join(tmp.path, "a.txt")
  237. // The real source lives outside the discovery directory; only a symlink
  238. // is discovered. Edits land at the target, which emits no event in the
  239. // plugin directory itself.
  240. const target = path.join(tmp.path, "elsewhere", "a.ts")
  241. await mkdir(path.dirname(target), { recursive: true })
  242. await writeFile(target, lifecycleSource(marker, "test.a", "a1"))
  243. await symlink(target, path.join(directory, "a.ts"))
  244. await using app = await bootApp(tmp.path)
  245. const read = () => readFile(marker, "utf8")
  246. expect(await until(read, (value) => value === "a1:setup\n")).toBe("a1:setup\n")
  247. await writeFile(target, lifecycleSource(marker, "test.a", "a2"))
  248. expect(await until(read, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n")
  249. process.emit("SIGHUP")
  250. await app.task
  251. })
  252. test("memory storage survives hot reload while disk storage persists", async () => {
  253. await using tmp = await tmpdir()
  254. const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
  255. await mkdir(directory, { recursive: true })
  256. const marker = path.join(tmp.path, "counter.txt")
  257. const source = path.join(directory, "counter.ts")
  258. const counterSource = (note: string) => `
  259. import { appendFile } from "node:fs/promises"
  260. // ${note}
  261. export default {
  262. id: "test.counter",
  263. setup: async (context: any) => {
  264. const [state, update] = context.storage.memory("counter", { initial: { count: 0 } })
  265. update((draft: any) => {
  266. draft.count += 1
  267. })
  268. await appendFile(${JSON.stringify(marker)}, "count:" + state.count + "\\n")
  269. },
  270. }
  271. `
  272. await writeFile(source, counterSource("v1"))
  273. await using app = await bootApp(tmp.path)
  274. const read = () => readFile(marker, "utf8")
  275. expect(await until(read, (value) => value === "count:1\n")).toBe("count:1\n")
  276. // The reloaded generation shares the same live store: the count continues.
  277. await writeFile(source, counterSource("v2"))
  278. expect(await until(read, (value) => value?.includes("count:2") ?? false)).toBe("count:1\ncount:2\n")
  279. process.emit("SIGHUP")
  280. await app.task
  281. })