Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1003 lines
35 KiB
TypeScript
1003 lines
35 KiB
TypeScript
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
|
||
import {
|
||
MAX_TERMINAL_LINE_TIMESTAMP_ENTRIES,
|
||
applyAltScreenAction,
|
||
createTerminalLineTimestampSegmenter,
|
||
formatTerminalLineTimestamp,
|
||
getTerminalLineTimestampLedgerCount,
|
||
getVisibleTerminalLineTimestampRows,
|
||
isSimpleAsciiControlText,
|
||
onTerminalLineTimestampsChange,
|
||
resolveTerminalLineTimestampCapacity,
|
||
resolveTerminalTimestampGutterRows,
|
||
resolveTerminalTimestampGutterRowsFromLedger,
|
||
tryMeasureVisualRows,
|
||
writeTerminalDataWithLineTimestamps,
|
||
type StampCursorEstimate,
|
||
} from "./terminalLineTimestamps.ts";
|
||
import { MAX_INCOMPLETE_TERMINAL_CONTROL_SEQUENCE_CHARS } from "./terminalControlSequenceLimits.ts";
|
||
|
||
const createFakeTerm = (options: {
|
||
cols?: number;
|
||
wraparoundMode?: boolean;
|
||
scrollback?: number;
|
||
rows?: number;
|
||
} = {}) => {
|
||
const writes: string[] = [];
|
||
const markerLines: number[] = [];
|
||
const disposedMarkerLines: number[] = [];
|
||
const liveMarkers: Array<{ line: number; isDisposed: boolean; dispose: () => void }> = [];
|
||
let cursorLine = 0;
|
||
const cols = options.cols ?? Number.POSITIVE_INFINITY
|
||
let wraparoundMode = options.wraparoundMode ?? true;
|
||
const scrollback = options.scrollback;
|
||
const rows = options.rows ?? 24;
|
||
const isCombiningMark = (char: string): boolean => {
|
||
const code = char.codePointAt(0);
|
||
return code !== undefined && /\p{Mark}/u.test(String.fromCodePoint(code));
|
||
};
|
||
const cellWidth = (char: string): number => {
|
||
const code = char.codePointAt(0);
|
||
if (code === undefined) return 1;
|
||
if (isCombiningMark(char)) return 0;
|
||
if (
|
||
code === 0x2329
|
||
|| code === 0x232a
|
||
|| (code >= 0x1100 && code <= 0x115f)
|
||
|| (code >= 0x2e80 && code <= 0x303e)
|
||
|| (code >= 0x3041 && code <= 0x33ff)
|
||
|| (code >= 0x3400 && code <= 0x4dbf)
|
||
|| (code >= 0x4e00 && code <= 0x9fff)
|
||
|| (code >= 0xac00 && code <= 0xd7a3)
|
||
|| (code >= 0xf900 && code <= 0xfaff)
|
||
|| (code >= 0xff00 && code <= 0xff60)
|
||
|| (code >= 0x1f000 && code <= 0x1f02f)
|
||
|| (code >= 0x1f300 && code <= 0x1faff)
|
||
) {
|
||
return 2;
|
||
}
|
||
return 1;
|
||
};
|
||
const readCsiSequence = (data: string, startIndex: number): { sequence: string; endIndex: number } | null => {
|
||
if (data[startIndex] !== "\x1b" || data[startIndex + 1] !== "[") return null;
|
||
for (let index = startIndex + 2; index < data.length; index += 1) {
|
||
const char = data[index];
|
||
if (char >= "@" && char <= "~") {
|
||
return { sequence: data.slice(startIndex, index + 1), endIndex: index };
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
const applyCsiSequence = (sequence: string): void => {
|
||
const final = sequence.at(-1);
|
||
const firstParam = Number.parseInt(sequence.slice(2, -1).split(";")[0] || "1", 10);
|
||
const count = Number.isFinite(firstParam) && firstParam > 0 ? firstParam : 1;
|
||
if (sequence === "\x1b[?7h") {
|
||
wraparoundMode = true;
|
||
} else if (sequence === "\x1b[?7l") {
|
||
wraparoundMode = false;
|
||
} else if (final === "A") {
|
||
cursorLine = Math.max(0, cursorLine - count);
|
||
} else if (final === "B") {
|
||
cursorLine += count;
|
||
}
|
||
};
|
||
const unicodeService = {
|
||
wcwidth(codePoint: number) {
|
||
if (this !== unicodeService) {
|
||
throw new Error("wcwidth must be called with its unicode service receiver");
|
||
}
|
||
return cellWidth(String.fromCodePoint(codePoint));
|
||
},
|
||
};
|
||
// Approximate xterm: dual buffers (normal + alternate). Active switches on
|
||
// 1049h/l; buffer.normal always exposes the saved normal-buffer cursor so
|
||
// stamp placement can restore correctly when a write starts already on alt.
|
||
const maxBufferLines = Number.isFinite(scrollback) && scrollback !== undefined && scrollback >= 0
|
||
? scrollback + rows
|
||
: Number.POSITIVE_INFINITY;
|
||
type BufferState = {
|
||
absoluteCursorLine: number;
|
||
baseY: number;
|
||
column: number;
|
||
lineText: Map<number, string>;
|
||
};
|
||
const normalState: BufferState = {
|
||
absoluteCursorLine: 0,
|
||
baseY: 0,
|
||
column: 0,
|
||
lineText: new Map(),
|
||
};
|
||
const altState: BufferState = {
|
||
absoluteCursorLine: 0,
|
||
baseY: 0,
|
||
column: 0,
|
||
lineText: new Map(),
|
||
};
|
||
let screen: "normal" | "alternate" = "normal";
|
||
const currentState = (): BufferState => (screen === "alternate" ? altState : normalState);
|
||
/** When null, viewport follows bottom (baseY). Tests may pin a scroll-up offset. */
|
||
let viewportYOverride: number | null = null;
|
||
|
||
// When not yet full, length grows with content but never below viewport.
|
||
// When full, length stays at maxBufferLines and baseY tracks the trim offset.
|
||
const resolveLength = (state: BufferState): number => {
|
||
if (!Number.isFinite(maxBufferLines)) {
|
||
return Math.max(rows, state.absoluteCursorLine + 1);
|
||
}
|
||
return Math.min(
|
||
maxBufferLines,
|
||
Math.max(rows, state.absoluteCursorLine - state.baseY + 1),
|
||
);
|
||
};
|
||
|
||
const trimScrollbackIfNeeded = (state: BufferState) => {
|
||
if (!Number.isFinite(maxBufferLines)) return;
|
||
while (state.absoluteCursorLine - state.baseY + 1 > maxBufferLines) {
|
||
state.baseY += 1;
|
||
}
|
||
if (screen !== "normal") return;
|
||
const keepFromAbsolute = state.baseY;
|
||
for (const marker of liveMarkers) {
|
||
if (!marker.isDisposed && marker.line < keepFromAbsolute) {
|
||
marker.dispose();
|
||
}
|
||
}
|
||
for (const key of [...state.lineText.keys()]) {
|
||
if (key < keepFromAbsolute) state.lineText.delete(key);
|
||
}
|
||
};
|
||
|
||
// buffer.normal: always the primary buffer (xterm keeps this while alt is active).
|
||
const normalBuffer = {
|
||
type: "normal" as const,
|
||
get viewportY() {
|
||
return viewportYOverride ?? normalState.baseY;
|
||
},
|
||
get baseY() {
|
||
return normalState.baseY;
|
||
},
|
||
get cursorY() {
|
||
return Math.max(0, normalState.absoluteCursorLine - normalState.baseY);
|
||
},
|
||
get cursorX() {
|
||
return normalState.column;
|
||
},
|
||
get length() {
|
||
return resolveLength(normalState);
|
||
},
|
||
getLine: (line?: number) => {
|
||
const absolute = typeof line === "number" ? line : normalState.absoluteCursorLine;
|
||
const text = normalState.lineText.get(absolute) ?? "";
|
||
return {
|
||
isWrapped: false,
|
||
translateToString: (_trimRight?: boolean) => text,
|
||
};
|
||
},
|
||
};
|
||
|
||
// active.type must reflect the current screen for alt-screen gates.
|
||
const activeBuffer = {
|
||
get type() {
|
||
return screen;
|
||
},
|
||
set type(value: string) {
|
||
screen = value === "alternate" ? "alternate" : "normal";
|
||
},
|
||
get viewportY() {
|
||
return viewportYOverride ?? currentState().baseY;
|
||
},
|
||
set viewportY(value: number) {
|
||
viewportYOverride = Math.max(0, value);
|
||
},
|
||
get baseY() {
|
||
return currentState().baseY;
|
||
},
|
||
set baseY(value: number) {
|
||
currentState().baseY = Math.max(0, value);
|
||
},
|
||
get cursorY() {
|
||
const state = currentState();
|
||
return Math.max(0, state.absoluteCursorLine - state.baseY);
|
||
},
|
||
set cursorY(value: number) {
|
||
const state = currentState();
|
||
state.absoluteCursorLine = state.baseY + Math.max(0, value);
|
||
cursorLine = state.absoluteCursorLine;
|
||
},
|
||
get cursorX() {
|
||
return currentState().column;
|
||
},
|
||
set cursorX(value: number) {
|
||
currentState().column = Math.max(0, value);
|
||
},
|
||
get length() {
|
||
return resolveLength(currentState());
|
||
},
|
||
getLine: (line?: number) => {
|
||
const state = currentState();
|
||
const absolute = typeof line === "number" ? line : state.absoluteCursorLine;
|
||
const text = state.lineText.get(absolute) ?? "";
|
||
return {
|
||
isWrapped: false,
|
||
translateToString: (_trimRight?: boolean) => text,
|
||
};
|
||
},
|
||
};
|
||
|
||
const enterAlternate = () => {
|
||
if (screen === "alternate") return;
|
||
// Save normal cursor (already in normalState); reset alt surface.
|
||
altState.absoluteCursorLine = 0;
|
||
altState.baseY = 0;
|
||
altState.column = 0;
|
||
altState.lineText.clear();
|
||
screen = "alternate";
|
||
cursorLine = 0;
|
||
};
|
||
|
||
const leaveAlternate = () => {
|
||
if (screen !== "alternate") return;
|
||
screen = "normal";
|
||
cursorLine = normalState.absoluteCursorLine;
|
||
};
|
||
|
||
const term = {
|
||
_core: {
|
||
unicodeService,
|
||
},
|
||
buffer: {
|
||
active: activeBuffer,
|
||
normal: normalBuffer,
|
||
},
|
||
cols,
|
||
options: Number.isFinite(scrollback) ? { scrollback } : {},
|
||
get modes() {
|
||
return { wraparoundMode };
|
||
},
|
||
rows,
|
||
write(data: string, callback?: () => void) {
|
||
writes.push(data);
|
||
for (let index = 0; index < data.length; index += 1) {
|
||
const sequence = readCsiSequence(data, index);
|
||
if (sequence) {
|
||
if (
|
||
sequence.sequence === "\x1b[?1049h"
|
||
|| sequence.sequence === "\x1b[?47h"
|
||
|| sequence.sequence === "\x1b[?1047h"
|
||
) {
|
||
enterAlternate();
|
||
index = sequence.endIndex;
|
||
continue;
|
||
}
|
||
if (
|
||
sequence.sequence === "\x1b[?1049l"
|
||
|| sequence.sequence === "\x1b[?47l"
|
||
|| sequence.sequence === "\x1b[?1047l"
|
||
) {
|
||
leaveAlternate();
|
||
index = sequence.endIndex;
|
||
continue;
|
||
}
|
||
applyCsiSequence(sequence.sequence);
|
||
currentState().absoluteCursorLine = cursorLine;
|
||
index = sequence.endIndex;
|
||
continue;
|
||
}
|
||
const state = currentState();
|
||
const char = data[index];
|
||
if (char === "\n") {
|
||
state.absoluteCursorLine += 1;
|
||
cursorLine = state.absoluteCursorLine;
|
||
state.column = Number.isFinite(cols) && state.column >= cols
|
||
? cols - 1
|
||
: 0;
|
||
trimScrollbackIfNeeded(state);
|
||
} else if (char === "\r") {
|
||
state.column = 0;
|
||
} else if (char === "\b") {
|
||
state.column = Math.max(0, state.column - 1);
|
||
} else if (char === "\t") {
|
||
if (state.column < cols) {
|
||
const nextTabStop = state.column + (8 - (state.column % 8));
|
||
state.column = Math.min(nextTabStop, cols - 1);
|
||
}
|
||
} else if (isCombiningMark(char)) {
|
||
continue;
|
||
} else if (char < " " || char === "\u007f") {
|
||
continue;
|
||
} else {
|
||
const code = data.codePointAt(index);
|
||
const isEmojiVariationSequence = code === 0x2764 && data.codePointAt(index + 1) === 0xfe0f;
|
||
const width = isEmojiVariationSequence ? 2 : cellWidth(char);
|
||
if (isEmojiVariationSequence) {
|
||
index += 1;
|
||
}
|
||
if (wraparoundMode && state.column + width > cols) {
|
||
state.absoluteCursorLine += 1;
|
||
cursorLine = state.absoluteCursorLine;
|
||
state.column = 0;
|
||
trimScrollbackIfNeeded(state);
|
||
}
|
||
const existing = state.lineText.get(state.absoluteCursorLine) ?? "";
|
||
state.lineText.set(
|
||
state.absoluteCursorLine,
|
||
existing + (isEmojiVariationSequence ? "❤️" : char),
|
||
);
|
||
state.column = Number.isFinite(cols)
|
||
? Math.min(cols, state.column + width)
|
||
: state.column + width;
|
||
}
|
||
}
|
||
cursorLine = currentState().absoluteCursorLine;
|
||
trimScrollbackIfNeeded(currentState());
|
||
callback?.();
|
||
},
|
||
registerMarker(offset: number) {
|
||
// Markers attach to the normal buffer in production for our ledger path
|
||
// after leave; while on alt, offset is still relative to active cursor.
|
||
const state = currentState();
|
||
const line = state.absoluteCursorLine + offset;
|
||
markerLines.push(line);
|
||
const marker = {
|
||
line,
|
||
isDisposed: false,
|
||
dispose() {
|
||
if (marker.isDisposed) return;
|
||
marker.isDisposed = true;
|
||
disposedMarkerLines.push(line);
|
||
},
|
||
};
|
||
liveMarkers.push(marker);
|
||
return marker;
|
||
},
|
||
};
|
||
|
||
return {
|
||
term,
|
||
writes,
|
||
markerLines,
|
||
disposedMarkerLines,
|
||
liveMarkers,
|
||
/** Test helper: inspect / force xterm-like baseY growth on the normal buffer. */
|
||
getBaseY: () => normalState.baseY,
|
||
setBaseY: (value: number) => {
|
||
normalState.baseY = Math.max(0, value);
|
||
},
|
||
setViewportY: (value: number) => {
|
||
viewportYOverride = Math.max(0, value);
|
||
},
|
||
getAbsoluteCursorLine: () => currentState().absoluteCursorLine,
|
||
/** Force already-on-alt with a saved normal cursor (skeptic multi-write path). */
|
||
enterAlternateWithSavedNormal: (savedNormalLine: number, savedNormalColumn = 0) => {
|
||
normalState.absoluteCursorLine = Math.max(0, savedNormalLine);
|
||
normalState.column = Math.max(0, savedNormalColumn);
|
||
normalState.lineText.set(normalState.absoluteCursorLine, "shell-prompt");
|
||
enterAlternate();
|
||
// Simulate a tall TUI: alt cursor deep in the alternate buffer.
|
||
altState.absoluteCursorLine = 40;
|
||
altState.column = 0;
|
||
cursorLine = 40;
|
||
},
|
||
getNormalAbsoluteLine: () => normalState.absoluteCursorLine,
|
||
};
|
||
};
|
||
|
||
test("segments terminal output into raw bytes plus timestamp markers", () => {
|
||
const segmenter = createTerminalLineTimestampSegmenter({
|
||
now: () => new Date(2026, 5, 6, 9, 8, 7),
|
||
});
|
||
|
||
assert.deepEqual(segmenter.append("hello\r\nnext"), [
|
||
{ kind: "timestamp", label: "09:08:07" },
|
||
{ kind: "data", data: "hello\r\n" },
|
||
{ kind: "timestamp", label: "09:08:07" },
|
||
{ kind: "data", data: "next" },
|
||
]);
|
||
});
|
||
|
||
test("does not create timestamp markers for alternate screen output", () => {
|
||
const segmenter = createTerminalLineTimestampSegmenter({
|
||
now: () => new Date(2026, 5, 6, 9, 8, 7),
|
||
});
|
||
|
||
assert.deepEqual(segmenter.append("\x1b[?1049hvim\r\ntext"), [
|
||
{ kind: "data", data: "\x1b[?1049hvim\r\ntext" },
|
||
]);
|
||
assert.deepEqual(segmenter.append("\x1b[?1049lprompt"), [
|
||
{ kind: "data", data: "\x1b[?1049l" },
|
||
{ kind: "timestamp", label: "09:08:07" },
|
||
{ kind: "data", data: "prompt" },
|
||
]);
|
||
});
|
||
|
||
test("preserves OSC prompt prefixes terminated by C1 string terminator", () => {
|
||
const segmenter = createTerminalLineTimestampSegmenter({
|
||
now: () => new Date(2026, 5, 6, 9, 8, 7),
|
||
});
|
||
|
||
assert.deepEqual(segmenter.append("\x1b]0;server\u009calice@server:~$ "), [
|
||
{ kind: "data", data: "\x1b]0;server\u009c" },
|
||
{ kind: "timestamp", label: "09:08:07" },
|
||
{ kind: "data", data: "alice@server:~$ " },
|
||
]);
|
||
});
|
||
|
||
test("preserves split OSC prompt prefixes terminated by C1 string terminator", () => {
|
||
const segmenter = createTerminalLineTimestampSegmenter({
|
||
now: () => new Date(2026, 5, 6, 9, 8, 7),
|
||
});
|
||
|
||
assert.deepEqual(segmenter.append("\x1b]7;file://server/home/alice"), []);
|
||
assert.deepEqual(segmenter.append("\u009calice@server:~$ "), [
|
||
{ kind: "data", data: "\x1b]7;file://server/home/alice\u009c" },
|
||
{ kind: "timestamp", label: "09:08:07" },
|
||
{ kind: "data", data: "alice@server:~$ " },
|
||
]);
|
||
});
|
||
|
||
test("resolves visible timestamp rows from marker lines", () => {
|
||
assert.deepEqual(
|
||
resolveTerminalTimestampGutterRows({
|
||
viewportY: 10,
|
||
rows: 4,
|
||
entries: [
|
||
{ marker: { line: 9 }, label: "before" },
|
||
{ marker: { line: 10 }, label: "10:00:00" },
|
||
{ marker: { line: 12 }, label: "10:00:02" },
|
||
{ marker: { line: 14 }, label: "after" },
|
||
],
|
||
}),
|
||
[
|
||
{ row: 0, label: "10:00:00" },
|
||
{ row: 2, label: "10:00:02" },
|
||
],
|
||
);
|
||
});
|
||
|
||
test("resolves timestamp rows for wrapped continuations", () => {
|
||
assert.deepEqual(
|
||
resolveTerminalTimestampGutterRows({
|
||
viewportY: 11,
|
||
rows: 4,
|
||
entries: [
|
||
{ marker: { line: 10 }, label: "10:00:10" },
|
||
{ marker: { line: 13 }, label: "10:00:13" },
|
||
],
|
||
isWrappedLine: (line) => line === 11 || line === 12,
|
||
}),
|
||
[
|
||
{ row: 0, label: "10:00:10" },
|
||
{ row: 1, label: "10:00:10" },
|
||
{ row: 2, label: "10:00:13" },
|
||
],
|
||
);
|
||
});
|
||
|
||
test("formats timestamp labels without terminal escape codes", () => {
|
||
assert.equal(formatTerminalLineTimestamp(new Date(2026, 5, 6, 1, 2, 3)), "01:02:03");
|
||
});
|
||
|
||
|
||
test("gutter off records a per-second ledger with a sparse reflow anchor", () => {
|
||
const { term, writes, markerLines } = createFakeTerm();
|
||
const t0 = new Date(2026, 5, 6, 12, 0, 0);
|
||
writeTerminalDataWithLineTimestamps(term as never, "before\r\nnext\r\nthird", () => {}, {
|
||
enabled: false,
|
||
timestampDate: t0,
|
||
});
|
||
|
||
assert.equal(writes.join(""), "before\r\nnext\r\nthird");
|
||
// One wall-clock second → one sparse anchor (not per-line markers).
|
||
assert.equal(markerLines.length, 1);
|
||
assert.equal(getTerminalLineTimestampLedgerCount(term as never), 1);
|
||
});
|
||
|
||
test("ledger keeps one stamp per wall-clock second whether gutter is on or off", () => {
|
||
const { term, markerLines } = createFakeTerm();
|
||
writeTerminalDataWithLineTimestamps(term as never, "a\r\nb\r\n", () => {}, {
|
||
enabled: true,
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 0),
|
||
});
|
||
writeTerminalDataWithLineTimestamps(term as never, "c\r\nd\r\n", () => {}, {
|
||
enabled: false,
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 1),
|
||
});
|
||
|
||
// Sparse anchors only: one registerMarker per stamped second.
|
||
assert.equal(markerLines.length, 2);
|
||
assert.equal(getTerminalLineTimestampLedgerCount(term as never), 2);
|
||
});
|
||
|
||
test("gutter paint fills blank rows with the previous stamp time", () => {
|
||
const { term } = createFakeTerm();
|
||
writeTerminalDataWithLineTimestamps(term as never, "a\r\nb\r\n", () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 0),
|
||
});
|
||
writeTerminalDataWithLineTimestamps(term as never, "c\r\n", () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 1),
|
||
});
|
||
|
||
const rows = getVisibleTerminalLineTimestampRows(term as never);
|
||
// line0 stamp 12:00:00 fills line1; line2 stamp 12:00:01.
|
||
assert.deepEqual(
|
||
rows.filter((row) => row.row <= 2).map((row) => ({ row: row.row, label: row.label })),
|
||
[
|
||
{ row: 0, label: "12:00:00" },
|
||
{ row: 1, label: "12:00:00" },
|
||
{ row: 2, label: "12:00:01" },
|
||
],
|
||
);
|
||
});
|
||
|
||
test("ledger notifies listeners once when a new second is stamped", () => {
|
||
const { term } = createFakeTerm();
|
||
let notifications = 0;
|
||
const unsubscribe = onTerminalLineTimestampsChange(term as never, () => {
|
||
notifications += 1;
|
||
});
|
||
|
||
writeTerminalDataWithLineTimestamps(term as never, "one\r\ntwo\r\nthree", () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 0),
|
||
});
|
||
writeTerminalDataWithLineTimestamps(term as never, " continued", () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 0),
|
||
});
|
||
writeTerminalDataWithLineTimestamps(term as never, "\r\nnext-second\r\n", () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 1),
|
||
});
|
||
unsubscribe();
|
||
|
||
assert.equal(getTerminalLineTimestampLedgerCount(term as never), 2);
|
||
assert.equal(notifications, 2);
|
||
});
|
||
|
||
test("large multi-line dump in one second still uses a single ledger stamp", () => {
|
||
const { term, writes, markerLines } = createFakeTerm();
|
||
const lines = Array.from({ length: 80 }, (_, index) => `line-${index}`).join("\r\n");
|
||
|
||
writeTerminalDataWithLineTimestamps(term as never, lines, () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 0),
|
||
});
|
||
|
||
assert.deepEqual(writes, [lines]);
|
||
assert.equal(markerLines.length, 1);
|
||
assert.equal(getTerminalLineTimestampLedgerCount(term as never), 1);
|
||
});
|
||
|
||
test("soft-wrapped long line places the next-second stamp after visual rows", () => {
|
||
// cols=10: "abcdefghij" is one full row; "klmnopqrst" wraps to a second row, then \n.
|
||
const { term, liveMarkers } = createFakeTerm({ cols: 10, rows: 24 });
|
||
writeTerminalDataWithLineTimestamps(term as never, `${"x".repeat(25)}\r\n`, () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 0),
|
||
});
|
||
writeTerminalDataWithLineTimestamps(term as never, "next\r\n", () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 1),
|
||
});
|
||
|
||
assert.equal(getTerminalLineTimestampLedgerCount(term as never), 2);
|
||
// 25 chars @ width 10 → rows 0,1,2 for first logical line; "next" on line 3.
|
||
// Second stamp must not share line 0 with the first (hard-\n-only bug).
|
||
const lines = liveMarkers.map((marker) => marker.line).sort((a, b) => a - b);
|
||
assert.equal(lines[0], 0);
|
||
assert.ok(
|
||
lines[1]! >= 3,
|
||
`expected second stamp at or after line 3 after soft-wrap, got ${JSON.stringify(lines)}`,
|
||
);
|
||
|
||
const painted = getVisibleTerminalLineTimestampRows(term as never);
|
||
assert.ok(painted.some((row) => row.label === "12:00:00"));
|
||
assert.ok(painted.some((row) => row.label === "12:00:01"));
|
||
});
|
||
|
||
test("reflow updates painted rows from sparse anchor marker.line", () => {
|
||
const { term, liveMarkers } = createFakeTerm({ cols: 80, rows: 24, scrollback: 1000 });
|
||
writeTerminalDataWithLineTimestamps(term as never, "early\r\n", () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 0),
|
||
});
|
||
writeTerminalDataWithLineTimestamps(term as never, "late\r\n", () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 1),
|
||
});
|
||
assert.equal(liveMarkers.length, 2);
|
||
|
||
// Simulate xterm reflow: markers move to new absolute lines.
|
||
liveMarkers[0]!.line = 5;
|
||
liveMarkers[1]!.line = 12;
|
||
|
||
const painted = getVisibleTerminalLineTimestampRows(term as never);
|
||
const byRow = new Map(painted.map((row) => [row.row, row.label]));
|
||
// viewportY=0 → buffer line === row for these anchors.
|
||
assert.equal(byRow.get(5), "12:00:00");
|
||
assert.equal(byRow.get(12), "12:00:01");
|
||
// Fill-forward between anchors.
|
||
assert.equal(byRow.get(8), "12:00:00");
|
||
});
|
||
|
||
test("does not withhold output when an OSC sequence is split across chunks", () => {
|
||
const { term, writes } = createFakeTerm();
|
||
const callbacks: string[] = [];
|
||
|
||
writeTerminalDataWithLineTimestamps(
|
||
term as never,
|
||
"\x1b]7;file://server/home/alice",
|
||
() => callbacks.push("first"),
|
||
);
|
||
writeTerminalDataWithLineTimestamps(
|
||
term as never,
|
||
"\u009calice@server:~$ ",
|
||
() => callbacks.push("second"),
|
||
);
|
||
|
||
assert.deepEqual(callbacks, ["first", "second"]);
|
||
assert.ok(writes.join("").includes("alice@server:~$ "));
|
||
});
|
||
|
||
test("drops an oversized unterminated control prefix and resumes timestamp parsing", () => {
|
||
const segmenter = createTerminalLineTimestampSegmenter({
|
||
now: () => new Date(2026, 5, 6, 12, 0, 0),
|
||
});
|
||
const malformed = `\x1b[?1049${"9".repeat(MAX_INCOMPLETE_TERMINAL_CONTROL_SEQUENCE_CHARS)}`;
|
||
|
||
const overflow = segmenter.append(malformed);
|
||
assert.equal(segmenter.flushPendingEscapeSequence(), "");
|
||
assert.equal(
|
||
overflow.filter((segment) => segment.kind === "data").reduce((sum, segment) => sum + segment.data.length, 0),
|
||
malformed.length,
|
||
);
|
||
|
||
const recovered = segmenter.append("ready\r\n");
|
||
assert.equal(recovered.some((segment) => segment.kind === "timestamp"), true);
|
||
assert.equal(recovered.some((segment) => segment.kind === "data" && segment.data.includes("ready")), true);
|
||
});
|
||
|
||
test("does not timestamp output suspended on the alternate screen", () => {
|
||
const { term } = createFakeTerm();
|
||
writeTerminalDataWithLineTimestamps(term as never, "\x1b[?1049hvim screen", () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 0),
|
||
});
|
||
assert.equal(getTerminalLineTimestampLedgerCount(term as never), 0);
|
||
});
|
||
|
||
test("records a ledger stamp after leaving alternate screen", () => {
|
||
const { term } = createFakeTerm();
|
||
// Start as if already on alt screen via segmenter enter then leave in data
|
||
writeTerminalDataWithLineTimestamps(
|
||
term as never,
|
||
"\x1b[?1049hframe\x1b[?1049lprompt line\r\n",
|
||
() => {},
|
||
{ timestampDate: new Date(2026, 5, 6, 12, 0, 0) },
|
||
);
|
||
assert.ok(getTerminalLineTimestampLedgerCount(term as never) >= 1);
|
||
});
|
||
|
||
test("applyAltScreenAction never rewinds normal-buffer line or column", () => {
|
||
// Pure truth table: enter/leave × altActive; line/col immutable.
|
||
const base: StampCursorEstimate = {
|
||
absoluteLine: 7,
|
||
column: 3,
|
||
wraparoundMode: true,
|
||
altActive: false,
|
||
};
|
||
const cases: Array<{
|
||
name: string;
|
||
start: StampCursorEstimate;
|
||
action: "enter" | "leave";
|
||
expectAlt: boolean;
|
||
}> = [
|
||
{
|
||
name: "enter while normal",
|
||
start: { ...base, altActive: false },
|
||
action: "enter",
|
||
expectAlt: true,
|
||
},
|
||
{
|
||
name: "enter while already alt",
|
||
start: { ...base, altActive: true },
|
||
action: "enter",
|
||
expectAlt: true,
|
||
},
|
||
{
|
||
name: "leave while alt",
|
||
start: { ...base, altActive: true },
|
||
action: "leave",
|
||
expectAlt: false,
|
||
},
|
||
{
|
||
name: "spurious leave while already normal",
|
||
start: { ...base, altActive: false, absoluteLine: 7, column: 3 },
|
||
action: "leave",
|
||
expectAlt: false,
|
||
},
|
||
];
|
||
for (const row of cases) {
|
||
const next = applyAltScreenAction(row.start, row.action);
|
||
assert.equal(next.absoluteLine, row.start.absoluteLine, row.name);
|
||
assert.equal(next.column, row.start.column, row.name);
|
||
assert.equal(next.wraparoundMode, row.start.wraparoundMode, row.name);
|
||
assert.equal(next.altActive, row.expectAlt, row.name);
|
||
}
|
||
// enter then leave preserves line/col from mid-walk freeze point.
|
||
const afterEnter = applyAltScreenAction({ ...base, absoluteLine: 1 }, "enter");
|
||
const afterLeave = applyAltScreenAction(afterEnter, "leave");
|
||
assert.equal(afterLeave.absoluteLine, 1);
|
||
assert.equal(afterLeave.altActive, false);
|
||
});
|
||
|
||
// --- Three write-path integrations for alt/seed/leave (shipped entry) ---
|
||
|
||
test("alt-screen newlines do not inflate post-exit stamp anchor lines", () => {
|
||
const { term, liveMarkers } = createFakeTerm({ rows: 24, scrollback: 1000 });
|
||
// Many hard newlines inside the alt screen must not push the restored
|
||
// normal-buffer stamp down (would mis-pin sparse reflow anchors).
|
||
const payload = `\x1b[?1049h${"frame\n".repeat(30)}\x1b[?1049lprompt\r\n`;
|
||
writeTerminalDataWithLineTimestamps(term as never, payload, () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 0),
|
||
});
|
||
|
||
assert.equal(getTerminalLineTimestampLedgerCount(term as never), 1);
|
||
assert.equal(liveMarkers.length, 1);
|
||
assert.equal(
|
||
liveMarkers[0]?.line,
|
||
0,
|
||
`expected stamp on restored normal buffer line 0, got ${liveMarkers[0]?.line}`,
|
||
);
|
||
});
|
||
|
||
test("same-chunk normal advance before enter is kept after leave for post-TUI stamp", () => {
|
||
// Leading hard newline advances the normal estimate to line 1 with no stamp
|
||
// yet; enter freezes that estimate; leave must not rewind to line 0.
|
||
const { term, liveMarkers } = createFakeTerm({ rows: 24, scrollback: 1000, cols: 80 });
|
||
const payload = `\r\n\x1b[?1049h${"frame\n".repeat(20)}\x1b[?1049lprompt\r\n`;
|
||
writeTerminalDataWithLineTimestamps(term as never, payload, () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 0),
|
||
});
|
||
|
||
assert.equal(getTerminalLineTimestampLedgerCount(term as never), 1);
|
||
assert.equal(liveMarkers.length, 1);
|
||
assert.equal(
|
||
liveMarkers[0]?.line,
|
||
1,
|
||
`expected prompt stamp on advanced normal line 1, got ${liveMarkers[0]?.line}`,
|
||
);
|
||
});
|
||
|
||
test("spurious leave without enter keeps same-chunk normal advance for prompt stamp", () => {
|
||
// Skeptic: '\r\n\x1b[?1049lprompt\r\n' must stamp line 1, not rewind to 0.
|
||
const { term, liveMarkers } = createFakeTerm({ rows: 24, scrollback: 1000, cols: 80 });
|
||
writeTerminalDataWithLineTimestamps(
|
||
term as never,
|
||
"\r\n\x1b[?1049lprompt\r\n",
|
||
() => {},
|
||
{ timestampDate: new Date(2026, 5, 6, 12, 0, 0) },
|
||
);
|
||
assert.equal(getTerminalLineTimestampLedgerCount(term as never), 1);
|
||
assert.equal(liveMarkers.length, 1);
|
||
assert.equal(
|
||
liveMarkers[0]?.line,
|
||
1,
|
||
`expected stamp on line 1 after leading \\n + spurious leave, got ${liveMarkers[0]?.line}`,
|
||
);
|
||
});
|
||
|
||
test("already-on-alt leave stamps on saved normal line not deep alt cursor", () => {
|
||
const fake = createFakeTerm({ rows: 24, scrollback: 1000 });
|
||
const { term, liveMarkers, disposedMarkerLines } = fake;
|
||
|
||
// Normal-buffer banner first (saves a real primary-buffer line).
|
||
writeTerminalDataWithLineTimestamps(term as never, "banner\r\n", () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 0),
|
||
});
|
||
const savedNormalLine = fake.getNormalAbsoluteLine();
|
||
assert.ok(savedNormalLine >= 1);
|
||
|
||
// Simulate a multi-write TUI session: already on alt with a deep alt cursor
|
||
// while buffer.normal still holds the shell line.
|
||
fake.enterAlternateWithSavedNormal(savedNormalLine, 0);
|
||
assert.equal((term.buffer.active as { type: string }).type, "alternate");
|
||
assert.equal(fake.getAbsoluteCursorLine(), 40);
|
||
|
||
// Alt-only output must not add stamps (segmenter suspended).
|
||
writeTerminalDataWithLineTimestamps(term as never, `${"frame\n".repeat(15)}`, () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 1),
|
||
});
|
||
assert.equal(getTerminalLineTimestampLedgerCount(term as never), 1);
|
||
|
||
// Leave alt + shell prompt — stamp must pin to saved normal line, not alt 40+.
|
||
writeTerminalDataWithLineTimestamps(term as never, "\x1b[?1049lprompt\r\n", () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 2),
|
||
});
|
||
|
||
assert.equal(
|
||
getTerminalLineTimestampLedgerCount(term as never),
|
||
2,
|
||
"post-TUI stamp must remain in the ledger (not dropped as past bufferLength)",
|
||
);
|
||
const live = liveMarkers.filter((marker) => !marker.isDisposed);
|
||
assert.ok(live.length >= 2);
|
||
const promptAnchor = live[live.length - 1]!;
|
||
assert.ok(
|
||
promptAnchor.line < 20,
|
||
`expected normal-buffer stamp, got alt-depth line ${promptAnchor.line}`,
|
||
);
|
||
assert.ok(
|
||
promptAnchor.line >= savedNormalLine - 1 && promptAnchor.line <= savedNormalLine + 2,
|
||
`expected stamp near saved normal line ${savedNormalLine}, got ${promptAnchor.line}`,
|
||
);
|
||
// The deep-alt mis-pin (line ~40) must not be the surviving prompt anchor.
|
||
assert.equal(disposedMarkerLines.includes(40), false);
|
||
assert.notEqual(promptAnchor.line, 40);
|
||
});
|
||
|
||
test("resolveTerminalTimestampGutterRowsFromLedger fills gaps with previous time", () => {
|
||
const rows = resolveTerminalTimestampGutterRowsFromLedger({
|
||
viewportY: 0,
|
||
rows: 5,
|
||
ledger: [
|
||
{ label: "12:00:00", secondKey: 1, line: 0 },
|
||
{ label: "12:00:01", secondKey: 2, line: 3 },
|
||
],
|
||
});
|
||
assert.deepEqual(rows, [
|
||
{ row: 0, label: "12:00:00" },
|
||
{ row: 1, label: "12:00:00" },
|
||
{ row: 2, label: "12:00:00" },
|
||
{ row: 3, label: "12:00:01" },
|
||
{ row: 4, label: "12:00:01" },
|
||
]);
|
||
});
|
||
|
||
test("resolveTerminalTimestampGutterRowsFromLedger does not paint past lastPaintLine", () => {
|
||
// Viewport is 10 rows but content only reaches line 2 — empty rows stay blank.
|
||
const rows = resolveTerminalTimestampGutterRowsFromLedger({
|
||
viewportY: 0,
|
||
rows: 10,
|
||
lastPaintLine: 2,
|
||
ledger: [
|
||
{ label: "12:00:00", secondKey: 1, line: 0 },
|
||
],
|
||
});
|
||
assert.deepEqual(rows, [
|
||
{ row: 0, label: "12:00:00" },
|
||
{ row: 1, label: "12:00:00" },
|
||
{ row: 2, label: "12:00:00" },
|
||
]);
|
||
});
|
||
|
||
test("gutter paint does not fill empty viewport rows past real content", () => {
|
||
// Fake buffer length is always >= rows (like xterm), even with few content lines.
|
||
const { term } = createFakeTerm({ rows: 24 });
|
||
writeTerminalDataWithLineTimestamps(term as never, "motd\r\nlogin\r\n", () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 0),
|
||
});
|
||
|
||
const painted = getVisibleTerminalLineTimestampRows(term as never);
|
||
// "motd\nlogin\n" → content on lines 0-1; cursor sits empty on line 2.
|
||
assert.ok(painted.length > 0);
|
||
assert.ok(
|
||
painted.every((row) => row.row <= 1),
|
||
`expected paint only on content rows, got rows: ${painted.map((r) => r.row).join(",")}`,
|
||
);
|
||
assert.equal(painted.at(-1)?.row, 1);
|
||
});
|
||
|
||
test("baseY growth while buffer is still growing does not drop early ledger stamps", () => {
|
||
// Large scrollback so a short session is not yet "full"; baseY may still rise
|
||
// as the viewport follows the cursor. Old logic rebased on every baseY delta
|
||
// and wiped MOTD stamps even though nothing was trimmed.
|
||
const fake = createFakeTerm({ rows: 10, scrollback: 1000 });
|
||
const { term } = fake;
|
||
|
||
writeTerminalDataWithLineTimestamps(term as never, "banner-line\r\n", () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 0),
|
||
});
|
||
writeTerminalDataWithLineTimestamps(term as never, "later-line\r\n", () => {}, {
|
||
timestampDate: new Date(2026, 5, 6, 12, 0, 5),
|
||
});
|
||
|
||
assert.equal(getTerminalLineTimestampLedgerCount(term as never), 2);
|
||
|
||
// Simulate mistaken "baseY follows cursor" growth while buffer not full.
|
||
fake.setBaseY(2);
|
||
// Scroll back to the top so paint includes the original banner line.
|
||
fake.setViewportY(0);
|
||
|
||
const painted = getVisibleTerminalLineTimestampRows(term as never);
|
||
// Early stamp must still fill-forward (not discarded by rebase).
|
||
assert.ok(
|
||
painted.some((row) => row.label === "12:00:00"),
|
||
`expected early stamp still visible, got ${JSON.stringify(painted)}`,
|
||
);
|
||
assert.equal(getTerminalLineTimestampLedgerCount(term as never), 2);
|
||
});
|
||
|
||
test("full-buffer scrollback trim rebases ledger and drops lines past the top", () => {
|
||
const fake = createFakeTerm({ rows: 4, scrollback: 4 });
|
||
const { term } = fake;
|
||
// maxLines = 4 + 4 = 8. Write enough distinct seconds to stamp many lines,
|
||
// then overflow so baseY grows while buffer is full.
|
||
|
||
for (let second = 0; second < 12; second += 1) {
|
||
writeTerminalDataWithLineTimestamps(
|
||
term as never,
|
||
`line-${second}\r\n`,
|
||
() => {},
|
||
{ timestampDate: new Date(2026, 5, 6, 12, 0, second) },
|
||
);
|
||
}
|
||
|
||
assert.ok(fake.getBaseY() > 0, "expected scrollback trim to raise baseY");
|
||
const ledgerCount = getTerminalLineTimestampLedgerCount(term as never);
|
||
assert.ok(ledgerCount > 0);
|
||
// Stamps for lines that scrolled off the top must be gone after rebase+filter.
|
||
const painted = getVisibleTerminalLineTimestampRows(term as never);
|
||
assert.ok(painted.length > 0);
|
||
// Every painted label should still be a valid HH:MM:SS from our window.
|
||
for (const row of painted) {
|
||
assert.match(row.label, /^12:00:\d{2}$/);
|
||
}
|
||
});
|
||
|
||
|
||
test("simple ASCII control text gate matches seq-style floods", () => {
|
||
assert.equal(isSimpleAsciiControlText("1\n2\n3\n"), true);
|
||
assert.equal(isSimpleAsciiControlText("line-0\r\nline-1\r\n"), true);
|
||
assert.equal(isSimpleAsciiControlText("a\tb\b c"), true);
|
||
assert.equal(isSimpleAsciiControlText("hello\x1b[0m"), false);
|
||
assert.equal(isSimpleAsciiControlText("界"), false);
|
||
});
|
||
test("tryMeasureVisualRows matches hard-newline accounting for short ASCII lines", () => {
|
||
const { term } = createFakeTerm({ cols: 80 });
|
||
const data = Array.from({ length: 100 }, (_, index) => `line-${index}`).join("\r\n");
|
||
const measured = tryMeasureVisualRows(term as never, data, 0, 80, true);
|
||
assert.ok(measured);
|
||
assert.equal(measured?.rowOffset, 99);
|
||
assert.equal(measured?.column, "line-99".length);
|
||
});
|
||
test("tryMeasureVisualRows accounts for soft wraps on long ASCII lines", () => {
|
||
const { term } = createFakeTerm({ cols: 5 });
|
||
const measured = tryMeasureVisualRows(term as never, "abcdefghij", 0, 5, true);
|
||
assert.ok(measured);
|
||
assert.equal(measured?.rowOffset, 1);
|
||
assert.equal(measured?.column, 5);
|
||
});
|
||
test("tryMeasureVisualRows rejects unmeasurable escape sequences", () => {
|
||
const { term } = createFakeTerm({ cols: 80 });
|
||
assert.equal(
|
||
tryMeasureVisualRows(term as never, "\x1b[Aup", 0, 80, true),
|
||
null,
|
||
);
|
||
});
|
||
test("capacity follows small scrollback and caps large histories", () => {
|
||
assert.equal(
|
||
resolveTerminalLineTimestampCapacity({
|
||
rows: 24,
|
||
options: { scrollback: 1000 },
|
||
} as never),
|
||
1000 + 24 + 64,
|
||
);
|
||
// Large scrollback values must not create a live xterm marker per retained row.
|
||
assert.equal(
|
||
resolveTerminalLineTimestampCapacity({
|
||
rows: 24,
|
||
options: { scrollback: 200000 },
|
||
} as never),
|
||
MAX_TERMINAL_LINE_TIMESTAMP_ENTRIES,
|
||
);
|
||
assert.equal(
|
||
resolveTerminalLineTimestampCapacity({
|
||
rows: 24,
|
||
options: { scrollback: 80000 },
|
||
} as never),
|
||
MAX_TERMINAL_LINE_TIMESTAMP_ENTRIES,
|
||
);
|
||
assert.equal(
|
||
resolveTerminalLineTimestampCapacity({
|
||
rows: 400,
|
||
options: { scrollback: 100000 },
|
||
} as never),
|
||
MAX_TERMINAL_LINE_TIMESTAMP_ENTRIES,
|
||
);
|
||
});
|