app-lifecycle.test.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  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 title generated while an untitled session is loading remains visible", 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 titles: string[] = []
  139. const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
  140. const generatedTitle = Promise.withResolvers<void>()
  141. setup.renderer.setTerminalTitle = (title) => {
  142. titles.push(title)
  143. if (title === "OC | Generated title") generatedTitle.resolve()
  144. setTitle(title)
  145. }
  146. const sessionRequested = Promise.withResolvers<void>()
  147. const renameSyncRequested = Promise.withResolvers<void>()
  148. const releaseSession = Promise.withResolvers<void>()
  149. let sessionRequests = 0
  150. const session = {
  151. id: "dummy",
  152. projectID: "project",
  153. location: { directory },
  154. cost: 0,
  155. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  156. time: { created: 0, updated: 0 },
  157. }
  158. const events = createEventStream()
  159. const calls = createFetch(async (url) => {
  160. if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
  161. if (url.pathname === "/api/session/dummy") {
  162. sessionRequests++
  163. sessionRequested.resolve()
  164. if (sessionRequests === 2) renameSyncRequested.resolve()
  165. await releaseSession.promise
  166. return json({ data: session })
  167. }
  168. if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
  169. if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
  170. if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
  171. }, events)
  172. const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
  173. try {
  174. const { run } = await import("../src/app")
  175. const task = Effect.runPromise(
  176. run({
  177. app: { name: "test", version: "test", channel: "test" },
  178. server: { endpoint: { url: server.url.toString() } },
  179. config: { get: async () => ({}), update: async () => ({}) },
  180. packages: { resolve: async () => undefined },
  181. args: { sessionID: "dummy" },
  182. log: () => {},
  183. }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
  184. )
  185. await sessionRequested.promise
  186. events.emit({
  187. id: "evt_renamed",
  188. created: 1,
  189. type: "session.renamed",
  190. durable: { aggregateID: "dummy", seq: 1, version: 1 },
  191. data: { sessionID: "dummy", title: "Generated title" },
  192. })
  193. await Promise.race([
  194. renameSyncRequested.promise,
  195. Bun.sleep(2_000).then(() => {
  196. throw new Error("rename sync did not start")
  197. }),
  198. ])
  199. releaseSession.resolve()
  200. await Promise.race([
  201. generatedTitle.promise,
  202. Bun.sleep(2_000).then(() => {
  203. throw new Error("generated title was not shown")
  204. }),
  205. ])
  206. await Bun.sleep(20)
  207. const generated = titles.lastIndexOf("OC | Generated title")
  208. expect(generated).toBeGreaterThan(-1)
  209. expect(titles.slice(generated + 1)).not.toContain("OpenCode")
  210. setup.renderer.destroy()
  211. await task
  212. } finally {
  213. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  214. await server.stop()
  215. mock.restore()
  216. }
  217. })
  218. test("session startup prompt is submitted exactly once", async () => {
  219. const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
  220. const core = await import("@opentui/core")
  221. mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
  222. const events = createEventStream()
  223. const cwd = process.cwd()
  224. const location = { directory: cwd, project: { id: "project", directory: cwd } }
  225. const session = {
  226. id: "dummy",
  227. title: "Demo session",
  228. projectID: "project",
  229. location: { directory: cwd },
  230. agent: "build",
  231. model: { providerID: "provider", id: "model" },
  232. cost: 0,
  233. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  234. time: { created: 0, updated: 0 },
  235. }
  236. const bodies: unknown[] = []
  237. const promptSubmitted = Promise.withResolvers<void>()
  238. const calls = createFetch(async (url, request) => {
  239. if (url.pathname === "/api/location") return json(location)
  240. if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
  241. if (url.pathname === "/api/session/dummy") return json({ data: session })
  242. if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
  243. if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
  244. if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
  245. if (url.pathname === "/api/agent")
  246. return json({
  247. location,
  248. data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }],
  249. })
  250. if (url.pathname === "/api/model")
  251. return json({
  252. location,
  253. data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }],
  254. })
  255. if (url.pathname === "/api/session/dummy/prompt") {
  256. bodies.push(await request.json())
  257. promptSubmitted.resolve()
  258. return json({ data: {} })
  259. }
  260. }, events)
  261. const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
  262. try {
  263. const { run } = await import("../src/app")
  264. const task = Effect.runPromise(
  265. run({
  266. app: { name: "test", version: "test", channel: "test" },
  267. server: { endpoint: { url: server.url.toString() } },
  268. config: { get: async () => ({}), update: async () => ({}) },
  269. packages: { resolve: async () => undefined },
  270. args: { sessionID: "dummy", prompt: "RESUME_READY" },
  271. log: () => {},
  272. }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
  273. )
  274. await Promise.race([
  275. promptSubmitted.promise,
  276. Bun.sleep(2000).then(() => {
  277. throw new Error("startup prompt was not submitted")
  278. }),
  279. ])
  280. await Bun.sleep(20)
  281. setup.renderer.destroy()
  282. await task
  283. expect(bodies).toHaveLength(1)
  284. expect(bodies[0]).toMatchObject({ text: "RESUME_READY" })
  285. } finally {
  286. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  287. await server.stop()
  288. mock.restore()
  289. }
  290. })