app-lifecycle.test.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import { afterEach, expect, spyOn, test } from "bun:test"
  2. import { createTestRenderer } from "@opentui/core/testing"
  3. import { mkdir } from "node:fs/promises"
  4. import path from "node:path"
  5. import { tmpdir } from "../../fixture/fixture"
  6. import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
  7. import { TuiPluginRuntime } from "../../../src/cli/cmd/tui/plugin/runtime"
  8. import { tui, type TuiHandle } from "../../../src/cli/cmd/tui/app"
  9. import { Global } from "@opencode-ai/core/global"
  10. import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
  11. import * as TuiAudio from "../../../src/cli/cmd/tui/util/audio"
  12. import * as TuiKeymap from "../../../src/cli/cmd/tui/keymap"
  13. type TestRendererSetup = Awaited<ReturnType<typeof createTestRenderer>>
  14. type TmpDir = Awaited<ReturnType<typeof tmpdir>>
  15. const disabledInternalPlugins = {
  16. "internal:home-footer": false,
  17. "internal:home-tips": false,
  18. "internal:sidebar-context": false,
  19. "internal:sidebar-mcp": false,
  20. "internal:sidebar-lsp": false,
  21. "internal:sidebar-todo": false,
  22. "internal:sidebar-files": false,
  23. "internal:sidebar-footer": false,
  24. "internal:plugin-manager": false,
  25. "internal:session-v2-debug": false,
  26. "which-key": false,
  27. }
  28. let active: { handle?: TuiHandle; setup?: TestRendererSetup; restore?: () => void; tmp?: TmpDir } | undefined
  29. afterEach(async () => {
  30. const current = active
  31. active = undefined
  32. await current?.handle?.exit().catch(() => {})
  33. await current?.handle?.done.catch(() => {})
  34. await current?.handle?.ready.catch(() => {})
  35. if (current?.setup && !current.setup.renderer.isDestroyed) current.setup.renderer.destroy()
  36. current?.restore?.()
  37. await Bun.sleep(20)
  38. await current?.tmp?.[Symbol.asyncDispose]()
  39. await TuiPluginRuntime.dispose().catch(() => {})
  40. })
  41. test("returns a handle immediately and resolves ready after async mount setup", async () => {
  42. const app = await startTui()
  43. expect(await promiseState(app.handle.ready)).toBe("pending")
  44. app.theme.resolve("dark")
  45. await app.handle.ready
  46. expect(app.setup.renderer.isDestroyed).toBe(false)
  47. expect(await promiseState(app.handle.done)).toBe("pending")
  48. })
  49. test("production can await done only and still receives mount failures", async () => {
  50. const app = await startTui({ rejectTheme: new Error("theme failed") })
  51. await expect(app.handle.done).rejects.toThrow("theme failed")
  52. expect(app.setup.renderer.isDestroyed).toBe(true)
  53. })
  54. test("exit destroys the renderer, resolves done, and runs cleanup once", async () => {
  55. const beforeSighup = process.listenerCount("SIGHUP")
  56. const app = await startTui()
  57. app.theme.resolve("dark")
  58. await app.handle.ready
  59. expect(process.listenerCount("SIGHUP")).toBeGreaterThan(beforeSighup)
  60. await Promise.all([app.handle.exit(), app.handle.exit()])
  61. await app.handle.done
  62. expect(app.setup.renderer.isDestroyed).toBe(true)
  63. expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
  64. })
  65. test("exit preserves reason formatting and exit messages", async () => {
  66. const stdout: string[] = []
  67. const stderr: string[] = []
  68. const stdoutWrite = spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => {
  69. stdout.push(String(chunk))
  70. return true
  71. })
  72. const stderrWrite = spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => {
  73. stderr.push(String(chunk))
  74. return true
  75. })
  76. try {
  77. const app = await startTui()
  78. app.theme.resolve("dark")
  79. await app.handle.ready
  80. app.handle.exit.message.set("goodbye")
  81. await app.handle.exit(new Error("boom"))
  82. await app.handle.done
  83. expect(stderr.join("")).toContain("boom")
  84. expect(stdout.join("")).toBe("goodbye\n")
  85. } finally {
  86. stdoutWrite.mockRestore()
  87. stderrWrite.mockRestore()
  88. }
  89. })
  90. test("exit before ready cancels mount and resolves done", async () => {
  91. const app = await startTui()
  92. await app.handle.exit()
  93. await app.handle.done
  94. expect(app.setup.renderer.isDestroyed).toBe(true)
  95. await expect(app.handle.ready).resolves.toBeUndefined()
  96. })
  97. test("direct renderer destruction still cleans up and resolves done", async () => {
  98. const beforeSighup = process.listenerCount("SIGHUP")
  99. const app = await startTui()
  100. app.theme.resolve("dark")
  101. await app.handle.ready
  102. app.setup.renderer.destroy()
  103. await app.handle.done
  104. expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
  105. })
  106. test("SIGHUP exits before ready and removes its listener", async () => {
  107. const beforeSighup = process.listenerCount("SIGHUP")
  108. const app = await startTui()
  109. process.emit("SIGHUP")
  110. await app.handle.done
  111. expect(app.setup.renderer.isDestroyed).toBe(true)
  112. expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
  113. })
  114. test("SIGHUP exits after ready and removes its listener", async () => {
  115. const beforeSighup = process.listenerCount("SIGHUP")
  116. const app = await startTui()
  117. app.theme.resolve("dark")
  118. await app.handle.ready
  119. process.emit("SIGHUP")
  120. await app.handle.done
  121. expect(app.setup.renderer.isDestroyed).toBe(true)
  122. expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
  123. })
  124. test("plugin, audio, and keymap cleanup run exactly once", async () => {
  125. const originalRegister = TuiKeymap.registerOpencodeKeymap
  126. let unregisterKeymapCalls = 0
  127. const registerKeymap = spyOn(TuiKeymap, "registerOpencodeKeymap").mockImplementation((...args) => {
  128. const unregister = originalRegister(...args)
  129. return () => {
  130. unregisterKeymapCalls++
  131. unregister()
  132. }
  133. })
  134. const disposePlugins = spyOn(TuiPluginRuntime, "dispose")
  135. const disposeAudio = spyOn(TuiAudio, "dispose")
  136. try {
  137. const app = await startTui()
  138. app.theme.resolve("dark")
  139. await app.handle.ready
  140. app.setup.renderer.destroy()
  141. await Promise.all([app.handle.exit(), app.handle.exit()])
  142. await app.handle.done
  143. expect(registerKeymap).toHaveBeenCalledTimes(1)
  144. expect(unregisterKeymapCalls).toBe(1)
  145. expect(disposePlugins).toHaveBeenCalledTimes(1)
  146. expect(disposeAudio).toHaveBeenCalledTimes(1)
  147. } finally {
  148. registerKeymap.mockRestore()
  149. disposePlugins.mockRestore()
  150. disposeAudio.mockRestore()
  151. }
  152. })
  153. async function startTui(options: { rejectTheme?: Error } = {}) {
  154. const tmp = await tmpdir()
  155. const restore = await isolateGlobalPaths(tmp.path)
  156. const setup = await createTestRenderer({ width: 80, height: 24, useThread: false, maxFps: Number.POSITIVE_INFINITY })
  157. const theme = deferred<"dark" | "light" | null>()
  158. const waitForThemeMode = spyOn(setup.renderer, "waitForThemeMode").mockImplementation(() => {
  159. if (options.rejectTheme) return Promise.reject(options.rejectTheme)
  160. return theme.promise
  161. })
  162. setup.renderer.once("destroy", () => theme.resolve(null))
  163. const calls = createFetch()
  164. const events = createEventSource()
  165. const handle = tui({
  166. url: "http://test",
  167. renderer: setup.renderer,
  168. config: createTuiResolvedConfig({ plugin_enabled: disabledInternalPlugins }),
  169. directory,
  170. fetch: calls.fetch,
  171. events: events.source,
  172. args: {},
  173. })
  174. active = {
  175. handle,
  176. setup,
  177. tmp,
  178. restore: () => {
  179. waitForThemeMode.mockRestore()
  180. restore()
  181. },
  182. }
  183. return { handle, setup, theme }
  184. }
  185. async function isolateGlobalPaths(root: string) {
  186. const previous = {
  187. config: Global.Path.config,
  188. state: Global.Path.state,
  189. }
  190. Global.Path.config = path.join(root, "config")
  191. Global.Path.state = path.join(root, "state")
  192. await mkdir(Global.Path.config, { recursive: true })
  193. await mkdir(Global.Path.state, { recursive: true })
  194. await Bun.write(path.join(Global.Path.state, "kv.json"), JSON.stringify({ animations_enabled: false }))
  195. return () => {
  196. Global.Path.config = previous.config
  197. Global.Path.state = previous.state
  198. }
  199. }
  200. async function promiseState(promise: Promise<unknown>) {
  201. let state: "pending" | "resolved" | "rejected" = "pending"
  202. promise.then(
  203. () => {
  204. state = "resolved"
  205. },
  206. () => {
  207. state = "rejected"
  208. },
  209. )
  210. await Promise.resolve()
  211. return state
  212. }
  213. function deferred<T>() {
  214. let resolve!: (value: T | PromiseLike<T>) => void
  215. let reject!: (error: unknown) => void
  216. const promise = new Promise<T>((done, fail) => {
  217. resolve = done
  218. reject = fail
  219. })
  220. return { promise, resolve, reject }
  221. }