Files
deepseek-harness/packages/client/ui-primitives/tests/ansi.spec.ts
Chinesezjc 1c8188ce2b Merge remote-tracking branch 'origin/master' into feat/web-terminal-card
# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
2026-07-28 21:03:26 +08:00

214 lines
8.0 KiB
TypeScript

// parseAnsiLines, the ANSI model behind TerminalBlock: anser's SGR runs
// resolved into inline styles and folded into per-line span arrays, with every
// escape and control character that carries no color removed first. The DOM
// side of the same model (which runs get a span wrapper) is in
// terminal-block.spec.tsx.
import { describe, expect, it } from 'vitest'
import { parseAnsiLines } from '../src/ansi.ts'
const ESC = '\u001b'
const BS = '\u0008'
/** Paint `text` with the SGR `codes`, then reset. */
function sgr(codes: string, text: string): string {
return `${ESC}[${codes}m${text}${ESC}[0m`
}
/** The single span of a single-line, single-run parse. */
function onlySpan(text: string) {
const lines = parseAnsiLines(text)
expect(lines).toHaveLength(1)
expect(lines[0]).toHaveLength(1)
return lines[0]![0]!
}
describe('parseAnsiLines: text without SGR state', () => {
it('leaves plain text as one unstyled span', () => {
expect(parseAnsiLines('hello')).toEqual([[{ text: 'hello', style: undefined }]])
})
it('returns exactly one empty line for empty input', () => {
expect(parseAnsiLines('')).toEqual([[]])
})
it('splits a multi-line run and drops the empty line between two blocks', () => {
expect(parseAnsiLines('a\n\nb')).toEqual([
[{ text: 'a', style: undefined }],
[],
[{ text: 'b', style: undefined }],
])
})
it('keeps tabs, which the terminal surface needs for column layout', () => {
expect(onlySpan('a\tb')).toEqual({ text: 'a\tb', style: undefined })
})
})
describe('parseAnsiLines: basic colors mapped onto theme tokens', () => {
it.each<[string, string, string]>([
['30', 'black', 'var(--dsw-alias-label-primary)'],
['37', 'white', 'var(--dsw-alias-label-primary)'],
['90', 'bright black', 'var(--dsw-alias-label-tertiary)'],
['31', 'red', 'var(--dsw-alias-state-error-primary)'],
['91', 'bright red', 'var(--dsw-alias-state-error-secondary)'],
['32', 'green', 'var(--dsw-alias-state-success-primary)'],
['92', 'bright green', 'var(--dsw-alias-state-success-secondary)'],
['33', 'yellow', 'var(--dsw-alias-state-warn-primary)'],
['93', 'bright yellow', 'var(--dsw-alias-state-warn-secondary)'],
['34', 'blue', 'var(--dsw-alias-state-business-primary)'],
['94', 'bright blue', 'var(--dsw-static-blue-400)'],
])('SGR %s (%s) resolves to %s', (code, _name, token) => {
expect(onlySpan(sgr(code, 'x'))).toEqual({ text: 'x', style: { color: token } })
})
})
describe('parseAnsiLines: colors with no token equivalent', () => {
it.each<[string, string, string]>([
['35', 'magenta', 'rgb(187, 0, 187)'],
['36', 'cyan', 'rgb(0, 187, 187)'],
['38;5;208', '256-palette orange', 'rgb(255, 135, 0)'],
['38;2;10;20;30', 'truecolor', 'rgb(10, 20, 30)'],
])('SGR %s (%s) falls through to %s', (code, _name, literal) => {
expect(onlySpan(sgr(code, 'x')).style).toEqual({ color: literal })
})
})
describe('parseAnsiLines: backgrounds', () => {
it('sets backgroundColor for a background-only run', () => {
expect(onlySpan(sgr('44', 'x')).style).toEqual({ backgroundColor: 'rgb(0, 0, 187)' })
})
it('keeps the literal foreground when the run paints its own background', () => {
expect(onlySpan(sgr('41;37', 'x')).style).toEqual({
backgroundColor: 'rgb(187, 0, 0)',
color: 'rgb(255,255,255)',
})
})
it('renders reverse video as the swapped pair anser reports', () => {
expect(onlySpan(sgr('31;7', 'x')).style).toEqual({
backgroundColor: 'rgb(187, 0, 0)',
color: 'rgb(0, 0, 0)',
})
})
})
describe('parseAnsiLines: decorations', () => {
it.each<[string, string, Record<string, unknown>]>([
['1', 'bold', { fontWeight: 700 }],
['2', 'dim', { opacity: 0.7 }],
['3', 'italic', { fontStyle: 'italic' }],
['4', 'underline', { textDecoration: 'underline' }],
['9', 'strikethrough', { textDecoration: 'line-through' }],
['8', 'hidden', { visibility: 'hidden' }],
])('SGR %s (%s) resolves to %o', (code, _name, style) => {
expect(onlySpan(sgr(code, 'x')).style).toEqual(style)
})
it('lets the later textDecoration win when a run declares underline and strikethrough', () => {
expect(onlySpan(sgr('4;9', 'x')).style).toEqual({ textDecoration: 'line-through' })
expect(onlySpan(sgr('9;4', 'x')).style).toEqual({ textDecoration: 'underline' })
})
it('combines a color with several decorations in one style', () => {
expect(onlySpan(sgr('1;3;31', 'x')).style).toEqual({
color: 'var(--dsw-alias-state-error-primary)',
fontWeight: 700,
fontStyle: 'italic',
})
})
it('reproduces no animation for blink, leaving the run unstyled', () => {
expect(onlySpan(sgr('5', 'x'))).toEqual({ text: 'x', style: undefined })
})
})
describe('parseAnsiLines: sequences that carry no color', () => {
it('removes an OSC string with its BEL terminator', () => {
expect(onlySpan(`a${ESC}]0;window title\u0007b`)).toEqual({ text: 'ab', style: undefined })
})
it('removes an OSC string terminated by ST', () => {
expect(onlySpan(`a${ESC}]8;;https://example.com${ESC}\\b`)).toEqual({ text: 'ab', style: undefined })
})
it('removes non-CSI escapes such as charset selection and reset', () => {
expect(onlySpan(`x${ESC}(By${ESC}cz`)).toEqual({ text: 'xyz', style: undefined })
})
it('removes inert C0 controls', () => {
expect(onlySpan('\u0000ab\u001fc\u007f')).toEqual({ text: 'abc', style: undefined })
})
it('keeps CSI sequences that only move the cursor out of the text', () => {
expect(onlySpan(`${ESC}[2K${ESC}[1Adone`)).toEqual({ text: 'done', style: undefined })
})
})
describe('parseAnsiLines: carriage returns', () => {
it('keeps only the last redraw of a line', () => {
expect(onlySpan('10%\r55%\r100%')).toEqual({ text: '100%', style: undefined })
})
it('drops the SGR codes that preceded a discarded redraw', () => {
expect(onlySpan(`${ESC}[31mgone\rkept`)).toEqual({ text: 'kept', style: undefined })
})
it('preserves both lines of a CRLF pair instead of treating it as a redraw', () => {
expect(parseAnsiLines('a\r\r\nb\r\n')).toEqual([
[{ text: 'a', style: undefined }],
[{ text: 'b', style: undefined }],
[],
])
})
it('applies the redraw per line, not across the whole text', () => {
expect(parseAnsiLines('one\rtwo\nthree')).toEqual([
[{ text: 'two', style: undefined }],
[{ text: 'three', style: undefined }],
])
})
})
describe('parseAnsiLines: backspaces', () => {
it('applies a backspace as the overwrite a terminal draws', () => {
// `abc` then two backspaces then `XY` shows as `aXY`, not `abcXY`.
expect(onlySpan(`abc${BS}${BS}XY`)).toEqual({ text: 'aXY', style: undefined })
})
it('stops at the line start instead of eating the newline before it', () => {
expect(parseAnsiLines(`ab\n${BS}${BS}${BS}cd`)).toEqual([
[{ text: 'ab', style: undefined }],
[{ text: 'cd', style: undefined }],
])
})
it('applies the overwrite after a carriage-return redraw, not before', () => {
// The redraw wins first; the backspace then erases inside what survived.
expect(onlySpan(`old\rnew${BS}`)).toEqual({ text: 'ne', style: undefined })
})
it('keeps the run\'s style while erasing its own characters', () => {
expect(onlySpan(sgr('31', `bad${BS}${BS}${BS}ok`)))
.toEqual({ text: 'ok', style: { color: 'var(--dsw-alias-state-error-primary)' } })
})
})
describe('parseAnsiLines: runs spanning lines', () => {
it('carries one run\'s style onto every line it covers', () => {
expect(parseAnsiLines(sgr('32', 'first\nsecond'))).toEqual([
[{ text: 'first', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
[{ text: 'second', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
])
})
it('keeps several runs of one line in order', () => {
expect(parseAnsiLines(`plain${sgr('31', 'red')}tail`)).toEqual([[
{ text: 'plain', style: undefined },
{ text: 'red', style: { color: 'var(--dsw-alias-state-error-primary)' } },
{ text: 'tail', style: undefined },
]])
})
})