From a8a6c1a648d7552e7af9ef990df7a9a1ef0b0777 Mon Sep 17 00:00:00 2001 From: arkon Date: Sun, 1 Mar 2026 16:40:26 +0100 Subject: [PATCH] test: add comprehensive overlay tests for visual fixes and setPrompt New cell-dimensions.test.ts (8 tests): DPR conversion, charTop/charHeight, null cases. Overlay renderer tests (+12): cellH+1 seam fix, span centering, no-transform, ligature disabling, multi-line cursor. Addon tests (+9): setPrompt() strategy switching, tab-switch cycle, ghost artifact prevention. Co-Authored-By: Claude Opus 4.6 --- .../test/cell-dimensions.test.ts | 127 +++++++++++++++++ packages/xterm-zerolag-input/test/helpers.ts | 10 ++ .../test/overlay-renderer.test.ts | 131 ++++++++++++++++++ .../test/zerolag-input-addon.test.ts | 125 +++++++++++++++++ 4 files changed, 393 insertions(+) create mode 100644 packages/xterm-zerolag-input/test/cell-dimensions.test.ts diff --git a/packages/xterm-zerolag-input/test/cell-dimensions.test.ts b/packages/xterm-zerolag-input/test/cell-dimensions.test.ts new file mode 100644 index 00000000..165a2b1f --- /dev/null +++ b/packages/xterm-zerolag-input/test/cell-dimensions.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import { getCellDimensions } from '../src/cell-dimensions.js'; +import { createMockTerminal } from './helpers.js'; +import type { XtermTerminal } from '../src/types.js'; + +let cleanups: (() => void)[] = []; + +afterEach(() => { + for (const fn of cleanups) fn(); + cleanups = []; +}); + +describe('getCellDimensions', () => { + describe('v5 private API (mock _core._renderService)', () => { + it('returns cell width and height from css.cell', () => { + const mock = createMockTerminal({ cellWidth: 8.4, cellHeight: 19 }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims).not.toBeNull(); + expect(dims!.width).toBe(8.4); + expect(dims!.height).toBe(19); + }); + + it('returns charTop from device.char.top divided by DPR', () => { + const mock = createMockTerminal({ + cellWidth: 8, cellHeight: 19, + deviceCharTop: 2, + }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims).not.toBeNull(); + // DPR=1 in jsdom, so charTop = 2 / 1 = 2 + expect(dims!.charTop).toBe(2); + }); + + it('returns charHeight from device.char.height divided by DPR', () => { + const mock = createMockTerminal({ + cellWidth: 8, cellHeight: 19, + deviceCharHeight: 16, + }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims).not.toBeNull(); + // DPR=1, so charHeight = 16 / 1 = 16 + expect(dims!.charHeight).toBe(16); + }); + + it('defaults charTop to 0 when device.char not present', () => { + // Default mock has deviceCharTop=0 + const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims!.charTop).toBe(0); + }); + + it('defaults charHeight to cellH when device.char.height not set', () => { + // Default mock has deviceCharHeight=cellH + const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims!.charHeight).toBe(19); + }); + }); + + describe('DPR simulation', () => { + const originalDPR = globalThis.devicePixelRatio; + + beforeEach(() => { + // Set DPR=2 to test division + Object.defineProperty(globalThis, 'devicePixelRatio', { + value: 2, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(globalThis, 'devicePixelRatio', { + value: originalDPR, + writable: true, + configurable: true, + }); + }); + + it('divides device.char.top by DPR', () => { + const mock = createMockTerminal({ + cellWidth: 16, cellHeight: 38, + deviceCharTop: 4, + deviceCharHeight: 32, + }); + cleanups.push(mock.cleanup); + const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal); + expect(dims).not.toBeNull(); + // charTop = 4 / 2 = 2 + expect(dims!.charTop).toBe(2); + // charHeight = 32 / 2 = 16 + expect(dims!.charHeight).toBe(16); + }); + }); + + describe('null cases', () => { + it('returns null for terminal without _core', () => { + const terminal = { + element: document.createElement('div'), + cols: 80, + rows: 24, + options: {}, + buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } }, + } as unknown as XtermTerminal; + const dims = getCellDimensions(terminal); + expect(dims).toBeNull(); + }); + + it('returns null for terminal with no dimensions', () => { + const terminal = { + element: document.createElement('div'), + cols: 80, + rows: 24, + options: {}, + buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } }, + _core: { _renderService: {} }, + } as unknown as XtermTerminal; + const dims = getCellDimensions(terminal); + expect(dims).toBeNull(); + }); + }); +}); diff --git a/packages/xterm-zerolag-input/test/helpers.ts b/packages/xterm-zerolag-input/test/helpers.ts index 9fd3c0c8..e1639b8a 100644 --- a/packages/xterm-zerolag-input/test/helpers.ts +++ b/packages/xterm-zerolag-input/test/helpers.ts @@ -31,6 +31,10 @@ interface MockTerminalOptions { }; cellWidth?: number; cellHeight?: number; + /** Device-pixel char top offset (for charTop calculation). Default: 0 */ + deviceCharTop?: number; + /** Device-pixel char height (for charHeight calculation). Default: cellHeight * dpr */ + deviceCharHeight?: number; } export function createMockTerminal(opts: MockTerminalOptions = {}) { @@ -95,6 +99,12 @@ export function createMockTerminal(opts: MockTerminalOptions = {}) { css: { cell: { width: cellW, height: cellH }, }, + device: { + char: { + top: opts.deviceCharTop ?? 0, + height: opts.deviceCharHeight ?? cellH, + }, + }, }, }, }, diff --git a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts index 0944afbc..81d12223 100644 --- a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts +++ b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts @@ -18,6 +18,8 @@ function makeParams(overrides: Partial = {}): RenderParams { totalCols: 80, cellW: 8.4, cellH: 17, + charTop: 2, + charHeight: 14, promptRow: 10, font: FONT, showCursor: true, @@ -166,4 +168,133 @@ describe('renderOverlay', () => { renderOverlay(container, makeParams()); expect(container.style.display).toBe(''); }); + + // ─── Anti-flicker / compositing seam tests ──────────────────── + + it('line div height extends 1px past cellH to cover compositing seam', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['abc'], cellH: 19 })); + const lineDiv = container.children[0] as HTMLDivElement; + // cellH + 1 = 20px — the extra 1px covers the compositing seam + expect(lineDiv.style.height).toBe('20px'); + }); + + it('line div height is cellH+1 for various cell heights', () => { + for (const cellH of [15, 17, 19, 22]) { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['x'], cellH })); + const lineDiv = container.children[0] as HTMLDivElement; + expect(lineDiv.style.height).toBe((cellH + 1) + 'px'); + } + }); + + it('multi-line overlay has cellH+1 height on each line div', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ + lines: ['first', 'second'], + cellH: 19, + })); + const line1 = container.children[0] as HTMLDivElement; + const line2 = container.children[1] as HTMLDivElement; + expect(line1.style.height).toBe('20px'); + expect(line2.style.height).toBe('20px'); + }); + + // ─── Span vertical centering tests ──────────────────────────── + + it('span uses full cellH for height and lineHeight (CSS centering)', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 })); + const lineDiv = container.children[0] as HTMLDivElement; + const span = lineDiv.children[0] as HTMLSpanElement; + expect(span.style.height).toBe('19px'); + expect(span.style.lineHeight).toBe('19px'); + }); + + it('span top is 0px (no vertical offset / no transform)', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 })); + const lineDiv = container.children[0] as HTMLDivElement; + const span = lineDiv.children[0] as HTMLSpanElement; + expect(span.style.top).toBe('0px'); + // No translateY transform — sub-pixel overhang causes artifacts + expect(span.style.transform).toBe(''); + }); + + // ─── Font rendering tests ───────────────────────────────────── + + it('span disables ligatures via font-feature-settings', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['fi'] })); + const lineDiv = container.children[0] as HTMLDivElement; + const span = lineDiv.children[0] as HTMLSpanElement; + // Check cssText includes the ligature-disabling settings + // jsdom may normalize whitespace; check that both liga and calt are disabled + expect(span.style.cssText).toContain("font-feature-settings:"); + expect(span.style.cssText).toContain("'liga' 0"); + expect(span.style.cssText).toContain("'calt' 0"); + }); + + it('span has text-align: center for glyph centering', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['m'] })); + const lineDiv = container.children[0] as HTMLDivElement; + const span = lineDiv.children[0] as HTMLSpanElement; + expect(span.style.textAlign).toBe('center'); + }); + + it('span has pointer-events: none', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['a'] })); + const lineDiv = container.children[0] as HTMLDivElement; + const span = lineDiv.children[0] as HTMLSpanElement; + expect(span.style.pointerEvents).toBe('none'); + }); + + // ─── Multi-line cursor positioning ──────────────────────────── + + it('cursor on wrapped line uses col 0 as base', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ + lines: ['first', 'ab'], + startCol: 5, + cellW: 10, + cellH: 20, + showCursor: true, + })); + // Cursor at end of second line: col = 0 + 2 = 2 + const cursor = container.children[container.children.length - 1] as HTMLSpanElement; + expect(cursor.style.left).toBe('20px'); // 2 * 10 + expect(cursor.style.top).toBe('20px'); // row 1 * cellH + }); + + // ─── charTop/charHeight passed through ──────────────────────── + + it('accepts charTop and charHeight params without error', () => { + const container = document.createElement('div'); + expect(() => renderOverlay(container, makeParams({ + lines: ['test'], + charTop: 2, + charHeight: 14, + }))).not.toThrow(); + expect(container.children.length).toBeGreaterThan(0); + }); + + // ─── Line div positioning regression ────────────────────────── + + it('line div background color matches font.backgroundColor', () => { + const container = document.createElement('div'); + const font: FontStyle = { ...FONT, backgroundColor: '#1a1a1a' }; + renderOverlay(container, makeParams({ lines: ['x'], font })); + const lineDiv = container.children[0] as HTMLDivElement; + // jsdom normalizes hex to rgb() + expect(lineDiv.style.backgroundColor).toBe('rgb(26, 26, 26)'); + }); + + it('empty line produces line div with no spans', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: [''] })); + const lineDiv = container.children[0] as HTMLDivElement; + expect(lineDiv.children.length).toBe(0); + }); }); diff --git a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts index b1680cc4..34a4f8f3 100644 --- a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts +++ b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts @@ -482,6 +482,131 @@ describe('ZerolagInputAddon', () => { }); }); + describe('setPrompt', () => { + it('changes prompt detection strategy', () => { + const { addon, mock } = tracked(['$ hello'], '$'); + // Initially finds $ prompt + expect(addon.findPrompt()).toEqual({ row: 0, col: 0 }); + expect(addon.readPromptText()).toBe('hello'); + + // Switch to > prompt — $ is no longer detected + mock.setLines(['> world']); + addon.setPrompt({ type: 'character', char: '>', offset: 2 }); + expect(addon.findPrompt()).toEqual({ row: 0, col: 0 }); + expect(addon.readPromptText()).toBe('world'); + }); + + it('returns null when new prompt character not found', () => { + const { addon } = tracked(['$ hello'], '$'); + addon.setPrompt({ type: 'character', char: '>', offset: 2 }); + // Buffer still has $ not > + expect(addon.findPrompt()).toBeNull(); + }); + + it('resets cached prompt position', () => { + const { addon } = tracked(['$ typed']); + addon.addChar('x'); + expect(addon.state.promptPosition).not.toBeNull(); + + addon.setPrompt({ type: 'character', char: '>', offset: 2 }); + // After setPrompt with no matching prompt, position resets + expect(addon.state.promptPosition).toBeNull(); + }); + + it('re-renders when text exists', () => { + const { addon, mock } = tracked(['$ '], '$'); + addon.addChar('h'); + addon.addChar('i'); + + // Switch prompt strategy — should re-render with existing text + mock.setLines(['> ']); + addon.setPrompt({ type: 'character', char: '>', offset: 2 }); + expect(addon.pendingText).toBe('hi'); + expect(addon.hasPending).toBe(true); + }); + + it('does not crash when no text to render', () => { + const { addon } = tracked(['$ ']); + expect(() => addon.setPrompt({ type: 'character', char: '>', offset: 2 })).not.toThrow(); + }); + + it('works with regex prompt strategy', () => { + const mock = createMockTerminal({ buffer: { lines: ['user@host:~$ cmd'] } }); + const addon = new ZerolagInputAddon({ + prompt: { type: 'character', char: '$', offset: 2 }, + }); + mock.terminal.loadAddon(addon); + cleanups.push(() => { addon.dispose(); mock.cleanup(); }); + + // Switch to regex + addon.setPrompt({ type: 'regex', pattern: /\$/, offset: 2 }); + expect(addon.findPrompt()).not.toBeNull(); + expect(addon.readPromptText()).toBe('cmd'); + }); + }); + + describe('tab switch with setPrompt (CLI switching)', () => { + it('full tab switch cycle: save state, setPrompt, restore', () => { + // Session A with $ prompt + const { addon, mock } = tracked(['$ '], '$'); + addon.addChar('h'); + addon.addChar('e'); + addon.addChar('l'); + addon.addChar('l'); + addon.addChar('o'); + expect(addon.pendingText).toBe('hello'); + + // Save state before tab switch + const pending = addon.pendingText; + const { count: flushedCount, text: flushedText } = addon.getFlushed(); + const totalText = flushedText + pending; + const totalCount = flushedCount + pending.length; + addon.clear(); + + // Switch to Session B with > prompt + mock.setLines(['> ']); + addon.setPrompt({ type: 'character', char: '>', offset: 2 }); + expect(addon.pendingText).toBe(''); + expect(addon.hasPending).toBe(false); + + // Switch back to Session A — restore state + mock.setLines(['$ ']); + addon.setPrompt({ type: 'character', char: '$', offset: 2 }); + addon.suppressBufferDetection(); + addon.setFlushed(totalCount, totalText, false); + + expect(addon.getFlushed()).toEqual({ count: 5, text: 'hello' }); + expect(addon.hasPending).toBe(true); + }); + }); + + describe('overlay hides when prompt not found (ghost artifact fix)', () => { + it('overlay hides when prompt scrolls away', () => { + const { addon, mock } = tracked(['$ ']); + addon.addChar('x'); + // Prompt visible, overlay should render + expect(addon.hasPending).toBe(true); + + // Simulate prompt scrolling away (no $ in buffer) + mock.setLines(['just output', 'more output']); + addon.clear(); + addon.addChar('y'); + // Prompt not found — overlay hidden despite pending text + // (the addon renders but finds no prompt, so display stays none) + const state = addon.state; + expect(state.pendingText).toBe('y'); + }); + + it('clear resets lastPromptPos to null', () => { + const { addon } = tracked(['$ ']); + addon.addChar('a'); + expect(addon.state.promptPosition).not.toBeNull(); + + addon.clear(); + expect(addon.state.promptPosition).toBeNull(); + }); + }); + describe('methods before activate / after dispose', () => { it('addChar accumulates but does not crash before activate', () => { const addon = new ZerolagInputAddon();