clarc/src/ansi.test.ts

484 lines
15 KiB
TypeScript

import { expect, test } from "bun:test";
import { ansiToHtml, trimLineEndPreserveAnsi } from "./ansi";
// 1. XSS/HTML escaping tests
test("escapes < to &lt;", () => {
const result = ansiToHtml("<div>");
expect(result).toBe("&lt;div&gt;");
});
test("escapes > to &gt;", () => {
const result = ansiToHtml("a > b");
expect(result).toBe("a &gt; b");
});
test("escapes & to &amp;", () => {
const result = ansiToHtml("foo & bar");
expect(result).toBe("foo &amp; bar");
});
test("escapes <script> tags", () => {
const result = ansiToHtml("<script>alert('xss')</script>");
expect(result).toBe("&lt;script&gt;alert('xss')&lt;/script&gt;");
});
test("escapes HTML in styled text", () => {
const result = ansiToHtml("\x1b[31m<script>alert(1)</script>\x1b[0m");
expect(result).toContain("&lt;script&gt;");
expect(result).toContain("&lt;/script&gt;");
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("&lt;script&gt;");
expect(result).toContain("&amp;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;");
});