484 lines
15 KiB
TypeScript
484 lines
15 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { ansiToHtml, trimLineEndPreserveAnsi } from "./ansi";
|
|
|
|
// 1. XSS/HTML escaping tests
|
|
|
|
test("escapes < to <", () => {
|
|
const result = ansiToHtml("<div>");
|
|
expect(result).toBe("<div>");
|
|
});
|
|
|
|
test("escapes > to >", () => {
|
|
const result = ansiToHtml("a > b");
|
|
expect(result).toBe("a > b");
|
|
});
|
|
|
|
test("escapes & to &", () => {
|
|
const result = ansiToHtml("foo & bar");
|
|
expect(result).toBe("foo & bar");
|
|
});
|
|
|
|
test("escapes <script> tags", () => {
|
|
const result = ansiToHtml("<script>alert('xss')</script>");
|
|
expect(result).toBe("<script>alert('xss')</script>");
|
|
});
|
|
|
|
test("escapes HTML in styled text", () => {
|
|
const result = ansiToHtml("\x1b[31m<script>alert(1)</script>\x1b[0m");
|
|
expect(result).toContain("<script>");
|
|
expect(result).toContain("</script>");
|
|
expect(result).not.toContain("<script>");
|
|
});
|
|
|
|
// 2. Basic 16 colors tests
|
|
|
|
test("red foreground (31)", () => {
|
|
const result = ansiToHtml("\x1b[31mred\x1b[0m");
|
|
expect(result).toBe('<span style="color:#f85149">red</span>');
|
|
});
|
|
|
|
test("green foreground (32)", () => {
|
|
const result = ansiToHtml("\x1b[32mgreen\x1b[0m");
|
|
expect(result).toBe('<span style="color:#3fb950">green</span>');
|
|
});
|
|
|
|
test("blue foreground (34)", () => {
|
|
const result = ansiToHtml("\x1b[34mblue\x1b[0m");
|
|
expect(result).toBe('<span style="color:#58a6ff">blue</span>');
|
|
});
|
|
|
|
test("yellow foreground (33)", () => {
|
|
const result = ansiToHtml("\x1b[33myellow\x1b[0m");
|
|
expect(result).toBe('<span style="color:#d29922">yellow</span>');
|
|
});
|
|
|
|
test("bright red foreground (91)", () => {
|
|
const result = ansiToHtml("\x1b[91mbright red\x1b[0m");
|
|
expect(result).toBe('<span style="color:#ff7b72">bright red</span>');
|
|
});
|
|
|
|
test("bright green foreground (92)", () => {
|
|
const result = ansiToHtml("\x1b[92mbright green\x1b[0m");
|
|
expect(result).toBe('<span style="color:#7ee787">bright green</span>');
|
|
});
|
|
|
|
test("red background (41)", () => {
|
|
const result = ansiToHtml("\x1b[41mred bg\x1b[0m");
|
|
expect(result).toBe('<span style="background-color:#f85149">red bg</span>');
|
|
});
|
|
|
|
test("green background (42)", () => {
|
|
const result = ansiToHtml("\x1b[42mgreen bg\x1b[0m");
|
|
expect(result).toBe('<span style="background-color:#3fb950">green bg</span>');
|
|
});
|
|
|
|
test("bright blue background (104)", () => {
|
|
const result = ansiToHtml("\x1b[104mbright blue bg\x1b[0m");
|
|
expect(result).toBe(
|
|
'<span style="background-color:#79c0ff">bright blue bg</span>',
|
|
);
|
|
});
|
|
|
|
test("foreground and background together", () => {
|
|
const result = ansiToHtml("\x1b[31;42mred on green\x1b[0m");
|
|
expect(result).toContain("color:#f85149");
|
|
expect(result).toContain("background-color:#3fb950");
|
|
});
|
|
|
|
// 3. 256-color palette tests
|
|
|
|
test("256-color basic colors (0-15): red", () => {
|
|
const result = ansiToHtml("\x1b[38;5;1mred\x1b[0m");
|
|
expect(result).toContain("color:#cd3131");
|
|
});
|
|
|
|
test("256-color basic colors (0-15): bright white", () => {
|
|
const result = ansiToHtml("\x1b[38;5;15mwhite\x1b[0m");
|
|
expect(result).toContain("color:#ffffff");
|
|
});
|
|
|
|
test("256-color 6x6x6 cube: first color (16)", () => {
|
|
const result = ansiToHtml("\x1b[38;5;16mcolor\x1b[0m");
|
|
expect(result).toContain("rgb(0,0,0)");
|
|
});
|
|
|
|
test("256-color 6x6x6 cube: mid-range color", () => {
|
|
// Color 196 = index 180 (196-16) in cube
|
|
// ri=5, gi=0, bi=0 -> r=255, g=0, b=0
|
|
const result = ansiToHtml("\x1b[38;5;196mred\x1b[0m");
|
|
expect(result).toContain("rgb(255,0,0)");
|
|
});
|
|
|
|
test("256-color grayscale ramp (232-255): dark gray", () => {
|
|
// Color 232: gray = (232-232)*10+8 = 8
|
|
const result = ansiToHtml("\x1b[38;5;232mgray\x1b[0m");
|
|
expect(result).toContain("rgb(8,8,8)");
|
|
});
|
|
|
|
test("256-color grayscale ramp (232-255): light gray", () => {
|
|
// Color 255: gray = (255-232)*10+8 = 238
|
|
const result = ansiToHtml("\x1b[38;5;255mgray\x1b[0m");
|
|
expect(result).toContain("rgb(238,238,238)");
|
|
});
|
|
|
|
test("256-color background", () => {
|
|
const result = ansiToHtml("\x1b[48;5;21mbg\x1b[0m");
|
|
expect(result).toContain("background-color");
|
|
});
|
|
|
|
// 4. 24-bit RGB tests
|
|
|
|
test("24-bit RGB foreground", () => {
|
|
const result = ansiToHtml("\x1b[38;2;255;128;64mrgb\x1b[0m");
|
|
expect(result).toBe('<span style="color:rgb(255,128,64)">rgb</span>');
|
|
});
|
|
|
|
test("24-bit RGB background", () => {
|
|
const result = ansiToHtml("\x1b[48;2;100;150;200mbg\x1b[0m");
|
|
expect(result).toBe(
|
|
'<span style="background-color:rgb(100,150,200)">bg</span>',
|
|
);
|
|
});
|
|
|
|
test("24-bit RGB foreground and background", () => {
|
|
const result = ansiToHtml("\x1b[38;2;255;0;0;48;2;0;255;0mtext\x1b[0m");
|
|
expect(result).toContain("color:rgb(255,0,0)");
|
|
expect(result).toContain("background-color:rgb(0,255,0)");
|
|
});
|
|
|
|
// 5. Text attributes tests
|
|
|
|
test("bold (1)", () => {
|
|
const result = ansiToHtml("\x1b[1mbold\x1b[0m");
|
|
expect(result).toBe('<span style="font-weight:bold">bold</span>');
|
|
});
|
|
|
|
test("dim (2)", () => {
|
|
const result = ansiToHtml("\x1b[2mdim\x1b[0m");
|
|
expect(result).toBe('<span style="opacity:0.5">dim</span>');
|
|
});
|
|
|
|
test("italic (3)", () => {
|
|
const result = ansiToHtml("\x1b[3mitalic\x1b[0m");
|
|
expect(result).toBe('<span style="font-style:italic">italic</span>');
|
|
});
|
|
|
|
test("underline (4)", () => {
|
|
const result = ansiToHtml("\x1b[4munderline\x1b[0m");
|
|
expect(result).toBe(
|
|
'<span style="text-decoration:underline">underline</span>',
|
|
);
|
|
});
|
|
|
|
test("inverse (7) swaps foreground and background", () => {
|
|
const result = ansiToHtml("\x1b[7minverse\x1b[0m");
|
|
expect(result).toContain("color:#ffffff");
|
|
expect(result).toContain("background-color:#0d1117");
|
|
});
|
|
|
|
test("inverse with colors swaps them", () => {
|
|
const result = ansiToHtml("\x1b[31;42;7minverse\x1b[0m");
|
|
// Original: fg=#f85149, bg=#3fb950
|
|
// Swapped: fg=#3fb950, bg=#f85149
|
|
expect(result).toContain("color:#3fb950");
|
|
expect(result).toContain("background-color:#f85149");
|
|
});
|
|
|
|
test("multiple attributes combined", () => {
|
|
const result = ansiToHtml("\x1b[1;3;4mtext\x1b[0m");
|
|
expect(result).toContain("font-weight:bold");
|
|
expect(result).toContain("font-style:italic");
|
|
expect(result).toContain("text-decoration:underline");
|
|
});
|
|
|
|
test("bold and color", () => {
|
|
const result = ansiToHtml("\x1b[1;31mbold red\x1b[0m");
|
|
expect(result).toContain("font-weight:bold");
|
|
expect(result).toContain("color:#f85149");
|
|
});
|
|
|
|
// 6. Reset codes tests
|
|
|
|
test("reset (0) clears all styles", () => {
|
|
const result = ansiToHtml("\x1b[1;31;42mbold red on green\x1b[0mplain");
|
|
expect(result).toBe(
|
|
'<span style="color:#f85149;background-color:#3fb950;font-weight:bold">bold red on green</span>plain',
|
|
);
|
|
});
|
|
|
|
test("reset bold/dim (22)", () => {
|
|
const result = ansiToHtml("\x1b[1mbold\x1b[22mnormal\x1b[0m");
|
|
expect(result).toBe('<span style="font-weight:bold">bold</span>normal');
|
|
});
|
|
|
|
test("reset italic (23)", () => {
|
|
const result = ansiToHtml("\x1b[3mitalic\x1b[23mnormal\x1b[0m");
|
|
expect(result).toBe('<span style="font-style:italic">italic</span>normal');
|
|
});
|
|
|
|
test("reset underline (24)", () => {
|
|
const result = ansiToHtml("\x1b[4munderline\x1b[24mnormal\x1b[0m");
|
|
expect(result).toBe(
|
|
'<span style="text-decoration:underline">underline</span>normal',
|
|
);
|
|
});
|
|
|
|
test("reset inverse (27)", () => {
|
|
const result = ansiToHtml("\x1b[7minverse\x1b[27mnormal\x1b[0m");
|
|
expect(result).toBe(
|
|
'<span style="color:#ffffff;background-color:#0d1117">inverse</span>normal',
|
|
);
|
|
});
|
|
|
|
test("reset foreground (39)", () => {
|
|
const result = ansiToHtml("\x1b[31mred\x1b[39mdefault\x1b[0m");
|
|
expect(result).toBe('<span style="color:#f85149">red</span>default');
|
|
});
|
|
|
|
test("reset background (49)", () => {
|
|
const result = ansiToHtml("\x1b[41mred bg\x1b[49mdefault\x1b[0m");
|
|
expect(result).toBe(
|
|
'<span style="background-color:#f85149">red bg</span>default',
|
|
);
|
|
});
|
|
|
|
// 7. Multiple codes in one sequence tests
|
|
|
|
test("bold and red in one sequence", () => {
|
|
const result = ansiToHtml("\x1b[1;31mbold red\x1b[0m");
|
|
expect(result).toContain("font-weight:bold");
|
|
expect(result).toContain("color:#f85149");
|
|
});
|
|
|
|
test("multiple attributes in one sequence", () => {
|
|
const result = ansiToHtml("\x1b[1;3;4;31;42mtext\x1b[0m");
|
|
expect(result).toContain("font-weight:bold");
|
|
expect(result).toContain("font-style:italic");
|
|
expect(result).toContain("text-decoration:underline");
|
|
expect(result).toContain("color:#f85149");
|
|
expect(result).toContain("background-color:#3fb950");
|
|
});
|
|
|
|
test("empty sequence defaults to reset", () => {
|
|
const result = ansiToHtml("\x1b[31mred\x1b[mdefault\x1b[0m");
|
|
expect(result).toBe('<span style="color:#f85149">red</span>default');
|
|
});
|
|
|
|
// 8. Newline handling tests
|
|
|
|
test("newline closes and reopens spans", () => {
|
|
const result = ansiToHtml("\x1b[31mred\nstill red\x1b[0m");
|
|
expect(result).toBe(
|
|
'<span style="color:#f85149">red</span>\n<span style="color:#f85149">still red</span>',
|
|
);
|
|
});
|
|
|
|
test("newline without styling", () => {
|
|
const result = ansiToHtml("line1\nline2");
|
|
expect(result).toBe("line1\nline2");
|
|
});
|
|
|
|
test("multiple newlines with styling", () => {
|
|
const result = ansiToHtml("\x1b[31mred\n\nstill red\x1b[0m");
|
|
expect(result).toBe(
|
|
'<span style="color:#f85149">red</span>\n<span style="color:#f85149"></span>\n<span style="color:#f85149">still red</span>',
|
|
);
|
|
});
|
|
|
|
test("carriage return is stripped", () => {
|
|
const result = ansiToHtml("line1\r\nline2");
|
|
expect(result).toBe("line1\nline2");
|
|
});
|
|
|
|
test("standalone carriage return is stripped", () => {
|
|
const result = ansiToHtml("text\rmore");
|
|
expect(result).toBe("textmore");
|
|
});
|
|
|
|
// 9. trimLineEndPreserveAnsi tests
|
|
|
|
test("trimLineEndPreserveAnsi trims trailing spaces", () => {
|
|
const result = trimLineEndPreserveAnsi("hello world ");
|
|
expect(result).toBe("hello world");
|
|
});
|
|
|
|
test("trimLineEndPreserveAnsi preserves content", () => {
|
|
const result = trimLineEndPreserveAnsi("no trailing spaces");
|
|
expect(result).toBe("no trailing spaces");
|
|
});
|
|
|
|
test("trimLineEndPreserveAnsi preserves trailing ANSI reset", () => {
|
|
const result = trimLineEndPreserveAnsi("text \x1b[0m");
|
|
expect(result).toBe("text\x1b[0m");
|
|
});
|
|
|
|
test("trimLineEndPreserveAnsi preserves trailing ANSI color", () => {
|
|
const result = trimLineEndPreserveAnsi("text \x1b[31m");
|
|
expect(result).toBe("text\x1b[31m");
|
|
});
|
|
|
|
test("trimLineEndPreserveAnsi handles lines without ANSI codes", () => {
|
|
const result = trimLineEndPreserveAnsi("plain text ");
|
|
expect(result).toBe("plain text");
|
|
});
|
|
|
|
test("trimLineEndPreserveAnsi handles empty string", () => {
|
|
const result = trimLineEndPreserveAnsi("");
|
|
expect(result).toBe("");
|
|
});
|
|
|
|
test("trimLineEndPreserveAnsi handles only spaces", () => {
|
|
const result = trimLineEndPreserveAnsi(" ");
|
|
expect(result).toBe("");
|
|
});
|
|
|
|
test("trimLineEndPreserveAnsi preserves multiple trailing ANSI sequences", () => {
|
|
const result = trimLineEndPreserveAnsi("text \x1b[0m\x1b[31m");
|
|
expect(result).toBe("text\x1b[0m\x1b[31m");
|
|
});
|
|
|
|
test("ansiToHtml uses trimLineEndPreserveAnsi on each line", () => {
|
|
const result = ansiToHtml("\x1b[41mtext \x1b[0m\nmore ");
|
|
// Trailing spaces should be trimmed
|
|
expect(result).not.toContain("text </span>");
|
|
expect(result).not.toContain("more ");
|
|
});
|
|
|
|
// 10. Edge cases tests
|
|
|
|
test("empty string returns empty", () => {
|
|
const result = ansiToHtml("");
|
|
expect(result).toBe("");
|
|
});
|
|
|
|
test("no ANSI codes (plain text)", () => {
|
|
const result = ansiToHtml("plain text");
|
|
expect(result).toBe("plain text");
|
|
});
|
|
|
|
test("malformed ANSI sequence (incomplete)", () => {
|
|
const result = ansiToHtml("\x1b[31incomplete");
|
|
// Incomplete sequence consumes the 3, but outputs the rest
|
|
expect(result).toContain("ncomplete");
|
|
});
|
|
|
|
test("ANSI sequence without parameters", () => {
|
|
const result = ansiToHtml("\x1b[mtext");
|
|
expect(result).toBe("text");
|
|
});
|
|
|
|
test("unknown ANSI codes are ignored", () => {
|
|
const result = ansiToHtml("\x1b[999mtext\x1b[0m");
|
|
expect(result).toBe("text");
|
|
});
|
|
|
|
test("non-SGR escape sequences are skipped", () => {
|
|
// Cursor positioning sequences like ESC[H should be ignored
|
|
const result = ansiToHtml("\x1b[Htext");
|
|
expect(result).toBe("text");
|
|
});
|
|
|
|
test("consecutive ANSI codes", () => {
|
|
const result = ansiToHtml("\x1b[31m\x1b[1mred bold\x1b[0m");
|
|
expect(result).toContain("color:#f85149");
|
|
expect(result).toContain("font-weight:bold");
|
|
});
|
|
|
|
test("ANSI code at end without text", () => {
|
|
const result = ansiToHtml("text\x1b[31m");
|
|
// Opens a span even though there's no text after
|
|
expect(result).toBe('text<span style="color:#f85149"></span>');
|
|
});
|
|
|
|
test("ANSI code at start", () => {
|
|
const result = ansiToHtml("\x1b[31mtext");
|
|
expect(result).toBe('<span style="color:#f85149">text</span>');
|
|
});
|
|
|
|
test("multiple resets", () => {
|
|
const result = ansiToHtml("\x1b[31mred\x1b[0m\x1b[0m\x1b[0m");
|
|
expect(result).toBe('<span style="color:#f85149">red</span>');
|
|
});
|
|
|
|
test("style change without reset", () => {
|
|
const result = ansiToHtml("\x1b[31mred\x1b[32mgreen");
|
|
expect(result).toBe(
|
|
'<span style="color:#f85149">red</span><span style="color:#3fb950">green</span>',
|
|
);
|
|
});
|
|
|
|
test("mixed escaped characters and ANSI codes", () => {
|
|
const result = ansiToHtml("\x1b[31m<script>&alert</script>\x1b[0m");
|
|
expect(result).toContain("<script>");
|
|
expect(result).toContain("&alert");
|
|
expect(result).toContain("color:#f85149");
|
|
});
|
|
|
|
test("preserves internal spaces", () => {
|
|
const result = ansiToHtml("hello world");
|
|
expect(result).toBe("hello world");
|
|
});
|
|
|
|
test("handles only ANSI codes with no text", () => {
|
|
const result = ansiToHtml("\x1b[31m\x1b[0m");
|
|
// Opens and closes span even with no text
|
|
expect(result).toBe('<span style="color:#f85149"></span>');
|
|
});
|
|
|
|
// OSC sequences (Operating System Command)
|
|
test("strips OSC terminal title with BEL", () => {
|
|
// ESC ] 0 ; title BEL
|
|
const result = ansiToHtml("\x1b]0;My Terminal Title\x07text after");
|
|
expect(result).toBe("text after");
|
|
});
|
|
|
|
test("strips OSC terminal title with ST", () => {
|
|
// ESC ] 0 ; title ESC \
|
|
const result = ansiToHtml("\x1b]0;My Terminal Title\x1b\\text after");
|
|
expect(result).toBe("text after");
|
|
});
|
|
|
|
test("strips OSC with emoji in title", () => {
|
|
const result = ansiToHtml("\x1b]0;★ Claude Code\x07hello");
|
|
expect(result).toBe("hello");
|
|
});
|
|
|
|
test("strips multiple OSC sequences", () => {
|
|
const result = ansiToHtml("\x1b]0;title1\x07text\x1b]0;title2\x07more");
|
|
expect(result).toBe("textmore");
|
|
});
|
|
|
|
// Private mode sequences (DEC)
|
|
test("strips bracketed paste mode enable", () => {
|
|
// ESC [ ? 2004 h
|
|
const result = ansiToHtml("\x1b[?2004htext");
|
|
expect(result).toBe("text");
|
|
});
|
|
|
|
test("strips bracketed paste mode disable", () => {
|
|
// ESC [ ? 2004 l
|
|
const result = ansiToHtml("\x1b[?2004ltext");
|
|
expect(result).toBe("text");
|
|
});
|
|
|
|
test("strips mixed private mode and SGR", () => {
|
|
const result = ansiToHtml("\x1b[?2004h\x1b[31mred\x1b[0m\x1b[?2004l");
|
|
expect(result).toBe('<span style="color:#f85149">red</span>');
|
|
});
|
|
|
|
test("strips cursor visibility sequences", () => {
|
|
// ESC [ ? 25 h (show cursor) and ESC [ ? 25 l (hide cursor)
|
|
const result = ansiToHtml("\x1b[?25lhidden cursor\x1b[?25h");
|
|
expect(result).toBe("hidden cursor");
|
|
});
|
|
|
|
test("handles Claude Code typical output", () => {
|
|
// Simulated Claude Code startup with title, bracketed paste, and colors
|
|
const input =
|
|
"\x1b]0;★ Claude Code\x07\x1b[?2004h\x1b[1;33mClaude Code\x1b[0m v2.1.15\x1b[?2004l";
|
|
const result = ansiToHtml(input);
|
|
expect(result).toContain("Claude Code");
|
|
expect(result).toContain("v2.1.15");
|
|
expect(result).not.toContain("2004");
|
|
expect(result).not.toContain("]0;");
|
|
});
|