mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
252 lines
11 KiB
TypeScript
252 lines
11 KiB
TypeScript
import { expect, test } from "bun:test"
|
|
import { createTestRenderer } from "@opentui/core/testing"
|
|
import { ScrollBoxRenderable, type Renderable } from "@opentui/core"
|
|
import { Effect, FileSystem } from "effect"
|
|
import { Global } from "@opencode/util/global"
|
|
import type { SessionMessageInfo } from "@opencode/client"
|
|
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
|
|
import { tmpdir } from "./fixture/fixture"
|
|
import { waitForHighlightedFrame } from "./fixture/frame"
|
|
import { mkdir } from "node:fs/promises"
|
|
|
|
test.each([
|
|
[48, 30],
|
|
[80, 30],
|
|
[120, 30],
|
|
[80, 0],
|
|
])(
|
|
"restores exact part/group anchors at width %s with %s trailing lines",
|
|
async (width, lines) => {
|
|
await using state = await tmpdir()
|
|
const setup = await createTestRenderer({ width, height: 30, kittyKeyboard: true })
|
|
await setup.renderer.setupTerminal()
|
|
setup.renderer.start()
|
|
const session = {
|
|
id: "ses_group_navigation",
|
|
title: "Grouped navigation",
|
|
projectID: "proj_test",
|
|
location: { directory },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 0, updated: 0 },
|
|
}
|
|
const other = { ...session, id: "ses_other", title: "Other session" }
|
|
await mkdir(`${state.path}/test/tui`, { recursive: true })
|
|
await Bun.write(
|
|
`${state.path}/test/tui/tabs.json`,
|
|
JSON.stringify({
|
|
global: { tabs: [{ sessionID: session.id }, { sessionID: other.id }], unread: {} },
|
|
cwd: {},
|
|
}),
|
|
)
|
|
const messages: SessionMessageInfo[] = [
|
|
{ type: "user", id: "msg_user", text: "User prompt", time: { created: 0 } },
|
|
{
|
|
type: "assistant",
|
|
id: "msg_a",
|
|
agent: "build",
|
|
model: { providerID: "fixture", id: "fixture" },
|
|
time: { created: 1, completed: 3 },
|
|
content: [
|
|
{ type: "text", text: "First response" },
|
|
{
|
|
type: "reasoning",
|
|
text: "**First title**\n\nFirst thought body with enough text to wrap differently at each tested terminal width, changing the target's measured offset.",
|
|
time: { created: 1, completed: 2 },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "assistant",
|
|
id: "msg_b",
|
|
agent: "build",
|
|
model: { providerID: "fixture", id: "fixture" },
|
|
time: { created: 4, completed: 6 },
|
|
finish: "stop",
|
|
content: [
|
|
{ type: "reasoning", text: "**Second title**\n\nSecond thought body", time: { created: 4, completed: 5 } },
|
|
{ type: "text", text: `Second response\n${"A later line of the response.\n".repeat(lines)}Final marker` },
|
|
],
|
|
},
|
|
]
|
|
const calls = createFetch((url) => {
|
|
if (url.pathname === "/api/session") return json({ data: [session, other], cursor: {} })
|
|
if (url.pathname === `/api/session/${other.id}`) return json({ data: other })
|
|
if (url.pathname === `/api/session/${other.id}/message`)
|
|
return json({
|
|
data: [{ type: "user", id: "msg_other", text: "Other session content", time: { created: 0 } }],
|
|
cursor: {},
|
|
})
|
|
if (url.pathname === `/api/session/${other.id}/inbox` || url.pathname === `/api/session/${other.id}/permission`)
|
|
return json({ data: [] })
|
|
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` ||
|
|
url.pathname === `/api/session/${session.id}/permission`
|
|
)
|
|
return json({ data: [] })
|
|
}, 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: { enabled: true, scope: "global" },
|
|
keybinds: { "session.messages_last_user": "ctrl+shift+u", "session.message.next": "ctrl+shift+n" },
|
|
}),
|
|
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 waitForHighlightedFrame(setup, (frame) => frame.includes("Final marker"))
|
|
expect(setup.captureCharFrame()).not.toContain("Second thought body")
|
|
setup.mockInput.pressKey("u", { ctrl: true, shift: true })
|
|
await waitForHighlightedFrame(setup, (frame) => frame.includes("Thought:"))
|
|
await setup.waitForVisualIdle({ quietFrames: 3 })
|
|
const find = (node: Renderable): ScrollBoxRenderable | undefined =>
|
|
node instanceof ScrollBoxRenderable && node.getRenderable("msg_b")
|
|
? node
|
|
: node.getChildren().map(find).find(Boolean)
|
|
const initial = find(setup.renderer.root)
|
|
if (!initial) throw new Error("Missing transcript scrollbox")
|
|
const summaryLine = setup
|
|
.captureCharFrame()
|
|
.split("\n")
|
|
.findIndex((line) => line.includes("Thought:"))
|
|
initial.scrollTo(initial.scrollTop + summaryLine - initial.viewport.y)
|
|
await setup.waitForVisualIdle({ quietFrames: 3 })
|
|
const summaryOffset =
|
|
setup
|
|
.captureCharFrame()
|
|
.split("\n")
|
|
.findIndex((line) => line.includes("Thought:")) - initial.viewport.y
|
|
if (lines > 0) expect(summaryOffset).toBe(0)
|
|
setup.mockInput.pressKey("2", { ctrl: true })
|
|
await waitForHighlightedFrame(setup, (frame) => frame.includes("Other session content"))
|
|
setup.mockInput.pressKey("1", { ctrl: true })
|
|
await waitForHighlightedFrame(setup, (frame) => {
|
|
const viewport = find(setup.renderer.root)
|
|
return (
|
|
!!viewport &&
|
|
frame.split("\n").findIndex((line) => line.includes("Thought:")) - viewport.viewport.y === summaryOffset
|
|
)
|
|
})
|
|
await setup.waitForVisualIdle({ quietFrames: 3 })
|
|
expect(setup.captureCharFrame()).not.toContain("Second thought body")
|
|
const summary = find(setup.renderer.root)
|
|
if (!summary) throw new Error("Missing restored summary viewport")
|
|
expect(
|
|
setup
|
|
.captureCharFrame()
|
|
.split("\n")
|
|
.findIndex((line) => line.includes("Thought:")) - summary.viewport.y,
|
|
).toBe(summaryOffset)
|
|
setup.mockInput.pressKey("u", { ctrl: true, shift: true })
|
|
await setup.waitForVisualIdle({ quietFrames: 3 })
|
|
setup.mockInput.pressKey("n", { ctrl: true, shift: true })
|
|
await setup.waitForVisualIdle({ quietFrames: 3 })
|
|
setup.mockInput.pressKey("n", { ctrl: true, shift: true })
|
|
await waitForHighlightedFrame(setup, (frame) => frame.includes("Second response"))
|
|
await setup.waitForVisualIdle({ quietFrames: 3 })
|
|
expect(setup.captureCharFrame()).not.toContain("Second thought body")
|
|
setup.mockInput.pressKey("u", { ctrl: true, shift: true })
|
|
await waitForHighlightedFrame(setup, (frame) => frame.includes("Thought:"))
|
|
await setup.waitForVisualIdle({ quietFrames: 3 })
|
|
const header = setup
|
|
.captureCharFrame()
|
|
.split("\n")
|
|
.findIndex((line) => line.includes("Thought:"))
|
|
await setup.mockMouse.click(8, header)
|
|
await waitForHighlightedFrame(setup, (frame) => frame.includes("First thought body"))
|
|
setup.mockInput.pressKey("u", { ctrl: true, shift: true })
|
|
await setup.waitForVisualIdle({ quietFrames: 3 })
|
|
setup.mockInput.pressKey("n", { ctrl: true, shift: true })
|
|
await setup.waitForVisualIdle({ quietFrames: 3 })
|
|
setup.mockInput.pressKey("n", { ctrl: true, shift: true })
|
|
await waitForHighlightedFrame(setup, (frame) => frame.includes("Second thought body"))
|
|
await setup.waitForVisualIdle({ quietFrames: 3 })
|
|
const scroll = find(setup.renderer.root)
|
|
if (!scroll) throw new Error("Missing transcript scrollbox")
|
|
const titleLine = setup
|
|
.captureCharFrame()
|
|
.split("\n")
|
|
.findIndex((line) => line.includes("Second title"))
|
|
expect(titleLine - scroll.viewport.y).toBe(1)
|
|
expect(scroll.scrollTop).toBeGreaterThan(0)
|
|
setup.mockInput.pressKey("2", { ctrl: true })
|
|
await waitForHighlightedFrame(setup, (frame) => frame.includes("Other session content"))
|
|
setup.mockInput.pressKey("1", { ctrl: true })
|
|
await waitForHighlightedFrame(setup, (frame) => {
|
|
const viewport = find(setup.renderer.root)
|
|
return (
|
|
!!viewport && frame.split("\n").findIndex((line) => line.includes("Second title")) - viewport.viewport.y === 1
|
|
)
|
|
})
|
|
await setup.waitForVisualIdle({ quietFrames: 3 })
|
|
const restored = find(setup.renderer.root)
|
|
if (!restored) throw new Error("Missing restored transcript")
|
|
const restoredTitle = setup
|
|
.captureCharFrame()
|
|
.split("\n")
|
|
.findIndex((line) => line.includes("Second title"))
|
|
expect(restoredTitle - restored.viewport.y).toBe(1)
|
|
if (lines === 0) return
|
|
|
|
// Save inside A's second part, not relative to A's earlier text part.
|
|
setup.mockInput.pressKey("u", { ctrl: true, shift: true })
|
|
await waitForHighlightedFrame(setup, (frame) => frame.includes("First title"))
|
|
await setup.waitForVisualIdle({ quietFrames: 3 })
|
|
const reading = find(setup.renderer.root)
|
|
if (!reading) throw new Error("Missing reading viewport")
|
|
const firstTitle = setup
|
|
.captureCharFrame()
|
|
.split("\n")
|
|
.findIndex((line) => line.includes("First title"))
|
|
reading.scrollTo(reading.scrollTop + firstTitle - reading.viewport.y + 2)
|
|
await setup.waitForVisualIdle({ quietFrames: 3 })
|
|
const bodyOffset =
|
|
setup
|
|
.captureCharFrame()
|
|
.split("\n")
|
|
.findIndex((line) => line.includes("First thought body")) - reading.viewport.y
|
|
expect(bodyOffset).toBe(0)
|
|
setup.mockInput.pressKey("2", { ctrl: true })
|
|
await waitForHighlightedFrame(setup, (frame) => frame.includes("Other session content"))
|
|
setup.mockInput.pressKey("1", { ctrl: true })
|
|
await waitForHighlightedFrame(setup, (frame) => {
|
|
const viewport = find(setup.renderer.root)
|
|
return (
|
|
!!viewport &&
|
|
frame.split("\n").findIndex((line) => line.includes("First thought body")) - viewport.viewport.y ===
|
|
bodyOffset
|
|
)
|
|
})
|
|
await setup.waitForVisualIdle({ quietFrames: 3 })
|
|
const final = find(setup.renderer.root)
|
|
if (!final) throw new Error("Missing final viewport")
|
|
expect(
|
|
setup
|
|
.captureCharFrame()
|
|
.split("\n")
|
|
.findIndex((line) => line.includes("First thought body")) - final.viewport.y,
|
|
).toBe(bodyOffset)
|
|
} finally {
|
|
setup.renderer.destroy()
|
|
await task
|
|
await server.stop()
|
|
}
|
|
},
|
|
15000,
|
|
)
|