Files
NetMesh/components/terminal/runtime/terminalLineTimestamps.test.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

1003 lines
35 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
);
});