diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index da130481f..14888bd02 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -2,6 +2,7 @@ * React/store environment directly so close and bootstrap behavior can be * asserted without mounting the full Electron renderer. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RESET_TERMINAL_WEBGL_ATLAS_EVENT } from '@/constants/terminal' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { BrowserTab, Tab, TabGroup, TerminalTab } from '../../../../shared/types' import type { OpenFile } from '@/store/slices/editor' @@ -593,6 +594,9 @@ describe('FloatingTerminalPanel close behavior', () => { } vi.stubGlobal('window', { addEventListener: vi.fn(), + // Why: the open-transition effect dispatches the WebGL atlas recovery + // event; the stubbed window needs a sink for it. + dispatchEvent: vi.fn(), api: { app: { getFloatingMarkdownDirectory: mocks.getFloatingMarkdownDirectory, @@ -952,6 +956,32 @@ describe('FloatingTerminalPanel close behavior', () => { expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('created-tab') }) + it('dispatches WebGL atlas recovery for the active terminal on reopen', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + + await renderPanel(false) + runEffects() + vi.mocked(window.dispatchEvent).mockClear() + + const countResetEvents = (): CustomEvent<{ tabId?: string }>[] => + vi + .mocked(window.dispatchEvent) + .mock.calls.map(([event]) => event as CustomEvent<{ tabId?: string }>) + .filter((event) => event.type === RESET_TERMINAL_WEBGL_ATLAS_EVENT) + + await renderPanel(true) + runEffects() + + const resetEvents = countResetEvents() + expect(resetEvents).toHaveLength(1) + expect(resetEvents[0]?.detail?.tabId).toBe('tab-1') + + // Why: re-renders while the panel stays open must not re-clear the atlas. + await renderPanel(true) + runEffects() + expect(countResetEvents()).toHaveLength(1) + }) + it('routes titlebar Cmd+T to the floating workspace', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) const element = await renderPanel(true) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 06c5566ef..287e5f761 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -19,6 +19,10 @@ import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import TabBar from '@/components/tab-bar/TabBar' import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' import TerminalPane from '@/components/terminal-pane/TerminalPane' +import { + RESET_TERMINAL_WEBGL_ATLAS_EVENT, + type ResetTerminalWebglAtlasDetail +} from '@/constants/terminal' import { Button } from '@/components/ui/button' import { useMountedRef } from '@/hooks/useMountedRef' import { useShortcutKeys } from '@/hooks/useShortcutLabel' @@ -528,6 +532,31 @@ export function FloatingTerminalPanel({ focusTerminalTabSurface(activeTerminalId) }, [activeTerminalId, open]) + const wasOpenForAtlasRecoveryRef = useRef(false) + useEffect(() => { + if (!open) { + wasOpenForAtlasRecoveryRef.current = false + return + } + if (wasOpenForAtlasRecoveryRef.current) { + return + } + wasOpenForAtlasRecoveryRef.current = true + if (!activeTerminalId) { + return + } + // Why: closing the panel only hides it with CSS, so the active terminal + // keeps a live WebGL context while hidden and reopening never flips + // TerminalPane visibility. A glyph atlas corrupted while hidden (no + // context-loss event) would otherwise stay garbled until another + // recovery trigger such as window refocus. + window.dispatchEvent( + new CustomEvent(RESET_TERMINAL_WEBGL_ATLAS_EVENT, { + detail: { tabId: activeTerminalId } + }) + ) + }, [activeTerminalId, open]) + useEffect(() => { if (!open || hasVisibleFloatingTabs) { return @@ -653,7 +682,10 @@ export function FloatingTerminalPanel({ return } createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, url, { - title: translate("auto.components.floating.terminal.FloatingTerminalPanel.8b14ba6c17", "New Browser Tab"), + title: translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.8b14ba6c17', + 'New Browser Tab' + ), focusAddressBar: true, targetGroupId: activeGroup?.id }) @@ -1386,7 +1418,11 @@ export function FloatingTerminalPanel({ - {translate("auto.components.floating.terminal.FloatingTerminalPanel.d6b563ae24", "Loading editor...")} + {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.d6b563ae24', + 'Loading editor...' + )} + } > {/* Why: floating workspace markdown is scratch/local context, @@ -1416,16 +1452,25 @@ export function FloatingTerminalPanel({ ) : null} - {showOrchestrationSetup && activeTabType === "terminal" ? ( + {showOrchestrationSetup && activeTabType === 'terminal' ? (
-

{translate("auto.components.floating.terminal.FloatingTerminalPanel.2a3c5ddf5e", "Enable orchestration")}

+

+ {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.2a3c5ddf5e', + 'Enable orchestration' + )} +

- {translate("auto.components.floating.terminal.FloatingTerminalPanel.8cf80db43b", "Set up the Orca CLI and agent skill so agents can coordinate through Orca.")}

+ {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.8cf80db43b', + 'Set up the Orca CLI and agent skill so agents can coordinate through Orca.' + )} +

+ {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.adc281394d', + 'Dismiss' + )} + + {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.bbc177f98f', + 'Enable' + )} +
@@ -1470,11 +1523,23 @@ export function FloatingTerminalPanel({ > - {translate("auto.components.floating.terminal.FloatingTerminalPanel.690b6fb98a", "Unsaved Changes")} + + {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.690b6fb98a', + 'Unsaved Changes' + )} + {saveDialogFile - ? translate("auto.components.floating.terminal.FloatingTerminalPanel.5ddc688c52", "\"{{value0}}\" has unsaved changes. Do you want to save before closing?", { value0: saveDialogFile.relativePath.split('/').pop() }) - : translate("auto.components.floating.terminal.FloatingTerminalPanel.b085fb58b5", "This file has unsaved changes.")} + ? translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.5ddc688c52', + '"{{value0}}" has unsaved changes. Do you want to save before closing?', + { value0: saveDialogFile.relativePath.split('/').pop() } + ) + : translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.b085fb58b5', + 'This file has unsaved changes.' + )} @@ -1484,16 +1549,28 @@ export function FloatingTerminalPanel({ size="sm" onClick={handleFloatingSaveDialogCancel} > - {translate("auto.components.floating.terminal.FloatingTerminalPanel.e7bf09d4d4", "Cancel")} + {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.e7bf09d4d4', + 'Cancel' + )} + + {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.918c2139f3', + "Don't Save" + )} + + {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.da508bd7f5', + 'Save' + )} + @@ -1541,7 +1618,12 @@ function FloatingTerminalEmptyState({ onClick={onNewTerminal} > - {translate("auto.components.floating.terminal.FloatingTerminalPanel.3215fc73e9", "New Terminal")} + + {translate( + 'auto.components.floating.terminal.FloatingTerminalPanel.3215fc73e9', + 'New Terminal' + )} + diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 245165b5b..1ab57576c 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: these hook tests share a mocked React lifecycle harness with global event cases. */ import type * as ReactModule from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal' +import { RESET_TERMINAL_WEBGL_ATLAS_EVENT, SYNC_FIT_PANES_EVENT } from '@/constants/terminal' import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects' const mocks = vi.hoisted(() => ({ @@ -359,6 +359,42 @@ describe('useTerminalPaneGlobalEffects', () => { expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) }) + it('clears WebGL texture atlases for a matching reset-atlas event', () => { + const { manager } = useMountForFileDrop() + + const resetListener = vi + .mocked(window.addEventListener) + .mock.calls.find(([eventName]) => eventName === RESET_TERMINAL_WEBGL_ATLAS_EVENT) + + expect(resetListener).toBeDefined() + const listener = resetListener?.[1] + if (typeof listener !== 'function') { + throw new Error('expected reset-atlas listener') + } + manager.resetWebglTextureAtlases.mockClear() + listener(new CustomEvent(RESET_TERMINAL_WEBGL_ATLAS_EVENT, { detail: { tabId: 'tab-1' } })) + + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + }) + + it('ignores reset-atlas events for another terminal tab', () => { + const { manager } = useMountForFileDrop() + + const resetListener = vi + .mocked(window.addEventListener) + .mock.calls.find(([eventName]) => eventName === RESET_TERMINAL_WEBGL_ATLAS_EVENT) + + expect(resetListener).toBeDefined() + const listener = resetListener?.[1] + if (typeof listener !== 'function') { + throw new Error('expected reset-atlas listener') + } + manager.resetWebglTextureAtlases.mockClear() + listener(new CustomEvent(RESET_TERMINAL_WEBGL_ATLAS_EVENT, { detail: { tabId: 'tab-2' } })) + + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + }) + it('ignores terminal file drops for another terminal tab', () => { const { onFileDrop } = useMountForFileDrop() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 902004d5a..9e7ff8130 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -2,9 +2,11 @@ import { useEffect, useRef } from 'react' import { FOCUS_TERMINAL_PANE_EVENT, PASTE_TERMINAL_TEXT_EVENT, + RESET_TERMINAL_WEBGL_ATLAS_EVENT, TOGGLE_TERMINAL_PANE_EXPAND_EVENT, type FocusTerminalPaneDetail, - type PasteTerminalTextDetail + type PasteTerminalTextDetail, + type ResetTerminalWebglAtlasDetail } from '@/constants/terminal' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { fitAndFocusPanes, fitPanes } from './pane-helpers' @@ -204,6 +206,21 @@ export function useTerminalPaneGlobalEffects({ return () => window.removeEventListener(FOCUS_TERMINAL_PANE_EVENT, onFocusPane) }, [tabId, managerRef, scheduleFollowOutputIfNeeded]) + useEffect(() => { + const onResetAtlas = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (!detail?.tabId || detail.tabId !== tabId) { + return + } + // Why: WebGL atlas corruption can occur with no context-loss event while + // a CSS-hidden surface (floating workspace) holds a live context; the + // owning surface dispatches this when it becomes visible again. + managerRef.current?.resetWebglTextureAtlases() + } + window.addEventListener(RESET_TERMINAL_WEBGL_ATLAS_EVENT, onResetAtlas) + return () => window.removeEventListener(RESET_TERMINAL_WEBGL_ATLAS_EVENT, onResetAtlas) + }, [tabId, managerRef]) + useEffect(() => { const onPasteText = (event: Event): void => { const detail = (event as CustomEvent).detail diff --git a/src/renderer/src/constants/terminal.ts b/src/renderer/src/constants/terminal.ts index b59e0be00..1a281ec9a 100644 --- a/src/renderer/src/constants/terminal.ts +++ b/src/renderer/src/constants/terminal.ts @@ -7,6 +7,10 @@ export const SPLIT_TERMINAL_PANE_EVENT = 'orca-split-terminal-pane' export const REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT = 'orca-request-active-terminal-pane-split' export const CLOSE_TERMINAL_PANE_EVENT = 'orca-close-terminal-pane' export const BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT = 'orca-background-mount-terminal-worktree' +// Why: surfaces that show/hide terminals with CSS only (floating workspace) +// never flip TerminalPane visibility, so they need an explicit trigger for +// WebGL glyph-atlas recovery when they become visible again. +export const RESET_TERMINAL_WEBGL_ATLAS_EVENT = 'orca-reset-terminal-webgl-atlas' // Why: sidebar open/close is an instantaneous width change. If we wait for // the ResizeObserver rAF (and the 150ms debounced global fit) to catch up, @@ -42,6 +46,10 @@ export type PasteTerminalTextDetail = { text: string } +export type ResetTerminalWebglAtlasDetail = { + tabId: string +} + export type SplitTerminalPaneDetail = { tabId: string paneRuntimeId: number diff --git a/tests/e2e/floating-workspace-reopen-webgl-recovery.spec.ts b/tests/e2e/floating-workspace-reopen-webgl-recovery.spec.ts new file mode 100644 index 000000000..f5670c4f2 --- /dev/null +++ b/tests/e2e/floating-workspace-reopen-webgl-recovery.spec.ts @@ -0,0 +1,443 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { sendToTerminal } 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: matches the local-cast pattern used by terminal-image-paste-webgl-recovery.spec; +// a global Window augmentation would leak into every spec in the suite. +type RecoveryCounterWindow = typeof window & { + __floatingManagerResets?: number + __floatingRenderResumes?: number +} + +async function enableFloatingWorkspaceWithWebgl(page: Page): Promise { + await page.evaluate((worktreeId) => { + 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' + } + }) + const tabs = store.getState().tabsByWorktree[worktreeId] ?? [] + if (tabs.length === 0) { + const tab = store.getState().createTab(worktreeId, undefined, undefined, { + activate: false + }) + store.getState().activateTab(tab.id) + } + }, FLOATING_WORKTREE_ID) + // 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 } + ) +} + +async function waitForFloatingPanePtyId(page: Page): Promise { + await expect + .poll( + () => + page.evaluate((worktreeId) => { + const state = window.__store?.getState() + const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0] + const manager = tab ? window.__paneManagers?.get(tab.id) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container?.dataset?.ptyId ?? null + }, FLOATING_WORKTREE_ID), + { + timeout: 15_000, + message: 'Floating terminal pane did not receive a PTY binding' + } + ) + .not.toBeNull() + const ptyId = await page.evaluate((worktreeId) => { + const state = window.__store?.getState() + const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0] + const manager = tab ? window.__paneManagers?.get(tab.id) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container?.dataset?.ptyId ?? null + }, FLOATING_WORKTREE_ID) + if (!ptyId) { + throw new Error('Floating terminal pane has no PTY binding') + } + return ptyId +} + +async function toggleFloatingPanel(page: Page, open: boolean): Promise { + 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 waitForFloatingWebglPane(page: Page): Promise { + // 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 + .waitForFunction( + (worktreeId) => { + const state = window.__store?.getState() + const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0] + const manager = tab ? window.__paneManagers?.get(tab.id) : null + return Boolean(manager?.getActivePane?.() ?? manager?.getPanes?.()[0]) + }, + FLOATING_WORKTREE_ID, + { timeout: 15_000 } + ) + .catch(() => undefined) + await page.evaluate((worktreeId) => { + const state = window.__store?.getState() + const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0] + const manager = tab ? window.__paneManagers?.get(tab.id) : null + manager?.setTerminalGpuAcceleration?.('on') + }, FLOATING_WORKTREE_ID) + // Why: getPanes()/getActivePane() return a public projection without + // webglAddon; getRenderingDiagnostics() is the supported way to observe + // whether WebGL is attached. + const attached = await page + .waitForFunction( + (worktreeId) => { + const state = window.__store?.getState() + const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0] + const manager = tab ? window.__paneManagers?.get(tab.id) : null + const diagnostics = manager?.getRenderingDiagnostics?.() ?? [] + return diagnostics.some((diagnostic) => diagnostic.hasWebgl) + }, + FLOATING_WORKTREE_ID, + { timeout: 10_000 } + ) + .then(() => true) + .catch(() => false) + if (!attached) { + const probe = await page.evaluate((worktreeId) => { + const state = window.__store?.getState() + const tabs = state?.tabsByWorktree?.[worktreeId] ?? [] + const tab = tabs[0] + const manager = tab ? window.__paneManagers?.get(tab.id) : null + return { + tabCount: tabs.length, + hasManager: Boolean(manager), + diagnostics: manager?.getRenderingDiagnostics?.() ?? null, + gpuSetting: state?.settings?.terminalGpuAcceleration ?? null + } + }, FLOATING_WORKTREE_ID) + console.log(`[floating-harness] webgl attach failed: ${JSON.stringify(probe)}`) + } + return attached +} + +async function writeStaticContent(page: Page, marker: string): Promise { + await page.evaluate( + async ({ worktreeId, content }) => { + const state = window.__store?.getState() + const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0] + const manager = tab ? window.__paneManagers?.get(tab.id) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error('Floating pane unavailable') + } + await new Promise((resolve) => pane.terminal.write(content, resolve)) + }, + { + worktreeId: FLOATING_WORKTREE_ID, + // Why: clear screen + scrollback and hide the cursor so screenshots are + // time-invariant, then render dense mixed glyphs so the WebGL atlas + // origin region is populated. + content: `\x1b[2J\x1b[3J\x1b[H\x1b[?25l${Array.from( + { length: 14 }, + (_, row) => + `${marker} row ${row} | abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\#@%&*+=~ |\r\n` + ).join('')}` + } + ) + // Why: let xterm's renderer paint the new content before baseline capture. + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + ) +} + +/** + * Corrupts the live glyph-atlas textures of the floating terminal's WebGL + * context by overwriting texels in every bound TEXTURE_2D, without raising a + * context-loss event. This simulates the in-the-wild Chromium failure that + * #5042 documents ("rapid TUI redraws can corrupt xterm's WebGL glyph atlas + * without a context-loss event") so recovery triggers can be tested + * deterministically. + */ +async function corruptFloatingAtlas(page: Page): Promise { + return page.evaluate( + ({ worktreeId, panelSelector }) => { + const state = window.__store?.getState() + const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0] + const manager = tab ? window.__paneManagers?.get(tab.id) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + return 0 + } + const panel = document.querySelector(panelSelector) + const canvases = panel ? Array.from(panel.querySelectorAll('canvas')) : [] + const noise = new Uint8Array(64 * 64 * 4) + for (let i = 0; i < noise.length; i += 4) { + noise[i] = (i * 7) % 256 + noise[i + 1] = (i * 13) % 256 + noise[i + 2] = (i * 29) % 256 + noise[i + 3] = 255 + } + let corrupted = 0 + for (const canvas of canvases) { + const gl = + (canvas.getContext('webgl2') as WebGL2RenderingContext | null) ?? + (canvas.getContext('webgl') as WebGLRenderingContext | null) + if (!gl) { + continue + } + const maxUnits = gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS) as number + for (let unit = 0; unit < maxUnits; unit += 1) { + gl.activeTexture(gl.TEXTURE0 + unit) + const bound = gl.getParameter(gl.TEXTURE_BINDING_2D) + if (!bound) { + continue + } + // Why: glyphs rasterize from the atlas origin outward, so noise + // tiles across the top-left region garble the visible text. + for (const [x, y] of [ + [0, 0], + [64, 0], + [128, 0], + [192, 0], + [0, 64], + [64, 64], + [128, 64], + [192, 64] + ]) { + gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, noise) + if (gl.getError() === gl.NO_ERROR) { + corrupted += 1 + } + } + } + gl.activeTexture(gl.TEXTURE0) + } + pane.terminal.refresh(0, pane.terminal.rows - 1) + return corrupted + }, + { worktreeId: FLOATING_WORKTREE_ID, panelSelector: PANEL_SELECTOR } + ) +} + +async function instrumentRecoveryCounters(page: Page): Promise { + return page.evaluate((worktreeId) => { + const state = window.__store?.getState() + const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0] + const manager = tab ? window.__paneManagers?.get(tab.id) : null + if (!manager?.resetWebglTextureAtlases || !manager.resumeRendering) { + return false + } + const counterWindow = window as RecoveryCounterWindow + counterWindow.__floatingManagerResets = 0 + counterWindow.__floatingRenderResumes = 0 + const originalReset = manager.resetWebglTextureAtlases.bind(manager) + manager.resetWebglTextureAtlases = () => { + counterWindow.__floatingManagerResets = (counterWindow.__floatingManagerResets ?? 0) + 1 + originalReset() + } + // Why: a suspend/resume cycle also rebuilds the atlas; count it so any + // future fix routed through resumeRendering() is recognized as recovery. + const originalResume = manager.resumeRendering.bind(manager) + manager.resumeRendering = () => { + counterWindow.__floatingRenderResumes = (counterWindow.__floatingRenderResumes ?? 0) + 1 + originalResume() + } + return true + }, FLOATING_WORKTREE_ID) +} + +async function readRecoveryCounters( + page: Page +): Promise<{ managerResets: number; renderResumes: number }> { + return page.evaluate(() => { + const counterWindow = window as RecoveryCounterWindow + return { + managerResets: counterWindow.__floatingManagerResets ?? 0, + renderResumes: counterWindow.__floatingRenderResumes ?? 0 + } + }) +} + +async function screenshotFloatingTerminal(page: Page): Promise { + const screen = page.locator(`${PANEL_SELECTOR} .xterm-screen`).first() + await expect(screen).toBeVisible() + return screen.screenshot({ animations: 'disabled' }) +} + +async function settleRecoveryWindows(page: Page): Promise { + // Why: #5042-style recovery schedules resets up to 500ms after its trigger; + // waiting past that window keeps "no recovery fired" assertions honest. + await page.waitForTimeout(800) +} + +async function captureStableBaseline(page: Page): Promise { + // Why: shell startup output can still be painting when content lands; two + // consecutive identical captures prove the surface is byte-stable before + // corruption comparisons begin. + let previous = await screenshotFloatingTerminal(page) + for (let attempt = 0; attempt < 10; attempt += 1) { + await page.waitForTimeout(250) + const next = await screenshotFloatingTerminal(page) + if (next.equals(previous)) { + return next + } + previous = next + } + throw new Error('Floating terminal surface did not stabilize for a baseline screenshot') +} + +async function setUpCorruptedFloatingTerminal( + page: Page, + marker: string +): Promise<{ baseline: Buffer; corrupted: Buffer } | null> { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await enableFloatingWorkspaceWithWebgl(page) + await toggleFloatingPanel(page, true) + if (!(await waitForFloatingWebglPane(page))) { + return null + } + const ptyId = await waitForFloatingPanePtyId(page) + await sendToTerminal(page, ptyId, 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, marker) + // Why: glyphs rasterized during startup can predate web font readiness; a + // clean atlas rebuild here makes the baseline byte-identical to any later + // recovery re-rasterization, so equality is a sound "healed" oracle. + await page.evaluate((worktreeId) => { + const state = window.__store?.getState() + const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0] + const manager = tab ? window.__paneManagers?.get(tab.id) : null + manager?.resetWebglTextureAtlases?.() + }, FLOATING_WORKTREE_ID) + const baseline = await captureStableBaseline(page) + const corruptedTiles = await corruptFloatingAtlas(page) + console.log(`[floating-harness] corrupted atlas tiles: ${corruptedTiles}`) + if (corruptedTiles === 0) { + return null + } + // Why: xterm paints on the next animation frame after refresh(); poll until + // the injected noise is actually visible so later "still corrupted" and + // "healed" comparisons are meaningful. Skip if the noise landed outside the + // atlas region glyphs are drawn from. + for (let attempt = 0; attempt < 8; attempt += 1) { + await page.waitForTimeout(250) + const shot = await screenshotFloatingTerminal(page) + if (!shot.equals(baseline)) { + return { baseline, corrupted: shot } + } + } + console.log('[floating-harness] injected atlas noise never became visible') + return null +} + +test.describe('floating workspace reopen WebGL recovery @headful', () => { + test('reopening the floating workspace recovers a corrupted glyph atlas', async ({ + orcaPage + }, testInfo) => { + // Why: the floating panel hides via CSS visibility only — its terminal + // keeps isVisible=true and a live WebGL context while hidden, so the + // visibility-resume/window-focus/paste recovery triggers never run on + // reopen. The panel dispatches RESET_TERMINAL_WEBGL_ATLAS_EVENT when it + // opens; this guards that a glyph atlas corrupted while hidden repaints. + const shots = await setUpCorruptedFloatingTerminal(orcaPage, 'REOPEN') + test.skip(!shots, 'WebGL was not active or atlas corruption could not be injected') + const { baseline, corrupted } = shots! + expect(corrupted.equals(baseline)).toBe(false) + + expect(await instrumentRecoveryCounters(orcaPage)).toBe(true) + + await toggleFloatingPanel(orcaPage, false) + // Why: the bug's precondition — the hidden floating terminal keeps its + // WebGL addon attached because closing the panel never suspends rendering. + const webglStillAttached = await orcaPage.evaluate((worktreeId) => { + const state = window.__store?.getState() + const tab = (state?.tabsByWorktree?.[worktreeId] ?? [])[0] + const manager = tab ? window.__paneManagers?.get(tab.id) : null + const diagnostics = manager?.getRenderingDiagnostics?.() ?? [] + return diagnostics.some((diagnostic) => diagnostic.hasWebgl) + }, FLOATING_WORKTREE_ID) + expect(webglStillAttached).toBe(true) + + await toggleFloatingPanel(orcaPage, true) + await settleRecoveryWindows(orcaPage) + + const counters = await readRecoveryCounters(orcaPage) + const afterReopen = await screenshotFloatingTerminal(orcaPage) + await testInfo.attach('baseline', { + body: baseline, + contentType: 'image/png' + }) + await testInfo.attach('corrupted', { + body: corrupted, + contentType: 'image/png' + }) + await testInfo.attach('after-reopen', { + body: afterReopen, + contentType: 'image/png' + }) + console.log( + `[floating-reopen] managerResets=${counters.managerResets} renderResumes=${counters.renderResumes} healed=${afterReopen.equals(baseline)}` + ) + + expect( + counters.managerResets + counters.renderResumes, + 'reopen should reset or rebuild the corrupted atlas' + ).toBeGreaterThan(0) + expect(afterReopen.equals(baseline), 'reopened terminal should render clean glyphs').toBe(true) + }) + + test('window focus regain recovers the corrupted atlas (harness control)', async ({ + orcaPage + }) => { + // Why: control proving the injected corruption is exactly the class the + // existing recovery machinery heals — isolating the reopen gap above as a + // missing trigger rather than a broken harness or unrecoverable state. + const shots = await setUpCorruptedFloatingTerminal(orcaPage, 'CONTROL') + test.skip(!shots, 'WebGL was not active or atlas corruption could not be injected') + const { baseline, corrupted } = shots! + expect(corrupted.equals(baseline)).toBe(false) + + await orcaPage.evaluate(() => { + window.dispatchEvent(new Event('focus')) + }) + await settleRecoveryWindows(orcaPage) + + const afterFocus = await screenshotFloatingTerminal(orcaPage) + console.log(`[floating-control] healedByFocus=${afterFocus.equals(baseline)}`) + expect(afterFocus.equals(baseline), 'window focus should heal the atlas').toBe(true) + }) +})