|
|
|
@@ -1,5 +1,6 @@
|
|
|
|
|
import assert from "node:assert/strict";
|
|
|
|
|
import test from "node:test";
|
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
DEFAULT_OUTPUT_HISTORY_MAX_LINES,
|
|
|
|
@@ -159,6 +160,58 @@ test("preview rows wrap long lines and flag the continuation rows", () => {
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("preview rows flag lines committed by automatic wraps as soft-wrapped", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
// "abcde" fills the viewport and "f" wraps; the transcript commits both
|
|
|
|
|
// lines, but the preview must keep the soft-wrapped join between them.
|
|
|
|
|
history.append("abcdef\n");
|
|
|
|
|
assert.deepEqual([...history.getLines()], ["abcde", "f"]);
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
history.getPreviewRows({ cols: 5, rows: 2, top: 0 }).rows.map((row) => row.isWrapped),
|
|
|
|
|
[false, true],
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("preview reflows adjacent soft-wrapped lines together when widened", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
// Captured at five columns, the transcript holds two wrap segments; a
|
|
|
|
|
// ten-column preview must rejoin them into the single row xterm shows
|
|
|
|
|
// after its resize reflow.
|
|
|
|
|
history.append("abcdef\n");
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows.map((row) => row.text),
|
|
|
|
|
["abcdef"],
|
|
|
|
|
);
|
|
|
|
|
// A narrower preview keeps splitting the rejoined run, flagging the
|
|
|
|
|
// continuation rows.
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
history.getPreviewRows({ cols: 3, rows: 2, top: 0 }).rows.map((row) => row.text),
|
|
|
|
|
["abc", "def"],
|
|
|
|
|
);
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
history.getPreviewRows({ cols: 3, rows: 2, top: 0 }).rows.map((row) => row.isWrapped),
|
|
|
|
|
[false, true],
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("CUB moves from the displayed last column while a wrap is deferred", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
// "abcde" fills the row and defers the wrap; xterm displays the cursor on
|
|
|
|
|
// the last column, so CUB 1 targets it minus one and X overwrites "e".
|
|
|
|
|
history.append("abcde\x1b[DX");
|
|
|
|
|
assert.deepEqual([...history.getLines()], ["abcXe"]);
|
|
|
|
|
history.clear();
|
|
|
|
|
// CUF from the same deferred state still clamps to the last column.
|
|
|
|
|
history.append("abcde\x1b[CX");
|
|
|
|
|
assert.deepEqual([...history.getLines()], ["abcdX"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("preview windows clamp to the retained rows and pad short output", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.append("one\ntwo\n");
|
|
|
|
@@ -184,6 +237,32 @@ test("preview rows keep wide characters intact at a column boundary", () => {
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("wrap decisions measure with the live terminal's Unicode width provider", () => {
|
|
|
|
|
// The configured `15-graphemes` runtime counts `🖥` as one xterm cell while
|
|
|
|
|
// the local fallback counts two, so without the injected provider the
|
|
|
|
|
// tracker commits `abcd` and puts `🖥X` on the continuation row while xterm
|
|
|
|
|
// renders `abcd🖥` with `X` wrapped. The preview must match the terminal.
|
|
|
|
|
const widthTerm = {
|
|
|
|
|
_core: {
|
|
|
|
|
unicodeService: { getStringCellWidth: (s: string) => [...s].length },
|
|
|
|
|
},
|
|
|
|
|
} as never;
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
history.setWidthTerminal(widthTerm);
|
|
|
|
|
history.append("abcd🖥X");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["abcd🖥", "X"]);
|
|
|
|
|
|
|
|
|
|
const preview = createTerminalOutputHistoryPreview();
|
|
|
|
|
preview.setWidthTerminal(widthTerm);
|
|
|
|
|
preview.append("abcd🖥X\n");
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
preview.getPreviewRows({ cols: 5, rows: 2, top: 0 }).rows.map((row) => row.text),
|
|
|
|
|
["abcd🖥", "X"],
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("wheel steps walk the preview from the newest row upwards", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
for (let index = 0; index < 6; index += 1) history.append(`row ${index}\n`);
|
|
|
|
@@ -233,4 +312,571 @@ test("default retention bounds the preview history", () => {
|
|
|
|
|
const transcript = [...history.getLines()].join("");
|
|
|
|
|
assert.ok(transcript.length <= 2 * 6, transcript);
|
|
|
|
|
assert.ok(transcript.includes("cc"), transcript);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
test("Vim absolute row moves separate printed rows across arbitrary chunks", () => {
|
|
|
|
|
const input = '\x1b[2;1HSMOKE-VIM-002\x1b[2;14H\x1b[K\x1b[3;1HSMOKE-VIM-003\x1b[3;14H\x1b[K\x1b[4;1HSMOKE-VIM-004';
|
|
|
|
|
for (let split = 0; split <= input.length; split++) {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.append(input.slice(0, split));
|
|
|
|
|
history.append(input.slice(split));
|
|
|
|
|
assert.deepEqual(history.getLines(), ['SMOKE-VIM-002', 'SMOKE-VIM-003', 'SMOKE-VIM-004']);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("same-row cursor-home redraw stays one progress line", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
for (let i = 0; i < 100; i++) history.append(`\x1b[1;1Hprogress ${i}\x1b[K`);
|
|
|
|
|
assert.deepEqual(history.getLines(), ['progress 99']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("vertical moves do not insert blank transcript rows or expose control strings", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.append('one\r\n\x1b[2;1Htwo\x1b[1Bthree\x9b4;1Hfour');
|
|
|
|
|
history.append('\x1b]titleH\x07\x1b[999999999Bfive');
|
|
|
|
|
assert.deepEqual(history.getLines(), ['one', 'two', ' three', 'four', ' five']);
|
|
|
|
|
history.clear();
|
|
|
|
|
history.append('\x1b[Hnew\x1b[HNEW\x1b[K');
|
|
|
|
|
assert.deepEqual(history.getLines(), ['NEW']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("row controls retain requested and inherited terminal columns", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.append('\x1b[2;10Htext\x1b[1Bnext\x1b[6dlast\x1b[1Eleft');
|
|
|
|
|
assert.deepEqual(history.getLines(), [' text', ' next', ' last', 'left']);
|
|
|
|
|
history.clear();
|
|
|
|
|
history.append('\x9b2;3fAB\x1b[2;3HXY');
|
|
|
|
|
assert.deepEqual(history.getLines(), [' XY']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("positioned output respects wide-cell boundaries and retained size limits", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview({ maxChars: 64 });
|
|
|
|
|
history.append('\x1b[2;3H中文\x1b[2;5HX\x1b[K');
|
|
|
|
|
assert.deepEqual(history.getLines(), [' 中X']);
|
|
|
|
|
history.clear();
|
|
|
|
|
history.append('\x1b[2;999999999Hend');
|
|
|
|
|
assert.ok(history.getLines().join('').length <= 128);
|
|
|
|
|
assert.ok(history.getLines().join('').endsWith('end'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("CHA and relative horizontal moves stay on the tracked row", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.append('abcde\x1b[1GX');
|
|
|
|
|
assert.deepEqual(history.getLines(), ['Xbcde']);
|
|
|
|
|
history.clear();
|
|
|
|
|
history.append('ab\x1b[3CX');
|
|
|
|
|
assert.deepEqual(history.getLines(), ['ab X']);
|
|
|
|
|
history.clear();
|
|
|
|
|
history.append('abcde\x1b[2DX');
|
|
|
|
|
assert.deepEqual(history.getLines(), ['abcXe']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("NEL and IND advance the row; RI moves back up", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.append('foo\x1bEbar');
|
|
|
|
|
assert.deepEqual(history.getLines(), ['foo', 'bar']);
|
|
|
|
|
history.clear();
|
|
|
|
|
history.append('foo\x1bDbar');
|
|
|
|
|
assert.deepEqual(history.getLines(), ['foo', ' bar']);
|
|
|
|
|
history.clear();
|
|
|
|
|
history.append('\x1b[r\x1b[3;1Hfoo\x1bMbar');
|
|
|
|
|
assert.deepEqual(history.getLines(), ['foo', ' bar']);
|
|
|
|
|
history.clear();
|
|
|
|
|
history.append('\x1b[2;3r\x1b[2;1Hfoo\x1bMbar');
|
|
|
|
|
assert.deepEqual(history.getLines(), ['foo', ' bar']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test("cursor placement alone does not append blank history rows", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.append('abc\x1b[2;4H\x1b[1B\x1b[1B');
|
|
|
|
|
assert.deepEqual(history.getLines(), ['abc']);
|
|
|
|
|
history.append('X');
|
|
|
|
|
assert.deepEqual(history.getLines(), ['abc', ' X']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("positioned overwrites replace whole graphemes and preserve cell spacing", () => {
|
|
|
|
|
for (const glyph of ['😀', '👩💻', '中']) {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.append(`A${glyph}B\x1b[1;2HX`);
|
|
|
|
|
assert.deepEqual(history.getLines(), ['AX B']);
|
|
|
|
|
history.clear();
|
|
|
|
|
history.append(`A${glyph}B\x1b[1;3HX`);
|
|
|
|
|
assert.deepEqual(history.getLines(), ['A XB']);
|
|
|
|
|
}
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.append('AéB\x1b[1;2HX');
|
|
|
|
|
assert.deepEqual(history.getLines(), ['AXB']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("EL 1 erases a wide glyph intersected by the erase boundary", () => {
|
|
|
|
|
// The cursor lands on the wide glyph's first cell: xterm blanks both cells
|
|
|
|
|
// and keeps the suffix at its columns.
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.append('中A\x1b[1G\x1b[1K');
|
|
|
|
|
assert.deepEqual(history.getLines(), [' A']);
|
|
|
|
|
history.clear();
|
|
|
|
|
// The cursor lands on the wide glyph's second cell: the glyph is erased too.
|
|
|
|
|
history.append('A中B\x1b[1;3H\x1b[1K');
|
|
|
|
|
assert.deepEqual(history.getLines(), [' B']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("cursor-row moves clamp to the reported terminal viewport", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
// Bottom-row address + a down move past the viewport: the terminal keeps
|
|
|
|
|
// the cursor on row 24, so the status redraw must not split history lines.
|
|
|
|
|
history.append("\x1b[24;1Hstatus\x1b[1BNEW");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["statusNEW"]);
|
|
|
|
|
history.clear();
|
|
|
|
|
// Absolute rows beyond the viewport clamp to the bottom row too: the redraw
|
|
|
|
|
// overwrites the bottom row in place instead of adding a history line.
|
|
|
|
|
history.append("\x1b[24;1Hrow24\x1b[999;1Hredraw");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["redraw"]);
|
|
|
|
|
history.clear();
|
|
|
|
|
// Without a reported viewport the legacy behavior applies (columns are
|
|
|
|
|
// retained across row moves).
|
|
|
|
|
const unclamped = createTerminalOutputHistoryPreview();
|
|
|
|
|
unclamped.append("\x1b[24;1Hstatus\x1b[1BNEW");
|
|
|
|
|
assert.deepEqual(unclamped.getLines(), ["status", " NEW"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("absolute cursor columns clamp to the reported viewport width", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(80);
|
|
|
|
|
// CUP past the last column clamps to it on screen; without the clamp the
|
|
|
|
|
// preview would pad 998 spaces and fabricate wrapped rows.
|
|
|
|
|
history.append("\x1b[1;999HX");
|
|
|
|
|
assert.deepEqual(history.getLines(), [" ".repeat(79) + "X"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("line feeds on the bottom row stay clamped to it", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.append("\x1b[24;1Hstatus\r\nnew\x1b[24;1HNEW");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["status", "NEW"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("shrinking the viewport clamps the tracked cursor row", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.append("\x1b[24;1Hbottom");
|
|
|
|
|
history.setViewportRows(10);
|
|
|
|
|
// The terminal moved the cursor to the new bottom row, so a relative move
|
|
|
|
|
// from there must stay a same-row redraw instead of committing a line.
|
|
|
|
|
history.append("\x1b[1BX");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["bottomX"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("relative row moves stop at the scroll region's bottom margin", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.append("\x1b[1;20r\x1b[20;1Hstatus\x1b[1BNEW");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["statusNEW"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("invalid DECSTBM ranges are ignored", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
// Bottom not greater than top: xterm ignores the sequence, so the existing
|
|
|
|
|
// text must stay on its row and later text must not home the cursor.
|
|
|
|
|
history.append("\x1b[5;1Hbefore\x1b[20;10rafter");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["beforeafter"]);
|
|
|
|
|
history.clear();
|
|
|
|
|
// A top past the viewport clamps to it, leaving no valid region either.
|
|
|
|
|
history.append("\x1b[5;1Hbefore\x1b[30;40rafter");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["beforeafter"]);
|
|
|
|
|
history.clear();
|
|
|
|
|
// A bottom past the viewport clamps to it; the valid region still applies.
|
|
|
|
|
history.append("\x1b[1;999r\x1b[24;1Hstatus\x1b[999BNEW");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["statusNEW"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("growing the viewport resets the scroll margins", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.append("\x1b[1;24r");
|
|
|
|
|
history.setViewportRows(40);
|
|
|
|
|
// xterm resets the scroll region on resize, so a relative move past row 24
|
|
|
|
|
// reaches row 25 instead of stopping at the stale bottom margin.
|
|
|
|
|
history.append("\x1b[24;1Hrow24\x1b[1Brow25");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["row24", " row25"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("clear resets retained scroll margins between terminal boots", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.append("\x1b[1;20robsolete");
|
|
|
|
|
history.clear();
|
|
|
|
|
// The fresh xterm instance has a full-viewport region; the new session must
|
|
|
|
|
// not inherit the previous boot's bottom margin.
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.append("\x1b[20;1Hrow20\x1b[1Brow21");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["row20", " row21"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("narrowing the viewport clamps the tracked cursor column", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(80);
|
|
|
|
|
history.append("\x1b[1;80H");
|
|
|
|
|
history.setViewportCols(10);
|
|
|
|
|
// xterm clamps the cursor to the new last column; the next printable span
|
|
|
|
|
// must be written there, not padded out to the old column 80.
|
|
|
|
|
history.append("X");
|
|
|
|
|
assert.deepEqual(history.getLines(), [" ".repeat(9) + "X"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("narrowing the viewport trims the tracked cursor row like xterm", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(10);
|
|
|
|
|
history.append("abcdefgh");
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
// xterm trims the cursor row to the new width on a narrowing resize
|
|
|
|
|
// (`reflowCursorLine` defaults to false, so `fgh` is discarded instead of
|
|
|
|
|
// re-wrapped), so X overwrites the last retained cell rather than leaving
|
|
|
|
|
// a phantom `fgh` tail plus an extra wrapped preview row.
|
|
|
|
|
history.append("X");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["abcdX"]);
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
history.getPreviewRows({ cols: 5, rows: 1, top: 0 }).rows.map((row) => row.text),
|
|
|
|
|
["abcdX"],
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("narrowing the viewport drops the trimmed wide-glyph tail", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(6);
|
|
|
|
|
history.append("中文");
|
|
|
|
|
history.setViewportCols(3);
|
|
|
|
|
// The second glyph spans cells 2-3 past the new width; xterm discards it
|
|
|
|
|
// whole, so the open row keeps only the glyph that still fits and the next
|
|
|
|
|
// printable span lands on the new last column.
|
|
|
|
|
assert.deepEqual(history.getLines(), ["中"]);
|
|
|
|
|
history.append("X");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["中X"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("printable output past the viewport width wraps the tracked cursor", () => {
|
|
|
|
|
// xterm wraps `f` onto the second row, so `CSI 2;1H` overwrites it there
|
|
|
|
|
// instead of adding a separate line below a stale wrapped row.
|
|
|
|
|
const input = "abcdef\x1b[2;1HXY";
|
|
|
|
|
for (let split = 0; split <= input.length; split++) {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
history.append(input.slice(0, split));
|
|
|
|
|
history.append(input.slice(split));
|
|
|
|
|
assert.deepEqual(history.getLines(), ["abcde", "XY"]);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a carriage return cancels the deferred wrap instead of splitting the row", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
history.append("abcde\rXYZ");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["XYZde"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("wide glyphs wrap whole at the viewport width", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(4);
|
|
|
|
|
history.append("中文中文中文\n");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["中文", "中文", "中文"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("origin mode resolves absolute rows against the scroll region", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
// In origin mode both addresses target row 20 (top margin + 15, then the
|
|
|
|
|
// bottom-margin clamp), so the redraw overwrites `status` in place instead
|
|
|
|
|
// of retaining it as a stale separate line.
|
|
|
|
|
history.append("\x1b[5;20r\x1b[?6h\x1b[16;1Hstatus\x1b[99;1HNEW");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["NEWtus"]);
|
|
|
|
|
history.clear();
|
|
|
|
|
// clear() forgets origin mode: rows are absolute again.
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.append("\x1b[5;20r\x1b[?6h\x1b[?6l\x1b[5;1Habsolute");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["absolute"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("non-wrapping wide glyph at the right edge cannot block terminal capture", () => {
|
|
|
|
|
const moduleUrl = new URL("./terminalOutputHistory.ts", import.meta.url).href;
|
|
|
|
|
const script = `
|
|
|
|
|
import { createTerminalOutputHistoryPreview } from ${JSON.stringify(moduleUrl)};
|
|
|
|
|
const results = [];
|
|
|
|
|
for (const glyph of ["中", "👩💻", "界界"]) {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
history.append(String.fromCharCode(27) + "[?7labcd" + glyph + "Z");
|
|
|
|
|
results.push(history.getLines());
|
|
|
|
|
}
|
|
|
|
|
console.log(JSON.stringify(results));
|
|
|
|
|
`;
|
|
|
|
|
const result = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], {
|
|
|
|
|
encoding: "utf8",
|
|
|
|
|
timeout: 5000,
|
|
|
|
|
});
|
|
|
|
|
assert.equal(result.error, undefined, String(result.error));
|
|
|
|
|
assert.equal(result.status, 0, result.stderr);
|
|
|
|
|
assert.deepEqual(JSON.parse(result.stdout), [["abcdZ"], ["abcdZ"], ["abcdZ"]]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("unchanged viewport reports preserve pending wrap across display chunks", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
history.append("abcde");
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
history.append("f");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["abcde", "f"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a zero-width mark arriving after the wrap column joins the final cell", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
// The base character fills the last column; its combining mark arrives in
|
|
|
|
|
// the next chunk. xterm attaches the mark to that cell without wrapping.
|
|
|
|
|
history.append("abcde");
|
|
|
|
|
history.append("́Z\n");
|
|
|
|
|
assert.deepEqual([...history.getLines()], ["abcdé", "Z"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a standalone zero-width mark after a control starts the wrapped row", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
// The SGR control xterm parses resets its preceding-join state, so the
|
|
|
|
|
// combining mark that follows is a standalone grapheme: it must not attach
|
|
|
|
|
// to the previous row's final cell; the pending wrap happens and the mark
|
|
|
|
|
// begins the next row (like xterm's `15-graphemes` runtime).
|
|
|
|
|
history.append("abcde\x1b[31m");
|
|
|
|
|
history.append("́Z\n");
|
|
|
|
|
assert.deepEqual([...history.getLines()], ["abcde", "́Z"]);
|
|
|
|
|
// The same holds when the control rides inside one display chunk.
|
|
|
|
|
history.clear();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
history.append("abcde\x1b[31ḿZ\n");
|
|
|
|
|
assert.deepEqual([...history.getLines()], ["abcde", "́Z"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("combined private-mode controls apply every parameter", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
// `CSI ?6;7l` resets both origin mode and DECAWM in one control; dropping
|
|
|
|
|
// the control whole would leave autowrap on and wrap the overflow into a
|
|
|
|
|
// second row (xterm keeps `abcdef` on one row as `abcdf`).
|
|
|
|
|
history.append("\x1b[5;20r\x1b[?6;7l\x1b[5;1Habcdef");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["abcdf"]);
|
|
|
|
|
// `CSI ?6;7h` re-enables both: absolute rows clamp to the margins again and
|
|
|
|
|
// printable output wraps once more.
|
|
|
|
|
history.append("\x1b[?6;7h\x1b[99;1HNEW");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["abcdf", "NEW"]);
|
|
|
|
|
// Unrelated parameters ride along: xterm applies both `CSI ?6;25h` params
|
|
|
|
|
// (25 is DECTCEM, untracked here), so dropping the control whole would
|
|
|
|
|
// resolve the later CUP row absolutely instead of against the top margin.
|
|
|
|
|
const combined = createTerminalOutputHistoryPreview();
|
|
|
|
|
combined.setViewportRows(24);
|
|
|
|
|
combined.append("\x1b[5;20r\x1b[?6;25h\x1b[16;1Hstatus\x1b[99;1HOLD");
|
|
|
|
|
assert.deepEqual(combined.getLines(), ["OLDtus"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a grapheme longer than the per-row piece cap is not dropped", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
// One base character trailed by more combining marks than the piece cap
|
|
|
|
|
// holds: cutting at a grapheme boundary yields an empty slice for it, and
|
|
|
|
|
// returning early would silently drop this grapheme and everything after.
|
|
|
|
|
const mark = "́";
|
|
|
|
|
history.append("abcd" + "e" + mark.repeat(80) + "Z");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["abcde" + mark.repeat(80), "Z"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("discarding a non-fitting wide glyph does not re-pad the deferred gap", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
// Cursor on the last column, DECAWM off: the wide glyph is discarded and the
|
|
|
|
|
// next narrow character overwrites the last cell without padding it again.
|
|
|
|
|
history.append("\x1b[?7l\x1b[1;5H中Z");
|
|
|
|
|
assert.deepEqual(history.getLines(), [" Z"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("restoring a cursor saved above a shrunken viewport clamps to the bottom row", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.append("\x1b[24;1Hsaved\x1b7");
|
|
|
|
|
history.setViewportRows(10);
|
|
|
|
|
history.append("\x1b8X");
|
|
|
|
|
// The saved row 24 no longer exists; the restore lands on the bottom row so
|
|
|
|
|
// the later bottom-row address writes `Y` on the same line as `X` instead of
|
|
|
|
|
// keeping an obsolete extra line (the saved column is preserved).
|
|
|
|
|
history.append("\x1b[10;1HY");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["saved", "Y X"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("RIS resets the tracked modes, margins, and cursor", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
// DECAWM off, then RIS: xterm re-enables autowrap and homes the cursor, so
|
|
|
|
|
// `abcdef` wraps to `abcde`/`f` exactly like a fresh terminal.
|
|
|
|
|
history.append("\x1b[?7l\x1bcabcdef");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["abcde", "f"]);
|
|
|
|
|
|
|
|
|
|
// RIS also clears the scroll region, DECOM, and the saved cursor.
|
|
|
|
|
const reset = createTerminalOutputHistoryPreview();
|
|
|
|
|
reset.setViewportRows(24);
|
|
|
|
|
reset.append("\x1b[5;20r\x1b[?6h\x1b7\x1bc\x1b[99;1HOLD");
|
|
|
|
|
// Without the reset, the CUP row would be relative to the top margin and
|
|
|
|
|
// clamp to its bottom (row 20), rewriting the same tracked line as before.
|
|
|
|
|
assert.deepEqual(reset.getLines(), ["OLD"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("RIS commits the open row even when the cursor is already homed", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(20);
|
|
|
|
|
// xterm clears the display before homing the cursor, so output after RIS
|
|
|
|
|
// must not overwrite a stale suffix of the cleared screen row.
|
|
|
|
|
history.append("LONG\x1bcX");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["LONG", "X"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a grapheme split across display chunks is rejoined", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(5);
|
|
|
|
|
history.setViewportCols(4);
|
|
|
|
|
// The backend delivered the ZWJ sequence's base in one chunk and its
|
|
|
|
|
// continuation in the next; xterm's grapheme provider joins them across
|
|
|
|
|
// writes, so the preview must measure the joined cluster, not the chunks.
|
|
|
|
|
history.append("👩");
|
|
|
|
|
history.append("💻Z");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["👩💻Z"]);
|
|
|
|
|
|
|
|
|
|
// The unsplit input is the reference behavior.
|
|
|
|
|
const joined = createTerminalOutputHistoryPreview();
|
|
|
|
|
joined.setViewportRows(5);
|
|
|
|
|
joined.setViewportCols(4);
|
|
|
|
|
joined.append("👩💻Z");
|
|
|
|
|
assert.deepEqual(joined.getLines(), ["👩💻Z"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a control between chunks resets the split-grapheme join", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(5);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
// xterm resets its preceding-join state at every control it parses, so an
|
|
|
|
|
// SGR between the base chunk and the continuation leaves two graphemes:
|
|
|
|
|
// 👩 (2 cells) + 💻 (2 cells) + Z fill the row before Q wraps.
|
|
|
|
|
history.append("👩");
|
|
|
|
|
history.append("\x1b[31m");
|
|
|
|
|
history.append("💻ZQ");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["👩💻Z", "Q"]);
|
|
|
|
|
|
|
|
|
|
// The unsplit input with the same control is the reference behavior.
|
|
|
|
|
const joined = createTerminalOutputHistoryPreview();
|
|
|
|
|
joined.setViewportRows(5);
|
|
|
|
|
joined.setViewportCols(5);
|
|
|
|
|
joined.append("👩\x1b[31m💻ZQ");
|
|
|
|
|
assert.deepEqual(joined.getLines(), ["👩💻Z", "Q"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a width-only resize resets the tracked scroll margins", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(80);
|
|
|
|
|
history.append("\x1b[1;20r");
|
|
|
|
|
history.append("row20content");
|
|
|
|
|
// xterm resets its scroll region on any buffer resize; a width-only resize
|
|
|
|
|
// must clear the old DECSTBM margins so relative moves clamp to the live
|
|
|
|
|
// bottom row instead of the region's stale one.
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(40);
|
|
|
|
|
history.append("\x1b[20;1HX\x1b[BY");
|
|
|
|
|
assert.deepEqual(history.getLines(), ["row20content", "X", " Y"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test("preview reflow retains leading, consecutive and trailing blank hard lines", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
history.append("\nabcdef\n\n\nend\n\n");
|
|
|
|
|
assert.deepEqual([...history.getLines()], ["", "abcde", "f", "", "", "end", ""]);
|
|
|
|
|
const preview = history.getPreviewRows({ cols: 10, rows: 6, top: 0 });
|
|
|
|
|
assert.equal(preview.totalRows, 6);
|
|
|
|
|
assert.deepEqual(preview.rows.map((row) => row.text), ["", "abcdef", "", "", "end", ""]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("blank-only history remains distinct from empty history", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
assert.equal(history.getPreviewRowCount(10), 0);
|
|
|
|
|
history.append("\n\n");
|
|
|
|
|
assert.equal(history.getPreviewRowCount(10), 2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("reflow keeps an erase-blanked wrapped predecessor as its own row", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
// At five columns `abcde` fills row one and defers the wrap; `CSI 2K`
|
|
|
|
|
// blanks it and `X` wraps to row two, so xterm shows a blank first row and
|
|
|
|
|
// a wrapped `X` on the second. The preview must keep both rows instead of
|
|
|
|
|
// joining the erased predecessor into its continuation.
|
|
|
|
|
history.append("abcde\x1b[2KX");
|
|
|
|
|
assert.deepEqual([...history.getLines()], ["", "X"]);
|
|
|
|
|
const preview = history.getPreviewRows({ cols: 5, rows: 2, top: 0 });
|
|
|
|
|
assert.equal(preview.totalRows, 2);
|
|
|
|
|
assert.deepEqual(preview.rows.map((row) => row.text), ["", "X"]);
|
|
|
|
|
assert.deepEqual(preview.rows.map((row) => row.isWrapped), [false, true]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a grapheme wider than the viewport wraps the following characters", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(1);
|
|
|
|
|
// xterm renders the wide glyph on the first row and wraps `X` to the
|
|
|
|
|
// second; retaining the whole tail as one row would shift every later
|
|
|
|
|
// cursor-row transition one row behind the terminal.
|
|
|
|
|
history.append("中X");
|
|
|
|
|
assert.deepEqual([...history.getLines()], ["中", "X"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test("resizing invalidates cached preview text even when returning to its old width", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(10);
|
|
|
|
|
history.append("abcdefgh");
|
|
|
|
|
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows,
|
|
|
|
|
[{ text: "abcdefgh", isWrapped: false }]);
|
|
|
|
|
// A hidden preview is not read between these resizes. Returning to the
|
|
|
|
|
// cached width must not resurrect characters trimmed from the live row.
|
|
|
|
|
history.setViewportCols(5);
|
|
|
|
|
history.setViewportCols(10);
|
|
|
|
|
assert.deepEqual(history.getLines(), ["abcde"]);
|
|
|
|
|
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows,
|
|
|
|
|
[{ text: "abcde", isWrapped: false }]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("resizing invalidates cached preview row counts after trimming a wide line", () => {
|
|
|
|
|
const history = createTerminalOutputHistoryPreview();
|
|
|
|
|
history.setViewportRows(24);
|
|
|
|
|
history.setViewportCols(10);
|
|
|
|
|
history.append("abcdefgh");
|
|
|
|
|
assert.equal(history.getPreviewRowCount(4), 2);
|
|
|
|
|
history.setViewportCols(3);
|
|
|
|
|
assert.equal(history.getPreviewRowCount(4), 1);
|
|
|
|
|
assert.deepEqual(history.getPreviewRows({ cols: 4, rows: 1, top: 0 }).rows,
|
|
|
|
|
[{ text: "abc", isWrapped: false }]);
|
|
|
|
|
});
|