import { describe, test, expect, beforeAll, afterEach } from "bun:test" import { Terminal, Ghostty } from "ghostty-web" import { SerializeAddon } from "./serialize" let ghostty: Ghostty beforeAll(async () => { ghostty = await Ghostty.load() }) const terminals: Terminal[] = [] afterEach(() => { for (const term of terminals) { term.dispose() } terminals.length = 0 document.body.innerHTML = "" }) function createTerminal(cols = 80, rows = 24): { term: Terminal; addon: SerializeAddon; container: HTMLElement } { const container = document.createElement("div") document.body.appendChild(container) const term = new Terminal({ cols, rows, ghostty }) const addon = new SerializeAddon() term.loadAddon(addon) term.open(container) terminals.push(term) return { term, addon, container } } function writeAndWait(term: Terminal, data: string): Promise { return new Promise((resolve) => { term.write(data, resolve) }) } describe("SerializeAddon", () => { describe("ANSI color preservation", () => { test("should preserve text attributes (bold, italic, underline)", async () => { const { term, addon } = createTerminal() const input = "\x1b[1mBOLD\x1b[0m \x1b[3mITALIC\x1b[0m \x1b[4mUNDER\x1b[0m" await writeAndWait(term, input) const origLine = term.buffer.active.getLine(0) expect(origLine!.getCell(0)!.isBold()).toBe(1) expect(origLine!.getCell(5)!.isItalic()).toBe(1) expect(origLine!.getCell(12)!.isUnderline()).toBe(1) const serialized = addon.serialize({ range: { start: 0, end: 0 } }) const { term: term2 } = createTerminal() terminals.push(term2) await writeAndWait(term2, serialized) const line = term2.buffer.active.getLine(0) const boldCell = line!.getCell(0) expect(boldCell!.getChars()).toBe("B") expect(boldCell!.isBold()).toBe(1) const italicCell = line!.getCell(5) expect(italicCell!.getChars()).toBe("I") expect(italicCell!.isItalic()).toBe(1) const underCell = line!.getCell(12) expect(underCell!.getChars()).toBe("U") expect(underCell!.isUnderline()).toBe(1) }) test("should preserve basic 16-color foreground colors", async () => { const { term, addon } = createTerminal() const input = "\x1b[31mRED\x1b[32mGREEN\x1b[34mBLUE\x1b[0mNORMAL" await writeAndWait(term, input) const origLine = term.buffer.active.getLine(0) const origRedFg = origLine!.getCell(0)!.getFgColor() const origGreenFg = origLine!.getCell(3)!.getFgColor() const origBlueFg = origLine!.getCell(8)!.getFgColor() const serialized = addon.serialize({ range: { start: 0, end: 0 } }) const { term: term2 } = createTerminal() terminals.push(term2) await writeAndWait(term2, serialized) const line = term2.buffer.active.getLine(0) expect(line).toBeDefined() const redCell = line!.getCell(0) expect(redCell!.getChars()).toBe("R") expect(redCell!.getFgColor()).toBe(origRedFg) const greenCell = line!.getCell(3) expect(greenCell!.getChars()).toBe("G") expect(greenCell!.getFgColor()).toBe(origGreenFg) const blueCell = line!.getCell(8) expect(blueCell!.getChars()).toBe("B") expect(blueCell!.getFgColor()).toBe(origBlueFg) }) test("should preserve 256-color palette colors", async () => { const { term, addon } = createTerminal() const input = "\x1b[38;5;196mRED256\x1b[0mNORMAL" await writeAndWait(term, input) const origLine = term.buffer.active.getLine(0) const origRedFg = origLine!.getCell(0)!.getFgColor() const serialized = addon.serialize({ range: { start: 0, end: 0 } }) const { term: term2 } = createTerminal() terminals.push(term2) await writeAndWait(term2, serialized) const line = term2.buffer.active.getLine(0) const redCell = line!.getCell(0) expect(redCell!.getChars()).toBe("R") expect(redCell!.getFgColor()).toBe(origRedFg) }) test("should preserve RGB/truecolor colors", async () => { const { term, addon } = createTerminal() const input = "\x1b[38;2;255;128;64mRGB_TEXT\x1b[0mNORMAL" await writeAndWait(term, input) const origLine = term.buffer.active.getLine(0) const origRgbFg = origLine!.getCell(0)!.getFgColor() const serialized = addon.serialize({ range: { start: 0, end: 0 } }) const { term: term2 } = createTerminal() terminals.push(term2) await writeAndWait(term2, serialized) const line = term2.buffer.active.getLine(0) const rgbCell = line!.getCell(0) expect(rgbCell!.getChars()).toBe("R") expect(rgbCell!.getFgColor()).toBe(origRgbFg) }) test("should preserve background colors", async () => { const { term, addon } = createTerminal() const input = "\x1b[48;2;255;0;0mRED_BG\x1b[48;2;0;255;0mGREEN_BG\x1b[0mNORMAL" await writeAndWait(term, input) const origLine = term.buffer.active.getLine(0) const origRedBg = origLine!.getCell(0)!.getBgColor() const origGreenBg = origLine!.getCell(6)!.getBgColor() const serialized = addon.serialize({ range: { start: 0, end: 0 } }) const { term: term2 } = createTerminal() terminals.push(term2) await writeAndWait(term2, serialized) const line = term2.buffer.active.getLine(0) const redBgCell = line!.getCell(0) expect(redBgCell!.getChars()).toBe("R") expect(redBgCell!.getBgColor()).toBe(origRedBg) const greenBgCell = line!.getCell(6) expect(greenBgCell!.getChars()).toBe("G") expect(greenBgCell!.getBgColor()).toBe(origGreenBg) }) test("should handle combined colors and attributes", async () => { const { term, addon } = createTerminal() const input = "\x1b[1;38;2;255;0;0;48;2;255;255;0mCOMBO\x1b[0mNORMAL " await writeAndWait(term, input) const origLine = term.buffer.active.getLine(0) const origFg = origLine!.getCell(0)!.getFgColor() const origBg = origLine!.getCell(0)!.getBgColor() expect(origLine!.getCell(0)!.isBold()).toBe(1) const serialized = addon.serialize({ range: { start: 0, end: 0 } }) const cleanSerialized = serialized.replace(/\x1b\[\d+X/g, "") expect(cleanSerialized.startsWith("\x1b[1;")).toBe(true) const { term: term2 } = createTerminal() terminals.push(term2) await writeAndWait(term2, cleanSerialized) const line = term2.buffer.active.getLine(0) const comboCell = line!.getCell(0) expect(comboCell!.getChars()).toBe("C") expect(cleanSerialized).toContain("\x1b[1;38;2;255;0;0;48;2;255;255;0m") }) }) describe("round-trip serialization", () => { test("should not produce ECH sequences", async () => { const { term, addon } = createTerminal() await writeAndWait(term, "\x1b[31mHello\x1b[0m World") const serialized = addon.serialize() const hasECH = /\x1b\[\d+X/.test(serialized) expect(hasECH).toBe(false) }) test("multi-line content should not have garbage characters", async () => { const { term, addon } = createTerminal() const content = [ "\x1b[1;32m❯\x1b[0m \x1b[34mcd\x1b[0m /some/path", "\x1b[1;32m❯\x1b[0m \x1b[34mls\x1b[0m -la", "total 42", ].join("\r\n") await writeAndWait(term, content) const serialized = addon.serialize() expect(/\x1b\[\d+X/.test(serialized)).toBe(false) const { term: term2 } = createTerminal() terminals.push(term2) await writeAndWait(term2, serialized) for (let row = 0; row < 3; row++) { const line = term2.buffer.active.getLine(row)?.translateToString(true) expect(line?.includes("𑼝")).toBe(false) } expect(term2.buffer.active.getLine(0)?.translateToString(true)).toContain("cd /some/path") expect(term2.buffer.active.getLine(1)?.translateToString(true)).toContain("ls -la") expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42") }) test("serialized output written to new terminal should match original colors", async () => { const { term, addon } = createTerminal(40, 5) const input = "\x1b[38;2;255;0;0mHello\x1b[0m \x1b[38;2;0;255;0mWorld\x1b[0m! " await writeAndWait(term, input) const origLine = term.buffer.active.getLine(0) const origHelloFg = origLine!.getCell(0)!.getFgColor() const origWorldFg = origLine!.getCell(6)!.getFgColor() const serialized = addon.serialize({ range: { start: 0, end: 0 } }) const { term: term2 } = createTerminal(40, 5) terminals.push(term2) await writeAndWait(term2, serialized) const newLine = term2.buffer.active.getLine(0) expect(newLine!.getCell(0)!.getChars()).toBe("H") expect(newLine!.getCell(0)!.getFgColor()).toBe(origHelloFg) expect(newLine!.getCell(6)!.getChars()).toBe("W") expect(newLine!.getCell(6)!.getFgColor()).toBe(origWorldFg) expect(newLine!.getCell(11)!.getChars()).toBe("!") }) }) })