Skip to content
File

Blob: tests/client/ansi.test.ts

typescript281 lines
1import { describe, expect, it } from "vitest";
2 
3import { createAnsiProcessor, parseAnsi, type AnsiSpan } from "@/client/lib";
4 
5/** Helper: extract just the fields we care about from a span. */
6const pick = (span: AnsiSpan, ...keys: (keyof AnsiSpan)[]) => Object.fromEntries(keys.map((k) => [k, span[k]]));
7 
8// ---------------------------------------------------------------------------
9// parseAnsi (stateless, single-shot)
10// ---------------------------------------------------------------------------
11 
12describe("parseAnsi", () => {
13 it("returns empty array for empty input", () => {
14 expect(parseAnsi("")).toEqual([]);
15 });
16 
17 it("passes plain text through as a single unstyled span", () => {
18 const spans = parseAnsi("hello world");
19 expect(spans).toHaveLength(1);
20 expect(spans[0]!.text).toBe("hello world");
21 expect(spans[0]!.fg).toBeNull();
22 expect(spans[0]!.bold).toBe(false);
23 });
24 
25 // -- Standard foreground colors ------------------------------------------
26 
27 it("parses standard foreground colors (30–37)", () => {
28 const spans = parseAnsi("\x1b[31mred text\x1b[0m plain");
29 expect(spans).toHaveLength(2);
30 expect(pick(spans[0]!, "text", "fg")).toEqual({ text: "red text", fg: "#f87171" });
31 expect(spans[1]!.fg).toBeNull(); // after reset
32 });
33 
34 it("parses bright foreground colors (90–97)", () => {
35 const spans = parseAnsi("\x1b[92mbright green\x1b[0m");
36 expect(spans[0]!.fg).toBe("#86efac");
37 });
38 
39 // -- Background colors ---------------------------------------------------
40 
41 it("parses standard background colors (40–47)", () => {
42 const spans = parseAnsi("\x1b[44mblue bg\x1b[0m");
43 expect(spans[0]!.bg).toBe("#56a6f3");
44 });
45 
46 it("parses bright background colors (100–107)", () => {
47 const spans = parseAnsi("\x1b[103mbright yellow bg\x1b[0m");
48 expect(spans[0]!.bg).toBe("#fde047");
49 });
50 
51 // -- 256-color -----------------------------------------------------------
52 
53 it("parses 256-color foreground (38;5;n) — standard palette", () => {
54 const spans = parseAnsi("\x1b[38;5;1mred\x1b[0m");
55 expect(spans[0]!.fg).toBe("#f87171");
56 });
57 
58 it("parses 256-color foreground — color cube", () => {
59 // Index 196 = r=5,g=0,b=0 → rgb(255,0,0)
60 const spans = parseAnsi("\x1b[38;5;196mred cube\x1b[0m");
61 expect(spans[0]!.fg).toBe("rgb(255,0,0)");
62 });
63 
64 it("parses 256-color foreground — grayscale ramp", () => {
65 // Index 240 = (240-232)*10+8 = 88
66 const spans = parseAnsi("\x1b[38;5;240mgray\x1b[0m");
67 expect(spans[0]!.fg).toBe("rgb(88,88,88)");
68 });
69 
70 it("parses 256-color background (48;5;n)", () => {
71 const spans = parseAnsi("\x1b[48;5;21mblue bg\x1b[0m");
72 expect(spans[0]!.bg).toMatch(/^rgb\(/);
73 });
74 
75 // -- 24-bit RGB ----------------------------------------------------------
76 
77 it("parses 24-bit foreground (38;2;r;g;b)", () => {
78 const spans = parseAnsi("\x1b[38;2;128;64;255mpurple\x1b[0m");
79 expect(spans[0]!.fg).toBe("rgb(128,64,255)");
80 });
81 
82 it("parses 24-bit background (48;2;r;g;b)", () => {
83 const spans = parseAnsi("\x1b[48;2;10;20;30mdark bg\x1b[0m");
84 expect(spans[0]!.bg).toBe("rgb(10,20,30)");
85 });
86 
87 // -- Decorations ---------------------------------------------------------
88 
89 it("parses bold", () => {
90 const spans = parseAnsi("\x1b[1mbold\x1b[0m plain");
91 expect(spans[0]!.bold).toBe(true);
92 expect(spans[1]!.bold).toBe(false);
93 });
94 
95 it("parses dim", () => {
96 const spans = parseAnsi("\x1b[2mdim\x1b[0m");
97 expect(spans[0]!.dim).toBe(true);
98 });
99 
100 it("parses italic", () => {
101 const spans = parseAnsi("\x1b[3mitalic\x1b[0m");
102 expect(spans[0]!.italic).toBe(true);
103 });
104 
105 it("parses underline", () => {
106 const spans = parseAnsi("\x1b[4munderline\x1b[0m");
107 expect(spans[0]!.underline).toBe(true);
108 });
109 
110 it("parses strikethrough", () => {
111 const spans = parseAnsi("\x1b[9mstrike\x1b[0m");
112 expect(spans[0]!.strikethrough).toBe(true);
113 });
114 
115 // -- Resets --------------------------------------------------------------
116 
117 it("resets all styles with code 0", () => {
118 const spans = parseAnsi("\x1b[1;31;4mbold red underline\x1b[0mplain");
119 expect(spans[0]!.bold).toBe(true);
120 expect(spans[0]!.fg).toBe("#f87171");
121 expect(spans[0]!.underline).toBe(true);
122 
123 expect(spans[1]!.bold).toBe(false);
124 expect(spans[1]!.fg).toBeNull();
125 expect(spans[1]!.underline).toBe(false);
126 });
127 
128 it("treats \\x1b[m as a full reset", () => {
129 const spans = parseAnsi("\x1b[1mBold\x1b[mNormal");
130 expect(spans[1]!.bold).toBe(false);
131 });
132 
133 it("partially resets bold/dim with code 22", () => {
134 const spans = parseAnsi("\x1b[1;3mbold italic\x1b[22mnot bold\x1b[0m");
135 expect(spans[0]!.bold).toBe(true);
136 expect(spans[0]!.italic).toBe(true);
137 expect(spans[1]!.bold).toBe(false);
138 expect(spans[1]!.italic).toBe(true); // italic NOT cleared by 22
139 });
140 
141 it("resets foreground with code 39", () => {
142 const spans = parseAnsi("\x1b[31mred\x1b[39mdefault");
143 expect(spans[0]!.fg).toBe("#f87171");
144 expect(spans[1]!.fg).toBeNull();
145 });
146 
147 it("resets background with code 49", () => {
148 const spans = parseAnsi("\x1b[42mgreen bg\x1b[49mno bg");
149 expect(spans[0]!.bg).toBe("#4ade80");
150 expect(spans[1]!.bg).toBeNull();
151 });
152 
153 // -- Combined codes ------------------------------------------------------
154 
155 it("parses combined SGR codes in a single sequence", () => {
156 const spans = parseAnsi("\x1b[1;31mbold red\x1b[0m");
157 expect(spans[0]!.bold).toBe(true);
158 expect(spans[0]!.fg).toBe("#f87171");
159 });
160 
161 // -- Non-SGR stripping ---------------------------------------------------
162 
163 it("strips cursor movement sequences", () => {
164 const spans = parseAnsi("before\x1b[2Jafter");
165 const text = spans.map((s) => s.text).join("");
166 expect(text).toBe("beforeafter");
167 });
168 
169 it("strips OSC sequences", () => {
170 const spans = parseAnsi("before\x1b]0;window title\x07after");
171 const text = spans.map((s) => s.text).join("");
172 expect(text).toBe("beforeafter");
173 });
174 
175 // -- Coalescing ----------------------------------------------------------
176 
177 it("coalesces adjacent text with the same style", () => {
178 // Two TEXT nodes with no style change between them
179 const spans = parseAnsi("hello world");
180 expect(spans).toHaveLength(1);
181 });
182 
183 it("does not emit empty spans for consecutive SGR codes", () => {
184 const spans = parseAnsi("\x1b[1m\x1b[31mred bold\x1b[0m");
185 // Should not have any empty-text spans
186 for (const span of spans) {
187 expect(span.text.length).toBeGreaterThan(0);
188 }
189 });
190});
191 
192// ---------------------------------------------------------------------------
193// createAnsiProcessor (stateful, cross-chunk)
194// ---------------------------------------------------------------------------
195 
196describe("createAnsiProcessor", () => {
197 it("carries style state across chunks", () => {
198 const proc = createAnsiProcessor();
199 const s1 = proc.feed("\x1b[31m");
200 // Setting a color without text may produce no spans
201 expect(s1).toHaveLength(0);
202 
203 const s2 = proc.feed("Hello");
204 expect(s2).toHaveLength(1);
205 expect(s2[0]!.fg).toBe("#f87171");
206 expect(s2[0]!.text).toBe("Hello");
207 });
208 
209 it("handles split escape sequences across chunks", () => {
210 const proc = createAnsiProcessor();
211 
212 // First chunk ends mid-escape
213 const s1 = proc.feed("Hello\x1b[3");
214 expect(s1).toHaveLength(1);
215 expect(s1[0]!.text).toBe("Hello");
216 expect(s1[0]!.fg).toBeNull();
217 
218 // Second chunk completes the escape
219 const s2 = proc.feed("1mWorld");
220 expect(s2).toHaveLength(1);
221 expect(s2[0]!.text).toBe("World");
222 expect(s2[0]!.fg).toBe("#f87171");
223 });
224 
225 it("handles escape split at just the ESC byte", () => {
226 const proc = createAnsiProcessor();
227 const s1 = proc.feed("Text\x1b");
228 expect(s1).toHaveLength(1);
229 expect(s1[0]!.text).toBe("Text");
230 
231 const s2 = proc.feed("[32mGreen");
232 expect(s2).toHaveLength(1);
233 expect(s2[0]!.fg).toBe("#4ade80");
234 expect(s2[0]!.text).toBe("Green");
235 });
236 
237 it("resets style across chunks", () => {
238 const proc = createAnsiProcessor();
239 proc.feed("\x1b[1;31m");
240 const s1 = proc.feed("Red bold");
241 expect(s1[0]!.fg).toBe("#f87171");
242 expect(s1[0]!.bold).toBe(true);
243 
244 const s2 = proc.feed("\x1b[0mPlain");
245 expect(s2).toHaveLength(1);
246 expect(s2[0]!.fg).toBeNull();
247 expect(s2[0]!.bold).toBe(false);
248 expect(s2[0]!.text).toBe("Plain");
249 });
250 
251 it("processes multiple chunks in sequence", () => {
252 const proc = createAnsiProcessor();
253 
254 proc.feed("\x1b[32m");
255 const s1 = proc.feed("step 1\n");
256 expect(s1[0]!.fg).toBe("#4ade80");
257 
258 proc.feed("\x1b[33m");
259 const s2 = proc.feed("step 2\n");
260 expect(s2[0]!.fg).toBe("#facc15");
261 
262 proc.feed("\x1b[0m");
263 const s3 = proc.feed("done\n");
264 expect(s3[0]!.fg).toBeNull();
265 });
266 
267 it("handles plain text chunks without escape codes", () => {
268 const proc = createAnsiProcessor();
269 const s1 = proc.feed("just text");
270 expect(s1).toHaveLength(1);
271 expect(s1[0]!.text).toBe("just text");
272 expect(s1[0]!.fg).toBeNull();
273 });
274 
275 it("handles empty chunk", () => {
276 const proc = createAnsiProcessor();
277 const s = proc.feed("");
278 expect(s).toHaveLength(0);
279 });
280});