1
0

app-lifecycle.test.tsx 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  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/permission") return json({ data: [] })
  96. }, events)
  97. const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
  98. const originalWrite = process.stdout.write.bind(process.stdout)
  99. let stdout = ""
  100. let api: TuiPluginApi | undefined
  101. let started!: () => void
  102. const ready = new Promise<void>((resolve) => {
  103. started = resolve
  104. })
  105. process.stdout.write = ((chunk: string | Uint8Array) => {
  106. stdout += String(chunk)
  107. return true
  108. }) as typeof process.stdout.write
  109. try {
  110. const { run } = await import("../src/app")
  111. const task = Effect.runPromise(
  112. run({
  113. server: { endpoint: { url: server.url.toString() } },
  114. config: createTuiResolvedConfig({ plugin_enabled: {} }),
  115. args: { sessionID: "dummy" },
  116. log: () => {},
  117. pluginHost: {
  118. async start(input) {
  119. api = input.api
  120. started()
  121. },
  122. async dispose() {},
  123. },
  124. }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
  125. )
  126. await ready
  127. await initialTitleSet
  128. events.emit({
  129. id: "evt_renamed",
  130. created: 1,
  131. type: "session.renamed",
  132. durable: { aggregateID: "dummy", seq: 1, version: 1 },
  133. data: { sessionID: "dummy", title: "Renamed session" },
  134. })
  135. await renamedTitleSet
  136. api?.keymap.dispatchCommand("app.exit")
  137. await task
  138. expect(stdout).toContain("Renamed session")
  139. expect(stdout).toContain("opencode -s dummy")
  140. } finally {
  141. process.stdout.write = originalWrite
  142. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  143. await server.stop()
  144. mock.restore()
  145. }
  146. })