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 { expect, test } from "bun:test";
|
||||||
|
import { splitAnsiCarryover } from "./ansi-carryover";
|
||||||
|
|
||||||
test("placeholder", () => {
|
test("splitAnsiCarryover: complete chunk with no incomplete sequences", () => {
|
||||||
expect(true).toBe(true);
|
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 type { ServerWebSocket } from "bun";
|
||||||
import { ansiToHtml } from "./ansi";
|
import { ansiToHtml } from "./ansi";
|
||||||
|
import { splitAnsiCarryover } from "./ansi-carryover";
|
||||||
import {
|
import {
|
||||||
appendOutput,
|
appendOutput,
|
||||||
createDevice,
|
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
|
// Initialize database
|
||||||
const port = Number.parseInt(process.env.PORT || "7200", 10);
|
const port = Number.parseInt(process.env.PORT || "7200", 10);
|
||||||
initDb();
|
initDb();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue