Compare commits

...
Author SHA1 Message Date
Anthony Kim fab7bc3c5a Reset maxAtlasPages after tests that lower it
setMaxAtlasPages mutates a static that lives for the page's lifetime, so a
lowered cap silently leaked into every renderer built by later tests in the
same file (their GL texture and shader sampler arrays were sized at the tiny
cap, making tests order-dependent). Add resetMaxAtlasPages and call it in
afterEach wherever the cap is lowered on a shared page.
2026-07-04 13:04:11 -07:00
Anthony Kim f9b043274d Fix atlas page overflow: merge same-size pairs, evict as last resort
TextureAtlas._createNewPage only merged exactly-4 same-size page sets; with a
heterogeneous size mix (e.g. 1024+512+512+512) it pushed past maxAtlasPages
instead. GlyphRenderer's GL texture array and fragment-shader sampler array
are sized to exactly maxAtlasPages, so the next render threw TypeError:
Cannot read properties of undefined (reading 'version') and rendering froze
(single-terminal breakage under long heavy-CLI sessions, vscode#322756).

- Generalize merge selection to the largest same-size group of 2-4 pages
  (preferring 4, then larger sizes); AtlasPage already supported non-quad
  merges. Net page count never exceeds the cap.
- Derive merging page indexes via _pages.indexOf instead of glyphs[0]
  .texturePage, which is empty-page safe.
- Last resort when no same-size pair exists below max texture size (only
  possible when almost all pages are at max size): evict all pages; glyphs
  re-rasterize on demand and models rebuild via pagesVersion.
- Defensive clamp in GlyphRenderer.render so any future overflow renders
  untextured glyphs and logs a warning instead of throwing mid-frame.

WebglAtlasOverflow now passes (was 4x TypeError); full webgl suite 75/75.
2026-07-04 13:01:13 -07:00
Anthony Kim 8481bfd8dd Add failing repro: atlas page overflow crashes GlyphRenderer.render
GlyphRenderer sizes its GL texture array and fragment-shader sampler array to
exactly TextureAtlas.maxAtlasPages at construction, but _createNewPage pushes
past that cap whenever a 4-page same-size merge is impossible (heterogeneous
sizes, e.g. 1024+512+512+512). The next render throws TypeError: Cannot read
properties of undefined (reading 'version') and rendering freezes — the
single-terminal breakage vector for long heavy-CLI sessions (vscode#322756).

The test builds the renderer with maxAtlasPages=4 (same code path as the
production 8/16/32 values), churns distinct glyphs, and asserts no pageerror,
pages.length <= texture capacity, and an intact pinned header. RED today:
4x TypeError from GlyphRenderer.render.
2026-07-04 12:58:33 -07:00
Anthony Kim 32fdce592b Fix shared-atlas garble: track page changes per renderer, not consume-once
TextureAtlas.beginFrame() returned a consume-once boolean, so after a page
merge only the FIRST renderer to draw rebuilt its vertex model. Terminals
sharing the atlas via CharAtlasCache kept stale texture-page indexes/coords
and drew garbled glyphs on their next partial render (vscode#322756).

Replace the flag with a monotonic TextureAtlas.pagesVersion, bumped on page
merges and overflow-page creation. Each GlyphRenderer tracks its own
last-seen value in beginFrame(), so every terminal sharing the atlas rebuilds
its model independently. setAtlas() resets the tracker to force a rebuild
when the atlas instance changes.

WebglSharedAtlasGarble now passes (was diff ~130/255 vs tolerance 14); full
webgl addon suite 74/74 green.
2026-07-04 12:54:34 -07:00
Anthony Kim 3113eaefaa Add failing repro: shared-atlas garble across terminals (vscode#322756)
Two terminals with equal configs share one TextureAtlas (CharAtlasCache).
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/coords and draws wrong glyphs on its next partial render even though
its buffer is intact.

The test churns terminal A to force merges, then gives terminal B a tiny
partial update: B's untouched header row garbles (signature diff ~130/255 vs
tolerance 14) while B's buffer still reads HEADER_REF. A DOM-renderer control
runs the same workload without GPU acceleration and passes: the corruption is
exclusive to the WebGL path.

RED on current code by design; the fix lands separately.
2026-07-04 12:48:26 -07:00
Anthony Kim a56dc02cc7 Fix scroll off-by-one in synthetic flood; guard repro against buffer scroll
generateColoredAsciiFlood emitted a trailing \r\n after its final cell, so a
flood whose last line lands on the bottom row scrolled the screen. In the
alt-buffer repro this destroyed the pinned header row in the BUFFER, making
the 255/255 signature diff a content change, not renderer corruption. The
single-terminal merge path is actually healthy: with the scroll removed the
same 30-chunk merge-heavy workload renders the header with zero drift.

- generateColoredAsciiFlood: no linefeed after the final cell
- WebglReproExperiment: now a green guard; asserts row-1 buffer text first so
  a content bug can never masquerade as corruption again
- WebglVisual: accurate comments; erase rewritten lines so leftover prefill
  text doesn't look like corruption
- WebglSingleTermStress: replace stale EXPECTED-RED comment (test passes)
2026-07-04 12:46:27 -07:00
Anthony Kim e3399b1db8 webgl stress + glyph-integrity tests for atlas merges; repro vscode#322756 single-terminal garble 2026-06-28 18:27:37 -07:00
13 changed files with 1553 additions and 53 deletions
+28 -3
View File
@@ -100,6 +100,8 @@ export class GlyphRenderer extends Disposable {
private readonly _attributesBuffer: WebGLBuffer;
private _atlas: ITextureAtlas | undefined;
private _lastAtlasPagesVersion: number = -1;
private _pageOverflowWarned: boolean = false;
private _activeBuffer: number = 0;
private readonly _vertices: IVertices = {
count: 0,
@@ -213,8 +215,21 @@ export class GlyphRenderer extends Disposable {
this.handleResize();
}
/**
* Returns whether the full model must be rebuilt before rendering this frame.
* This is tracked per renderer (not on the shared atlas) so that every
* terminal sharing the atlas rebuilds its model after a page merge, not just
* the first one to render (vscode#322756).
*/
public beginFrame(): boolean {
return this._atlas ? this._atlas.beginFrame() : true;
if (!this._atlas) {
return true;
}
if (this._atlas.pagesVersion !== this._lastAtlasPagesVersion) {
this._lastAtlasPagesVersion = this._atlas.pagesVersion;
return true;
}
return false;
}
public updateCell(x: number, y: number, code: number, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void {
@@ -362,8 +377,15 @@ export class GlyphRenderer extends Disposable {
// Bind the atlas page texture if they have changed. AtlasPage.version is globally
// monotonic, so a page object swap at the same index (which happens after a page merge)
// is detected by the same comparison.
for (let i = 0; i < this._atlas.pages.length; i++) {
// is detected by the same comparison. The page count is clamped to the fixed texture
// array size so an atlas page overflow degrades (untextured glyphs) instead of throwing
// mid-frame; the atlas is responsible for never exceeding maxAtlasPages.
const pageCount = Math.min(this._atlas.pages.length, this._atlasTextures.length);
if (this._atlas.pages.length > this._atlasTextures.length && !this._pageOverflowWarned) {
this._pageOverflowWarned = true;
this._logService.warn(`Atlas page count (${this._atlas.pages.length}) exceeds the renderer's texture capacity (${this._atlasTextures.length}), some glyphs will not render correctly`);
}
for (let i = 0; i < pageCount; i++) {
if (this._atlas.pages[i].version !== this._atlasTextures[i].version) {
this._bindAtlasPageTexture(gl, this._atlas, i);
}
@@ -375,6 +397,9 @@ export class GlyphRenderer extends Disposable {
public setAtlas(atlas: ITextureAtlas): void {
this._atlas = atlas;
// Force a model rebuild on the next frame as glyph coordinates from a
// previous atlas are meaningless in the new one.
this._lastAtlasPagesVersion = -1;
this.invalidateAtlasTextures();
}
+79 -47
View File
@@ -132,12 +132,16 @@ export class TextureAtlas implements ITextureAtlas {
}
}
private _requestClearModel = false;
public beginFrame(): boolean {
const result = this._requestClearModel;
this._requestClearModel = false;
return result;
}
/**
* Incremented whenever the structure of the pages array changes in a way that
* invalidates texture page indexes/coordinates cached outside the atlas (page
* merges, overflow page creation). Renderers compare this against their own
* last-seen value to decide when to rebuild their models; the atlas is shared
* across terminals, so a consume-once flag would only repair the first
* renderer to draw (see the shared-atlas garble in vscode#322756).
*/
private _pagesVersion = 0;
public get pagesVersion(): number { return this._pagesVersion; }
public clearTexture(): void {
if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) {
@@ -157,7 +161,7 @@ export class TextureAtlas implements ITextureAtlas {
// animation frame which would result in blank rendered areas. This is actually not that
// expensive relative to drawing the glyphs, so there is no need to wait for an idle callback.
if (TextureAtlas.maxAtlasPages && this._pages.length >= Math.max(4, TextureAtlas.maxAtlasPages)) {
// Find the set of the largest 4 images, below the maximum size, with the highest
// Find the set of the largest images, below the maximum size, with the highest
// percentages used
const pagesBySize = this._pages.filter(e => {
return e.canvas.width * 2 <= (TextureAtlas.maxTextureSize || Constants.FORCED_MAX_TEXTURE_SIZE);
@@ -167,48 +171,55 @@ export class TextureAtlas implements ITextureAtlas {
}
return b.percentageUsed - a.percentageUsed;
});
let sameSizeI = -1;
let size = 0;
for (let i = 0; i < pagesBySize.length; i++) {
if (pagesBySize[i].canvas.width !== size) {
sameSizeI = i;
size = pagesBySize[i].canvas.width;
} else if (i - sameSizeI === 3) {
break;
// Find the largest group (up to 4) of same-sized pages, preferring larger page sizes. A
// group of 2 or 3 still reduces (or at worst maintains) the page count net of the new page
// pushed below; growing past the cap is never an option as GlyphRenderer's texture and
// shader sampler arrays are sized to exactly maxAtlasPages (exceeding them crashes render).
let bestGroupStart = -1;
let bestGroupSize = 0;
let groupStart = 0;
for (let i = 1; i <= pagesBySize.length; i++) {
if (i === pagesBySize.length || pagesBySize[i].canvas.width !== pagesBySize[groupStart].canvas.width) {
const groupSize = Math.min(4, i - groupStart);
if (groupSize > bestGroupSize) {
bestGroupSize = groupSize;
bestGroupStart = groupStart;
if (groupSize === 4) {
break;
}
}
groupStart = i;
}
}
// Gather details of the merge
const mergingPages = pagesBySize.slice(sameSizeI, sameSizeI + 4);
if (bestGroupSize >= 2) {
// Gather details of the merge
const mergingPages = pagesBySize.slice(bestGroupStart, bestGroupStart + bestGroupSize);
const sortedMergingPagesIndexes = mergingPages.map(e => this._pages.indexOf(e)).sort((a, b) => a - b);
const mergedPageIndex = this.pages.length - mergingPages.length;
// Only proceed with merge if we have exactly 4 same-sized pages. If not, we cannot
// effectively reduce page count and merging would cause issues.
if (mergingPages.length < 4 || mergingPages.some(p => p.canvas.width !== mergingPages[0].canvas.width)) {
const newPage = new AtlasPage(this._document, this._textureSize);
this._pages.push(newPage);
this._activePages.push(newPage);
this._onAddTextureAtlasCanvas.fire(newPage.canvas);
return newPage;
// Merge into the new page
const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
mergedPage.version = ++AtlasPage.nextVersion;
// Delete the pages, shifting glyph texture pages as needed
for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
this._deletePage(sortedMergingPagesIndexes[i]);
}
// Add the new merged page to the end
this.pages.push(mergedPage);
// Invalidate models so all texture pages are refreshed.
this._pagesVersion++;
this._onAddTextureAtlasCanvas.fire(mergedPage.canvas);
} else {
// No two same-sized pages below the maximum size exist, so merging cannot reduce the
// page count. Evict all pages rather than exceed the renderers' fixed texture/sampler
// capacity; glyphs re-rasterize on demand and models rebuild via pagesVersion.
this._evictAllPages();
}
const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a - b);
const mergedPageIndex = this.pages.length - mergingPages.length;
// Merge into the new page
const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
mergedPage.version = ++AtlasPage.nextVersion;
// Delete the pages, shifting glyph texture pages as needed
for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
this._deletePage(sortedMergingPagesIndexes[i]);
}
// Add the new merged page to the end
this.pages.push(mergedPage);
// Request the model to be cleared to refresh all texture pages.
this._requestClearModel = true;
this._onAddTextureAtlasCanvas.fire(mergedPage.canvas);
}
// All new atlas pages are created small as they are highly dynamic
@@ -258,6 +269,27 @@ export class TextureAtlas implements ITextureAtlas {
}
}
/**
* Removes every page and clears the glyph caches. This is the last resort when the page cap is
* reached but no same-sized pages exist to merge: the page count must never exceed
* maxAtlasPages because renderers size their texture and shader sampler arrays to exactly
* that. Glyphs re-rasterize on demand and renderers rebuild their models via
* {@link pagesVersion}.
*/
private _evictAllPages(): void {
for (const page of this._pages) {
this._onRemoveTextureAtlasCanvas.fire(page.canvas);
page.canvas.remove();
}
this._pages.length = 0;
this._activePages.length = 0;
this._overflowSizePage = undefined;
this._cacheMap.clear();
this._cacheMapCombined.clear();
this._didWarmUp = false;
this._pagesVersion++;
}
public getRasterizedGlyphCombinedChar(chars: string, bg: number, fg: number, ext: number, restrictToCellHeight: boolean, domContainer: HTMLElement | undefined): IRasterizedGlyph {
return this._getFromCacheMap(this._cacheMapCombined, chars, bg, fg, ext, restrictToCellHeight, domContainer);
}
@@ -818,8 +850,8 @@ export class TextureAtlas implements ITextureAtlas {
this._overflowSizePage = new AtlasPage(this._document, this._config.deviceMaxTextureSize);
this.pages.push(this._overflowSizePage);
// Request the model to be cleared to refresh all texture pages.
this._requestClearModel = true;
// Invalidate models so all texture pages are refreshed.
this._pagesVersion++;
this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas);
}
activePage = this._overflowSizePage;
@@ -1099,7 +1131,7 @@ class AtlasPage {
sourcePages[2]._usedPixels +
sourcePages[3]._usedPixels;
} else {
// fallback for non quadmerges (should never be used)
// 2- and 3-page merges, used when no 4-page same-size set exists at the page cap
for (let i = 0; i < sourcePages.length; ++i) {
this._glyphs = this._glyphs.concat(sourcePages[i].glyphs);
this._usedPixels += sourcePages[i]._usedPixels;
+6 -3
View File
@@ -68,10 +68,13 @@ export interface ITextureAtlas extends IDisposable {
warmUp(): void;
/**
* Call when a frame is being drawn, this will return true if the atlas was cleared to make room
* for a new set of glyphs.
* Incremented whenever the pages array changes structurally (page merge,
* overflow page creation), invalidating any texture page indexes/coordinates
* cached by renderers. Tracked as a version rather than a consumable flag
* because the atlas may be shared by multiple renderers, each of which must
* notice the change independently.
*/
beginFrame(): boolean;
readonly pagesVersion: number;
/**
* Clear all glyphs from the texture atlas.
@@ -0,0 +1,104 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*
* Reproduces the single-terminal WebGL crash/garble when the texture atlas
* page count overflows TextureAtlas.maxAtlasPages (vscode#322756 class).
*
* GlyphRenderer sizes its GL texture array and fragment-shader sampler array
* to exactly maxAtlasPages at construction time, but TextureAtlas._createNewPage
* pushes past that cap whenever a 4-page same-size merge is impossible (e.g. a
* heterogeneous mix like 1024+512+512+512). The next GlyphRenderer.render then
* throws "Cannot read properties of undefined (reading 'version')" and cells
* reference sampler indexes the shader does not have, freezing/garbling the
* terminal. Long heavy-CLI sessions hit this in production; the test makes it
* fast by building the renderer with a small cap (the same code path as the
* production 8/16/32 values).
*
* 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,
loadWebglStrict,
resetMaxAtlasPages,
setMaxAtlasPages,
writeAndWaitForRender
} from '../../../test/playwright/RendererTestUtils';
import { generateUniqueGlyphFlood } from '../../../test/playwright/SyntheticTui';
const HEADER = ' OVERFLOW_REF_0123456789';
const HEADER_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
test.describe('atlas page overflow (#322756 class)', () => {
test.skip(({ browserName }) => browserName !== 'chromium');
test.describe.configure({ timeout: 60000 });
test('renderer survives the atlas exceeding maxAtlasPages-sized textures', async ({ browser }) => {
const ctx: ITestContext = await createTestContext(browser);
const errors: string[] = [];
const onError = (e: Error): void => { errors.push(e.message); };
ctx.page.on('pageerror', onError);
try {
// Bootstrap: create a throwaway WebGL terminal so the TextureAtlas class
// statics exist, then lower the page cap. openTerminal() disposes the old
// terminal (and its atlas), so the next WebGL renderer is CONSTRUCTED
// with the small cap: its GL texture array and fragment-shader sampler
// array have exactly 4 entries, faithfully mirroring production where
// they always equal maxAtlasPages.
await openTerminal(ctx, { cols: 80, rows: 24 });
await loadWebglStrict(ctx);
await setMaxAtlasPages(ctx, 4);
await openTerminal(ctx, { cols: 80, rows: 24 });
await loadWebglStrict(ctx);
const atlasTexturesLength = await ctx.page.evaluate(() =>
(window as any).term._core._renderService._renderer.value._glyphRenderer.value._atlasTextures.length);
expect(atlasTexturesLength, 'renderer must be built with the lowered page cap').toBe(4);
// Pinned header in the alt buffer; body churns distinct CJK glyphs below
// (cursor-positioned, no trailing linefeed on the bottom row -> no scroll).
await writeAndWaitForRender(ctx, `\x1b[?1049h\x1b[H${HEADER}`);
const reference = await captureRowSignatures(ctx, 1, HEADER_COLS);
await ctx.page.locator('#terminal-container .xterm-screen').screenshot({ path: 'out-esbuild-test/playwright/overflow-before.png' });
const glyphsPerChunk = 23 * 40 - 1;
for (let c = 0; c < 16; c++) {
// Plain write + short wait instead of waiting for a render event: once
// the overflow crash fires, render events stop arriving and a render
// wait would hang the test instead of failing it.
await ctx.proxy.write('\x1b[2;1H' + generateUniqueGlyphFlood(glyphsPerChunk, 80, c * glyphsPerChunk));
await ctx.page.waitForTimeout(50);
}
await ctx.page.waitForTimeout(300);
await ctx.page.locator('#terminal-container .xterm-screen').screenshot({ path: 'out-esbuild-test/playwright/overflow-after.png' });
const state = await ctx.page.evaluate(() => {
const renderer = (window as any).term._core._renderService._renderer.value;
return {
pages: renderer._charAtlas.pages.length,
atlasTextures: renderer._glyphRenderer.value._atlasTextures.length
};
});
console.error('[overflow] pages=', state.pages, 'atlasTextures=', state.atlasTextures, 'pageErrors=', errors.length, errors[0] ?? '');
// Contract: the page count must never exceed the renderer's texture and
// sampler capacity, and rendering must never throw.
expect(errors, `GlyphRenderer must not crash when the atlas is saturated: ${errors[0] ?? ''}`).toEqual([]);
expect(state.pages, 'atlas page count must stay within the renderer texture capacity').toBeLessThanOrEqual(state.atlasTextures);
// The untouched header must still render correctly after the churn.
const after = await captureRowSignatures(ctx, 1, HEADER_COLS);
for (let i = 0; i < HEADER_COLS.length; i++) {
expectSignatureMatches(after[i], reference[i], 14, `header col ${HEADER_COLS[i]} corrupted after atlas saturation`);
}
} finally {
ctx.page.off('pageerror', onError);
// Restore the default so a lowered cap can never leak into other tests.
await resetMaxAtlasPages(ctx).catch(() => { /* page may already be unusable after a crash */ });
await ctx.page.close();
}
});
});
@@ -0,0 +1,109 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*
* WebGL device-pixel-ratio (HiDPI) and font-size ("resize") atlas tests.
*
* A devicePixelRatio > 1 and font-size changes both change the device cell
* dimensions, which key the texture atlas cache and force the atlas to be
* rebuilt. Historically these paths have produced blank/oversized/garbled glyphs
* (e.g. xtermjs#4728, #5915/#5929). These tests assert glyphs render and stay
* correct across those transitions.
*
* Chromium + SwiftShader only (additive scope; Firefox/WebKit WebGL broken in CI
* per xtermjs#5854).
*/
import test, { Browser, expect } from '@playwright/test';
import { ITestContext, TerminalProxy, openTerminal } from '../../../test/playwright/TestUtils';
import {
assertTextureAtlasPresent,
captureRowSignatures,
expectSignatureMatches,
loadWebglStrict,
waitForRender,
writeAndWaitForRender
} from '../../../test/playwright/RendererTestUtils';
const REFERENCE_COLS = [1, 2, 3, 4, 5, 6, 7, 8];
/**
* Creates a test context whose page uses a specific devicePixelRatio. The shared
* `createTestContext` helper always uses DPR 1 (`browser.newPage()`), so HiDPI
* tests build their own context here.
*/
async function createDprContext(browser: Browser, deviceScaleFactor: number): Promise<ITestContext> {
const context = await browser.newContext({ deviceScaleFactor });
const page = await context.newPage();
page.on('pageerror', e => console.error(`[dpr ${deviceScaleFactor}]`, e));
await page.goto('/test');
const proxy = new TerminalProxy(page);
proxy.initPage();
return {
browser,
page,
termHandle: await page.evaluateHandle('window.term'),
proxy
};
}
test.describe('WebGL HiDPI (devicePixelRatio) rendering', () => {
test.skip(({ browserName }) => browserName !== 'chromium', 'WebGL atlas tests run on Chromium/SwiftShader only');
test('initializes the WebGL renderer and scales to device pixels at devicePixelRatio 2', async ({ browser }) => {
const ctx = await createDprContext(browser, 2);
try {
await openTerminal(ctx, { cols: 80, rows: 24 });
// GPU must be genuinely active at HiDPI (asserted inside loadWebglStrict).
await loadWebglStrict(ctx);
await writeAndWaitForRender(ctx, 'A W .');
await assertTextureAtlasPresent(ctx);
// The WebGL renderer must render at the device resolution: device cell
// dimensions should be ~2x the CSS cell dimensions. This catches the
// DPR-scaling class of bugs (e.g. xtermjs#4728) without relying on
// screenshotting a WebGL canvas at a non-1 device scale factor.
const dims = await ctx.proxy.core.renderDimensions;
expect(dims.device.cell.width).toBeGreaterThan(dims.css.cell.width * 1.8);
expect(dims.device.cell.width).toBeLessThan(dims.css.cell.width * 2.2);
expect(dims.device.cell.height).toBeGreaterThan(dims.css.cell.height * 1.8);
expect(dims.device.cell.height).toBeLessThan(dims.css.cell.height * 2.2);
} finally {
await ctx.page.context().close();
}
});
});
test.describe('WebGL font-size change atlas integrity', () => {
let ctx: ITestContext;
test.skip(({ browserName }) => browserName !== 'chromium', 'WebGL atlas tests run on Chromium/SwiftShader only');
test.beforeAll(async ({ browser }) => {
ctx = await createDprContext(browser, 1);
});
test.afterAll(async () => await ctx.page.close());
test.beforeEach(async () => {
await openTerminal(ctx, { cols: 80, rows: 24, fontSize: 15 });
await loadWebglStrict(ctx);
});
test('glyphs stay correct after cycling font size (atlas rebuilds)', async () => {
await writeAndWaitForRender(ctx, 'FontRef0123456789');
const reference = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
// Changing font size changes device cell dimensions -> the atlas is rebuilt.
for (const fontSize of [12, 18, 21, 15]) {
await ctx.proxy.setOption('fontSize', fontSize);
await writeAndWaitForRender(ctx, '\r\nchurn');
}
// Back at the original font size the reference glyphs must be unchanged.
await ctx.proxy.scrollToTop();
await waitForRender(ctx);
const after = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
for (let i = 0; i < REFERENCE_COLS.length; i++) {
expectSignatureMatches(after[i], reference[i], 16, `reference glyph at col ${REFERENCE_COLS[i]} corrupted after font-size cycling`);
}
});
});
+121
View File
@@ -0,0 +1,121 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*
* WebGL glyph + texture-atlas integrity tests.
*
* These tests verify that *glyphs* render correctly (not just that cell colors
* are right) under conditions that stress the texture atlas: multiple atlas
* pages, page merges and atlas clears. The goal is to catch the "garbled glyph"
* corruption class reported in microsoft/vscode#322756.
*
* Note: CI runs WebGL on Chromium + SwiftShader (software WebGL). That catches
* renderer/atlas *logic* corruption (stale texture pages, missing model clears),
* but not GPU-driver-specific glitches. These tests target the former.
*/
import test, { expect } from '@playwright/test';
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
import {
assertTextureAtlasPresent,
captureRowSignatures,
expectSignatureDiffers,
expectSignatureMatches,
floodDistinctGlyphs,
loadWebglStrict,
resetMaxAtlasPages,
setMaxAtlasPages,
waitForRender,
writeAndWaitForRender
} from '../../../test/playwright/RendererTestUtils';
let ctx: ITestContext;
test.beforeAll(async ({ browser }) => {
ctx = await createTestContext(browser);
});
test.afterAll(async () => await ctx.page.close());
test.describe('WebGL glyph + atlas integrity', () => {
// Additive scope: only run on Chromium (SwiftShader). Firefox/WebKit WebGL
// tests are known-broken in CI (xtermjs#5854) and out of scope here.
test.skip(({ browserName }) => browserName !== 'chromium', 'WebGL atlas tests run on Chromium/SwiftShader only');
test.beforeEach(async () => {
// Fresh terminal (and therefore a predictable atlas) per test.
await openTerminal(ctx, { cols: 80, rows: 24 });
await loadWebglStrict(ctx);
});
// A lowered page cap must not leak into renderers built by later tests.
test.afterEach(async () => await resetMaxAtlasPages(ctx));
test('renders distinct, non-blank glyphs', async () => {
await writeAndWaitForRender(ctx, 'A W .');
await assertTextureAtlasPresent(ctx);
const [a, w, dot, blank] = await captureRowSignatures(ctx, 1, [1, 3, 5, 40]);
// A written cell must differ from an empty cell (glyph actually drawn).
expectSignatureDiffers(a, blank, 24, 'glyph "A" should not be blank');
expectSignatureDiffers(w, blank, 24, 'glyph "W" should not be blank');
// Different glyphs must look different (signature has discriminating power).
expectSignatureDiffers(a, w, 24, 'glyphs "A" and "W" should render differently');
expectSignatureDiffers(a, dot, 24, 'glyphs "A" and "." should render differently');
});
test('glyphs on the first atlas page stay correct after more pages are allocated', async () => {
// Reference: a known row of glyphs that lands on the first atlas page.
await writeAndWaitForRender(ctx, 'REFERENCE_LINE_0123456789\r\n');
const cols = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const reference = await captureRowSignatures(ctx, 1, cols);
// Flood thousands of distinct glyphs (one screenful rendered at a time, so
// they actually rasterize) to force multiple atlas pages.
const pages = await floodDistinctGlyphs(ctx, 4);
expect(pages, 'flooding distinct glyphs should allocate more than one atlas page').toBeGreaterThan(1);
// Bring the reference line back into view and re-check its glyphs.
await ctx.proxy.scrollToTop();
await waitForRender(ctx);
const after = await captureRowSignatures(ctx, 1, cols);
for (let i = 0; i < cols.length; i++) {
expectSignatureMatches(after[i], reference[i], 14, `reference glyph at col ${cols[i]} corrupted after multi-page allocation`);
}
});
test('glyphs stay correct after a texture-atlas page merge', async () => {
// Force merges to happen almost immediately.
await writeAndWaitForRender(ctx, 'MERGE_REF_LINE_0123456789\r\n');
await setMaxAtlasPages(ctx, 4);
const cols = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const reference = await captureRowSignatures(ctx, 1, cols);
// Flood distinct glyphs (rendered per screenful) to exceed the tiny page cap
// and trigger merges.
const pages = await floodDistinctGlyphs(ctx, 12);
expect(pages, 'flood should have allocated multiple atlas pages (and merged)').toBeGreaterThan(1);
await ctx.proxy.scrollToTop();
await waitForRender(ctx);
const after = await captureRowSignatures(ctx, 1, cols);
for (let i = 0; i < cols.length; i++) {
expectSignatureMatches(after[i], reference[i], 14, `reference glyph at col ${cols[i]} corrupted after atlas page merge`);
}
});
test('glyphs re-render correctly after clearTextureAtlas() on the same terminal', async () => {
await writeAndWaitForRender(ctx, 'CLEAR_REF_LINE_0123456789');
const cols = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const reference = await captureRowSignatures(ctx, 1, cols);
await ctx.proxy.clearTextureAtlas();
// Force a full redraw so glyphs are re-rasterized into the cleared atlas.
await ctx.proxy.refresh(0, (await ctx.proxy.rows) - 1);
await waitForRender(ctx);
const after = await captureRowSignatures(ctx, 1, cols);
for (let i = 0; i < cols.length; i++) {
expectSignatureMatches(after[i], reference[i], 14, `glyph at col ${cols[i]} corrupted after clearTextureAtlas()`);
}
});
});
@@ -0,0 +1,65 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*
* Single-terminal guard for the vscode#322756 "garbled glyph" class: a pinned
* alt-buffer header must render identically while the body churns enough
* distinct colored glyphs to force texture-atlas page merges.
*
* Note this scenario is GREEN on current master: the single-terminal mid-frame
* merge race was fixed upstream (merge-retry loop in WebglRenderer.renderRows +
* globally monotonic AtlasPage versions). The test asserts the buffer content
* first so a content bug (e.g. an accidental scroll from the churn stream) can
* never masquerade as renderer corruption.
*/
import test, { expect } from '@playwright/test';
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
import { captureRowSignatures, expectSignatureMatches, getAtlasPageCount, loadWebglStrict, resetMaxAtlasPages, setMaxAtlasPages, writeAndWaitForRender } from '../../../test/playwright/RendererTestUtils';
import { generateColoredAsciiFlood } from '../../../test/playwright/SyntheticTui';
const HEADER = ' HEADER_REF_0123456789';
let ctx: ITestContext;
test.beforeAll(async ({ browser }) => { ctx = await createTestContext(browser); });
test.afterAll(async () => await ctx.page.close());
test.describe('single-terminal merge integrity (#322756 class)', () => {
test.skip(({ browserName }) => browserName !== 'chromium');
test.describe.configure({ timeout: 60000 });
// A lowered page cap must not leak into renderers built by later tests.
test.afterEach(async () => await resetMaxAtlasPages(ctx));
test('pinned header renders identically across 30 merge-heavy body redraws', async () => {
await openTerminal(ctx, { cols: 80, rows: 24 });
await loadWebglStrict(ctx);
await setMaxAtlasPages(ctx, 4);
const cols = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// Alt-buffer TUI: pinned header on row 1, body churns below (like Copilot CLI).
await writeAndWaitForRender(ctx, `\x1b[?1049h\x1b[H${HEADER}`);
const ref = await captureRowSignatures(ctx, 1, cols);
let maxDiff = 0;
for (let c = 0; c < 30; c++) {
// Redraw body rows 2..24 with unique glyphs; the header row is never touched.
let frame = '\x1b[2;1H';
frame += generateColoredAsciiFlood(23 * 78, c * 23 * 78);
await writeAndWaitForRender(ctx, frame);
const now = await captureRowSignatures(ctx, 1, cols); // forces GPU sync
for (let i = 0; i < cols.length; i++) {
const d = Math.max(...ref[i].map((v, k) => Math.abs(v - now[i][k])));
maxDiff = Math.max(maxDiff, d);
}
}
console.error('[repro] pages=', await getAtlasPageCount(ctx), 'headerMaxDiff=', maxDiff);
// Guard: the header must still be in the buffer. If this fails the churn
// stream scrolled the screen and any pixel diff is meaningless.
const row1Text = await ctx.page.evaluate(() => (window as any).term.buffer.active.getLine(0)?.translateToString(true));
expect(row1Text, 'churn stream must not scroll the pinned header out of the buffer').toBe(HEADER);
const after = await captureRowSignatures(ctx, 1, cols);
for (let i = 0; i < cols.length; i++) {
expectSignatureMatches(after[i], ref[i], 14, `header col ${cols[i]} garbled`);
}
});
});
@@ -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<void> {
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<void> {
await ctx.page.evaluate(d => new Promise<void>(resolve => {
const termB = (window as any).termB;
const disposable = termB.onRender(() => {
disposable.dispose();
resolve();
});
termB.write(d);
}), data);
}
async function getTermBRow1Text(ctx: ITestContext): Promise<string | undefined> {
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();
}
});
});
@@ -0,0 +1,104 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*
* Single-terminal WebGL atlas stress: try to reproduce the "garbled glyph"
* corruption from microsoft/vscode#322756 in ONE terminal (no shared atlas),
* via heavy unique-glyph churn that forces atlas page merges, both with content
* pinned on screen (TUI-style) and mid-scroll. Chromium/SwiftShader only.
*/
import test, { expect } from '@playwright/test';
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
import {
captureRowSignatures,
expectSignatureMatches,
floodDistinctGlyphs,
getAtlasPageCount,
loadWebglStrict,
resetMaxAtlasPages,
setMaxAtlasPages,
waitForRender,
writeAndWaitForRender
} from '../../../test/playwright/RendererTestUtils';
import { generateUniqueGlyphFlood } from '../../../test/playwright/SyntheticTui';
let ctx: ITestContext;
test.beforeAll(async ({ browser }) => {
ctx = await createTestContext(browser);
});
test.afterAll(async () => await ctx.page.close());
test.describe('WebGL single-terminal heavy render + merge', () => {
test.skip(({ browserName }) => browserName !== 'chromium', 'WebGL atlas tests run on Chromium/SwiftShader only');
test.describe.configure({ timeout: 30000 });
test.beforeEach(async () => {
await openTerminal(ctx, { cols: 80, rows: 24, scrollback: 5000 });
await loadWebglStrict(ctx);
});
// A lowered page cap must not leak into renderers built by later tests.
test.afterEach(async () => await resetMaxAtlasPages(ctx));
test('heavy merge churn must not crash the GlyphRenderer (vscode#322756)', async () => {
// Guard: heavy merges must not throw from GlyphRenderer.render. Note the
// page cap here is lowered AFTER the renderer was built, so _atlasTextures
// keeps its original (large) length and this test cannot hit the
// page-overflow crash; that scenario needs the cap lowered BEFORE loading
// the addon and is covered separately (WebglAtlasOverflow.test.ts).
const errors: string[] = [];
const onError = (e: Error): void => { errors.push(e.message); };
ctx.page.on('pageerror', onError);
try {
await setMaxAtlasPages(ctx, 4);
await floodDistinctGlyphs(ctx, 10);
// Force a GPU read (screenshot) mid-churn so a render lands during/after a
// merge, then churn more — this reliably exposes the version crash.
await captureRowSignatures(ctx, 1, [1, 2, 3]);
const pages = await floodDistinctGlyphs(ctx, 10);
expect(pages, 'should have allocated/merged pages').toBeGreaterThan(1);
await ctx.page.waitForTimeout(300);
expect(errors, `renderer crashed during atlas merges: ${errors[0] ?? ''}`).toEqual([]);
} finally {
ctx.page.off('pageerror', onError);
}
});
test('pinned header stays correct while body churns many pages', async () => {
const cols = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
await writeAndWaitForRender(ctx, 'HEADER_REF_0123456789');
const reference = await captureRowSignatures(ctx, 1, cols);
// Body churns unique glyphs (forces merges) while header line stays on screen.
const pages = await floodDistinctGlyphs(ctx, 8);
expect(pages, 'should have allocated/merged pages').toBeGreaterThan(1);
await ctx.proxy.scrollToTop();
await waitForRender(ctx);
const after = await captureRowSignatures(ctx, 1, cols);
for (let i = 0; i < cols.length; i++) {
expectSignatureMatches(after[i], reference[i], 14, `header glyph col ${cols[i]} corrupted by single-terminal merges`);
}
});
test('visible rows match content while scrolling (no merge)', async () => {
// Big scrollback of distinct glyphs across multiple pages (no tiny page cap,
// so no merge), then scroll up/down repeatedly.
for (let c = 0; c < 8; c++) {
await writeAndWaitForRender(ctx, generateUniqueGlyphFlood(24 * 40, 80, c * 24 * 40));
}
expect(await getAtlasPageCount(ctx)).toBeGreaterThan(1);
await ctx.proxy.scrollToTop();
await waitForRender(ctx);
const top = await captureRowSignatures(ctx, 1, [1, 2, 3, 4, 5]);
await ctx.proxy.scrollToBottom();
await ctx.proxy.scrollToTop();
await waitForRender(ctx);
const topAgain = await captureRowSignatures(ctx, 1, [1, 2, 3, 4, 5]);
for (let i = 0; i < top.length; i++) {
expectSignatureMatches(topAgain[i], top[i], 14, `row1 col${i + 1} changed after scroll round-trip`);
}
});
});
@@ -0,0 +1,152 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*
* WebGL "heavy TUI" stress tests: large scrollback + scrolling, alternate-buffer
* full-screen redraws (like an agent CLI animating output), high-volume
* rendering and replay of recorded real-world TUI sessions (vim, colored ls).
*
* The shared invariant: a known reference row of glyphs, captured in a fresh
* atlas, must still render identically after the stress workload. If the WebGL
* texture atlas is corrupted by the workload the reference glyphs change and the
* test fails. See microsoft/vscode#322756.
*
* Chromium + SwiftShader only (additive scope; Firefox/WebKit WebGL are broken
* in CI per xtermjs#5854).
*/
import { readFileSync } from 'fs';
import test, { expect } from '@playwright/test';
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
import {
assertTextureAtlasPresent,
captureRowSignatures,
expectSignatureMatches,
loadWebglStrict,
waitForRender,
writeAndWaitForRender
} from '../../../test/playwright/RendererTestUtils';
import { generateMixedGlyphBlock, generateSyntheticTuiFrames, generateUniqueGlyphFlood } from '../../../test/playwright/SyntheticTui';
const REFERENCE_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const REFERENCE_LINE = 'Ref0123456789ABCDEF';
let ctx: ITestContext;
test.beforeAll(async ({ browser }) => {
ctx = await createTestContext(browser);
});
test.afterAll(async () => await ctx.page.close());
test.describe('WebGL heavy TUI / scrolling stress', () => {
test.skip(({ browserName }) => browserName !== 'chromium', 'WebGL stress tests run on Chromium/SwiftShader only');
test.beforeEach(async () => {
await openTerminal(ctx, { cols: 80, rows: 24, scrollback: 5000 });
await loadWebglStrict(ctx);
});
test('reference glyphs survive heavy scrolling through large scrollback', async () => {
await writeAndWaitForRender(ctx, `${REFERENCE_LINE}\r\n`);
const reference = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
// Push the reference far up into scrollback with thousands of varied glyphs.
for (let i = 0; i < 20; i++) {
await ctx.proxy.write(generateMixedGlyphBlock(i + 1, 30, 70));
}
await waitForRender(ctx);
// Scroll around, then back to the very top where the reference lives.
await ctx.proxy.scrollToTop();
await waitForRender(ctx);
await ctx.proxy.scrollPages(3);
await waitForRender(ctx);
await ctx.proxy.scrollToTop();
await waitForRender(ctx);
const after = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
for (let i = 0; i < REFERENCE_COLS.length; i++) {
expectSignatureMatches(after[i], reference[i], 14, `reference glyph at col ${REFERENCE_COLS[i]} corrupted after heavy scrolling`);
}
});
test('alternate-buffer TUI redraws do not corrupt the restored primary buffer', async () => {
// Reference content lives in the PRIMARY buffer.
await writeAndWaitForRender(ctx, `${REFERENCE_LINE}`);
const reference = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
// Animate a synthetic agent TUI in the alternate buffer.
const frames = generateSyntheticTuiFrames({ seed: 7, frames: 30, cols: 80, rows: 24, useAltBuffer: true });
for (const frame of frames) {
await ctx.proxy.write(frame);
}
await waitForRender(ctx);
await assertTextureAtlasPresent(ctx);
// Back in the primary buffer the reference glyphs must be intact.
await ctx.proxy.refresh(0, (await ctx.proxy.rows) - 1);
await waitForRender(ctx);
const after = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
for (let i = 0; i < REFERENCE_COLS.length; i++) {
expectSignatureMatches(after[i], reference[i], 14, `primary-buffer glyph at col ${REFERENCE_COLS[i]} corrupted by alt-buffer TUI redraws`);
}
});
test('reference glyphs survive a heavy mixed render + unique-glyph churn', async () => {
// Capture the reference rendering of a known line in a fresh atlas.
await writeAndWaitForRender(ctx, `${REFERENCE_LINE}`);
const reference = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
// High-volume rendering: synthetic TUI frames (which clear/redraw the screen)
// plus a large unique-glyph flood to churn the atlas, mimicking a long agent
// CLI session.
const frames = generateSyntheticTuiFrames({ seed: 3, frames: 40, cols: 80, rows: 24 });
for (const frame of frames) {
await ctx.proxy.write(frame);
}
await ctx.proxy.write(generateUniqueGlyphFlood(3000, 80));
await waitForRender(ctx);
// The synthetic frames erase the screen, so re-render the reference line and
// assert the (heavily churned) atlas still draws those glyphs correctly.
await writeAndWaitForRender(ctx, `\x1b[2J\x1b[H${REFERENCE_LINE}`);
const after = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
for (let i = 0; i < REFERENCE_COLS.length; i++) {
expectSignatureMatches(after[i], reference[i], 14, `reference glyph at col ${REFERENCE_COLS[i]} corrupted after heavy render`);
}
});
test('replaying recorded TUI sessions renders without errors or atlas corruption', async () => {
const pageErrors: string[] = [];
const onError = (err: Error): void => {
pageErrors.push(err.message);
};
ctx.page.on('pageerror', onError);
try {
const fixtures = [
'./test/fixtures/escape_sequence_files/t0090-alt_screen.in',
'./test/fixtures/escape_sequence_files/t0502-bash_ls_color.in',
'./test/fixtures/escape_sequence_files/t0500-bash_long_line.in',
'./test/fixtures/escape_sequence_files/t0504-vim.in'
];
for (const fixture of fixtures) {
const data = readFileSync(fixture, { encoding: 'latin1' });
await ctx.proxy.write(data);
}
await waitForRender(ctx);
await assertTextureAtlasPresent(ctx);
// Re-rendering the final frame must be idempotent (self-consistent): if the
// atlas were corrupted, a forced redraw would differ from what's shown.
const before = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
await ctx.proxy.refresh(0, (await ctx.proxy.rows) - 1);
await waitForRender(ctx);
const after = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
for (let i = 0; i < REFERENCE_COLS.length; i++) {
expectSignatureMatches(after[i], before[i], 14, `fixture replay frame not self-consistent at col ${REFERENCE_COLS[i]}`);
}
expect(pageErrors, `no page errors during fixture replay, got: ${pageErrors.join(', ')}`).toEqual([]);
} finally {
ctx.page.off('pageerror', onError);
}
});
});
@@ -0,0 +1,65 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
* Produces BEFORE/AFTER screenshots of a single terminal under merge-heavy
* churn for manual inspection (before.png / after.png, mono-*.png). On current
* master the single-terminal merge path is healthy, so the pinned header lines
* are expected to remain readable in the "after" images; a corrupted header in
* these captures indicates a renderer regression. Note the mono "after" body is
* SUPPOSED to look like glyph soup — that is the mixed box-drawing/braille
* payload rendered faithfully, not corruption.
*/
import test from '@playwright/test';
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
import { loadWebglStrict, setMaxAtlasPages, writeAndWaitForRender } from '../../../test/playwright/RendererTestUtils';
import { generateColoredAsciiFlood, generateMixedGlyphBlock } from '../../../test/playwright/SyntheticTui';
test.describe('visual #322756', () => {
test.skip(({ browserName }) => browserName !== 'chromium');
test.describe.configure({ timeout: 60000 });
test('capture before/after pinned header under merge churn', async ({ browser }) => {
const ctx: ITestContext = await createTestContext(browser);
await openTerminal(ctx, { cols: 80, rows: 24 });
await loadWebglStrict(ctx);
await setMaxAtlasPages(ctx, 4);
const screen = ctx.page.locator('#terminal-container .xterm-screen');
// Header + readable sentences pinned at top; body region churns below.
await writeAndWaitForRender(ctx, '\x1b[?1049h\x1b[H\x1b[97mHEADER: THE QUICK BROWN FOX 0123456789\r\nSTATUS: this line should stay readable\r\n');
await screen.screenshot({ path: 'out-esbuild-test/playwright/before.png' });
for (let c = 0; c < 30; c++) {
await writeAndWaitForRender(ctx, '\x1b[5;1H' + generateColoredAsciiFlood(20 * 78, c * 20 * 78));
}
await screen.screenshot({ path: 'out-esbuild-test/playwright/after.png' });
await ctx.page.close();
});
// Issue-like framing: prefill realistic CLI text, force merges with a mixed
// glyph payload, then rewrite the top lines clean. The rewritten header lines
// must stay readable in mono-after.png.
test('capture mono before/after (issue-like)', async ({ browser }) => {
const ctx: ITestContext = await createTestContext(browser);
await openTerminal(ctx, { cols: 80, rows: 24 });
await loadWebglStrict(ctx);
await setMaxAtlasPages(ctx, 4);
const screen = ctx.page.locator('#terminal-container .xterm-screen');
// Prefill a screenful of normal-looking CLI output (all rendered fine).
await writeAndWaitForRender(ctx, '\x1b[?1049h\x1b[2J\x1b[H');
let pre = '';
for (let r = 0; r < 22; r++) {
pre += ` $ Shell inspect bundled MCP JSON schema for type enum values ${r} lines\r\n`;
}
await writeAndWaitForRender(ctx, pre);
await screen.screenshot({ path: 'out-esbuild-test/playwright/mono-before.png' });
// Overflow the atlas (cap=4) so pages MERGE while most rows keep their cells,
// then rewrite a few lines clean (like scroll/redraw).
for (let c = 0; c < 12; c++) {
let frame = '\x1b[3;1H';
frame += generateMixedGlyphBlock(c + 1, 18, 78);
await writeAndWaitForRender(ctx, frame);
}
await writeAndWaitForRender(ctx, '\x1b[1;1H\x1b[2K $ Shell grep installed Copilot CLI binary - 53 lines\r\n\x1b[2K Thought for 1s');
await screen.screenshot({ path: 'out-esbuild-test/playwright/mono-after.png' });
await ctx.page.close();
});
});
+362
View File
@@ -0,0 +1,362 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IImage32, decodePng } from '@lunapaint/png-codec';
import { expect } from '@playwright/test';
import { ITestContext } from './TestUtils';
import { generateUniqueGlyphFlood } from './SyntheticTui';
/**
* A decoded screenshot of a terminal's render surface together with its grid
* dimensions, used to sample per-cell pixels.
*/
export interface IRendererFrame {
cols: number;
rows: number;
decoded: IImage32;
}
/**
* A coarse, downsampled fingerprint of a single cell's rendered pixels. It is an
* `grid * grid` array of averaged `[r, g, b, a]` sub-cell colors, flattened.
*
* Averaging makes the signature tolerant to sub-pixel antialiasing differences
* (which vary slightly even under SwiftShader) while still changing massively
* when a glyph is garbled, blank, or drawn from the wrong texture atlas page.
*/
export type CellSignature = number[];
const DEFAULT_GRID = 4;
const DEFAULT_SCREEN_SELECTOR = '#terminal-container .xterm-screen';
/**
* Captures a fresh screenshot of a terminal render surface and decodes it.
*
* Unlike the cached helper in `SharedRendererTests.ts`, this always takes a new
* screenshot and accepts a `selector` so multiple terminals (each in their own
* container) can be sampled independently in the same test.
* @param ctx The test context.
* @param selector CSS selector for the `.xterm-screen` element to capture.
* @param termGlobal Name of the `window` terminal global to read grid dimensions
* from (default `'term'`); use e.g. `'termB'` for multi-terminal tests.
*/
export async function captureFrame(
ctx: ITestContext,
selector: string = DEFAULT_SCREEN_SELECTOR,
termGlobal: string = 'term'
): Promise<IRendererFrame> {
const screenshotOptions = process.env.DEBUG
? { path: `out-esbuild-test/playwright/screenshot-${termGlobal}.png` }
: undefined;
const buffer = await ctx.page.locator(selector).screenshot(screenshotOptions);
const grid = await ctx.page.evaluate((name) => {
const term = (window as any)[name];
return { cols: term.cols as number, rows: term.rows as number };
}, termGlobal);
return {
cols: grid.cols,
rows: grid.rows,
decoded: (await decodePng(new Uint8Array(buffer), { force32: true })).image
};
}
/**
* Computes the coarse pixel signature of a single 1-based cell from a frame.
* @param frame The decoded frame to sample.
* @param col The 1-based column.
* @param row The 1-based row.
* @param grid The sub-cell resolution per axis (default 4 -> 16 sub-cells).
*/
export function cellSignature(frame: IRendererFrame, col: number, row: number, grid: number = DEFAULT_GRID): CellSignature {
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 subCount = grid * grid;
const sums = new Array<number>(subCount * 4).fill(0);
const counts = new Array<number>(subCount).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<number>(subCount * 4).fill(0);
for (let sub = 0; sub < subCount; 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;
}
/**
* Returns the maximum absolute per-channel difference between two signatures.
* @param a The first signature.
* @param b The second signature.
*/
export function signatureMaxChannelDiff(a: CellSignature, b: CellSignature): number {
if (a.length !== b.length) {
return Number.POSITIVE_INFINITY;
}
let max = 0;
for (let i = 0; i < a.length; i++) {
max = Math.max(max, Math.abs(a[i] - b[i]));
}
return max;
}
/**
* Asserts that two cell signatures are effectively identical (i.e. the glyph
* rendered the same), within a tolerance for antialiasing noise. Use this after
* a stress action (atlas page merge, atlas clear, scroll, resize) to assert a
* reference glyph did not become garbled.
* @param actual The signature observed after the stress action.
* @param reference The baseline signature captured in a fresh atlas.
* @param tolerance Max allowed per-channel difference (default 12 / 255).
* @param message Optional assertion message.
*/
export function expectSignatureMatches(actual: CellSignature, reference: CellSignature, tolerance: number = 12, message?: string): void {
const diff = signatureMaxChannelDiff(actual, reference);
expect(diff, message ?? `glyph signature drifted by ${diff} (> ${tolerance}); likely corruption`).toBeLessThanOrEqual(tolerance);
}
/**
* Asserts that two cell signatures are meaningfully different. Useful as a
* sanity check that distinct glyphs actually render differently (so the
* signature has discriminating power) and that a cell is not blank.
* @param a The first signature.
* @param b The second signature.
* @param minDiff Minimum required per-channel difference (default 24 / 255).
* @param message Optional assertion message.
*/
export function expectSignatureDiffers(a: CellSignature, b: CellSignature, minDiff: number = 24, message?: string): void {
const diff = signatureMaxChannelDiff(a, b);
expect(diff, message ?? `signatures unexpectedly similar (${diff} < ${minDiff})`).toBeGreaterThanOrEqual(minDiff);
}
/**
* Convenience: capture a fresh frame and return the signature of one cell.
* @param ctx The test context.
* @param col The 1-based column.
* @param row The 1-based row.
* @param options Optional overrides.
* @param options.selector CSS selector for the screen element to capture.
* @param options.grid Sub-cell resolution per axis.
* @param options.termGlobal Window terminal global to read dimensions from.
*/
export async function captureCellSignature(
ctx: ITestContext,
col: number,
row: number,
options: { selector?: string, grid?: number, termGlobal?: string } = {}
): Promise<CellSignature> {
const frame = await captureFrame(ctx, options.selector ?? DEFAULT_SCREEN_SELECTOR, options.termGlobal ?? 'term');
return cellSignature(frame, col, row, options.grid ?? DEFAULT_GRID);
}
/**
* Captures the signatures of a contiguous run of cells on a single row from one
* screenshot (cheaper than one screenshot per cell).
* @param ctx The test context.
* @param row The 1-based row.
* @param cols The 1-based columns to sample.
* @param options Optional overrides.
* @param options.selector CSS selector for the screen element to capture.
* @param options.grid Sub-cell resolution per axis.
* @param options.termGlobal Window terminal global to read dimensions from.
*/
export async function captureRowSignatures(
ctx: ITestContext,
row: number,
cols: number[],
options: { selector?: string, grid?: number, termGlobal?: string } = {}
): Promise<CellSignature[]> {
const frame = await captureFrame(ctx, options.selector ?? DEFAULT_SCREEN_SELECTOR, options.termGlobal ?? 'term');
return cols.map(col => cellSignature(frame, col, row, options.grid ?? DEFAULT_GRID));
}
/**
* Loads the WebGL addon onto `window.term` and asserts GPU acceleration is
* genuinely active.
*
* This deliberately does NOT wrap loading in try/catch (unlike some existing
* tests). If WebGL2 is unavailable the addon throws and the test must fail
* loudly, because a silent fallback to the DOM renderer would make GPU
* corruption tests pass falsely.
* @param ctx The test context.
* @param options WebGL addon options.
* @param options.customGlyphs Whether to draw custom glyphs (defaults to the addon default).
* @param options.preserveDrawingBuffer Whether to preserve the drawing buffer (defaults to true).
*/
export async function loadWebglStrict(
ctx: ITestContext,
options: { customGlyphs?: boolean, preserveDrawingBuffer?: boolean } = {}
): Promise<void> {
await ctx.page.evaluate((opts) => {
const w = window as any;
w.addon = new w.WebglAddon(opts);
w.term.loadAddon(w.addon);
}, { preserveDrawingBuffer: true, ...options });
await assertWebglActive(ctx);
}
/**
* Asserts that the WebGL renderer (not the DOM renderer) is the active renderer
* for `window.term`. Fails loudly on a silent DOM fallback.
* @param ctx The test context.
*/
export async function assertWebglActive(ctx: ITestContext): Promise<void> {
const state = await ctx.page.evaluate(() => {
const w = window as any;
const term = w.term;
const addon = w.addon;
const renderer = term?._core?._renderService?._renderer?.value;
return {
hasAddon: !!addon,
// The addon's private renderer must be the one the render service is using.
isWebglRenderer: !!addon && !!renderer && renderer === addon._renderer
};
});
expect(state.hasAddon, 'WebglAddon should be loaded on window.term').toBe(true);
expect(state.isWebglRenderer, 'WebGL renderer must be the active renderer (no silent DOM fallback)').toBe(true);
}
/**
* Asserts the WebGL texture atlas canvas exists. Call after writing some content
* so the atlas has been created. This is an additional public-API proof that the
* GPU path actually rendered glyphs.
* @param ctx The test context.
*/
export async function assertTextureAtlasPresent(ctx: ITestContext): Promise<void> {
const hasAtlas = await ctx.page.evaluate(() => {
const addon = (window as any).addon;
return !!addon && addon.textureAtlas instanceof HTMLCanvasElement;
});
expect(hasAtlas, 'WebGL texture atlas canvas should exist after rendering').toBe(true);
}
/**
* Reads the current number of pages in the active WebGL renderer's texture
* atlas, via internals. Returns -1 if unavailable.
* @param ctx The test context.
*/
export async function getAtlasPageCount(ctx: ITestContext): Promise<number> {
return ctx.page.evaluate(() => {
const renderer = (window as any).term?._core?._renderService?._renderer?.value;
const atlas = renderer?._charAtlas;
return atlas?.pages?.length ?? -1;
});
}
/**
* Forces the texture atlas to merge pages much sooner by lowering the static
* `maxAtlasPages` knob on the live atlas class (reached via the atlas instance's
* constructor). Must be called after the WebGL renderer/atlas exists.
* @param ctx The test context.
* @param maxAtlasPages The new page cap (the merge path triggers at >= max(4, this)).
*/
export async function setMaxAtlasPages(ctx: ITestContext, maxAtlasPages: number): Promise<void> {
const applied = await ctx.page.evaluate((max) => {
const renderer = (window as any).term?._core?._renderService?._renderer?.value;
const atlas = renderer?._charAtlas;
if (!atlas) {
return false;
}
// maxAtlasPages is a static on the TextureAtlas class, reached via constructor.
(atlas.constructor as any).maxAtlasPages = max;
return true;
}, maxAtlasPages);
expect(applied, 'should be able to reach the texture atlas to set maxAtlasPages').toBe(true);
}
/**
* Resets `maxAtlasPages` to undefined so the next constructed renderer re-derives it from GL
* capabilities. Call after any test that used {@link setMaxAtlasPages}: the static lives for
* the page's lifetime, so without a reset every terminal opened later in the same file builds
* its GL texture and shader sampler arrays at the lowered cap.
* @param ctx The test context.
*/
export async function resetMaxAtlasPages(ctx: ITestContext): Promise<void> {
await ctx.page.evaluate(() => {
const renderer = (window as any).term?._core?._renderService?._renderer?.value;
const atlas = renderer?._charAtlas;
if (atlas) {
(atlas.constructor as any).maxAtlasPages = undefined;
}
});
}
/**
* Writes `chunks` screenfuls of distinct glyphs, rendering after each chunk.
*
* Only glyphs that are actually visible during a render get rasterized into the
* texture atlas, so a single huge write mostly rasterizes the final viewport. To
* accumulate enough distinct glyphs to allocate (and merge) multiple atlas pages
* we must write a screenful, render it, then write the next distinct screenful.
* @param ctx The test context.
* @param chunks Number of screenfuls of distinct glyphs to write.
* @param options Sizing overrides.
* @param options.linesPerChunk Number of lines per screenful chunk.
* @param options.cols Columns per line.
* @returns The atlas page count after flooding.
*/
export async function floodDistinctGlyphs(
ctx: ITestContext,
chunks: number,
options: { linesPerChunk?: number, cols?: number } = {}
): Promise<number> {
const linesPerChunk = options.linesPerChunk ?? 24;
const cols = options.cols ?? 80;
const glyphsPerChunk = linesPerChunk * Math.max(1, Math.floor(cols / 2));
for (let c = 0; c < chunks; c++) {
await writeAndWaitForRender(ctx, generateUniqueGlyphFlood(glyphsPerChunk, cols, c * glyphsPerChunk));
}
return getAtlasPageCount(ctx);
}
/**
* Resolves once the terminal fires its next render event.
* @param ctx The test context.
*/
export async function waitForRender(ctx: ITestContext): Promise<void> {
await new Promise<void>(resolve => {
const disposable = ctx.proxy.onRender(() => {
disposable.dispose();
resolve();
});
});
}
/**
* Writes data and waits for the terminal to render it.
* @param ctx The test context.
* @param data The data to write.
*/
export async function writeAndWaitForRender(ctx: ITestContext, data: string): Promise<void> {
const renderPromise = waitForRender(ctx);
await ctx.proxy.write(data);
await renderPromise;
}
+199
View File
@@ -0,0 +1,199 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*
* Deterministic generators that mimic the output of an AI/agent TUI CLI (e.g.
* Copilot CLI): boxed panels, braille spinners, streaming colored text, status
* lines, full-screen redraws and large unique-glyph floods.
*
* Everything here is seeded/precomputed and contains NO `Math.random`, `Date`
* or other non-determinism, so the produced byte streams are stable across runs
* and machines. This lets WebGL/atlas stress tests assert exact render results.
*/
/** A tiny deterministic PRNG (mulberry32) so "random-looking" output is stable. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const BRAILLE_SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const SGR_FG = [31, 32, 33, 34, 35, 36, 37, 90, 91, 92, 93, 94, 95, 96];
const WORDS = [
'analyzing', 'repository', 'reading', 'TextureAtlas', 'rendering', 'glyph',
'webgl', 'terminal', 'buffer', 'scrollback', 'tokenizing', 'synthesizing',
'patch', 'diff', 'commit', 'validate', 'compile', 'searching', 'context'
];
/** Box-drawing characters (custom-glyph range that stresses the WebGL atlas). */
const BOX = { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' };
const CSI = '\x1b[';
const RESET = `${CSI}0m`;
const CLEAR_HOME = `${CSI}2J${CSI}H`;
const ENTER_ALT = `${CSI}?1049h`;
const EXIT_ALT = `${CSI}?1049l`;
export interface ISyntheticTuiOptions {
/** PRNG seed for stable pseudo-random content. */
seed?: number;
/** Number of full-screen redraw frames to emit. */
frames?: number;
/** Terminal columns to format for. */
cols?: number;
/** Terminal rows to format for. */
rows?: number;
/** Wrap each frame in alternate-buffer enter/exit (TUI app behavior). */
useAltBuffer?: boolean;
}
function pad(text: string, width: number): string {
if (text.length >= width) {
return text.slice(0, width);
}
return text + ' '.repeat(width - text.length);
}
function boxLine(kind: 'top' | 'bottom' | 'mid', content: string, width: number): string {
const inner = Math.max(0, width - 2);
if (kind === 'top') {
return `${BOX.tl}${BOX.h.repeat(inner)}${BOX.tr}`;
}
if (kind === 'bottom') {
return `${BOX.bl}${BOX.h.repeat(inner)}${BOX.br}`;
}
return `${BOX.v}${pad(content, inner)}${BOX.v}`;
}
/**
* Generates an array of frame chunks that look like an animated agent TUI. Write
* each chunk with a render in between to mimic streaming redraws. Each frame
* clears the screen, redraws a bordered panel, a spinner, several colored
* "assistant" lines and a status footer.
* @param options Generation options.
*/
export function generateSyntheticTuiFrames(options: ISyntheticTuiOptions = {}): string[] {
const seed = options.seed ?? 1;
const frameCount = options.frames ?? 24;
const cols = options.cols ?? 80;
const rows = options.rows ?? 24;
const rand = mulberry32(seed);
const panelWidth = Math.min(cols, 72);
const bodyRows = Math.max(3, rows - 6);
const frames: string[] = [];
for (let f = 0; f < frameCount; f++) {
let frame = CLEAR_HOME;
const spinner = BRAILLE_SPINNER[f % BRAILLE_SPINNER.length];
frame += boxLine('top', '', panelWidth) + '\r\n';
frame += boxLine('mid', ` ${spinner} Copilot is working...`, panelWidth) + '\r\n';
frame += boxLine('bottom', '', panelWidth) + '\r\n';
for (let r = 0; r < bodyRows; r++) {
const fg = SGR_FG[Math.floor(rand() * SGR_FG.length)];
const wordCount = 3 + Math.floor(rand() * 6);
let line = '';
for (let w = 0; w < wordCount; w++) {
const word = WORDS[Math.floor(rand() * WORDS.length)];
line += `${CSI}${fg}m${word}${RESET} `;
}
frame += line + '\r\n';
}
frame += `${CSI}2m── tokens ${1000 + f * 37} · ${spinner} streaming ──${RESET}`;
frames.push(frame);
}
if (options.useAltBuffer) {
frames[0] = ENTER_ALT + frames[0];
frames[frames.length - 1] = frames[frames.length - 1] + EXIT_ALT;
}
return frames;
}
/**
* Generates a single string that writes `count` distinct Unicode glyphs, wrapping
* at `cols`. Distinct codepoints each need their own texture-atlas slot, so this
* is the primary lever for forcing the atlas to allocate (and, with a low
* `maxAtlasPages`, merge) pages.
*
* Codepoints are drawn deterministically from CJK Unified Ideographs
* (U+4E00..U+9FFF), which provides 20,000+ unique, mono-width glyphs.
* @param count Number of distinct glyphs to emit.
* @param cols Columns to wrap at (CJK glyphs are width-2, so half this many per row).
* @param offset Starting offset into the codepoint range, so consecutive chunks
* emit non-overlapping distinct glyphs.
*/
export function generateUniqueGlyphFlood(count: number, cols: number = 80, offset: number = 0): string {
const base = 0x4E00;
const range = 0x9FFF - 0x4E00; // 20,991 unique codepoints
const perRow = Math.max(1, Math.floor(cols / 2));
let out = '';
for (let i = 0; i < count; i++) {
out += String.fromCodePoint(base + ((offset + i) % range));
if ((i + 1) % perRow === 0) {
out += '\r\n';
}
}
return out;
}
/**
* Generates a deterministic block of mixed "interesting" glyphs spanning the
* ranges most likely to exercise the renderer: ASCII, box drawing, block
* elements, braille and powerline symbols. Useful as a stable reference payload.
* @param seed PRNG seed.
* @param lines Number of lines to emit.
* @param cols Approximate columns per line.
*/
export function generateMixedGlyphBlock(seed: number = 1, lines: number = 10, cols: number = 60): string {
const rand = mulberry32(seed);
const ranges: [number, number][] = [
[0x21, 0x7E], // printable ASCII
[0x2500, 0x257F], // box drawing
[0x2580, 0x259F], // block elements
[0x2800, 0x28FF], // braille
[0x2190, 0x21FF] // arrows
];
let out = '';
for (let l = 0; l < lines; l++) {
for (let c = 0; c < cols; c++) {
const [lo, hi] = ranges[Math.floor(rand() * ranges.length)];
out += String.fromCodePoint(lo + Math.floor(rand() * (hi - lo)));
}
out += '\r\n';
}
return out;
}
/**
* Emits ASCII letters/words each in a distinct 256-color foreground+background.
* Every (char,color) pair is a unique atlas glyph, so this fills/merges atlas
* pages with realistic English text (no CJK). Deterministic.
*
* No `\r\n` is emitted after the final cell: when the flood's last line lands on
* the terminal's bottom row a trailing linefeed would scroll the screen (in the
* alt buffer this destroys pinned reference rows and makes glyph-integrity
* assertions fail for buffer-content reasons rather than rendering reasons).
* @param cells Number of colored cells to emit.
* @param offset Color offset so consecutive chunks use new color combos.
*/
export function generateColoredAsciiFlood(cells: number, offset: number = 0): string {
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let out = '';
for (let i = 0; i < cells; i++) {
const ch = letters[(offset + i) % letters.length];
const fg = (offset + i) % 256;
const bg = (offset + i * 7) % 256;
out += `\x1b[38;5;${fg}m\x1b[48;5;${bg}m${ch}`;
if ((i + 1) % 78 === 0 && i + 1 < cells) { out += '\x1b[0m\r\n'; }
}
return out + '\x1b[0m';
}