mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-28 23:13:54 +00:00
213 lines
9 KiB
TypeScript
213 lines
9 KiB
TypeScript
import { afterEach, expect, test } from "bun:test"
|
|
import {
|
|
CliRenderEvents,
|
|
ImageRenderable,
|
|
TextRenderable,
|
|
type NativeImage,
|
|
type ScrollbackSurface,
|
|
} from "@opentui/core"
|
|
import { createTestRenderer } from "@opentui/core/testing"
|
|
import { RunScrollbackStream } from "../../src/mini/scrollback.surface"
|
|
import { RUN_THEME_FALLBACK, RUN_THEME_MONO } from "../../src/mini/theme"
|
|
import type { StreamCommit } from "../../src/mini/types"
|
|
import { diffImageFixture } from "../fixture/diff-image"
|
|
|
|
const wide = `data:image/png;base64,${Buffer.from(diffImageFixture).toString("base64")}`
|
|
// A 48 x 192 PNG with four colored quadrants.
|
|
const tall =
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAADACAIAAADN8nfJAAAA+klEQVR4nO3bUQ0AIAxDwUmZEETMf+YFTDR8XYIAQmBs7WvdM5HVoVU25ITcIa9MHVIYfR0+V+2HBk3HqKc2dZjLDIpGaWIDOYZgRUGjMVJh6dSEc9YC84U9xcDjKPJcudJ8eyAB1AKMAtcBNCGsMGgoPRwjsBJ6Cs6FLwO8EeeYfKkFuQ5BE1EcYaU/ca6dyOrQsiEn5A55ZeqQwujr8LlqPzRoOkY9tanDXGZQNEoTG8gxBCsKGo2RCkunJpyzFpgv7CkGHkeR58qV5tsDCaAWYBS4DqAJYYVBQ+nhGIGV0FNwLnwZ4I04x+RLLch1CJqI4ggr7Y841wMtbiD+34CxQgAAAABJRU5ErkJggg=="
|
|
|
|
type Preview = {
|
|
surface: ScrollbackSurface
|
|
image: ImageRenderable
|
|
native: NativeImage | null
|
|
size: { width: number; height: number }
|
|
x: number
|
|
y: number
|
|
captionHeight: number
|
|
}
|
|
|
|
const cleanups: Array<() => void> = []
|
|
|
|
afterEach(() => {
|
|
cleanups.splice(0).forEach((cleanup) => cleanup())
|
|
})
|
|
|
|
async function setup(
|
|
options: { width?: number; height?: number; footerHeight?: number; imagePreview?: boolean; mono?: boolean } = {},
|
|
) {
|
|
const renderer = await createTestRenderer({
|
|
width: options.width ?? 80,
|
|
height: options.height ?? 24,
|
|
footerHeight: options.footerHeight ?? 6,
|
|
screenMode: "split-footer",
|
|
externalOutputMode: "capture-stdout",
|
|
})
|
|
const out = {
|
|
...renderer,
|
|
scrollback: new RunScrollbackStream(renderer.renderer, options.mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK, options),
|
|
previews: [] as Preview[],
|
|
}
|
|
cleanups.push(() => {
|
|
out.scrollback.destroy()
|
|
out.renderer.destroy()
|
|
})
|
|
out.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, () => {
|
|
// oxlint-disable-next-line no-restricted-globals -- RunScrollbackStream does not expose its image surface publicly.
|
|
const surface = Reflect.get(out.scrollback, "imageSurface") as ScrollbackSurface | undefined
|
|
if (!surface || out.previews.some((preview) => preview.surface === surface)) return
|
|
const image = surface.root.getChildren().find((child) => child instanceof ImageRenderable)
|
|
const caption = surface.root.getChildren().find((child) => child instanceof TextRenderable)
|
|
if (!image || !caption) throw new Error("Missing image or caption on image surface")
|
|
out.previews.push({
|
|
surface,
|
|
image,
|
|
native: image.image,
|
|
size: { width: image.width, height: image.height },
|
|
x: image.x,
|
|
y: image.y,
|
|
captionHeight: caption.height,
|
|
})
|
|
})
|
|
return out
|
|
}
|
|
|
|
function image(source = wide, text = "landscape.png", partID = "image-1"): StreamCommit {
|
|
return { kind: "user", source: "system", phase: "final", messageID: "message-1", partID, text, image: source }
|
|
}
|
|
|
|
test.each([
|
|
{
|
|
name: "wide",
|
|
image: wide,
|
|
width: 40,
|
|
height: 30,
|
|
size: { width: 40, height: 10 },
|
|
},
|
|
{
|
|
name: "tall",
|
|
image: tall,
|
|
width: 80,
|
|
height: 24,
|
|
size: { width: 9, height: 17 },
|
|
},
|
|
])("fits a $name image at the left edge and commits only its fitted rows", async (options) => {
|
|
const out = await setup({ ...options, imagePreview: true })
|
|
const listeners = out.renderer.listenerCount(CliRenderEvents.DESTROY)
|
|
await out.scrollback.append(image(options.image))
|
|
|
|
expect(out.previews).toHaveLength(1)
|
|
const preview = out.previews[0]!
|
|
expect(preview.size).toEqual(options.size)
|
|
expect(preview.x).toBe(0)
|
|
expect(preview.y).toBe(preview.captionHeight)
|
|
const commits = out.externalOutput.take()
|
|
expect(commits).toHaveLength(1)
|
|
expect(commits[0]!.rows[0]).toBe("\u203a landscape.png")
|
|
expect(commits[0]!.height).toBe(options.size.height + preview.captionHeight)
|
|
expect(preview.surface.isDestroyed).toBe(true)
|
|
expect(preview.image.isDestroyed).toBe(true)
|
|
expect(preview.image.image).toBeNull()
|
|
expect(() => preview.native!.info()).toThrow()
|
|
expect(out.renderer.listenerCount(CliRenderEvents.DESTROY)).toBe(listeners)
|
|
})
|
|
|
|
test("reserves wrapped caption rows and allows caption-only output in a short terminal", async () => {
|
|
const out = await setup({ width: 20, height: 10, footerHeight: 6, imagePreview: true })
|
|
await out.scrollback.append(image(tall, "first caption wraps here\nsecond caption line"))
|
|
expect(out.previews[0]!.captionHeight).toBe(3)
|
|
expect(out.previews[0]!.size.height).toBe(1)
|
|
expect(out.externalOutput.take()[0]!.height).toBe(4)
|
|
|
|
out.resize(20, 8)
|
|
await out.scrollback.append(image(wide, "first caption wraps here\nsecond caption line", "image-2"))
|
|
expect(out.previews[1]!.image.visible).toBe(false)
|
|
expect(out.externalOutput.take().at(-1)!.height).toBe(3)
|
|
})
|
|
|
|
test("uses physical cell geometry without enlarging a small image", async () => {
|
|
const out = await setup({ imagePreview: true })
|
|
// oxlint-disable-next-line no-restricted-globals -- OpenTUI does not expose test resolution mutation publicly.
|
|
Reflect.set(out.renderer, "_resolution", { width: 800, height: 720 })
|
|
await out.scrollback.append(image(wide))
|
|
expect(out.previews[0]!.size).toEqual({ width: 6, height: 1 })
|
|
expect(out.previews[0]!.x).toBe(0)
|
|
})
|
|
|
|
test("finishes active text before every image and renders more than three images in order", async () => {
|
|
const out = await setup({ imagePreview: true })
|
|
const first = { ...image(wide, "image 1"), kind: "tool", source: "tool", tool: "read" } as const
|
|
await out.scrollback.append({ ...first, image: undefined, tool: "shell", phase: "progress", text: "before images" })
|
|
expect(out.externalOutput.take()).toEqual([])
|
|
await out.scrollback.append(first)
|
|
for (const index of [2, 3, 4, 5]) {
|
|
await out.scrollback.append({ ...first, partID: `image-${index}`, text: `image ${index}`, image: tall })
|
|
}
|
|
await out.scrollback.append({ kind: "system", source: "system", phase: "final", text: "after images" })
|
|
await out.scrollback.complete()
|
|
|
|
expect(out.previews).toHaveLength(5)
|
|
expect(
|
|
out.externalOutput
|
|
.take()
|
|
.filter((commit) => commit.text.trim())
|
|
.map((commit) => commit.rows[0]),
|
|
).toEqual(["before images", "image 1", "image 2", "image 3", "image 4", "image 5", "after images"])
|
|
expect(out.previews.every((preview) => preview.image.isDestroyed && preview.surface.isDestroyed)).toBe(true)
|
|
})
|
|
|
|
test.each([{}, { imagePreview: false }, { imagePreview: true, mono: true }])(
|
|
"keeps user and tool captions without loading images when previews are disabled or monochrome (%j)",
|
|
async (options) => {
|
|
const out = await setup(options)
|
|
const commit = image("data:image/png;base64,AQID", "attachment.png")
|
|
await out.scrollback.append(commit)
|
|
await out.scrollback.append({ ...commit, kind: "tool", source: "tool", tool: "read", partID: "image-2" })
|
|
expect(out.previews).toEqual([])
|
|
expect(
|
|
out.externalOutput
|
|
.take()
|
|
.filter((commit) => commit.text.trim())
|
|
.map((commit) => commit.rows[0]),
|
|
).toEqual([`${options.mono ? ">" : "\u203a"} attachment.png`, "attachment.png"])
|
|
},
|
|
)
|
|
|
|
test("prints a failed decode caption and continues with text", async () => {
|
|
const out = await setup({ imagePreview: true })
|
|
await expect(out.scrollback.append(image("data:image/png;base64,AQID", "broken.png"))).resolves.toBeUndefined()
|
|
await out.scrollback.append({ kind: "system", source: "system", phase: "final", text: "still running" })
|
|
const output = out.externalOutput.takeText()
|
|
expect(output).toContain("\u203a broken.png\nNo preview")
|
|
expect(output).toContain("still running")
|
|
expect(out.previews[0]!.image.loadError).not.toBeNull()
|
|
expect(out.previews[0]!.image.isDestroyed).toBe(true)
|
|
expect(out.previews[0]!.surface.isDestroyed).toBe(true)
|
|
})
|
|
|
|
test("remeasures at load completion", async () => {
|
|
const out = await setup({ imagePreview: true })
|
|
const pending = out.scrollback.append(image(tall))
|
|
await Promise.resolve()
|
|
out.resize(40, 18)
|
|
await pending
|
|
expect(out.previews[0]!.size).toEqual({ width: 6, height: 11 })
|
|
})
|
|
|
|
test.each(["stream", "renderer"])("disposes an image load when the %s is destroyed", async (owner) => {
|
|
const out = await setup({ imagePreview: true })
|
|
const pending = out.scrollback.append(image())
|
|
await Promise.resolve()
|
|
// oxlint-disable-next-line no-restricted-globals -- RunScrollbackStream does not expose its image surface publicly.
|
|
const surface = Reflect.get(out.scrollback, "imageSurface") as ScrollbackSurface
|
|
const preview = surface.root.getChildren().find((child) => child instanceof ImageRenderable)!
|
|
expect(preview.loading).toBe(true)
|
|
|
|
if (owner === "stream") out.scrollback.destroy()
|
|
if (owner === "renderer") out.renderer.destroy()
|
|
await expect(pending).resolves.toBeUndefined()
|
|
expect(surface.isDestroyed).toBe(true)
|
|
expect(preview.isDestroyed).toBe(true)
|
|
expect(preview.image).toBeNull()
|
|
expect(out.externalOutput.take()).toEqual([])
|
|
})
|