ansi.spec.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. // parseAnsiLines, the ANSI model behind TerminalBlock: anser's SGR runs
  2. // resolved into inline styles and folded into per-line span arrays, with every
  3. // escape and control character that carries no color removed first. The DOM
  4. // side of the same model (which runs get a span wrapper) is in
  5. // terminal-block.spec.tsx.
  6. import { describe, expect, it } from 'vitest'
  7. import { parseAnsiLines } from '../src/ansi.ts'
  8. const ESC = '\u001b'
  9. const BS = '\u0008'
  10. /** A combining acute accent: zero-width, so it takes no terminal column. */
  11. const ACCENT = '\u0301'
  12. /** Paint `text` with the SGR `codes`, then reset. */
  13. function sgr(codes: string, text: string): string {
  14. return `${ESC}[${codes}m${text}${ESC}[0m`
  15. }
  16. /** The single span of a single-line, single-run parse. */
  17. function onlySpan(text: string) {
  18. const lines = parseAnsiLines(text)
  19. expect(lines).toHaveLength(1)
  20. expect(lines[0]).toHaveLength(1)
  21. return lines[0]![0]!
  22. }
  23. describe('parseAnsiLines: text without SGR state', () => {
  24. it('leaves plain text as one unstyled span', () => {
  25. expect(parseAnsiLines('hello')).toEqual([[{ text: 'hello', style: undefined }]])
  26. })
  27. it('returns exactly one empty line for empty input', () => {
  28. expect(parseAnsiLines('')).toEqual([[]])
  29. })
  30. it('splits a multi-line run and drops the empty line between two blocks', () => {
  31. expect(parseAnsiLines('a\n\nb')).toEqual([
  32. [{ text: 'a', style: undefined }],
  33. [],
  34. [{ text: 'b', style: undefined }],
  35. ])
  36. })
  37. it('keeps tabs, which the terminal surface needs for column layout', () => {
  38. expect(onlySpan('a\tb')).toEqual({ text: 'a\tb', style: undefined })
  39. })
  40. })
  41. describe('parseAnsiLines: basic colors mapped onto theme tokens', () => {
  42. it.each<[string, string, string]>([
  43. ['30', 'black', 'var(--dsw-alias-label-primary)'],
  44. ['37', 'white', 'var(--dsw-alias-label-primary)'],
  45. ['90', 'bright black', 'var(--dsw-alias-label-tertiary)'],
  46. ['31', 'red', 'var(--dsw-alias-state-error-primary)'],
  47. ['91', 'bright red', 'var(--dsw-alias-state-error-secondary)'],
  48. ['32', 'green', 'var(--dsw-alias-state-success-primary)'],
  49. ['92', 'bright green', 'var(--dsw-alias-state-success-secondary)'],
  50. ['33', 'yellow', 'var(--dsw-alias-state-warn-primary)'],
  51. ['93', 'bright yellow', 'var(--dsw-alias-state-warn-secondary)'],
  52. ['34', 'blue', 'var(--dsw-alias-state-business-primary)'],
  53. ['94', 'bright blue', 'var(--dsw-static-blue-400)'],
  54. ])('SGR %s (%s) resolves to %s', (code, _name, token) => {
  55. expect(onlySpan(sgr(code, 'x'))).toEqual({ text: 'x', style: { color: token } })
  56. })
  57. })
  58. describe('parseAnsiLines: colors with no token equivalent', () => {
  59. it.each<[string, string, string]>([
  60. ['35', 'magenta', 'rgb(187, 0, 187)'],
  61. ['36', 'cyan', 'rgb(0, 187, 187)'],
  62. ['38;5;208', '256-palette orange', 'rgb(255, 135, 0)'],
  63. ['38;2;10;20;30', 'truecolor', 'rgb(10, 20, 30)'],
  64. ])('SGR %s (%s) falls through to %s', (code, _name, literal) => {
  65. expect(onlySpan(sgr(code, 'x')).style).toEqual({ color: literal })
  66. })
  67. })
  68. describe('parseAnsiLines: backgrounds', () => {
  69. it('sets backgroundColor for a background-only run', () => {
  70. expect(onlySpan(sgr('44', 'x')).style).toEqual({ backgroundColor: 'rgb(0, 0, 187)' })
  71. })
  72. it('keeps the literal foreground when the run paints its own background', () => {
  73. expect(onlySpan(sgr('41;37', 'x')).style).toEqual({
  74. backgroundColor: 'rgb(187, 0, 0)',
  75. color: 'rgb(255,255,255)',
  76. })
  77. })
  78. it('renders reverse video as the swapped pair anser reports', () => {
  79. expect(onlySpan(sgr('31;7', 'x')).style).toEqual({
  80. backgroundColor: 'rgb(187, 0, 0)',
  81. color: 'rgb(0, 0, 0)',
  82. })
  83. })
  84. })
  85. describe('parseAnsiLines: decorations', () => {
  86. it.each<[string, string, Record<string, unknown>]>([
  87. ['1', 'bold', { fontWeight: 700 }],
  88. ['2', 'dim', { opacity: 0.7 }],
  89. ['3', 'italic', { fontStyle: 'italic' }],
  90. ['4', 'underline', { textDecoration: 'underline' }],
  91. ['9', 'strikethrough', { textDecoration: 'line-through' }],
  92. ['8', 'hidden', { visibility: 'hidden' }],
  93. ])('SGR %s (%s) resolves to %o', (code, _name, style) => {
  94. expect(onlySpan(sgr(code, 'x')).style).toEqual(style)
  95. })
  96. it('lets the later textDecoration win when a run declares underline and strikethrough', () => {
  97. expect(onlySpan(sgr('4;9', 'x')).style).toEqual({ textDecoration: 'line-through' })
  98. expect(onlySpan(sgr('9;4', 'x')).style).toEqual({ textDecoration: 'underline' })
  99. })
  100. it('combines a color with several decorations in one style', () => {
  101. expect(onlySpan(sgr('1;3;31', 'x')).style).toEqual({
  102. color: 'var(--dsw-alias-state-error-primary)',
  103. fontWeight: 700,
  104. fontStyle: 'italic',
  105. })
  106. })
  107. it('reproduces no animation for blink, leaving the run unstyled', () => {
  108. expect(onlySpan(sgr('5', 'x'))).toEqual({ text: 'x', style: undefined })
  109. })
  110. })
  111. describe('parseAnsiLines: sequences that carry no color', () => {
  112. it('removes an OSC string with its BEL terminator', () => {
  113. expect(onlySpan(`a${ESC}]0;window title\u0007b`)).toEqual({ text: 'ab', style: undefined })
  114. })
  115. it('removes an OSC string terminated by ST', () => {
  116. expect(onlySpan(`a${ESC}]8;;https://example.com${ESC}\\b`)).toEqual({ text: 'ab', style: undefined })
  117. })
  118. it('removes non-CSI escapes such as charset selection and reset', () => {
  119. expect(onlySpan(`x${ESC}(By${ESC}cz`)).toEqual({ text: 'xyz', style: undefined })
  120. })
  121. it('removes inert C0 controls', () => {
  122. expect(onlySpan('\u0000ab\u001fc\u007f')).toEqual({ text: 'abc', style: undefined })
  123. })
  124. it('keeps CSI sequences that only move the cursor out of the text', () => {
  125. expect(onlySpan(`${ESC}[2K${ESC}[1Adone`)).toEqual({ text: 'done', style: undefined })
  126. })
  127. })
  128. describe('parseAnsiLines: carriage returns', () => {
  129. it('keeps only the last redraw of a line', () => {
  130. expect(onlySpan('10%\r55%\r100%')).toEqual({ text: '100%', style: undefined })
  131. })
  132. it('leaves the tail of a longer frame standing under a shorter redraw', () => {
  133. // Verified against a real terminal: `100%\rOK` paints `OK0%`. A carriage
  134. // return only moves the cursor, so the two columns the redraw never reaches
  135. // still hold the frame beneath — truncating to the last `\r` would lose them.
  136. expect(onlySpan('100%\rOK')).toEqual({ text: 'OK0%', style: undefined })
  137. expect(onlySpan('abcdef\rXY')).toEqual({ text: 'XYcdef', style: undefined })
  138. })
  139. it('clamps a backspace run at the line start rather than going negative', () => {
  140. // More backspaces than characters: the cursor stops at column 0, so the
  141. // following write simply overwrites from there.
  142. expect(onlySpan(`ab${BS}${BS}${BS}${BS}xyz`)).toEqual({ text: 'xyz', style: undefined })
  143. })
  144. it('keeps SGR state in force across a redraw, as a terminal does', () => {
  145. // Verified against a real terminal: `\x1b[31mgone\rkept` paints `kept` RED.
  146. // A carriage return moves the cursor; it does not reset the graphic state,
  147. // so the redraw inherits the color the discarded frame was written with.
  148. expect(onlySpan(`${ESC}[31mgone\rkept`))
  149. .toEqual({ text: 'kept', style: { color: 'var(--dsw-alias-state-error-primary)' } })
  150. })
  151. it('preserves both lines of a CRLF pair instead of treating it as a redraw', () => {
  152. expect(parseAnsiLines('a\r\r\nb\r\n')).toEqual([
  153. [{ text: 'a', style: undefined }],
  154. [{ text: 'b', style: undefined }],
  155. [],
  156. ])
  157. })
  158. it('applies the redraw per line, not across the whole text', () => {
  159. expect(parseAnsiLines('one\rtwo\nthree')).toEqual([
  160. [{ text: 'two', style: undefined }],
  161. [{ text: 'three', style: undefined }],
  162. ])
  163. })
  164. })
  165. describe('parseAnsiLines: backspaces', () => {
  166. it('applies a backspace as the overwrite a terminal draws', () => {
  167. // `abc` then two backspaces then `XY` shows as `aXY`, not `abcXY`.
  168. expect(onlySpan(`abc${BS}${BS}XY`)).toEqual({ text: 'aXY', style: undefined })
  169. })
  170. it('stops at the line start instead of eating the newline before it', () => {
  171. expect(parseAnsiLines(`ab\n${BS}${BS}${BS}cd`)).toEqual([
  172. [{ text: 'ab', style: undefined }],
  173. [{ text: 'cd', style: undefined }],
  174. ])
  175. })
  176. it('treats a trailing backspace as a cursor move, not a delete', () => {
  177. // Verified against a real terminal: `abc\b` still shows `abc`. Only a later
  178. // write overwrites; a backspace with nothing after it erases nothing.
  179. expect(onlySpan(`abc${BS}`)).toEqual({ text: 'abc', style: undefined })
  180. // Same at a line boundary: the newline ends the line before any overwrite.
  181. expect(parseAnsiLines(`abc${BS}\ndef`)).toEqual([
  182. [{ text: 'abc', style: undefined }],
  183. [{ text: 'def', style: undefined }],
  184. ])
  185. })
  186. it('steps over an SGR sequence instead of erasing its bytes', () => {
  187. // `abc` reset then two backspaces then `XY`: erasing the reset's bytes would
  188. // corrupt it and repaint the rest of the line with whatever the remainder
  189. // parses as. The visible result is `aXY`, still red, with the reset intact.
  190. expect(parseAnsiLines(`${sgr('31', 'abc')}${BS}${BS}XY`)).toEqual([[
  191. { text: 'a', style: { color: 'var(--dsw-alias-state-error-primary)' } },
  192. { text: 'XY', style: undefined },
  193. ]])
  194. })
  195. it('erases across a style boundary without dropping the styles between', () => {
  196. // The backspace reaches back past the reset to the last printed character.
  197. expect(parseAnsiLines(`${sgr('32', 'ok')}${ESC}[31m${BS}bad`)).toEqual([[
  198. { text: 'o', style: { color: 'var(--dsw-alias-state-success-primary)' } },
  199. { text: 'bad', style: { color: 'var(--dsw-alias-state-error-primary)' } },
  200. ]])
  201. })
  202. it('replays a redraw and a trailing backspace as pure cursor moves', () => {
  203. // Verified against a real terminal: `old\rnew\b` shows `new`. The redraw
  204. // repaints all three columns and the trailing backspace only moves the
  205. // cursor left — nothing overwrites the `w`, so nothing is lost.
  206. expect(onlySpan(`old\rnew${BS}`)).toEqual({ text: 'new', style: undefined })
  207. })
  208. it('overwrites only the columns the later write reaches, keeping the rest styled', () => {
  209. // Verified against a real terminal: red `bad`, three backspaces, then `ok`
  210. // shows `okd` — the cursor returned to column 0 and `ok` overwrote two of
  211. // the three columns, so the untouched `d` keeps the run's red.
  212. expect(parseAnsiLines(`${sgr('31', 'bad')}${BS}${BS}${BS}ok`)).toEqual([[
  213. { text: 'ok', style: undefined },
  214. { text: 'd', style: { color: 'var(--dsw-alias-state-error-primary)' } },
  215. ]])
  216. })
  217. })
  218. describe('parseAnsiLines: erase and column arithmetic', () => {
  219. it('erases the rest of the line, the fixed companion of a redraw', () => {
  220. // Verified in a real terminal: `100%\r\x1b[KOK` shows `OK`. Every spinner and
  221. // progress bar writes `\r\x1b[K`; without the erase the previous frame's tail
  222. // stands and the card shows text the terminal never displayed.
  223. expect(onlySpan(`100%\r${ESC}[KOK`)).toEqual({ text: 'OK', style: undefined })
  224. // The parameterless form and `0` are the same erase.
  225. expect(onlySpan(`100%\r${ESC}[0KOK`)).toEqual({ text: 'OK', style: undefined })
  226. })
  227. it('erases the whole line for the 2K form and to the cursor for 1K', () => {
  228. expect(onlySpan(`ab\r${ESC}[2Kxy`)).toEqual({ text: 'xy', style: undefined })
  229. // 1K clears left of the cursor without moving it, so those columns read as
  230. // blanks — verified in a real terminal, which shows ` |` for this input.
  231. expect(onlySpan(`abcd${ESC}[1K|`)).toEqual({ text: ' |', style: undefined })
  232. })
  233. it('paints columns a 2K dropped as blanks when a later write lands past them', () => {
  234. // 2K clears the line but leaves the cursor where it was, so writing there
  235. // leaves the columns before it unwritten — blanks, as a terminal shows.
  236. expect(onlySpan(`abcd${ESC}[2Kx`)).toEqual({ text: ' x', style: undefined })
  237. })
  238. it('advances a redraw cursor by tab stops, leaving a tabbed column standing', () => {
  239. // Verified in a real terminal: `a\tb\rXY` shows `XY b` — the `b` sits at
  240. // column 8, which a two-character redraw cannot reach. Counting the tab as
  241. // one column would have produced `XYb` and destroyed the alignment.
  242. expect(onlySpan('a\tb\rXY')).toEqual({ text: 'XY b', style: undefined })
  243. })
  244. it('counts a wide character as the two columns a terminal advances', () => {
  245. // `中` occupies two cells, so a two-character redraw covers exactly it.
  246. expect(onlySpan('中x\rab')).toEqual({ text: 'abx', style: undefined })
  247. })
  248. it('does not accumulate a cursor or erase sequence into a cell style', () => {
  249. // Only SGR carries graphic state. An erase folded into the style string
  250. // would grow it per redraw and emit boundaries anser has to discard.
  251. expect(parseAnsiLines(`${ESC}[31ma\r${ESC}[Kb`)).toEqual([[
  252. { text: 'b', style: { color: 'var(--dsw-alias-state-error-primary)' } },
  253. ]])
  254. })
  255. })
  256. describe('parseAnsiLines: line-end state and column widths', () => {
  257. it('closes a run whose reset lands after the last written cell', () => {
  258. // Verified in a real terminal: `\x1b[32mdone\rok\x1b[0m` then `plain` shows
  259. // `okne` GREEN and `plain` in the DEFAULT color. The reset changes no cell,
  260. // so returning the last cell's state leaked green onto every later line —
  261. // and this exact shape (`\r\x1b[K\x1b[32m✓ built\x1b[0m`) is what every
  262. // build tool writes.
  263. expect(parseAnsiLines(`${ESC}[32mdone\rok${ESC}[0m\nplain`)).toEqual([
  264. [{ text: 'okne', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
  265. [{ text: 'plain', style: undefined }],
  266. ])
  267. })
  268. it('erases through the cursor column for 1K, not up to it', () => {
  269. // Verified in a real terminal: `abcd\b\x1b[1K|` shows ` |` — the `d` under
  270. // the cursor is erased too, which the CSI spec calls inclusive.
  271. expect(onlySpan(`abcd${BS}${ESC}[1K|`)).toEqual({ text: ' |', style: undefined })
  272. })
  273. it('gives a combining mark no column of its own', () => {
  274. // Verified in a real terminal: `é` (e + U+0301) then `x`, redrawn with `YZ`,
  275. // shows `YZ`. Counting the mark as a column left the `x` standing.
  276. expect(onlySpan('e\u0301x\rYZ')).toEqual({ text: 'YZ', style: undefined })
  277. })
  278. it('drops a combining mark left with no cell to attach to by a redraw', () => {
  279. // Verified in a real terminal: `ab` then CR then U+0301 then `x` shows `xb`.
  280. // The redraw puts the cursor at column 0, so the mark has no preceding cell
  281. // and the terminal shows nothing for it rather than a lone accent.
  282. expect(onlySpan(`ab\r${ACCENT}x`)).toEqual({ text: 'xb', style: undefined })
  283. // A mark with no movement on its line never reaches the replay at all: it
  284. // is width business, not a cursor move, so it stays as authored.
  285. expect(onlySpan(`${ACCENT}abc`)).toEqual({ text: `${ACCENT}abc`, style: undefined })
  286. })
  287. it('carries a colour opened after the last write onto the next line', () => {
  288. // The mirror of the reset case, verified in a real terminal: `ab` CR `X` then
  289. // `\x1b[31m` with nothing after it shows `Xb` UNSTYLED and the next line red.
  290. // The scan ends styled while the last cell is not, so the convergence has to
  291. // open the run at the line end for it to reach the following line.
  292. expect(parseAnsiLines(`ab\rX${ESC}[31m\nnext`)).toEqual([
  293. [{ text: 'Xb', style: undefined }],
  294. [{ text: 'next', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
  295. ])
  296. })
  297. it('blanks a wide character\'s spacer once its lead cell is overwritten', () => {
  298. // Verified in a real terminal: `中x` redrawn with `A` shows `A x` — the wide
  299. // glyph's second cell becomes a blank rather than closing the gap, so the
  300. // `x` keeps column 3.
  301. expect(onlySpan('中x\rA')).toEqual({ text: 'A x', style: undefined })
  302. // Covering both of its columns leaves no spacer behind.
  303. expect(onlySpan('中x\rab')).toEqual({ text: 'abx', style: undefined })
  304. })
  305. it('replays an erase whose parameters carry a semicolon', () => {
  306. // The replay guard has to match the same CSI shape the parser accepts, or a
  307. // form like `\x1b[1;2K` skips the replay and its erase never happens.
  308. expect(onlySpan(`abcd${ESC}[1;2K|`)).toEqual({ text: ' |', style: undefined })
  309. })
  310. })
  311. describe('parseAnsiLines: bounded state and true widths', () => {
  312. it('emits one canonical sequence per boundary however the state was reached', () => {
  313. // Colors that never fully reset used to accumulate raw sequence history per
  314. // cell, so every boundary re-emitted the whole chain: 3200 such cells
  315. // produced 25 MB and eventually a RangeError. The state is normalized now,
  316. // so the emitted text stays linear in the number of cells.
  317. let input = ''
  318. for (let index = 0; index < 2000; index += 1) input += `${ESC}[3${index % 6 + 1}mx`
  319. const emitted = parseAnsiLines(`${input}\rz`)[0] ?? []
  320. expect(emitted.reduce((total, span) => total + span.text.length, 0)).toBe(2000)
  321. })
  322. it('closes an attribute with its closer instead of appending to the state', () => {
  323. // `1` then `22` is bold then not-bold, which every chalk-based tool writes;
  324. // appending both left the cell bold and grew the chain.
  325. // Verified in a real terminal: the `22` closes the bold, so the `x` written
  326. // after the redraw is PLAIN. Appending both left it bold and grew the chain.
  327. expect(parseAnsiLines(`${ESC}[1mbold${ESC}[22mplain\r${ESC}[Kx`)).toEqual([[
  328. { text: 'x', style: undefined },
  329. ]])
  330. expect(parseAnsiLines(`${ESC}[1mA${ESC}[22mB`)).toEqual([[
  331. { text: 'A', style: { fontWeight: 700 } },
  332. { text: 'B', style: undefined },
  333. ]])
  334. })
  335. it('folds extended colors, backgrounds and every attribute closer', () => {
  336. // The 256-palette and truecolor forms consume their own arguments, so the
  337. // fold has to take them whole rather than as separate codes.
  338. expect(parseAnsiLines(`${ESC}[38;5;208mA\r${ESC}[KB`)).toEqual([[
  339. { text: 'B', style: { color: 'rgb(255, 135, 0)' } },
  340. ]])
  341. expect(parseAnsiLines(`${ESC}[38;2;10;20;30mA\r${ESC}[KB`)).toEqual([[
  342. { text: 'B', style: { color: 'rgb(10, 20, 30)' } },
  343. ]])
  344. // A background survives the same way, and `49` closes it.
  345. expect(parseAnsiLines(`${ESC}[41mA${ESC}[49mB\r${ESC}[KC`)).toEqual([[
  346. { text: 'C', style: undefined },
  347. ]])
  348. // Each closer drops only its own attribute: `4` underline closed by `24`
  349. // while the italic opened before it stays in force.
  350. expect(parseAnsiLines(`${ESC}[3;4mA${ESC}[24mB\r${ESC}[KC`)).toEqual([[
  351. { text: 'C', style: { fontStyle: 'italic' } },
  352. ]])
  353. // `39` closes a foreground without touching the background.
  354. expect(parseAnsiLines(`${ESC}[31;42mA${ESC}[39mB\r${ESC}[KC`)).toEqual([[
  355. { text: 'C', style: { backgroundColor: 'rgb(0, 187, 0)' } },
  356. ]])
  357. })
  358. it('folds the remaining SGR shapes the model has to carry', () => {
  359. // A 48-background in extended form, so the `48` arm and the `2`-span both run.
  360. expect(parseAnsiLines(`${ESC}[48;2;1;2;3mA\r${ESC}[KB`)).toEqual([[
  361. { text: 'B', style: { backgroundColor: 'rgb(1, 2, 3)' } },
  362. ]])
  363. // A bright foreground and a bright background, the 90-97 / 100-107 arms.
  364. expect(parseAnsiLines(`${ESC}[91mA\r${ESC}[KB`)).toEqual([[
  365. { text: 'B', style: { color: 'var(--dsw-alias-state-error-secondary)' } },
  366. ]])
  367. expect(parseAnsiLines(`${ESC}[101mA\r${ESC}[KB`)).toEqual([[
  368. { text: 'B', style: { backgroundColor: 'rgb(255, 85, 85)' } },
  369. ]])
  370. // An extended form with no recognized kind byte consumes nothing extra.
  371. expect(parseAnsiLines(`${ESC}[38mA\r${ESC}[KB`)).toEqual([[{ text: 'B', style: undefined }]])
  372. // Re-opening an attribute already in force does not duplicate it, and a bare
  373. // `\x1b[m` resets exactly as `\x1b[0m` does.
  374. expect(parseAnsiLines(`${ESC}[1m${ESC}[1mA${ESC}[mB\r${ESC}[KC`)).toEqual([[
  375. { text: 'C', style: undefined },
  376. ]])
  377. })
  378. it('treats a text-presentation symbol as one column', () => {
  379. // Verified in a real terminal: `A✓B` redrawn with `XY` shows `XYB`, so the
  380. // check mark is ONE column. Taking the whole U+2600-U+27BF block as wide
  381. // misaligned exactly the progress output this card exists to show.
  382. expect(onlySpan('A\u2713B\rXY')).toEqual({ text: 'XYB', style: undefined })
  383. // An emoji-presentation character is two, so the same redraw leaves a blank.
  384. expect(onlySpan('A\u{1f600}B\rXY')).toEqual({ text: 'XY B', style: undefined })
  385. })
  386. it('clears a wide pair from either side, including through an erase', () => {
  387. // Verified in a real terminal (`A x`): the redraw puts the cursor at column
  388. // 0, the backspace clamps there, and writing `A` over the wide lead blanks
  389. // its spacer rather than letting the `x` slide left.
  390. expect(onlySpan(`\u4e2dx\r${BS}A`)).toEqual({ text: 'A x', style: undefined })
  391. // An erase reaching the lead blanks its spacer through the same helper.
  392. // Verified in a real terminal (` |`): 1K blanks through the cursor column,
  393. // so the wide glyph's two cells and the `x` all become blanks.
  394. expect(onlySpan(`\u4e2dx${ESC}[1K|`)).toEqual({ text: ' |', style: undefined })
  395. })
  396. it('clears the lead when the write lands on the spacer itself', () => {
  397. // Two backspaces from after `中x` stop ON the wide glyph's second cell;
  398. // writing there blanks the lead through the spacer side of the pair clear,
  399. // so the glyph cannot survive as half a character.
  400. expect(onlySpan(`中x${BS}${BS}A`)).toEqual({ text: ' Ax', style: undefined })
  401. })
  402. it('keeps a surviving spacer as a blank when its lead was replaced by a spacer', () => {
  403. // `好` written over the first glyph's spacer puts its own spacer on the
  404. // second glyph's lead cell — a write that goes down without a pair clear.
  405. // The second glyph's spacer survives with a dead lead and must emit a
  406. // blank, or everything after it shifts one column left.
  407. expect(onlySpan(`中中${BS}${BS}${BS}好`)).toEqual({ text: ' 好 ', style: undefined })
  408. })
  409. it('blanks both halves of a wide pair when either is overwritten', () => {
  410. // A terminal cannot leave one cell of a two-cell glyph standing, so writing
  411. // over the spacer clears the lead as well.
  412. // Verified in a real terminal: two wide chars, CR, then `A` shows `A ` and
  413. // the second glyph — writing the lead cell blanks its spacer, so the column
  414. // stays occupied rather than collapsing.
  415. expect(onlySpan('\u4e2d\u4e2d\rA')).toEqual({ text: 'A \u4e2d', style: undefined })
  416. })
  417. })
  418. describe('parseAnsiLines: SGR across lines', () => {
  419. it('carries active state past a newline, as a terminal does', () => {
  420. // Verified in a real terminal: `\x1b[31mabc\rX\nnext` paints BOTH lines red.
  421. // A newline does not reset the graphic state, so a replayed line must hand
  422. // its state to the next one instead of closing it off.
  423. expect(parseAnsiLines(`${ESC}[31mabc\rX\nnext`)).toEqual([
  424. [{ text: 'Xbc', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
  425. [{ text: 'next', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
  426. ])
  427. })
  428. it('tracks state through a line that needs no replay', () => {
  429. // The middle line has no movement, so it is not replayed — but its own SGR
  430. // still has to reach the line after it.
  431. expect(parseAnsiLines(`a\r${ESC}[32mb\nplain\nc`)).toEqual([
  432. [{ text: 'b', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
  433. [{ text: 'plain', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
  434. [{ text: 'c', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
  435. ])
  436. })
  437. })
  438. describe('parseAnsiLines: runs spanning lines', () => {
  439. it('carries one run\'s style onto every line it covers', () => {
  440. expect(parseAnsiLines(sgr('32', 'first\nsecond'))).toEqual([
  441. [{ text: 'first', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
  442. [{ text: 'second', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
  443. ])
  444. })
  445. it('keeps several runs of one line in order', () => {
  446. expect(parseAnsiLines(`plain${sgr('31', 'red')}tail`)).toEqual([[
  447. { text: 'plain', style: undefined },
  448. { text: 'red', style: { color: 'var(--dsw-alias-state-error-primary)' } },
  449. { text: 'tail', style: undefined },
  450. ]])
  451. })
  452. })