Skip to content
Source
Sign inSign up

terminalOutput.test.ts

267 lines10.4 KB

1import { describe, expect, it } from "vite-plus/test";
2​
3import {
4 appendTerminalOutput,
5 EMPTY_TERMINAL_OUTPUT,
6 normalizeTerminalOutput,
7 TERMINAL_PALETTES,
8 terminalOutputPlainText,
9 terminalOutputResumeText,
10 terminalOutputSpans,
11 terminalOutputTail,
12 type TerminalOutputState,
13} from "./terminalOutput.ts";
14​
15function feed(chunks: ReadonlyArray<string>, maxChars?: number): TerminalOutputState {
16 return chunks.reduce(
17 (state, chunk) => appendTerminalOutput(state, chunk, maxChars),
18 EMPTY_TERMINAL_OUTPUT,
19 );
21​
22describe("appendTerminalOutput", () => {
23 it("keeps only the latest frame of a carriage-return progress bar", () => {
24 expect(feed(["[1/3]", "\r[2/3]", "\r[3/3] done\nnext"]).text).toBe("[3/3] done\nnext");
25 });
26​
27 it("redraws several lines in place when the cursor moves up", () => {
28 const frames = [
29 "layer a: 10%\nlayer b: 10%\n",
30 "\u001b[2A\u001b[2Klayer a: 60%\n\u001b[2Klayer b: 40%\n",
31 "\u001b[2A\u001b[2Klayer a: done\n\u001b[2Klayer b: done\n",
32 ];
33 expect(feed(frames).text).toBe("layer a: done\nlayer b: done\n");
34 });
35​
36 it("treats CRLF as one newline even when the pair is split across chunks", () => {
37 const state = feed(["one\r", "\ntwo\r\n"]);
38 expect(state.text).toBe("one\ntwo\n");
39 expect(state.pending).toBe("");
40 });
41​
42 it("holds an escape sequence split across chunks until it finishes", () => {
43 const first = appendTerminalOutput(EMPTY_TERMINAL_OUTPUT, "red: \u001b[3");
44 expect(first.text).toBe("red: ");
45 expect(feed(["red: \u001b[3", "1mR\u001b[0m"]).text).toBe("red: \u001b[0;31mR\u001b[0m");
46 expect(
47 terminalOutputSpans(feed(["\u001b[38:2::12:", "34:56mR"]).text, TERMINAL_PALETTES.dark),
48 ).toEqual([{ text: "R", style: { color: "rgb(12,34,56)" } }]);
49 });
50​
51 it("keeps a bounded tail that starts on a fresh line", () => {
52 const lines = Array.from({ length: 2_000 }, (_, index) => `line ${index}`).join("\n");
53 const state = feed([lines], 1_000);
54 expect(state.truncated).toBe(true);
55 expect(state.text.length).toBeLessThanOrEqual(1_000);
56 expect(state.text.startsWith("line ")).toBe(true);
57 expect(state.text.endsWith("line 1999")).toBe(true);
58 // A newline a quarter of the budget past the cut is too far to start on.
59 expect(feed(["abcdefgh\nij\nklmnop"], 8).text).toBe("klmnop");
60 expect(feed(["abcdefgh\nijk\nlmnop"], 8).text).toBe("jk\nlmnop");
61 });
62​
63 it("stays bounded across many appends of huge output, including one endless line", () => {
64 let state = EMPTY_TERMINAL_OUTPUT;
65 for (let index = 0; index < 5_000; index += 1) {
66 state = appendTerminalOutput(state, "y\n".repeat(200), 4_096);
67 }
68 expect(state.text.length).toBeLessThanOrEqual(4_096);
69 expect(state.truncated).toBe(true);
70 for (let index = 0; index < 1_000; index += 1) {
71 state = appendTerminalOutput(state, ".".repeat(100), 4_096);
72 }
73 expect(state.text.length).toBeLessThanOrEqual(4_096);
74 expect(state.text.endsWith(".")).toBe(true);
75 });
76​
77 it("hides a long string sequence's payload however it is split into chunks", () => {
78 const input = `a\u001b]0;${"t".repeat(100)}\u0007b`;
79 expect(feed([input]).text).toBe("ab");
80 expect(feed(["a\u001b]0;", "t".repeat(100), "\u0007b"]).text).toBe("ab");
81 expect(feed([`a\u001b]0;${"t".repeat(80)}`, "\u001b", "\\b"]).text).toBe("ab");
82 });
83​
84 it("drops an escape sequence that never terminates and resumes after it", () => {
85 expect(feed(["ok\u001b]", "x".repeat(200), "\nafter"]).text).toBe("ok\nafter");
86 expect(feed(["ok\u001b]0;title\u001b", "\u001b[31mred"]).text).toBe(
87 "ok\u001b[0;31mred\u001b[0m",
88 );
89 });
90});
91​
92describe("appendTerminalOutput edge cases from review", () => {
93 const boldRed = { color: TERMINAL_PALETTES.dark[1], fontWeight: "600" } as const;
94 it.each([
95 [
96 "a curly underline",
97 "\u001b[1;31m\u001b[4:3mX",
98 { ...boldRed, textDecorationLine: "underline" },
99 ],
100 ["underline off in colon form", "\u001b[1;31m\u001b[4:0mX", boldRed],
101 ["a true-colour underline colour", "\u001b[1;31m\u001b[58;2;255;0;0mX", boldRed],
102 ["a 256-colour underline colour", "\u001b[58;5;31mX", null],
103 [
104 "a colon-form underline colour",
105 "\u001b[4m\u001b[58:2::255:0:0mX",
106 { textDecorationLine: "underline" },
107 ],
108 ["extra fields in a colon-form indexed underline colour", "\u001b[58:5:0:32mX", null],
109 [
110 "extra fields in a colon-form indexed colour",
111 "\u001b[38:5:1:4mX",
112 { color: TERMINAL_PALETTES.dark[1] },
113 ],
114 [
115 "a true colour with its fields after colons",
116 "\u001b[38;2:255:0:0;1mX",
117 { color: "rgb(255,0,0)", fontWeight: "600" },
118 ],
119 [
120 "an indexed colour with its index after a colon",
121 "\u001b[38;5:1;4mX",
122 { color: TERMINAL_PALETTES.dark[1], textDecorationLine: "underline" },
123 ],
124 [
125 "a semicolon-form colour value that looks like a colour code",
126 "\u001b[38;5;38;2:1:2:3mX",
127 { color: "rgb(0,175,215)", opacity: 0.65 },
128 ],
129 ])("reads %s without disturbing the other attributes", (_, input, style) => {
130 const text = normalizeTerminalOutput(input).text;
131 expect(terminalOutputSpans(text, TERMINAL_PALETTES.dark)).toEqual([{ text: "X", style }]);
132 for (let cut = 1; cut < input.length; cut += 1) {
133 expect(feed([input.slice(0, cut), input.slice(cut)]).text).toBe(text);
134 }
135 });
136​
137 it("leaves the screen and cursor alone for unsupported erase modes", () => {
138 for (const sequence of ["3K", "4J", "99K", "99J"]) {
139 const input = `a\nb\nc\u001b[${sequence}X`;
140 expect(feed([input]).text).toBe("a\nb\ncX");
141 for (let cut = 1; cut < input.length; cut += 1) {
142 expect(feed([input.slice(0, cut), input.slice(cut)]).text).toBe("a\nb\ncX");
143 }
144 }
145 });
146​
147 it("clears only scrollback on ESC[3J", () => {
148 const lines = Array.from({ length: 70 }, (_, index) => `line ${index}`).join("\n");
149 const state = feed([lines, "\u001b[3J"]);
150 expect(state.text.startsWith("line 6\n")).toBe(true);
151 expect(state.text.endsWith("line 69")).toBe(true);
152 expect(state.truncated).toBe(true);
153 });
154​
155 it("keeps the whole window within the tail when every line is long", () => {
156 const long = `${"x".repeat(900)}\n`;
157 const state = feed([long.repeat(64)], 4_096);
158 const windowChars = state.screen.lines.reduce(
159 (total, line) => total + line.reduce((sum, run) => sum + run.text.length, 0),
160 0,
161 );
162 expect(windowChars).toBeLessThanOrEqual(4_096);
163 expect(state.text.length).toBeLessThanOrEqual(4_096);
164 });
165});
166​
167describe("appendTerminalOutput under adversarial output", () => {
168 it("ignores unsupported cursor-control parameters without corrupting the screen", () => {
169 for (const sequence of ["1.5A", "1.5G", "1$K", "1$J", ">1A", "?25l", "1:2A", "1:2K", "1:2J"]) {
170 const input = `a\nb\nc\u001b[${sequence}X`;
171 expect(feed([input]).text).toBe("a\nb\ncX");
172 for (let cut = 1; cut < input.length; cut += 1) {
173 expect(feed([input.slice(0, cut), input.slice(cut)]).text).toBe("a\nb\ncX");
174 }
175 }
176 });
177​
178 it("drops escape sequences with intermediate bytes without printing their final byte", () => {
179 for (const sequence of ["#8", "#6", "%G", " F", "-A", ".A", "(%5", "(B"]) {
180 const input = `a\u001b${sequence}b`;
181 expect(feed([input]).text).toBe("ab");
182 for (let cut = 1; cut < input.length; cut += 1) {
183 expect(feed([input.slice(0, cut), input.slice(cut)]).text).toBe("ab");
184 }
185 }
186 for (const [interrupted, expected] of [
187 ["a\u001b#\u001b[31mb", feed(["a\u001b[31mb"]).text],
188 ["a\u001b#\n8b", "a\n8b"],
189 ] as const) {
190 for (let cut = 0; cut < interrupted.length; cut += 1) {
191 expect(feed([interrupted.slice(0, cut), interrupted.slice(cut)]).text).toBe(expected);
192 }
193 }
194 });
195​
196 it("gives the same result however the output is split into chunks", () => {
197 const input =
198 "abcdef\rX\u001b[31mred\u001b[0m\nnext line\r\u001b[2Kdone\n" +
199 "\u001b[1;31mbold red\u001b[mplain\u001b[38:2::12:34:56mrgb\u001b[0m\n" +
200 "\u001b[1;31mbold red again\u001b[mplain again\u001b[38:2::12:34:56mrgb again\u001b[0m";
201 for (const maxChars of [3, 1_024]) {
202 const whole = feed([input], maxChars).text;
203 for (let cut = 1; cut < input.length; cut += 1) {
204 expect(feed([input.slice(0, cut), input.slice(cut)], maxChars).text).toBe(whole);
205 }
206 }
207 expect(feed(["abcdef", "\rX"], 3).text).toBe(feed(["abcdef\rX"], 3).text);
208 });
209});
210​
211describe("terminalOutputResumeText", () => {
212 it("lets a viewer that starts from it apply later chunks exactly as the source does", () => {
213 const chunks = [
214 "a: 1%\nb: 1%\n",
215 "\u001b[2A\u001b[2K\u001b[33ma: 5",
216 "0%\u001b[0",
217 "m\n\u001b[2Kb: 50%\n",
218 ];
219 const source = feed(chunks.slice(0, 3));
220 const viewer = appendTerminalOutput(
221 appendTerminalOutput(EMPTY_TERMINAL_OUTPUT, terminalOutputResumeText(source)),
222 chunks[3]!,
223 );
224 expect(viewer.text).toBe(feed(chunks).text);
225 expect(terminalOutputPlainText(viewer.text)).toBe("a: 50%\nb: 50%\n");
226 });
227});
228​
229describe("terminalOutputResumeText with a saved cursor or trimmed output", () => {
230 it("restores a cursor saved before the resync", () => {
231 const source = feed(["abc\u001b7def"]);
232 const viewer = appendTerminalOutput(
233 appendTerminalOutput(EMPTY_TERMINAL_OUTPUT, terminalOutputResumeText(source)),
234 "\u001b8X",
235 );
236 expect(viewer.text).toBe("abcXef");
237 });
238​
239 it("stays on the right line when early output was trimmed away", () => {
240 const lines = Array.from({ length: 40 }, (_, index) => `line ${index}`).join("\n");
241 const source = feed([lines, "\u001b[1A\rLINE"], 120);
242 const viewer = appendTerminalOutput(
243 appendTerminalOutput(EMPTY_TERMINAL_OUTPUT, terminalOutputResumeText(source), 120),
244 " edited",
245 120,
246 );
247 expect(viewer.text).toBe(appendTerminalOutput(source, " edited", 120).text);
248 });
249});
250​
251describe("terminalOutputTail", () => {
252 it("never begins on a lone low surrogate", () => {
253 const text = `${"a".repeat(10)}πŸ˜€${"b".repeat(4)}`;
254 const tail = terminalOutputTail(text, 5);
255 expect(tail.text).toBe("bbbb");
256 });
257});
258​
259describe("terminalOutputSpans", () => {
260 it("collapses the rest into plain text past the span limit", () => {
261 const text = normalizeTerminalOutput("\u001b[31mx\u001b[32my".repeat(50)).text;
262 const spans = terminalOutputSpans(text, TERMINAL_PALETTES.light, 10);
263 expect(spans.length).toBe(11);
264 expect(spans.at(-1)?.style).toBeNull();
265 expect(spans.map((span) => span.text).join("")).toBe("xy".repeat(50));
266 });
267});