Files
opencode/packages/tui/test/completion-notices.test.tsx
T
2026-09-22 01:00:26 +00:00

196 lines
7.9 KiB
TypeScript

import { expect, test } from "bun:test"
import { type Renderable, ScrollBoxRenderable } from "@opentui/core"
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.each([40, 120])("shell completion notices do not navigate at width %s", async (width) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width, height: 36, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const session = {
id: "ses_notices",
title: "Completion notices",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const notices = [
{ source: "shell", state: "completed", shellID: "shell-1", label: "Shell finished", description: "Done" },
{
source: "shell",
state: "error",
jobID: "shell-1",
label: "Shell failed",
description: "Long command ".repeat(30),
},
{
source: "shell",
state: "cancelled",
shellID: "shell-1",
label: "Shell cancelled",
description: "Cancelled command",
},
]
const messages = [
{ id: "user-0", type: "user", text: "Run background tasks", time: { created: 0 } },
{
id: "assistant-0",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test" },
content: [
{
type: "tool",
id: "shell-1",
name: "shell",
state: {
status: "completed",
input: { command: "echo done", background: true },
content: [{ type: "text", text: "Running" }],
metadata: { shellID: "shell-1" },
},
time: { created: 1, completed: 2 },
},
],
time: { created: 1, completed: 2 },
},
...Array.from({ length: 20 }, (_, index) => ({
id: `history-${index}`,
type: "user",
text: `History message ${index}`,
time: { created: index + 3 },
})),
...notices.map(({ label, description, ...metadata }, index) => ({
id: `notice-${index}`,
type: "synthetic",
text: label,
description,
metadata,
time: { created: index + 30 },
})),
]
const calls = createFetch((url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`) return json({ data: messages.toReversed(), cursor: {} })
if (url.pathname === `/api/session/${session.id}/inbox`) return json({ data: [] })
if (url.pathname === `/api/session/${session.id}/permission`) return json({ data: [] })
return undefined
}, createEventStream())
const server = Bun.serve({ port: 0, idleTimeout: 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, tabs: { mode: "off" } }),
update: async () => ({}),
},
packages: { prepare: async () => ({ directory: "" }) },
args: { sessionID: session.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await setup.waitForFrame((frame) => frame.includes("Shell cancelled"))
await setup.waitForVisualIdle()
const find = (root: Renderable): ScrollBoxRenderable | undefined =>
root instanceof ScrollBoxRenderable && root.getRenderable("history-19")
? root
: root.getChildren().map(find).find(Boolean)
const scroll = find(setup.renderer.root)
if (!scroll) throw new Error("Session scrollbox not found")
expect(scroll.scrollTop).toBeGreaterThan(0)
const before = scroll.scrollTop
for (const notice of notices) {
const lines = setup.captureCharFrame().split("\n")
const y = lines.findIndex((line) => line.includes(notice.label))
expect(y).toBeGreaterThanOrEqual(0)
const x = lines[y].indexOf(notice.label)
await setup.mockMouse.click(x + 1, y)
await setup.waitForVisualIdle()
expect(scroll.scrollTop).toBe(before)
expect(setup.renderer.currentFocusedRenderable?.id).toBe(scroll.id)
expect(setup.captureCharFrame()).toContain(notice.label)
}
} finally {
setup.renderer.destroy()
await task
await server.stop()
}
})
test.each([40, 120])("subagent completion notices navigate to the child session at width %s", async (width) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width, height: 20, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const parent = {
id: "ses_parent",
title: "Parent session",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const child = { ...parent, id: "ses_child", title: "Diagnose authentication", parentID: parent.id }
const messages = [
{ id: "user-0", type: "user", text: "Run a background subagent", time: { created: 0 } },
{
id: "notice-0",
type: "synthetic",
text: "Subagent result",
description: "Diagnose subagent search auth",
metadata: { source: "subagent", childID: child.id, agent: "general", state: "completed" },
time: { created: 1 },
},
]
const childMessages = [{ id: "child-user", type: "user", text: "Investigate authentication", time: { created: 0 } }]
const calls = createFetch((url) => {
if (url.pathname === "/api/session") return json({ data: [parent, child], cursor: {} })
if (url.pathname === `/api/session/${parent.id}`) return json({ data: parent })
if (url.pathname === `/api/session/${child.id}`) return json({ data: child })
if (url.pathname === `/api/session/${parent.id}/message`) return json({ data: messages.toReversed(), cursor: {} })
if (url.pathname === `/api/session/${child.id}/message`)
return json({ data: childMessages.toReversed(), cursor: {} })
if (url.pathname.endsWith("/inbox") || url.pathname.endsWith("/permission")) return json({ data: [] })
return undefined
}, createEventStream())
const server = Bun.serve({ port: 0, idleTimeout: 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, tabs: { mode: "off" } }),
update: async () => ({}),
},
packages: { prepare: async () => ({ directory: "" }) },
args: { sessionID: parent.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await setup.waitForFrame((frame) => frame.includes("General finished"))
await setup.waitForVisualIdle()
const lines = setup.captureCharFrame().split("\n")
const y = lines.findIndex((line) => line.includes("General finished"))
const x = lines[y].indexOf("General finished")
await setup.mockMouse.click(x + 1, y)
await setup.waitForFrame((frame) => frame.includes("Investigate authentication"))
} finally {
setup.renderer.destroy()
await task
await server.stop()
}
})