Prepare TUI lifecycle for scenario tests (#28258)

This commit is contained in:
Sebastian
2026-05-26 21:02:02 +02:00
committed by GitHub
parent fdfd0afed7
commit 848d763d08
9 changed files with 596 additions and 280 deletions

View File

@@ -0,0 +1,261 @@
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<ReturnType<typeof createTestRenderer>>
type TmpDir = Awaited<ReturnType<typeof tmpdir>>
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<unknown>) {
let state: "pending" | "resolved" | "rejected" = "pending"
promise.then(
() => {
state = "resolved"
},
() => {
state = "rejected"
},
)
await Promise.resolve()
return state
}
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((done, fail) => {
resolve = done
reject = fail
})
return { promise, resolve, reject }
}

View File

@@ -6,6 +6,7 @@ import { onMount } from "solid-js"
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
import { useEvent } from "../../../src/cli/cmd/tui/context/event"
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
const projectID = "proj_test"
@@ -46,35 +47,11 @@ function update(version: string): Event {
}
}
function createSource() {
let fn: ((event: GlobalEvent) => void) | undefined
return {
source: {
subscribe: async (handler: (event: GlobalEvent) => void) => {
fn = handler
return () => {
if (fn === handler) fn = undefined
}
},
},
emit(evt: GlobalEvent) {
if (!fn) throw new Error("event source not ready")
fn(evt)
},
}
}
async function mount() {
const source = createSource()
const events = createEventSource()
const calls = createFetch()
const seen: Event[] = []
const workspaces: Array<string | undefined> = []
const fetch = (async (input: RequestInfo | URL) => {
const url = new URL(input instanceof Request ? input.url : String(input))
if (url.pathname === "/path") return Response.json({ home: "", state: "", config: "", directory: "/tmp/root" })
if (url.pathname === "/project/current") return Response.json({ id: projectID })
throw new Error(`unexpected request: ${url.pathname}`)
}) as typeof globalThis.fetch
let project!: ReturnType<typeof useProject>
let done!: () => void
const ready = new Promise<void>((resolve) => {
@@ -82,7 +59,7 @@ async function mount() {
})
const app = await testRender(() => (
<SDKProvider url="http://test" directory="/tmp/root" events={source.source} fetch={fetch}>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<Probe
onReady={async (ctx) => {
@@ -98,7 +75,7 @@ async function mount() {
))
await ready
return { app, emit: source.emit, project, seen, workspaces }
return { app, emit: events.emit, project, seen, workspaces }
}
function Probe(props: {
@@ -140,7 +117,7 @@ describe("useEvent", () => {
const { app, emit, seen } = await mount()
try {
emit(event(vcs("other"), { directory: "/tmp/root", project: "proj_other" }))
emit(event(vcs("other"), { directory, project: "proj_other" }))
await Bun.sleep(30)
expect(seen).toHaveLength(0)