// Tests for terminal-to-image rendering. // Each test generates an actual image and saves it to testdata/images/ for visual inspection. import { describe, it, expect, beforeAll } from "bun:test" import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs" import { join } from "path" import { ptyToJson, PersistentTerminal, type TerminalData } from "./ffi.js" import { renderOpenTuiToImage, renderOpenTuiToSvg, renderTerminalToImage, renderTerminalToPaginatedImages, renderTerminalToSvg, type OpenTuiCapturedFrame, type OpenTuiCapturedRgba, } from "./image.js" const TESTDATA_DIR = join(import.meta.dirname, "..", "testdata") const IMAGES_DIR = join(TESTDATA_DIR, "images") // PNG magic bytes const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) const OpenTuiTextAttributes = { BOLD: 1 << 0, DIM: 1 << 1, ITALIC: 1 << 2, UNDERLINE: 1 << 3, INVERSE: 1 << 5, STRIKETHROUGH: 1 << 7, } as const function isPng(buffer: Buffer): boolean { return buffer.subarray(0, 8).equals(PNG_HEADER) } function readTestData(filename: string): string { return readFileSync(join(TESTDATA_DIR, filename), "utf-8") } function saveImage(name: string, buffer: Buffer, ext: string = "png"): string { const path = join(IMAGES_DIR, `${name}.${ext}`) writeFileSync(path, buffer) return path } function rgba(hex: string): OpenTuiCapturedRgba { const value = hex.replace("#", "") return { r: Number.parseInt(value.slice(0, 2), 16) / 255, g: Number.parseInt(value.slice(2, 4), 16) / 255, b: Number.parseInt(value.slice(4, 6), 16) / 255, a: 1, } } function transparent(): OpenTuiCapturedRgba { return { r: 0, g: 0, b: 0, a: 0 } } function isCommandAvailable(command: string): boolean { return Bun.which(command) !== null } beforeAll(() => { if (!existsSync(IMAGES_DIR)) { mkdirSync(IMAGES_DIR, { recursive: true }) } }) // ───────────────────────────────────────────────────────────── // Testdata file renders // ───────────────────────────────────────────────────────────── describe("testdata renders", () => { it("git-status.log — colored git output", async () => { const ansi = readTestData("git-status.log") const data = ptyToJson(ansi, { cols: 80, rows: 24 }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) expect(image.length).toBeGreaterThan(1000) saveImage("git-status", image) }) it("diff.log — red/green diff lines", async () => { const ansi = readTestData("diff.log") const data = ptyToJson(ansi, { cols: 100, rows: 30 }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("diff", image) }) it("colors.log — basic ANSI colors", async () => { const ansi = readTestData("colors.log") const data = ptyToJson(ansi, { cols: 80, rows: 10 }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("colors", image) }) it("256colors.log — 256-color palette", async () => { const ansi = readTestData("256colors.log") const data = ptyToJson(ansi, { cols: 80, rows: 24 }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("256colors", image) }) it("truecolor.log — 24-bit RGB colors", async () => { const ansi = readTestData("truecolor.log") const data = ptyToJson(ansi, { cols: 80, rows: 10 }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("truecolor", image) }) it("styles.log — bold, italic, underline, faint", async () => { const ansi = readTestData("styles.log") const data = ptyToJson(ansi, { cols: 80, rows: 10 }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("styles", image) }) it("table.log — box-drawing table", async () => { const ansi = readTestData("table.log") const data = ptyToJson(ansi, { cols: 60, rows: 20 }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("table", image) }) it("tree.log — file tree with box chars", async () => { const ansi = readTestData("tree.log") const data = ptyToJson(ansi, { cols: 60, rows: 20 }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("tree", image) }) it("vitest.log — test runner output", async () => { const ansi = readTestData("vitest.log") const data = ptyToJson(ansi, { cols: 80, rows: 30 }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("vitest", image) }) it("logs.log — log output", async () => { const ansi = readTestData("logs.log") const data = ptyToJson(ansi, { cols: 100, rows: 30 }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("logs", image) }) it("session.log — shell session", async () => { const ansi = readTestData("session.log") const data = ptyToJson(ansi, { cols: 120, rows: 40 }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("session", image) }) it("backgrounds.log — background colors", async () => { const ansi = readTestData("backgrounds.log") const data = ptyToJson(ansi, { cols: 80, rows: 10 }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("backgrounds", image) }) }) // ───────────────────────────────────────────────────────────── // Options and edge cases // ───────────────────────────────────────────────────────────── describe("rendering options", () => { it("custom theme — light background", async () => { const ansi = readTestData("diff.log") const data = ptyToJson(ansi, { cols: 100, rows: 30 }) const image = await renderTerminalToImage(data, { theme: { background: "#ffffff", text: "#24292e" }, }) expect(isPng(image)).toBe(true) saveImage("diff-light-theme", image) }) it("larger font size — 20px", async () => { const ansi = readTestData("git-status.log") const data = ptyToJson(ansi, { cols: 80, rows: 24 }) const image = await renderTerminalToImage(data, { fontSize: 20, lineHeight: 1.6, }) expect(isPng(image)).toBe(true) saveImage("git-status-large", image) }) it("fixed width — 1200px", async () => { const ansi = readTestData("table.log") const data = ptyToJson(ansi, { cols: 60, rows: 20 }) const image = await renderTerminalToImage(data, { width: 1200, }) expect(isPng(image)).toBe(true) saveImage("table-1200w", image) }) it("fixed height — clips content", async () => { const ansi = readTestData("vitest.log") const data = ptyToJson(ansi, { cols: 80, rows: 30 }) const image = await renderTerminalToImage(data, { height: 200, }) expect(isPng(image)).toBe(true) saveImage("vitest-clipped", image) }) it("svg output — deterministic vector terminal frame", () => { const data = ptyToJson("\x1b[32mOK\x1b[0m \x1b[7mINV\x1b[0m", { cols: 10, rows: 1 }) const svg = renderTerminalToSvg(data, { width: 100, fontSize: 10, lineHeight: 1.4, theme: { background: "#000000", text: "#ffffff" }, }) expect(svg.replaceAll("><", ">\n<")).toMatchInlineSnapshot(` " OK INV " `) }) it("opentui frame to svg — maps captured colors and attributes", () => { const frame: OpenTuiCapturedFrame = { cols: 8, rows: 1, cursor: [0, 0], lines: [{ spans: [ { text: "OK", fg: rgba("#00ff00"), bg: transparent(), attributes: OpenTuiTextAttributes.BOLD | OpenTuiTextAttributes.UNDERLINE, width: 2, }, { text: "INV", fg: rgba("#ff0000"), bg: rgba("#0000ff"), attributes: OpenTuiTextAttributes.INVERSE | OpenTuiTextAttributes.DIM, width: 3, }, ], }], } const svg = renderOpenTuiToSvg(frame, { fontSize: 10, lineHeight: 1, theme: { background: "#000000", text: "#ffffff" }, }) expect(svg.replaceAll("><", ">\n<")).toMatchInlineSnapshot(` " OK INV " `) }) it("opentui frame to svg — reuses terminal glyph geometry", () => { const frame: OpenTuiCapturedFrame = { cols: 4, rows: 2, cursor: [0, 0], lines: [ { spans: [{ text: "┌─┐", fg: rgba("#ffffff"), bg: transparent(), attributes: 0, width: 3 }] }, { spans: [{ text: "█⠿", fg: rgba("#ffcc00"), bg: transparent(), attributes: OpenTuiTextAttributes.STRIKETHROUGH, width: 3 }] }, ], } const svg = renderOpenTuiToSvg(frame, { fontSize: 10, lineHeight: 1, theme: { background: "#000000", text: "#ffffff" }, }) expect(svg.replaceAll("><", ">\n<")).toMatchInlineSnapshot(` " " `) }) it("opentui frame to png", async () => { const frame: OpenTuiCapturedFrame = { cols: 4, rows: 1, cursor: [0, 0], lines: [{ spans: [{ text: "PNG", fg: rgba("#ffffff"), bg: transparent(), attributes: 0, width: 3 }] }], } const image = await renderOpenTuiToImage(frame, { fontSize: 10, lineHeight: 1, theme: { background: "#000000", text: "#ffffff" }, }) expect(isPng(image)).toBe(true) expect(image.length).toBeGreaterThan(100) }) it("svg output — draws terminal glyphs as geometry", () => { const data = ptyToJson("┌─┐\n│█│\n└⠿", { cols: 4, rows: 3 }) const svg = renderTerminalToSvg(data, { fontSize: 10, lineHeight: 1, theme: { background: "#000000", text: "#ffffff" }, }) expect(svg.replaceAll("><", ">\n<")).toMatchInlineSnapshot(` " " `) }) it("svg output — keeps style flags on geometry glyphs", () => { const data = ptyToJson("\x1b[2;4m█\x1b[0m", { cols: 1, rows: 1 }) const svg = renderTerminalToSvg(data, { fontSize: 10, lineHeight: 1, theme: { background: "#000000", text: "#ffffff" }, }) expect(svg.replaceAll("><", ">\n<")).toMatchInlineSnapshot(` " " `) }) it("svg output — uses terminal cell widths for glyph positioning", () => { const data = ptyToJson("🬀█\n󰄛█\n❤️█\n╹█", { cols: 4, rows: 4 }) const svg = renderTerminalToSvg(data, { fontSize: 10, lineHeight: 1, theme: { background: "#000000", text: "#ffffff" }, }) expect(svg.replaceAll("><", ">\n<")).toMatchInlineSnapshot(` " 🬀 󰄛 ❤️ " `) }) it("unicode fallback fonts — CJK and symbols", async () => { const data = ptyToJson("Latin Ελληνικά Кириллица\nCJK 你好 日本語 한국어\nSymbols ∑ ∫ ⌘ ⚙ ☂", { cols: 60, rows: 3 }) const image = await renderTerminalToImage(data, { fontSize: 18, lineHeight: 1.3, paddingX: 12, paddingY: 10, }) expect(isPng(image)).toBe(true) expect(image.length).toBeGreaterThan(1000) saveImage("unicode-fallback", image) }) }) describe("paginated rendering", () => { it("splits long content into multiple images", async () => { // Create long content by repeating session.log data const ansi = readTestData("session.log") const data = ptyToJson(ansi, { cols: 120, rows: 200 }) const result = await renderTerminalToPaginatedImages(data, { maxLinesPerImage: 10, }) expect(result.imageCount).toBeGreaterThanOrEqual(1) expect(result.images.length).toBe(result.imageCount) expect(result.paths.length).toBe(result.imageCount) // Save first and last page if (result.images[0]) { expect(isPng(result.images[0])).toBe(true) saveImage("paginated-page-1", result.images[0]) } if (result.imageCount > 1 && result.images[result.imageCount - 1]) { saveImage("paginated-page-last", result.images[result.imageCount - 1]!) } }) }) // ───────────────────────────────────────────────────────────── // Real command spawns — capture actual terminal output as images // ───────────────────────────────────────────────────────────── /** Spawn a command in a PTY via Bun.spawn terminal API, feed into PersistentTerminal, return TerminalData */ async function spawnAndCapture( command: string, args: string[], options: { cols?: number; rows?: number; waitMs?: number; idleMs?: number; env?: Record } = {}, ): Promise { const cols = options.cols ?? 120 const rows = options.rows ?? 40 const waitMs = options.waitMs ?? 5000 const idleMs = options.idleMs ?? 500 const term = new PersistentTerminal({ cols, rows }) const env: Record = {} for (const [k, v] of Object.entries(process.env)) { if (v !== undefined) env[k] = v } Object.assign(env, options.env, { TERM: "xterm-truecolor", COLORTERM: "truecolor", }) let done = false let idleTimer: ReturnType | undefined let idleResolve: (() => void) | undefined const idlePromise = new Promise((resolve) => { idleResolve = resolve }) const proc = Bun.spawn([command, ...args], { env, terminal: { cols, rows, data(_terminal: any, data: any) { if (done) return term.feed(data) clearTimeout(idleTimer) idleTimer = setTimeout(() => idleResolve?.(), idleMs) }, }, }) // Wait for content to stabilize or timeout await Promise.race([ idlePromise, new Promise((resolve) => setTimeout(resolve, waitMs)), ]) done = true const terminalData = term.getJson() proc.kill() term.destroy() return terminalData } describe("real command spawns", () => { const runExternalCliTests = process.env["GHOSTTY_OPENTUI_TEST_EXTERNAL_COMMANDS"] === "1" const itIfOpencodeAvailable = runExternalCliTests && isCommandAvailable("opencode") ? it : it.skip const itIfClaudeAvailable = runExternalCliTests && isCommandAvailable("claude") ? it : it.skip itIfOpencodeAvailable("opencode — interactive TUI (snapshot after launch)", async () => { // opencode sends initial escape sequences at ~500ms, then renders UI at ~1500ms. // Need idleMs > 1000ms gap between first data and actual UI render. const data = await spawnAndCapture("opencode", [], { cols: 120, rows: 40, waitMs: 6000, idleMs: 2000, }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) expect(image.length).toBeGreaterThan(1000) saveImage("opencode", image) }, 15000) itIfOpencodeAvailable("opencode --help", async () => { const data = await spawnAndCapture("opencode", ["--help"], { cols: 100, rows: 40, waitMs: 5000, }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) expect(image.length).toBeGreaterThan(1000) saveImage("opencode-help", image) }, 15000) itIfClaudeAvailable("claude --help", async () => { const data = await spawnAndCapture("claude", ["--help"], { cols: 100, rows: 50, waitMs: 5000, }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) expect(image.length).toBeGreaterThan(1000) saveImage("claude-help", image) }, 15000) it("ls with colors", async () => { const data = await spawnAndCapture("ls", ["-la", "--color=always"], { cols: 100, rows: 40, waitMs: 3000, }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("ls-color", image) }, 10000) it("git log with colors", async () => { const data = await spawnAndCapture( "git", ["log", "--oneline", "--graph", "--color=always", "-20"], { cols: 120, rows: 30, waitMs: 3000 }, ) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("git-log", image) }, 10000) it("git diff with colors", async () => { const data = await spawnAndCapture( "git", ["diff", "--color=always", "HEAD~1"], { cols: 120, rows: 50, waitMs: 3000 }, ) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("git-diff-real", image) }, 10000) }) describe("edge cases", () => { it("empty terminal — throws", async () => { const data = ptyToJson("", { cols: 80, rows: 24 }) await expect(renderTerminalToImage(data)).rejects.toThrow("No content to render") }) it("single character", async () => { const data = ptyToJson("X", { cols: 80, rows: 1 }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("single-char", image) }) it("inverse video", async () => { const ansi = "\x1b[7mINVERSE\x1b[0m normal \x1b[1;7mBOLD INVERSE\x1b[0m" const data = ptyToJson(ansi, { cols: 60, rows: 3 }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("inverse-video", image) }) it("combined styles — bold + italic + color", async () => { const ansi = readTestData("combined.log") const data = ptyToJson(ansi, { cols: 80, rows: 10 }) const image = await renderTerminalToImage(data) expect(isPng(image)).toBe(true) saveImage("combined-styles", image) }) it("empty lines with background — should not be trimmed", async () => { const ansi = "Line 1\nLine 2\n\x1b[44m \x1b[0m" const data = ptyToJson(ansi, { cols: 20, rows: 3 }) const image = await renderTerminalToImage(data) // We expect 3 lines visible expect(data.lines.length).toBe(3) // Image should be generated successfully expect(isPng(image)).toBe(true) saveImage("bg-lines-preserved", image) }) })