import { afterEach, expect, spyOn, test } from "bun:test" import { createTestRenderer } from "@opentui/core/testing" import { mkdir } from "node:fs/promises" import path from "node:path" import { tmpdir } from "../../fixture/fixture" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TuiPluginRuntime } from "../../../src/cli/cmd/tui/plugin/runtime" import { tui, type TuiHandle } from "../../../src/cli/cmd/tui/app" import { Global } from "@opencode-ai/core/global" import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk" import * as TuiAudio from "../../../src/cli/cmd/tui/util/audio" import * as TuiKeymap from "../../../src/cli/cmd/tui/keymap" type TestRendererSetup = Awaited> type TmpDir = Awaited> const disabledInternalPlugins = { "internal:home-footer": false, "internal:home-tips": false, "internal:sidebar-context": false, "internal:sidebar-mcp": false, "internal:sidebar-lsp": false, "internal:sidebar-todo": false, "internal:sidebar-files": false, "internal:sidebar-footer": false, "internal:plugin-manager": false, "internal:session-v2-debug": false, "which-key": false, } let active: { handle?: TuiHandle; setup?: TestRendererSetup; restore?: () => void; tmp?: TmpDir } | undefined afterEach(async () => { const current = active active = undefined await current?.handle?.exit().catch(() => {}) await current?.handle?.done.catch(() => {}) await current?.handle?.ready.catch(() => {}) if (current?.setup && !current.setup.renderer.isDestroyed) current.setup.renderer.destroy() current?.restore?.() await Bun.sleep(20) await current?.tmp?.[Symbol.asyncDispose]() await TuiPluginRuntime.dispose().catch(() => {}) }) test("returns a handle immediately and resolves ready after async mount setup", async () => { const app = await startTui() expect(await promiseState(app.handle.ready)).toBe("pending") app.theme.resolve("dark") await app.handle.ready expect(app.setup.renderer.isDestroyed).toBe(false) expect(await promiseState(app.handle.done)).toBe("pending") }) test("production can await done only and still receives mount failures", async () => { const app = await startTui({ rejectTheme: new Error("theme failed") }) await expect(app.handle.done).rejects.toThrow("theme failed") expect(app.setup.renderer.isDestroyed).toBe(true) }) test("exit destroys the renderer, resolves done, and runs cleanup once", async () => { const beforeSighup = process.listenerCount("SIGHUP") const app = await startTui() app.theme.resolve("dark") await app.handle.ready expect(process.listenerCount("SIGHUP")).toBeGreaterThan(beforeSighup) await Promise.all([app.handle.exit(), app.handle.exit()]) await app.handle.done expect(app.setup.renderer.isDestroyed).toBe(true) expect(process.listenerCount("SIGHUP")).toBe(beforeSighup) }) test("exit preserves reason formatting and exit messages", async () => { const stdout: string[] = [] const stderr: string[] = [] const stdoutWrite = spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => { stdout.push(String(chunk)) return true }) const stderrWrite = spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => { stderr.push(String(chunk)) return true }) try { const app = await startTui() app.theme.resolve("dark") await app.handle.ready app.handle.exit.message.set("goodbye") await app.handle.exit(new Error("boom")) await app.handle.done expect(stderr.join("")).toContain("boom") expect(stdout.join("")).toBe("goodbye\n") } finally { stdoutWrite.mockRestore() stderrWrite.mockRestore() } }) test("exit before ready cancels mount and resolves done", async () => { const app = await startTui() await app.handle.exit() await app.handle.done expect(app.setup.renderer.isDestroyed).toBe(true) await expect(app.handle.ready).resolves.toBeUndefined() }) test("direct renderer destruction still cleans up and resolves done", async () => { const beforeSighup = process.listenerCount("SIGHUP") const app = await startTui() app.theme.resolve("dark") await app.handle.ready app.setup.renderer.destroy() await app.handle.done expect(process.listenerCount("SIGHUP")).toBe(beforeSighup) }) test("SIGHUP exits before ready and removes its listener", async () => { const beforeSighup = process.listenerCount("SIGHUP") const app = await startTui() process.emit("SIGHUP") await app.handle.done expect(app.setup.renderer.isDestroyed).toBe(true) expect(process.listenerCount("SIGHUP")).toBe(beforeSighup) }) test("SIGHUP exits after ready and removes its listener", async () => { const beforeSighup = process.listenerCount("SIGHUP") const app = await startTui() app.theme.resolve("dark") await app.handle.ready process.emit("SIGHUP") await app.handle.done expect(app.setup.renderer.isDestroyed).toBe(true) expect(process.listenerCount("SIGHUP")).toBe(beforeSighup) }) test("plugin, audio, and keymap cleanup run exactly once", async () => { const originalRegister = TuiKeymap.registerOpencodeKeymap let unregisterKeymapCalls = 0 const registerKeymap = spyOn(TuiKeymap, "registerOpencodeKeymap").mockImplementation((...args) => { const unregister = originalRegister(...args) return () => { unregisterKeymapCalls++ unregister() } }) const disposePlugins = spyOn(TuiPluginRuntime, "dispose") const disposeAudio = spyOn(TuiAudio, "dispose") try { const app = await startTui() app.theme.resolve("dark") await app.handle.ready app.setup.renderer.destroy() await Promise.all([app.handle.exit(), app.handle.exit()]) await app.handle.done expect(registerKeymap).toHaveBeenCalledTimes(1) expect(unregisterKeymapCalls).toBe(1) expect(disposePlugins).toHaveBeenCalledTimes(1) expect(disposeAudio).toHaveBeenCalledTimes(1) } finally { registerKeymap.mockRestore() disposePlugins.mockRestore() disposeAudio.mockRestore() } }) async function startTui(options: { rejectTheme?: Error } = {}) { const tmp = await tmpdir() const restore = await isolateGlobalPaths(tmp.path) const setup = await createTestRenderer({ width: 80, height: 24, useThread: false, maxFps: Number.POSITIVE_INFINITY }) const theme = deferred<"dark" | "light" | null>() const waitForThemeMode = spyOn(setup.renderer, "waitForThemeMode").mockImplementation(() => { if (options.rejectTheme) return Promise.reject(options.rejectTheme) return theme.promise }) setup.renderer.once("destroy", () => theme.resolve(null)) const calls = createFetch() const events = createEventSource() const handle = tui({ url: "http://test", renderer: setup.renderer, config: createTuiResolvedConfig({ plugin_enabled: disabledInternalPlugins }), directory, fetch: calls.fetch, events: events.source, args: {}, }) active = { handle, setup, tmp, restore: () => { waitForThemeMode.mockRestore() restore() }, } return { handle, setup, theme } } async function isolateGlobalPaths(root: string) { const previous = { config: Global.Path.config, state: Global.Path.state, } Global.Path.config = path.join(root, "config") Global.Path.state = path.join(root, "state") await mkdir(Global.Path.config, { recursive: true }) await mkdir(Global.Path.state, { recursive: true }) await Bun.write(path.join(Global.Path.state, "kv.json"), JSON.stringify({ animations_enabled: false })) return () => { Global.Path.config = previous.config Global.Path.state = previous.state } } async function promiseState(promise: Promise) { let state: "pending" | "resolved" | "rejected" = "pending" promise.then( () => { state = "resolved" }, () => { state = "rejected" }, ) await Promise.resolve() return state } function deferred() { let resolve!: (value: T | PromiseLike) => void let reject!: (error: unknown) => void const promise = new Promise((done, fail) => { resolve = done reject = fail }) return { promise, resolve, reject } }