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
@@ -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`);
}
});
});
+117
View File
@@ -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();
});
});