webgl stress + glyph-integrity tests for atlas merges; repro vscode#322756 single-terminal garble

This commit is contained in:
Anthony Kim
2026-06-28 18:27:37 -07:00
parent 8aab310366
commit e3399b1db8
8 changed files with 1121 additions and 0 deletions
+345
View File
@@ -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;
}
+194
View File
@@ -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';
}