Files
opencode/packages/tui/test/prompt-shell-failure.test.tsx
Finn763 52a10f573f fix(tui): keep long-pending requests past Bun's fetch deadline (#55149)
Give the TUI client timeout: false, share the no-timeout fetch helper across run, mini and the TUI via @opencode/util/no-timeout-fetch, and surface existing-session shell failures with a toast and composer restore.

Closes #54974

Co-authored-by: Finn763 <finn763@users.noreply.github.com>
2026-10-19 13:37:11 -04:00

77 lines
3.6 KiB
TypeScript

import { expect, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode/util/global"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test("a failed shell command in an existing session toasts and restores the composer", async () => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, kittyKeyboard: true })
await setup.renderer.setupTerminal()
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const shell = Promise.withResolvers<string>()
const events = createEventStream()
// The draft stash is process-wide, so reruns use a fresh session for an empty composer.
const sessionID = `ses_shell_failure_${crypto.randomUUID()}`
const location = { directory, project: { id: "project", directory, canonical: directory } }
const calls = createFetch(async (url, request) => {
if (url.pathname === `/api/session/${sessionID}`)
return json({
data: {
id: sessionID,
projectID: "project",
title: "Shell fixture",
agent: "build",
model: { providerID: "demo", id: "model" },
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/${sessionID}/message`) return json({ data: [], cursor: {} })
if (url.pathname === `/api/session/${sessionID}/inbox` || url.pathname === `/api/session/${sessionID}/permission`)
return json({ data: [] })
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "demo", name: "Demo" }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "demo", name: "Demo Model", variants: [] }] })
if (url.pathname === `/api/session/${sessionID}/shell`) {
shell.resolve((await request.json()).command)
return json({ message: "Shell unavailable" }, { status: 400 })
}
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
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 () => ({ animations: false }), update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: { sessionID },
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await ready.promise
await setup.waitForFrame((frame) => frame.includes("Demo Model"))
await setup.mockInput.typeText("!")
await setup.mockInput.typeText("echo restored")
setup.mockInput.pressEnter()
expect(await shell.promise).toBe("echo restored")
const frame = await setup.waitForFrame((frame) => frame.includes("Shell unavailable"))
expect(frame).toContain("Failed to run shell command")
expect(frame).toContain("echo restored")
} finally {
setup.renderer.destroy()
await task
await server.stop()
}
})