app-lifecycle.test.tsx 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  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 { createEventSource, 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 = createEventSource()
  21. const calls = createFetch()
  22. let started!: () => void
  23. const ready = new Promise<void>((resolve) => {
  24. started = resolve
  25. })
  26. let disposes = 0
  27. try {
  28. const { run } = await import("../src/app")
  29. const task = Effect.runPromise(
  30. run({
  31. url: "http://test",
  32. directory,
  33. config: createTuiResolvedConfig({ plugin_enabled: {} }),
  34. fetch: calls.fetch,
  35. events: events.source,
  36. args: {},
  37. pluginHost: {
  38. async start() {
  39. started()
  40. },
  41. async dispose() {
  42. disposes++
  43. },
  44. },
  45. }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
  46. )
  47. await ready
  48. process.emit("SIGHUP")
  49. await task
  50. expect(setup.renderer.isDestroyed).toBe(true)
  51. expect(titles.at(-1)).toBe("")
  52. expect(disposes).toBe(1)
  53. expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
  54. } finally {
  55. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  56. mock.restore()
  57. }
  58. })
  59. test("app.exit prints the session epilogue after scoped 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. const events = createEventSource()
  64. const calls = createFetch((url) => {
  65. if (url.pathname === "/session")
  66. return json([
  67. {
  68. id: "dummy",
  69. title: "Demo session",
  70. slug: "dummy",
  71. projectID: "project",
  72. directory,
  73. version: "0.0.0-test",
  74. time: { created: 0, updated: 0 },
  75. },
  76. ])
  77. })
  78. const originalWrite = process.stdout.write.bind(process.stdout)
  79. let stdout = ""
  80. let api: TuiPluginApi | undefined
  81. let started!: () => void
  82. const ready = new Promise<void>((resolve) => {
  83. started = resolve
  84. })
  85. process.stdout.write = ((chunk: string | Uint8Array) => {
  86. stdout += String(chunk)
  87. return true
  88. }) as typeof process.stdout.write
  89. try {
  90. const { run } = await import("../src/app")
  91. const task = Effect.runPromise(
  92. run({
  93. url: "http://test",
  94. directory,
  95. config: createTuiResolvedConfig({ plugin_enabled: {} }),
  96. fetch: calls.fetch,
  97. events: events.source,
  98. args: { continue: true },
  99. pluginHost: {
  100. async start(input) {
  101. api = input.api
  102. started()
  103. },
  104. async dispose() {},
  105. },
  106. }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
  107. )
  108. await ready
  109. await setup.renderOnce()
  110. await setup.renderOnce()
  111. api?.keymap.dispatchCommand("app.exit")
  112. await task
  113. expect(stdout).toContain("Demo session")
  114. expect(stdout).toContain("opencode -s dummy")
  115. } finally {
  116. process.stdout.write = originalWrite
  117. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  118. mock.restore()
  119. }
  120. })