Revert "Prevent stale terminal pixels on tab reveal" (#7073)
This commit is contained in:
parent
d6578af833
commit
7d3944c8ed
|
|
@ -1,106 +0,0 @@
|
|||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resumeTerminalVisibility } from './terminal-visibility-resume'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
enforceTerminalCurrentScrollIntent: vi.fn(),
|
||||
fitAndFocusPanes: vi.fn(),
|
||||
fitPanes: vi.fn(),
|
||||
flushTerminalOutput: vi.fn(),
|
||||
focusActivePane: vi.fn(),
|
||||
requestTerminalBacklogRecovery: vi.fn(),
|
||||
resetAndRefreshAllTerminalWebglAtlases: vi.fn(),
|
||||
scheduleTerminalVisibilityWebglRecovery: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({
|
||||
resetAndRefreshAllTerminalWebglAtlases: mocks.resetAndRefreshAllTerminalWebglAtlases
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
|
||||
flushTerminalOutput: mocks.flushTerminalOutput,
|
||||
requestTerminalBacklogRecovery: mocks.requestTerminalBacklogRecovery
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({
|
||||
enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent
|
||||
}))
|
||||
|
||||
vi.mock('./pane-helpers', () => ({
|
||||
fitAndFocusPanes: mocks.fitAndFocusPanes,
|
||||
fitPanes: mocks.fitPanes,
|
||||
focusActivePane: mocks.focusActivePane
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-webgl-atlas-recovery', () => ({
|
||||
scheduleTerminalVisibilityWebglRecovery: mocks.scheduleTerminalVisibilityWebglRecovery
|
||||
}))
|
||||
|
||||
describe('resumeTerminalVisibility', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function createManager(): PaneManager {
|
||||
return {
|
||||
getPanes: vi.fn(() => [{ terminal: 'first-terminal' }, { terminal: 'second-terminal' }]),
|
||||
resumeRendering: vi.fn()
|
||||
} as unknown as PaneManager
|
||||
}
|
||||
|
||||
function resume(
|
||||
options: {
|
||||
isActive?: boolean
|
||||
shouldUseLightTabResume?: boolean
|
||||
wasVisible?: boolean
|
||||
} = {}
|
||||
): {
|
||||
captureViewportPositions: ReturnType<typeof vi.fn>
|
||||
manager: PaneManager
|
||||
withSuppressedScrollTracking: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const manager = createManager()
|
||||
const captureViewportPositions = vi.fn(() => new Map())
|
||||
const withSuppressedScrollTracking = vi.fn((callback: () => void) => callback())
|
||||
|
||||
resumeTerminalVisibility({
|
||||
manager,
|
||||
isActive: options.isActive ?? true,
|
||||
wasVisible: options.wasVisible ?? false,
|
||||
shouldUseLightTabResume: options.shouldUseLightTabResume ?? true,
|
||||
captureViewportPositions,
|
||||
withSuppressedScrollTracking
|
||||
})
|
||||
|
||||
return { captureViewportPositions, manager, withSuppressedScrollTracking }
|
||||
}
|
||||
|
||||
it('uses visibility-owned WebGL recovery on light regular-tab resume', () => {
|
||||
const { captureViewportPositions, manager, withSuppressedScrollTracking } = resume()
|
||||
|
||||
expect(captureViewportPositions).toHaveBeenCalledWith(true)
|
||||
expect(withSuppressedScrollTracking).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith('first-terminal')
|
||||
expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith('second-terminal')
|
||||
expect(mocks.flushTerminalOutput).not.toHaveBeenCalled()
|
||||
expect(manager.resumeRendering).not.toHaveBeenCalled()
|
||||
expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled()
|
||||
expect(mocks.fitPanes).not.toHaveBeenCalled()
|
||||
expect(mocks.scheduleTerminalVisibilityWebglRecovery).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled()
|
||||
expect(mocks.focusActivePane).toHaveBeenCalledWith(manager)
|
||||
expect(mocks.enforceTerminalCurrentScrollIntent).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('preserves direct reset and refresh on heavy visibility resume', () => {
|
||||
const { manager } = resume({ shouldUseLightTabResume: false })
|
||||
|
||||
expect(mocks.scheduleTerminalVisibilityWebglRecovery).not.toHaveBeenCalled()
|
||||
expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.flushTerminalOutput).toHaveBeenCalledTimes(2)
|
||||
expect(manager.resumeRendering).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.fitAndFocusPanes).toHaveBeenCalledWith(manager)
|
||||
expect(mocks.resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.enforceTerminalCurrentScrollIntent).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers'
|
||||
import { scheduleTerminalVisibilityWebglRecovery } from './terminal-webgl-atlas-recovery'
|
||||
import { scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery'
|
||||
|
||||
const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024
|
||||
const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024
|
||||
|
|
@ -62,7 +62,7 @@ export function resumeTerminalVisibility({
|
|||
// overlay's delayed geometry fit. Still request hidden-output recovery:
|
||||
// agent TUIs can suppress hidden bytes until the pane is foregrounded.
|
||||
requestLightTabBacklogRecovery(manager)
|
||||
scheduleTerminalVisibilityWebglRecovery()
|
||||
scheduleTerminalWebglAtlasRecovery()
|
||||
if (isActive) {
|
||||
focusActivePane(manager)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
} from '@/lib/pane-manager/pane-manager-registry'
|
||||
import {
|
||||
scheduleImagePasteWebglAtlasRecovery,
|
||||
scheduleTerminalVisibilityWebglRecovery,
|
||||
scheduleTerminalWebglAtlasRecovery
|
||||
} from './terminal-webgl-atlas-recovery'
|
||||
|
||||
|
|
@ -179,81 +178,4 @@ describe('terminal WebGL atlas recovery', () => {
|
|||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(6)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('does not coalesce visibility recovery behind in-flight terminal-output recovery', () => {
|
||||
vi.useFakeTimers()
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
)
|
||||
const manager = registerManager()
|
||||
|
||||
scheduleTerminalWebglAtlasRecovery()
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
rafCallbacks.shift()?.(0)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1)
|
||||
vi.advanceTimersByTime(120)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(2)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(2)
|
||||
|
||||
scheduleTerminalVisibilityWebglRecovery()
|
||||
scheduleTerminalVisibilityWebglRecovery()
|
||||
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
rafCallbacks.shift()?.(0)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(3)
|
||||
vi.advanceTimersByTime(120)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(4)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(4)
|
||||
vi.advanceTimersByTime(380)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(6)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(6)
|
||||
|
||||
scheduleTerminalVisibilityWebglRecovery()
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
rafCallbacks.shift()?.(0)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(7)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(7)
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(9)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(9)
|
||||
})
|
||||
|
||||
it('allows another first-frame tab reveal while coalescing the delayed tail', () => {
|
||||
vi.useFakeTimers()
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
)
|
||||
const manager = registerManager()
|
||||
|
||||
scheduleTerminalVisibilityWebglRecovery()
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
rafCallbacks.shift()?.(0)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1)
|
||||
|
||||
scheduleTerminalVisibilityWebglRecovery()
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
rafCallbacks.shift()?.(0)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(2)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(2)
|
||||
|
||||
vi.advanceTimersByTime(120)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(3)
|
||||
vi.advanceTimersByTime(380)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(4)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,23 +1,8 @@
|
|||
import { resetAndRefreshAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
|
||||
const ATLAS_RECOVERY_DELAYS_MS = [120, 500]
|
||||
|
||||
let terminalOutputRecoveryScheduled = false
|
||||
let terminalVisibilityFrameRecoveryScheduled = false
|
||||
let terminalVisibilityDelayedRecoveryScheduled = false
|
||||
|
||||
type TerminalRecoveryTestWindow = Window & {
|
||||
__terminalTabOverlapSuppressVisibilityRecovery?: boolean
|
||||
}
|
||||
|
||||
function isVisibilityRecoverySuppressedForTest(): boolean {
|
||||
return (
|
||||
e2eConfig.exposeStore &&
|
||||
typeof window !== 'undefined' &&
|
||||
Boolean((window as TerminalRecoveryTestWindow).__terminalTabOverlapSuppressVisibilityRecovery)
|
||||
)
|
||||
}
|
||||
|
||||
function scheduleNextFrame(callback: () => void): void {
|
||||
if (typeof globalThis.requestAnimationFrame === 'function') {
|
||||
|
|
@ -37,21 +22,13 @@ function resetAtlasesAndRefreshPanes(): void {
|
|||
}
|
||||
}
|
||||
|
||||
type AtlasRecoveryBurstCallbacks = {
|
||||
onComplete?: () => void
|
||||
onFirstReset?: () => void
|
||||
}
|
||||
|
||||
function scheduleAtlasRecoveryBurst(callbacks: AtlasRecoveryBurstCallbacks = {}): void {
|
||||
scheduleNextFrame(() => {
|
||||
resetAtlasesAndRefreshPanes()
|
||||
callbacks.onFirstReset?.()
|
||||
})
|
||||
function scheduleAtlasRecoveryBurst(onComplete?: () => void): void {
|
||||
scheduleNextFrame(() => resetAtlasesAndRefreshPanes())
|
||||
for (const [index, delayMs] of ATLAS_RECOVERY_DELAYS_MS.entries()) {
|
||||
globalThis.setTimeout(() => {
|
||||
resetAtlasesAndRefreshPanes()
|
||||
if (index === ATLAS_RECOVERY_DELAYS_MS.length - 1) {
|
||||
callbacks.onComplete?.()
|
||||
onComplete?.()
|
||||
}
|
||||
}, delayMs)
|
||||
}
|
||||
|
|
@ -70,39 +47,7 @@ export function scheduleTerminalWebglAtlasRecovery(): void {
|
|||
terminalOutputRecoveryScheduled = true
|
||||
// Why: TUI redraw bursts can corrupt xterm's shared WebGL glyph atlas without
|
||||
// a context-loss event; coalesce resets so output storms do not queue timers.
|
||||
scheduleAtlasRecoveryBurst({
|
||||
onComplete: () => {
|
||||
terminalOutputRecoveryScheduled = false
|
||||
}
|
||||
scheduleAtlasRecoveryBurst(() => {
|
||||
terminalOutputRecoveryScheduled = false
|
||||
})
|
||||
}
|
||||
|
||||
export function scheduleTerminalVisibilityWebglRecovery(): void {
|
||||
if (isVisibilityRecoverySuppressedForTest()) {
|
||||
return
|
||||
}
|
||||
if (terminalVisibilityFrameRecoveryScheduled) {
|
||||
return
|
||||
}
|
||||
terminalVisibilityFrameRecoveryScheduled = true
|
||||
// Why: tab reveal is a separate repaint boundary from hidden-output parsing,
|
||||
// so an in-flight output recovery must not suppress the returned tab's repaint.
|
||||
// The first-frame repaint is per reveal, while the delayed tail is coalesced
|
||||
// so rapid tab cycling does not queue unbounded global refresh bursts.
|
||||
scheduleNextFrame(() => {
|
||||
resetAtlasesAndRefreshPanes()
|
||||
terminalVisibilityFrameRecoveryScheduled = false
|
||||
})
|
||||
if (terminalVisibilityDelayedRecoveryScheduled) {
|
||||
return
|
||||
}
|
||||
terminalVisibilityDelayedRecoveryScheduled = true
|
||||
for (const [index, delayMs] of ATLAS_RECOVERY_DELAYS_MS.entries()) {
|
||||
globalThis.setTimeout(() => {
|
||||
resetAtlasesAndRefreshPanes()
|
||||
if (index === ATLAS_RECOVERY_DELAYS_MS.length - 1) {
|
||||
terminalVisibilityDelayedRecoveryScheduled = false
|
||||
}
|
||||
}, delayMs)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
import type { Page } from '@stablyai/playwright-test'
|
||||
|
||||
type RecoveryCounterWindow = Window & {
|
||||
__terminalTabOverlapResetCounts?: Record<string, number>
|
||||
__terminalTabOverlapResetTimes?: Record<string, number[]>
|
||||
__terminalTabOverlapSuppressVisibilityRecovery?: boolean
|
||||
}
|
||||
|
||||
export async function instrumentTabResetCounters(page: Page, tabIds: string[]): Promise<void> {
|
||||
const instrumented = await page.evaluate((tabIds) => {
|
||||
const counterWindow = window as RecoveryCounterWindow
|
||||
counterWindow.__terminalTabOverlapResetCounts = Object.fromEntries(
|
||||
tabIds.map((tabId) => [tabId, 0])
|
||||
)
|
||||
counterWindow.__terminalTabOverlapResetTimes = Object.fromEntries(
|
||||
tabIds.map((tabId) => [tabId, []])
|
||||
)
|
||||
return tabIds.map((tabId) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager?.resetWebglTextureAtlases) {
|
||||
return false
|
||||
}
|
||||
const originalReset = manager.resetWebglTextureAtlases.bind(manager)
|
||||
manager.resetWebglTextureAtlases = () => {
|
||||
const counts = counterWindow.__terminalTabOverlapResetCounts ?? {}
|
||||
counts[tabId] = (counts[tabId] ?? 0) + 1
|
||||
counterWindow.__terminalTabOverlapResetCounts = counts
|
||||
const times = counterWindow.__terminalTabOverlapResetTimes ?? {}
|
||||
times[tabId] = [...(times[tabId] ?? []), performance.now()]
|
||||
counterWindow.__terminalTabOverlapResetTimes = times
|
||||
originalReset()
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, tabIds)
|
||||
if (!instrumented.every(Boolean)) {
|
||||
throw new Error(`could not instrument WebGL reset counters for tabs ${tabIds.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function readTabResetCount(page: Page, tabId: string): Promise<number> {
|
||||
return page.evaluate(
|
||||
(tabId) => (window as RecoveryCounterWindow).__terminalTabOverlapResetCounts?.[tabId] ?? 0,
|
||||
tabId
|
||||
)
|
||||
}
|
||||
|
||||
export async function readTabResetSnapshot(page: Page, tabId: string) {
|
||||
return page.evaluate((tabId) => {
|
||||
const counterWindow = window as RecoveryCounterWindow
|
||||
const times = counterWindow.__terminalTabOverlapResetTimes?.[tabId] ?? []
|
||||
return {
|
||||
count: counterWindow.__terminalTabOverlapResetCounts?.[tabId] ?? 0,
|
||||
latestAt: times.at(-1) ?? 0
|
||||
}
|
||||
}, tabId)
|
||||
}
|
||||
|
||||
export async function setVisibilityRecoverySuppressed(
|
||||
page: Page,
|
||||
suppressed: boolean
|
||||
): Promise<void> {
|
||||
await page.evaluate((suppressed) => {
|
||||
;(window as RecoveryCounterWindow).__terminalTabOverlapSuppressVisibilityRecovery = suppressed
|
||||
}, suppressed)
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import type { Page } from '@stablyai/playwright-test'
|
||||
|
||||
export const STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS = 3_000
|
||||
|
||||
type TimeoutStretchWindow = Window & {
|
||||
__terminalTabOverlapOriginalSetTimeout?: typeof window.setTimeout
|
||||
}
|
||||
|
||||
export async function stretchFiveHundredMillisecondTimeouts(page: Page): Promise<void> {
|
||||
await page.evaluate((stretchedDelayMs) => {
|
||||
const testWindow = window as TimeoutStretchWindow
|
||||
if (testWindow.__terminalTabOverlapOriginalSetTimeout) {
|
||||
return
|
||||
}
|
||||
const originalSetTimeout = window.setTimeout
|
||||
const boundSetTimeout = originalSetTimeout.bind(window)
|
||||
testWindow.__terminalTabOverlapOriginalSetTimeout = originalSetTimeout
|
||||
// Why: terminal reveal recovery tests must prove reveal repaint, so the old
|
||||
// hidden-output fallback is stretched out of the assertion window.
|
||||
window.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) =>
|
||||
boundSetTimeout(
|
||||
handler,
|
||||
timeout === 500 ? stretchedDelayMs : timeout,
|
||||
...args
|
||||
)) as typeof window.setTimeout
|
||||
}, STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS)
|
||||
}
|
||||
|
||||
export async function restoreFiveHundredMillisecondTimeouts(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const testWindow = window as TimeoutStretchWindow
|
||||
if (!testWindow.__terminalTabOverlapOriginalSetTimeout) {
|
||||
return
|
||||
}
|
||||
window.setTimeout = testWindow.__terminalTabOverlapOriginalSetTimeout
|
||||
delete testWindow.__terminalTabOverlapOriginalSetTimeout
|
||||
})
|
||||
}
|
||||
|
|
@ -1,830 +0,0 @@
|
|||
import { writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { PNG } from 'pngjs'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import {
|
||||
ensureTerminalVisible,
|
||||
getActiveTabId,
|
||||
getActiveWorktreeId,
|
||||
waitForActiveWorktree,
|
||||
waitForSessionReady
|
||||
} from './helpers/store'
|
||||
import {
|
||||
getTerminalContent,
|
||||
sendToTerminal,
|
||||
waitForActiveTerminalManager
|
||||
} from './helpers/terminal'
|
||||
import {
|
||||
restoreFiveHundredMillisecondTimeouts,
|
||||
STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS,
|
||||
stretchFiveHundredMillisecondTimeouts
|
||||
} from './terminal-recovery-timeout-stretch'
|
||||
import {
|
||||
instrumentTabResetCounters,
|
||||
readTabResetCount,
|
||||
readTabResetSnapshot,
|
||||
setVisibilityRecoverySuppressed
|
||||
} from './terminal-recovery-reset-counters'
|
||||
import { compareTerminalScreenshots } from './terminal-screenshot-diff'
|
||||
|
||||
const TAB_A_MARKER = 'ORCA_OVERLAP_REPRO_TAB_A_ONLY'
|
||||
const TAB_B_MARKER = 'ORCA_OVERLAP_REPRO_TAB_B_ONLY'
|
||||
const TAB_B_GLYPHS = 'ZYXWVUTSRQPONMLKJIHGFEDCBA 9876543210 !?^"\'();:,.|$_-'
|
||||
const TAB_B_COLOR = { red: 255, green: 75, blue: 170 }
|
||||
const VISUAL_OVERLAP_PROBE_CYCLES = 140
|
||||
const SIBLING_COLOR_PIXEL_FLOOR = 150
|
||||
const SIBLING_COLOR_PIXEL_DELTA = 100
|
||||
const REVEAL_RECOVERY_TIMEOUT_MS = 180
|
||||
const REAL_CLAUDE_PROBE_CYCLES = 160
|
||||
const REFRESH_REPAIR_DIFF_RATIO = 0.035
|
||||
|
||||
type TabIdentity = {
|
||||
tabId: string
|
||||
leafId: string
|
||||
ptyId: string
|
||||
}
|
||||
|
||||
type TabBufferProbe = {
|
||||
containsOwnMarker: boolean
|
||||
containsSiblingMarker: boolean
|
||||
visibleText: string
|
||||
}
|
||||
|
||||
type RefreshRepairProbe = {
|
||||
diffRatio: number
|
||||
diffPixels: number
|
||||
bufferUnchanged: boolean
|
||||
beforeText: string
|
||||
afterText: string
|
||||
beforeScreenshot: Buffer
|
||||
afterScreenshot: Buffer
|
||||
}
|
||||
|
||||
type HiddenOutputWindow = Window & {
|
||||
__terminalPtyDataInjection?: {
|
||||
inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean
|
||||
}
|
||||
}
|
||||
|
||||
async function setTerminalGpuOn(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,
|
||||
terminalGpuAcceleration: 'on'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureTwoTerminalTabs(
|
||||
page: Page
|
||||
): Promise<{ firstTabId: string; secondTabId: string }> {
|
||||
const worktreeId = (await getActiveWorktreeId(page))!
|
||||
const result = await page.evaluate((worktreeId) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
const state = store.getState()
|
||||
const existing = state.tabsByWorktree[worktreeId] ?? []
|
||||
const first = existing.find((tab) => tab.type === 'terminal') ?? state.createTab(worktreeId)
|
||||
let second = existing.find((tab) => tab.type === 'terminal' && tab.id !== first.id)
|
||||
if (!second) {
|
||||
second = state.createTab(worktreeId, undefined, undefined, { activate: false })
|
||||
}
|
||||
state.setActiveTab(first.id)
|
||||
state.setActiveTabType('terminal')
|
||||
return { firstTabId: first.id, secondTabId: second.id }
|
||||
}, worktreeId)
|
||||
await waitForTerminalManagerOnTab(page, result.firstTabId)
|
||||
await waitForTerminalManagerOnTab(page, result.secondTabId)
|
||||
return result
|
||||
}
|
||||
|
||||
async function ensureThreeTerminalTabs(
|
||||
page: Page
|
||||
): Promise<{ firstTabId: string; secondTabId: string; thirdTabId: string }> {
|
||||
const worktreeId = (await getActiveWorktreeId(page))!
|
||||
const result = await page.evaluate((worktreeId) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
const state = store.getState()
|
||||
const terminalTabs = (state.tabsByWorktree[worktreeId] ?? []).filter(
|
||||
(tab) => tab.type === 'terminal'
|
||||
)
|
||||
const first = terminalTabs[0] ?? state.createTab(worktreeId)
|
||||
const second =
|
||||
terminalTabs.find((tab) => tab.id !== first.id) ??
|
||||
state.createTab(worktreeId, undefined, undefined, { activate: false })
|
||||
const third =
|
||||
terminalTabs.find((tab) => tab.id !== first.id && tab.id !== second.id) ??
|
||||
state.createTab(worktreeId, undefined, undefined, { activate: false })
|
||||
state.setActiveTab(first.id)
|
||||
state.setActiveTabType('terminal')
|
||||
return { firstTabId: first.id, secondTabId: second.id, thirdTabId: third.id }
|
||||
}, worktreeId)
|
||||
await waitForTerminalManagerOnTab(page, result.firstTabId)
|
||||
await waitForTerminalManagerOnTab(page, result.secondTabId)
|
||||
await waitForTerminalManagerOnTab(page, result.thirdTabId)
|
||||
return result
|
||||
}
|
||||
|
||||
async function waitForTerminalManagerOnTab(page: Page, tabId: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((tabId) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
return Boolean(manager?.getActivePane?.() ?? manager?.getPanes?.()[0])
|
||||
}, tabId),
|
||||
{ timeout: 15_000, message: `terminal manager did not mount for ${tabId}` }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function activateTerminalTab(page: Page, tabId: string): Promise<void> {
|
||||
await page.evaluate((tabId) => {
|
||||
const state = window.__store?.getState()
|
||||
state?.setActiveTab(tabId)
|
||||
state?.setActiveTabType('terminal')
|
||||
}, tabId)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page
|
||||
.locator(`[data-testid="sortable-tab"][data-active="true"]`)
|
||||
.getAttribute('data-tab-id'),
|
||||
{ timeout: 5_000, message: `terminal tab ${tabId} did not become active` }
|
||||
)
|
||||
.toBe(tabId)
|
||||
}
|
||||
|
||||
async function waitForWebglOnTab(page: Page, tabId: string): Promise<boolean> {
|
||||
await page.evaluate((tabId) => {
|
||||
window.__paneManagers?.get(tabId)?.setTerminalGpuAcceleration?.('on')
|
||||
}, tabId)
|
||||
return page
|
||||
.waitForFunction(
|
||||
(tabId) => {
|
||||
const diagnostics = window.__paneManagers?.get(tabId)?.getRenderingDiagnostics?.() ?? []
|
||||
return diagnostics.some((entry) => entry.hasWebgl)
|
||||
},
|
||||
tabId,
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
async function readTabIdentity(page: Page, tabId: string): Promise<TabIdentity> {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((tabId) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
return pane?.container.dataset.ptyId ?? null
|
||||
}, tabId),
|
||||
{ timeout: 20_000, message: `terminal tab ${tabId} did not bind a PTY` }
|
||||
)
|
||||
.not.toBeNull()
|
||||
const identity = await page.evaluate((tabId) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
tabId,
|
||||
leafId: pane.container.dataset.leafId ?? null,
|
||||
ptyId: pane.container.dataset.ptyId ?? null
|
||||
}
|
||||
}, tabId)
|
||||
if (!identity?.leafId || !identity.ptyId) {
|
||||
throw new Error(`terminal tab ${tabId} did not bind a PTY`)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
async function readTabBufferProbe(
|
||||
page: Page,
|
||||
tabId: string,
|
||||
ownMarker: string,
|
||||
siblingMarker: string
|
||||
): Promise<TabBufferProbe> {
|
||||
return page.evaluate(
|
||||
({ tabId, ownMarker, siblingMarker }) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error(`No terminal pane for ${tabId}`)
|
||||
}
|
||||
const activeBuffer = pane.terminal.buffer.active
|
||||
const rows: string[] = []
|
||||
for (let row = 0; row < pane.terminal.rows; row += 1) {
|
||||
rows.push(activeBuffer.getLine(activeBuffer.viewportY + row)?.translateToString(true) ?? '')
|
||||
}
|
||||
const visibleText = rows.join('\n')
|
||||
return {
|
||||
containsOwnMarker: visibleText.includes(ownMarker),
|
||||
containsSiblingMarker: visibleText.includes(siblingMarker),
|
||||
visibleText
|
||||
}
|
||||
},
|
||||
{ tabId, ownMarker, siblingMarker }
|
||||
)
|
||||
}
|
||||
|
||||
async function startTuiStream(
|
||||
page: Page,
|
||||
ptyId: string,
|
||||
scriptPath: string,
|
||||
marker: string,
|
||||
glyphs: string,
|
||||
color: { red: number; green: number; blue: number },
|
||||
cadenceMs: number
|
||||
): Promise<void> {
|
||||
const script = [
|
||||
`const marker=${JSON.stringify(marker)};`,
|
||||
`const glyphs=${JSON.stringify(glyphs)};`,
|
||||
`const color=${JSON.stringify(color)};`,
|
||||
`const cadence=${cadenceMs};`,
|
||||
'let frame=0;',
|
||||
'function bar(width){return "█".repeat((frame % width) + 1) + "░".repeat(width - ((frame % width) + 1));}',
|
||||
'function paint(text){return `\\x1b[38;2;${color.red};${color.green};${color.blue}m${text}\\x1b[0m`; }',
|
||||
'function emit(){',
|
||||
' const lines=[];',
|
||||
' lines.push("\\x1b[?2026h");',
|
||||
' lines.push("\\x1b[?1049h");',
|
||||
' lines.push("\\x1b[2J\\x1b[H\\x1b[?25l");',
|
||||
' lines.push(paint(`╭────────────────────────────────────────────────────────────────────╮`));',
|
||||
' lines.push(paint(`│ ${marker} frame ${String(frame).padStart(4,"0")} ${bar(18)} │`));',
|
||||
' lines.push(paint(`├────────────────────────────────────────────────────────────────────┤`));',
|
||||
' for (let row=0; row<18; row++) {',
|
||||
' lines.push(paint(`│ row ${String(row).padStart(2,"0")} ${glyphs} ${bar(10)} │`));',
|
||||
' }',
|
||||
' lines.push(paint(`╰────────────────────────────────────────────────────────────────────╯`));',
|
||||
' lines.push("\\x1b[?25h\\x1b[?2026l");',
|
||||
' process.stdout.write(lines.join("\\r\\n"));',
|
||||
' frame += 1;',
|
||||
'}',
|
||||
'emit();',
|
||||
'setInterval(emit, cadence);'
|
||||
].join('')
|
||||
writeFileSync(scriptPath, script)
|
||||
await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
|
||||
}
|
||||
|
||||
async function writeSparsePromptFrame(
|
||||
page: Page,
|
||||
ptyId: string,
|
||||
scriptPath: string
|
||||
): Promise<void> {
|
||||
const script = [
|
||||
`const marker=${JSON.stringify(TAB_A_MARKER)};`,
|
||||
'process.stdout.write("\\x1b[2J\\x1b[3J\\x1b[H\\x1b[?25h");',
|
||||
'process.stdout.write(`${marker}\\r\\n`);',
|
||||
'process.stdout.write("Please make more to 5 more different random files. Explore orca a bit actually, then do it. BBB\\r\\n");'
|
||||
].join('')
|
||||
writeFileSync(scriptPath, script)
|
||||
await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
|
||||
}
|
||||
|
||||
async function writeDenseStaticFrame(
|
||||
page: Page,
|
||||
ptyId: string,
|
||||
scriptPath: string,
|
||||
marker: string
|
||||
): Promise<void> {
|
||||
const script = [
|
||||
`const marker=${JSON.stringify(marker)};`,
|
||||
'const glyphs="ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\\\#@%&*+=~";',
|
||||
'process.stdout.write("\\x1b[2J\\x1b[3J\\x1b[H\\x1b[?25l");',
|
||||
'for (let row = 0; row < 22; row += 1) {',
|
||||
' process.stdout.write(`${marker} row ${String(row).padStart(2, "0")} | ${glyphs} |\\r\\n`);',
|
||||
'}'
|
||||
].join('')
|
||||
writeFileSync(scriptPath, script)
|
||||
await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
|
||||
}
|
||||
|
||||
async function injectHiddenTuiFrame(
|
||||
page: Page,
|
||||
identity: TabIdentity,
|
||||
marker: string
|
||||
): Promise<void> {
|
||||
const paneKey = `${identity.tabId}:${identity.leafId}`
|
||||
const frame = [
|
||||
'\x1b[?2026h',
|
||||
'\x1b[?1049h',
|
||||
'\x1b[2J\x1b[H\x1b[?25l',
|
||||
`╭────────────────────────────────────────────────────────╮`,
|
||||
`│ ${marker} hidden atlas recovery frame │`,
|
||||
`├────────────────────────────────────────────────────────┤`,
|
||||
...Array.from(
|
||||
{ length: 12 },
|
||||
(_, row) => `│ hidden row ${String(row).padStart(2, '0')} ███████████████████ │`
|
||||
),
|
||||
`╰────────────────────────────────────────────────────────╯`,
|
||||
'\x1b[?25h\x1b[?2026l'
|
||||
].join('\r\n')
|
||||
const injected = await page.evaluate(
|
||||
({ paneKey, frame }) =>
|
||||
(window as HiddenOutputWindow).__terminalPtyDataInjection?.inject(paneKey, frame, {
|
||||
seq: frame.length,
|
||||
rawLength: frame.length
|
||||
}) ?? false,
|
||||
{ paneKey, frame }
|
||||
)
|
||||
if (!injected) {
|
||||
throw new Error(`No terminal PTY data injector registered for ${paneKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function corruptTabAtlas(page: Page, tabId: string): Promise<number> {
|
||||
return page.evaluate((tabId) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
return 0
|
||||
}
|
||||
const canvases = Array.from(
|
||||
document.querySelectorAll<HTMLCanvasElement>(`[data-terminal-tab-id="${tabId}"] canvas`)
|
||||
)
|
||||
const noise = new Uint8Array(64 * 64 * 4)
|
||||
for (let index = 0; index < noise.length; index += 4) {
|
||||
noise[index] = (index * 7) % 256
|
||||
noise[index + 1] = (index * 13) % 256
|
||||
noise[index + 2] = (index * 29) % 256
|
||||
noise[index + 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)
|
||||
if (!gl.getParameter(gl.TEXTURE_BINDING_2D)) {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
if (corrupted > 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
gl.activeTexture(gl.TEXTURE0)
|
||||
if (corrupted > 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
return corrupted
|
||||
}, tabId)
|
||||
}
|
||||
|
||||
async function resetAndRefreshTab(page: Page, tabId: string): Promise<void> {
|
||||
await page.evaluate((tabId) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
manager?.resetWebglTextureAtlases?.()
|
||||
manager?.refreshAllPanes?.()
|
||||
}, tabId)
|
||||
await waitForTwoAnimationFrames(page)
|
||||
}
|
||||
|
||||
async function waitForTwoAnimationFrames(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
)
|
||||
}
|
||||
|
||||
async function captureTabScreen(page: Page, tabId: string): Promise<Buffer> {
|
||||
const screen = page.locator(`[data-terminal-tab-id="${tabId}"] .xterm-screen`).first()
|
||||
await expect(screen).toBeVisible()
|
||||
return screen.screenshot({ animations: 'disabled' })
|
||||
}
|
||||
|
||||
function countColorPixels(
|
||||
buffer: Buffer,
|
||||
target: { red: number; green: number; blue: number }
|
||||
): number {
|
||||
const image = PNG.sync.read(buffer)
|
||||
let pixels = 0
|
||||
for (let offset = 0; offset < image.data.length; offset += 4) {
|
||||
const alpha = image.data[offset + 3] ?? 0
|
||||
if (alpha < 120) {
|
||||
continue
|
||||
}
|
||||
const red = image.data[offset] ?? 0
|
||||
const green = image.data[offset + 1] ?? 0
|
||||
const blue = image.data[offset + 2] ?? 0
|
||||
const distance =
|
||||
Math.abs(red - target.red) + Math.abs(green - target.green) + Math.abs(blue - target.blue)
|
||||
if (distance <= 95) {
|
||||
pixels += 1
|
||||
}
|
||||
}
|
||||
return pixels
|
||||
}
|
||||
|
||||
async function attachArtifact(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
body: Buffer | string
|
||||
): Promise<void> {
|
||||
await testInfo.attach(name, {
|
||||
body: typeof body === 'string' ? Buffer.from(body) : body,
|
||||
contentType: typeof body === 'string' ? 'text/plain' : 'image/png'
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshActiveTabAndCompare(page: Page, tabId: string): Promise<RefreshRepairProbe> {
|
||||
const beforeText = (await readTabBufferProbe(page, tabId, '', '\u0000')).visibleText
|
||||
const beforeScreenshot = await captureTabScreen(page, tabId)
|
||||
await page.evaluate((tabId) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error(`No terminal pane for ${tabId}`)
|
||||
}
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
}, tabId)
|
||||
await waitForTwoAnimationFrames(page)
|
||||
const afterScreenshot = await captureTabScreen(page, tabId)
|
||||
const afterText = (await readTabBufferProbe(page, tabId, '', '\u0000')).visibleText
|
||||
const diff = compareTerminalScreenshots(beforeScreenshot, afterScreenshot)
|
||||
return {
|
||||
diffRatio: diff.diffRatio,
|
||||
diffPixels: diff.diffPixels,
|
||||
bufferUnchanged: beforeText === afterText,
|
||||
beforeText,
|
||||
afterText,
|
||||
beforeScreenshot,
|
||||
afterScreenshot
|
||||
}
|
||||
}
|
||||
|
||||
async function startClaudeEditSession(
|
||||
page: Page,
|
||||
ptyId: string,
|
||||
marker: string,
|
||||
filePrefix: string
|
||||
): Promise<void> {
|
||||
const prompt = [
|
||||
`This is an Orca terminal renderer reproduction run. Marker: ${marker}.`,
|
||||
'Create or edit 10 small scratch files in the current repo.',
|
||||
`Name them ${filePrefix}-01.txt through ${filePrefix}-10.txt.`,
|
||||
'After each file edit, run a tiny shell command that prints the marker and sleep 1 second.',
|
||||
'Keep working without asking questions. The visible terminal activity is the point.'
|
||||
].join(' ')
|
||||
await sendToTerminal(
|
||||
page,
|
||||
ptyId,
|
||||
`claude --model sonnet --effort low --dangerously-skip-permissions --permission-mode bypassPermissions ${JSON.stringify(prompt)}\r`
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Terminal tab switch visual overlap repro @headful', () => {
|
||||
test('manual probe for visible sibling TUI pixels while tab buffers stay isolated', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
process.env.ORCA_E2E_VISUAL_OVERLAP_PROBE !== '1',
|
||||
'Set ORCA_E2E_VISUAL_OVERLAP_PROBE=1 to run the best-effort visual overlap probe'
|
||||
)
|
||||
test.setTimeout(180_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await setTerminalGpuOn(orcaPage)
|
||||
|
||||
const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage)
|
||||
await activateTerminalTab(orcaPage, firstTabId)
|
||||
const firstWebgl = await waitForWebglOnTab(orcaPage, firstTabId)
|
||||
const firstIdentity = await readTabIdentity(orcaPage, firstTabId)
|
||||
|
||||
await activateTerminalTab(orcaPage, secondTabId)
|
||||
const secondWebgl = await waitForWebglOnTab(orcaPage, secondTabId)
|
||||
const secondIdentity = await readTabIdentity(orcaPage, secondTabId)
|
||||
test.skip(!firstWebgl || !secondWebgl, 'WebGL did not attach on both regular terminal tabs')
|
||||
|
||||
await writeSparsePromptFrame(
|
||||
orcaPage,
|
||||
firstIdentity.ptyId,
|
||||
path.join(testRepoPath, 'orca-overlap-tab-a-sparse.mjs')
|
||||
)
|
||||
await startTuiStream(
|
||||
orcaPage,
|
||||
secondIdentity.ptyId,
|
||||
path.join(testRepoPath, 'orca-overlap-tab-b.mjs'),
|
||||
TAB_B_MARKER,
|
||||
TAB_B_GLYPHS,
|
||||
TAB_B_COLOR,
|
||||
41
|
||||
)
|
||||
|
||||
await activateTerminalTab(orcaPage, firstTabId)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 12_000), {
|
||||
timeout: 20_000,
|
||||
message: 'tab A sparse prompt frame did not reach the visible terminal'
|
||||
})
|
||||
.toContain(TAB_A_MARKER)
|
||||
const baselineA = await captureTabScreen(orcaPage, firstTabId)
|
||||
const baselineAHasSiblingColor = countColorPixels(baselineA, TAB_B_COLOR)
|
||||
|
||||
await activateTerminalTab(orcaPage, secondTabId)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 12_000), {
|
||||
timeout: 20_000,
|
||||
message: 'tab B TUI stream did not reach the visible terminal'
|
||||
})
|
||||
.toContain(`${TAB_B_MARKER} frame`)
|
||||
const baselineB = await captureTabScreen(orcaPage, secondTabId)
|
||||
const baselineBHasOwnColor = countColorPixels(baselineB, TAB_B_COLOR)
|
||||
expect(baselineBHasOwnColor).toBeGreaterThan(500)
|
||||
|
||||
const reports: string[] = []
|
||||
for (let cycle = 0; cycle < VISUAL_OVERLAP_PROBE_CYCLES; cycle += 1) {
|
||||
await activateTerminalTab(orcaPage, secondTabId)
|
||||
await orcaPage.waitForTimeout(15)
|
||||
await activateTerminalTab(orcaPage, firstTabId)
|
||||
const activeTabId = (await getActiveTabId(orcaPage))!
|
||||
const screenshot = await captureTabScreen(orcaPage, activeTabId)
|
||||
const probe = await readTabBufferProbe(orcaPage, activeTabId, TAB_A_MARKER, TAB_B_MARKER)
|
||||
if (!probe.containsOwnMarker || probe.containsSiblingMarker) {
|
||||
reports.push(
|
||||
`cycle ${cycle} buffer mismatch active=${activeTabId} own=${probe.containsOwnMarker} sibling=${probe.containsSiblingMarker}`
|
||||
)
|
||||
await attachArtifact(testInfo, `buffer-mismatch-cycle-${cycle}.txt`, probe.visibleText)
|
||||
break
|
||||
}
|
||||
const siblingColorPixels = countColorPixels(screenshot, TAB_B_COLOR)
|
||||
if (
|
||||
siblingColorPixels >
|
||||
Math.max(SIBLING_COLOR_PIXEL_FLOOR, baselineAHasSiblingColor + SIBLING_COLOR_PIXEL_DELTA)
|
||||
) {
|
||||
reports.push(
|
||||
`cycle ${cycle} visual sibling color active=${activeTabId} siblingPixels=${siblingColorPixels} baselineSiblingPixels=${baselineAHasSiblingColor} bufferSibling=${probe.containsSiblingMarker}`
|
||||
)
|
||||
await attachArtifact(testInfo, `overlap-candidate-${cycle}.png`, screenshot)
|
||||
await attachArtifact(testInfo, `overlap-buffer-${cycle}.txt`, probe.visibleText)
|
||||
break
|
||||
}
|
||||
await orcaPage.waitForTimeout(20)
|
||||
}
|
||||
|
||||
await attachArtifact(testInfo, 'tab-a-baseline.png', baselineA)
|
||||
await attachArtifact(testInfo, 'tab-b-baseline.png', baselineB)
|
||||
expect(
|
||||
reports,
|
||||
reports.length > 0
|
||||
? `active terminal showed sibling-colored pixels while buffers stayed isolated:\n${reports.join('\n')}`
|
||||
: undefined
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('repairs returned tab pixels even while hidden-output recovery is in flight', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await setTerminalGpuOn(orcaPage)
|
||||
|
||||
const { firstTabId, secondTabId, thirdTabId } = await ensureThreeTerminalTabs(orcaPage)
|
||||
await activateTerminalTab(orcaPage, firstTabId)
|
||||
const firstWebgl = await waitForWebglOnTab(orcaPage, firstTabId)
|
||||
const firstIdentity = await readTabIdentity(orcaPage, firstTabId)
|
||||
await writeDenseStaticFrame(
|
||||
orcaPage,
|
||||
firstIdentity.ptyId,
|
||||
path.join(testRepoPath, 'orca-overlap-static-a.mjs'),
|
||||
'ORCA_DETERMINISTIC_A'
|
||||
)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 12_000), {
|
||||
timeout: 20_000,
|
||||
message: 'tab A static frame did not reach the visible terminal'
|
||||
})
|
||||
.toContain('ORCA_DETERMINISTIC_A row 21')
|
||||
await resetAndRefreshTab(orcaPage, firstTabId)
|
||||
const baseline = await captureTabScreen(orcaPage, firstTabId)
|
||||
|
||||
const activeCorruptedTiles = await corruptTabAtlas(orcaPage, firstTabId)
|
||||
test.skip(activeCorruptedTiles === 0, 'Could not inject WebGL atlas corruption')
|
||||
const activeCorruption = await captureTabScreen(orcaPage, firstTabId)
|
||||
expect(
|
||||
compareTerminalScreenshots(baseline, activeCorruption).matches,
|
||||
'atlas corruption precondition should visibly change active tab glyphs'
|
||||
).toBe(false)
|
||||
await resetAndRefreshTab(orcaPage, firstTabId)
|
||||
const restored = await captureTabScreen(orcaPage, firstTabId)
|
||||
expect(compareTerminalScreenshots(baseline, restored).matches).toBe(true)
|
||||
|
||||
await activateTerminalTab(orcaPage, thirdTabId)
|
||||
const thirdWebgl = await waitForWebglOnTab(orcaPage, thirdTabId)
|
||||
const thirdIdentity = await readTabIdentity(orcaPage, thirdTabId)
|
||||
await activateTerminalTab(orcaPage, secondTabId)
|
||||
const secondWebgl = await waitForWebglOnTab(orcaPage, secondTabId)
|
||||
test.skip(
|
||||
!firstWebgl || !secondWebgl || !thirdWebgl,
|
||||
'WebGL did not attach on all regular terminal tabs'
|
||||
)
|
||||
await instrumentTabResetCounters(orcaPage, [firstTabId, secondTabId, thirdTabId])
|
||||
await stretchFiveHundredMillisecondTimeouts(orcaPage)
|
||||
try {
|
||||
await injectHiddenTuiFrame(orcaPage, thirdIdentity, 'ORCA_HIDDEN_OUTPUT_RECOVERY_IN_FLIGHT')
|
||||
// Why: the regression is the output-recovery latch suppressing reveal
|
||||
// recovery. Prove the hidden-output burst reached its rAF and 120ms passes
|
||||
// before reveal, while its old 500ms fallback is stretched out of range.
|
||||
await expect
|
||||
.poll(() => readTabResetCount(orcaPage, thirdTabId), {
|
||||
timeout: 2_000,
|
||||
message: 'hidden-output recovery did not fire its first two reset passes'
|
||||
})
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
} finally {
|
||||
await restoreFiveHundredMillisecondTimeouts(orcaPage)
|
||||
}
|
||||
const hiddenOutputResetCount = await readTabResetCount(orcaPage, thirdTabId)
|
||||
expect(hiddenOutputResetCount).toBeGreaterThanOrEqual(2)
|
||||
expect(
|
||||
hiddenOutputResetCount,
|
||||
'hidden-output fallback fired before the reveal precondition could be exercised'
|
||||
).toBeLessThanOrEqual(2)
|
||||
const hiddenOutputResetWindow = await readTabResetSnapshot(orcaPage, thirdTabId)
|
||||
const resetCountBeforeSuppressedReveal = await readTabResetCount(orcaPage, firstTabId)
|
||||
expect(resetCountBeforeSuppressedReveal).toBeGreaterThanOrEqual(2)
|
||||
expect(
|
||||
resetCountBeforeSuppressedReveal,
|
||||
'hidden-output fallback fired before tab reveal'
|
||||
).toBeLessThanOrEqual(2)
|
||||
let suppressedReveal: Buffer | null = null
|
||||
try {
|
||||
await setVisibilityRecoverySuppressed(orcaPage, true)
|
||||
await activateTerminalTab(orcaPage, firstTabId)
|
||||
await waitForTwoAnimationFrames(orcaPage)
|
||||
const staleCorruptedTiles = await corruptTabAtlas(orcaPage, firstTabId)
|
||||
expect(staleCorruptedTiles).toBeGreaterThan(0)
|
||||
suppressedReveal = await captureTabScreen(orcaPage, firstTabId)
|
||||
const suppressedRevealDiff = compareTerminalScreenshots(baseline, suppressedReveal)
|
||||
expect(
|
||||
suppressedRevealDiff.matches,
|
||||
'simulated stale returned-tab pixels must be visible when recovery is suppressed'
|
||||
).toBe(false)
|
||||
await activateTerminalTab(orcaPage, secondTabId)
|
||||
await waitForTwoAnimationFrames(orcaPage)
|
||||
} finally {
|
||||
await setVisibilityRecoverySuppressed(orcaPage, false)
|
||||
}
|
||||
const resetCountBeforeReveal = await readTabResetCount(orcaPage, firstTabId)
|
||||
expect(resetCountBeforeReveal).toBe(resetCountBeforeSuppressedReveal)
|
||||
expect(resetCountBeforeReveal).toBeGreaterThanOrEqual(2)
|
||||
expect(
|
||||
resetCountBeforeReveal,
|
||||
'hidden-output fallback fired before tab reveal'
|
||||
).toBeLessThanOrEqual(2)
|
||||
const revealStartedAt = await orcaPage.evaluate(() => performance.now())
|
||||
expect(
|
||||
revealStartedAt - hiddenOutputResetWindow.latestAt,
|
||||
'tab reveal started too close to the stretched hidden-output fallback to prove independent recovery'
|
||||
).toBeLessThan(STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS - REVEAL_RECOVERY_TIMEOUT_MS)
|
||||
await activateTerminalTab(orcaPage, firstTabId)
|
||||
await expect
|
||||
.poll(() => readTabResetCount(orcaPage, firstTabId), {
|
||||
timeout: REVEAL_RECOVERY_TIMEOUT_MS,
|
||||
message: 'tab reveal did not schedule an independent WebGL recovery reset'
|
||||
})
|
||||
.toBeGreaterThan(resetCountBeforeReveal)
|
||||
const resetAfterReveal = await readTabResetSnapshot(orcaPage, firstTabId)
|
||||
expect(
|
||||
resetAfterReveal.latestAt - revealStartedAt,
|
||||
'reveal recovery must beat the old hidden-output 500ms fallback reset'
|
||||
).toBeLessThan(REVEAL_RECOVERY_TIMEOUT_MS)
|
||||
expect(
|
||||
resetAfterReveal.latestAt - hiddenOutputResetWindow.latestAt,
|
||||
'post-reveal reset must occur before the hidden-output 500ms fallback could explain it'
|
||||
).toBeLessThan(STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS)
|
||||
await waitForTwoAnimationFrames(orcaPage)
|
||||
const afterReveal = await captureTabScreen(orcaPage, firstTabId)
|
||||
const afterRevealDiff = compareTerminalScreenshots(baseline, afterReveal)
|
||||
await attachArtifact(testInfo, 'deterministic-baseline.png', baseline)
|
||||
await attachArtifact(
|
||||
testInfo,
|
||||
'deterministic-suppressed-reveal.png',
|
||||
suppressedReveal ?? Buffer.from('')
|
||||
)
|
||||
await attachArtifact(testInfo, 'deterministic-after-reveal.png', afterReveal)
|
||||
await attachArtifact(
|
||||
testInfo,
|
||||
'deterministic-reset-counts.txt',
|
||||
`beforeReveal=${resetCountBeforeReveal}\nafterReveal=${resetAfterReveal.count}\nrevealDelayMs=${resetAfterReveal.latestAt - revealStartedAt}\nhiddenSecondResetToRevealMs=${revealStartedAt - hiddenOutputResetWindow.latestAt}\nstretchedHiddenOutputFallbackMs=${STRETCHED_HIDDEN_OUTPUT_FALLBACK_MS}\n`
|
||||
)
|
||||
expect(
|
||||
afterRevealDiff.matches,
|
||||
`returned tab still had stale WebGL pixels after reveal: ${afterRevealDiff.diffPixels} px (${(afterRevealDiff.diffRatio * 100).toFixed(2)}%)`
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('real Claude sessions do not leave refresh-repairable stale pixels', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
process.env.ORCA_E2E_REAL_CLAUDE_OVERLAP_REPRO !== '1',
|
||||
'Set ORCA_E2E_REAL_CLAUDE_OVERLAP_REPRO=1 to spend real Claude Code tokens on this repro'
|
||||
)
|
||||
test.setTimeout(420_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await setTerminalGpuOn(orcaPage)
|
||||
|
||||
const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage)
|
||||
await activateTerminalTab(orcaPage, firstTabId)
|
||||
const firstWebgl = await waitForWebglOnTab(orcaPage, firstTabId)
|
||||
const firstIdentity = await readTabIdentity(orcaPage, firstTabId)
|
||||
await activateTerminalTab(orcaPage, secondTabId)
|
||||
const secondWebgl = await waitForWebglOnTab(orcaPage, secondTabId)
|
||||
const secondIdentity = await readTabIdentity(orcaPage, secondTabId)
|
||||
test.skip(!firstWebgl || !secondWebgl, 'WebGL did not attach on both regular terminal tabs')
|
||||
|
||||
await startClaudeEditSession(
|
||||
orcaPage,
|
||||
firstIdentity.ptyId,
|
||||
'ORCA_REAL_CLAUDE_A',
|
||||
'orca-real-claude-a'
|
||||
)
|
||||
await startClaudeEditSession(
|
||||
orcaPage,
|
||||
secondIdentity.ptyId,
|
||||
'ORCA_REAL_CLAUDE_B',
|
||||
'orca-real-claude-b'
|
||||
)
|
||||
|
||||
const reports: string[] = []
|
||||
for (let cycle = 0; cycle < REAL_CLAUDE_PROBE_CYCLES; cycle += 1) {
|
||||
const tabId = cycle % 2 === 0 ? firstTabId : secondTabId
|
||||
await activateTerminalTab(orcaPage, tabId)
|
||||
await orcaPage.waitForTimeout(120)
|
||||
const repair = await refreshActiveTabAndCompare(orcaPage, tabId)
|
||||
if (repair.bufferUnchanged && repair.diffRatio > REFRESH_REPAIR_DIFF_RATIO) {
|
||||
reports.push(
|
||||
`cycle ${cycle} tab=${tabId} refresh changed ${(repair.diffRatio * 100).toFixed(2)}% of pixels without buffer change (${repair.diffPixels} px)`
|
||||
)
|
||||
await attachArtifact(
|
||||
testInfo,
|
||||
`real-claude-before-refresh-${cycle}.png`,
|
||||
repair.beforeScreenshot
|
||||
)
|
||||
await attachArtifact(
|
||||
testInfo,
|
||||
`real-claude-after-refresh-${cycle}.png`,
|
||||
repair.afterScreenshot
|
||||
)
|
||||
await attachArtifact(testInfo, `real-claude-buffer-${cycle}.txt`, repair.beforeText)
|
||||
break
|
||||
}
|
||||
await orcaPage.waitForTimeout(500)
|
||||
}
|
||||
|
||||
expect(
|
||||
reports,
|
||||
reports.length > 0
|
||||
? `real Claude terminal surface had refresh-repairable stale pixels:\n${reports.join('\n')}`
|
||||
: undefined
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue