diff --git a/addons/addon-search/src/DecorationManager.test.ts b/addons/addon-search/src/DecorationManager.test.ts new file mode 100644 index 0000000000..26e40dbc20 --- /dev/null +++ b/addons/addon-search/src/DecorationManager.test.ts @@ -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 { + 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); + }); +}); diff --git a/package-lock.json b/package-lock.json index c3d4c0f777..5f58772405 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6689,16 +6689,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -6932,27 +6922,6 @@ "tslib": "^2.1.0" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -7078,13 +7047,13 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/serve-static": { diff --git a/package.json b/package.json index d3da864b6a..cb7d146f08 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "workspaces": [ "addons/*" ], + "overrides": { + "serialize-javascript": "^7.0.3" + }, "keywords": [ "cli", "command-line", diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index 8f0109727e..9eab8cfa23 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -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]); diff --git a/src/common/WindowsMode.test.ts b/src/common/WindowsMode.test.ts new file mode 100644 index 0000000000..38daa52b72 --- /dev/null +++ b/src/common/WindowsMode.test.ts @@ -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); + }); + }); +}); diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index 8116c28c40..cbb8cf9ed4 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -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; diff --git a/src/common/buffer/BufferReflow.test.ts b/src/common/buffer/BufferReflow.test.ts index c799fee19a..8bcf5bf9c1 100644 --- a/src/common/buffer/BufferReflow.test.ts +++ b/src/common/buffer/BufferReflow.test.ts @@ -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 { + const lines = new CircularList(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), []); + }); + }); }); diff --git a/src/common/parser/ApcParser.test.ts b/src/common/parser/ApcParser.test.ts index 563ddbeabd..49fd261322 100644 --- a/src/common/parser/ApcParser.test.ts +++ b/src/common/parser/ApcParser.test.ts @@ -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] + ]); + }); + }); }); diff --git a/src/common/parser/DcsParser.test.ts b/src/common/parser/DcsParser.test.ts index 5fa7b94167..6732a18f35 100644 --- a/src/common/parser/DcsParser.test.ts +++ b/src/common/parser/DcsParser.test.ts @@ -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] + ]); + }); + }); }); diff --git a/src/common/parser/EscapeSequenceParser.test.ts b/src/common/parser/EscapeSequenceParser.test.ts index 73238d0398..5f12137df4 100644 --- a/src/common/parser/EscapeSequenceParser.test.ts +++ b/src/common/parser/EscapeSequenceParser.test.ts @@ -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(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 | 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); diff --git a/src/common/parser/OscParser.test.ts b/src/common/parser/OscParser.test.ts index b97a7617a3..9a58f617cd 100644 --- a/src/common/parser/OscParser.test.ts +++ b/src/common/parser/OscParser.test.ts @@ -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] + ]); + }); + }); }); diff --git a/src/common/services/BufferService.test.ts b/src/common/services/BufferService.test.ts new file mode 100644 index 0000000000..90716cdb41 --- /dev/null +++ b/src/common/services/BufferService.test.ts @@ -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); + }); + }); +}); diff --git a/src/common/services/CharsetService.test.ts b/src/common/services/CharsetService.test.ts new file mode 100644 index 0000000000..a4c3646b77 --- /dev/null +++ b/src/common/services/CharsetService.test.ts @@ -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); + }); +});