app-lifecycle.test.tsx 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  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. server: { endpoint: { url: server.url.toString() } },
  31. config: { get: async () => ({}), update: async () => ({}) },
  32. packages: { resolve: async () => undefined },
  33. args: {},
  34. log: () => {},
  35. }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
  36. )
  37. await ready
  38. process.emit("SIGHUP")
  39. await task
  40. expect(setup.renderer.isDestroyed).toBe(true)
  41. expect(titles.at(-1)).toBe("")
  42. expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
  43. } finally {
  44. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  45. await server.stop()
  46. mock.restore()
  47. }
  48. })
  49. test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
  50. const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
  51. const core = await import("@opentui/core")
  52. mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
  53. let initialTitle!: () => void
  54. const initialTitleSet = new Promise<void>((resolve) => {
  55. initialTitle = resolve
  56. })
  57. let renamedTitle!: () => void
  58. const renamedTitleSet = new Promise<void>((resolve) => {
  59. renamedTitle = resolve
  60. })
  61. const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
  62. setup.renderer.setTerminalTitle = (title) => {
  63. if (title === "OC | Demo session") initialTitle()
  64. if (title === "OC | Renamed session") renamedTitle()
  65. setTitle(title)
  66. }
  67. const events = createEventStream()
  68. const calls = createFetch((url) => {
  69. const session = {
  70. id: "dummy",
  71. title: "Demo session",
  72. projectID: "project",
  73. location: { directory },
  74. cost: 0,
  75. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  76. time: { created: 0, updated: 0 },
  77. }
  78. if (url.pathname === "/api/session")
  79. return json({
  80. data: [session],
  81. cursor: {},
  82. })
  83. if (url.pathname === "/api/session/dummy") return json({ data: session })
  84. if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
  85. if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
  86. if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
  87. }, events)
  88. const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
  89. const originalWrite = process.stdout.write.bind(process.stdout)
  90. let stdout = ""
  91. process.stdout.write = ((chunk: string | Uint8Array) => {
  92. stdout += String(chunk)
  93. return true
  94. }) as typeof process.stdout.write
  95. try {
  96. const { run } = await import("../src/app")
  97. const task = Effect.runPromise(
  98. run({
  99. server: { endpoint: { url: server.url.toString() } },
  100. config: { get: async () => ({}), update: async () => ({}) },
  101. packages: { resolve: async () => undefined },
  102. args: { sessionID: "dummy" },
  103. log: () => {},
  104. }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
  105. )
  106. await initialTitleSet
  107. events.emit({
  108. id: "evt_renamed",
  109. created: 1,
  110. type: "session.renamed",
  111. durable: { aggregateID: "dummy", seq: 1, version: 1 },
  112. data: { sessionID: "dummy", title: "Renamed session" },
  113. })
  114. await renamedTitleSet
  115. setup.renderer.destroy()
  116. await task
  117. expect(stdout).toContain("Renamed session")
  118. expect(stdout).toContain("opencode2 -s dummy")
  119. } finally {
  120. process.stdout.write = originalWrite
  121. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  122. await server.stop()
  123. mock.restore()
  124. }
  125. })