Add unit tests for parser reset, buffer scroll, ConPTY, and search (#5988)

* Add InputHandler unit tests for ISO-2022 charset mapping

InputHandler unit tests always used MockCharsetService, so regressions in
print()'s ASCII remapping, G0/G1 designation (ESC ( ) * +), and SO/SI
level switching were never caught at unit level.

Add an ISO-2022 character sets suite wired to real CharsetService and
BufferService that verifies DEC line drawing on G0 (ESC ( 0), G1 after
ESC ) 0 + SO, and charset/glevel restoration via ESC 7 / ESC 8 before
printing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add BufferService.scroll unit tests for isUserScrolling

BufferService.scroll() adjusts ybase and ydisp differently depending on
whether the circular buffer is full and whether isUserScrolling is set,
but no unit test asserted those branches. InputHandler and other callers
use MockBufferService.scroll, which throws, so real scroll viewport logic
was only exercised indirectly.

Add BufferService.test.ts covering full-buffer scroll with the user
scrolled up (ydisp decrements), partial-buffer scroll while scrolled up
(ydisp unchanged), and full-buffer scroll at the viewport bottom
(ydisp tracks ybase).

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add InputHandler unit tests for DECOM cursor positioning

CUP with DEC origin mode (DECOM) offsets rows by the scroll region top
and clamps within DECSTBM margins, but unit tests only toggled the mode
for save/restore. Playwright covered the combination; headless unit tests
did not.

Add a cursor positioning test matching the Playwright DECOM case: with
margins and origin on, CUP places the cursor relative to scrollTop, clamps
to the margin, and uses absolute rows when origin is off.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add WindowsMode unit tests for wrap heuristic edges

ConPTY does not set isWrapped on soft-wrapped lines; updateWindowsModeWrappedState
infers wrapping from the last cell of the previous row. Browser Terminal tests
exercise this only through full writes, with no direct coverage of the helper
or negative cases (trailing space or null).

Add WindowsMode.test.ts asserting isWrapped is set for a full non-space line,
cleared for trailing whitespace, and cleared when the previous line ends empty.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add OscParser.reset unit test for mid-payload abort

OscParser.reset() must call end(false) on active handlers when aborted
during PAYLOAD, but no test invoked reset() directly. EscapeSequenceParser
tests mock OSC, so production reset/cleanup was unverified at unit level.

Add a reset describe block asserting handlers receive END false after a
partial payload, then a subsequent OSC sequence completes normally.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add DcsParser.reset unit test for mid-payload abort

DcsParser.reset() must call unhook(false) on hooked handlers when aborted
mid-DCS, matching OscParser's end(false) cleanup. No DcsParser test called
reset() directly, leaving the abort path unverified at unit level.

Add a reset describe block asserting UNHOOK false after partial put, then a
subsequent DCS sequence completes with UNHOOK true.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add InputHandler print tests for grapheme joining

print() uses UnicodeService.shouldJoin to merge combining marks, but
InputHandler tests never exercised that path. The U+200B infinite-loop
regression test used mocks and had no assertions, so it would pass even
if print() misbehaved.

Add tests for e + combining acute in one parse and across two parses.
Strengthen the U+200B regression test to use BufferService and assert the
buffer does not grow spurious rows.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add CharsetService unit tests for glevel and designation

CharsetService only updates the active charset when setgCharset targets the
current glevel; most InputHandler tests use MockCharsetService instead. A
regression in setgLevel or inactive-slot designation would not be caught
without direct service tests.

Add CharsetService.test.ts covering inactive designation, setgLevel
activation, active-slot updates, and reset clearing state.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add reflowCursorLine unit tests for ConPTY resize behavior

reflowCursorLine lets resize reflow wrapped lines that contain the cursor
(needed when ConPTY does not). No test referenced the option or
reflowLargerGetLinesToRemove's cursor skip branch.

Add BufferReflow tests for skip vs reflow when the cursor is inside a
wrapped block, and Buffer.resize tests showing merge on widen only when
reflowCursorLine is enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add EscapeSequenceParser reset test for async pause recovery

reset() sets ParserStackType.RESET so the next parse continues in GROUND
at chunkPos + 1 without resuming a paused async handler. Existing async
tests only verified reset lifts FAIL after improper continuation, not
mid-chunk recovery with trailing printable data.

Add a test that pauses on async CSI, calls reset(), then continues parsing
and asserts XY is printed once and the stack returns to NONE.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add Buffer.resize unit test for ConPTY row growth

When rows increase on Windows ConPTY, Buffer.resize appends lines without
decrementing ybase or ydisp so the viewport stays stable while ConPTY
repaints. Buffer tests covered the non-Windows scroll-up path but never
passed windowsPty into options.

Add a ConPTY row-growth test asserting ybase, ydisp, and line count when
windowsPty is configured.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add Buffer reflow test for ConPTY buildNumber threshold

Buffer disables reflow on ConPTY when buildNumber is below 21376, but no
unit test asserted that gate. Column shrink without reflow leaves wrapped
content on a single line instead of splitting across rows.

Add a test comparing shrink behavior at build 21375 versus 21376, plus
retain unwrap-on-widen coverage for supported ConPTY builds.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add ApcParser.reset unit test for mid-payload abort

ApcParser.reset() calls end(false) on active handlers when aborted mid-APC,
matching OscParser and DcsParser, but no ApcParser test invoked reset()
directly.

Add a reset describe block asserting END false after partial put and a
clean subsequent APC sequence.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add InputHandler unit test for REP with grapheme clusters

repeatPrecedingCharacter uses parser.precedingJoinState and reprints the
full cluster from the buffer, but only Playwright covered e + combining
acute with CSI b. A regression could break REP for joined sequences without
failing unit tests.

Add a print-suite test asserting CSI 2 b triples e + acute in one parse.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add InputHandler unit tests for REP no-op cases

repeatPrecedingCharacter returns early when parser.precedingJoinState is
zero, including after another escape sequence clears join state. Only the
positive grapheme REP case was unit tested.

Add tests for bare CSI b and for REP following SGR so regressions cannot
spuriously duplicate buffer content.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add DecorationManager unit test for wrapped search matches

DecorationManager splits matches across wrapped rows with per-segment x
and width, but only SearchEngine had unit coverage; decoration geometry
was left to Playwright.

Add DecorationManager.test.ts using a real Terminal, a wrapped-buffer
match from SearchEngine, and assertions on registerDecoration options
for both row segments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add BufferService.scroll unit test for DECSTBM margins

When scrollTop is non-zero, scroll() shifts lines inside the margin with
shiftElements instead of pushing scrollback. Existing BufferService tests
only covered scrollTop === 0.

Add a test with margins on rows 1–3 that verifies rows outside the region
stay fixed and lines inside the region scroll correctly.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add BufferService.scrollLines unit tests

scrollLines adjusts ydisp and toggles isUserScrolling for viewport
scrolling, but only scroll() had unit coverage. Regressions in the
user-scroll flag or ydisp clamping would affect the whole viewport.

Add tests for scrolling up, clamping at ydisp 0, and clearing
isUserScrolling when reaching the buffer bottom.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add DecorationManager overview ruler dedup unit test

Multiple highlight matches on the same buffer line should only register
one overview ruler marker; later matches on that line omit ruler options.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Assert overview ruler on each line of wrapped search match

Wrapped highlight decorations span two buffer lines; each segment
should register overview ruler options unlike same-line dedup.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Daniel Imms
2026-06-02 12:41:54 +00:00
committed by GitHub
co-authored by Cursor
parent b3de9fa6db
commit 564113e053
11 changed files with 626 additions and 11 deletions
@@ -0,0 +1,81 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { DecorationManager } from './DecorationManager';
import { SearchEngine } from './SearchEngine';
import { SearchLineCache } from './SearchLineCache';
import { Terminal } from 'browser/public/Terminal';
import type { ISearchDecorationOptions } from '@xterm/addon-search';
import type { IDecorationOptions } from '@xterm/xterm';
import { DisposableStore } from 'common/Lifecycle';
function writeP(terminal: Terminal, data: string): Promise<void> {
return new Promise(r => terminal.write(data, r));
}
describe('DecorationManager', () => {
let store: DisposableStore;
let terminal: Terminal;
let decorationManager: DecorationManager;
beforeEach(() => {
store = new DisposableStore();
terminal = store.add(new Terminal({ cols: 10, rows: 5 }));
decorationManager = store.add(new DecorationManager(terminal));
});
it('should split highlight decorations for a wrapped match', async () => {
await writeP(terminal, '0123456789abcde');
const searchEngine = new SearchEngine(terminal, store.add(new SearchLineCache(terminal)));
const match = searchEngine.find('9abc', 0, 0);
assert.ok(match);
const decorationOptions: IDecorationOptions[] = [];
const registerDecoration = terminal.registerDecoration.bind(terminal);
terminal.registerDecoration = (options: IDecorationOptions) => {
decorationOptions.push(options);
return registerDecoration(options);
};
const options: ISearchDecorationOptions = {
matchOverviewRuler: '#ff0000',
activeMatchColorOverviewRuler: '#00ff00'
};
decorationManager.createHighlightDecorations([match], options);
assert.strictEqual(decorationOptions.length, 2);
assert.strictEqual(decorationOptions[0].x, 9);
assert.strictEqual(decorationOptions[0].width, 1);
assert.strictEqual(decorationOptions[1].x, 0);
assert.strictEqual(decorationOptions[1].width, 3);
const withOverviewRuler = decorationOptions.filter(o => o.overviewRulerOptions !== undefined);
assert.strictEqual(withOverviewRuler.length, 2);
});
it('should only add one overview ruler marker per buffer line', async () => {
await writeP(terminal, 'abcdefghij');
const decorationOptions: IDecorationOptions[] = [];
const registerDecoration = terminal.registerDecoration.bind(terminal);
terminal.registerDecoration = (options: IDecorationOptions) => {
decorationOptions.push(options);
return registerDecoration(options);
};
const options: ISearchDecorationOptions = {
matchOverviewRuler: '#ff0000',
activeMatchColorOverviewRuler: '#00ff00'
};
decorationManager.createHighlightDecorations([
{ term: 'a', col: 0, row: 0, size: 1 },
{ term: 'f', col: 5, row: 0, size: 1 }
], options);
const withOverviewRuler = decorationOptions.filter(o => o.overviewRulerOptions !== undefined);
assert.strictEqual(withOverviewRuler.length, 1);
assert.strictEqual(withOverviewRuler[0].x, 0);
});
});
+87 -10
View File
@@ -16,8 +16,10 @@ import { MockCoreService, MockBufferService, MockOptionsService, MockLogService,
import { IBufferService, ICoreService, type IOscLinkService } from './services/Services';
import { DEFAULT_OPTIONS } from './services/OptionsService';
import { BufferService } from './services/BufferService';
import { CharsetService } from './services/CharsetService';
import { CoreService } from './services/CoreService';
import { OscLinkService } from './services/OscLinkService';
import { CHARSETS } from './data/Charsets';
function getCursor(bufferService: IBufferService): number[] {
@@ -639,19 +641,38 @@ describe('InputHandler', () => {
});
describe('print', () => {
it('should not cause an infinite loop (regression test)', () => {
const inputHandler = new TestInputHandler(
new MockBufferService(80, 30),
new MockCharsetService(),
new MockCoreService(),
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
new MockMouseStateService(),
new MockUnicodeService()
);
const container = new Uint32Array(10);
container[0] = 0x200B;
const lineCountBefore = bufferService.buffer.lines.length;
inputHandler.print(container, 0, 1);
assert.strictEqual(bufferService.buffer.y, 0);
assert.strictEqual(bufferService.buffer.lines.length, lineCountBefore);
});
it('should join combining characters in a single print', async () => {
await inputHandler.parseP('e\u0301');
assert.strictEqual(bufferService.buffer.translateBufferLineToString(0, true), 'e\u0301');
assert.strictEqual(bufferService.buffer.x, 1);
});
it('should join combining characters split across parse calls', async () => {
await inputHandler.parseP('e');
await inputHandler.parseP('\u0301');
assert.strictEqual(bufferService.buffer.translateBufferLineToString(0, true), 'e\u0301');
assert.strictEqual(bufferService.buffer.x, 1);
});
it('should repeat preceding grapheme cluster via REP', async () => {
await inputHandler.parseP('e\u0301\x1b[2b');
assert.strictEqual(bufferService.buffer.translateBufferLineToString(0, true), 'e\u0301e\u0301e\u0301');
assert.strictEqual(bufferService.buffer.x, 3);
});
it('should not repeat when REP has no preceding join state', async () => {
await inputHandler.parseP('\x1b[2b');
assert.strictEqual(bufferService.buffer.translateBufferLineToString(0, true), '');
assert.strictEqual(bufferService.buffer.x, 0);
});
it('should not repeat after an intervening escape sequence', async () => {
await inputHandler.parseP('a\x1b[0m\x1b[2b');
assert.strictEqual(bufferService.buffer.translateBufferLineToString(0, true), 'a');
assert.strictEqual(bufferService.buffer.x, 1);
});
it('should clear cells to the right on early wrap-around', async () => {
bufferService.resize(5, 5);
@@ -668,6 +689,49 @@ describe('InputHandler', () => {
});
});
describe('ISO-2022 character sets', () => {
let charsetService: CharsetService;
beforeEach(() => {
charsetService = new CharsetService();
inputHandler = new TestInputHandler(
bufferService,
charsetService,
coreService,
new MockLogService(),
optionsService,
oscLinkService,
new MockMouseStateService(),
new MockUnicodeService()
);
});
it('should map G0 line drawing via ESC ( 0', async () => {
await inputHandler.parseP('\x1b(0q\x1b(Bq');
assert.strictEqual(bufferService.buffer.translateBufferLineToString(0, true), '\u2500q');
});
it('should map G1 line drawing after ESC ) 0 and SO', async () => {
await inputHandler.parseP('\x1b)0\x0eq\x0f\x1b(Bq');
assert.strictEqual(bufferService.buffer.translateBufferLineToString(0, true), '\u2500q');
});
it('should restore charset and glevel on ESC 7 / ESC 8', async () => {
await inputHandler.parseP('\x1b)0\x0e');
assert.strictEqual(charsetService.glevel, 1);
assert.strictEqual(charsetService.charset, CHARSETS['0']);
await inputHandler.parseP('\x1b7');
await inputHandler.parseP('\x0f\x1b(B');
assert.strictEqual(charsetService.glevel, 0);
assert.ok(charsetService.charset === undefined);
await inputHandler.parseP('\x1b8');
assert.strictEqual(charsetService.glevel, 1);
assert.strictEqual(charsetService.charset, CHARSETS['0']);
await inputHandler.parseP('q');
assert.strictEqual(bufferService.buffer.translateBufferLineToString(0, true), '\u2500');
});
});
describe('alt screen', () => {
let bufferService: IBufferService;
let handler: TestInputHandler;
@@ -1182,6 +1246,19 @@ describe('InputHandler', () => {
await inputHandler.parseP('\x1b[100;100H');
assert.deepEqual(getCursor(bufferService), [9, 9]);
});
it('cursor position (CUP) with DECOM and scroll margins', async () => {
await inputHandler.parseP('\x1b[?6h\x1b[2;3r\x1b[1;1H');
assert.deepEqual(getCursor(bufferService), [0, 1]);
await inputHandler.parseP('X');
assert.strictEqual(getLines(bufferService, 3)[1], 'X');
await inputHandler.parseP('\x1b[2;1H');
assert.deepEqual(getCursor(bufferService), [0, 2]);
await inputHandler.parseP('\x1b[10;10H');
assert.deepEqual(getCursor(bufferService), [9, 2]);
await inputHandler.parseP('\x1b[?6l');
await inputHandler.parseP('\x1b[2;1H');
assert.deepEqual(getCursor(bufferService), [0, 1]);
});
it('horizontal position absolute (HPA)', async () => {
await inputHandler.parseP('\x1b[`');
assert.deepEqual(getCursor(bufferService), [0, 0]);
+54
View File
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { DEFAULT_ATTR_DATA } from './buffer/BufferLine';
import { updateWindowsModeWrappedState } from './WindowsMode';
import { BufferService } from './services/BufferService';
import { OptionsService } from './services/OptionsService';
import { MockLogService } from './TestUtils.test';
describe('WindowsMode', () => {
describe('updateWindowsModeWrappedState', () => {
it('should mark the next line wrapped when the previous line ends in a non-whitespace character', () => {
const bufferService = new BufferService(new OptionsService({ rows: 5, cols: 10 }), new MockLogService());
const buffer = bufferService.buffer;
const previousLine = buffer.lines.get(buffer.ybase)!;
for (let i = 0; i < bufferService.cols; i++) {
previousLine!.setCellFromCodepoint(i, 'a'.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
}
buffer.y = 1;
updateWindowsModeWrappedState(bufferService);
assert.strictEqual(buffer.lines.get(buffer.ybase + 1)!.isWrapped, true);
});
it('should not mark the next line wrapped when the previous line ends in whitespace', () => {
const bufferService = new BufferService(new OptionsService({ rows: 5, cols: 10 }), new MockLogService());
const buffer = bufferService.buffer;
const previousLine = buffer.lines.get(buffer.ybase)!;
for (let i = 0; i < bufferService.cols - 1; i++) {
previousLine!.setCellFromCodepoint(i, 'a'.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
}
previousLine!.setCellFromCodepoint(bufferService.cols - 1, ' '.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
buffer.y = 1;
updateWindowsModeWrappedState(bufferService);
assert.strictEqual(buffer.lines.get(buffer.ybase + 1)!.isWrapped, false);
});
it('should not mark the next line wrapped when the previous line ends in a null cell', () => {
const bufferService = new BufferService(new OptionsService({ rows: 5, cols: 10 }), new MockLogService());
const buffer = bufferService.buffer;
buffer.y = 1;
updateWindowsModeWrappedState(bufferService);
assert.strictEqual(buffer.lines.get(buffer.ybase + 1)!.isWrapped, false);
});
});
});
+86
View File
@@ -233,6 +233,27 @@ describe('Buffer', () => {
assert.equal(buffer.lines.length, INIT_ROWS + 10);
});
});
describe('Windows ConPTY', () => {
beforeEach(() => {
optionsService.options.windowsPty = { backend: 'conpty', buildNumber: 19000 };
});
it('should not adjust ybase or ydisp when growing rows', () => {
buffer.fillViewportRows();
for (let i = 0; i < 10; i++) {
buffer.lines.push(buffer.getBlankLine(DEFAULT_ATTR_DATA));
}
buffer.y = INIT_ROWS - 1;
buffer.ybase = 10;
buffer.ydisp = 10;
const linesBefore = buffer.lines.length;
buffer.resize(INIT_COLS, INIT_ROWS + 5);
assert.equal(buffer.ybase, 10);
assert.equal(buffer.ydisp, 10);
assert.equal(buffer.lines.length, linesBefore + 5);
});
});
});
describe('row and column increased', () => {
@@ -305,6 +326,71 @@ describe('Buffer', () => {
assert.equal(buffer.lines.get(8)!.translateToString(), ' ');
assert.equal(buffer.lines.get(9)!.translateToString(), ' ');
});
it('should gate reflow on ConPTY buildNumber 21376', () => {
const prepareWrappedShrink = () => {
buffer.fillViewportRows();
buffer.resize(5, 10);
const firstLine = buffer.lines.get(0)!;
for (let i = 0; i < 5; i++) {
const code = 'a'.charCodeAt(0) + i;
firstLine.set(i, [0, String.fromCharCode(code), 1, code]);
}
buffer.y = 1;
buffer.resize(1, 10);
};
optionsService.options.windowsPty = { backend: 'conpty', buildNumber: 21375 };
prepareWrappedShrink();
assert.equal(buffer.lines.get(1)!.translateToString().trim(), '');
buffer = new TestBuffer(true, optionsService, bufferService, new MockLogService());
optionsService.options.windowsPty = { backend: 'conpty', buildNumber: 21376 };
prepareWrappedShrink();
assert.equal(buffer.lines.get(1)!.translateToString().trim(), 'b');
assert.ok(buffer.lines.get(1)!.isWrapped);
});
it('should unwrap lines on ConPTY builds with reflow support', () => {
optionsService.options.windowsPty = { backend: 'conpty', buildNumber: 21376 };
buffer.fillViewportRows();
buffer.resize(5, 10);
const firstLine = buffer.lines.get(0)!;
for (let i = 0; i < 5; i++) {
const code = 'a'.charCodeAt(0) + i;
firstLine.set(i, [0, String.fromCharCode(code), 1, code]);
}
buffer.y = 1;
buffer.resize(1, 10);
buffer.resize(5, 10);
assert.equal(buffer.lines.get(0)!.translateToString(), 'abcde');
assert.equal(buffer.lines.get(1)!.translateToString(), ' ');
});
it('should reflow wrapped lines containing the cursor when reflowCursorLine is enabled', () => {
optionsService.options.reflowCursorLine = true;
buffer.fillViewportRows();
buffer.resize(5, 10);
const firstLine = buffer.lines.get(0)!;
for (let i = 0; i < 5; i++) {
const code = 'a'.charCodeAt(0) + i;
firstLine.set(i, [0, String.fromCharCode(code), 1, code]);
}
buffer.resize(1, 10);
buffer.y = 2;
buffer.resize(5, 10);
assert.equal(buffer.lines.get(0)!.translateToString(), 'abcde');
});
it('should not reflow wrapped lines containing the cursor by default', () => {
buffer.fillViewportRows();
buffer.resize(5, 10);
const firstLine = buffer.lines.get(0)!;
for (let i = 0; i < 5; i++) {
const code = 'a'.charCodeAt(0) + i;
firstLine.set(i, [0, String.fromCharCode(code), 1, code]);
}
buffer.resize(1, 10);
buffer.y = 2;
buffer.resize(5, 10);
assert.notEqual(buffer.lines.get(0)!.translateToString(), 'abcde');
});
it('should discard parts of wrapped lines that go out of the scrollback', () => {
buffer.fillViewportRows();
optionsService.options.scrollback = 1;
+31 -1
View File
@@ -3,10 +3,13 @@
* @license MIT
*/
import { assert } from 'chai';
import { CircularList } from '../CircularList';
import { BufferLine } from './BufferLine';
import { BufferLineStringCache } from './BufferLineStringCache';
import { CellData } from './CellData';
import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from './Constants';
import { reflowSmallerGetNewLineLengths } from './BufferReflow';
import { reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from './BufferReflow';
import { IBufferLine } from './Types';
const TEST_STRING_CACHE = new BufferLineStringCache();
@@ -93,4 +96,31 @@ describe('BufferReflow', () => {
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 4, 2), [2, 2], 'line: 汉, 语');
});
});
describe('reflowLargerGetLinesToRemove', () => {
const nullCell = CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
function createWrappedLines(chars: string): CircularList<IBufferLine> {
const lines = new CircularList<IBufferLine>(chars.length);
for (let i = 0; i < chars.length; i++) {
const line = new BufferLine(TEST_STRING_CACHE, 1);
line.set(0, [0, chars[i], 1, chars.charCodeAt(i)]);
line.isWrapped = i > 0;
lines.push(line);
}
return lines;
}
it('should skip reflow when the cursor is in a wrapped block and reflowCursorLine is false', () => {
const lines = createWrappedLines('abcde');
const skipped = reflowLargerGetLinesToRemove(lines, 1, 5, 2, nullCell, false);
const reflowed = reflowLargerGetLinesToRemove(lines, 1, 5, 2, nullCell, true);
assert.deepEqual(skipped, []);
assert.notDeepEqual(reflowed, []);
});
it('should reflow wrapped blocks when the cursor is outside the block', () => {
const lines = createWrappedLines('abcde');
assert.notDeepEqual(reflowLargerGetLinesToRemove(lines, 1, 5, 10, nullCell, false), []);
});
});
});
+25
View File
@@ -459,4 +459,29 @@ describe('ApcParser - async tests', () => {
});
});
});
describe('reset', () => {
it('should abort active handlers with end(false) when reset during payload', () => {
const ident = identifier({ intermediates: '+', final: 'p' });
parser.registerHandler(ident, new TestHandler(reports, 'th'));
parser.start(ident);
let data = toUtf32('partial');
parser.put(data, 0, data.length);
parser.reset();
assert.deepEqual(reports, [
['th', 'START'],
['th', 'PUT', 'partial'],
['th', 'END', false]
]);
reports.length = 0;
parser.start(ident);
data = toUtf32('complete');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(reports, [
['th', 'START'],
['th', 'PUT', 'complete'],
['th', 'END', true]
]);
});
});
});
+26
View File
@@ -470,4 +470,30 @@ describe('DcsParser - async tests', () => {
});
});
});
describe('reset', () => {
it('should abort active handlers with unhook(false) when reset during payload', () => {
const ident = identifier({ intermediates: '+', final: 'p' });
const params = Params.fromArray([1, 2, 3]);
parser.registerHandler(ident, new TestHandler(reports, 'th'));
parser.hook(ident, params);
let data = toUtf32('partial');
parser.put(data, 0, data.length);
parser.reset();
assert.deepEqual(reports, [
['th', 'HOOK', [1, 2, 3]],
['th', 'PUT', 'partial'],
['th', 'UNHOOK', false]
]);
reports.length = 0;
parser.hook(ident, params);
data = toUtf32('complete');
parser.put(data, 0, data.length);
parser.unhook(true);
assert.deepEqual(reports, [
['th', 'HOOK', [1, 2, 3]],
['th', 'PUT', 'complete'],
['th', 'UNHOOK', true]
]);
});
});
});
@@ -2177,6 +2177,38 @@ describe('EscapeSequenceParser - async', () => {
parser.reset();
await parseP(parser, INPUT); // does not throw anymore
});
it('reset during async pause continues at next codepoint in chunk', async () => {
const localParser = new TestEscapeSequenceParser();
const localStack: any[] = [];
localParser.setPrintHandler((data, start, end) => {
let result = '';
for (let i = start; i < end; ++i) {
result += stringFromCodePoint(data[i]);
}
localStack.push(['PRINT', result]);
});
localParser.registerCsiHandler({ final: 'm' }, async params => {
localStack.push(['SGR', params.toArray()]);
return new Promise<boolean>(resolve => setTimeout(() => resolve(true), 0));
});
const data = '\x1b[1mXY';
const container = new Uint32Array(data.length);
const decoder = new StringToUtf32();
const len = decoder.decode(data, container);
assert.ok(localParser.parse(container, len) instanceof Promise);
localParser.reset();
assert.equal(localParser.parseStack.state, ParserStackType.RESET);
let prev: boolean | undefined = true;
let result: void | Promise<boolean> | undefined;
while (result = localParser.parse(container, len, prev)) {
prev = await result;
}
assert.deepEqual(localStack, [['SGR', [1]], ['PRINT', 'XY']]);
assert.equal(localParser.parseStack.state, ParserStackType.NONE);
});
it('correct result on awaited parse call', async () => {
await parseP(parser, INPUT);
assert.deepEqual(callstack, RESULT);
+24
View File
@@ -464,4 +464,28 @@ describe('OscParser - async tests', () => {
});
});
});
describe('reset', () => {
it('should abort active handlers with end(false) when reset during payload', () => {
parser.registerHandler(1234, new TestHandler(1234, reports, 'th'));
parser.start();
let data = toUtf32('1234;partial');
parser.put(data, 0, data.length);
parser.reset();
assert.deepEqual(reports, [
['th', 1234, 'START'],
['th', 1234, 'PUT', 'partial'],
['th', 1234, 'END', false]
]);
reports.length = 0;
parser.start();
data = toUtf32('1234;complete');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(reports, [
['th', 1234, 'START'],
['th', 1234, 'PUT', 'complete'],
['th', 1234, 'END', true]
]);
});
});
});
+137
View File
@@ -0,0 +1,137 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { AttributeData } from '../buffer/AttributeData';
import { BufferService } from './BufferService';
import { OptionsService } from './OptionsService';
import { MockLogService } from '../TestUtils.test';
describe('BufferService', () => {
describe('scroll', () => {
const eraseAttr = new AttributeData();
it('should decrement ydisp when the buffer is full and the user has scrolled up', () => {
const optionsService = new OptionsService({ rows: 3, cols: 10, scrollback: 2 });
const bufferService = new BufferService(optionsService, new MockLogService());
const buffer = bufferService.buffer;
while (!buffer.lines.isFull) {
bufferService.scroll(eraseAttr);
}
assert.strictEqual(buffer.lines.length, 5);
bufferService.isUserScrolling = true;
buffer.ydisp = 2;
const ybaseBefore = buffer.ybase;
bufferService.scroll(eraseAttr);
assert.strictEqual(buffer.ybase, ybaseBefore);
assert.strictEqual(buffer.ydisp, 1);
});
it('should not advance ydisp with ybase while the user has scrolled up and the buffer is not full', () => {
const optionsService = new OptionsService({ rows: 3, cols: 10, scrollback: 2 });
const bufferService = new BufferService(optionsService, new MockLogService());
const buffer = bufferService.buffer;
bufferService.isUserScrolling = true;
buffer.ydisp = 0;
const ybaseBefore = buffer.ybase;
bufferService.scroll(eraseAttr);
assert.strictEqual(buffer.ybase, ybaseBefore + 1);
assert.strictEqual(buffer.ydisp, 0);
});
it('should follow ybase with ydisp when the user is not scrolling', () => {
const optionsService = new OptionsService({ rows: 3, cols: 10, scrollback: 2 });
const bufferService = new BufferService(optionsService, new MockLogService());
const buffer = bufferService.buffer;
while (!buffer.lines.isFull) {
bufferService.scroll(eraseAttr);
}
bufferService.isUserScrolling = false;
bufferService.scroll(eraseAttr);
assert.strictEqual(buffer.ydisp, buffer.ybase);
});
it('should scroll within DECSTBM margins without affecting lines outside the region', () => {
const optionsService = new OptionsService({ rows: 5, cols: 10, scrollback: 10 });
const bufferService = new BufferService(optionsService, new MockLogService());
const buffer = bufferService.buffer;
const markRow = (row: number, ch: string) => {
buffer.lines.get(buffer.ybase + row)!.setCellFromCodepoint(0, ch.charCodeAt(0), 1, eraseAttr);
};
markRow(0, 'A');
markRow(1, 'B');
markRow(2, 'C');
markRow(3, 'D');
markRow(4, 'E');
buffer.scrollTop = 1;
buffer.scrollBottom = 3;
bufferService.scroll(eraseAttr);
assert.strictEqual(buffer.lines.get(buffer.ybase + 0)!.translateToString().trim(), 'A');
assert.strictEqual(buffer.lines.get(buffer.ybase + 1)!.translateToString().trim(), 'C');
assert.strictEqual(buffer.lines.get(buffer.ybase + 2)!.translateToString().trim(), 'D');
assert.strictEqual(buffer.lines.get(buffer.ybase + 3)!.translateToString(true).trim(), '');
assert.strictEqual(buffer.lines.get(buffer.ybase + 4)!.translateToString().trim(), 'E');
});
});
describe('scrollLines', () => {
it('should move ydisp and set isUserScrolling when scrolling up', () => {
const optionsService = new OptionsService({ rows: 10, cols: 80, scrollback: 10 });
const bufferService = new BufferService(optionsService, new MockLogService());
const buffer = bufferService.buffer;
buffer.ybase = 5;
buffer.ydisp = 5;
let scrollEvent: number | undefined;
bufferService.onScroll(e => { scrollEvent = e; });
bufferService.scrollLines(-2);
assert.strictEqual(buffer.ydisp, 3);
assert.strictEqual(bufferService.isUserScrolling, true);
assert.strictEqual(scrollEvent, 3);
});
it('should not scroll above the top of the buffer', () => {
const optionsService = new OptionsService({ rows: 10, cols: 80, scrollback: 10 });
const bufferService = new BufferService(optionsService, new MockLogService());
const buffer = bufferService.buffer;
buffer.ybase = 5;
buffer.ydisp = 0;
bufferService.scrollLines(-1);
assert.strictEqual(buffer.ydisp, 0);
assert.strictEqual(bufferService.isUserScrolling, false);
});
it('should clear isUserScrolling when scrolling to the bottom', () => {
const optionsService = new OptionsService({ rows: 10, cols: 80, scrollback: 10 });
const bufferService = new BufferService(optionsService, new MockLogService());
const buffer = bufferService.buffer;
buffer.ybase = 5;
buffer.ydisp = 2;
bufferService.isUserScrolling = true;
bufferService.scrollLines(10);
assert.strictEqual(buffer.ydisp, 5);
assert.strictEqual(bufferService.isUserScrolling, false);
});
});
});
@@ -0,0 +1,43 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { CHARSETS } from '../data/Charsets';
import { CharsetService } from './CharsetService';
describe('CharsetService', () => {
let service: CharsetService;
beforeEach(() => {
service = new CharsetService();
});
it('should not update active charset when designating an inactive glevel', () => {
service.setgCharset(1, CHARSETS['0']);
assert.strictEqual(service.glevel, 0);
assert.ok(service.charset === undefined);
});
it('should expose the designated charset after setgLevel', () => {
service.setgCharset(1, CHARSETS['0']);
service.setgLevel(1);
assert.strictEqual(service.charset, CHARSETS['0']);
});
it('should update active charset when designating the current glevel', () => {
service.setgLevel(1);
service.setgCharset(1, CHARSETS['0']);
assert.strictEqual(service.charset, CHARSETS['0']);
});
it('should reset glevel, charsets, and active charset', () => {
service.setgCharset(1, CHARSETS['0']);
service.setgLevel(1);
service.reset();
assert.strictEqual(service.glevel, 0);
assert.deepEqual(service.charsets, []);
assert.ok(service.charset === undefined);
});
});