Drop the burst-aware key interceptor that tried to keep derived prompt state current for every control binding. Frame-batch content updates only, and let exit, clear, stash, and autocomplete read or flush live textarea state at the command boundary instead.
216 lines
8.2 KiB
TypeScript
216 lines
8.2 KiB
TypeScript
import { expect, mock, test } from "bun:test"
|
|
import { createTestRenderer } from "@opentui/core/testing"
|
|
import { Effect, FileSystem } from "effect"
|
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
import { Global } from "@opencode-ai/util/global"
|
|
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
|
|
|
|
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
|
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
|
const core = await import("@opentui/core")
|
|
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
|
const titles: string[] = []
|
|
let started!: () => void
|
|
const ready = new Promise<void>((resolve) => {
|
|
started = resolve
|
|
})
|
|
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
|
setup.renderer.setTerminalTitle = (title) => {
|
|
titles.push(title)
|
|
if (title === "OpenCode") started()
|
|
setTitle(title)
|
|
}
|
|
const listeners = new Set(process.listeners("SIGHUP"))
|
|
const events = createEventStream()
|
|
const calls = createFetch(undefined, events)
|
|
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
|
try {
|
|
const { run } = await import("../src/app")
|
|
const task = Effect.runPromise(
|
|
run({
|
|
app: { name: "test", version: "test", channel: "test" },
|
|
server: { endpoint: { url: server.url.toString() } },
|
|
config: { get: async () => ({}), update: async () => ({}) },
|
|
packages: { resolve: async () => undefined },
|
|
args: {},
|
|
log: () => {},
|
|
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
|
)
|
|
await ready
|
|
process.emit("SIGHUP")
|
|
await task
|
|
|
|
expect(setup.renderer.isDestroyed).toBe(true)
|
|
expect(titles.at(-1)).toBe("")
|
|
expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
|
|
} finally {
|
|
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
|
await server.stop()
|
|
mock.restore()
|
|
}
|
|
})
|
|
|
|
test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
|
|
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
|
const core = await import("@opentui/core")
|
|
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
|
let initialTitle!: () => void
|
|
const initialTitleSet = new Promise<void>((resolve) => {
|
|
initialTitle = resolve
|
|
})
|
|
let renamedTitle!: () => void
|
|
const renamedTitleSet = new Promise<void>((resolve) => {
|
|
renamedTitle = resolve
|
|
})
|
|
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
|
setup.renderer.setTerminalTitle = (title) => {
|
|
if (title === "OC | Demo session") initialTitle()
|
|
if (title === "OC | Renamed session") renamedTitle()
|
|
setTitle(title)
|
|
}
|
|
const events = createEventStream()
|
|
let promptRequests = 0
|
|
const calls = createFetch((url) => {
|
|
const session = {
|
|
id: "dummy",
|
|
title: "Demo session",
|
|
projectID: "project",
|
|
location: { directory },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 0, updated: 0 },
|
|
}
|
|
if (url.pathname === "/api/session")
|
|
return json({
|
|
data: [session],
|
|
cursor: {},
|
|
})
|
|
if (url.pathname === "/api/session/dummy") return json({ data: session })
|
|
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
|
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
|
|
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
|
if (url.pathname === "/api/session/dummy/prompt") {
|
|
promptRequests++
|
|
return json({ data: {} })
|
|
}
|
|
}, events)
|
|
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
|
const originalWrite = process.stdout.write.bind(process.stdout)
|
|
let stdout = ""
|
|
process.stdout.write = ((chunk: string | Uint8Array) => {
|
|
stdout += String(chunk)
|
|
return true
|
|
}) as typeof process.stdout.write
|
|
|
|
try {
|
|
const { run } = await import("../src/app")
|
|
const task = Effect.runPromise(
|
|
run({
|
|
app: { name: "test", version: "test", channel: "test" },
|
|
server: { endpoint: { url: server.url.toString() } },
|
|
config: { get: async () => ({}), update: async () => ({}) },
|
|
packages: { resolve: async () => undefined },
|
|
args: { sessionID: "dummy" },
|
|
log: () => {},
|
|
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
|
)
|
|
|
|
await initialTitleSet
|
|
events.emit({
|
|
id: "evt_renamed",
|
|
created: 1,
|
|
type: "session.renamed",
|
|
durable: { aggregateID: "dummy", seq: 1, version: 1 },
|
|
data: { sessionID: "dummy", title: "Renamed session" },
|
|
})
|
|
await renamedTitleSet
|
|
setup.renderer.destroy()
|
|
await task
|
|
|
|
expect(stdout).toContain("Renamed session")
|
|
expect(stdout).toContain("opencode2 -s dummy")
|
|
expect(promptRequests).toBe(0)
|
|
} finally {
|
|
process.stdout.write = originalWrite
|
|
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
|
await server.stop()
|
|
mock.restore()
|
|
}
|
|
})
|
|
|
|
test("session startup prompt is submitted exactly once", async () => {
|
|
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
|
const core = await import("@opentui/core")
|
|
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
|
const events = createEventStream()
|
|
const cwd = process.cwd()
|
|
const location = { directory: cwd, project: { id: "project", directory: cwd } }
|
|
const session = {
|
|
id: "dummy",
|
|
title: "Demo session",
|
|
projectID: "project",
|
|
location: { directory: cwd },
|
|
agent: "build",
|
|
model: { providerID: "provider", id: "model" },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 0, updated: 0 },
|
|
}
|
|
const bodies: unknown[] = []
|
|
const promptSubmitted = Promise.withResolvers<void>()
|
|
const calls = createFetch(async (url, request) => {
|
|
if (url.pathname === "/api/location") return json(location)
|
|
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
|
if (url.pathname === "/api/session/dummy") return json({ data: session })
|
|
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
|
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
|
|
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
|
if (url.pathname === "/api/agent")
|
|
return json({
|
|
location,
|
|
data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }],
|
|
})
|
|
if (url.pathname === "/api/model")
|
|
return json({
|
|
location,
|
|
data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }],
|
|
})
|
|
if (url.pathname === "/api/session/dummy/prompt") {
|
|
bodies.push(await request.json())
|
|
promptSubmitted.resolve()
|
|
return json({ data: {} })
|
|
}
|
|
}, events)
|
|
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
|
|
|
try {
|
|
const { run } = await import("../src/app")
|
|
const task = Effect.runPromise(
|
|
run({
|
|
app: { name: "test", version: "test", channel: "test" },
|
|
server: { endpoint: { url: server.url.toString() } },
|
|
config: { get: async () => ({}), update: async () => ({}) },
|
|
packages: { resolve: async () => undefined },
|
|
args: { sessionID: "dummy", prompt: "RESUME_READY" },
|
|
log: () => {},
|
|
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
|
)
|
|
|
|
await Promise.race([
|
|
promptSubmitted.promise,
|
|
Bun.sleep(2000).then(() => {
|
|
throw new Error("startup prompt was not submitted")
|
|
}),
|
|
])
|
|
await Bun.sleep(20)
|
|
setup.renderer.destroy()
|
|
await task
|
|
|
|
expect(bodies).toHaveLength(1)
|
|
expect(bodies[0]).toMatchObject({ text: "RESUME_READY" })
|
|
} finally {
|
|
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
|
await server.stop()
|
|
mock.restore()
|
|
}
|
|
})
|