mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-09 23:18:30 +00:00
330 lines
10 KiB
TypeScript
330 lines
10 KiB
TypeScript
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 { tui, type TuiHandle } from "@opencode-ai/tui"
|
|
import { createLegacyTuiHost } from "../../../src/cli/tui/host"
|
|
import { Global } from "@opencode-ai/core/global"
|
|
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
|
|
import * as TuiAudio from "../../../src/cli/tui/audio"
|
|
import * as TuiKeymap from "@opencode-ai/tui/keymap"
|
|
import { createTuiBuildInfo, createTuiEnvironment } from "@opencode-ai/tui/runtime"
|
|
|
|
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]()
|
|
})
|
|
|
|
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("plugin startup failure does not fail the app", async () => {
|
|
const error = spyOn(console, "error").mockImplementation(() => {})
|
|
try {
|
|
const app = await startTui({ rejectPlugins: new Error("plugins failed") })
|
|
app.theme.resolve("dark")
|
|
|
|
await expect(app.handle.ready).resolves.toBeUndefined()
|
|
await app.pluginHost.started
|
|
expect(app.setup.renderer.isDestroyed).toBe(false)
|
|
expect(app.pluginHost.starts).toBe(1)
|
|
await app.handle.exit()
|
|
await app.handle.done
|
|
} finally {
|
|
error.mockRestore()
|
|
}
|
|
})
|
|
|
|
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")).toBeLessThanOrEqual(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")).toBeLessThanOrEqual(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")).toBeLessThanOrEqual(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 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(app.pluginHost.disposes).toBe(1)
|
|
expect(disposeAudio).toHaveBeenCalledTimes(1)
|
|
} finally {
|
|
registerKeymap.mockRestore()
|
|
disposeAudio.mockRestore()
|
|
}
|
|
})
|
|
|
|
test("plugin disposal failure does not stop remaining cleanup", async () => {
|
|
const error = spyOn(console, "error").mockImplementation(() => {})
|
|
const disposeAudio = spyOn(TuiAudio, "dispose")
|
|
try {
|
|
const app = await startTui({ rejectPluginDispose: new Error("dispose failed") })
|
|
app.theme.resolve("dark")
|
|
await app.handle.ready
|
|
|
|
await app.handle.exit()
|
|
await app.handle.done
|
|
|
|
expect(app.pluginHost.disposes).toBe(1)
|
|
expect(disposeAudio).toHaveBeenCalledTimes(1)
|
|
expect(app.setup.renderer.isDestroyed).toBe(true)
|
|
} finally {
|
|
error.mockRestore()
|
|
disposeAudio.mockRestore()
|
|
}
|
|
})
|
|
|
|
async function startTui(options: { rejectTheme?: Error; rejectPlugins?: Error; rejectPluginDispose?: Error } = {}) {
|
|
const tmp = await tmpdir()
|
|
const isolated = 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 pluginStarted = deferred<void>()
|
|
const pluginHost = {
|
|
starts: 0,
|
|
disposes: 0,
|
|
started: pluginStarted.promise,
|
|
async start() {
|
|
pluginHost.starts++
|
|
pluginStarted.resolve()
|
|
if (options.rejectPlugins) throw options.rejectPlugins
|
|
},
|
|
async dispose() {
|
|
pluginHost.disposes++
|
|
if (options.rejectPluginDispose) throw options.rejectPluginDispose
|
|
},
|
|
}
|
|
const environment = createTuiEnvironment({
|
|
cwd: tmp.path,
|
|
platform: "linux",
|
|
paths: { home: tmp.path, state: isolated.state, worktree: path.join(tmp.path, "worktree") },
|
|
capabilities: {
|
|
mouse: true,
|
|
copyOnSelect: true,
|
|
terminalTitle: false,
|
|
terminalSuspend: false,
|
|
workspaces: false,
|
|
showTimeToFirstDraw: false,
|
|
},
|
|
terminal: {},
|
|
editor: { zedTerminal: false },
|
|
skipInitialLoading: false,
|
|
})
|
|
const handle = tui({
|
|
environment,
|
|
build: createTuiBuildInfo({ version: "test", channel: "test" }),
|
|
url: "http://test",
|
|
renderer: setup.renderer,
|
|
host: createLegacyTuiHost(setup.renderer),
|
|
config: createTuiResolvedConfig({ plugin_enabled: disabledInternalPlugins }),
|
|
directory,
|
|
fetch: calls.fetch,
|
|
events: events.source,
|
|
pluginHost,
|
|
args: {},
|
|
})
|
|
active = {
|
|
handle,
|
|
setup,
|
|
tmp,
|
|
restore: () => {
|
|
waitForThemeMode.mockRestore()
|
|
isolated.restore()
|
|
},
|
|
}
|
|
|
|
return { handle, setup, theme, pluginHost }
|
|
}
|
|
|
|
async function isolateGlobalPaths(root: string) {
|
|
const previous = Global.Path.config
|
|
Global.Path.config = path.join(root, "config")
|
|
const state = path.join(root, "state")
|
|
await mkdir(Global.Path.config, { recursive: true })
|
|
await mkdir(state, { recursive: true })
|
|
await Bun.write(path.join(state, "kv.json"), JSON.stringify({ animations_enabled: false }))
|
|
|
|
return {
|
|
state,
|
|
restore() {
|
|
Global.Path.config = previous
|
|
},
|
|
}
|
|
}
|
|
|
|
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 }
|
|
}
|