311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
|
|
import assert from "node:assert/strict";
|
||
|
|
import test from "node:test";
|
||
|
|
|
||
|
|
import {
|
||
|
|
createReplaySafeTerminalLog,
|
||
|
|
createReplaySafeTerminalLogSanitizer,
|
||
|
|
} from "./replaySafeTerminalLog";
|
||
|
|
|
||
|
|
test("plain text output is preserved in large chunks", () => {
|
||
|
|
const chunk = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\r\n".repeat(16 * 1024);
|
||
|
|
const sanitizer = createReplaySafeTerminalLogSanitizer();
|
||
|
|
|
||
|
|
assert.equal(sanitizer.append(chunk), chunk);
|
||
|
|
assert.equal(sanitizer.append("tail\n"), "tail\n");
|
||
|
|
assert.equal(sanitizer.finish(), "");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("common shell clear sequence is safe to replay", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("login banner\n$ clear\n\x1b[H\x1b[2J\x1b[3Jafter clear\n");
|
||
|
|
|
||
|
|
assert.equal(log, "login banner\n$ clear\n\r\nafter clear\n");
|
||
|
|
assert.equal(log.includes("\x1b[2J"), false);
|
||
|
|
assert.equal(log.includes("\x1b[3J"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("display clear followed by cursor home keeps prior replay history", () => {
|
||
|
|
assert.equal(
|
||
|
|
createReplaySafeTerminalLog("old1\nold2\n\x1b[2J\x1b[Hnew\n"),
|
||
|
|
"old1\nold2\n\r\nnew\n",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("home erase-to-end clear keeps prior replay history", () => {
|
||
|
|
assert.equal(
|
||
|
|
createReplaySafeTerminalLog("before zellij\n$ zellij\n\x1b[H\x1b[Jzellij pane\n"),
|
||
|
|
"before zellij\n$ zellij\n\r\nzellij pane\n",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("repeated home erase-to-end clears create fresh replay sections", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("history\n\x1b[2Jframe1\x1b[H\x1b[Jframe2\n");
|
||
|
|
|
||
|
|
assert.equal(log, "history\n\r\nframe1\r\n\r\nframe2\n");
|
||
|
|
assert.equal(log.includes("\x1b[H"), false);
|
||
|
|
assert.equal(log.includes("\x1b[J"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("repeated cursor home before clear does not overwrite replay history", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("old1\nold2\n\x1b[H\x1b[H\x1b[2Jafter\n");
|
||
|
|
|
||
|
|
assert.equal(log, "old1\nold2\n\r\nafter\n");
|
||
|
|
assert.equal(log.includes("\x1b[H"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("mode controls after clear do not allow later home to overwrite history", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("history\n\x1b[H\x1b[2J\x1b[?25l\x1b[Hnew\n");
|
||
|
|
|
||
|
|
assert.equal(log, "history\n\r\n\x1b[?25lnew\n");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("cursor positioning after clear does not overwrite replay history", () => {
|
||
|
|
const log = createReplaySafeTerminalLog(
|
||
|
|
"old1\nold2\n\x1b[2J\x1b[10;5Hpanel\x1b[999Aup\x1b[5Fprev\x1b[12drow\x1b[20Gcol\n",
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.equal(log, "old1\nold2\n\r\npanelupprevrowcol\n");
|
||
|
|
assert.equal(log.includes("\x1b[10;5H"), false);
|
||
|
|
assert.equal(log.includes("\x1b[999A"), false);
|
||
|
|
assert.equal(log.includes("\x1b[5F"), false);
|
||
|
|
assert.equal(log.includes("\x1b[12d"), false);
|
||
|
|
assert.equal(log.includes("\x1b[20G"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("cursor home after protected clear is dropped when no erase follows", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("old\n\x1b[2Jframe\x1b[H\x1b[?25ltext\n");
|
||
|
|
|
||
|
|
assert.equal(log, "old\n\r\nframe\x1b[?25ltext\n");
|
||
|
|
assert.equal(log.includes("\x1b[H"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("single-character cursor controls after clear do not overwrite replay history", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("old\n\x1b[2J\x1bMri\x8dc1ri\x1bDind\x84c1ind\x1bEnel\x85c1nel\n");
|
||
|
|
|
||
|
|
assert.equal(log, "old\n\r\nric1riindc1indnelc1nel\n");
|
||
|
|
assert.equal(log.includes("\x1bM"), false);
|
||
|
|
assert.equal(log.includes("\x8d"), false);
|
||
|
|
assert.equal(log.includes("\x1bD"), false);
|
||
|
|
assert.equal(log.includes("\x84"), false);
|
||
|
|
assert.equal(log.includes("\x1bE"), false);
|
||
|
|
assert.equal(log.includes("\x85"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("queued cursor and erase controls before clear are not preserved", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("old\n\x1b[H\x1b[2;1H\x1b[K\x1b[s\x1b[u\x1b[?25l\x1b[2Jnew\n");
|
||
|
|
|
||
|
|
assert.equal(log, "old\n\r\n\x1b[?25lnew\n");
|
||
|
|
assert.equal(log.includes("\x1b[2;1H"), false);
|
||
|
|
assert.equal(log.includes("\x1b[K"), false);
|
||
|
|
assert.equal(log.includes("\x1b[s"), false);
|
||
|
|
assert.equal(log.includes("\x1b[u"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("cursor save and restore are preserved before any protected clear", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("abc\x1b[sXYZ\x1b[u!");
|
||
|
|
|
||
|
|
assert.equal(log, "abc\x1b[sXYZ\x1b[u!");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("pending cursor-home controls are preserved when no clear follows", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("abc\x1b[H\x1b[s\x1b[2;1HXYZ");
|
||
|
|
|
||
|
|
assert.equal(log, "abc\x1b[H\x1b[s\x1b[2;1HXYZ");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("mode controls between home and erase are kept without preserving clear controls", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("history\n\x1b[H\x1b[?25l\x1b[Jnew\n");
|
||
|
|
|
||
|
|
assert.equal(log, "history\n\r\n\x1b[?25lnew\n");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("erase-display backward controls are dropped from replay data", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("old\n\x1b[2Jnew\x1b[1Jafter\n");
|
||
|
|
|
||
|
|
assert.equal(log, "old\n\r\nnew\r\n\r\nafter\n");
|
||
|
|
assert.equal(log.includes("\x1b[1J"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("scrollback-only clears protect replay history", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("history\n\x1b[3J\x1b[Hoverwrite\n");
|
||
|
|
|
||
|
|
assert.equal(log, "history\n\r\noverwrite\n");
|
||
|
|
assert.equal(log.includes("\x1b[3J"), false);
|
||
|
|
assert.equal(log.includes("\x1b[H"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("terminal control strings are stripped from replay data", () => {
|
||
|
|
const log = createReplaySafeTerminalLog(
|
||
|
|
"before\x1b]52;c;secret\x07mid\x1b]7;file://host/path\x1b\\"
|
||
|
|
+ "dcs\x1bP1$rpayload\x1b\\apc\x1b_payload\x1b\\pm\x1b^payload\x1b\\"
|
||
|
|
+ "sos\x1bXpayload\x1b\\c1sos\x98hidden\x9cafter",
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.equal(log, "beforemiddcsapcpmsosc1sosafter");
|
||
|
|
assert.equal(log.includes("\x1b"), false);
|
||
|
|
assert.equal(log.includes("secret"), false);
|
||
|
|
assert.equal(log.includes("payload"), false);
|
||
|
|
assert.equal(log.includes("hidden"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("split terminal control strings are stripped before truncation", () => {
|
||
|
|
const sanitizer = createReplaySafeTerminalLogSanitizer();
|
||
|
|
const hiddenPayload = "secret".repeat(200_000);
|
||
|
|
let captured = "";
|
||
|
|
|
||
|
|
captured += sanitizer.append("before\x1b]52;c;");
|
||
|
|
captured = captured.slice(-1_000_000);
|
||
|
|
captured += sanitizer.append(hiddenPayload);
|
||
|
|
captured = captured.slice(-1_000_000);
|
||
|
|
captured += sanitizer.append("\x07after");
|
||
|
|
captured = captured.slice(-1_000_000);
|
||
|
|
captured += sanitizer.finish();
|
||
|
|
|
||
|
|
assert.equal(captured, "beforeafter");
|
||
|
|
assert.equal(captured.includes("secret"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("overlong pending csi data is discarded until the sequence ends", () => {
|
||
|
|
const sanitizer = createReplaySafeTerminalLogSanitizer();
|
||
|
|
const parameters = "1;".repeat(3000);
|
||
|
|
|
||
|
|
const log = sanitizer.append(`before\x1b[${parameters}`)
|
||
|
|
+ sanitizer.append(parameters)
|
||
|
|
+ sanitizer.append("mafter")
|
||
|
|
+ sanitizer.finish();
|
||
|
|
|
||
|
|
assert.equal(log, "beforeafter");
|
||
|
|
assert.equal(log.includes(parameters.slice(0, 32)), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("pending cursor-home lookahead controls are bounded before clear", () => {
|
||
|
|
const controls = "\x1b[31m".repeat(900);
|
||
|
|
const log = createReplaySafeTerminalLog(`old\n\x1b[H${controls}\x1b[2Jnew\n`);
|
||
|
|
|
||
|
|
assert.equal(log, "old\n\r\nnew\n");
|
||
|
|
assert.equal(log.includes("\x1b[31m"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("alternate-screen body is omitted from replay-safe connection logs", () => {
|
||
|
|
const log = createReplaySafeTerminalLog(
|
||
|
|
"before\n\x1b[?1049h\x1b[Hvim screen\n~\n\"file\" 0L, 0B\x1b[?1049lafter\n"
|
||
|
|
+ "\x1b[?47h\x1b[10;5Htop screen\n\x1b[?47l"
|
||
|
|
+ "\x1b[?25lcursor hidden\n",
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.equal(log, "before\nafter\n\x1b[?25lcursor hidden\n");
|
||
|
|
assert.equal(log.includes("vim screen"), false);
|
||
|
|
assert.equal(log.includes("~"), false);
|
||
|
|
assert.equal(log.includes("0L, 0B"), false);
|
||
|
|
assert.equal(log.includes("top screen"), false);
|
||
|
|
assert.equal(log.includes("\x1b[?1049h"), false);
|
||
|
|
assert.equal(log.includes("\x1b[?1049l"), false);
|
||
|
|
assert.equal(log.includes("\x1b[?47h"), false);
|
||
|
|
assert.equal(log.includes("\x1b[?47l"), false);
|
||
|
|
assert.equal(log.includes("\x1b[H"), false);
|
||
|
|
assert.equal(log.includes("\x1b[10;5H"), false);
|
||
|
|
assert.equal(log.includes("\x1b[?25l"), true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("alternate-screen omit tracks enter/leave split across chunks", () => {
|
||
|
|
const sanitizer = createReplaySafeTerminalLogSanitizer();
|
||
|
|
const log = sanitizer.append("$ vim\n\x1b[?1049")
|
||
|
|
+ sanitizer.append("h~\nstatus\x1b[?1049")
|
||
|
|
+ sanitizer.append("l$ ")
|
||
|
|
+ sanitizer.finish();
|
||
|
|
|
||
|
|
assert.equal(log, "$ vim\n$ ");
|
||
|
|
assert.equal(log.includes("~"), false);
|
||
|
|
assert.equal(log.includes("status"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("alternate-screen omit does not use plain-text fast path between chunks", () => {
|
||
|
|
const sanitizer = createReplaySafeTerminalLogSanitizer();
|
||
|
|
const log = sanitizer.append("\x1b[?1049h")
|
||
|
|
+ sanitizer.append("~\nstatus")
|
||
|
|
+ sanitizer.append("\x1b[?1049l")
|
||
|
|
+ sanitizer.finish();
|
||
|
|
|
||
|
|
assert.equal(log, "");
|
||
|
|
assert.equal(log.includes("~"), false);
|
||
|
|
assert.equal(log.includes("status"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("seeded alternate-screen sanitizer omits TUI paint until leave", () => {
|
||
|
|
const sanitizer = createReplaySafeTerminalLogSanitizer({ alternateScreenActive: true });
|
||
|
|
const log = sanitizer.append("~\nstatus")
|
||
|
|
+ sanitizer.append("\x1b[?1049l$ done")
|
||
|
|
+ sanitizer.finish();
|
||
|
|
|
||
|
|
assert.equal(log, "$ done");
|
||
|
|
assert.equal(log.includes("~"), false);
|
||
|
|
assert.equal(log.includes("status"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("pending cursor-home is discarded when entering alternate screen", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("before\n\x1b[H\x1b[?1049hTUI\x1b[?1049lafter");
|
||
|
|
|
||
|
|
assert.equal(log, "before\nafter");
|
||
|
|
assert.equal(log.includes("\x1b[H"), false);
|
||
|
|
assert.equal(log.includes("TUI"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("dec cursor save mode is not treated as alternate screen", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("before\n\x1b[?1048h\x1b[?1048l\x1b[10;5Hpositioned\n");
|
||
|
|
|
||
|
|
assert.equal(log, "before\n\x1b[?1048h\x1b[?1048l\x1b[10;5Hpositioned\n");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("cursor save and restore controls are stripped around protected clears", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("old\n\x1b[s\x1b7\x1b[2J\x1b[uafter\x1b8done\n");
|
||
|
|
|
||
|
|
assert.equal(log, "old\n\x1b[s\x1b7\r\n\r\nafterdone\n");
|
||
|
|
assert.equal(log.includes("\x1b[s"), true);
|
||
|
|
assert.equal(log.includes("\x1b[u"), false);
|
||
|
|
assert.equal(log.includes("\x1b7"), true);
|
||
|
|
assert.equal(log.includes("\x1b8"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("terminal reset controls are dropped from replay data", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("before\x1bcafter\n");
|
||
|
|
|
||
|
|
assert.equal(log, "before\r\n\r\nafter\n");
|
||
|
|
assert.equal(log.includes("\x1bc"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("RIS during alternate screen restores logging of later shell output", () => {
|
||
|
|
const log = createReplaySafeTerminalLog("before\n\x1b[?1049hTUI\x1bcshell-after\n");
|
||
|
|
|
||
|
|
assert.equal(log, "before\n\r\nshell-after\n");
|
||
|
|
assert.equal(log.includes("TUI"), false);
|
||
|
|
assert.equal(log.includes("\x1bc"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("split RIS during alternate screen restores logging of later shell output", () => {
|
||
|
|
const sanitizer = createReplaySafeTerminalLogSanitizer();
|
||
|
|
|
||
|
|
const log = sanitizer.append("before\n\x1b[?1049hTUI\x1b")
|
||
|
|
+ sanitizer.append("cshell-after\n")
|
||
|
|
+ sanitizer.finish();
|
||
|
|
|
||
|
|
assert.equal(log, "before\n\r\nshell-after\n");
|
||
|
|
assert.equal(log.includes("TUI"), false);
|
||
|
|
assert.equal(log.includes("\x1bc"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("split terminal reset controls are dropped from replay data", () => {
|
||
|
|
const sanitizer = createReplaySafeTerminalLogSanitizer();
|
||
|
|
|
||
|
|
const log = sanitizer.append("before\x1b")
|
||
|
|
+ sanitizer.append("cafter\n")
|
||
|
|
+ sanitizer.finish();
|
||
|
|
|
||
|
|
assert.equal(log, "before\r\n\r\nafter\n");
|
||
|
|
assert.equal(log.includes("\x1bc"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("non-clear cursor and color controls are preserved", () => {
|
||
|
|
const input = "\x1b[H\x1b[31mred\x1b[0m\n";
|
||
|
|
|
||
|
|
assert.equal(createReplaySafeTerminalLog(input), input);
|
||
|
|
});
|