From f4042876c64356c71f48bb6d7c4816ce4cbe56ac Mon Sep 17 00:00:00 2001 From: mgabor3141 <@mgabor3141> Date: Thu, 12 Mar 2026 18:50:39 +0100 Subject: [PATCH] test: add viewport synchronized-output regression coverage (fails until fix) --- src/browser/Viewport.test.ts | 161 +++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 src/browser/Viewport.test.ts diff --git a/src/browser/Viewport.test.ts b/src/browser/Viewport.test.ts new file mode 100644 index 000000000..359a9a42c --- /dev/null +++ b/src/browser/Viewport.test.ts @@ -0,0 +1,161 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import jsdom = require('jsdom'); +import { Viewport } from 'browser/Viewport'; +import { MockThemeService } from 'browser/TestUtils.test'; +import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils'; +import { Emitter } from 'common/Event'; +import { MockBufferService, MockCoreService, MockMouseStateService, MockOptionsService } from 'common/TestUtils.test'; +import { css } from 'common/Color'; +import type { IRenderDimensions } from 'browser/renderer/shared/Types'; +import type { ICoreBrowserService, IRenderService } from 'browser/services/Services'; + +class TestRenderService implements IRenderService { + public serviceBrand: undefined; + private readonly _onRender = new Emitter<{ start: number, end: number }>(); + + public onDimensionsChange = new Emitter().event; + public onRenderedViewportChange = new Emitter<{ start: number, end: number }>().event; + public onRender = this._onRender.event; + public onRefreshRequest = new Emitter<{ start: number, end: number }>().event; + public dimensions: IRenderDimensions = createRenderDimensions(); + + constructor() { + this.dimensions.css.cell.height = 10; + this.dimensions.css.canvas.height = 100; + } + + public fireRender(): void { + this._onRender.fire({ start: 0, end: 0 }); + } + + public addRefreshCallback(callback: FrameRequestCallback): number { + callback(0); + return 1; + } + public refreshRows(start: number, end: number, sync?: boolean): void { } + public clearTextureAtlas(): void { } + public resize(cols: number, rows: number): void { } + public hasRenderer(): boolean { return true; } + public setRenderer(renderer: any): void { } + public handleDevicePixelRatioChange(): void { } + public handleResize(cols: number, rows: number): void { } + public handleCharSizeChanged(): void { } + public handleBlur(): void { } + public handleFocus(): void { } + public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void { } + public handleCursorMove(): void { } + public clear(): void { } + public dispose(): void { } +} + +describe('Viewport', () => { + function setup(): { + viewport: Viewport; + renderService: TestRenderService; + coreService: MockCoreService; + dispose: () => void; + } { + const dom = new jsdom.JSDOM(); + const previousWindow = (globalThis as any).window; + const previousDocument = (globalThis as any).document; + (globalThis as any).window = dom.window; + (globalThis as any).document = dom.window.document; + + const element = dom.window.document.createElement('div'); + const screenElement = dom.window.document.createElement('div'); + + const optionsService = new MockOptionsService(); + const bufferService = new MockBufferService(80, 5, optionsService); + const coreBrowserService: ICoreBrowserService = { + serviceBrand: undefined, + isFocused: true, + onDprChange: new Emitter().event, + onWindowChange: new Emitter().event, + window: dom.window as unknown as Window & typeof globalThis, + mainDocument: dom.window.document, + dpr: 1 + }; + + const coreService = new MockCoreService(); + const mouseStateService = new MockMouseStateService(); + const themeService = new MockThemeService(); + (themeService.colors as any).scrollbarSliderBackground = css.toColor('#333333'); + (themeService.colors as any).scrollbarSliderHoverBackground = css.toColor('#444444'); + (themeService.colors as any).scrollbarSliderActiveBackground = css.toColor('#555555'); + const renderService = new TestRenderService(); + + const viewport = new Viewport( + element, + screenElement, + bufferService, + coreBrowserService, + coreService, + mouseStateService, + themeService, + optionsService, + renderService + ); + + return { + viewport, + renderService, + coreService, + dispose: () => { + viewport.dispose(); + dom.window.close(); + (globalThis as any).window = previousWindow; + (globalThis as any).document = previousDocument; + } + }; + } + + it('should defer DOM sync during synchronized output and flush on render', () => { + const test = setup(); + let dimSyncCalls = 0; + let posSyncCalls = 0; + + const scrollableElement = (test.viewport as any)._scrollableElement; + scrollableElement.setScrollDimensions = () => { dimSyncCalls++; }; + scrollableElement.setScrollPosition = () => { posSyncCalls++; }; + + test.coreService.decPrivateModes.synchronizedOutput = true; + (test.viewport as any)._sync(3); + + assert.equal(dimSyncCalls, 0); + assert.equal(posSyncCalls, 0); + assert.equal((test.viewport as any)._needsSyncOnRender, true); + + test.coreService.decPrivateModes.synchronizedOutput = false; + test.renderService.fireRender(); + + assert.equal(dimSyncCalls, 1); + assert.equal(posSyncCalls, 1); + assert.equal((test.viewport as any)._needsSyncOnRender, false); + + test.dispose(); + }); + + it('should sync DOM immediately when synchronized output is disabled', () => { + const test = setup(); + let dimSyncCalls = 0; + let posSyncCalls = 0; + + const scrollableElement = (test.viewport as any)._scrollableElement; + scrollableElement.setScrollDimensions = () => { dimSyncCalls++; }; + scrollableElement.setScrollPosition = () => { posSyncCalls++; }; + + test.coreService.decPrivateModes.synchronizedOutput = false; + (test.viewport as any)._sync(2); + + assert.equal(dimSyncCalls, 1); + assert.equal(posSyncCalls, 1); + assert.equal((test.viewport as any)._needsSyncOnRender, false); + + test.dispose(); + }); +});