webgl stress + glyph-integrity tests for atlas merges; repro vscode#322756 single-terminal garble
This commit is contained in:
@@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 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,
|
||||
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);
|
||||
});
|
||||
|
||||
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,45 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
* EXPERIMENT: try to reproduce vscode#322756 garbled single-terminal render.
|
||||
*/
|
||||
import test, { expect } from '@playwright/test';
|
||||
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||
import { captureRowSignatures, expectSignatureMatches, getAtlasPageCount, loadWebglStrict, setMaxAtlasPages, waitForRender, writeAndWaitForRender } from '../../../test/playwright/RendererTestUtils';
|
||||
import { generateColoredAsciiFlood } from '../../../test/playwright/SyntheticTui';
|
||||
|
||||
let ctx: ITestContext;
|
||||
test.beforeAll(async ({ browser }) => { ctx = await createTestContext(browser); });
|
||||
test.afterAll(async () => await ctx.page.close());
|
||||
|
||||
test.describe('repro #322756', () => {
|
||||
test.skip(({ browserName }) => browserName !== 'chromium');
|
||||
test.describe.configure({ timeout: 60000 });
|
||||
|
||||
test('header survives merge race with per-chunk sync', 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_REF_0123456789');
|
||||
const ref = await captureRowSignatures(ctx, 1, cols);
|
||||
let maxDiff = 0;
|
||||
for (let c = 0; c < 30; c++) {
|
||||
// redraw header + churn body rows 2..24 with unique glyphs, header stays put
|
||||
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);
|
||||
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,99 @@
|
||||
/**
|
||||
* 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,
|
||||
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);
|
||||
});
|
||||
|
||||
test('heavy merge churn must not crash the GlyphRenderer (xtermjs#322756)', async () => {
|
||||
// EXPECTED RED: with a small page cap, atlas merges shrink the pages array
|
||||
// while GlyphRenderer.render still indexes the old page count -> throws
|
||||
// "Cannot read properties of undefined (reading 'version')" and the terminal
|
||||
// garbles. Capture the crash via pageerror.
|
||||
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,60 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
* Produces a strong BEFORE/AFTER visual of single-terminal #322756 garble:
|
||||
* a plain white header that should stay readable, body churns colored text
|
||||
* forcing atlas merges. Saves before.png / after.png for manual viewing.
|
||||
*/
|
||||
import test from '@playwright/test';
|
||||
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||
import { loadWebglStrict, setMaxAtlasPages, waitForRender, 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 header garble', 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();
|
||||
});
|
||||
|
||||
// Closer to vscode#322756: prefill realistic CLI text, trigger ONE merge,
|
||||
// rewrite a couple lines -> some lines intact, most garble sparsely on dark bg.
|
||||
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; churn most rows so their cells
|
||||
// hold stale slots, 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 $ Shell grep installed Copilot CLI binary - 53 lines\r\n Thought for 1s');
|
||||
await screen.screenshot({ path: 'out-esbuild-test/playwright/mono-after.png' });
|
||||
await ctx.page.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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.
|
||||
* @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) { out += '\x1b[0m\r\n'; }
|
||||
}
|
||||
return out + '\x1b[0m';
|
||||
}
|
||||
Reference in New Issue
Block a user