app-lifecycle.test.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. import { expect, mock, test } from "bun:test"
  2. import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
  3. import { createTestRenderer } from "@opentui/core/testing"
  4. import { Effect } from "effect"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { Global } from "@opencode-ai/core/global"
  7. import { createTuiResolvedConfig } from "./fixture/tui-runtime"
  8. import { createEventStream, createFetch, directory, json } from "./fixture/tui-sdk"
  9. test("SIGHUP clears title and disposes scoped resources once", async () => {
  10. const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
  11. const core = await import("@opentui/core")
  12. mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
  13. const titles: string[] = []
  14. const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
  15. setup.renderer.setTerminalTitle = (title) => {
  16. titles.push(title)
  17. setTitle(title)
  18. }
  19. const listeners = new Set(process.listeners("SIGHUP"))
  20. const events = createEventStream()
  21. const calls = createFetch(undefined, events)
  22. const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
  23. let started!: () => void
  24. const ready = new Promise<void>((resolve) => {
  25. started = resolve
  26. })
  27. let disposes = 0
  28. try {
  29. const { run } = await import("../src/app")
  30. const task = Effect.runPromise(
  31. run({
  32. server: { endpoint: { url: server.url.toString() } },
  33. config: createTuiResolvedConfig({ plugin_enabled: {} }),
  34. args: {},
  35. log: () => {},
  36. pluginHost: {
  37. async start() {
  38. started()
  39. },
  40. async dispose() {
  41. disposes++
  42. },
  43. },
  44. }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
  45. )
  46. await ready
  47. process.emit("SIGHUP")
  48. await task
  49. expect(setup.renderer.isDestroyed).toBe(true)
  50. expect(titles.at(-1)).toBe("")
  51. expect(disposes).toBe(1)
  52. expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
  53. } finally {
  54. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  55. await server.stop()
  56. mock.restore()
  57. }
  58. })
  59. test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
  60. const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
  61. const core = await import("@opentui/core")
  62. mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
  63. let initialTitle!: () => void
  64. const initialTitleSet = new Promise<void>((resolve) => {
  65. initialTitle = resolve
  66. })
  67. let renamedTitle!: () => void
  68. const renamedTitleSet = new Promise<void>((resolve) => {
  69. renamedTitle = resolve
  70. })
  71. const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
  72. setup.renderer.setTerminalTitle = (title) => {
  73. if (title === "OC | Demo session") initialTitle()
  74. if (title === "OC | Renamed session") renamedTitle()
  75. setTitle(title)
  76. }
  77. const events = createEventStream()
  78. const calls = createFetch((url) => {
  79. const session = {
  80. id: "dummy",
  81. title: "Demo session",
  82. projectID: "project",
  83. location: { directory },
  84. cost: 0,
  85. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  86. time: { created: 0, updated: 0 },
  87. }
  88. if (url.pathname === "/api/session")
  89. return json({
  90. data: [session],
  91. cursor: {},
  92. })
  93. if (url.pathname === "/api/session/dummy") return json({ data: session })
  94. if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
  95. if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
  96. if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
  97. }, events)
  98. const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
  99. const originalWrite = process.stdout.write.bind(process.stdout)
  100. let stdout = ""
  101. let api: TuiPluginApi | undefined
  102. let started!: () => void
  103. const ready = new Promise<void>((resolve) => {
  104. started = resolve
  105. })
  106. process.stdout.write = ((chunk: string | Uint8Array) => {
  107. stdout += String(chunk)
  108. return true
  109. }) as typeof process.stdout.write
  110. try {
  111. const { run } = await import("../src/app")
  112. const task = Effect.runPromise(
  113. run({
  114. server: { endpoint: { url: server.url.toString() } },
  115. config: createTuiResolvedConfig({ plugin_enabled: {} }),
  116. args: { sessionID: "dummy" },
  117. log: () => {},
  118. pluginHost: {
  119. async start(input) {
  120. api = input.api
  121. started()
  122. },
  123. async dispose() {},
  124. },
  125. }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
  126. )
  127. await ready
  128. await initialTitleSet
  129. events.emit({
  130. id: "evt_renamed",
  131. created: 1,
  132. type: "session.renamed",
  133. durable: { aggregateID: "dummy", seq: 1, version: 1 },
  134. data: { sessionID: "dummy", title: "Renamed session" },
  135. })
  136. await renamedTitleSet
  137. api?.keymap.dispatchCommand("app.exit")
  138. await task
  139. expect(stdout).toContain("Renamed session")
  140. expect(stdout).toContain("opencode -s dummy")
  141. } finally {
  142. process.stdout.write = originalWrite
  143. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  144. await server.stop()
  145. mock.restore()
  146. }
  147. })