qwen-code/packages/web-shell/client/utils/ansi.test.ts
chinesepowered f43a2e46c8
fix(web-shell): parse 256-color and truecolor SGR sequences in parseAnsi (#7620)
* fix(web-shell): parse 256-color and truecolor SGR sequences in parseAnsi

parseAnsi read every ';'-separated SGR parameter as a standalone code,
but the arguments of 38/48/58 (extended foreground/background/underline
color) are not codes. `38;5;<n>` and `38;2;<r>;<g>;<b>` were fed back
into the code loop, so:

- `38;5;2` set dim (from color index 2) and produced no color;
- any truecolor value with a zero channel hit the `code === 0` branch
  and wiped the color, bold and dim already set on the line;
- a background such as `48;5;22` fed 22 to the reset-intensity branch,
  silently un-bolding the text.

Consume the 38/48/58 arguments instead of reading them as codes, and
resolve the foreground to hex: 0-15 map onto the existing themed palette,
16-231 onto the 6x6x6 cube, 232-255 onto the grayscale ramp. Background
and underline color are parsed but not rendered (Segment has no such
field) so their arguments still cannot leak into the code stream.

parseAnsi feeds shell tool-output rendering in ToolGroup, so this
affects any 256-color CLI. Adds ansi.test.ts (none existed); the four
new cases fail on the previous parser.

* fix(web-shell): keep the current color when an extended-color sequence is malformed

An unreadable 38/48/58 sequence was assigning its undefined result straight
to the color, so `\x1b[31m\x1b[38;5;300m` dropped the red that code 31 had
already set. Ignore the sequence instead and leave the color as-is; a
well-formed one still replaces it.

Also widen toHex/xterm256 to accept `number | undefined` so the truncated
arguments they are actually handed match their declared types, and drop the
non-null assertions that were hiding that mismatch from the compiler.

Add the regression case the existing malformed-sequence test could not catch:
it started from no color, so a cleared color was invisible to it.

* test(web-shell): pin the 58 branch and the truecolor channel guard

The 58;5;2 case asserted only bold, so dropping 58 from the extended-color
trio left the test green: the leaked 2 argument turns on dim, not bold.
Assert the whole segment instead.

No case used an out-of-range truecolor channel either, so toHex's 0-255
guard was unpinned. Add 38;2;999;0;0 to both malformed-sequence loops.

Both gaps found by wenshao's mutation run in review.
2026-07-26 02:25:20 +00:00

137 lines
5.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest';
import { hasAnsi, parseAnsi } from './ansi';
const ESC = '\x1b[';
describe('parseAnsi', () => {
it('applies basic and bright colors, bold and dim', () => {
expect(parseAnsi(`${ESC}32mok`)).toEqual([
{ text: 'ok', color: '#48bb78', bold: false, dim: false },
]);
expect(parseAnsi(`${ESC}1;91mhot`)).toEqual([
{ text: 'hot', color: '#feb2b2', bold: true, dim: false },
]);
expect(parseAnsi(`${ESC}2mfaint`)).toEqual([
{ text: 'faint', color: undefined, bold: false, dim: true },
]);
});
it('resets state on 0, 22 and 39', () => {
expect(parseAnsi(`${ESC}1;31ma${ESC}0mb`)).toEqual([
{ text: 'a', color: '#fc8181', bold: true, dim: false },
{ text: 'b', color: undefined, bold: false, dim: false },
]);
expect(parseAnsi(`${ESC}1;2;31ma${ESC}22mb`)).toEqual([
{ text: 'a', color: '#fc8181', bold: true, dim: true },
{ text: 'b', color: '#fc8181', bold: false, dim: false },
]);
expect(parseAnsi(`${ESC}31ma${ESC}39mb`)[1]!.color).toBeUndefined();
});
// The arguments of 38/48/58 are not SGR codes. Reading them as codes is what
// made `38;5;2` set dim (from the color index) instead of a color.
it('does not read 256-color arguments as SGR codes', () => {
expect(parseAnsi(`${ESC}38;5;2mgreen`)).toEqual([
{ text: 'green', color: '#48bb78', bold: false, dim: false },
]);
expect(parseAnsi(`${ESC}38;5;1mred`)[0]!.color).toBe('#fc8181');
// Bright half of the standard range maps onto the 90-97 palette.
expect(parseAnsi(`${ESC}38;5;9mbright`)[0]!.color).toBe('#feb2b2');
// 6x6x6 cube: 208 -> (5, 2, 0) -> #ff8700.
expect(parseAnsi(`${ESC}38;5;208morange`)[0]!.color).toBe('#ff8700');
// Cube corners.
expect(parseAnsi(`${ESC}38;5;16ma`)[0]!.color).toBe('#000000');
expect(parseAnsi(`${ESC}38;5;231ma`)[0]!.color).toBe('#ffffff');
// Grayscale ramp: 232 -> 8, 255 -> 238.
expect(parseAnsi(`${ESC}38;5;232ma`)[0]!.color).toBe('#080808');
expect(parseAnsi(`${ESC}38;5;255ma`)[0]!.color).toBe('#eeeeee');
});
it('does not let truecolor channels reset the style', () => {
expect(parseAnsi(`${ESC}38;2;255;0;0mred`)).toEqual([
{ text: 'red', color: '#ff0000', bold: false, dim: false },
]);
// A zero channel used to hit the `code === 0` reset, clearing the bold
// that was already set; two more channels are 128 and 255, neither of
// which is an SGR code at all.
expect(parseAnsi(`${ESC}1m${ESC}38;2;0;128;255mblue`)).toEqual([
{ text: 'blue', color: '#0080ff', bold: true, dim: false },
]);
});
it('keeps background and underline color out of the code stream', () => {
// 48;5;22 used to feed `22` to the reset-intensity branch, so setting a
// background silently un-bolded the text.
expect(parseAnsi(`${ESC}1m${ESC}48;5;22mtext`)).toEqual([
{ text: 'text', color: undefined, bold: true, dim: false },
]);
// Foreground survives a background change on the same sequence, and the
// trailing 1 is still read as bold once the 48 arguments are consumed.
expect(parseAnsi(`${ESC}31;48;2;0;0;0;1mtext`)).toEqual([
{ text: 'text', color: '#fc8181', bold: true, dim: false },
]);
// Asserting the whole segment, not just `bold`: dropping 58 from the trio
// leaks its `2` argument into the dim branch, which a bold-only assertion
// cannot see.
expect(parseAnsi(`${ESC}1m${ESC}58;5;2mtext`)).toEqual([
{ text: 'text', color: undefined, bold: true, dim: false },
]);
});
it('drops malformed extended-color sequences without corrupting state', () => {
// Out-of-range index and truncated argument lists yield no color rather
// than a bogus one, and never fall through to the plain-code branches.
// `38;2;999;0;0` is the truecolor equivalent: a channel outside 0255.
for (const seq of [
'38;5;300',
'38;5',
'38;2;1;2',
'38;7;1',
'38',
'38;2;999;0;0',
]) {
expect(parseAnsi(`${ESC}1m${ESC}${seq}mtext`)).toEqual([
{ text: 'text', color: undefined, bold: true, dim: false },
]);
}
});
it('leaves an already-set color alone when the sequence is malformed', () => {
// An unreadable sequence is ignored, not treated as a reset: the red from
// code 31 has to survive it.
for (const seq of [
'38;5;300',
'38;5',
'38;2;1;2',
'38;7;1',
'38',
'38;2;999;0;0',
]) {
expect(parseAnsi(`${ESC}31m${ESC}${seq}mtext`)).toEqual([
{ text: 'text', color: '#fc8181', bold: false, dim: false },
]);
}
// A well-formed sequence still replaces it.
expect(parseAnsi(`${ESC}31m${ESC}38;5;21mtext`)[0]!.color).toBe('#0000ff');
});
it('splits text around sequences and keeps the trailing run', () => {
expect(parseAnsi(`plain${ESC}1mbold${ESC}0mtail`)).toEqual([
{ text: 'plain', color: undefined, bold: false, dim: false },
{ text: 'bold', color: undefined, bold: true, dim: false },
{ text: 'tail', color: undefined, bold: false, dim: false },
]);
expect(parseAnsi('no escapes')).toEqual([
{ text: 'no escapes', color: undefined, bold: false, dim: false },
]);
// `ESC[m` is shorthand for `ESC[0m`.
expect(parseAnsi(`${ESC}1ma${ESC}mb`)[1]!.bold).toBe(false);
});
});
describe('hasAnsi', () => {
it('detects a CSI introducer', () => {
expect(hasAnsi(`${ESC}0mx`)).toBe(true);
expect(hasAnsi('plain text')).toBe(false);
});
});