/** * 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(); }); });