| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319 |
- 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<void> {
- 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 should restore after Terminal.reset()", 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()
- const { term: term2 } = createTerminal()
- terminals.push(term2)
- term2.reset()
- await writeAndWait(term2, serialized)
- 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("alternate buffer should round-trip without garbage", async () => {
- const { term, addon } = createTerminal(20, 5)
- await writeAndWait(term, "normal\r\n")
- await writeAndWait(term, "\x1b[?1049h\x1b[HALT")
- expect(term.buffer.active.type).toBe("alternate")
- const serialized = addon.serialize()
- const { term: term2 } = createTerminal(20, 5)
- terminals.push(term2)
- await writeAndWait(term2, serialized)
- expect(term2.buffer.active.type).toBe("alternate")
- const line = term2.buffer.active.getLine(0)
- expect(line?.translateToString(true)).toBe("ALT")
- // Ensure a cell beyond content isn't garbage
- const cellCode = line?.getCell(10)?.getCode()
- expect(cellCode === 0 || cellCode === 32).toBe(true)
- })
- 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("!")
- })
- })
- })
|