opencode/packages/tui/test/mini/scrollback.image.test.ts

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([])
})