Reset WebGL glyph atlases globally to stop cross-terminal glyph corruption (#5122)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
47927d019e
commit
36ab59d640
|
|
@ -1255,10 +1255,7 @@ export default function TerminalPane({
|
|||
pasteText: (text, options) => {
|
||||
pasteTerminalText(pane.terminal, text, options)
|
||||
if (options?.forceBracketedPaste) {
|
||||
const manager = managerRef.current
|
||||
if (manager) {
|
||||
scheduleImagePasteWebglAtlasRecovery(manager)
|
||||
}
|
||||
scheduleImagePasteWebglAtlasRecovery()
|
||||
}
|
||||
},
|
||||
onImagePasteError: (error) => setTerminalError(formatClipboardImagePasteError(error))
|
||||
|
|
|
|||
|
|
@ -1,8 +1,24 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import {
|
||||
registerLivePaneManager,
|
||||
unregisterLivePaneManager
|
||||
} from '@/lib/pane-manager/pane-manager-registry'
|
||||
import { scheduleImagePasteWebglAtlasRecovery } from './terminal-webgl-paste-recovery'
|
||||
|
||||
describe('terminal image paste WebGL recovery', () => {
|
||||
const registeredManagers: { resetWebglTextureAtlases(): void }[] = []
|
||||
|
||||
function registerManager(): { resetWebglTextureAtlases: Mock<() => void> } {
|
||||
const manager = { resetWebglTextureAtlases: vi.fn<() => void>() }
|
||||
registerLivePaneManager(manager)
|
||||
registeredManagers.push(manager)
|
||||
return manager
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const manager of registeredManagers.splice(0)) {
|
||||
unregisterLivePaneManager(manager)
|
||||
}
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
|
@ -17,13 +33,17 @@ describe('terminal image paste WebGL recovery', () => {
|
|||
return rafCallbacks.length
|
||||
})
|
||||
)
|
||||
const manager = { resetWebglTextureAtlases: vi.fn() }
|
||||
// Why: resets go through the live-manager registry so every terminal
|
||||
// sharing the glyph atlas rebuilds, not just the pasted-into pane.
|
||||
const manager = registerManager()
|
||||
const otherManager = registerManager()
|
||||
|
||||
scheduleImagePasteWebglAtlasRecovery(manager)
|
||||
scheduleImagePasteWebglAtlasRecovery()
|
||||
|
||||
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
|
||||
rafCallbacks[0]?.(0)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
|
||||
expect(otherManager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.advanceTimersByTime(120)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(2)
|
||||
|
|
@ -34,9 +54,9 @@ describe('terminal image paste WebGL recovery', () => {
|
|||
it('falls back to a timeout when animation frames are unavailable', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('requestAnimationFrame', undefined)
|
||||
const manager = { resetWebglTextureAtlases: vi.fn() }
|
||||
const manager = registerManager()
|
||||
|
||||
scheduleImagePasteWebglAtlasRecovery(manager)
|
||||
scheduleImagePasteWebglAtlasRecovery()
|
||||
|
||||
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(0)
|
||||
|
|
@ -57,8 +77,10 @@ describe('terminal image paste WebGL recovery', () => {
|
|||
throw new Error('pane disposed')
|
||||
})
|
||||
}
|
||||
registerLivePaneManager(manager)
|
||||
registeredManagers.push(manager)
|
||||
|
||||
expect(() => scheduleImagePasteWebglAtlasRecovery(manager)).not.toThrow()
|
||||
expect(() => scheduleImagePasteWebglAtlasRecovery()).not.toThrow()
|
||||
expect(() => vi.runAllTimers()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
type TerminalWebglRecoveryManager = {
|
||||
resetWebglTextureAtlases: () => void
|
||||
}
|
||||
import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry'
|
||||
|
||||
const IMAGE_PASTE_ATLAS_RECOVERY_DELAYS_MS = [120, 500]
|
||||
|
||||
|
|
@ -12,20 +10,23 @@ function scheduleNextFrame(callback: () => void): void {
|
|||
globalThis.setTimeout(callback, 0)
|
||||
}
|
||||
|
||||
function resetAtlas(manager: TerminalWebglRecoveryManager): void {
|
||||
function resetAtlases(): void {
|
||||
try {
|
||||
manager.resetWebglTextureAtlases()
|
||||
// Why: the glyph atlas is shared across same-config terminals, so the
|
||||
// recovery reset must rebuild every live terminal's render model — a
|
||||
// single-manager reset would garble the others.
|
||||
resetAllTerminalWebglAtlases()
|
||||
} catch {
|
||||
/* ignore - terminal pane may have unmounted after paste */
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleImagePasteWebglAtlasRecovery(manager: TerminalWebglRecoveryManager): void {
|
||||
export function scheduleImagePasteWebglAtlasRecovery(): void {
|
||||
// Why: Claude Code redraws its image chip immediately after bracketed paste,
|
||||
// and xterm WebGL atlas corruption can appear after that redraw without a
|
||||
// context-loss event. A few cheap resets cover the post-paste paint window.
|
||||
scheduleNextFrame(() => resetAtlas(manager))
|
||||
scheduleNextFrame(() => resetAtlases())
|
||||
for (const delayMs of IMAGE_PASTE_ATLAS_RECOVERY_DELAYS_MS) {
|
||||
globalThis.setTimeout(() => resetAtlas(manager), delayMs)
|
||||
globalThis.setTimeout(() => resetAtlases(), delayMs)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,10 +171,7 @@ export function useTerminalPaneContextMenu({
|
|||
pasteText: (text, options) => {
|
||||
pasteTerminalText(pane.terminal, text, options)
|
||||
if (options?.forceBracketedPaste) {
|
||||
const manager = managerRef.current
|
||||
if (manager) {
|
||||
scheduleImagePasteWebglAtlasRecovery(manager)
|
||||
}
|
||||
scheduleImagePasteWebglAtlasRecovery()
|
||||
}
|
||||
},
|
||||
onImagePasteError: (error) => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
import type * as ReactModule from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal'
|
||||
import {
|
||||
registerLivePaneManager,
|
||||
unregisterLivePaneManager
|
||||
} from '@/lib/pane-manager/pane-manager-registry'
|
||||
import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
|
|
@ -135,6 +139,16 @@ function useMountForFileDrop(
|
|||
}
|
||||
|
||||
describe('useTerminalPaneGlobalEffects', () => {
|
||||
// Why: the live-manager registry is module-global; unregister in afterEach
|
||||
// so a failed assertion cannot leak fake managers into later tests.
|
||||
const registeredManagers: { resetWebglTextureAtlases(): void }[] = []
|
||||
|
||||
function registerManagerForReset<T extends { resetWebglTextureAtlases(): void }>(manager: T): T {
|
||||
registerLivePaneManager(manager)
|
||||
registeredManagers.push(manager)
|
||||
return manager
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetHookRefs()
|
||||
vi.clearAllMocks()
|
||||
|
|
@ -154,6 +168,9 @@ describe('useTerminalPaneGlobalEffects', () => {
|
|||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const manager of registeredManagers.splice(0)) {
|
||||
unregisterLivePaneManager(manager)
|
||||
}
|
||||
delete (globalThis as unknown as { window?: unknown }).window
|
||||
delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver
|
||||
})
|
||||
|
|
@ -189,6 +206,10 @@ describe('useTerminalPaneGlobalEffects', () => {
|
|||
})
|
||||
mocks.fitAndFocusPanes.mockImplementation(() => order.push('fit-focus'))
|
||||
|
||||
// Why: the resume path resets atlases through the live-manager registry
|
||||
// (shared glyph atlas), so the fake manager must be registered to observe
|
||||
// its reset in the ordering assertion.
|
||||
registerManagerForReset(manager)
|
||||
const isActiveRef = { current: false }
|
||||
const isVisibleRef = { current: false }
|
||||
beginHookRender()
|
||||
|
|
@ -328,6 +349,9 @@ describe('useTerminalPaneGlobalEffects', () => {
|
|||
getActivePane: vi.fn(() => null)
|
||||
}
|
||||
|
||||
// Why: focus recovery resets every registered manager (shared glyph
|
||||
// atlas), so the fake manager observes the reset through the registry.
|
||||
registerManagerForReset(manager)
|
||||
beginHookRender()
|
||||
useTerminalPaneGlobalEffects({
|
||||
tabId: 'tab-1',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type PasteTerminalTextDetail
|
||||
} from '@/constants/terminal'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry'
|
||||
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import { handleTerminalFileDrop } from './terminal-drop-handler'
|
||||
|
|
@ -118,7 +119,9 @@ export function useTerminalPaneGlobalEffects({
|
|||
restoreScrollStateAfterLayout(pane.terminal, position)
|
||||
}
|
||||
}
|
||||
manager.resetWebglTextureAtlases()
|
||||
// Why: this clear wipes the glyph atlas shared with other same-config
|
||||
// terminals; the global reset rebuilds their render models too.
|
||||
resetAllTerminalWebglAtlases()
|
||||
})
|
||||
wasVisibleRef.current = true
|
||||
applyPendingFollowOutputRequests()
|
||||
|
|
@ -143,11 +146,13 @@ export function useTerminalPaneGlobalEffects({
|
|||
const onFocus = (): void => {
|
||||
// Why: WebGL atlas corruption does not always raise context loss; window
|
||||
// focus regain is a low-cost recovery point for agent TUI glyph damage.
|
||||
managerRef.current?.resetWebglTextureAtlases()
|
||||
// Reset globally — a per-manager reset clears the shared glyph atlas
|
||||
// under every other visible same-config terminal and garbles it.
|
||||
resetAllTerminalWebglAtlases()
|
||||
}
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [isActive, isVisible, managerRef])
|
||||
}, [isActive, isVisible])
|
||||
|
||||
useEffect(() => {
|
||||
const manager = managerRef.current
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import {
|
||||
registerLivePaneManager,
|
||||
resetAllTerminalWebglAtlases,
|
||||
unregisterLivePaneManager
|
||||
} from './pane-manager-registry'
|
||||
|
||||
describe('pane manager registry', () => {
|
||||
// Why: the registry is module-global; unregister in afterEach so a failed
|
||||
// assertion cannot leak fake managers into later tests.
|
||||
const registeredManagers: { resetWebglTextureAtlases(): void }[] = []
|
||||
|
||||
function registerManager(): { resetWebglTextureAtlases: Mock<() => void> } {
|
||||
const manager = { resetWebglTextureAtlases: vi.fn<() => void>() }
|
||||
registerLivePaneManager(manager)
|
||||
registeredManagers.push(manager)
|
||||
return manager
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const manager of registeredManagers.splice(0)) {
|
||||
unregisterLivePaneManager(manager)
|
||||
}
|
||||
})
|
||||
|
||||
it('resets atlases on every registered manager', () => {
|
||||
const first = registerManager()
|
||||
const second = registerManager()
|
||||
|
||||
resetAllTerminalWebglAtlases()
|
||||
|
||||
expect(first.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
|
||||
expect(second.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops resetting managers after they unregister', () => {
|
||||
const manager = registerManager()
|
||||
unregisterLivePaneManager(manager)
|
||||
|
||||
resetAllTerminalWebglAtlases()
|
||||
|
||||
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
type AtlasResettablePaneManager = {
|
||||
resetWebglTextureAtlases(): void
|
||||
}
|
||||
|
||||
const liveManagers = new Set<AtlasResettablePaneManager>()
|
||||
|
||||
export function registerLivePaneManager(manager: AtlasResettablePaneManager): void {
|
||||
liveManagers.add(manager)
|
||||
}
|
||||
|
||||
export function unregisterLivePaneManager(manager: AtlasResettablePaneManager): void {
|
||||
liveManagers.delete(manager)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the WebGL glyph atlases of every live pane manager, not just one.
|
||||
*
|
||||
* Why: @xterm/addon-webgl keeps a module-global atlas cache, so terminals with
|
||||
* identical font configs share one glyph texture atlas. Clearing it through a
|
||||
* single manager invalidates the cached glyph coordinates of every other
|
||||
* sharing terminal without rebuilding their render models, which paints them
|
||||
* as garbled glyphs. Recovery resets must therefore rebuild all terminals.
|
||||
*/
|
||||
export function resetAllTerminalWebglAtlases(): void {
|
||||
for (const manager of liveManagers) {
|
||||
manager.resetWebglTextureAtlases()
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ import {
|
|||
suspendPaneRendering
|
||||
} from './pane-rendering-control'
|
||||
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry'
|
||||
import { PaneIdentityRegistry } from './pane-identity-registry'
|
||||
import { closeManagedPane, splitManagedPane } from './pane-split-close'
|
||||
import { FIRST_PANE_ID } from '../../../../shared/pane-key'
|
||||
|
|
@ -62,6 +63,9 @@ export class PaneManager {
|
|||
this.root = root
|
||||
this.options = options
|
||||
this.renderingSuspended = options.initialRenderingSuspended === true
|
||||
// Why: atlas recovery must reach every live manager — see
|
||||
// resetAllTerminalWebglAtlases for the shared-atlas rationale.
|
||||
registerLivePaneManager(this)
|
||||
}
|
||||
|
||||
createInitialPane(opts?: { focus?: boolean; leafId?: string }): ManagedPane {
|
||||
|
|
@ -289,6 +293,7 @@ export class PaneManager {
|
|||
|
||||
destroy(): void {
|
||||
this.destroyed = true
|
||||
unregisterLivePaneManager(this)
|
||||
cancelActivePaneDrag(this.dragState)
|
||||
this.cancelPendingPaneReparentFrames()
|
||||
for (const pane of this.panes.values()) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,403 @@
|
|||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import { sendToTerminal, waitForActivePanePtyId } from './helpers/terminal'
|
||||
|
||||
// Why: mirrors FLOATING_TERMINAL_WORKTREE_ID in src/shared/constants.ts.
|
||||
// e2e specs avoid importing renderer/shared modules into the Playwright runner.
|
||||
const FLOATING_WORKTREE_ID = 'global-floating-terminal'
|
||||
const PANEL_SELECTOR = '[data-floating-terminal-panel]'
|
||||
|
||||
// Why: the floating panel toggles via this window event
|
||||
// (src/renderer/src/lib/floating-terminal.ts); dispatching it exercises the
|
||||
// same code path as the status bar button and the keyboard shortcut.
|
||||
const TOGGLE_EVENT = 'orca-toggle-floating-terminal'
|
||||
|
||||
// Why: a silent foreground command blocks the shell so no prompt framework
|
||||
// (e.g. async p10k segments) repaints while screenshots are compared.
|
||||
const SILENT_FOREGROUND_COMMAND = 'node -e "setInterval(() => {}, 1000)"\r'
|
||||
|
||||
// Why: distinct glyph populations per terminal. After a shared-atlas clear the
|
||||
// pages refill in first-use order, so terminals with different content put
|
||||
// different glyphs at the coordinates a stale render model still points to.
|
||||
const WORKSPACE_GLYPH_ROW = 'abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\#@%&*+=~'
|
||||
const FLOATING_GLYPH_ROW = 'ZYXWVUTSRQPONMLKJIHGFEDCBA 9876543210 !?^"\'();:,.|$_-'
|
||||
|
||||
async function dumpFloatingDiagnostics(page: Page, label: string): Promise<void> {
|
||||
const probe = await page.evaluate((worktreeId) => {
|
||||
const state = window.__store?.getState()
|
||||
const tabs = state?.tabsByWorktree?.[worktreeId] ?? []
|
||||
return tabs.map((tab) => ({
|
||||
tabId: tab.id,
|
||||
diagnostics: window.__paneManagers?.get(tab.id)?.getRenderingDiagnostics?.() ?? null
|
||||
}))
|
||||
}, FLOATING_WORKTREE_ID)
|
||||
console.log(`[shared-atlas] ${label}: ${JSON.stringify(probe)}`)
|
||||
}
|
||||
|
||||
async function setSharedAtlasSettings(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
const state = store?.getState()
|
||||
if (!store || !state?.settings) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
store.setState({
|
||||
settings: {
|
||||
...state.settings,
|
||||
floatingTerminalEnabled: true,
|
||||
terminalGpuAcceleration: 'on'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureFloatingTabs(page: Page, count: number): Promise<string[]> {
|
||||
const tabIds = await page.evaluate(
|
||||
({ worktreeId, wanted }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
while ((store.getState().tabsByWorktree[worktreeId] ?? []).length < wanted) {
|
||||
store.getState().createTab(worktreeId, undefined, undefined, { activate: false })
|
||||
}
|
||||
const tabs = store.getState().tabsByWorktree[worktreeId] ?? []
|
||||
store.getState().activateTab(tabs[0].id)
|
||||
return tabs.slice(0, wanted).map((tab) => tab.id)
|
||||
},
|
||||
{ worktreeId: FLOATING_WORKTREE_ID, wanted: count }
|
||||
)
|
||||
// Why: the toggle event listener closes over floatingTerminalEnabled; wait
|
||||
// for the (lazy) panel to mount so React has committed the enabled state
|
||||
// before the toggle event is dispatched, otherwise the event is dropped.
|
||||
await page.waitForFunction(
|
||||
(panelSelector) => Boolean(document.querySelector(panelSelector)),
|
||||
PANEL_SELECTOR,
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
return tabIds
|
||||
}
|
||||
|
||||
async function activateFloatingTab(page: Page, tabId: string): Promise<void> {
|
||||
await page.evaluate((id) => {
|
||||
window.__store?.getState().activateTab(id)
|
||||
}, tabId)
|
||||
}
|
||||
|
||||
async function toggleFloatingPanel(page: Page, open: boolean): Promise<void> {
|
||||
await page.evaluate((eventName) => {
|
||||
window.dispatchEvent(new Event(eventName))
|
||||
}, TOGGLE_EVENT)
|
||||
await (open
|
||||
? expect(page.locator(PANEL_SELECTOR)).toBeVisible()
|
||||
: expect(page.locator(PANEL_SELECTOR)).toBeHidden())
|
||||
}
|
||||
|
||||
async function waitForWebglOnTab(page: Page, tabId: string): Promise<boolean> {
|
||||
// Why: a pane that mounted before the GPU setting landed needs the manager
|
||||
// call too — mirrors forceWebgl in terminal-image-paste-webgl-recovery.spec.
|
||||
await page.evaluate((id) => {
|
||||
window.__paneManagers?.get(id)?.setTerminalGpuAcceleration?.('on')
|
||||
}, tabId)
|
||||
// Why: getPanes()/getActivePane() return a public projection without
|
||||
// webglAddon; getRenderingDiagnostics() is the supported way to observe
|
||||
// whether WebGL is attached.
|
||||
return page
|
||||
.waitForFunction(
|
||||
(id) => {
|
||||
const diagnostics = window.__paneManagers?.get(id)?.getRenderingDiagnostics?.() ?? []
|
||||
return diagnostics.some((diagnostic) => diagnostic.hasWebgl)
|
||||
},
|
||||
tabId,
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
async function waitForPanePtyIdOnTab(page: Page, tabId: string): Promise<string> {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((id) => {
|
||||
const manager = window.__paneManagers?.get(id)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
return pane?.container?.dataset?.ptyId ?? null
|
||||
}, tabId),
|
||||
{ timeout: 15_000, message: `Pane for tab ${tabId} did not receive a PTY binding` }
|
||||
)
|
||||
.not.toBeNull()
|
||||
const ptyId = await page.evaluate((id) => {
|
||||
const manager = window.__paneManagers?.get(id)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
return pane?.container?.dataset?.ptyId ?? null
|
||||
}, tabId)
|
||||
if (!ptyId) {
|
||||
throw new Error(`Pane for tab ${tabId} has no PTY binding`)
|
||||
}
|
||||
return ptyId
|
||||
}
|
||||
|
||||
async function writeStaticContent(
|
||||
page: Page,
|
||||
tabId: string,
|
||||
marker: string,
|
||||
glyphRow: string
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
async ({ id, content }) => {
|
||||
const manager = window.__paneManagers?.get(id)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error(`Pane unavailable for tab ${id}`)
|
||||
}
|
||||
await new Promise<void>((resolve) => pane.terminal.write(content, resolve))
|
||||
},
|
||||
{
|
||||
id: tabId,
|
||||
// Why: clear screen + scrollback and hide the cursor so screenshots are
|
||||
// time-invariant, then render dense mixed glyphs so the shared WebGL
|
||||
// atlas region this terminal depends on is populated. Default-colored
|
||||
// ASCII only: a small glyph population avoids atlas page merges, whose
|
||||
// one-shot clear-model flag would let a stale renderer accidentally
|
||||
// self-heal and mask the corruption this spec reproduces.
|
||||
content: `\x1b[2J\x1b[3J\x1b[H\x1b[?25l${Array.from(
|
||||
{ length: 14 },
|
||||
(_, row) => `${marker} row ${row} | ${glyphRow} |\r\n`
|
||||
).join('')}`
|
||||
}
|
||||
)
|
||||
await page.evaluate(
|
||||
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
)
|
||||
}
|
||||
|
||||
async function refreshTerminalOnTab(page: Page, tabId: string): Promise<void> {
|
||||
// Why: stands in for the steady output stream of a real agent session. The
|
||||
// workspace shell is blocked, so without a repaint trigger the stale-model
|
||||
// corruption would stay latent and the comparison would prove nothing.
|
||||
await page.evaluate((id) => {
|
||||
const manager = window.__paneManagers?.get(id)
|
||||
for (const pane of manager?.getPanes?.() ?? []) {
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
}
|
||||
}, tabId)
|
||||
await page.evaluate(
|
||||
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when both tabs' WebGL renderers draw from the same glyph texture atlas.
|
||||
* @xterm/addon-webgl keeps a module-global atlas cache keyed by font config,
|
||||
* so terminals with identical settings share pages — the precondition for the
|
||||
* cross-terminal corruption this spec reproduces.
|
||||
*/
|
||||
async function tabsShareGlyphAtlas(page: Page, tabIdA: string, tabIdB: string): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
({ a, b }) => {
|
||||
const atlasCanvasOf = (tabId: string): HTMLCanvasElement | null => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
// Why: the public pane projection omits webglAddon; reach the internal
|
||||
// pane map (runtime-visible) to compare addon.textureAtlas identity.
|
||||
const internalPanes = (
|
||||
manager as unknown as
|
||||
| { panes?: Map<number, { webglAddon?: { textureAtlas?: HTMLCanvasElement } | null }> }
|
||||
| undefined
|
||||
)?.panes
|
||||
const pane = internalPanes ? [...internalPanes.values()][0] : undefined
|
||||
return pane?.webglAddon?.textureAtlas ?? null
|
||||
}
|
||||
const atlasA = atlasCanvasOf(a)
|
||||
return Boolean(atlasA) && atlasA === atlasCanvasOf(b)
|
||||
},
|
||||
{ a: tabIdA, b: tabIdB }
|
||||
)
|
||||
}
|
||||
|
||||
async function resetAtlasOnTab(page: Page, tabId: string): Promise<void> {
|
||||
await page.evaluate((id) => {
|
||||
window.__paneManagers?.get(id)?.resetWebglTextureAtlases?.()
|
||||
}, tabId)
|
||||
}
|
||||
|
||||
function workspaceScreenLocator(page: Page, ptyId: string): ReturnType<Page['locator']> {
|
||||
return page.locator(`[data-pty-id="${ptyId}"] .xterm-screen`).first()
|
||||
}
|
||||
|
||||
async function screenshotWorkspaceTerminal(page: Page, ptyId: string): Promise<Buffer> {
|
||||
const screen = workspaceScreenLocator(page, ptyId)
|
||||
await expect(screen).toBeVisible()
|
||||
return screen.screenshot({ animations: 'disabled' })
|
||||
}
|
||||
|
||||
async function captureStableWorkspaceShot(page: Page, ptyId: string): Promise<Buffer> {
|
||||
// Why: two consecutive identical captures prove the surface is byte-stable
|
||||
// before screenshot-equality comparisons begin.
|
||||
let previous = await screenshotWorkspaceTerminal(page, ptyId)
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
await page.waitForTimeout(250)
|
||||
const next = await screenshotWorkspaceTerminal(page, ptyId)
|
||||
if (next.equals(previous)) {
|
||||
return next
|
||||
}
|
||||
previous = next
|
||||
}
|
||||
throw new Error('Workspace terminal surface did not stabilize for a screenshot')
|
||||
}
|
||||
|
||||
async function settleAtlasActivity(page: Page): Promise<void> {
|
||||
// Why: atlas warm-up re-rasterization runs in idle callbacks and scheduled
|
||||
// recovery resets fire up to 500ms after their trigger; wait past both.
|
||||
await page.waitForTimeout(800)
|
||||
}
|
||||
|
||||
type SharedAtlasScenario = {
|
||||
workspaceTabId: string
|
||||
workspacePtyId: string
|
||||
floatingTabIds: string[]
|
||||
baseline: Buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage: a visible workspace terminal with stable static content, plus two
|
||||
* floating workspace terminal tabs whose WebGL renderers share its glyph
|
||||
* atlas. Returns a workspace baseline screenshot taken with the panel closed
|
||||
* (the panel can overlap the workspace terminal region).
|
||||
*/
|
||||
async function setUpSharedAtlasScenario(page: Page): Promise<SharedAtlasScenario | null> {
|
||||
await waitForSessionReady(page)
|
||||
await waitForActiveWorktree(page)
|
||||
await ensureTerminalVisible(page)
|
||||
await setSharedAtlasSettings(page)
|
||||
|
||||
const workspaceTabId = await page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
return state?.activeTabId ?? null
|
||||
})
|
||||
if (!workspaceTabId) {
|
||||
return null
|
||||
}
|
||||
const workspacePtyId = await waitForActivePanePtyId(page)
|
||||
if (!(await waitForWebglOnTab(page, workspaceTabId))) {
|
||||
console.log('[shared-atlas] workspace terminal never attached WebGL')
|
||||
return null
|
||||
}
|
||||
await sendToTerminal(page, workspacePtyId, SILENT_FOREGROUND_COMMAND)
|
||||
// Why: give the shell a beat to echo the command and start blocking before
|
||||
// the screen is cleared; later captures verify stability explicitly.
|
||||
await page.waitForTimeout(1_000)
|
||||
await writeStaticContent(page, workspaceTabId, 'WORKSPACE', WORKSPACE_GLYPH_ROW)
|
||||
|
||||
const floatingTabIds = await ensureFloatingTabs(page, 2)
|
||||
await toggleFloatingPanel(page, true)
|
||||
if (!(await waitForWebglOnTab(page, floatingTabIds[0]))) {
|
||||
await dumpFloatingDiagnostics(page, 'active floating tab never attached WebGL')
|
||||
return null
|
||||
}
|
||||
for (const tabId of floatingTabIds) {
|
||||
const ptyId = await waitForPanePtyIdOnTab(page, tabId)
|
||||
await sendToTerminal(page, ptyId, SILENT_FOREGROUND_COMMAND)
|
||||
}
|
||||
await page.waitForTimeout(1_000)
|
||||
// Why: the hidden second tab accepts writes too — its buffer paints on
|
||||
// resume, refilling the cleared shared atlas with a different glyph layout.
|
||||
for (const tabId of floatingTabIds) {
|
||||
await writeStaticContent(page, tabId, 'FLOATING', FLOATING_GLYPH_ROW)
|
||||
}
|
||||
|
||||
if (!(await tabsShareGlyphAtlas(page, workspaceTabId, floatingTabIds[0]))) {
|
||||
console.log('[shared-atlas] workspace and floating terminals do not share an atlas')
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: glyphs rasterized during startup can predate web font readiness; a
|
||||
// clean rebuild here makes the baseline byte-identical to any later
|
||||
// re-rasterization, so equality is a sound "intact" oracle.
|
||||
await resetAtlasOnTab(page, workspaceTabId)
|
||||
await settleAtlasActivity(page)
|
||||
|
||||
// Why: the panel overlay can cover the workspace terminal region, so all
|
||||
// workspace screenshots are taken with the panel closed. Closing only
|
||||
// suspends the floating renderer; it never mutates the shared atlas.
|
||||
await toggleFloatingPanel(page, false)
|
||||
// Why: closing the panel can refit the workspace terminal; rebuild once more
|
||||
// so the baseline model/atlas state matches the post-trigger capture path.
|
||||
await resetAtlasOnTab(page, workspaceTabId)
|
||||
const baseline = await captureStableWorkspaceShot(page, workspacePtyId)
|
||||
|
||||
return { workspaceTabId, workspacePtyId, floatingTabIds, baseline }
|
||||
}
|
||||
|
||||
async function captureWorkspaceAfterTrigger(
|
||||
page: Page,
|
||||
scenario: SharedAtlasScenario
|
||||
): Promise<Buffer> {
|
||||
await settleAtlasActivity(page)
|
||||
// Why: a real agent session repaints continuously; the blocked test shell
|
||||
// does not, so force the equivalent full repaint before comparing.
|
||||
await refreshTerminalOnTab(page, scenario.workspaceTabId)
|
||||
return captureStableWorkspaceShot(page, scenario.workspacePtyId)
|
||||
}
|
||||
|
||||
test.describe('floating workspace shared glyph atlas @headful', () => {
|
||||
test('switching floating workspace tabs keeps workspace terminal glyphs intact', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
// Why: xterm WebGL terminals with identical font configs share one glyph
|
||||
// texture atlas. The floating tab switch resumes a hidden renderer, whose
|
||||
// atlas reset clears those shared pages; unless every sharing terminal
|
||||
// rebuilds its render model too, the visible workspace terminal keeps
|
||||
// stale glyph coordinates and paints garbage (the bug this guards).
|
||||
const scenario = await setUpSharedAtlasScenario(orcaPage)
|
||||
test.skip(!scenario, 'WebGL inactive or terminals do not share a glyph atlas')
|
||||
const { baseline, floatingTabIds } = scenario!
|
||||
|
||||
await toggleFloatingPanel(orcaPage, true)
|
||||
await activateFloatingTab(orcaPage, floatingTabIds[1])
|
||||
// Why: the switched-to tab attaching WebGL proves the suspend/resume
|
||||
// (and with it the atlas reset trigger) actually ran.
|
||||
expect(
|
||||
await waitForWebglOnTab(orcaPage, floatingTabIds[1]),
|
||||
'switched-to floating tab should resume WebGL'
|
||||
).toBe(true)
|
||||
await settleAtlasActivity(orcaPage)
|
||||
await toggleFloatingPanel(orcaPage, false)
|
||||
|
||||
const afterSwitch = await captureWorkspaceAfterTrigger(orcaPage, scenario!)
|
||||
await testInfo.attach('baseline', { body: baseline, contentType: 'image/png' })
|
||||
await testInfo.attach('after-tab-switch', { body: afterSwitch, contentType: 'image/png' })
|
||||
console.log(`[shared-atlas] tabSwitchIntact=${afterSwitch.equals(baseline)}`)
|
||||
|
||||
expect(
|
||||
afterSwitch.equals(baseline),
|
||||
'workspace terminal must render identically after floating tab switching'
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('reopening the floating workspace keeps workspace terminal glyphs intact', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
// Why: reopening the panel resumes its terminal, whose atlas reset clears
|
||||
// the shared pages just like a tab switch — the other user flow that
|
||||
// garbled visible workspace terminals before resets went global.
|
||||
const scenario = await setUpSharedAtlasScenario(orcaPage)
|
||||
test.skip(!scenario, 'WebGL inactive or terminals do not share a glyph atlas')
|
||||
const { baseline } = scenario!
|
||||
|
||||
await toggleFloatingPanel(orcaPage, true)
|
||||
await settleAtlasActivity(orcaPage)
|
||||
await toggleFloatingPanel(orcaPage, false)
|
||||
|
||||
const afterReopen = await captureWorkspaceAfterTrigger(orcaPage, scenario!)
|
||||
await testInfo.attach('baseline', { body: baseline, contentType: 'image/png' })
|
||||
await testInfo.attach('after-reopen', { body: afterReopen, contentType: 'image/png' })
|
||||
console.log(`[shared-atlas] reopenIntact=${afterReopen.equals(baseline)}`)
|
||||
|
||||
expect(
|
||||
afterReopen.equals(baseline),
|
||||
'workspace terminal must render identically after a floating panel reopen'
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue