diff --git a/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts b/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts new file mode 100644 index 000000000..1f7fd759a --- /dev/null +++ b/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts @@ -0,0 +1,159 @@ +/** + * Copyright (c) 2025 The xterm.js authors. All rights reserved. + * @license MIT + * + * Reproduces the vscode#322756 "garbled glyph" corruption across terminals that + * SHARE a texture atlas (CharAtlasCache hands the same TextureAtlas to every + * terminal with an equal config — in VS Code that is every integrated terminal + * using the same font settings, including task/background terminals). + * + * Mechanism: when the atlas merges pages it raises a consume-once + * "clear model" flag (TextureAtlas.beginFrame). Only the FIRST terminal to + * render consumes it and rebuilds its vertex model; every other terminal keeps + * stale texture-page indexes/coordinates in its vertex buffer and draws wrong + * glyphs on its next partial render, even though its buffer content is intact. + * + * The DOM-renderer control shows the corruption is exclusive to the GPU path + * (`terminal.integrated.gpuAcceleration off` cannot reproduce it: the DOM + * renderer has no texture atlas). + * + * Chromium only (additive scope; Firefox/WebKit WebGL broken in CI, xtermjs#5854). + */ +import test, { expect } from '@playwright/test'; +import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils'; +import { + captureRowSignatures, + expectSignatureMatches, + getAtlasPageCount, + loadWebglStrict, + setMaxAtlasPages, + writeAndWaitForRender +} from '../../../test/playwright/RendererTestUtils'; +import { generateColoredAsciiFlood } from '../../../test/playwright/SyntheticTui'; + +const HEADER = ' HEADER_REF_0123456789'; +const HEADER_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; +const TERM_B_SELECTOR = '#terminal-container-b .xterm-screen'; + +/** + * Creates a second terminal ("termB") on the test page with the given options + * and optionally loads the WebGL addon on it. Identical rendering options mean + * CharAtlasCache gives it the same atlas instance as `window.term`. + */ +async function createTerminalB(ctx: ITestContext, options: { useWebgl: boolean }): Promise { + await ctx.page.evaluate(async (useWebgl) => { + const w = window as any; + w.termB?.dispose(); + document.getElementById('terminal-container-b')?.remove(); + const el = document.createElement('div'); + el.id = 'terminal-container-b'; + document.body.appendChild(el); + w.termB = new w.Terminal({ cols: 80, rows: 24, allowProposedApi: true }); + w.termB.open(el); + if (useWebgl) { + w.addonB = new w.WebglAddon({ preserveDrawingBuffer: true }); + w.termB.loadAddon(w.addonB); + } + }, options.useWebgl); +} + +/** Writes data to termB and resolves after its next render event. */ +async function writeToBAndWaitForRender(ctx: ITestContext, data: string): Promise { + await ctx.page.evaluate(d => new Promise(resolve => { + const termB = (window as any).termB; + const disposable = termB.onRender(() => { + disposable.dispose(); + resolve(); + }); + termB.write(d); + }), data); +} + +async function getTermBRow1Text(ctx: ITestContext): Promise { + return ctx.page.evaluate(() => (window as any).termB.buffer.active.getLine(0)?.translateToString(true)); +} + +test.describe('shared-atlas garble across terminals (#322756)', () => { + test.skip(({ browserName }) => browserName !== 'chromium'); + test.describe.configure({ timeout: 60000 }); + + test('terminal B keeps rendering its header correctly while terminal A forces atlas merges', async ({ browser }) => { + const ctx: ITestContext = await createTestContext(browser); + try { + await openTerminal(ctx, { cols: 80, rows: 24 }); + await loadWebglStrict(ctx); + await createTerminalB(ctx, { useWebgl: true }); + + // Both terminals must be using the same atlas instance for this test to + // mean anything. + const atlasShared = await ctx.page.evaluate(() => { + const w = window as any; + const atlasA = w.term._core._renderService._renderer.value._charAtlas; + const atlasB = w.termB._core._renderService._renderer.value._charAtlas; + return !!atlasA && atlasA === atlasB; + }); + expect(atlasShared, 'terminals with equal configs should share one texture atlas').toBe(true); + + // Lower the cap AFTER both renderers exist so their shaders/texture + // arrays keep the original size (this test targets stale vertex data, + // not the page-overflow crash). + await setMaxAtlasPages(ctx, 4); + + // Render B's header pre-merge and capture its reference pixels. + await writeToBAndWaitForRender(ctx, HEADER); + await ctx.page.locator(TERM_B_SELECTOR).screenshot({ path: 'out-esbuild-test/playwright/termB-before.png' }); + const reference = await captureRowSignatures(ctx, 1, HEADER_COLS, { selector: TERM_B_SELECTOR, termGlobal: 'termB' }); + + // Churn terminal A with distinct colored glyphs to force page merges in + // the SHARED atlas. A repairs itself (it consumes the clear-model flag); + // B is never told. + for (let c = 0; c < 10; c++) { + await writeAndWaitForRender(ctx, '\x1b[2;1H' + generateColoredAsciiFlood(23 * 78, c * 23 * 78)); + } + const pages = await getAtlasPageCount(ctx); + expect(pages, 'churn should have exercised multiple atlas pages').toBeGreaterThan(1); + + // A tiny unrelated update to B's LAST row triggers a partial render in B. + // B's header row is untouched; with a healthy renderer its pixels must + // not change. + await writeToBAndWaitForRender(ctx, '\x1b[24;1Hx'); + await ctx.page.locator(TERM_B_SELECTOR).screenshot({ path: 'out-esbuild-test/playwright/termB-after.png' }); + + // Guard: B's buffer content is intact, so any pixel change is renderer + // corruption by construction. + expect(await getTermBRow1Text(ctx), 'terminal B buffer must still contain the header').toBe(HEADER); + + const after = await captureRowSignatures(ctx, 1, HEADER_COLS, { selector: TERM_B_SELECTOR, termGlobal: 'termB' }); + for (let i = 0; i < HEADER_COLS.length; i++) { + expectSignatureMatches(after[i], reference[i], 14, `terminal B header col ${HEADER_COLS[i]} garbled by terminal A's atlas merges`); + } + } finally { + await ctx.page.close(); + } + }); + + test('DOM renderer control: same workload cannot garble terminal B (no atlas without GPU acceleration)', async ({ browser }) => { + const ctx: ITestContext = await createTestContext(browser); + try { + // No WebGL addon on either terminal -> DOM renderer -> no texture atlas. + await openTerminal(ctx, { cols: 80, rows: 24 }); + await createTerminalB(ctx, { useWebgl: false }); + + await writeToBAndWaitForRender(ctx, HEADER); + const reference = await captureRowSignatures(ctx, 1, HEADER_COLS, { selector: TERM_B_SELECTOR, termGlobal: 'termB' }); + + for (let c = 0; c < 10; c++) { + await writeAndWaitForRender(ctx, '\x1b[2;1H' + generateColoredAsciiFlood(23 * 78, c * 23 * 78)); + } + await writeToBAndWaitForRender(ctx, '\x1b[24;1Hx'); + + expect(await getTermBRow1Text(ctx), 'terminal B buffer must still contain the header').toBe(HEADER); + const after = await captureRowSignatures(ctx, 1, HEADER_COLS, { selector: TERM_B_SELECTOR, termGlobal: 'termB' }); + for (let i = 0; i < HEADER_COLS.length; i++) { + expectSignatureMatches(after[i], reference[i], 14, `DOM-rendered terminal B header col ${HEADER_COLS[i]} unexpectedly changed`); + } + } finally { + await ctx.page.close(); + } + }); +});