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.
363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|