Reset WebGL glyph atlases globally to stop cross-terminal glyph corruption (#5122)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-10 13:41:05 -07:00 committed by GitHub
parent 47927d019e
commit 36ab59d640
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 551 additions and 25 deletions

View File

@ -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))

View File

@ -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()
})
})

View File

@ -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)
}
}

View File

@ -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) => {

View File

@ -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',

View File

@ -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

View File

@ -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()
})
})

View File

@ -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()
}
}

View File

@ -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()) {

View File

@ -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)
})
})