import { describe, expect, it, beforeEach, afterEach } from "bun:test" import { rgbToHex } from "@opentui/core" import { ptyToJson, ptyToText, StyleFlags, PersistentTerminal, hasPersistentTerminalSupport, type TerminalData, type TerminalSpan } from "./ffi.js" import { terminalDataToStyledText, type HighlightRegion } from "./terminal-buffer.js" describe("ptyToJson", () => { it("should parse simple ANSI text", () => { const input = "\x1b[32mgreen\x1b[0m normal" const result = ptyToJson(input, { cols: 80, rows: 24 }) expect(result.cols).toBe(80) expect(result.rows).toBe(24) expect(result.lines.length).toBeGreaterThan(0) }) it("should parse bold text", () => { const input = "\x1b[1mbold\x1b[0m" const result = ptyToJson(input, { cols: 80, rows: 24 }) const firstLine = result.lines[0] expect(firstLine.spans.length).toBeGreaterThan(0) const boldSpan = firstLine.spans.find((s) => s.text === "bold") expect(boldSpan).toBeDefined() expect(boldSpan!.flags & StyleFlags.BOLD).toBeTruthy() }) it("should parse colored text", () => { const input = "\x1b[31mred\x1b[0m \x1b[32mgreen\x1b[0m" const result = ptyToJson(input, { cols: 80, rows: 24 }) const firstLine = result.lines[0] expect(firstLine.spans.length).toBeGreaterThan(0) const redSpan = firstLine.spans.find((s) => s.text === "red") expect(redSpan).toBeDefined() expect(redSpan!.fg).toBeTruthy() const greenSpan = firstLine.spans.find((s) => s.text === "green") expect(greenSpan).toBeDefined() expect(greenSpan!.fg).toBeTruthy() }) it("should handle multiple style flags", () => { const input = "\x1b[1;3;4mstyles\x1b[0m" const result = ptyToJson(input, { cols: 80, rows: 24 }) const firstLine = result.lines[0] const styledSpan = firstLine.spans.find((s) => s.text === "styles") expect(styledSpan).toBeDefined() expect(styledSpan!.flags & StyleFlags.BOLD).toBeTruthy() expect(styledSpan!.flags & StyleFlags.ITALIC).toBeTruthy() expect(styledSpan!.flags & StyleFlags.UNDERLINE).toBeTruthy() }) it("should parse RGB colors", () => { const input = "\x1b[38;2;255;0;128mrgb\x1b[0m" const result = ptyToJson(input, { cols: 80, rows: 24 }) const firstLine = result.lines[0] const rgbSpan = firstLine.spans.find((s) => s.text === "rgb") expect(rgbSpan).toBeDefined() expect(rgbSpan!.fg).toMatch(/^#[0-9a-fA-F]{6}$/) }) it("should track cursor position", () => { const input = "line1\nline2\nline3" const result = ptyToJson(input, { cols: 80, rows: 24 }) expect(result.cursor).toBeDefined() expect(result.cursor.length).toBe(2) }) it("should handle whitespace input", () => { const result = ptyToJson(" ", { cols: 80, rows: 24 }) expect(result.cols).toBe(80) expect(result.rows).toBe(24) expect(result.totalLines).toBeGreaterThanOrEqual(0) }) it("should respect cols/rows options", () => { const input = "test" const result = ptyToJson(input, { cols: 120, rows: 50 }) expect(result.cols).toBe(120) expect(result.rows).toBe(50) }) it("should preserve multi-codepoint grapheme clusters", () => { const samples = [ { input: "β™₯️X", width: 3 }, { input: "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦X", width: 3 }, { input: "πŸ‡ΊπŸ‡ΈX", width: 3 }, ] for (const { input, width } of samples) { const result = ptyToJson(`\x1b[?2027h${input}`, { cols: 20, rows: 2 }) const text = result.lines[0].spans.map((s) => s.text).join("") const cellWidth = result.lines[0].spans.reduce((sum, s) => sum + s.width, 0) expect(text).toBe(input) expect(cellWidth).toBe(width) } }) it("should handle tab expansion correctly", () => { // Tab character followed by colored text // Tab should expand to spaces and be included in the span, not break it const input = "a\tb\tc" const result = ptyToJson(input, { cols: 80, rows: 24 }) // The first line should have spans with expanded tabs as spaces expect(result.lines[0].spans.map(s => s.text)).toMatchInlineSnapshot(` [ "a b c", ] `) }) it("should handle tabs with ANSI colors", () => { // Colored text with tabs: red "a", tab, green "b", tab, blue "c" const input = "\x1b[31ma\x1b[0m\t\x1b[32mb\x1b[0m\t\x1b[33mc\x1b[0m" const result = ptyToJson(input, { cols: 80, rows: 24 }) // Tabs should expand to spaces within the spans // Each colored character plus its following tab spaces should form a span const spans = result.lines[0].spans expect(spans.map(s => ({ text: s.text, fg: s.fg }))).toMatchInlineSnapshot(` [ { "fg": "#cc6666", "text": "a", }, { "fg": null, "text": " ", }, { "fg": "#b5bd68", "text": "b", }, { "fg": null, "text": " ", }, { "fg": "#f0c674", "text": "c", }, ] `) }) it("should handle limit parameter efficiently", () => { // Generate 1000 lines const lines = Array.from({ length: 1000 }, (_, i) => `Line ${i + 1}`).join("\n") // With limit=10, should only get 10 lines const result = ptyToJson(lines, { cols: 80, rows: 1000, limit: 10 }) expect(result.lines.length).toBe(10) // First line should be "Line 1" expect(result.lines[0].spans[0].text).toContain("Line 1") // 10th line should be "Line 10" expect(result.lines[9].spans[0].text).toContain("Line 10") }) }) describe("StyleFlags", () => { it("should have correct flag values", () => { expect(StyleFlags.BOLD).toBe(1) expect(StyleFlags.ITALIC).toBe(2) expect(StyleFlags.UNDERLINE).toBe(4) expect(StyleFlags.STRIKETHROUGH).toBe(8) expect(StyleFlags.INVERSE).toBe(16) expect(StyleFlags.FAINT).toBe(32) }) }) describe("terminalDataToStyledText highlights", () => { it("should apply highlight with replaceWithX", () => { const input = "hello world" const data = ptyToJson(input, { cols: 80, rows: 24 }) const highlights: HighlightRegion[] = [ { line: 0, start: 0, end: 5, backgroundColor: "#ff0000", replaceWithX: true }, ] const styled = terminalDataToStyledText(data, highlights) // Should have "xxxxx" with red background const maskedChunk = styled.chunks.find((c) => c.text === "xxxxx") expect(maskedChunk).toBeDefined() expect(maskedChunk?.bg ? rgbToHex(maskedChunk.bg) : undefined).toBe("#ff0000") }) it("should highlight without replacing text", () => { const input = "test string" const data = ptyToJson(input, { cols: 80, rows: 24 }) const highlights: HighlightRegion[] = [ { line: 0, start: 5, end: 11, backgroundColor: "#00ff00" }, ] const styled = terminalDataToStyledText(data, highlights) // Should have "string" with green background const highlightedChunk = styled.chunks.find( (c) => c.text === "string" && c.bg && rgbToHex(c.bg) === "#00ff00" ) expect(highlightedChunk).toBeDefined() }) it("should place the cursor on the correct cell after a wide character", () => { const data = ptyToJson("A東B\x1b[D", { cols: 80, rows: 24 }) const styled = terminalDataToStyledText(data, undefined, { x: data.cursor[0], y: data.cursor[1], style: "block", }) const firstNewline = styled.chunks.findIndex((chunk) => chunk.text === "\n") const firstLineText = styled.chunks .slice(0, firstNewline >= 0 ? firstNewline : undefined) .map((chunk) => chunk.text) .join("") expect(firstLineText).toBe("A東B") const cursorChunk = styled.chunks.find( (c) => c.text === "B" && c.bg && rgbToHex(c.bg) === "#d4d4d4" ) const trailingCursor = styled.chunks.find( (c) => c.text === " " && c.bg && rgbToHex(c.bg) === "#d4d4d4" ) expect(cursorChunk).toBeDefined() expect(trailingCursor).toBeUndefined() }) }) describe("ptyToText", () => { it("should handle large output without truncation", () => { // Generate 1000 lines of colored output const lines = Array.from({ length: 1000 }, (_, i) => `\x1b[3${i % 8}mLine ${i + 1}\x1b[0m`).join("\n") const result = ptyToText(lines) const resultLines = result.split("\n") expect(resultLines.length).toBe(1000) expect(resultLines[0]).toBe("Line 1") expect(resultLines[999]).toBe("Line 1000") }) it("should strip ANSI codes and return plain text", () => { const input = "\x1b[31mred\x1b[0m \x1b[32mgreen\x1b[0m" const result = ptyToText(input) expect(result).toMatchInlineSnapshot(`"red green"`) }) it("should handle bold and italic ANSI codes", () => { const input = "\x1b[1mBold\x1b[0m \x1b[3mItalic\x1b[0m" const result = ptyToText(input) expect(result).toMatchInlineSnapshot(`"Bold Italic"`) }) it("should handle multiline input", () => { const input = "\x1b[31mLine 1\x1b[0m\n\x1b[32mLine 2\x1b[0m" const result = ptyToText(input) expect(result).toMatchInlineSnapshot(` "Line 1 Line 2" `) }) it("should handle RGB color codes", () => { const input = "\x1b[38;2;255;0;128mRGB text\x1b[0m" const result = ptyToText(input) expect(result).toMatchInlineSnapshot(`"RGB text"`) }) it("should handle plain text without ANSI codes", () => { const input = "Plain text without any ANSI codes" const result = ptyToText(input) expect(result).toMatchInlineSnapshot(`"Plain text without any ANSI codes"`) }) it("should handle empty input", () => { const result = ptyToText("") expect(result).toMatchInlineSnapshot(`""`) }) it("should handle complex nested ANSI codes", () => { const input = "\x1b[1;31;4mBold Red Underline\x1b[0m normal \x1b[32;3mGreen Italic\x1b[0m" const result = ptyToText(input) expect(result).toMatchInlineSnapshot(`"Bold Red Underline normal Green Italic"`) }) }) describe("cursor gap padding", () => { it("should place cursor at correct column when beyond line content", () => { const data = ptyToJson("abc", { cols: 80, rows: 24 }) // Cursor at col 3 (immediately after "abc") β€” no gap needed const styled3 = terminalDataToStyledText(data, undefined, { x: 3, y: 0, style: "block" }) const line3 = styled3.chunks.slice(0, styled3.chunks.indexOf(styled3.chunks.find(c => c.text === "\n")!)) const widths3 = line3.map(c => c.text.length) expect(widths3).toEqual([3, 1]) // "abc" + cursor " " // Cursor at col 5 (2 columns past "abc") β€” needs 2-space gap const styled5 = terminalDataToStyledText(data, undefined, { x: 5, y: 0, style: "block" }) const line5 = styled5.chunks.slice(0, styled5.chunks.indexOf(styled5.chunks.find(c => c.text === "\n")!)) const widths5 = line5.map(c => c.text.length) expect(widths5).toEqual([3, 2, 1]) // "abc" + " " gap + cursor " " }) it("should produce distinct output for each cursor position", () => { const term = new PersistentTerminal({ cols: 80, rows: 24 }) term.feed("abc") const renders: string[] = [] for (let col = 3; col <= 6; col++) { // Simulate cursor at different columns via CUP term.feed(`\x1b[1;${col + 1}H`) // 1-based const data = term.getJson() const styled = terminalDataToStyledText(data, undefined, { x: data.cursor[0], y: data.cursor[1], style: "block", }) renders.push(styled.chunks.map(c => c.text).join("")) } term.destroy() // Each position should produce a unique render const unique = new Set(renders) expect(unique.size).toBe(renders.length) }) }) describe("PersistentTerminal", () => { let terminal: PersistentTerminal | null = null afterEach(() => { if (terminal && !terminal.destroyed) { terminal.destroy() } terminal = null }) it("should have persistent terminal support", () => { expect(hasPersistentTerminalSupport()).toBe(true) }) it("should create a terminal with default dimensions", () => { terminal = new PersistentTerminal() expect(terminal.cols).toBe(120) expect(terminal.rows).toBe(40) expect(terminal.destroyed).toBe(false) }) it("should create a terminal with custom dimensions", () => { terminal = new PersistentTerminal({ cols: 80, rows: 24 }) expect(terminal.cols).toBe(80) expect(terminal.rows).toBe(24) }) it("should feed data and get text output", () => { terminal = new PersistentTerminal({ cols: 80, rows: 24 }) terminal.feed("Hello World") const text = terminal.getText() expect(text).toContain("Hello World") }) it("should feed data and get JSON output", () => { terminal = new PersistentTerminal({ cols: 80, rows: 24 }) terminal.feed("Hello World") const data = terminal.getJson() expect(data.cols).toBe(80) expect(data.rows).toBe(24) expect(data.lines.length).toBeGreaterThan(0) expect(data.lines[0].spans[0].text).toBe("Hello World") }) it("should maintain state across multiple feeds", () => { terminal = new PersistentTerminal({ cols: 80, rows: 24 }) terminal.feed("Hello ") terminal.feed("World") terminal.feed("\n") terminal.feed("Line 2") const text = terminal.getText() expect(text).toContain("Hello World") expect(text).toContain("Line 2") }) it("should track cursor position", () => { terminal = new PersistentTerminal({ cols: 80, rows: 24 }) terminal.feed("Hello") const cursor = terminal.getCursor() expect(cursor).toEqual([5, 0]) // x=5, y=0 }) it("should track cursor across newlines", () => { terminal = new PersistentTerminal({ cols: 80, rows: 24 }) terminal.feed("Line 1\nLine 2\nLine 3") const cursor = terminal.getCursor() expect(cursor[0]).toBe(6) // x = length of "Line 3" expect(cursor[1]).toBe(2) // y = 2 (0-indexed) }) it("should handle ANSI colors in streamed data", () => { terminal = new PersistentTerminal({ cols: 80, rows: 24 }) terminal.feed("\x1b[32m") // Set green terminal.feed("Green Text") terminal.feed("\x1b[0m") // Reset const data = terminal.getJson() const greenSpan = data.lines[0].spans.find(s => s.text === "Green Text") expect(greenSpan).toBeDefined() expect(greenSpan!.fg).toBeTruthy() // Has a color }) it("should reset terminal state", () => { terminal = new PersistentTerminal({ cols: 80, rows: 24 }) terminal.feed("Hello World\nLine 2") terminal.reset() const cursor = terminal.getCursor() expect(cursor).toEqual([0, 0]) // After reset, feeding new data should start fresh terminal.feed("Fresh Start") const text = terminal.getText() expect(text).toContain("Fresh Start") }) it("should resize terminal", () => { terminal = new PersistentTerminal({ cols: 80, rows: 24 }) terminal.feed("Hello World") terminal.resize(40, 10) expect(terminal.cols).toBe(40) expect(terminal.rows).toBe(10) const data = terminal.getJson() expect(data.cols).toBe(40) expect(data.rows).toBe(10) }) it("should destroy terminal and prevent further operations", () => { terminal = new PersistentTerminal({ cols: 80, rows: 24 }) terminal.feed("Hello") terminal.destroy() expect(terminal.destroyed).toBe(true) // Should throw on further operations expect(() => terminal!.feed("World")).toThrow("Terminal has been destroyed") expect(() => terminal!.getText()).toThrow("Terminal has been destroyed") expect(() => terminal!.getJson()).toThrow("Terminal has been destroyed") expect(() => terminal!.getCursor()).toThrow("Terminal has been destroyed") expect(() => terminal!.resize(40, 10)).toThrow("Terminal has been destroyed") expect(() => terminal!.reset()).toThrow("Terminal has been destroyed") }) it("should handle limit parameter in getJson", () => { terminal = new PersistentTerminal({ cols: 80, rows: 100 }) // Feed 50 lines for (let i = 0; i < 50; i++) { terminal.feed(`Line ${i + 1}\n`) } // Get only first 10 lines const data = terminal.getJson({ limit: 10 }) expect(data.lines.length).toBe(10) expect(data.lines[0].spans[0].text).toContain("Line 1") expect(data.lines[9].spans[0].text).toContain("Line 10") }) it("should handle offset parameter in getJson", () => { terminal = new PersistentTerminal({ cols: 80, rows: 100 }) // Feed 20 lines for (let i = 0; i < 20; i++) { terminal.feed(`Line ${i + 1}\n`) } // Get lines starting from offset 10, limit 5 const data = terminal.getJson({ offset: 10, limit: 5 }) expect(data.lines.length).toBe(5) expect(data.offset).toBe(10) expect(data.lines[0].spans[0].text).toContain("Line 11") }) it("should handle Buffer input", () => { terminal = new PersistentTerminal({ cols: 80, rows: 24 }) const buffer = Buffer.from("Buffer Input") terminal.feed(buffer) const text = terminal.getText() expect(text).toContain("Buffer Input") }) it("should handle Uint8Array input", () => { terminal = new PersistentTerminal({ cols: 80, rows: 24 }) const encoder = new TextEncoder() const uint8 = encoder.encode("Uint8Array Input") terminal.feed(uint8) const text = terminal.getText() expect(text).toContain("Uint8Array Input") }) it("should preserve split UTF-8 Buffer input across feeds", () => { terminal = new PersistentTerminal({ cols: 80, rows: 24 }) const bytes = Buffer.from("πŸ™‚", "utf-8") terminal.feed(bytes.subarray(0, 2)) expect(terminal.getText()).toBe("") terminal.feed(bytes.subarray(2)) expect(terminal.getText()).toBe("πŸ™‚") }) it("should preserve split UTF-8 Uint8Array input across feeds", () => { terminal = new PersistentTerminal({ cols: 80, rows: 24 }) const bytes = new TextEncoder().encode("πŸ™‚") terminal.feed(bytes.subarray(0, 2)) expect(terminal.getText()).toBe("") terminal.feed(bytes.subarray(2)) expect(terminal.getText()).toBe("πŸ™‚") }) it("should not stitch split UTF-8 bytes across a string feed", () => { terminal = new PersistentTerminal({ cols: 80, rows: 24 }) const bytes = Buffer.from("πŸ™‚", "utf-8") terminal.feed(bytes.subarray(0, 2)) terminal.feed("X") terminal.feed(bytes.subarray(2)) const text = terminal.getText() expect(text).toContain("X") expect(text).not.toContain("πŸ™‚") }) it("should handle cursor movement escape sequences", () => { terminal = new PersistentTerminal({ cols: 80, rows: 24 }) // Move cursor to position 5,5 (1-indexed in ANSI) terminal.feed("\x1b[6;6H") terminal.feed("X") const cursor = terminal.getCursor() expect(cursor).toEqual([6, 5]) // x=6 (after writing X), y=5 }) it("should handle multiple terminals independently", () => { const term1 = new PersistentTerminal({ cols: 80, rows: 24 }) const term2 = new PersistentTerminal({ cols: 80, rows: 24 }) try { term1.feed("Terminal 1") term2.feed("Terminal 2") expect(term1.getText()).toContain("Terminal 1") expect(term1.getText()).not.toContain("Terminal 2") expect(term2.getText()).toContain("Terminal 2") expect(term2.getText()).not.toContain("Terminal 1") } finally { term1.destroy() term2.destroy() } }) it("should be more efficient than stateless ptyToJson for streaming", () => { terminal = new PersistentTerminal({ cols: 80, rows: 100 }) // Simulate streaming data in chunks const chunks = [ "\x1b[32mStarting build...\x1b[0m\n", "Compiling src/index.ts\n", "Compiling src/utils.ts\n", "\x1b[33mWarning: unused variable\x1b[0m\n", "\x1b[32mBuild complete!\x1b[0m\n", ] // Feed chunks one by one (like streaming PTY output) for (const chunk of chunks) { terminal.feed(chunk) } // Use limit to get just the content lines const data = terminal.getJson({ limit: 5 }) expect(data.lines.length).toBe(5) // Verify all content is there const text = terminal.getText() expect(text).toContain("Starting build") expect(text).toContain("Build complete") }) })