Add tests for splitAnsiCarryover function
This commit is contained in:
parent
0a3bfa6092
commit
42ba893ea5
3 changed files with 122 additions and 65 deletions
62
src/ansi-carryover.ts
Normal file
62
src/ansi-carryover.ts
Normal 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, ""];
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue