Recover floating workspace terminal WebGL atlas on reopen (#5069)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
ad66692e7c
commit
1cbc40b2aa
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<ResetTerminalWebglAtlasDetail>(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({
|
|||
<Suspense
|
||||
fallback={
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{translate("auto.components.floating.terminal.FloatingTerminalPanel.d6b563ae24", "Loading editor...")}</div>
|
||||
{translate(
|
||||
'auto.components.floating.terminal.FloatingTerminalPanel.d6b563ae24',
|
||||
'Loading editor...'
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Why: floating workspace markdown is scratch/local context,
|
||||
|
|
@ -1416,16 +1452,25 @@ export function FloatingTerminalPanel({
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{showOrchestrationSetup && activeTabType === "terminal" ? (
|
||||
{showOrchestrationSetup && activeTabType === 'terminal' ? (
|
||||
<div
|
||||
className="absolute right-4 bottom-4 z-10 w-[280px] rounded-md border border-border/60 bg-card/95 p-3 text-card-foreground shadow-xs"
|
||||
data-floating-terminal-no-drag
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">{translate("auto.components.floating.terminal.FloatingTerminalPanel.2a3c5ddf5e", "Enable orchestration")}</p>
|
||||
<p className="text-sm font-medium">
|
||||
{translate(
|
||||
'auto.components.floating.terminal.FloatingTerminalPanel.2a3c5ddf5e',
|
||||
'Enable orchestration'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{translate("auto.components.floating.terminal.FloatingTerminalPanel.8cf80db43b", "Set up the Orca CLI and agent skill so agents can coordinate through Orca.")}</p>
|
||||
{translate(
|
||||
'auto.components.floating.terminal.FloatingTerminalPanel.8cf80db43b',
|
||||
'Set up the Orca CLI and agent skill so agents can coordinate through Orca.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
|
@ -1435,7 +1480,11 @@ export function FloatingTerminalPanel({
|
|||
className="flex-1"
|
||||
onClick={dismissOrchestrationSetup}
|
||||
>
|
||||
{translate("auto.components.floating.terminal.FloatingTerminalPanel.adc281394d", "Dismiss")}</Button>
|
||||
{translate(
|
||||
'auto.components.floating.terminal.FloatingTerminalPanel.adc281394d',
|
||||
'Dismiss'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
|
|
@ -1443,7 +1492,11 @@ export function FloatingTerminalPanel({
|
|||
className="flex-1"
|
||||
onClick={() => setOrchestrationDialogOpen(true)}
|
||||
>
|
||||
{translate("auto.components.floating.terminal.FloatingTerminalPanel.bbc177f98f", "Enable")}</Button>
|
||||
{translate(
|
||||
'auto.components.floating.terminal.FloatingTerminalPanel.bbc177f98f',
|
||||
'Enable'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1470,11 +1523,23 @@ export function FloatingTerminalPanel({
|
|||
>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">{translate("auto.components.floating.terminal.FloatingTerminalPanel.690b6fb98a", "Unsaved Changes")}</DialogTitle>
|
||||
<DialogTitle className="text-sm">
|
||||
{translate(
|
||||
'auto.components.floating.terminal.FloatingTerminalPanel.690b6fb98a',
|
||||
'Unsaved Changes'
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{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.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
|
|
@ -1484,16 +1549,28 @@ export function FloatingTerminalPanel({
|
|||
size="sm"
|
||||
onClick={handleFloatingSaveDialogCancel}
|
||||
>
|
||||
{translate("auto.components.floating.terminal.FloatingTerminalPanel.e7bf09d4d4", "Cancel")}</Button>
|
||||
{translate(
|
||||
'auto.components.floating.terminal.FloatingTerminalPanel.e7bf09d4d4',
|
||||
'Cancel'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFloatingSaveDialogDiscard}
|
||||
>
|
||||
{translate("auto.components.floating.terminal.FloatingTerminalPanel.918c2139f3", "Don't Save")}</Button>
|
||||
{translate(
|
||||
'auto.components.floating.terminal.FloatingTerminalPanel.918c2139f3',
|
||||
"Don't Save"
|
||||
)}
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={handleFloatingSaveDialogSave}>
|
||||
{translate("auto.components.floating.terminal.FloatingTerminalPanel.da508bd7f5", "Save")}</Button>
|
||||
{translate(
|
||||
'auto.components.floating.terminal.FloatingTerminalPanel.da508bd7f5',
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
@ -1541,7 +1618,12 @@ function FloatingTerminalEmptyState({
|
|||
onClick={onNewTerminal}
|
||||
>
|
||||
<TerminalSquare className="size-3.5 opacity-90" />
|
||||
<span className="truncate text-left leading-none">{translate("auto.components.floating.terminal.FloatingTerminalPanel.3215fc73e9", "New Terminal")}</span>
|
||||
<span className="truncate text-left leading-none">
|
||||
{translate(
|
||||
'auto.components.floating.terminal.FloatingTerminalPanel.3215fc73e9',
|
||||
'New Terminal'
|
||||
)}
|
||||
</span>
|
||||
<FloatingEmptyStateShortcut keys={newTerminalShortcutKeys} />
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -1551,7 +1633,12 @@ function FloatingTerminalEmptyState({
|
|||
onClick={onNewMarkdown}
|
||||
>
|
||||
<FileText className="size-3.5 opacity-90" />
|
||||
<span className="truncate text-left leading-none">{translate("auto.components.floating.terminal.FloatingTerminalPanel.629528690b", "New Markdown Note")}</span>
|
||||
<span className="truncate text-left leading-none">
|
||||
{translate(
|
||||
'auto.components.floating.terminal.FloatingTerminalPanel.629528690b',
|
||||
'New Markdown Note'
|
||||
)}
|
||||
</span>
|
||||
<FloatingEmptyStateShortcut keys={newMarkdownShortcutKeys} />
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -1561,7 +1648,12 @@ function FloatingTerminalEmptyState({
|
|||
onClick={onOpenMarkdown}
|
||||
>
|
||||
<FileText className="size-3.5 opacity-90" />
|
||||
<span className="truncate text-left leading-none">{translate("auto.components.floating.terminal.FloatingTerminalPanel.88ffb502e5", "Open Markdown Note")}</span>
|
||||
<span className="truncate text-left leading-none">
|
||||
{translate(
|
||||
'auto.components.floating.terminal.FloatingTerminalPanel.88ffb502e5',
|
||||
'Open Markdown Note'
|
||||
)}
|
||||
</span>
|
||||
<FloatingEmptyStateShortcut keys={openMarkdownShortcutKeys} />
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -1571,7 +1663,12 @@ function FloatingTerminalEmptyState({
|
|||
onClick={onNewBrowser}
|
||||
>
|
||||
<Globe className="size-3.5 opacity-90" />
|
||||
<span className="truncate text-left leading-none">{translate("auto.components.floating.terminal.FloatingTerminalPanel.8b07759314", "New Browser")}</span>
|
||||
<span className="truncate text-left leading-none">
|
||||
{translate(
|
||||
'auto.components.floating.terminal.FloatingTerminalPanel.8b07759314',
|
||||
'New Browser'
|
||||
)}
|
||||
</span>
|
||||
<FloatingEmptyStateShortcut keys={newBrowserShortcutKeys} />
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -1581,7 +1678,12 @@ function FloatingTerminalEmptyState({
|
|||
onClick={onClose}
|
||||
>
|
||||
<Minus className="size-3.5 opacity-90" />
|
||||
<span className="truncate text-left leading-none">{translate("auto.components.floating.terminal.FloatingTerminalPanel.fc1042e92b", "Minimize")}</span>
|
||||
<span className="truncate text-left leading-none">
|
||||
{translate(
|
||||
'auto.components.floating.terminal.FloatingTerminalPanel.fc1042e92b',
|
||||
'Minimize'
|
||||
)}
|
||||
</span>
|
||||
<FloatingEmptyStateShortcut keys={closeShortcutKeys} />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ResetTerminalWebglAtlasDetail | undefined>).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<PasteTerminalTextDetail | undefined>).detail
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<string> {
|
||||
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<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 waitForFloatingWebglPane(page: Page): 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
|
||||
.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<void> {
|
||||
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<void>((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<number> {
|
||||
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<boolean> {
|
||||
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<Buffer> {
|
||||
const screen = page.locator(`${PANEL_SELECTOR} .xterm-screen`).first()
|
||||
await expect(screen).toBeVisible()
|
||||
return screen.screenshot({ animations: 'disabled' })
|
||||
}
|
||||
|
||||
async function settleRecoveryWindows(page: Page): Promise<void> {
|
||||
// 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<Buffer> {
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue