serialize.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import { describe, test, expect, beforeAll, afterEach } from "bun:test"
  2. import { Terminal, Ghostty } from "ghostty-web"
  3. import { SerializeAddon } from "./serialize"
  4. let ghostty: Ghostty
  5. beforeAll(async () => {
  6. ghostty = await Ghostty.load()
  7. })
  8. const terminals: Terminal[] = []
  9. afterEach(() => {
  10. for (const term of terminals) {
  11. term.dispose()
  12. }
  13. terminals.length = 0
  14. document.body.innerHTML = ""
  15. })
  16. function createTerminal(cols = 80, rows = 24): { term: Terminal; addon: SerializeAddon; container: HTMLElement } {
  17. const container = document.createElement("div")
  18. document.body.appendChild(container)
  19. const term = new Terminal({ cols, rows, ghostty })
  20. const addon = new SerializeAddon()
  21. term.loadAddon(addon)
  22. term.open(container)
  23. terminals.push(term)
  24. return { term, addon, container }
  25. }
  26. function writeAndWait(term: Terminal, data: string): Promise<void> {
  27. return new Promise((resolve) => {
  28. term.write(data, resolve)
  29. })
  30. }
  31. describe("SerializeAddon", () => {
  32. describe("ANSI color preservation", () => {
  33. test("should preserve text attributes (bold, italic, underline)", async () => {
  34. const { term, addon } = createTerminal()
  35. const input = "\x1b[1mBOLD\x1b[0m \x1b[3mITALIC\x1b[0m \x1b[4mUNDER\x1b[0m"
  36. await writeAndWait(term, input)
  37. const origLine = term.buffer.active.getLine(0)
  38. expect(origLine!.getCell(0)!.isBold()).toBe(1)
  39. expect(origLine!.getCell(5)!.isItalic()).toBe(1)
  40. expect(origLine!.getCell(12)!.isUnderline()).toBe(1)
  41. const serialized = addon.serialize({ range: { start: 0, end: 0 } })
  42. const { term: term2 } = createTerminal()
  43. terminals.push(term2)
  44. await writeAndWait(term2, serialized)
  45. const line = term2.buffer.active.getLine(0)
  46. const boldCell = line!.getCell(0)
  47. expect(boldCell!.getChars()).toBe("B")
  48. expect(boldCell!.isBold()).toBe(1)
  49. const italicCell = line!.getCell(5)
  50. expect(italicCell!.getChars()).toBe("I")
  51. expect(italicCell!.isItalic()).toBe(1)
  52. const underCell = line!.getCell(12)
  53. expect(underCell!.getChars()).toBe("U")
  54. expect(underCell!.isUnderline()).toBe(1)
  55. })
  56. test("should preserve basic 16-color foreground colors", async () => {
  57. const { term, addon } = createTerminal()
  58. const input = "\x1b[31mRED\x1b[32mGREEN\x1b[34mBLUE\x1b[0mNORMAL"
  59. await writeAndWait(term, input)
  60. const origLine = term.buffer.active.getLine(0)
  61. const origRedFg = origLine!.getCell(0)!.getFgColor()
  62. const origGreenFg = origLine!.getCell(3)!.getFgColor()
  63. const origBlueFg = origLine!.getCell(8)!.getFgColor()
  64. const serialized = addon.serialize({ range: { start: 0, end: 0 } })
  65. const { term: term2 } = createTerminal()
  66. terminals.push(term2)
  67. await writeAndWait(term2, serialized)
  68. const line = term2.buffer.active.getLine(0)
  69. expect(line).toBeDefined()
  70. const redCell = line!.getCell(0)
  71. expect(redCell!.getChars()).toBe("R")
  72. expect(redCell!.getFgColor()).toBe(origRedFg)
  73. const greenCell = line!.getCell(3)
  74. expect(greenCell!.getChars()).toBe("G")
  75. expect(greenCell!.getFgColor()).toBe(origGreenFg)
  76. const blueCell = line!.getCell(8)
  77. expect(blueCell!.getChars()).toBe("B")
  78. expect(blueCell!.getFgColor()).toBe(origBlueFg)
  79. })
  80. test("should preserve 256-color palette colors", async () => {
  81. const { term, addon } = createTerminal()
  82. const input = "\x1b[38;5;196mRED256\x1b[0mNORMAL"
  83. await writeAndWait(term, input)
  84. const origLine = term.buffer.active.getLine(0)
  85. const origRedFg = origLine!.getCell(0)!.getFgColor()
  86. const serialized = addon.serialize({ range: { start: 0, end: 0 } })
  87. const { term: term2 } = createTerminal()
  88. terminals.push(term2)
  89. await writeAndWait(term2, serialized)
  90. const line = term2.buffer.active.getLine(0)
  91. const redCell = line!.getCell(0)
  92. expect(redCell!.getChars()).toBe("R")
  93. expect(redCell!.getFgColor()).toBe(origRedFg)
  94. })
  95. test("should preserve RGB/truecolor colors", async () => {
  96. const { term, addon } = createTerminal()
  97. const input = "\x1b[38;2;255;128;64mRGB_TEXT\x1b[0mNORMAL"
  98. await writeAndWait(term, input)
  99. const origLine = term.buffer.active.getLine(0)
  100. const origRgbFg = origLine!.getCell(0)!.getFgColor()
  101. const serialized = addon.serialize({ range: { start: 0, end: 0 } })
  102. const { term: term2 } = createTerminal()
  103. terminals.push(term2)
  104. await writeAndWait(term2, serialized)
  105. const line = term2.buffer.active.getLine(0)
  106. const rgbCell = line!.getCell(0)
  107. expect(rgbCell!.getChars()).toBe("R")
  108. expect(rgbCell!.getFgColor()).toBe(origRgbFg)
  109. })
  110. test("should preserve background colors", async () => {
  111. const { term, addon } = createTerminal()
  112. const input = "\x1b[48;2;255;0;0mRED_BG\x1b[48;2;0;255;0mGREEN_BG\x1b[0mNORMAL"
  113. await writeAndWait(term, input)
  114. const origLine = term.buffer.active.getLine(0)
  115. const origRedBg = origLine!.getCell(0)!.getBgColor()
  116. const origGreenBg = origLine!.getCell(6)!.getBgColor()
  117. const serialized = addon.serialize({ range: { start: 0, end: 0 } })
  118. const { term: term2 } = createTerminal()
  119. terminals.push(term2)
  120. await writeAndWait(term2, serialized)
  121. const line = term2.buffer.active.getLine(0)
  122. const redBgCell = line!.getCell(0)
  123. expect(redBgCell!.getChars()).toBe("R")
  124. expect(redBgCell!.getBgColor()).toBe(origRedBg)
  125. const greenBgCell = line!.getCell(6)
  126. expect(greenBgCell!.getChars()).toBe("G")
  127. expect(greenBgCell!.getBgColor()).toBe(origGreenBg)
  128. })
  129. test("should handle combined colors and attributes", async () => {
  130. const { term, addon } = createTerminal()
  131. const input =
  132. "\x1b[1;38;2;255;0;0;48;2;255;255;0mCOMBO\x1b[0mNORMAL "
  133. await writeAndWait(term, input)
  134. const origLine = term.buffer.active.getLine(0)
  135. const origFg = origLine!.getCell(0)!.getFgColor()
  136. const origBg = origLine!.getCell(0)!.getBgColor()
  137. expect(origLine!.getCell(0)!.isBold()).toBe(1)
  138. const serialized = addon.serialize({ range: { start: 0, end: 0 } })
  139. const cleanSerialized = serialized.replace(/\x1b\[\d+X/g, "")
  140. expect(cleanSerialized.startsWith("\x1b[1;")).toBe(true)
  141. const { term: term2 } = createTerminal()
  142. terminals.push(term2)
  143. await writeAndWait(term2, cleanSerialized)
  144. const line = term2.buffer.active.getLine(0)
  145. const comboCell = line!.getCell(0)
  146. expect(comboCell!.getChars()).toBe("C")
  147. expect(cleanSerialized).toContain("\x1b[1;38;2;255;0;0;48;2;255;255;0m")
  148. })
  149. })
  150. describe("round-trip serialization", () => {
  151. test("should not produce ECH sequences", async () => {
  152. const { term, addon } = createTerminal()
  153. await writeAndWait(term, "\x1b[31mHello\x1b[0m World")
  154. const serialized = addon.serialize()
  155. const hasECH = /\x1b\[\d+X/.test(serialized)
  156. expect(hasECH).toBe(false)
  157. })
  158. test("multi-line content should not have garbage characters", async () => {
  159. const { term, addon } = createTerminal()
  160. const content = [
  161. "\x1b[1;32m❯\x1b[0m \x1b[34mcd\x1b[0m /some/path",
  162. "\x1b[1;32m❯\x1b[0m \x1b[34mls\x1b[0m -la",
  163. "total 42",
  164. ].join("\r\n")
  165. await writeAndWait(term, content)
  166. const serialized = addon.serialize()
  167. expect(/\x1b\[\d+X/.test(serialized)).toBe(false)
  168. const { term: term2 } = createTerminal()
  169. terminals.push(term2)
  170. await writeAndWait(term2, serialized)
  171. for (let row = 0; row < 3; row++) {
  172. const line = term2.buffer.active.getLine(row)?.translateToString(true)
  173. expect(line?.includes("𑼝")).toBe(false)
  174. }
  175. expect(term2.buffer.active.getLine(0)?.translateToString(true)).toContain("cd /some/path")
  176. expect(term2.buffer.active.getLine(1)?.translateToString(true)).toContain("ls -la")
  177. expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
  178. })
  179. test("serialized output should restore after Terminal.reset()", async () => {
  180. const { term, addon } = createTerminal()
  181. const content = [
  182. "\x1b[1;32m❯\x1b[0m \x1b[34mcd\x1b[0m /some/path",
  183. "\x1b[1;32m❯\x1b[0m \x1b[34mls\x1b[0m -la",
  184. "total 42",
  185. ].join("\r\n")
  186. await writeAndWait(term, content)
  187. const serialized = addon.serialize()
  188. const { term: term2 } = createTerminal()
  189. terminals.push(term2)
  190. term2.reset()
  191. await writeAndWait(term2, serialized)
  192. expect(term2.buffer.active.getLine(0)?.translateToString(true)).toContain("cd /some/path")
  193. expect(term2.buffer.active.getLine(1)?.translateToString(true)).toContain("ls -la")
  194. expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
  195. })
  196. test("alternate buffer should round-trip without garbage", async () => {
  197. const { term, addon } = createTerminal(20, 5)
  198. await writeAndWait(term, "normal\r\n")
  199. await writeAndWait(term, "\x1b[?1049h\x1b[HALT")
  200. expect(term.buffer.active.type).toBe("alternate")
  201. const serialized = addon.serialize()
  202. const { term: term2 } = createTerminal(20, 5)
  203. terminals.push(term2)
  204. await writeAndWait(term2, serialized)
  205. expect(term2.buffer.active.type).toBe("alternate")
  206. const line = term2.buffer.active.getLine(0)
  207. expect(line?.translateToString(true)).toBe("ALT")
  208. // Ensure a cell beyond content isn't garbage
  209. const cellCode = line?.getCell(10)?.getCode()
  210. expect(cellCode === 0 || cellCode === 32).toBe(true)
  211. })
  212. test("serialized output written to new terminal should match original colors", async () => {
  213. const { term, addon } = createTerminal(40, 5)
  214. const input = "\x1b[38;2;255;0;0mHello\x1b[0m \x1b[38;2;0;255;0mWorld\x1b[0m! "
  215. await writeAndWait(term, input)
  216. const origLine = term.buffer.active.getLine(0)
  217. const origHelloFg = origLine!.getCell(0)!.getFgColor()
  218. const origWorldFg = origLine!.getCell(6)!.getFgColor()
  219. const serialized = addon.serialize({ range: { start: 0, end: 0 } })
  220. const { term: term2 } = createTerminal(40, 5)
  221. terminals.push(term2)
  222. await writeAndWait(term2, serialized)
  223. const newLine = term2.buffer.active.getLine(0)
  224. expect(newLine!.getCell(0)!.getChars()).toBe("H")
  225. expect(newLine!.getCell(0)!.getFgColor()).toBe(origHelloFg)
  226. expect(newLine!.getCell(6)!.getChars()).toBe("W")
  227. expect(newLine!.getCell(6)!.getFgColor()).toBe(origWorldFg)
  228. expect(newLine!.getCell(11)!.getChars()).toBe("!")
  229. })
  230. })
  231. })