Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
467eb5a227 | ||
|
|
a8632435a6 | ||
|
|
a0919b9004 | ||
|
|
b5e0cdb20c |
+27
-19
@@ -11,9 +11,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
./addons/addon-webgl/out/* \
|
||||
./addons/addon-webgl/out-*st/*
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: compressed-build.zip
|
||||
@@ -79,9 +79,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
@@ -100,16 +100,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
@@ -129,7 +129,6 @@ jobs:
|
||||
exit $EXIT_CODE
|
||||
|
||||
test-unit:
|
||||
needs: build
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -137,16 +136,21 @@ jobs:
|
||||
runs-on: [ubuntu, macos, windows]
|
||||
runs-on: ${{ matrix.runs-on }}-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js ${{ matrix.node-version }}.x
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}.x
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
- uses: actions/download-artifact@v7
|
||||
- name: Wait for build job
|
||||
uses: NathanFirmo/wait-for-other-job@v1.1.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
job: build
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
@@ -162,7 +166,6 @@ jobs:
|
||||
run: npm run test-unit --forbid-only
|
||||
|
||||
test-integration:
|
||||
needs: build
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -171,9 +174,9 @@ jobs:
|
||||
browser: [chromium, firefox, webkit]
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js ${{ matrix.node-version }}.x
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}.x
|
||||
cache: 'npm'
|
||||
@@ -182,7 +185,12 @@ jobs:
|
||||
npm ci
|
||||
- name: Install playwright
|
||||
run: npx playwright install --with-deps ${{ matrix.browser }}
|
||||
- uses: actions/download-artifact@v7
|
||||
- name: Wait for build job
|
||||
uses: NathanFirmo/wait-for-other-job@v1.1.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
job: build
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
@@ -232,9 +240,9 @@ jobs:
|
||||
matrix:
|
||||
node-version: [22]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js ${{ matrix.node-version }}.x
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}.x
|
||||
cache: 'npm'
|
||||
@@ -243,7 +251,7 @@ jobs:
|
||||
npm ci
|
||||
- name: Install playwright
|
||||
run: npx playwright install
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
|
||||
@@ -24,12 +24,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v3
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
@@ -18,9 +18,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
|
||||
@@ -10,9 +10,9 @@ jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
|
||||
@@ -254,7 +254,6 @@ Xterm.js is used in many world-class applications to provide great terminal expe
|
||||
- [**WooTTY**](https://github.com/icoretech/wootty): Flawless browser terminal for real operators.
|
||||
- [**Orca**](https://github.com/stablyai/orca): Agentic development environment for 100x builders.
|
||||
- [**4bit**](https://ciembor.github.io/4bit): Terminal Color Scheme Designer.
|
||||
- [**Cate**](https://github.com/0-AI-UG/cate): Open source desktop IDE on an infinite zoomable canvas. Editor, browser, and terminal panels float in spatial space; the terminals are xterm.js with the WebGL addon, backed by node-pty.
|
||||
- [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D)
|
||||
|
||||
Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Please add any new contributions to the end of the list.
|
||||
|
||||
@@ -78,43 +78,13 @@ export class BrowserClipboardProvider implements IClipboardProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Once the base64 handling on Uint8Arrays is more common,
|
||||
* remove the btoa/atob fallbacks below.
|
||||
*/
|
||||
interface IUint8ArrayB64 extends Uint8Array {
|
||||
toBase64(): string;
|
||||
}
|
||||
interface IUint8ArrayB64Ctor extends Uint8ArrayConstructor {
|
||||
fromBase64(s: string): IUint8ArrayB64;
|
||||
}
|
||||
|
||||
export class Base64 implements IBase64 {
|
||||
public encodeText(data: string): string {
|
||||
const bytes = new TextEncoder().encode(data) as IUint8ArrayB64;
|
||||
if (bytes.toBase64 !== undefined) {
|
||||
return bytes.toBase64();
|
||||
}
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bin += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(bin);
|
||||
return btoa(data);
|
||||
}
|
||||
public decodeText(data: string): string {
|
||||
if ((Uint8Array as IUint8ArrayB64Ctor).fromBase64 !== undefined) {
|
||||
try {
|
||||
return new TextDecoder().decode((Uint8Array as IUint8ArrayB64Ctor).fromBase64(data));
|
||||
} catch {}
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const bin = atob(data);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; ++i) {
|
||||
bytes[i] = bin.charCodeAt(i);
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
return atob(data);
|
||||
} catch {}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ test.describe('ClipboardAddon', () => {
|
||||
await ctx.page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
}
|
||||
await ctx.page.evaluate(`
|
||||
window.term.reset();
|
||||
window.term.reset()
|
||||
window.clipboard?.dispose();
|
||||
window.clipboard = new ClipboardAddon();
|
||||
window.term.loadAddon(window.clipboard);
|
||||
@@ -92,22 +92,4 @@ test.describe('ClipboardAddon', () => {
|
||||
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), '');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('non-ASCII', () => {
|
||||
const testDataEncoded = '4oKsbWzDpMO8dMOf';
|
||||
const testDataDecoded = '€mläütß';
|
||||
test('write simple string', async () => {
|
||||
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`);
|
||||
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
|
||||
});
|
||||
test('read simple string', async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window.data2 = [];
|
||||
window.term.onData(e => data2.push(e));
|
||||
`);
|
||||
await ctx.page.evaluate((d) => window.navigator.clipboard.writeText(d), testDataDecoded);
|
||||
await ctx.proxy.write(`\x1b]52;c;?\x07`);
|
||||
deepEqual(await ctx.page.evaluate('window.data2'), [`\x1b]52;c;${testDataEncoded}\x07`]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -431,8 +431,8 @@ export class ImageStorage implements IDisposable {
|
||||
// because text writes clear the BG flag but leave image tile data intact.
|
||||
// This lets top-layer images survive text overwrites (kitty C=1 behavior).
|
||||
for (let row = start; row <= end; ++row) {
|
||||
const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;
|
||||
if (!line) return;
|
||||
const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt | undefined;
|
||||
if (!line) continue;
|
||||
for (let col = 0; col < cols; ++col) {
|
||||
let e: IExtendedAttrsImage;
|
||||
if (line.getBg(col) & BgFlags.HAS_EXTENDED) {
|
||||
@@ -513,7 +513,10 @@ export class ImageStorage implements IDisposable {
|
||||
const rows = buffer.lines.length;
|
||||
const oldCol = this._viewportMetrics.cols - 1;
|
||||
for (let row = 0; row < rows; ++row) {
|
||||
const line = buffer.lines.get(row) as IBufferLineExt;
|
||||
const line = buffer.lines.get(row) as IBufferLineExt | undefined;
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {
|
||||
const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;
|
||||
const imageId = e.imageId;
|
||||
@@ -531,7 +534,7 @@ export class ImageStorage implements IDisposable {
|
||||
}
|
||||
// expand only if right side is empty (nothing got wrapped from below)
|
||||
let hasData = false;
|
||||
for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {
|
||||
for (let rightCol = oldCol + 1; rightCol < metrics.cols; ++rightCol) {
|
||||
if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {
|
||||
hasData = true;
|
||||
break;
|
||||
|
||||
@@ -142,14 +142,12 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
||||
}
|
||||
prevResult = result;
|
||||
results.push(prevResult);
|
||||
const cols = this._terminal.cols;
|
||||
let nextCol = prevResult.col + prevResult.size;
|
||||
let nextRow = prevResult.row;
|
||||
if (nextCol >= cols) {
|
||||
nextRow += Math.floor(nextCol / cols);
|
||||
nextCol = nextCol % cols;
|
||||
}
|
||||
result = this._engine.find(term, nextRow, nextCol, searchOptions);
|
||||
result = this._engine.find(
|
||||
term,
|
||||
prevResult.col + prevResult.term.length >= this._terminal.cols ? prevResult.row + 1 : prevResult.row,
|
||||
prevResult.col + prevResult.term.length >= this._terminal.cols ? 0 : prevResult.col + 1,
|
||||
searchOptions
|
||||
);
|
||||
}
|
||||
|
||||
this._resultTracker.updateResults(results, this._highlightLimit);
|
||||
|
||||
@@ -414,20 +414,6 @@ test.describe('Search Tests', () => {
|
||||
});
|
||||
|
||||
test.describe('Regression tests', () => {
|
||||
test('should advance highlight-all scan by buffer match size for wide characters', async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window.calls = [];
|
||||
window.search.onDidChangeResults(e => window.calls.push(e));
|
||||
`);
|
||||
await ctx.proxy.resize(3, 5);
|
||||
await ctx.proxy.write('𝄞𝄞𝄞');
|
||||
strictEqual(await ctx.page.evaluate(`window.search.findNext('𝄞', { decorations: { activeMatchColorOverviewRuler: '#ff0000', matchOverviewRuler: '#ffff00' } })`), true);
|
||||
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
||||
{ resultCount: 3, resultIndex: 0 }
|
||||
]);
|
||||
await ctx.proxy.resize(80, 24);
|
||||
});
|
||||
|
||||
test.describe('#2444 wrapped line content not being found', () => {
|
||||
let fixture: string;
|
||||
test.beforeAll(async () => {
|
||||
|
||||
@@ -100,8 +100,6 @@ export class GlyphRenderer extends Disposable {
|
||||
private readonly _attributesBuffer: WebGLBuffer;
|
||||
|
||||
private _atlas: ITextureAtlas | undefined;
|
||||
private _lastAtlasPagesVersion: number = -1;
|
||||
private _pageOverflowWarned: boolean = false;
|
||||
private _activeBuffer: number = 0;
|
||||
private readonly _vertices: IVertices = {
|
||||
count: 0,
|
||||
@@ -215,21 +213,8 @@ export class GlyphRenderer extends Disposable {
|
||||
this.handleResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the full model must be rebuilt before rendering this frame.
|
||||
* This is tracked per renderer (not on the shared atlas) so that every
|
||||
* terminal sharing the atlas rebuilds its model after a page merge, not just
|
||||
* the first one to render (vscode#322756).
|
||||
*/
|
||||
public beginFrame(): boolean {
|
||||
if (!this._atlas) {
|
||||
return true;
|
||||
}
|
||||
if (this._atlas.pagesVersion !== this._lastAtlasPagesVersion) {
|
||||
this._lastAtlasPagesVersion = this._atlas.pagesVersion;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return this._atlas ? this._atlas.beginFrame() : true;
|
||||
}
|
||||
|
||||
public updateCell(x: number, y: number, code: number, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void {
|
||||
@@ -377,15 +362,8 @@ export class GlyphRenderer extends Disposable {
|
||||
|
||||
// Bind the atlas page texture if they have changed. AtlasPage.version is globally
|
||||
// monotonic, so a page object swap at the same index (which happens after a page merge)
|
||||
// is detected by the same comparison. The page count is clamped to the fixed texture
|
||||
// array size so an atlas page overflow degrades (untextured glyphs) instead of throwing
|
||||
// mid-frame; the atlas is responsible for never exceeding maxAtlasPages.
|
||||
const pageCount = Math.min(this._atlas.pages.length, this._atlasTextures.length);
|
||||
if (this._atlas.pages.length > this._atlasTextures.length && !this._pageOverflowWarned) {
|
||||
this._pageOverflowWarned = true;
|
||||
this._logService.warn(`Atlas page count (${this._atlas.pages.length}) exceeds the renderer's texture capacity (${this._atlasTextures.length}), some glyphs will not render correctly`);
|
||||
}
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
// is detected by the same comparison.
|
||||
for (let i = 0; i < this._atlas.pages.length; i++) {
|
||||
if (this._atlas.pages[i].version !== this._atlasTextures[i].version) {
|
||||
this._bindAtlasPageTexture(gl, this._atlas, i);
|
||||
}
|
||||
@@ -397,9 +375,6 @@ export class GlyphRenderer extends Disposable {
|
||||
|
||||
public setAtlas(atlas: ITextureAtlas): void {
|
||||
this._atlas = atlas;
|
||||
// Force a model rebuild on the next frame as glyph coordinates from a
|
||||
// previous atlas are meaningless in the new one.
|
||||
this._lastAtlasPagesVersion = -1;
|
||||
this.invalidateAtlasTextures();
|
||||
}
|
||||
|
||||
@@ -414,9 +389,8 @@ export class GlyphRenderer extends Disposable {
|
||||
gl.bindTexture(gl.TEXTURE_2D, this._atlasTextures[i].texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas.pages[i].canvas);
|
||||
gl.generateMipmap(gl.TEXTURE_2D);
|
||||
this._atlasTextures[i].version = atlas.pages[i].version;
|
||||
}
|
||||
|
||||
|
||||
@@ -132,16 +132,12 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremented whenever the structure of the pages array changes in a way that
|
||||
* invalidates texture page indexes/coordinates cached outside the atlas (page
|
||||
* merges, overflow page creation). Renderers compare this against their own
|
||||
* last-seen value to decide when to rebuild their models; the atlas is shared
|
||||
* across terminals, so a consume-once flag would only repair the first
|
||||
* renderer to draw (see the shared-atlas garble in vscode#322756).
|
||||
*/
|
||||
private _pagesVersion = 0;
|
||||
public get pagesVersion(): number { return this._pagesVersion; }
|
||||
private _requestClearModel = false;
|
||||
public beginFrame(): boolean {
|
||||
const result = this._requestClearModel;
|
||||
this._requestClearModel = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
public clearTexture(): void {
|
||||
if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) {
|
||||
@@ -161,7 +157,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
// animation frame which would result in blank rendered areas. This is actually not that
|
||||
// expensive relative to drawing the glyphs, so there is no need to wait for an idle callback.
|
||||
if (TextureAtlas.maxAtlasPages && this._pages.length >= Math.max(4, TextureAtlas.maxAtlasPages)) {
|
||||
// Find the set of the largest images, below the maximum size, with the highest
|
||||
// Find the set of the largest 4 images, below the maximum size, with the highest
|
||||
// percentages used
|
||||
const pagesBySize = this._pages.filter(e => {
|
||||
return e.canvas.width * 2 <= (TextureAtlas.maxTextureSize || Constants.FORCED_MAX_TEXTURE_SIZE);
|
||||
@@ -171,55 +167,48 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
}
|
||||
return b.percentageUsed - a.percentageUsed;
|
||||
});
|
||||
|
||||
// Find the largest group (up to 4) of same-sized pages, preferring larger page sizes. A
|
||||
// group of 2 or 3 still reduces (or at worst maintains) the page count net of the new page
|
||||
// pushed below; growing past the cap is never an option as GlyphRenderer's texture and
|
||||
// shader sampler arrays are sized to exactly maxAtlasPages (exceeding them crashes render).
|
||||
let bestGroupStart = -1;
|
||||
let bestGroupSize = 0;
|
||||
let groupStart = 0;
|
||||
for (let i = 1; i <= pagesBySize.length; i++) {
|
||||
if (i === pagesBySize.length || pagesBySize[i].canvas.width !== pagesBySize[groupStart].canvas.width) {
|
||||
const groupSize = Math.min(4, i - groupStart);
|
||||
if (groupSize > bestGroupSize) {
|
||||
bestGroupSize = groupSize;
|
||||
bestGroupStart = groupStart;
|
||||
if (groupSize === 4) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
groupStart = i;
|
||||
let sameSizeI = -1;
|
||||
let size = 0;
|
||||
for (let i = 0; i < pagesBySize.length; i++) {
|
||||
if (pagesBySize[i].canvas.width !== size) {
|
||||
sameSizeI = i;
|
||||
size = pagesBySize[i].canvas.width;
|
||||
} else if (i - sameSizeI === 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestGroupSize >= 2) {
|
||||
// Gather details of the merge
|
||||
const mergingPages = pagesBySize.slice(bestGroupStart, bestGroupStart + bestGroupSize);
|
||||
const sortedMergingPagesIndexes = mergingPages.map(e => this._pages.indexOf(e)).sort((a, b) => a - b);
|
||||
const mergedPageIndex = this.pages.length - mergingPages.length;
|
||||
// Gather details of the merge
|
||||
const mergingPages = pagesBySize.slice(sameSizeI, sameSizeI + 4);
|
||||
|
||||
// Merge into the new page
|
||||
const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
|
||||
mergedPage.version = ++AtlasPage.nextVersion;
|
||||
|
||||
// Delete the pages, shifting glyph texture pages as needed
|
||||
for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
|
||||
this._deletePage(sortedMergingPagesIndexes[i]);
|
||||
}
|
||||
|
||||
// Add the new merged page to the end
|
||||
this.pages.push(mergedPage);
|
||||
|
||||
// Invalidate models so all texture pages are refreshed.
|
||||
this._pagesVersion++;
|
||||
this._onAddTextureAtlasCanvas.fire(mergedPage.canvas);
|
||||
} else {
|
||||
// No two same-sized pages below the maximum size exist, so merging cannot reduce the
|
||||
// page count. Evict all pages rather than exceed the renderers' fixed texture/sampler
|
||||
// capacity; glyphs re-rasterize on demand and models rebuild via pagesVersion.
|
||||
this._evictAllPages();
|
||||
// Only proceed with merge if we have exactly 4 same-sized pages. If not, we cannot
|
||||
// effectively reduce page count and merging would cause issues.
|
||||
if (mergingPages.length < 4 || mergingPages.some(p => p.canvas.width !== mergingPages[0].canvas.width)) {
|
||||
const newPage = new AtlasPage(this._document, this._textureSize);
|
||||
this._pages.push(newPage);
|
||||
this._activePages.push(newPage);
|
||||
this._onAddTextureAtlasCanvas.fire(newPage.canvas);
|
||||
return newPage;
|
||||
}
|
||||
|
||||
const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a - b);
|
||||
const mergedPageIndex = this.pages.length - mergingPages.length;
|
||||
|
||||
// Merge into the new page
|
||||
const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
|
||||
mergedPage.version = ++AtlasPage.nextVersion;
|
||||
|
||||
// Delete the pages, shifting glyph texture pages as needed
|
||||
for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
|
||||
this._deletePage(sortedMergingPagesIndexes[i]);
|
||||
}
|
||||
|
||||
// Add the new merged page to the end
|
||||
this.pages.push(mergedPage);
|
||||
|
||||
// Request the model to be cleared to refresh all texture pages.
|
||||
this._requestClearModel = true;
|
||||
this._onAddTextureAtlasCanvas.fire(mergedPage.canvas);
|
||||
}
|
||||
|
||||
// All new atlas pages are created small as they are highly dynamic
|
||||
@@ -269,27 +258,6 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes every page and clears the glyph caches. This is the last resort when the page cap is
|
||||
* reached but no same-sized pages exist to merge: the page count must never exceed
|
||||
* maxAtlasPages because renderers size their texture and shader sampler arrays to exactly
|
||||
* that. Glyphs re-rasterize on demand and renderers rebuild their models via
|
||||
* {@link pagesVersion}.
|
||||
*/
|
||||
private _evictAllPages(): void {
|
||||
for (const page of this._pages) {
|
||||
this._onRemoveTextureAtlasCanvas.fire(page.canvas);
|
||||
page.canvas.remove();
|
||||
}
|
||||
this._pages.length = 0;
|
||||
this._activePages.length = 0;
|
||||
this._overflowSizePage = undefined;
|
||||
this._cacheMap.clear();
|
||||
this._cacheMapCombined.clear();
|
||||
this._didWarmUp = false;
|
||||
this._pagesVersion++;
|
||||
}
|
||||
|
||||
public getRasterizedGlyphCombinedChar(chars: string, bg: number, fg: number, ext: number, restrictToCellHeight: boolean, domContainer: HTMLElement | undefined): IRasterizedGlyph {
|
||||
return this._getFromCacheMap(this._cacheMapCombined, chars, bg, fg, ext, restrictToCellHeight, domContainer);
|
||||
}
|
||||
@@ -850,8 +818,8 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
this._overflowSizePage = new AtlasPage(this._document, this._config.deviceMaxTextureSize);
|
||||
this.pages.push(this._overflowSizePage);
|
||||
|
||||
// Invalidate models so all texture pages are refreshed.
|
||||
this._pagesVersion++;
|
||||
// Request the model to be cleared to refresh all texture pages.
|
||||
this._requestClearModel = true;
|
||||
this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas);
|
||||
}
|
||||
activePage = this._overflowSizePage;
|
||||
@@ -1112,33 +1080,17 @@ class AtlasPage {
|
||||
size: number,
|
||||
sourcePages?: AtlasPage[]
|
||||
) {
|
||||
if (sourcePages) {
|
||||
for (const p of sourcePages) {
|
||||
this._glyphs.push(...p.glyphs);
|
||||
this._usedPixels += p._usedPixels;
|
||||
}
|
||||
}
|
||||
this.canvas = createCanvas(document, size, size);
|
||||
// The canvas needs alpha because we use clearColor to convert the background color to alpha.
|
||||
// It might also contain some characters with transparent backgrounds if allowTransparency is
|
||||
// set.
|
||||
this.ctx = throwIfFalsy(this.canvas.getContext('2d', { alpha: true }));
|
||||
if (sourcePages) {
|
||||
if (sourcePages.length === 4) {
|
||||
// optimized for quadmerge
|
||||
this._glyphs = this._glyphs.concat(
|
||||
sourcePages[0].glyphs,
|
||||
sourcePages[1].glyphs,
|
||||
sourcePages[2].glyphs,
|
||||
sourcePages[3].glyphs
|
||||
);
|
||||
this._usedPixels = sourcePages[0]._usedPixels +
|
||||
sourcePages[1]._usedPixels +
|
||||
sourcePages[2]._usedPixels +
|
||||
sourcePages[3]._usedPixels;
|
||||
} else {
|
||||
// 2- and 3-page merges, used when no 4-page same-size set exists at the page cap
|
||||
for (let i = 0; i < sourcePages.length; ++i) {
|
||||
this._glyphs = this._glyphs.concat(sourcePages[i].glyphs);
|
||||
this._usedPixels += sourcePages[i]._usedPixels;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
|
||||
@@ -68,13 +68,10 @@ export interface ITextureAtlas extends IDisposable {
|
||||
warmUp(): void;
|
||||
|
||||
/**
|
||||
* Incremented whenever the pages array changes structurally (page merge,
|
||||
* overflow page creation), invalidating any texture page indexes/coordinates
|
||||
* cached by renderers. Tracked as a version rather than a consumable flag
|
||||
* because the atlas may be shared by multiple renderers, each of which must
|
||||
* notice the change independently.
|
||||
* Call when a frame is being drawn, this will return true if the atlas was cleared to make room
|
||||
* for a new set of glyphs.
|
||||
*/
|
||||
readonly pagesVersion: number;
|
||||
beginFrame(): boolean;
|
||||
|
||||
/**
|
||||
* Clear all glyphs from the texture atlas.
|
||||
|
||||
@@ -456,18 +456,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
|
||||
for (y = start; y <= end; y++) {
|
||||
row = y + terminal.buffer.ydisp;
|
||||
const bufferLine = terminal.buffer.lines.get(row);
|
||||
if (!bufferLine) {
|
||||
this._model.lineLengths[y] = 0;
|
||||
for (x = 0; x < terminal.cols; x++) {
|
||||
j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
|
||||
modelUpdated = true;
|
||||
this._nullModelCell(x, y, j, 0, 0, 0);
|
||||
}
|
||||
this._setRowBlinkState(y, false);
|
||||
continue;
|
||||
}
|
||||
line = bufferLine;
|
||||
line = terminal.buffer.lines.get(row)!;
|
||||
let rowHasBlinkingCells = false;
|
||||
this._model.lineLengths[y] = 0;
|
||||
isCursorRow = cursorY === row;
|
||||
@@ -598,9 +587,13 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
// Null out non-first cells
|
||||
for (x++; x <= lastCharX; x++) {
|
||||
j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
|
||||
this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, 0, 0, 0, NULL_CELL_CHAR, 0, 0);
|
||||
this._model.cells[j] = NULL_CELL_CODE;
|
||||
// Don't re-resolve the cell color since multi-colored ligature backgrounds are not
|
||||
// supported
|
||||
this._nullModelCell(x, y, j, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext);
|
||||
this._model.cells[j + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg;
|
||||
this._model.cells[j + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg;
|
||||
this._model.cells[j + RenderModelConstants.EXT_OFFSET] = this._cellColorResolver.result.ext;
|
||||
}
|
||||
x--; // Go back to the previous update cell for next iteration
|
||||
}
|
||||
@@ -614,14 +607,6 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
this._updateTextBlinkState();
|
||||
}
|
||||
|
||||
private _nullModelCell(x: number, y: number, cellIndex: number, bg: number, fg: number, ext: number): void {
|
||||
this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, bg, fg, ext, NULL_CELL_CHAR, 0, 0);
|
||||
this._model.cells[cellIndex] = NULL_CELL_CODE;
|
||||
this._model.cells[cellIndex + RenderModelConstants.BG_OFFSET] = bg;
|
||||
this._model.cells[cellIndex + RenderModelConstants.FG_OFFSET] = fg;
|
||||
this._model.cells[cellIndex + RenderModelConstants.EXT_OFFSET] = ext;
|
||||
}
|
||||
|
||||
private _resetBlinkingRowState(): void {
|
||||
this._rowHasBlinkingCells = new Array(this._terminal.rows).fill(false);
|
||||
this._rowHasBlinkingCellsCount = 0;
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*
|
||||
* Reproduces the single-terminal WebGL crash/garble when the texture atlas
|
||||
* page count overflows TextureAtlas.maxAtlasPages (vscode#322756 class).
|
||||
*
|
||||
* GlyphRenderer sizes its GL texture array and fragment-shader sampler array
|
||||
* to exactly maxAtlasPages at construction time, but TextureAtlas._createNewPage
|
||||
* pushes past that cap whenever a 4-page same-size merge is impossible (e.g. a
|
||||
* heterogeneous mix like 1024+512+512+512). The next GlyphRenderer.render then
|
||||
* throws "Cannot read properties of undefined (reading 'version')" and cells
|
||||
* reference sampler indexes the shader does not have, freezing/garbling the
|
||||
* terminal. Long heavy-CLI sessions hit this in production; the test makes it
|
||||
* fast by building the renderer with a small cap (the same code path as the
|
||||
* production 8/16/32 values).
|
||||
*
|
||||
* Chromium only (additive scope; Firefox/WebKit WebGL broken in CI, xtermjs#5854).
|
||||
*/
|
||||
import test, { expect } from '@playwright/test';
|
||||
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||
import {
|
||||
captureRowSignatures,
|
||||
expectSignatureMatches,
|
||||
loadWebglStrict,
|
||||
resetMaxAtlasPages,
|
||||
setMaxAtlasPages,
|
||||
writeAndWaitForRender
|
||||
} from '../../../test/playwright/RendererTestUtils';
|
||||
import { generateUniqueGlyphFlood } from '../../../test/playwright/SyntheticTui';
|
||||
|
||||
const HEADER = ' OVERFLOW_REF_0123456789';
|
||||
const HEADER_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
|
||||
test.describe('atlas page overflow (#322756 class)', () => {
|
||||
test.skip(({ browserName }) => browserName !== 'chromium');
|
||||
test.describe.configure({ timeout: 60000 });
|
||||
|
||||
test('renderer survives the atlas exceeding maxAtlasPages-sized textures', async ({ browser }) => {
|
||||
const ctx: ITestContext = await createTestContext(browser);
|
||||
const errors: string[] = [];
|
||||
const onError = (e: Error): void => { errors.push(e.message); };
|
||||
ctx.page.on('pageerror', onError);
|
||||
try {
|
||||
// Bootstrap: create a throwaway WebGL terminal so the TextureAtlas class
|
||||
// statics exist, then lower the page cap. openTerminal() disposes the old
|
||||
// terminal (and its atlas), so the next WebGL renderer is CONSTRUCTED
|
||||
// with the small cap: its GL texture array and fragment-shader sampler
|
||||
// array have exactly 4 entries, faithfully mirroring production where
|
||||
// they always equal maxAtlasPages.
|
||||
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||
await loadWebglStrict(ctx);
|
||||
await setMaxAtlasPages(ctx, 4);
|
||||
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||
await loadWebglStrict(ctx);
|
||||
|
||||
const atlasTexturesLength = await ctx.page.evaluate(() =>
|
||||
(window as any).term._core._renderService._renderer.value._glyphRenderer.value._atlasTextures.length);
|
||||
expect(atlasTexturesLength, 'renderer must be built with the lowered page cap').toBe(4);
|
||||
|
||||
// Pinned header in the alt buffer; body churns distinct CJK glyphs below
|
||||
// (cursor-positioned, no trailing linefeed on the bottom row -> no scroll).
|
||||
await writeAndWaitForRender(ctx, `\x1b[?1049h\x1b[H${HEADER}`);
|
||||
const reference = await captureRowSignatures(ctx, 1, HEADER_COLS);
|
||||
await ctx.page.locator('#terminal-container .xterm-screen').screenshot({ path: 'out-esbuild-test/playwright/overflow-before.png' });
|
||||
|
||||
const glyphsPerChunk = 23 * 40 - 1;
|
||||
for (let c = 0; c < 16; c++) {
|
||||
// Plain write + short wait instead of waiting for a render event: once
|
||||
// the overflow crash fires, render events stop arriving and a render
|
||||
// wait would hang the test instead of failing it.
|
||||
await ctx.proxy.write('\x1b[2;1H' + generateUniqueGlyphFlood(glyphsPerChunk, 80, c * glyphsPerChunk));
|
||||
await ctx.page.waitForTimeout(50);
|
||||
}
|
||||
await ctx.page.waitForTimeout(300);
|
||||
await ctx.page.locator('#terminal-container .xterm-screen').screenshot({ path: 'out-esbuild-test/playwright/overflow-after.png' });
|
||||
|
||||
const state = await ctx.page.evaluate(() => {
|
||||
const renderer = (window as any).term._core._renderService._renderer.value;
|
||||
return {
|
||||
pages: renderer._charAtlas.pages.length,
|
||||
atlasTextures: renderer._glyphRenderer.value._atlasTextures.length
|
||||
};
|
||||
});
|
||||
console.error('[overflow] pages=', state.pages, 'atlasTextures=', state.atlasTextures, 'pageErrors=', errors.length, errors[0] ?? '');
|
||||
|
||||
// Contract: the page count must never exceed the renderer's texture and
|
||||
// sampler capacity, and rendering must never throw.
|
||||
expect(errors, `GlyphRenderer must not crash when the atlas is saturated: ${errors[0] ?? ''}`).toEqual([]);
|
||||
expect(state.pages, 'atlas page count must stay within the renderer texture capacity').toBeLessThanOrEqual(state.atlasTextures);
|
||||
|
||||
// The untouched header must still render correctly after the churn.
|
||||
const after = await captureRowSignatures(ctx, 1, HEADER_COLS);
|
||||
for (let i = 0; i < HEADER_COLS.length; i++) {
|
||||
expectSignatureMatches(after[i], reference[i], 14, `header col ${HEADER_COLS[i]} corrupted after atlas saturation`);
|
||||
}
|
||||
} finally {
|
||||
ctx.page.off('pageerror', onError);
|
||||
// Restore the default so a lowered cap can never leak into other tests.
|
||||
await resetMaxAtlasPages(ctx).catch(() => { /* page may already be unusable after a crash */ });
|
||||
await ctx.page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* 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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
resetMaxAtlasPages,
|
||||
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);
|
||||
});
|
||||
|
||||
// A lowered page cap must not leak into renderers built by later tests.
|
||||
test.afterEach(async () => await resetMaxAtlasPages(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()`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*
|
||||
* Single-terminal guard for the vscode#322756 "garbled glyph" class: a pinned
|
||||
* alt-buffer header must render identically while the body churns enough
|
||||
* distinct colored glyphs to force texture-atlas page merges.
|
||||
*
|
||||
* Note this scenario is GREEN on current master: the single-terminal mid-frame
|
||||
* merge race was fixed upstream (merge-retry loop in WebglRenderer.renderRows +
|
||||
* globally monotonic AtlasPage versions). The test asserts the buffer content
|
||||
* first so a content bug (e.g. an accidental scroll from the churn stream) can
|
||||
* never masquerade as renderer corruption.
|
||||
*/
|
||||
import test, { expect } from '@playwright/test';
|
||||
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||
import { captureRowSignatures, expectSignatureMatches, getAtlasPageCount, loadWebglStrict, resetMaxAtlasPages, setMaxAtlasPages, writeAndWaitForRender } from '../../../test/playwright/RendererTestUtils';
|
||||
import { generateColoredAsciiFlood } from '../../../test/playwright/SyntheticTui';
|
||||
|
||||
const HEADER = ' HEADER_REF_0123456789';
|
||||
|
||||
let ctx: ITestContext;
|
||||
test.beforeAll(async ({ browser }) => { ctx = await createTestContext(browser); });
|
||||
test.afterAll(async () => await ctx.page.close());
|
||||
|
||||
test.describe('single-terminal merge integrity (#322756 class)', () => {
|
||||
test.skip(({ browserName }) => browserName !== 'chromium');
|
||||
test.describe.configure({ timeout: 60000 });
|
||||
|
||||
// A lowered page cap must not leak into renderers built by later tests.
|
||||
test.afterEach(async () => await resetMaxAtlasPages(ctx));
|
||||
|
||||
test('pinned header renders identically across 30 merge-heavy body redraws', 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}`);
|
||||
const ref = await captureRowSignatures(ctx, 1, cols);
|
||||
let maxDiff = 0;
|
||||
for (let c = 0; c < 30; c++) {
|
||||
// Redraw body rows 2..24 with unique glyphs; the header row is never touched.
|
||||
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);
|
||||
|
||||
// Guard: the header must still be in the buffer. If this fails the churn
|
||||
// stream scrolled the screen and any pixel diff is meaningless.
|
||||
const row1Text = await ctx.page.evaluate(() => (window as any).term.buffer.active.getLine(0)?.translateToString(true));
|
||||
expect(row1Text, 'churn stream must not scroll the pinned header out of the buffer').toBe(HEADER);
|
||||
|
||||
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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*
|
||||
* Reproduces the vscode#322756 "garbled glyph" corruption across terminals that
|
||||
* SHARE a texture atlas (CharAtlasCache hands the same TextureAtlas to every
|
||||
* terminal with an equal config — in VS Code that is every integrated terminal
|
||||
* using the same font settings, including task/background terminals).
|
||||
*
|
||||
* Mechanism: when the atlas merges pages it raises a consume-once
|
||||
* "clear model" flag (TextureAtlas.beginFrame). Only the FIRST terminal to
|
||||
* render consumes it and rebuilds its vertex model; every other terminal keeps
|
||||
* stale texture-page indexes/coordinates in its vertex buffer and draws wrong
|
||||
* glyphs on its next partial render, even though its buffer content is intact.
|
||||
*
|
||||
* The DOM-renderer control shows the corruption is exclusive to the GPU path
|
||||
* (`terminal.integrated.gpuAcceleration off` cannot reproduce it: the DOM
|
||||
* renderer has no texture atlas).
|
||||
*
|
||||
* Chromium only (additive scope; Firefox/WebKit WebGL broken in CI, xtermjs#5854).
|
||||
*/
|
||||
import test, { expect } from '@playwright/test';
|
||||
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||
import {
|
||||
captureRowSignatures,
|
||||
expectSignatureMatches,
|
||||
getAtlasPageCount,
|
||||
loadWebglStrict,
|
||||
setMaxAtlasPages,
|
||||
writeAndWaitForRender
|
||||
} from '../../../test/playwright/RendererTestUtils';
|
||||
import { generateColoredAsciiFlood } from '../../../test/playwright/SyntheticTui';
|
||||
|
||||
const HEADER = ' HEADER_REF_0123456789';
|
||||
const HEADER_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const TERM_B_SELECTOR = '#terminal-container-b .xterm-screen';
|
||||
|
||||
/**
|
||||
* Creates a second terminal ("termB") on the test page with the given options
|
||||
* and optionally loads the WebGL addon on it. Identical rendering options mean
|
||||
* CharAtlasCache gives it the same atlas instance as `window.term`.
|
||||
*/
|
||||
async function createTerminalB(ctx: ITestContext, options: { useWebgl: boolean }): Promise<void> {
|
||||
await ctx.page.evaluate(async (useWebgl) => {
|
||||
const w = window as any;
|
||||
w.termB?.dispose();
|
||||
document.getElementById('terminal-container-b')?.remove();
|
||||
const el = document.createElement('div');
|
||||
el.id = 'terminal-container-b';
|
||||
document.body.appendChild(el);
|
||||
w.termB = new w.Terminal({ cols: 80, rows: 24, allowProposedApi: true });
|
||||
w.termB.open(el);
|
||||
if (useWebgl) {
|
||||
w.addonB = new w.WebglAddon({ preserveDrawingBuffer: true });
|
||||
w.termB.loadAddon(w.addonB);
|
||||
}
|
||||
}, options.useWebgl);
|
||||
}
|
||||
|
||||
/** Writes data to termB and resolves after its next render event. */
|
||||
async function writeToBAndWaitForRender(ctx: ITestContext, data: string): Promise<void> {
|
||||
await ctx.page.evaluate(d => new Promise<void>(resolve => {
|
||||
const termB = (window as any).termB;
|
||||
const disposable = termB.onRender(() => {
|
||||
disposable.dispose();
|
||||
resolve();
|
||||
});
|
||||
termB.write(d);
|
||||
}), data);
|
||||
}
|
||||
|
||||
async function getTermBRow1Text(ctx: ITestContext): Promise<string | undefined> {
|
||||
return ctx.page.evaluate(() => (window as any).termB.buffer.active.getLine(0)?.translateToString(true));
|
||||
}
|
||||
|
||||
test.describe('shared-atlas garble across terminals (#322756)', () => {
|
||||
test.skip(({ browserName }) => browserName !== 'chromium');
|
||||
test.describe.configure({ timeout: 60000 });
|
||||
|
||||
test('terminal B keeps rendering its header correctly while terminal A forces atlas merges', async ({ browser }) => {
|
||||
const ctx: ITestContext = await createTestContext(browser);
|
||||
try {
|
||||
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||
await loadWebglStrict(ctx);
|
||||
await createTerminalB(ctx, { useWebgl: true });
|
||||
|
||||
// Both terminals must be using the same atlas instance for this test to
|
||||
// mean anything.
|
||||
const atlasShared = await ctx.page.evaluate(() => {
|
||||
const w = window as any;
|
||||
const atlasA = w.term._core._renderService._renderer.value._charAtlas;
|
||||
const atlasB = w.termB._core._renderService._renderer.value._charAtlas;
|
||||
return !!atlasA && atlasA === atlasB;
|
||||
});
|
||||
expect(atlasShared, 'terminals with equal configs should share one texture atlas').toBe(true);
|
||||
|
||||
// Lower the cap AFTER both renderers exist so their shaders/texture
|
||||
// arrays keep the original size (this test targets stale vertex data,
|
||||
// not the page-overflow crash).
|
||||
await setMaxAtlasPages(ctx, 4);
|
||||
|
||||
// Render B's header pre-merge and capture its reference pixels.
|
||||
await writeToBAndWaitForRender(ctx, HEADER);
|
||||
await ctx.page.locator(TERM_B_SELECTOR).screenshot({ path: 'out-esbuild-test/playwright/termB-before.png' });
|
||||
const reference = await captureRowSignatures(ctx, 1, HEADER_COLS, { selector: TERM_B_SELECTOR, termGlobal: 'termB' });
|
||||
|
||||
// Churn terminal A with distinct colored glyphs to force page merges in
|
||||
// the SHARED atlas. A repairs itself (it consumes the clear-model flag);
|
||||
// B is never told.
|
||||
for (let c = 0; c < 10; c++) {
|
||||
await writeAndWaitForRender(ctx, '\x1b[2;1H' + generateColoredAsciiFlood(23 * 78, c * 23 * 78));
|
||||
}
|
||||
const pages = await getAtlasPageCount(ctx);
|
||||
expect(pages, 'churn should have exercised multiple atlas pages').toBeGreaterThan(1);
|
||||
|
||||
// A tiny unrelated update to B's LAST row triggers a partial render in B.
|
||||
// B's header row is untouched; with a healthy renderer its pixels must
|
||||
// not change.
|
||||
await writeToBAndWaitForRender(ctx, '\x1b[24;1Hx');
|
||||
await ctx.page.locator(TERM_B_SELECTOR).screenshot({ path: 'out-esbuild-test/playwright/termB-after.png' });
|
||||
|
||||
// Guard: B's buffer content is intact, so any pixel change is renderer
|
||||
// corruption by construction.
|
||||
expect(await getTermBRow1Text(ctx), 'terminal B buffer must still contain the header').toBe(HEADER);
|
||||
|
||||
const after = await captureRowSignatures(ctx, 1, HEADER_COLS, { selector: TERM_B_SELECTOR, termGlobal: 'termB' });
|
||||
for (let i = 0; i < HEADER_COLS.length; i++) {
|
||||
expectSignatureMatches(after[i], reference[i], 14, `terminal B header col ${HEADER_COLS[i]} garbled by terminal A's atlas merges`);
|
||||
}
|
||||
} finally {
|
||||
await ctx.page.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('DOM renderer control: same workload cannot garble terminal B (no atlas without GPU acceleration)', async ({ browser }) => {
|
||||
const ctx: ITestContext = await createTestContext(browser);
|
||||
try {
|
||||
// No WebGL addon on either terminal -> DOM renderer -> no texture atlas.
|
||||
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||
await createTerminalB(ctx, { useWebgl: false });
|
||||
|
||||
await writeToBAndWaitForRender(ctx, HEADER);
|
||||
const reference = await captureRowSignatures(ctx, 1, HEADER_COLS, { selector: TERM_B_SELECTOR, termGlobal: 'termB' });
|
||||
|
||||
for (let c = 0; c < 10; c++) {
|
||||
await writeAndWaitForRender(ctx, '\x1b[2;1H' + generateColoredAsciiFlood(23 * 78, c * 23 * 78));
|
||||
}
|
||||
await writeToBAndWaitForRender(ctx, '\x1b[24;1Hx');
|
||||
|
||||
expect(await getTermBRow1Text(ctx), 'terminal B buffer must still contain the header').toBe(HEADER);
|
||||
const after = await captureRowSignatures(ctx, 1, HEADER_COLS, { selector: TERM_B_SELECTOR, termGlobal: 'termB' });
|
||||
for (let i = 0; i < HEADER_COLS.length; i++) {
|
||||
expectSignatureMatches(after[i], reference[i], 14, `DOM-rendered terminal B header col ${HEADER_COLS[i]} unexpectedly changed`);
|
||||
}
|
||||
} finally {
|
||||
await ctx.page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
resetMaxAtlasPages,
|
||||
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);
|
||||
});
|
||||
|
||||
// A lowered page cap must not leak into renderers built by later tests.
|
||||
test.afterEach(async () => await resetMaxAtlasPages(ctx));
|
||||
|
||||
test('heavy merge churn must not crash the GlyphRenderer (vscode#322756)', async () => {
|
||||
// Guard: heavy merges must not throw from GlyphRenderer.render. Note the
|
||||
// page cap here is lowered AFTER the renderer was built, so _atlasTextures
|
||||
// keeps its original (large) length and this test cannot hit the
|
||||
// page-overflow crash; that scenario needs the cap lowered BEFORE loading
|
||||
// the addon and is covered separately (WebglAtlasOverflow.test.ts).
|
||||
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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
* Produces BEFORE/AFTER screenshots of a single terminal under merge-heavy
|
||||
* churn for manual inspection (before.png / after.png, mono-*.png). On current
|
||||
* master the single-terminal merge path is healthy, so the pinned header lines
|
||||
* are expected to remain readable in the "after" images; a corrupted header in
|
||||
* these captures indicates a renderer regression. Note the mono "after" body is
|
||||
* SUPPOSED to look like glyph soup — that is the mixed box-drawing/braille
|
||||
* payload rendered faithfully, not corruption.
|
||||
*/
|
||||
import test from '@playwright/test';
|
||||
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||
import { loadWebglStrict, setMaxAtlasPages, 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 pinned header under merge churn', 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();
|
||||
});
|
||||
|
||||
// Issue-like framing: prefill realistic CLI text, force merges with a mixed
|
||||
// glyph payload, then rewrite the top lines clean. The rewritten header lines
|
||||
// must stay readable in mono-after.png.
|
||||
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 while most rows keep their cells,
|
||||
// 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\x1b[2K $ Shell grep installed Copilot CLI binary - 53 lines\r\n\x1b[2K Thought for 1s');
|
||||
await screen.screenshot({ path: 'out-esbuild-test/playwright/mono-after.png' });
|
||||
await ctx.page.close();
|
||||
});
|
||||
});
|
||||
@@ -11,33 +11,8 @@ export class WebglWindow extends BaseWindow implements IControlWindow {
|
||||
public readonly label = 'webgl';
|
||||
|
||||
private _textureAtlasContainer!: HTMLElement;
|
||||
private _stressRunning = false;
|
||||
private _stressScrolling: HTMLInputElement | undefined;
|
||||
|
||||
public build(container: HTMLElement): void {
|
||||
const stressDiv = document.createElement('div');
|
||||
const stressStart = document.createElement('button');
|
||||
stressStart.id = 'stress-start';
|
||||
stressStart.textContent = 'atlas stress start';
|
||||
stressStart.addEventListener('click', () => this._stress());
|
||||
stressDiv.appendChild(stressStart);
|
||||
|
||||
const stressStop = document.createElement('button');
|
||||
stressStop.id = 'stress-stop';
|
||||
stressStop.textContent = 'atlas stress stop';
|
||||
stressStop.addEventListener('click', () => this._stressRunning = false);
|
||||
stressDiv.appendChild(stressStop);
|
||||
|
||||
this._stressScrolling = document.createElement('input');
|
||||
this._stressScrolling.type = 'checkbox';
|
||||
this._stressScrolling.id = 'stress-scrolling';
|
||||
stressDiv.appendChild(this._stressScrolling);
|
||||
const stressScrollingLabel = document.createElement('label');
|
||||
stressScrollingLabel.htmlFor = 'stress-scrolling';
|
||||
stressScrollingLabel.textContent = 'stress scrolling';
|
||||
stressDiv.appendChild(stressScrollingLabel);
|
||||
container.appendChild(stressDiv);
|
||||
|
||||
const zoomCheckbox = document.createElement('input');
|
||||
zoomCheckbox.type = 'checkbox';
|
||||
zoomCheckbox.id = 'texture-atlas-zoom';
|
||||
@@ -73,33 +48,4 @@ export class WebglWindow extends BaseWindow implements IControlWindow {
|
||||
canvas.style.width = `${canvas.width / dpr}px`;
|
||||
canvas.style.height = `${canvas.height / dpr}px`;
|
||||
}
|
||||
|
||||
private async _stress(): Promise<void> {
|
||||
const TEXT = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~';
|
||||
if (this._stressRunning) {
|
||||
return;
|
||||
}
|
||||
this._stressRunning = true;
|
||||
const scrolling = !!(this._stressScrolling?.checked);
|
||||
for (let r = 0; r < 256; r += 2) {
|
||||
for (let g = 0; g < 256; g += 2) {
|
||||
let s: string[] = [];
|
||||
for (let b = 0; b < 256; b += 2) {
|
||||
if (!this._stressRunning) {
|
||||
return;
|
||||
}
|
||||
const rbg = `RGB: ${[r, g, b]}`;
|
||||
s.push(`\r\x1b[38;2;${r};${g};${b}m${rbg.padEnd(18, ' ')}${TEXT}`);
|
||||
if (s.length >= 16) {
|
||||
if (scrolling) {
|
||||
await new Promise<void>(r => this._terminal.write(s.join('\r\n') + '\r\n', r));
|
||||
} else {
|
||||
await new Promise<void>(r => this._terminal.write('\x1b[H' + s.join('\r\n'), r));
|
||||
}
|
||||
s = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+107
-107
@@ -663,9 +663,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -680,9 +680,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -697,9 +697,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -714,9 +714,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -731,9 +731,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -748,9 +748,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -765,9 +765,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -782,9 +782,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -799,9 +799,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -816,9 +816,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -833,9 +833,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -850,9 +850,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -867,9 +867,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -884,9 +884,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -901,9 +901,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -918,9 +918,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -935,9 +935,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -952,9 +952,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -969,9 +969,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -986,9 +986,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1003,9 +1003,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1020,9 +1020,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1037,9 +1037,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1054,9 +1054,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1071,9 +1071,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1088,9 +1088,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3869,9 +3869,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -3882,32 +3882,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
|
||||
@@ -1072,6 +1072,10 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
* Clear the entire buffer, making the prompt line the new first line.
|
||||
*/
|
||||
public clear(): void {
|
||||
if (this.buffer.ybase === 0 && this.buffer.y === 0) {
|
||||
// Don't clear if it's already clear
|
||||
return;
|
||||
}
|
||||
this.buffer.clearAllMarkers();
|
||||
this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)!);
|
||||
this.buffer.lines.length = 1;
|
||||
|
||||
@@ -536,14 +536,9 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
for (let y = start; y <= end; y++) {
|
||||
const row = y + buffer.ydisp;
|
||||
const rowElement = this._rowElements[y];
|
||||
if (!rowElement) {
|
||||
continue;
|
||||
}
|
||||
const lineData = buffer.lines.get(row);
|
||||
if (!lineData) {
|
||||
rowElement.replaceChildren();
|
||||
this._setRowBlinkState(y, false);
|
||||
continue;
|
||||
if (!rowElement || !lineData) {
|
||||
break;
|
||||
}
|
||||
rowElement.replaceChildren(
|
||||
...this._rowFactory.createRow(
|
||||
@@ -615,14 +610,9 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
for (let i = y; i <= y2; ++i) {
|
||||
const row = i + buffer.ydisp;
|
||||
const rowElement = this._rowElements[i];
|
||||
if (!rowElement) {
|
||||
continue;
|
||||
}
|
||||
const bufferline = buffer.lines.get(row);
|
||||
if (!bufferline) {
|
||||
rowElement.replaceChildren();
|
||||
this._setRowBlinkState(i, false);
|
||||
continue;
|
||||
if (!rowElement || !bufferline) {
|
||||
break;
|
||||
}
|
||||
rowElement.replaceChildren(
|
||||
...this._rowFactory.createRow(
|
||||
|
||||
@@ -64,13 +64,6 @@ describe('SelectionModel', () => {
|
||||
assert.deepEqual(model.finalSelectionStart, undefined);
|
||||
assert.deepEqual(model.finalSelectionEnd, undefined);
|
||||
});
|
||||
it('should reset selection start to origin when start row is trimmed', () => {
|
||||
model.selectionStart = [50, 0];
|
||||
model.selectionEnd = [10, 2];
|
||||
assert.equal(model.handleTrim(1), true);
|
||||
assert.deepEqual(model.finalSelectionStart, [0, 0]);
|
||||
assert.deepEqual(model.finalSelectionEnd, [10, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalSelectionStart', () => {
|
||||
|
||||
@@ -135,10 +135,9 @@ export class SelectionModel {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the selection start row is trimmed away, reset to the buffer origin.
|
||||
// If the selection start is trimmed, ensure the start column is 0.
|
||||
if (this.selectionStart && this.selectionStart[1] < 0) {
|
||||
this.selectionStart = [0, 0];
|
||||
return true;
|
||||
this.selectionStart[1] = 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,6 @@ export class CharacterJoinerService implements ICharacterJoinerService {
|
||||
|
||||
const ranges: [number, number][] = [];
|
||||
const lineStr = line.translateToString(true);
|
||||
const trimmedLength = line.getTrimmedLength();
|
||||
|
||||
// Because some cells can be represented by multiple javascript characters,
|
||||
// we track the cell and the string indexes separately. This allows us to
|
||||
@@ -112,7 +111,7 @@ export class CharacterJoinerService implements ICharacterJoinerService {
|
||||
let rangeAttrFG = line.getFg(0);
|
||||
let rangeAttrBG = line.getBg(0);
|
||||
|
||||
for (let x = 0; x < trimmedLength; x++) {
|
||||
for (let x = 0; x < line.getTrimmedLength(); x++) {
|
||||
line.loadCell(x, this._workCell);
|
||||
|
||||
if (this._workCell.getWidth() === 0) {
|
||||
@@ -148,7 +147,7 @@ export class CharacterJoinerService implements ICharacterJoinerService {
|
||||
}
|
||||
|
||||
// Process any trailing ranges.
|
||||
if (trimmedLength - rangeStartColumn > 1) {
|
||||
if (this._bufferService.cols - rangeStartColumn > 1) {
|
||||
const joinedRanges = this._getJoinedRanges(
|
||||
lineStr,
|
||||
rangeStartStringIndex,
|
||||
@@ -217,8 +216,7 @@ export class CharacterJoinerService implements ICharacterJoinerService {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedLength = line.getTrimmedLength();
|
||||
for (let x = startCol; x < trimmedLength; x++) {
|
||||
for (let x = startCol; x < this._bufferService.cols; x++) {
|
||||
const width = line.getWidth(x);
|
||||
const length = line.getString(x).length || WHITESPACE_CELL_CHAR.length;
|
||||
|
||||
@@ -266,7 +264,7 @@ export class CharacterJoinerService implements ICharacterJoinerService {
|
||||
// If there is still a range left at the end, it must extend all the way to
|
||||
// the end of the line.
|
||||
if (currentRange) {
|
||||
currentRange[1] = trimmedLength;
|
||||
currentRange[1] = this._bufferService.cols;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -351,96 +351,3 @@ describe('MouseService mouseEventsRequireAlt', () => {
|
||||
assert.isFalse(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS));
|
||||
});
|
||||
});
|
||||
|
||||
describe('MouseService multi-window', () => {
|
||||
interface IRecordingDocument {
|
||||
doc: Document;
|
||||
added: string[];
|
||||
removed: string[];
|
||||
listeners: { [type: string]: EventListener[] };
|
||||
}
|
||||
|
||||
function createRecordingDocument(): IRecordingDocument {
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const listeners: { [type: string]: EventListener[] } = {};
|
||||
const doc = {
|
||||
addEventListener: (type: string, listener: EventListener) => {
|
||||
added.push(type);
|
||||
(listeners[type] ??= []).push(listener);
|
||||
},
|
||||
removeEventListener: (type: string) => removed.push(type)
|
||||
} as any;
|
||||
return { doc, added, removed, listeners };
|
||||
}
|
||||
|
||||
it('should manage protocol mouseup/mousemove listeners on the element\'s current document, not the document the terminal was opened with', () => {
|
||||
const mouseStateService = new MouseStateService();
|
||||
const optionsService = new OptionsService({});
|
||||
const mouseService = new MouseService(
|
||||
new MockRenderService(),
|
||||
{
|
||||
getMouseReportCoords: () => ({ col: 0, row: 0, x: 0, y: 0 })
|
||||
} as any,
|
||||
mouseStateService,
|
||||
{
|
||||
triggerDataEvent: () => {},
|
||||
triggerBinaryEvent: () => {},
|
||||
decPrivateModes: { applicationCursorKeys: false }
|
||||
} as any,
|
||||
bufferService,
|
||||
optionsService,
|
||||
new TestSelectionService(),
|
||||
logService,
|
||||
new MockCoreBrowserService()
|
||||
);
|
||||
|
||||
// Simulate a terminal that was opened with one document but whose element now lives in
|
||||
// another document (eg. it was moved to a child window)
|
||||
const openDocument = createRecordingDocument();
|
||||
const childDocument = createRecordingDocument();
|
||||
const elementListeners: { [type: string]: EventListener[] } = {};
|
||||
const element = {
|
||||
classList: {
|
||||
add: () => {},
|
||||
remove: () => {},
|
||||
contains: () => false
|
||||
},
|
||||
addEventListener: (type: string, listener: EventListener) => {
|
||||
(elementListeners[type] ??= []).push(listener);
|
||||
},
|
||||
removeEventListener: () => {},
|
||||
ownerDocument: childDocument.doc
|
||||
} as any;
|
||||
|
||||
mouseService.bindMouse({
|
||||
element,
|
||||
screenElement: createTestMouseTargetElement(),
|
||||
document: openDocument.doc
|
||||
}, disposable => disposable, () => {});
|
||||
mouseStateService.activeProtocol = 'ANY';
|
||||
|
||||
elementListeners['mousedown'][0]({
|
||||
type: 'mousedown',
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
preventDefault: () => {}
|
||||
} as any);
|
||||
assert.deepEqual(childDocument.added, ['mouseup', 'mousemove']);
|
||||
assert.deepEqual(openDocument.added, []);
|
||||
|
||||
childDocument.listeners['mouseup'][0]({
|
||||
type: 'mouseup',
|
||||
button: 0,
|
||||
buttons: 0,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false
|
||||
} as any);
|
||||
assert.deepEqual(childDocument.removed, ['mouseup', 'mousemove']);
|
||||
assert.deepEqual(openDocument.removed, []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { addDisposableListener } from '../Dom';
|
||||
import { IBufferService, IMouseStateService, ICoreService, ILogService, IOptionsService } from '../../common/services/Services';
|
||||
import { CoreMouseAction, CoreMouseButton, CoreMouseEventType, ICoreMouseEvent, IDisposable } from '../../common/Types';
|
||||
import { C0 } from '../../common/data/EscapeSequences';
|
||||
import { DisposableStore, MutableDisposable } from '../../common/Lifecycle';
|
||||
import { DisposableStore, MutableDisposable, toDisposable } from '../../common/Lifecycle';
|
||||
import { ICoreBrowserService, IMouseCoordsService, IMouseService, IMouseServiceTarget, IRenderService, ISelectionService } from './Services';
|
||||
import { Gesture, EventType as GestureEventType, IGestureEvent } from '../scrollable/touch';
|
||||
|
||||
@@ -21,8 +21,6 @@ interface IMouseBindContext {
|
||||
readonly target: IMouseServiceTarget;
|
||||
readonly focus: () => void;
|
||||
readonly requestedEvents: RequestedMouseEvents;
|
||||
readonly mouseupListener: MutableDisposable<IDisposable>;
|
||||
readonly mousedragListener: MutableDisposable<IDisposable>;
|
||||
}
|
||||
|
||||
export class MouseService implements IMouseService {
|
||||
@@ -63,11 +61,7 @@ export class MouseService implements IMouseService {
|
||||
mousedrag: null,
|
||||
mousemove: null
|
||||
};
|
||||
const mouseupListener = new MutableDisposable<IDisposable>();
|
||||
const mousedragListener = new MutableDisposable<IDisposable>();
|
||||
register(mouseupListener);
|
||||
register(mousedragListener);
|
||||
const ctx: IMouseBindContext = { target, focus, requestedEvents, mouseupListener, mousedragListener };
|
||||
const ctx: IMouseBindContext = { target, focus, requestedEvents };
|
||||
const eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener> = {
|
||||
mouseup: (ev: Event) => this._handleMouseUp(ctx, ev as MouseEvent),
|
||||
wheel: (ev: Event) => this._handleWheel(ctx, ev as WheelEvent),
|
||||
@@ -91,6 +85,16 @@ export class MouseService implements IMouseService {
|
||||
// force initial onProtocolChange so we dont miss early mouse requests
|
||||
this._mouseStateService.activeProtocol = this._mouseStateService.activeProtocol;
|
||||
|
||||
// Ensure document-level listeners are removed on dispose
|
||||
register(toDisposable(() => {
|
||||
if (requestedEvents.mouseup) {
|
||||
document.removeEventListener('mouseup', requestedEvents.mouseup);
|
||||
}
|
||||
if (requestedEvents.mousedrag) {
|
||||
document.removeEventListener('mousemove', requestedEvents.mousedrag);
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* "Always on" event listeners.
|
||||
*/
|
||||
@@ -195,8 +199,12 @@ export class MouseService implements IMouseService {
|
||||
this._sendEvent(ctx, ev);
|
||||
if (!ev.buttons) {
|
||||
// if no other button is held remove global handlers
|
||||
ctx.mouseupListener.clear();
|
||||
ctx.mousedragListener.clear();
|
||||
if (ctx.requestedEvents.mouseup) {
|
||||
ctx.target.document.removeEventListener('mouseup', ctx.requestedEvents.mouseup);
|
||||
}
|
||||
if (ctx.requestedEvents.mousedrag) {
|
||||
ctx.target.document.removeEventListener('mousemove', ctx.requestedEvents.mousedrag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,14 +246,11 @@ export class MouseService implements IMouseService {
|
||||
// of the terminal element.
|
||||
// Note: Other emulators also do this for 'mousedown' while a button
|
||||
// is held, we currently limit 'mousedown' to the terminal only.
|
||||
// Use the element's current document in case it moved to another window after open.
|
||||
const { element, document: targetDocument } = ctx.target;
|
||||
const listenerDocument = element.ownerDocument ?? targetDocument;
|
||||
if (ctx.requestedEvents.mouseup) {
|
||||
ctx.mouseupListener.value = addDisposableListener(listenerDocument, 'mouseup', ctx.requestedEvents.mouseup);
|
||||
ctx.target.document.addEventListener('mouseup', ctx.requestedEvents.mouseup);
|
||||
}
|
||||
if (ctx.requestedEvents.mousedrag) {
|
||||
ctx.mousedragListener.value = addDisposableListener(listenerDocument, 'mousemove', ctx.requestedEvents.mousedrag);
|
||||
ctx.target.document.addEventListener('mousemove', ctx.requestedEvents.mousedrag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,7 +398,7 @@ export class MouseService implements IMouseService {
|
||||
}
|
||||
|
||||
private _handleProtocolChange(ctx: IMouseBindContext, eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener>, events: CoreMouseEventType): void {
|
||||
const { element } = ctx.target;
|
||||
const { element, document } = ctx.target;
|
||||
const { requestedEvents } = ctx;
|
||||
// apply global changes on events
|
||||
if (events) {
|
||||
@@ -428,14 +433,18 @@ export class MouseService implements IMouseService {
|
||||
}
|
||||
|
||||
if (!(events & CoreMouseEventType.UP)) {
|
||||
ctx.mouseupListener.clear();
|
||||
if (requestedEvents.mouseup) {
|
||||
document.removeEventListener('mouseup', requestedEvents.mouseup);
|
||||
}
|
||||
requestedEvents.mouseup = null;
|
||||
} else {
|
||||
requestedEvents.mouseup ??= eventListeners.mouseup;
|
||||
}
|
||||
|
||||
if (!(events & CoreMouseEventType.DRAG)) {
|
||||
ctx.mousedragListener.clear();
|
||||
if (requestedEvents.mousedrag) {
|
||||
document.removeEventListener('mousemove', requestedEvents.mousedrag);
|
||||
}
|
||||
requestedEvents.mousedrag = null;
|
||||
} else {
|
||||
requestedEvents.mousedrag ??= eventListeners.mousedrag;
|
||||
|
||||
@@ -704,7 +704,7 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
if (this._activeSelectionMode !== SelectionMode.COLUMN) {
|
||||
this._model.selectionEnd[0] = this._bufferService.cols;
|
||||
}
|
||||
this._model.selectionEnd[1] = Math.min(buffer.ydisp + this._bufferService.rows - 1, buffer.lines.length - 1);
|
||||
this._model.selectionEnd[1] = Math.min(buffer.ydisp + this._bufferService.rows, buffer.lines.length - 1);
|
||||
} else {
|
||||
if (this._activeSelectionMode !== SelectionMode.COLUMN) {
|
||||
this._model.selectionEnd[0] = 0;
|
||||
|
||||
@@ -79,7 +79,7 @@ function assertDecodedRange(
|
||||
}
|
||||
const target = new Uint32Array(count);
|
||||
const length = decode(input, target);
|
||||
assert.strictEqual(length, count);
|
||||
assert.equal(length, count);
|
||||
let mismatchIndex = -1;
|
||||
let index = 0;
|
||||
for (let i = min; i < max; ++i) {
|
||||
@@ -92,8 +92,8 @@ function assertDecodedRange(
|
||||
}
|
||||
index++;
|
||||
}
|
||||
assert.strictEqual(mismatchIndex, -1);
|
||||
assert.strictEqual(outputToString(target, length), input);
|
||||
assert.equal(mismatchIndex, -1);
|
||||
assert.equal(outputToString(target, length), input);
|
||||
}
|
||||
|
||||
const BATCH_SIZE = 8192;
|
||||
@@ -116,9 +116,9 @@ describe('text encodings', () => {
|
||||
const data = new Uint32Array(s.length);
|
||||
for (let i = 0; i < s.length; ++i) {
|
||||
data[i] = s.charCodeAt(i);
|
||||
assert.strictEqual(stringFromCodePoint(data[i]), s[i]);
|
||||
assert.equal(stringFromCodePoint(data[i]), s[i]);
|
||||
}
|
||||
assert.strictEqual(utf32ToString(data), s);
|
||||
assert.equal(utf32ToString(data), s);
|
||||
});
|
||||
|
||||
describe('StringToUtf32 decoder', () => {
|
||||
@@ -159,7 +159,7 @@ describe('text encodings', () => {
|
||||
const decoder = new StringToUtf32();
|
||||
const target = new Uint32Array(5);
|
||||
const length = decoder.decode(String.fromCharCode(0xFEFF), target);
|
||||
assert.strictEqual(length, 0);
|
||||
assert.equal(length, 0);
|
||||
decoder.clear();
|
||||
});
|
||||
});
|
||||
@@ -169,7 +169,7 @@ describe('text encodings', () => {
|
||||
const target = new Uint32Array(500);
|
||||
for (let i = 0; i < TEST_STRINGS.length; ++i) {
|
||||
const length = decoder.decode(TEST_STRINGS[i], target);
|
||||
assert.strictEqual(toString(target, length), TEST_STRINGS[i]);
|
||||
assert.equal(toString(target, length), TEST_STRINGS[i]);
|
||||
decoder.clear();
|
||||
}
|
||||
});
|
||||
@@ -184,7 +184,7 @@ describe('text encodings', () => {
|
||||
const written = decoder.decode(input[i], target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
|
||||
assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -226,7 +226,7 @@ describe('text encodings', () => {
|
||||
const target = new Uint32Array(5);
|
||||
const utf8Data = stringToUtf8Bytes(String.fromCharCode(0xFEFF));
|
||||
const length = decoder.decode(utf8Data, target);
|
||||
assert.strictEqual(length, 0);
|
||||
assert.equal(length, 0);
|
||||
decoder.clear();
|
||||
});
|
||||
});
|
||||
@@ -237,7 +237,7 @@ describe('text encodings', () => {
|
||||
for (let i = 0; i < TEST_STRINGS.length; ++i) {
|
||||
const utf8Data = stringToUtf8Bytes(TEST_STRINGS[i]);
|
||||
const length = decoder.decode(utf8Data, target);
|
||||
assert.strictEqual(toString(target, length), TEST_STRINGS[i]);
|
||||
assert.equal(toString(target, length), TEST_STRINGS[i]);
|
||||
decoder.clear();
|
||||
}
|
||||
});
|
||||
@@ -252,7 +252,7 @@ describe('text encodings', () => {
|
||||
const written = decoder.decode(utf8Data.slice(i, i + 1), target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert.strictEqual(decoded, 'ÄÖÜßöäü');
|
||||
assert(decoded, 'ÄÖÜßöäü');
|
||||
});
|
||||
|
||||
it('2/3 byte sequences - advance by 1', () => {
|
||||
@@ -264,7 +264,7 @@ describe('text encodings', () => {
|
||||
const written = decoder.decode(utf8Data.slice(i, i + 1), target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert.strictEqual(decoded, 'Āր܀߀ö€ä€ü');
|
||||
assert(decoded, 'Āր܀߀ö€ä€ü');
|
||||
});
|
||||
|
||||
it('2/3/4 byte sequences - advance by 1', () => {
|
||||
@@ -276,7 +276,7 @@ describe('text encodings', () => {
|
||||
const written = decoder.decode(utf8Data.slice(i, i + 1), target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
|
||||
assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
|
||||
});
|
||||
|
||||
it('2/3/4 byte sequences - advance by 2', () => {
|
||||
@@ -288,7 +288,7 @@ describe('text encodings', () => {
|
||||
const written = decoder.decode(utf8Data.slice(i, i + 2), target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
|
||||
assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
|
||||
});
|
||||
|
||||
it('2/3/4 byte sequences - advance by 3', () => {
|
||||
@@ -300,7 +300,7 @@ describe('text encodings', () => {
|
||||
const written = decoder.decode(utf8Data.slice(i, i + 3), target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
|
||||
assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
|
||||
});
|
||||
|
||||
it('BOMs (3 byte sequences) - advance by 2', () => {
|
||||
@@ -312,7 +312,7 @@ describe('text encodings', () => {
|
||||
const written = decoder.decode(utf8Data.slice(i, i + 2), target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert.strictEqual(decoded, '');
|
||||
assert.equal(decoded, '');
|
||||
});
|
||||
|
||||
it('test break after 3 bytes - issue #2495', () => {
|
||||
@@ -320,37 +320,11 @@ describe('text encodings', () => {
|
||||
const target = new Uint32Array(5);
|
||||
const utf8Data = fromByteString('\xf0\xa0\x9c\x8e');
|
||||
let written = decoder.decode(utf8Data.slice(0, 3), target);
|
||||
assert.strictEqual(written, 0);
|
||||
assert.equal(written, 0);
|
||||
written = decoder.decode(utf8Data.slice(3), target);
|
||||
assert.strictEqual(written, 1);
|
||||
assert.strictEqual(toString(target, written), '𠜎');
|
||||
assert.equal(written, 1);
|
||||
assert(toString(target, written), '𠜎');
|
||||
});
|
||||
|
||||
describe('0x80 not swallowed in continuation', () => {
|
||||
it('A—B', () => {
|
||||
const decoder = new Utf8ToUtf32();
|
||||
const target = new Uint32Array(5);
|
||||
const utf8Data = new TextEncoder().encode('A—BA—BA—BA—BA—B');
|
||||
let decoded = '';
|
||||
for (let i = 0; i < utf8Data.length; i += 2) {
|
||||
const written = decoder.decode(utf8Data.slice(i, i + 2), target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert.strictEqual(decoded, 'A—BA—BA—BA—BA—B');
|
||||
});
|
||||
it('A𐀀B', () => {
|
||||
const decoder = new Utf8ToUtf32();
|
||||
const target = new Uint32Array(5);
|
||||
const utf8Data = new TextEncoder().encode('A𐀀BA𐀀BA𐀀BA𐀀BA𐀀B');
|
||||
let decoded = '';
|
||||
for (let i = 0; i < utf8Data.length; i += 2) {
|
||||
const written = decoder.decode(utf8Data.slice(i, i + 2), target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert.strictEqual(decoded, 'A𐀀BA𐀀BA𐀀BA𐀀BA𐀀B');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,9 +158,9 @@ export class Utf8ToUtf32 {
|
||||
cp &= ((((cp & 0xE0) === 0xC0)) ? 0x1F : (((cp & 0xF0) === 0xE0)) ? 0x0F : 0x07);
|
||||
let pos = 0;
|
||||
let tmp: number;
|
||||
while ((tmp = this.interim[++pos]) && pos < 4) {
|
||||
while ((tmp = this.interim[++pos] & 0x3F) && pos < 4) {
|
||||
cp <<= 6;
|
||||
cp |= tmp & 0x3F;
|
||||
cp |= tmp;
|
||||
}
|
||||
// missing bytes - read ahead from input
|
||||
const type = (((this.interim[0] & 0xE0) === 0xC0)) ? 2 : (((this.interim[0] & 0xF0) === 0xE0)) ? 3 : 4;
|
||||
|
||||
@@ -99,6 +99,10 @@ export class Terminal extends CoreTerminal {
|
||||
* Clear the entire buffer, making the prompt line the new first line.
|
||||
*/
|
||||
public clear(): void {
|
||||
if (this.buffer.ybase === 0 && this.buffer.y === 0) {
|
||||
// Don't clear if it's already clear
|
||||
return;
|
||||
}
|
||||
this.buffer.clearAllMarkers();
|
||||
this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)!);
|
||||
this.buffer.lines.length = 1;
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* No `\r\n` is emitted after the final cell: when the flood's last line lands on
|
||||
* the terminal's bottom row a trailing linefeed would scroll the screen (in the
|
||||
* alt buffer this destroys pinned reference rows and makes glyph-integrity
|
||||
* assertions fail for buffer-content reasons rather than rendering reasons).
|
||||
* @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 && i + 1 < cells) { out += '\x1b[0m\r\n'; }
|
||||
}
|
||||
return out + '\x1b[0m';
|
||||
}
|
||||
Reference in New Issue
Block a user