Add tests for splitAnsiCarryover function

This commit is contained in:
Jared Miller 2026-01-31 09:11:49 -05:00
parent 0a3bfa6092
commit 42ba893ea5
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
3 changed files with 122 additions and 65 deletions

62
src/ansi-carryover.ts Normal file
View file

@ -0,0 +1,62 @@
/**
* Detect and split any incomplete ANSI control sequence at the end of a chunk.
*
* ANSI sequences can span multiple network packets. This function identifies
* incomplete sequences at the end of a chunk to prevent them from being
* rendered as partial/broken control codes.
*
* @param chunk - Raw output string potentially containing ANSI sequences
* @returns Tuple of [body, carry] where:
* - body: Complete portion safe to render
* - carry: Incomplete sequence to prepend to next chunk
*
* @example
* const [body, carry] = splitAnsiCarryover("hello\x1b[31");
* // body = "hello", carry = "\x1b[31"
*/
export function splitAnsiCarryover(chunk: string): [string, string] {
if (!chunk) return ["", ""];
const ESC = 0x1b;
const len = chunk.length;
// If last char is ESC, entire ESC starts a sequence we can't complete
if (chunk.charCodeAt(len - 1) === ESC) {
return [chunk.slice(0, -1), "\x1b"];
}
// Search from the last ESC backwards for a potentially incomplete sequence
for (let i = len - 2; i >= 0; i--) {
if (chunk.charCodeAt(i) !== ESC) continue;
const next = chunk[i + 1];
// OSC: ESC ] ... (terminated by BEL 0x07 or ST = ESC \
if (next === "]") {
const tail = chunk.slice(i + 2);
const hasBEL = tail.indexOf("\x07") !== -1;
const hasST = tail.indexOf("\x1b\\") !== -1;
if (!hasBEL && !hasST) {
return [chunk.slice(0, i), chunk.slice(i)];
}
// else complete; continue scanning
continue;
}
// CSI: ESC [ params final — ensure we have a final byte 0x40-0x7E
if (next === "[") {
let j = i + 2;
while (j < len && /[0-9;?]/.test(chunk[j] || "")) j++;
// If we reached end without a final byte, it's incomplete
if (j >= len) {
return [chunk.slice(0, i), chunk.slice(i)];
}
// Final byte exists at chunk[j]; sequence complete
continue;
}
// DCS/PM/APC and other ESC-prefixed two-char intros — if ESC is near end and likely incomplete, carry
// If ESC is the penultimate and we're at end, treat as incomplete unknown sequence
if (i >= len - 2) {
return [chunk.slice(0, i), chunk.slice(i)];
}
// Otherwise, unknown but complete; continue
}
return [chunk, ""];
}

View file

@ -1,5 +1,62 @@
import { expect, test } from "bun:test";
import { splitAnsiCarryover } from "./ansi-carryover";
test("placeholder", () => {
expect(true).toBe(true);
test("splitAnsiCarryover: complete chunk with no incomplete sequences", () => {
const [body, carry] = splitAnsiCarryover("hello world");
expect(body).toBe("hello world");
expect(carry).toBe("");
});
test("splitAnsiCarryover: ESC at end of chunk", () => {
const [body, carry] = splitAnsiCarryover("hello\x1b");
expect(body).toBe("hello");
expect(carry).toBe("\x1b");
});
test("splitAnsiCarryover: incomplete CSI sequence without final byte", () => {
const [body, carry] = splitAnsiCarryover("hello\x1b[31");
expect(body).toBe("hello");
expect(carry).toBe("\x1b[31");
});
test("splitAnsiCarryover: incomplete OSC without BEL terminator", () => {
const [body, carry] = splitAnsiCarryover("hello\x1b]0;title");
expect(body).toBe("hello");
expect(carry).toBe("\x1b]0;title");
});
test("splitAnsiCarryover: complete OSC with BEL terminator", () => {
const [body, carry] = splitAnsiCarryover("hello\x1b]0;title\x07world");
expect(body).toBe("hello\x1b]0;title\x07world");
expect(carry).toBe("");
});
test("splitAnsiCarryover: complete CSI sequence", () => {
const [body, carry] = splitAnsiCarryover("hello\x1b[31mworld");
expect(body).toBe("hello\x1b[31mworld");
expect(carry).toBe("");
});
test("splitAnsiCarryover: empty string", () => {
const [body, carry] = splitAnsiCarryover("");
expect(body).toBe("");
expect(carry).toBe("");
});
test("splitAnsiCarryover: multiple complete sequences", () => {
const [body, carry] = splitAnsiCarryover("\x1b[1m\x1b[31mhello\x1b[0m");
expect(body).toBe("\x1b[1m\x1b[31mhello\x1b[0m");
expect(carry).toBe("");
});
test("splitAnsiCarryover: incomplete CSI at end with complete CSI before", () => {
const [body, carry] = splitAnsiCarryover("\x1b[31mhello\x1b[");
expect(body).toBe("\x1b[31mhello");
expect(carry).toBe("\x1b[");
});
test("splitAnsiCarryover: ESC with unknown sequence near end", () => {
const [body, carry] = splitAnsiCarryover("hello\x1bP");
expect(body).toBe("hello");
expect(carry).toBe("\x1bP");
});

View file

@ -2,6 +2,7 @@
import type { ServerWebSocket } from "bun";
import { ansiToHtml } from "./ansi";
import { splitAnsiCarryover } from "./ansi-carryover";
import {
appendOutput,
createDevice,
@ -81,69 +82,6 @@ function broadcastSSE(event: SSEEvent): void {
}
}
/**
* Detect and split any incomplete ANSI control sequence at the end of a chunk.
*
* ANSI sequences can span multiple network packets. This function identifies
* incomplete sequences at the end of a chunk to prevent them from being
* rendered as partial/broken control codes.
*
* @param chunk - Raw output string potentially containing ANSI sequences
* @returns Tuple of [body, carry] where:
* - body: Complete portion safe to render
* - carry: Incomplete sequence to prepend to next chunk
*
* @example
* const [body, carry] = splitAnsiCarryover("hello\x1b[31");
* // body = "hello", carry = "\x1b[31"
*/
function splitAnsiCarryover(chunk: string): [string, string] {
if (!chunk) return ["", ""];
const ESC = 0x1b;
const len = chunk.length;
// If last char is ESC, entire ESC starts a sequence we can't complete
if (chunk.charCodeAt(len - 1) === ESC) {
return [chunk.slice(0, -1), "\x1b"];
}
// Search from the last ESC backwards for a potentially incomplete sequence
for (let i = len - 2; i >= 0; i--) {
if (chunk.charCodeAt(i) !== ESC) continue;
const next = chunk[i + 1];
// OSC: ESC ] ... (terminated by BEL 0x07 or ST = ESC \
if (next === "]") {
const tail = chunk.slice(i + 2);
const hasBEL = tail.indexOf("\x07") !== -1;
const hasST = tail.indexOf("\x1b\\") !== -1;
if (!hasBEL && !hasST) {
return [chunk.slice(0, i), chunk.slice(i)];
}
// else complete; continue scanning
continue;
}
// CSI: ESC [ params final — ensure we have a final byte 0x40-0x7E
if (next === "[") {
let j = i + 2;
while (j < len && /[0-9;?]/.test(chunk[j] || "")) j++;
// If we reached end without a final byte, it's incomplete
if (j >= len) {
return [chunk.slice(0, i), chunk.slice(i)];
}
// Final byte exists at chunk[j]; sequence complete
continue;
}
// DCS/PM/APC and other ESC-prefixed two-char intros — if ESC is near end and likely incomplete, carry
// If ESC is the penultimate and we're at end, treat as incomplete unknown sequence
if (i >= len - 2) {
return [chunk.slice(0, i), chunk.slice(i)];
}
// Otherwise, unknown but complete; continue
}
return [chunk, ""];
}
// Initialize database
const port = Number.parseInt(process.env.PORT || "7200", 10);
initDb();