import { describe, expect, it, afterEach } from "bun:test" import { createRoot, extend } from "@opentui/react" import { createTestRenderer, type TestRendererOptions } from "@opentui/core/testing" import { GhosttyTerminalRenderable } from "./terminal-buffer.js" import { act } from "react" import type { ReactNode } from "react" // Register the component extend({ "ghostty-terminal": GhosttyTerminalRenderable }) // Track current test setup for cleanup let currentTestSetup: Awaited> | null = null // Cleanup after each test to prevent event listener accumulation afterEach(() => { if (currentTestSetup) { currentTestSetup.renderer.destroy() currentTestSetup = null } }) // Custom testRender that uses the main entry point's createRoot (and thus shared component catalogue) async function testRender(node: ReactNode, options: TestRendererOptions = {}) { // @ts-ignore globalThis.IS_REACT_ACT_ENVIRONMENT = true const testSetup = await createTestRenderer({ ...options, }) currentTestSetup = testSetup const root = createRoot(testSetup.renderer) await act(async () => { root.render(node) }) return testSetup } describe("GhosttyTerminalRenderable", () => { it("should render basic text component", async () => { const { renderOnce, captureCharFrame } = await testRender( Test Basic, { width: 40, height: 10 } ) await renderOnce() const output = captureCharFrame() expect(output).toContain("Test Basic") expect(output).toBe(`Test Basic `) }) it("should render simple ANSI text", async () => { const ansi = "\x1b[32mHello\x1b[0m World" const { renderOnce, captureCharFrame } = await testRender( , { width: 40, height: 10 } ) await renderOnce() const output = captureCharFrame() expect(output).toContain("Hello") expect(output).toContain("World") expect(output).toBe(`Hello World `) }) it("should render colored text", async () => { const ansi = "\x1b[31mRed\x1b[0m \x1b[32mGreen\x1b[0m \x1b[34mBlue\x1b[0m" const { renderOnce, captureCharFrame } = await testRender( , { width: 40, height: 10 } ) await renderOnce() const output = captureCharFrame() expect(output).toContain("Red") expect(output).toContain("Green") expect(output).toContain("Blue") expect(output).toBe(`Red Green Blue `) }) it("should render multi-line ANSI", async () => { const ansi = "Line 1\nLine 2\nLine 3" const { renderOnce, captureCharFrame } = await testRender( , { width: 40, height: 10 } ) await renderOnce() const output = captureCharFrame() expect(output).toContain("Line 1") expect(output).toContain("Line 2") expect(output).toContain("Line 3") expect(output).toBe(`Line 1 Line 2 Line 3 `) }) it("should handle prefix being added", async () => { const original = "Original Text" // Add prefix const prefix = "\x1b[1;35m[PREFIX]\x1b[0m\n" const updated = prefix + original const { renderOnce, captureCharFrame } = await testRender( , { width: 40, height: 10 } ) await renderOnce() const output = captureCharFrame() expect(output).toContain("PREFIX") expect(output).toContain("Original Text") expect(output).toBe(`[PREFIX] Original Text `) }) it("should handle multiple prefix additions", async () => { let ansi = "Base Text" // Add first prefix ansi = "\x1b[1;35m[PREFIX 1]\x1b[0m\n" + ansi // Add second prefix ansi = "\x1b[1;35m[PREFIX 2]\x1b[0m\n" + ansi const { renderOnce, captureCharFrame } = await testRender( , { width: 40, height: 10 } ) await renderOnce() const output = captureCharFrame() expect(output).toContain("PREFIX 2") expect(output).toContain("PREFIX 1") expect(output).toContain("Base Text") expect(output).toBe(`[PREFIX 2] [PREFIX 1] Base Text `) }) it("should respect cols and rows options", async () => { const ansi = "Test" const { renderOnce, captureCharFrame } = await testRender( , { width: 20, height: 5 } ) await renderOnce() const output = captureCharFrame() expect(output).toContain("Test") expect(output).toBe(`Test `) }) it("should handle bold and italic text", async () => { const ansi = "\x1b[1mBold\x1b[0m \x1b[3mItalic\x1b[0m \x1b[1;3mBoth\x1b[0m" const { renderOnce, captureCharFrame } = await testRender( , { width: 40, height: 10 } ) await renderOnce() const output = captureCharFrame() expect(output).toContain("Bold") expect(output).toContain("Italic") expect(output).toContain("Both") expect(output).toBe(`Bold Italic Both `) }) it("should handle RGB colors", async () => { const ansi = "\x1b[38;2;255;105;180mHot Pink\x1b[0m \x1b[38;2;0;255;127mSpring Green\x1b[0m" const { renderOnce, captureCharFrame } = await testRender( , { width: 40, height: 10 } ) await renderOnce() const output = captureCharFrame() expect(output).toContain("Hot Pink") expect(output).toContain("Spring Green") expect(output).toBe(`Hot Pink Spring Green `) }) it("should handle empty ANSI", async () => { const { renderOnce, captureCharFrame } = await testRender( , { width: 40, height: 10 } ) await renderOnce() const output = captureCharFrame() expect(output).toBeDefined() expect(output).toBe(` `) }) it("should preserve newlines correctly", async () => { const ansi = "Line1\n\nLine3" const { renderOnce, captureCharFrame } = await testRender( , { width: 40, height: 10 } ) await renderOnce() const output = captureCharFrame() expect(output).toContain("Line1") expect(output).toContain("Line3") expect(output).toBe(`Line1 Line3 `) }) it("should handle background colors", async () => { const ansi = "\x1b[41m Red BG \x1b[0m \x1b[42m Green BG \x1b[0m" const { renderOnce, captureCharFrame } = await testRender( , { width: 40, height: 10 } ) await renderOnce() const output = captureCharFrame() expect(output).toContain("Red BG") expect(output).toContain("Green BG") expect(output).toBe(` Red BG Green BG `) }) it("should render the cursor via terminal cursor APIs instead of StyledText", async () => { const ref = { current: null as GhosttyTerminalRenderable | null } const { renderOnce, captureCharFrame } = await testRender( { ref.current = r }} ansi={"ABCDE\x1b[1G"} cols={5} rows={1} showCursor cursorStyle="block" style={{ width: 5, height: 1 }} />, { width: 5, height: 1 } ) const positionCalls: Array<[number, number, boolean | undefined]> = [] const styleCalls: Array<{ style: string; blinking: boolean }> = [] const ctx = ref.current!.ctx as { setCursorPosition: (x: number, y: number, visible?: boolean) => void setCursorStyle: (options: { style: string; blinking: boolean }) => void } const originalSetCursorPosition = ctx.setCursorPosition.bind(ctx) ctx.setCursorPosition = (x, y, visible) => { positionCalls.push([x, y, visible]) originalSetCursorPosition(x, y, visible) } const originalSetCursorStyle = ctx.setCursorStyle.bind(ctx) ctx.setCursorStyle = (options) => { styleCalls.push(options) originalSetCursorStyle(options) } await renderOnce() expect(captureCharFrame()).toBe("ABCDE\n") expect(styleCalls).toContainEqual({ style: "block", blinking: false }) expect(positionCalls).toContainEqual([1, 1, true]) }) it("should not render cursor when focusable but not focused", async () => { const ref = { current: null as GhosttyTerminalRenderable | null } const { renderOnce } = await testRender( { ref.current = r }} ansi={"ABCDE\x1b[1G"} cols={5} rows={1} focusable={true} showCursor cursorStyle="block" style={{ width: 5, height: 1 }} />, { width: 5, height: 1 } ) const positionCalls: Array<[number, number, boolean | undefined]> = [] const ctx = ref.current!.ctx as { setCursorPosition: (x: number, y: number, visible?: boolean) => void } const originalSetCursorPosition = ctx.setCursorPosition.bind(ctx) ctx.setCursorPosition = (x, y, visible) => { positionCalls.push([x, y, visible]) originalSetCursorPosition(x, y, visible) } await renderOnce() // Cursor should not be set when focusable but not focused const visibleCalls = positionCalls.filter(([, , v]) => v === true) expect(visibleCalls).toHaveLength(0) }) it("should render cursor when focusable and focused", async () => { const ref = { current: null as GhosttyTerminalRenderable | null } const { renderOnce } = await testRender( { ref.current = r }} ansi={"ABCDE\x1b[1G"} cols={5} rows={1} focusable={true} focused={true} showCursor cursorStyle="block" style={{ width: 5, height: 1 }} />, { width: 5, height: 1 } ) const positionCalls: Array<[number, number, boolean | undefined]> = [] const ctx = ref.current!.ctx as { setCursorPosition: (x: number, y: number, visible?: boolean) => void } const originalSetCursorPosition = ctx.setCursorPosition.bind(ctx) ctx.setCursorPosition = (x, y, visible) => { positionCalls.push([x, y, visible]) originalSetCursorPosition(x, y, visible) } await renderOnce() expect(positionCalls).toContainEqual([1, 1, true]) }) it("should hide cursor on blur", async () => { const ref = { current: null as GhosttyTerminalRenderable | null } const { renderOnce } = await testRender( { ref.current = r }} ansi={"ABCDE\x1b[1G"} cols={5} rows={1} focusable={true} focused={true} showCursor cursorStyle="block" style={{ width: 5, height: 1 }} />, { width: 5, height: 1 } ) await renderOnce() const positionCalls: Array<[number, number, boolean | undefined]> = [] const ctx = ref.current!.ctx as { setCursorPosition: (x: number, y: number, visible?: boolean) => void } const originalSetCursorPosition = ctx.setCursorPosition.bind(ctx) ctx.setCursorPosition = (x, y, visible) => { positionCalls.push([x, y, visible]) originalSetCursorPosition(x, y, visible) } ref.current!.blur() expect(positionCalls).toContainEqual([0, 0, false]) }) it("should use 'default' cursor style when no DECSCUSR received and cursorStyle unset", async () => { const ref = { current: null as GhosttyTerminalRenderable | null } // No DECSCUSR in the ANSI content const { renderOnce } = await testRender( { ref.current = r }} ansi={"hello"} cols={10} rows={1} showCursor style={{ width: 10, height: 1 }} />, { width: 10, height: 1 } ) const styleCalls: Array<{ style: string; blinking: boolean }> = [] const ctx = ref.current!.ctx as { setCursorStyle: (options: { style: string; blinking: boolean }) => void } const originalSetCursorStyle = ctx.setCursorStyle.bind(ctx) ctx.setCursorStyle = (options) => { styleCalls.push(options) originalSetCursorStyle(options) } await renderOnce() // No DECSCUSR → "default" style (preserves outer terminal's native cursor) expect(styleCalls).toContainEqual({ style: "default", blinking: false }) }) it("should pass through terminal bar cursor style as 'line' when cursorStyle is unset", async () => { const ref = { current: null as GhosttyTerminalRenderable | null } // CSI 6 SP q = DECSCUSR steady bar const { renderOnce } = await testRender( { ref.current = r }} ansi={"hello\x1b[6 q"} cols={10} rows={1} showCursor style={{ width: 10, height: 1 }} />, { width: 10, height: 1 } ) const styleCalls: Array<{ style: string; blinking: boolean }> = [] const ctx = ref.current!.ctx as { setCursorStyle: (options: { style: string; blinking: boolean }) => void } const originalSetCursorStyle = ctx.setCursorStyle.bind(ctx) ctx.setCursorStyle = (options) => { styleCalls.push(options) originalSetCursorStyle(options) } await renderOnce() // Ghostty "bar" maps to opentui "line" expect(styleCalls).toContainEqual({ style: "line", blinking: false }) }) it("should override terminal cursor style when cursorStyle is explicitly set", async () => { const ref = { current: null as GhosttyTerminalRenderable | null } // Terminal sets bar via DECSCUSR, but cursorStyle prop forces block const { renderOnce } = await testRender( { ref.current = r }} ansi={"hello\x1b[6 q"} cols={10} rows={1} showCursor cursorStyle="block" style={{ width: 10, height: 1 }} />, { width: 10, height: 1 } ) const styleCalls: Array<{ style: string; blinking: boolean }> = [] const ctx = ref.current!.ctx as { setCursorStyle: (options: { style: string; blinking: boolean }) => void } const originalSetCursorStyle = ctx.setCursorStyle.bind(ctx) ctx.setCursorStyle = (options) => { styleCalls.push(options) originalSetCursorStyle(options) } await renderOnce() // Explicit cursorStyle="block" should override terminal's bar expect(styleCalls).toContainEqual({ style: "block", blinking: false }) }) describe("trimEnd", () => { it("should keep trailing empty lines without trimEnd", async () => { const ansi = "Line 1\nLine 2" const { renderOnce, captureCharFrame } = await testRender( , { width: 24, height: 10 } ) await renderOnce() expect(captureCharFrame()).toMatchInlineSnapshot(` "┌──────────────────────┐ │Line 1 │ │Line 2 │ │ │ │ │ │ │ └──────────────────────┘ " `) }) it("should remove trailing empty lines when trimEnd is true", async () => { const ansi = "Line 1\nLine 2" const { renderOnce, captureCharFrame } = await testRender( , { width: 24, height: 10 } ) await renderOnce() expect(captureCharFrame()).toMatchInlineSnapshot(` "┌──────────────────────┐ │Line 1 │ │Line 2 │ └──────────────────────┘ " `) }) }) describe("ls output tests", () => { it("should handle ls --color=always output without extra blank lines when using limit", async () => { // Simulate ls --color=always -la output (5 lines) const lsOutput = "total 224\n" + "drwxrwxr-x 27 user staff 864 Nov 26 19:30 \x1b[34m.\x1b[0m\n" + "drwx------ 71 user staff 2272 Nov 26 19:44 \x1b[34m..\x1b[0m\n" + "-rw-r--r-- 1 user staff 109 Nov 26 18:15 .gitignore\n" + "-rw-r--r-- 1 user staff 1100 Nov 26 19:14 package.json" const actualLines = lsOutput.split("\n").length // Without limit: rows creates that many lines const { renderOnce: renderWithoutLimit, captureCharFrame: captureWithoutLimit } = await testRender( , { width: 80, height: 50 } ) await renderWithoutLimit() const outputWithoutLimit = captureWithoutLimit() // Should have 50 lines (many blank) - checking count by counting newlines roughly expect(outputWithoutLimit.split('\n').length).toBeGreaterThanOrEqual(50) // With limit: only first N lines const { renderOnce: renderWithLimit, captureCharFrame: captureWithLimit } = await testRender( , { width: 80, height: actualLines } ) await renderWithLimit() const outputWithLimit = captureWithLimit() expect(outputWithLimit).toBe(`total 224 drwxrwxr-x 27 user staff 864 Nov 26 19:30 . drwx------ 71 user staff 2272 Nov 26 19:44 .. -rw-r--r-- 1 user staff 109 Nov 26 18:15 .gitignore -rw-r--r-- 1 user staff 1100 Nov 26 19:14 package.json `) // Should be compact expect(outputWithLimit.trim().split('\n').length).toBeLessThan(10) }) it("should handle ls output with smaller rows to avoid blank lines", async () => { const lsOutput = "total 224\n" + "drwxrwxr-x 27 user staff 864 Nov 26 19:30 \x1b[34m.\x1b[0m\n" + "drwx------ 71 user staff 2272 Nov 26 19:44 \x1b[34m..\x1b[0m" const actualLines = lsOutput.split("\n").length // Using rows close to actual content const { renderOnce, captureCharFrame } = await testRender( , { width: 80, height: actualLines + 2 } ) await renderOnce() const output = captureCharFrame() expect(output).toBe(`total 224 drwxrwxr-x 27 user staff 864 Nov 26 19:30 . drwx------ 71 user staff 2272 Nov 26 19:44 .. `) }) it("should preserve ANSI colors in ls output", async () => { const lsOutput = "drwxr-xr-x 3 user staff 96 Nov 26 16:19 \x1b[34m.git\x1b[0m" const { renderOnce, captureCharFrame } = await testRender( , { width: 80, height: 5 } ) await renderOnce() const output = captureCharFrame() expect(output).toBe(`drwxr-xr-x 3 user staff 96 Nov 26 16:19 .git `) expect(output).toContain(".git") }) }) describe("persistent mode", () => { it("should render with persistent mode enabled", async () => { const ansi = "\x1b[32mHello\x1b[0m World" const { renderOnce, captureCharFrame } = await testRender( , { width: 40, height: 10 } ) await renderOnce() const output = captureCharFrame() expect(output).toContain("Hello") expect(output).toContain("World") }) it("should allow feeding data in persistent mode", async () => { const ref = { current: null as GhosttyTerminalRenderable | null } const { renderOnce, captureCharFrame } = await testRender( { ref.current = r }} cols={40} rows={10} persistent style={{ width: 40, height: 10 }} />, { width: 40, height: 10 } ) await renderOnce() // Feed data ref.current?.feed("Hello ") ref.current?.feed("World") await renderOnce() const output = captureCharFrame() expect(output).toContain("Hello World") }) it("should support streaming data in persistent mode", async () => { const ref = { current: null as GhosttyTerminalRenderable | null } const { renderOnce, captureCharFrame } = await testRender( { ref.current = r }} cols={80} rows={10} persistent style={{ width: 80, height: 10 }} />, { width: 80, height: 10 } ) await renderOnce() // Stream data like a PTY would ref.current?.feed("\x1b[32mStarting...\x1b[0m\n") ref.current?.feed("Processing file 1\n") ref.current?.feed("Processing file 2\n") ref.current?.feed("\x1b[32mDone!\x1b[0m") await renderOnce() const output = captureCharFrame() expect(output).toContain("Starting...") expect(output).toContain("Processing file 1") expect(output).toContain("Processing file 2") expect(output).toContain("Done!") }) it("should reset terminal in persistent mode", async () => { const ref = { current: null as GhosttyTerminalRenderable | null } const { renderOnce, captureCharFrame } = await testRender( { ref.current = r }} cols={40} rows={10} persistent style={{ width: 40, height: 10 }} />, { width: 40, height: 10 } ) await renderOnce() // Feed initial data ref.current?.feed("Old Content") await renderOnce() expect(captureCharFrame()).toContain("Old Content") // Reset and feed new data ref.current?.reset() ref.current?.feed("New Content") await renderOnce() const output = captureCharFrame() expect(output).toContain("New Content") // Old content should be gone after reset }) it("should track cursor position in persistent mode", async () => { const ref = { current: null as GhosttyTerminalRenderable | null } const { renderOnce } = await testRender( { ref.current = r }} cols={80} rows={24} persistent style={{ width: 80, height: 24 }} />, { width: 80, height: 24 } ) await renderOnce() ref.current?.feed("Hello") const cursor = ref.current?.getCursor() expect(cursor).toEqual([5, 0]) ref.current?.feed("\nLine 2") const cursor2 = ref.current?.getCursor() expect(cursor2?.[0]).toBe(6) expect(cursor2?.[1]).toBe(1) }) it("should get text content in persistent mode", async () => { const ref = { current: null as GhosttyTerminalRenderable | null } const { renderOnce } = await testRender( { ref.current = r }} cols={80} rows={24} persistent style={{ width: 80, height: 24 }} />, { width: 80, height: 24 } ) await renderOnce() ref.current?.feed("\x1b[32mColored\x1b[0m Text") const text = ref.current?.getText() expect(text).toContain("Colored Text") }) it("should render cursor on correct line when scrollback exists", async () => { // Issue #4: cursor rendered on wrong line when scrollback exists // When totalLines > rows, cursor Y is screen-relative but was used as // a direct index into data.lines without adjusting for scrollback offset. // The fix adjusts Y: (totalLines - rows) + cursor[1] - offset const ref = { current: null as GhosttyTerminalRenderable | null } const rows = 5 const cols = 40 const { renderOnce, captureCharFrame } = await testRender( { ref.current = r }} cols={cols} rows={rows} persistent showCursor style={{ width: cols, height: rows }} />, { width: cols, height: rows } ) await renderOnce() // Feed more lines than rows to create scrollback // 8 lines + "final" in a 5-row terminal = scrollback exists for (let i = 1; i <= 8; i++) { ref.current?.feed(`Line ${i}\n`) } ref.current?.feed("final") // Verify scrollback exists const data = ref.current!['_persistentTerminal']!.getJson() expect(data.totalLines).toBeGreaterThan(rows) const cursor = ref.current!.getCursor() expect(cursor[0]).toBe(5) // x = after "final" // cursor[1] is screen-relative, NOT the correct line index const screenY = data.cursor[1] const adjustedY = Math.max(0, (data.totalLines - data.rows) + screenY - data.offset) // Find the "final" line in data.lines const finalLineIndex = data.lines.findIndex(line => line.spans.some(span => span.text.includes("final")) ) expect(finalLineIndex).toBeGreaterThanOrEqual(0) // Screen-relative Y does NOT match the correct line expect(screenY).not.toBe(finalLineIndex) // Adjusted Y matches the correct line expect(adjustedY).toBe(finalLineIndex) // The line at screenY (buggy) does not contain "final" const lineAtScreenY = data.lines[screenY].spans.map(s => s.text).join('') expect(lineAtScreenY).not.toContain("final") // The line at adjustedY (fixed) does contain "final" const lineAtAdjustedY = data.lines[adjustedY].spans.map(s => s.text).join('') expect(lineAtAdjustedY).toContain("final") }) it("should throw when using persistent methods in stateless mode", async () => { const ref = { current: null as GhosttyTerminalRenderable | null } const { renderOnce } = await testRender( { ref.current = r }} ansi="Hello" cols={40} rows={10} style={{ width: 40, height: 10 }} />, { width: 40, height: 10 } ) await renderOnce() expect(() => ref.current?.feed("Data")).toThrow("persistent mode") expect(() => ref.current?.reset()).toThrow("persistent mode") expect(() => ref.current?.getCursor()).toThrow("persistent mode") }) }) })