app-lifecycle.test.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. import { expect, mock, test } from "bun:test"
  2. import { createTestRenderer } from "@opentui/core/testing"
  3. import { Effect, FileSystem } from "effect"
  4. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  5. import { Global } from "@opencode-ai/util/global"
  6. import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
  7. test("SIGHUP clears title and disposes scoped resources once", async () => {
  8. const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
  9. const core = await import("@opentui/core")
  10. mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
  11. const titles: string[] = []
  12. let started!: () => void
  13. const ready = new Promise<void>((resolve) => {
  14. started = resolve
  15. })
  16. const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
  17. setup.renderer.setTerminalTitle = (title) => {
  18. titles.push(title)
  19. if (title === "OpenCode") started()
  20. setTitle(title)
  21. }
  22. const listeners = new Set(process.listeners("SIGHUP"))
  23. const events = createEventStream()
  24. const calls = createFetch(undefined, events)
  25. const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
  26. try {
  27. const { run } = await import("../src/app")
  28. const task = Effect.runPromise(
  29. run({
  30. app: { name: "test", version: "test", channel: "test" },
  31. server: { endpoint: { url: server.url.toString() } },
  32. config: { get: async () => ({}), update: async () => ({}) },
  33. packages: { resolve: async () => undefined },
  34. args: {},
  35. log: () => {},
  36. }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
  37. )
  38. await ready
  39. process.emit("SIGHUP")
  40. await task
  41. expect(setup.renderer.isDestroyed).toBe(true)
  42. expect(titles.at(-1)).toBe("")
  43. expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
  44. } finally {
  45. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  46. await server.stop()
  47. mock.restore()
  48. }
  49. })
  50. test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
  51. const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
  52. const core = await import("@opentui/core")
  53. mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
  54. let initialTitle!: () => void
  55. const initialTitleSet = new Promise<void>((resolve) => {
  56. initialTitle = resolve
  57. })
  58. let renamedTitle!: () => void
  59. const renamedTitleSet = new Promise<void>((resolve) => {
  60. renamedTitle = resolve
  61. })
  62. const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
  63. setup.renderer.setTerminalTitle = (title) => {
  64. if (title === "OC | Demo session") initialTitle()
  65. if (title === "OC | Renamed session") renamedTitle()
  66. setTitle(title)
  67. }
  68. const events = createEventStream()
  69. let promptRequests = 0
  70. const calls = createFetch((url) => {
  71. const session = {
  72. id: "dummy",
  73. title: "Demo session",
  74. projectID: "project",
  75. location: { directory },
  76. cost: 0,
  77. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  78. time: { created: 0, updated: 0 },
  79. }
  80. if (url.pathname === "/api/session")
  81. return json({
  82. data: [session],
  83. cursor: {},
  84. })
  85. if (url.pathname === "/api/session/dummy") return json({ data: session })
  86. if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
  87. if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
  88. if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
  89. if (url.pathname === "/api/session/dummy/prompt") {
  90. promptRequests++
  91. return json({ data: {} })
  92. }
  93. }, events)
  94. const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
  95. const originalWrite = process.stdout.write.bind(process.stdout)
  96. let stdout = ""
  97. process.stdout.write = ((chunk: string | Uint8Array) => {
  98. stdout += String(chunk)
  99. return true
  100. }) as typeof process.stdout.write
  101. try {
  102. const { run } = await import("../src/app")
  103. const task = Effect.runPromise(
  104. run({
  105. app: { name: "test", version: "test", channel: "test" },
  106. server: { endpoint: { url: server.url.toString() } },
  107. config: { get: async () => ({}), update: async () => ({}) },
  108. packages: { resolve: async () => undefined },
  109. args: { sessionID: "dummy" },
  110. log: () => {},
  111. }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
  112. )
  113. await initialTitleSet
  114. events.emit({
  115. id: "evt_renamed",
  116. created: 1,
  117. type: "session.renamed",
  118. durable: { aggregateID: "dummy", seq: 1, version: 1 },
  119. data: { sessionID: "dummy", title: "Renamed session" },
  120. })
  121. await renamedTitleSet
  122. setup.renderer.destroy()
  123. await task
  124. expect(stdout).toContain("Renamed session")
  125. expect(stdout).toContain("opencode2 -s dummy")
  126. expect(promptRequests).toBe(0)
  127. } finally {
  128. process.stdout.write = originalWrite
  129. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  130. await server.stop()
  131. mock.restore()
  132. }
  133. })
  134. test("session startup prompt is submitted exactly once", async () => {
  135. const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
  136. const core = await import("@opentui/core")
  137. mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
  138. const events = createEventStream()
  139. const cwd = process.cwd()
  140. const location = { directory: cwd, project: { id: "project", directory: cwd } }
  141. const session = {
  142. id: "dummy",
  143. title: "Demo session",
  144. projectID: "project",
  145. location: { directory: cwd },
  146. agent: "build",
  147. model: { providerID: "provider", id: "model" },
  148. cost: 0,
  149. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  150. time: { created: 0, updated: 0 },
  151. }
  152. const bodies: unknown[] = []
  153. const promptSubmitted = Promise.withResolvers<void>()
  154. const calls = createFetch(async (url, request) => {
  155. if (url.pathname === "/api/location") return json(location)
  156. if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
  157. if (url.pathname === "/api/session/dummy") return json({ data: session })
  158. if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
  159. if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
  160. if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
  161. if (url.pathname === "/api/agent")
  162. return json({
  163. location,
  164. data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }],
  165. })
  166. if (url.pathname === "/api/model")
  167. return json({
  168. location,
  169. data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }],
  170. })
  171. if (url.pathname === "/api/session/dummy/prompt") {
  172. bodies.push(await request.json())
  173. promptSubmitted.resolve()
  174. return json({ data: {} })
  175. }
  176. }, events)
  177. const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
  178. try {
  179. const { run } = await import("../src/app")
  180. const task = Effect.runPromise(
  181. run({
  182. app: { name: "test", version: "test", channel: "test" },
  183. server: { endpoint: { url: server.url.toString() } },
  184. config: { get: async () => ({}), update: async () => ({}) },
  185. packages: { resolve: async () => undefined },
  186. args: { sessionID: "dummy", prompt: "RESUME_READY" },
  187. log: () => {},
  188. }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
  189. )
  190. await Promise.race([
  191. promptSubmitted.promise,
  192. Bun.sleep(2000).then(() => {
  193. throw new Error("startup prompt was not submitted")
  194. }),
  195. ])
  196. await Bun.sleep(20)
  197. setup.renderer.destroy()
  198. await task
  199. expect(bodies).toHaveLength(1)
  200. expect(bodies[0]).toMatchObject({ text: "RESUME_READY" })
  201. } finally {
  202. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  203. await server.stop()
  204. mock.restore()
  205. }
  206. })