From e3399b1db8951bd69702297fafdd0342253da8a0 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Sun, 28 Jun 2026 18:27:37 -0700 Subject: [PATCH] webgl stress + glyph-integrity tests for atlas merges; repro vscode#322756 single-terminal garble --- .../addon-webgl/test/WebglDprResize.test.ts | 109 ++++++ addons/addon-webgl/test/WebglGlyph.test.ts | 117 ++++++ .../test/WebglReproExperiment.test.ts | 45 +++ .../test/WebglSingleTermStress.test.ts | 99 +++++ .../addon-webgl/test/WebglTuiStress.test.ts | 152 ++++++++ addons/addon-webgl/test/WebglVisual.test.ts | 60 +++ test/playwright/RendererTestUtils.ts | 345 ++++++++++++++++++ test/playwright/SyntheticTui.ts | 194 ++++++++++ 8 files changed, 1121 insertions(+) create mode 100644 addons/addon-webgl/test/WebglDprResize.test.ts create mode 100644 addons/addon-webgl/test/WebglGlyph.test.ts create mode 100644 addons/addon-webgl/test/WebglReproExperiment.test.ts create mode 100644 addons/addon-webgl/test/WebglSingleTermStress.test.ts create mode 100644 addons/addon-webgl/test/WebglTuiStress.test.ts create mode 100644 addons/addon-webgl/test/WebglVisual.test.ts create mode 100644 test/playwright/RendererTestUtils.ts create mode 100644 test/playwright/SyntheticTui.ts diff --git a/addons/addon-webgl/test/WebglDprResize.test.ts b/addons/addon-webgl/test/WebglDprResize.test.ts new file mode 100644 index 000000000..f0adfce38 --- /dev/null +++ b/addons/addon-webgl/test/WebglDprResize.test.ts @@ -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 { + 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`); + } + }); +}); diff --git a/addons/addon-webgl/test/WebglGlyph.test.ts b/addons/addon-webgl/test/WebglGlyph.test.ts new file mode 100644 index 000000000..c6cf260ea --- /dev/null +++ b/addons/addon-webgl/test/WebglGlyph.test.ts @@ -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()`); + } + }); +}); diff --git a/addons/addon-webgl/test/WebglReproExperiment.test.ts b/addons/addon-webgl/test/WebglReproExperiment.test.ts new file mode 100644 index 000000000..4c2d07576 --- /dev/null +++ b/addons/addon-webgl/test/WebglReproExperiment.test.ts @@ -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`); + } + }); +}); diff --git a/addons/addon-webgl/test/WebglSingleTermStress.test.ts b/addons/addon-webgl/test/WebglSingleTermStress.test.ts new file mode 100644 index 000000000..c4eead29d --- /dev/null +++ b/addons/addon-webgl/test/WebglSingleTermStress.test.ts @@ -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`); + } + }); +}); diff --git a/addons/addon-webgl/test/WebglTuiStress.test.ts b/addons/addon-webgl/test/WebglTuiStress.test.ts new file mode 100644 index 000000000..89605ea62 --- /dev/null +++ b/addons/addon-webgl/test/WebglTuiStress.test.ts @@ -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); + } + }); +}); diff --git a/addons/addon-webgl/test/WebglVisual.test.ts b/addons/addon-webgl/test/WebglVisual.test.ts new file mode 100644 index 000000000..38949197d --- /dev/null +++ b/addons/addon-webgl/test/WebglVisual.test.ts @@ -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(); + }); +}); diff --git a/test/playwright/RendererTestUtils.ts b/test/playwright/RendererTestUtils.ts new file mode 100644 index 000000000..a3862c77f --- /dev/null +++ b/test/playwright/RendererTestUtils.ts @@ -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 { + 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(subCount * 4).fill(0); + const counts = new Array(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(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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await new Promise(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 { + const renderPromise = waitForRender(ctx); + await ctx.proxy.write(data); + await renderPromise; +} diff --git a/test/playwright/SyntheticTui.ts b/test/playwright/SyntheticTui.ts new file mode 100644 index 000000000..e613020d7 --- /dev/null +++ b/test/playwright/SyntheticTui.ts @@ -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'; +}