Apply PR #28258: Prepare TUI lifecycle for scenario tests

This commit is contained in:
opencode-agent[bot]
2026-05-19 17:24:17 +00:00
9 changed files with 596 additions and 280 deletions

View File

@@ -3,7 +3,7 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import * as Clipboard from "@tui/util/clipboard"
import * as Selection from "@tui/util/selection"
import * as TuiAudio from "@tui/util/audio"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { createCliRenderer, MouseButton, type CliRenderer, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import {
Switch,
@@ -18,7 +18,7 @@ import {
Show,
on,
} from "solid-js"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
import { Flag } from "@opencode-ai/core/flag/flag"
import semver from "semver"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
@@ -51,7 +51,7 @@ import { PromptStashProvider } from "./component/prompt/stash"
import { DialogAlert } from "./ui/dialog-alert"
import { DialogConfirm } from "./ui/dialog-confirm"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit"
import { createExit, ExitProvider, useExit, type Exit } from "./context/exit"
import { Session as SessionApi } from "@/session/session"
import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv"
@@ -123,7 +123,7 @@ const appBindingCommands = [
"app.toggle.session_directory_filter",
] as const
function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
export function tuiRendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
return {
@@ -146,6 +146,34 @@ function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
}
}
export function createTuiRenderer(config: TuiConfig.Resolved) {
return createCliRenderer(tuiRendererConfig(config))
}
export type TuiHandle = {
ready: Promise<void>
done: Promise<void>
exit: Exit
}
type TuiInput = {
url: string
args: Args
config: TuiConfig.Resolved
renderer: CliRenderer
onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
}
type TuiLifecycle = {
exit: Exit
exited: Promise<void>
fail(error: unknown): Promise<never>
}
function errorMessage(error: unknown) {
const formatted = FormatError(error)
if (formatted !== undefined) return formatted
@@ -163,105 +191,175 @@ function errorMessage(error: unknown) {
return FormatUnknownError(error)
}
export function tui(input: {
url: string
args: Args
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
}) {
// promise to prevent immediate exit
// oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve
return new Promise<void>(async (resolve) => {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
export function tui(input: TuiInput): TuiHandle {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
const onExit = async () => {
unguard?.()
resolve()
}
const onBeforeExit = async () => {
offKeymap()
const renderer = input.renderer
const keymap = createDefaultOpenTuiKeymap(renderer)
const unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
const lifecycle = createTuiLifecycle({
renderer,
unguard,
cleanup: async () => {
unregisterKeymap()
await TuiPluginRuntime.dispose()
TuiAudio.dispose()
}
const renderer = await createCliRenderer(rendererConfig(input.config))
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
const keymap = createDefaultOpenTuiKeymap(renderer)
const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
await render(() => {
return (
<ErrorBoundary
fallback={(error, reset) => (
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
)}
>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args}>
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<ProjectProvider>
<SyncProvider>
<SyncProviderV2>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</SyncProviderV2>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ErrorBoundary>
)
}, renderer)
},
})
const ready = mountTui({ ...input, keymap, exit: lifecycle.exit }).catch((error) => lifecycle.fail(error))
const done = waitUntilDone(ready, lifecycle.exited)
return { ready, done, exit: lifecycle.exit }
}
async function mountTui(input: TuiInput & { keymap: ReturnType<typeof createDefaultOpenTuiKeymap>; exit: Exit }) {
const renderer = input.renderer
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
if (renderer.isDestroyed) return
await render(() => {
return (
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} exit={input.exit} mode={mode} />}
>
<OpencodeKeymapProvider keymap={input.keymap}>
<ArgsProvider {...input.args}>
<ExitProvider exit={input.exit}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<ProjectProvider>
<SyncProvider>
<SyncProviderV2>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</SyncProviderV2>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ErrorBoundary>
)
}, renderer)
}
function createTuiLifecycle(input: {
renderer: CliRenderer
unguard?: () => void
cleanup: () => Promise<void>
}): TuiLifecycle {
let resolveExited!: () => void
const exited = new Promise<void>((resolve) => {
resolveExited = resolve
})
let exitCompleted = false
let exiting = false
let cleanupTask: Promise<void> | undefined
const completeExit = () => {
if (exitCompleted) return
exitCompleted = true
resolveExited()
}
const cleanup = () => {
cleanupTask ??= (async () => {
process.off("SIGHUP", onSighup)
try {
await input.cleanup()
} finally {
input.unguard?.()
}
})()
return cleanupTask
}
const exit = createExit(async (reason, message) => {
exiting = true
await cleanup()
if (!input.renderer.isDestroyed) {
input.renderer.setTerminalTitle("")
input.renderer.destroy()
}
win32FlushInputBuffer()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) process.stderr.write(formatted + "\n")
}
const text = message()
if (text) process.stdout.write(text + "\n")
completeExit()
})
const onSighup = () => {
void exit()
}
input.renderer.once("destroy", () => {
if (exiting) return
void cleanup().finally(() => {
win32FlushInputBuffer()
completeExit()
})
})
process.on("SIGHUP", onSighup)
return {
exit,
exited,
async fail(error) {
exiting = true
await cleanup().catch(() => {})
if (!input.renderer.isDestroyed) input.renderer.destroy()
completeExit()
throw error
},
}
}
async function waitUntilDone(ready: Promise<void>, exited: Promise<void>) {
await ready
await exited
}
function App(props: { onSnapshot?: () => Promise<string[]> }) {

View File

@@ -67,7 +67,6 @@ export const AttachCommand = cmd({
})()
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const config = await TuiConfig.get()
const { tui } = await import("./app")
try {
await validateSession({
@@ -82,9 +81,12 @@ export const AttachCommand = cmd({
return
}
await tui({
const { createTuiRenderer, tui } = await import("./app")
const renderer = await createTuiRenderer(config)
const handle = tui({
url: args.url,
config,
renderer,
args: {
continue: args.continue,
sessionID: args.session,
@@ -93,6 +95,7 @@ export const AttachCommand = cmd({
directory,
headers,
})
await handle.done
} finally {
unguard?.()
}

View File

@@ -1,32 +1,21 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import * as Clipboard from "@tui/util/clipboard"
import { createSignal } from "solid-js"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { win32FlushInputBuffer } from "../win32"
import { getScrollAcceleration } from "../util/scroll"
export function ErrorComponent(props: {
error: Error
reset: () => void
onBeforeExit?: () => Promise<void>
onExit: () => Promise<void>
exit: () => Promise<void>
mode?: "dark" | "light"
}) {
const term = useTerminalDimensions()
const renderer = useRenderer()
const handleExit = async () => {
await props.onBeforeExit?.()
renderer.setTerminalTitle("")
renderer.destroy()
win32FlushInputBuffer()
await props.onExit()
}
useKeyboard((evt) => {
if (evt.ctrl && evt.name === "c") {
void handleExit()
void props.exit()
}
})
const [copied, setCopied] = createSignal(false)
@@ -79,7 +68,7 @@ export function ErrorComponent(props: {
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
<text fg={colors.bg}>Reset TUI</text>
</box>
<box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
<box onMouseUp={() => void props.exit()} backgroundColor={colors.primary} padding={1}>
<text fg={colors.bg}>Exit</text>
</box>
</box>

View File

@@ -1,8 +1,6 @@
import { useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { win32FlushInputBuffer } from "../win32"
type Exit = ((reason?: unknown) => Promise<void>) & {
export type Exit = ((reason?: unknown) => Promise<void>) & {
message: {
set: (value?: string) => () => void
clear: () => void
@@ -10,51 +8,35 @@ type Exit = ((reason?: unknown) => Promise<void>) & {
}
}
export function createExit(run: (reason: unknown | undefined, message: () => string | undefined) => Promise<void>) {
let message: string | undefined
let task: Promise<void> | undefined
const store = {
set: (value?: string) => {
const prev = message
message = value
return () => {
message = prev
}
},
clear: () => {
message = undefined
},
get: () => message,
}
return Object.assign(
(reason?: unknown) => {
task ??= run(reason, store.get)
return task
},
{
message: store,
},
) satisfies Exit
}
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
name: "Exit",
init: (input: { onBeforeExit?: () => Promise<void>; onExit?: () => Promise<void> }) => {
const renderer = useRenderer()
let message: string | undefined
let task: Promise<void> | undefined
const store = {
set: (value?: string) => {
const prev = message
message = value
return () => {
message = prev
}
},
clear: () => {
message = undefined
},
get: () => message,
}
const exit: Exit = Object.assign(
(reason?: unknown) => {
if (task) return task
task = (async () => {
await input.onBeforeExit?.()
// Reset window title before destroying renderer
renderer.setTerminalTitle("")
renderer.destroy()
win32FlushInputBuffer()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) {
process.stderr.write(formatted + "\n")
}
}
const text = store.get()
if (text) process.stdout.write(text + "\n")
await input.onExit?.()
})()
return task
},
{
message: store,
},
)
process.on("SIGHUP", () => exit())
return exit
},
init: (input: { exit: Exit }) => input.exit,
})

View File

@@ -241,9 +241,11 @@ export const TuiThreadCommand = cmd({
}, 1000).unref?.()
try {
const { tui } = await import("./app")
await tui({
const { createTuiRenderer, tui } = await import("./app")
const renderer = await createTuiRenderer(config)
const handle = tui({
url: transport.url,
renderer,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
@@ -263,6 +265,7 @@ export const TuiThreadCommand = cmd({
fork: args.fork,
},
})
await handle.done
} finally {
await stop()
}

View File

@@ -2,15 +2,13 @@
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args"
import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit"
import { createExit, ExitProvider } from "../../../../src/cli/cmd/tui/context/exit"
import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv"
import { ProjectProvider, useProject } from "../../../../src/cli/cmd/tui/context/project"
import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk"
import { SDKProvider } from "../../../../src/cli/cmd/tui/context/sdk"
import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
export const worktree = "/tmp/opencode"
export const directory = `${worktree}/packages/opencode`
import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
export async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
@@ -20,83 +18,6 @@ export async function wait(fn: () => boolean, timeout = 2000) {
}
}
export function json(data: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(data), {
...init,
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
})
}
export function eventSource(): EventSource {
return { subscribe: async () => () => {} }
}
export function createEventSource() {
let fn: ((event: GlobalEvent) => void) | undefined
return {
source: {
subscribe: async (handler: (event: GlobalEvent) => void) => {
fn = handler
return () => {
if (fn === handler) fn = undefined
}
},
} satisfies EventSource,
emit(event: GlobalEvent) {
if (!fn) throw new Error("event source not ready")
fn(event)
},
}
}
type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
export function createFetch(override?: FetchHandler) {
const session = [] as URL[]
const fetch = (async (input: RequestInfo | URL) => {
const url = new URL(input instanceof Request ? input.url : String(input))
if (url.pathname === "/session") session.push(url)
const overridden = await override?.(url)
if (overridden) return overridden
switch (url.pathname) {
case "/agent":
case "/command":
case "/experimental/workspace":
case "/experimental/workspace/status":
case "/formatter":
case "/lsp":
return json([])
case "/config":
case "/experimental/resource":
case "/mcp":
case "/provider/auth":
case "/session/status":
return json({})
case "/config/providers":
return json({ providers: {}, default: {} })
case "/experimental/console":
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
case "/path":
return json({ home: "", state: "", config: "", worktree, directory })
case "/project/current":
return json({ id: "proj_test" })
case "/provider":
return json({ all: [], default: {}, connected: [] })
case "/session":
return json([])
case "/vcs":
return json({ branch: "main" })
}
throw new Error(`unexpected request: ${url.pathname}`)
}) as typeof globalThis.fetch
return { fetch, session }
}
type Ctx = { kv: ReturnType<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
export async function mount(override?: FetchHandler) {
@@ -123,7 +44,7 @@ export async function mount(override?: FetchHandler) {
const app = await testRender(() => (
<ArgsProvider>
<ExitProvider>
<ExitProvider exit={createExit(async () => {})}>
<KVProvider>
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
<ProjectProvider>

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)

View File

@@ -0,0 +1,82 @@
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "../../src/cli/cmd/tui/context/sdk"
export const worktree = "/tmp/opencode"
export const directory = `${worktree}/packages/opencode`
export function json(data: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(data), {
...init,
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
})
}
export function eventSource(): EventSource {
return { subscribe: async () => () => {} }
}
export function createEventSource() {
let fn: ((event: GlobalEvent) => void) | undefined
return {
source: {
subscribe: async (handler: (event: GlobalEvent) => void) => {
fn = handler
return () => {
if (fn === handler) fn = undefined
}
},
} satisfies EventSource,
emit(event: GlobalEvent) {
if (!fn) throw new Error("event source not ready")
fn(event)
},
}
}
export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
export function createFetch(override?: FetchHandler) {
const session = [] as URL[]
const fetch = (async (input: RequestInfo | URL) => {
const url = new URL(input instanceof Request ? input.url : String(input))
if (url.pathname === "/session") session.push(url)
const overridden = await override?.(url)
if (overridden) return overridden
switch (url.pathname) {
case "/agent":
case "/command":
case "/experimental/workspace":
case "/experimental/workspace/status":
case "/formatter":
case "/lsp":
return json([])
case "/config":
case "/experimental/resource":
case "/mcp":
case "/provider/auth":
case "/session/status":
return json({})
case "/config/providers":
return json({ providers: {}, default: {} })
case "/experimental/console":
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
case "/path":
return json({ home: "", state: "", config: "", worktree, directory })
case "/project/current":
return json({ id: "proj_test" })
case "/provider":
return json({ all: [], default: {}, connected: [] })
case "/session":
return json([])
case "/vcs":
return json({ branch: "main" })
}
throw new Error(`unexpected request: ${url.pathname}`)
}) as typeof globalThis.fetch
return { fetch, session }
}