From 5b00193876c00272a22b9f29bd2eb76f4b33d616 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Fri, 17 Jul 2026 11:17:30 -0700 Subject: [PATCH] adjust button style --- .../addon-webgl/test/WebglAtlasStress.test.ts | 288 ++++++++++++++++++ demo/client/components/window/webglWindow.ts | 14 +- 2 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 addons/addon-webgl/test/WebglAtlasStress.test.ts diff --git a/addons/addon-webgl/test/WebglAtlasStress.test.ts b/addons/addon-webgl/test/WebglAtlasStress.test.ts new file mode 100644 index 000000000..789635614 --- /dev/null +++ b/addons/addon-webgl/test/WebglAtlasStress.test.ts @@ -0,0 +1,288 @@ +/** + * Copyright (c) 2025 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IImage32, decodePng } from '@lunapaint/png-codec'; +import test, { expect } from '@playwright/test'; +import type { Terminal, ITerminalInitOnlyOptions, ITerminalOptions } from '@xterm/xterm'; +import type { IWebglAddonOptions, WebglAddon } from '@xterm/addon-webgl'; +import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils'; + +type CellSignature = number[]; +type TestTerminalConstructor = new (options?: ITerminalOptions & ITerminalInitOnlyOptions) => ITestTerminal; +type TestWebglAddonConstructor = new (options?: IWebglAddonOptions) => ITestWebglAddon; + +interface ITestTextureAtlas { + pages: { canvas: HTMLCanvasElement }[]; +} + +interface ITestGlyphRenderer { + _atlasTextures: unknown[]; +} + +interface ITestRenderer { + _charAtlas?: ITestTextureAtlas; + _glyphRenderer?: { + value?: ITestGlyphRenderer; + }; +} + +interface ITestRenderService { + _renderer?: { + value?: ITestRenderer; + }; +} + +interface ITestTerminal extends Terminal { + _core?: { + _renderService?: ITestRenderService; + }; +} + +interface ITestWebglAddon extends WebglAddon { + _renderer?: ITestRenderer; +} + +interface IAtlasEventStats { + additions: number; + maxPages: number; +} + +interface ITestWindow { + WebglAddon: TestWebglAddonConstructor; + term: ITestTerminal; + addon?: ITestWebglAddon; + atlasEventStats?: IAtlasEventStats; +} + +interface IAtlasState { + additions: number; + atlasTextures: number; + maxPages: number; +} + +const REFERENCE_LINE = 'Ref0123456789ABCDEF'; +const REFERENCE_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]; +const TERM_SELECTOR = '#terminal-container .xterm-screen'; + +async function loadWebglStrict(ctx: ITestContext): Promise { + await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + w.addon = new w.WebglAddon({ preserveDrawingBuffer: true }); + w.term.loadAddon(w.addon); + }); + const isWebglRenderer = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + return !!w.addon && w.term._core?._renderService?._renderer?.value === w.addon._renderer; + }); + expect(isWebglRenderer, 'WebGL renderer must be active').toBe(true); +} + +async function installAtlasEventStats(ctx: ITestContext): Promise { + const installed = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + const atlas = w.term._core?._renderService?._renderer?.value?._charAtlas; + if (!atlas || !w.addon) { + return false; + } + const stats: IAtlasEventStats = { + additions: 0, + maxPages: atlas.pages.length + }; + w.atlasEventStats = stats; + w.addon.onAddTextureAtlasCanvas(() => { + stats.additions++; + stats.maxPages = Math.max(stats.maxPages, atlas.pages.length); + }); + return true; + }); + expect(installed, 'atlas event tracking must be installed').toBe(true); +} + +async function getAtlasState(ctx: ITestContext): Promise { + const state = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + const renderer = w.term._core?._renderService?._renderer?.value; + const glyphRenderer = renderer?._glyphRenderer?.value; + const stats = w.atlasEventStats; + if (!glyphRenderer || !stats) { + return undefined; + } + return { + additions: stats.additions, + atlasTextures: glyphRenderer._atlasTextures.length, + maxPages: stats.maxPages + }; + }); + expect(state, 'atlas state must be available').toBeDefined(); + return state!; +} + +async function writeAndWaitForRender(ctx: ITestContext, data: string): Promise { + const renderPromise = new Promise(resolve => { + const disposable = ctx.proxy.onRender(() => { + disposable.dispose(); + resolve(); + }); + }); + await ctx.proxy.write(data); + await renderPromise; +} + +async function refreshAndWaitForRender(ctx: ITestContext): Promise { + const renderPromise = new Promise(resolve => { + const disposable = ctx.proxy.onRender(() => { + disposable.dispose(); + resolve(); + }); + }); + await ctx.proxy.refresh(0, (await ctx.proxy.rows) - 1); + await renderPromise; +} + +function generateSyntheticTuiFrames(frameCount: number, cols: number, rows: number): string[] { + const spinner = ['|', '/', '-', '\\']; + const words = ['reading', 'rendering', 'atlas', 'terminal', 'buffer', 'tokens', 'diff', 'patch']; + const frames: string[] = []; + for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) { + const lines = [ + `+${'-'.repeat(cols - 2)}+`, + `|${(` ${spinner[frameIndex % spinner.length]} Copilot is working`).padEnd(cols - 2).slice(0, cols - 2)}|`, + `+${'-'.repeat(cols - 2)}+` + ]; + for (let row = 3; row < rows - 1; row++) { + const foreground = 31 + ((frameIndex + row) % 6); + const text = `${words[(frameIndex + row) % words.length]} ${frameIndex}:${row}`; + lines.push(`\x1b[${foreground}m${text}\x1b[0m`); + } + lines.push(`\x1b[2mtokens ${1000 + frameIndex * 37}\x1b[0m`); + frames.push(`\x1b[2J\x1b[H${lines.join('\r\n')}`); + } + return frames; +} + +function generateUniqueGlyphFlood(count: number, cols: number, offset: number): string { + const base = 0x4E00; + const range = 0x9FFF - base; + const perRow = Math.max(1, Math.floor(cols / 2)); + let result = ''; + for (let i = 0; i < count; i++) { + result += String.fromCodePoint(base + ((offset + i) % range)); + if ((i + 1) % perRow === 0 && i + 1 < count) { + result += '\r\n'; + } + } + return result; +} + +async function captureRowSignatures(ctx: ITestContext, row: number, cols: number[], debugName: string): Promise { + const screenshotOptions = process.env.DEBUG ? { path: `out-esbuild-test/playwright/${debugName}.png` } : undefined; + const buffer = await ctx.page.locator(TERM_SELECTOR).screenshot(screenshotOptions); + const frame = { + cols: await ctx.proxy.cols, + rows: await ctx.proxy.rows, + decoded: (await decodePng(new Uint8Array(buffer), { force32: true })).image + }; + return cols.map(col => cellSignature(frame, col, row)); +} + +function cellSignature(frame: { cols: number, rows: number, decoded: IImage32 }, col: number, row: number): CellSignature { + const grid = 4; + const data = frame.decoded.data; + const imageWidth = frame.decoded.width; + const cellWidth = frame.decoded.width / frame.cols; + const cellHeight = frame.decoded.height / frame.rows; + const x0 = (col - 1) * cellWidth; + const y0 = (row - 1) * cellHeight; + const sums = new Array(grid * grid * 4).fill(0); + const counts = new Array(grid * grid).fill(0); + const startX = Math.max(0, Math.floor(x0)); + const startY = Math.max(0, Math.floor(y0)); + const endX = Math.min(frame.decoded.width, Math.ceil(x0 + cellWidth)); + const endY = Math.min(frame.decoded.height, Math.ceil(y0 + cellHeight)); + + for (let y = startY; y < endY; y++) { + const gy = Math.min(grid - 1, Math.max(0, Math.floor(((y - y0) / cellHeight) * grid))); + for (let x = startX; x < endX; x++) { + const gx = Math.min(grid - 1, Math.max(0, Math.floor(((x - x0) / cellWidth) * grid))); + const sub = gy * grid + gx; + const i = (y * imageWidth + x) * 4; + sums[sub * 4] += data[i]; + sums[sub * 4 + 1] += data[i + 1]; + sums[sub * 4 + 2] += data[i + 2]; + sums[sub * 4 + 3] += data[i + 3]; + counts[sub]++; + } + } + + const signature = new Array(grid * grid * 4).fill(0); + for (let sub = 0; sub < counts.length; sub++) { + if (counts[sub] > 0) { + signature[sub * 4] = sums[sub * 4] / counts[sub]; + signature[sub * 4 + 1] = sums[sub * 4 + 1] / counts[sub]; + signature[sub * 4 + 2] = sums[sub * 4 + 2] / counts[sub]; + signature[sub * 4 + 3] = sums[sub * 4 + 3] / counts[sub]; + } + } + return signature; +} + +function expectSignatureMatches(actual: CellSignature, reference: CellSignature, message: string): void { + let diff = 0; + for (let i = 0; i < actual.length; i++) { + diff = Math.max(diff, Math.abs(actual[i] - reference[i])); + } + expect(diff, `${message}; max channel diff ${diff}`).toBeLessThanOrEqual(14); +} + +test.describe('WebGL atlas TUI stress (#6038)', () => { + test.skip(({ browserName }) => browserName !== 'chromium', 'WebGL atlas tests run on Chromium/SwiftShader only'); + test.describe.configure({ timeout: 90000 }); + + test('primary-buffer glyphs survive alternate-buffer redraws and atlas page churn', async ({ browser }) => { + const ctx = await createTestContext(browser); + const errors: string[] = []; + const onError = (error: Error): void => { errors.push(error.message); }; + ctx.page.on('pageerror', onError); + try { + await openTerminal(ctx, { cols: 80, rows: 24, scrollback: 5000 }); + await loadWebglStrict(ctx); + await installAtlasEventStats(ctx); + + await writeAndWaitForRender(ctx, REFERENCE_LINE); + const reference = await captureRowSignatures(ctx, 1, REFERENCE_COLS, 'atlas-stress-reference'); + + await writeAndWaitForRender(ctx, '\x1b[?1049h'); + for (const frame of generateSyntheticTuiFrames(30, 80, 24)) { + await writeAndWaitForRender(ctx, frame); + } + const glyphsPerViewport = 23 * 40 - 1; + for (let chunk = 0; chunk < 4; chunk++) { + await writeAndWaitForRender(ctx, `\x1b[2J\x1b[H${generateUniqueGlyphFlood(glyphsPerViewport, 80, chunk * glyphsPerViewport)}`); + } + await writeAndWaitForRender(ctx, '\x1b[?1049l'); + await refreshAndWaitForRender(ctx); + + const state = await getAtlasState(ctx); + expect(state.additions, 'stress workload must add atlas pages').toBeGreaterThan(0); + expect(state.maxPages, 'stress workload must exercise multiple atlas pages').toBeGreaterThan(1); + expect(state.maxPages, 'atlas pages must remain within renderer texture capacity').toBeLessThanOrEqual(state.atlasTextures); + expect(errors, `renderer must not throw during TUI stress: ${errors[0] ?? ''}`).toEqual([]); + + const primaryText = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + return w.term.buffer.active.getLine(0)?.translateToString(true); + }); + expect(primaryText, 'primary buffer must still contain the reference line').toBe(REFERENCE_LINE); + + const after = await captureRowSignatures(ctx, 1, REFERENCE_COLS, 'atlas-stress-after'); + for (let i = 0; i < REFERENCE_COLS.length; i++) { + expectSignatureMatches(after[i], reference[i], `primary-buffer glyph at col ${REFERENCE_COLS[i]} changed after TUI stress`); + } + } finally { + ctx.page.off('pageerror', onError); + await ctx.page.close(); + } + }); +}); diff --git a/demo/client/components/window/webglWindow.ts b/demo/client/components/window/webglWindow.ts index d7c175c45..90634a105 100644 --- a/demo/client/components/window/webglWindow.ts +++ b/demo/client/components/window/webglWindow.ts @@ -15,28 +15,30 @@ export class WebglWindow extends BaseWindow implements IControlWindow { private _stressScrolling: HTMLInputElement | undefined; public build(container: HTMLElement): void { - const stressDiv = document.createElement('div'); + const stressButtons = document.createElement('div'); const stressStart = document.createElement('button'); stressStart.id = 'stress-start'; stressStart.textContent = 'atlas stress start'; stressStart.addEventListener('click', () => this._stress()); - stressDiv.appendChild(stressStart); + stressButtons.appendChild(stressStart); const stressStop = document.createElement('button'); stressStop.id = 'stress-stop'; stressStop.textContent = 'atlas stress stop'; stressStop.addEventListener('click', () => this._stressRunning = false); - stressDiv.appendChild(stressStop); + stressButtons.appendChild(stressStop); + container.appendChild(stressButtons); + const stressOptions = document.createElement('div'); this._stressScrolling = document.createElement('input'); this._stressScrolling.type = 'checkbox'; this._stressScrolling.id = 'stress-scrolling'; - stressDiv.appendChild(this._stressScrolling); + stressOptions.appendChild(this._stressScrolling); const stressScrollingLabel = document.createElement('label'); stressScrollingLabel.htmlFor = 'stress-scrolling'; stressScrollingLabel.textContent = 'stress scrolling'; - stressDiv.appendChild(stressScrollingLabel); - container.appendChild(stressDiv); + stressOptions.appendChild(stressScrollingLabel); + container.appendChild(stressOptions); const zoomCheckbox = document.createElement('input'); zoomCheckbox.type = 'checkbox';