From 066c245d14d78451a61decf4490e27632a8a9089 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:33:36 -0700 Subject: [PATCH] fix(terminal): force WebGL repaint after split when dimensions match (#1272) Co-authored-by: Orca --- .../use-terminal-pane-lifecycle.ts | 3 +- .../pane-fit-resize-observer.test.ts | 1 + .../lib/pane-manager/pane-lifecycle.test.ts | 3 +- .../src/lib/pane-manager/pane-lifecycle.ts | 3 +- .../lib/pane-manager/pane-manager-types.ts | 5 + .../lib/pane-manager/pane-tree-ops.test.ts | 3 +- .../src/lib/pane-manager/pane-tree-ops.ts | 23 ++- .../src/runtime/sync-runtime-graph.ts | 12 +- tests/e2e/dead-terminal-repro.spec.ts | 168 +++++++++++++++++ tests/e2e/dead-terminal-stress.spec.ts | 151 +++++++++++++++ tests/e2e/global-setup.ts | 9 +- tests/e2e/helpers/dead-terminal.ts | 175 ++++++++++++++++++ tests/e2e/helpers/runtime-types.ts | 2 + 13 files changed, 540 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/dead-terminal-repro.spec.ts create mode 100644 tests/e2e/dead-terminal-stress.spec.ts create mode 100644 tests/e2e/helpers/dead-terminal.ts diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 5136ef262..72fb203f0 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -663,7 +663,8 @@ export function useTerminalPaneLifecycle({ // Why: TerminalPane instances stay mounted for hidden visited worktrees // so PTYs survive navigation. Creating WebGL for those offscreen panes // still consumes Chromium's context budget and can blank visible panes. - initialRenderingSuspended: !isVisibleRef.current + initialRenderingSuspended: !isVisibleRef.current, + debugLabel: `tab:${tabId}/wt:${worktreeId}` }) managerRef.current = manager diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts index fda07eeea..e40d81f43 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts @@ -58,6 +58,7 @@ function createPane(): ManagedPaneInternal { webglAddon: null, ligaturesAddon: null, compositionHandler: null, + debugLabel: null, pendingSplitScrollState: { wasAtBottom: true, firstVisibleLineContent: '', diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index 41e3f02ce..746239621 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -45,7 +45,8 @@ function createPane(): ManagedPaneInternal { webLinksAddon: {} as never, webglAddon: null, compositionHandler: null, - pendingSplitScrollState: null + pendingSplitScrollState: null, + debugLabel: null } } diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 33281244d..297fd5e6f 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -121,7 +121,8 @@ export function createPaneDOM( webglAddon: null, ligaturesAddon: null, compositionHandler: null, - pendingSplitScrollState: null + pendingSplitScrollState: null, + debugLabel: options.debugLabel ?? null } // Focus handler: clicking a pane makes it active and explicitly focuses diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 84ce99705..0fd7dfb18 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -28,6 +28,10 @@ export type PaneManagerOptions = { terminalOptions?: (paneId: number) => Partial onLinkClick?: (event: MouseEvent | undefined, url: string) => void initialRenderingSuspended?: boolean + // Why: diagnostic label for log correlation. safeFit and other internal + // helpers log warnings that are hard to correlate without knowing which + // tab/worktree the PaneManager belongs to. + debugLabel?: string } export type PaneStyleOptions = { @@ -91,6 +95,7 @@ export type ManagedPaneInternal = { // intermediate fit paths skip their own scroll restoration, deferring to // the splitPane's final authoritative restore. pendingSplitScrollState: ScrollState | null + debugLabel: string | null } & ManagedPane export type DropZone = 'top' | 'bottom' | 'left' | 'right' diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index adaf155ae..e52585fb0 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -52,7 +52,8 @@ function createPane({ webglAddon: null, ligaturesAddon: null, compositionHandler: null, - pendingSplitScrollState: null + pendingSplitScrollState: null, + debugLabel: null } } diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 3187fecd4..31b37b8c3 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -41,18 +41,27 @@ export function safeFit(pane: ManagedPaneInternal): void { // cross a cell boundary. Skipping those avoids FitAddon.clear()+refresh() // churn, which was causing visible terminal blinking while resizing. // - // Why: diagnostic for intermittent dead-terminal-after-split. If a - // just-reparented pane's proposed dimensions match its current - // dimensions (the default 80×24 at certain screen widths), this - // early-return skips fitAddon.fit() and no terminal.resize() fires - // — leaving the WebGL canvas at stale dimensions. + // Why: wrapInSplit() reparents the pane's container, which can leave + // the WebGL canvas stale even when proposed dimensions match current + // (the browser detaches and reattaches the canvas during the DOM move). + // When pendingSplitScrollState is set we must force a fit + refresh so + // the WebGL renderer repaints. Without this, the pane appears blank + // until something forces a dimension change. if (pane.pendingSplitScrollState) { console.warn( - '[terminal] safeFit early-return during pending split for pane', + '[terminal] safeFit forcing fit+refresh during pending split for pane', pane.id, `— dims ${dims.cols}×${dims.rows} match current, webgl:`, - !!pane.webglAddon + !!pane.webglAddon, + pane.debugLabel ? `(${pane.debugLabel})` : '' ) + pane.fitAddon.fit() + try { + pane.terminal.refresh(0, pane.terminal.rows - 1) + } catch { + /* ignore — terminal may not be fully initialised */ + } + return } return } diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index f7502bdeb..53dee0b6f 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -13,6 +13,13 @@ type RegisteredTerminalTab = { } const registeredTabs = new Map() +// Why: track when each tab was registered so we can suppress the "no live +// transport" warning during the initial PTY connection window. The warning +// is noise when it fires on mount (PTY spawn/attach is async and hasn't +// finished yet), but valuable if the transport is still missing after the +// grace period — that indicates a real stuck state. +const tabRegisteredAt = new Map() +const NO_TRANSPORT_GRACE_MS = 10_000 let syncScheduled = false let syncEnabled = false let getStoreState: (() => AppState) | null = null @@ -23,9 +30,11 @@ export function setRuntimeGraphStoreStateGetter(getter: (() => AppState) | null) export function registerRuntimeTerminalTab(tab: RegisteredTerminalTab): () => void { registeredTabs.set(tab.tabId, tab) + tabRegisteredAt.set(tab.tabId, Date.now()) scheduleRuntimeGraphSync() return () => { registeredTabs.delete(tab.tabId) + tabRegisteredAt.delete(tab.tabId) scheduleRuntimeGraphSync() } } @@ -89,7 +98,8 @@ async function syncRuntimeGraph(): Promise { const leafId = paneLeafId(pane.id) const ptyId = registeredTab.getPtyIdForPane(pane.id) const savedPtyId = savedPtyIdsByLeafId[leafId] ?? null - if (!ptyId && savedPtyId) { + const registeredTime = tabRegisteredAt.get(tabId) ?? 0 + if (!ptyId && savedPtyId && Date.now() - registeredTime > NO_TRANSPORT_GRACE_MS) { warnTerminalLifecycleAnomaly('mounted terminal leaf has saved PTY but no live transport', { tabId, worktreeId: registeredTab.worktreeId, diff --git a/tests/e2e/dead-terminal-repro.spec.ts b/tests/e2e/dead-terminal-repro.spec.ts new file mode 100644 index 000000000..dce799ef7 --- /dev/null +++ b/tests/e2e/dead-terminal-repro.spec.ts @@ -0,0 +1,168 @@ +/** + * Stress test for dead-terminal reproduction (setup-split flow). + * + * Why @headful: the dead-terminal bug is a WebGL canvas staleness issue — after + * wrapInSplit() reparents the existing pane's container, the WebGL canvas can + * fail to repaint. In headless mode WebGL is NEVER active, so the DOM fallback + * renderer is used and the bug cannot manifest. Running headful ensures real + * WebGL contexts matching production. + * + * See helpers/dead-terminal.ts for the shared worktree-creation helper that + * replicates the exact activateAndRevealWorktree + ensureWorktreeHasInitialTerminal + * production flow. + */ + +import { test, expect } from './helpers/orca-app' +import { + waitForSessionReady, + waitForActiveWorktree, + getActiveWorktreeId, + switchToWorktree, + ensureTerminalVisible +} from './helpers/store' +import { waitForActiveTerminalManager, waitForPaneCount } from './helpers/terminal' +import { + createAndActivateWorktreeWithSetup, + removeWorktreeViaStore, + waitForAllPanesToHaveContent, + checkWebglState +} from './helpers/dead-terminal' + +const STRESS_ITERATIONS = 5 + +test.describe('Dead Terminal Reproduction @headful', () => { + const createdWorktreeIds: string[] = [] + + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + + await orcaPage.evaluate(async () => { + const state = window.__store?.getState() + if (!state) { + return + } + state.updateSettings({ setupScriptLaunchMode: 'split-vertical' }) + + // Why: write orca.yaml into the repo so createWorktree IPC returns a + // WorktreeSetupLaunch with a runner script, triggering the setup split. + // This is scoped to the dead-terminal tests to avoid breaking other + // specs that don't expect setup scripts to fire on worktree creation. + const wt = Object.values(state.worktreesByRepo) + .flat() + .find((w) => w.id === state.activeWorktreeId) + if (wt) { + const sep = wt.path.includes('\\') ? '\\' : '/' + await window.api.fs.writeFile({ + filePath: `${wt.path}${sep}orca.yaml`, + content: 'scripts:\n setup: echo SETUP_COMPLETE\n' + }) + } + }) + }) + + test.afterEach(async ({ orcaPage }) => { + for (const id of createdWorktreeIds) { + await removeWorktreeViaStore(orcaPage, id) + } + createdWorktreeIds.length = 0 + }) + + test('@headful setup-split flow does not produce dead terminals', async ({ orcaPage }) => { + test.setTimeout(120_000) + const homeWorktreeId = await waitForActiveWorktree(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await checkWebglState(orcaPage, 'home-initial') + + for (let i = 0; i < STRESS_ITERATIONS; i++) { + const direction = i % 2 === 0 ? 'vertical' : 'horizontal' + const newId = await createAndActivateWorktreeWithSetup(orcaPage, `setup-${i}`, direction) + createdWorktreeIds.push(newId) + + await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPaneCount(orcaPage, 2, 15_000) + await checkWebglState(orcaPage, `setup-${i}`) + await waitForAllPanesToHaveContent(orcaPage, `setup-${i} both panes`) + + await switchToWorktree(orcaPage, homeWorktreeId) + await expect + .poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }) + .toBe(homeWorktreeId) + await removeWorktreeViaStore(orcaPage, newId) + createdWorktreeIds.pop() + } + }) + + test('@headful setup-split then switch-back does not leave panes dead', async ({ orcaPage }) => { + test.setTimeout(120_000) + const homeWorktreeId = await waitForActiveWorktree(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + for (let i = 0; i < STRESS_ITERATIONS; i++) { + const newId = await createAndActivateWorktreeWithSetup( + orcaPage, + `switchback-${i}`, + 'vertical' + ) + createdWorktreeIds.push(newId) + + await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPaneCount(orcaPage, 2, 15_000) + await waitForAllPanesToHaveContent(orcaPage, `switchback-${i} initial`) + + await switchToWorktree(orcaPage, homeWorktreeId) + await expect + .poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }) + .toBe(homeWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 15_000) + + await switchToWorktree(orcaPage, newId) + await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 15_000) + await waitForAllPanesToHaveContent(orcaPage, `switchback-${i} after return`) + + await switchToWorktree(orcaPage, homeWorktreeId) + await expect + .poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }) + .toBe(homeWorktreeId) + await removeWorktreeViaStore(orcaPage, newId) + createdWorktreeIds.pop() + } + }) + + test('@headful rapid switching between many setup-split worktrees', async ({ orcaPage }) => { + test.setTimeout(120_000) + const homeWorktreeId = await waitForActiveWorktree(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const worktreeIds = [homeWorktreeId] + for (let i = 0; i < 4; i++) { + const newId = await createAndActivateWorktreeWithSetup(orcaPage, `multi-${i}`, 'vertical') + createdWorktreeIds.push(newId) + worktreeIds.push(newId) + + await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPaneCount(orcaPage, 2, 15_000) + await waitForAllPanesToHaveContent(orcaPage, `multi-create-${i}`) + } + + for (let round = 0; round < 3; round++) { + for (const wId of worktreeIds) { + await switchToWorktree(orcaPage, wId) + await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(wId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 15_000) + await waitForAllPanesToHaveContent(orcaPage, `multi-r${round}-${wId.slice(0, 8)}`) + } + } + }) +}) diff --git a/tests/e2e/dead-terminal-stress.spec.ts b/tests/e2e/dead-terminal-stress.spec.ts new file mode 100644 index 000000000..8ffb1a614 --- /dev/null +++ b/tests/e2e/dead-terminal-stress.spec.ts @@ -0,0 +1,151 @@ +/** + * Aggressive stress tests for dead-terminal reproduction. + * + * These tests target specific failure vectors beyond the basic setup-split flow: + * - Forced WebGL context loss (simulating Chromium memory pressure) + * - Rapid switching during the ~200ms scheduleSplitScrollRestore window + * + * All tests require @headful mode for WebGL to be active. + */ + +import { test, expect } from './helpers/orca-app' +import { + waitForSessionReady, + waitForActiveWorktree, + getActiveWorktreeId, + switchToWorktree, + ensureTerminalVisible +} from './helpers/store' +import { waitForActiveTerminalManager, waitForPaneCount } from './helpers/terminal' +import { + createAndActivateWorktreeWithSetup, + removeWorktreeViaStore, + waitForAllPanesToHaveContent +} from './helpers/dead-terminal' + +const STRESS_ITERATIONS = 5 + +test.describe('Dead Terminal Stress @headful', () => { + const createdWorktreeIds: string[] = [] + + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + + await orcaPage.evaluate(async () => { + const state = window.__store?.getState() + if (!state) { + return + } + state.updateSettings({ setupScriptLaunchMode: 'split-vertical' }) + + const wt = Object.values(state.worktreesByRepo) + .flat() + .find((w) => w.id === state.activeWorktreeId) + if (wt) { + const sep = wt.path.includes('\\') ? '\\' : '/' + await window.api.fs.writeFile({ + filePath: `${wt.path}${sep}orca.yaml`, + content: 'scripts:\n setup: echo SETUP_COMPLETE\n' + }) + } + }) + }) + + test.afterEach(async ({ orcaPage }) => { + for (const id of createdWorktreeIds) { + await removeWorktreeViaStore(orcaPage, id) + } + createdWorktreeIds.length = 0 + }) + + /** + * Force WebGL context loss on visible canvases immediately after a setup + * split. In production, Chromium reclaims WebGL contexts under memory + * pressure — especially with many worktrees open. The recovery path is: + * onContextLoss → dispose WebGL → DOM fallback → rAF → fit + refresh. + */ + test('@headful setup-split with forced WebGL context loss recovers', async ({ orcaPage }) => { + test.setTimeout(120_000) + const homeWorktreeId = await waitForActiveWorktree(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + for (let i = 0; i < STRESS_ITERATIONS; i++) { + const newId = await createAndActivateWorktreeWithSetup(orcaPage, `ctxloss-${i}`, 'vertical') + createdWorktreeIds.push(newId) + + await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPaneCount(orcaPage, 2, 15_000) + + const lostCount = await orcaPage.evaluate(() => { + const canvases = document.querySelectorAll('.pane canvas:not(.xterm-link-layer)') + let lost = 0 + for (const canvas of canvases) { + const gl = + (canvas as HTMLCanvasElement).getContext('webgl2') ?? + (canvas as HTMLCanvasElement).getContext('webgl') + if (gl) { + const ext = gl.getExtension('WEBGL_lose_context') + if (ext) { + ext.loseContext() + lost++ + } + } + } + return lost + }) + if (lostCount > 0) { + console.log(`[ctxloss-${i}] Forced context loss on ${lostCount} canvases`) + } + + await orcaPage.waitForTimeout(500) + await waitForAllPanesToHaveContent(orcaPage, `ctxloss-${i} after context loss`) + + await switchToWorktree(orcaPage, homeWorktreeId) + await expect + .poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }) + .toBe(homeWorktreeId) + await removeWorktreeViaStore(orcaPage, newId) + createdWorktreeIds.pop() + } + }) + + /** + * Switch worktrees WITHOUT waiting for the split to settle. This hits the + * race between wrapInSplit() reparenting, WebGL context creation during + * resumeRendering(), and the scheduleSplitScrollRestore 200ms timer. + */ + test('@headful rapid worktree switching during setup-split lifecycle', async ({ orcaPage }) => { + test.setTimeout(120_000) + const homeWorktreeId = await waitForActiveWorktree(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + for (let i = 0; i < 3; i++) { + const newId = await createAndActivateWorktreeWithSetup(orcaPage, `rapid-${i}`, 'vertical') + createdWorktreeIds.push(newId) + + // Switch away during the ~200ms scheduleSplitScrollRestore window + await orcaPage.waitForTimeout(50) + await switchToWorktree(orcaPage, homeWorktreeId) + await orcaPage.waitForTimeout(50) + + // Switch back — triggers resumeRendering on partially-initialized panes + await switchToWorktree(orcaPage, newId) + await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPaneCount(orcaPage, 2, 15_000) + await waitForAllPanesToHaveContent(orcaPage, `rapid-${i} after return`) + + await switchToWorktree(orcaPage, homeWorktreeId) + await expect + .poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }) + .toBe(homeWorktreeId) + await removeWorktreeViaStore(orcaPage, newId) + createdWorktreeIds.pop() + } + }) +}) diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index ef4eaa037..cfb96a1b2 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -33,7 +33,7 @@ export default function globalSetup(): void { execSync('npx electron-vite build --mode e2e', { cwd: root, stdio: 'inherit', - timeout: 120_000, + timeout: 120_000 }) console.log('[e2e] Build complete.') } @@ -53,10 +53,7 @@ export default function globalSetup(): void { path.join(testRepoDir, 'README.md'), '# Orca E2E Test Repo\n\nThis repo was created automatically for Playwright tests.\n' ) - writeFileSync( - path.join(testRepoDir, 'CLAUDE.md'), - '# CLAUDE.md\n\nTest instructions for E2E.\n' - ) + writeFileSync(path.join(testRepoDir, 'CLAUDE.md'), '# CLAUDE.md\n\nTest instructions for E2E.\n') writeFileSync( path.join(testRepoDir, 'package.json'), `${JSON.stringify({ name: 'orca-e2e-test', version: '0.0.0', private: true }, null, 2)}\n` @@ -74,7 +71,7 @@ export default function globalSetup(): void { const worktreeDir = path.join(testRepoDir, '..', `orca-e2e-worktree-${Date.now()}`) execSync(`git worktree add "${worktreeDir}" -b e2e-secondary`, { cwd: testRepoDir, - stdio: 'pipe', + stdio: 'pipe' }) console.log(`[e2e] Secondary worktree created at ${worktreeDir}`) diff --git a/tests/e2e/helpers/dead-terminal.ts b/tests/e2e/helpers/dead-terminal.ts new file mode 100644 index 000000000..b26a2a178 --- /dev/null +++ b/tests/e2e/helpers/dead-terminal.ts @@ -0,0 +1,175 @@ +/** + * Shared helpers for dead-terminal reproduction tests. + * + * These helpers exercise the real production worktree-creation-with-setup-split + * flow and verify that terminal panes render content after the split. + */ + +import { expect } from '@playwright/test' +import type { getActiveWorktreeId } from './store' + +type TestPage = Parameters[0] + +/** + * Create a worktree with setup via the real IPC flow, then activate it + * replicating the exact activateAndRevealWorktree + ensureWorktreeHasInitialTerminal + * path. The setup split is queued on the tab BEFORE the TerminalPane mounts, + * matching production timing. + */ +export async function createAndActivateWorktreeWithSetup( + page: TestPage, + suffix: string, + direction: 'vertical' | 'horizontal' +): Promise { + const name = `e2e-dead-term-${suffix}-${Date.now()}` + return page.evaluate( + async ({ worktreeName, direction }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + const activeWorktreeId = state.activeWorktreeId + if (!activeWorktreeId) { + throw new Error('No active worktree') + } + + const activeWorktree = Object.values(state.worktreesByRepo) + .flat() + .find((wt) => wt.id === activeWorktreeId) + if (!activeWorktree) { + throw new Error('Active worktree not found in store') + } + + const result = await state.createWorktree( + activeWorktree.repoId, + worktreeName, + undefined, + 'run' + ) + await state.fetchWorktrees(activeWorktree.repoId) + const worktreeId = result.worktree.id + + if (activeWorktree.repoId !== state.activeRepoId) { + state.setActiveRepo(activeWorktree.repoId) + } + if (store.getState().activeView !== 'terminal') { + state.setActiveView('terminal') + } + state.setActiveWorktree(worktreeId) + + const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId) + if (renderableTabCount > 0) { + return worktreeId + } + + const tab = state.createTab(worktreeId, undefined, undefined, { + pendingActivationSpawn: true + }) + state.setActiveTab(tab.id) + + if (result.setup) { + const runnerPath = result.setup.runnerScriptPath + const command = `bash ${runnerPath}` + state.queueTabSetupSplit(tab.id, { + command, + env: result.setup.envVars, + direction + }) + } + + state.revealWorktreeInSidebar(worktreeId) + return worktreeId + }, + { worktreeName: name, direction } + ) +} + +export async function removeWorktreeViaStore(page: TestPage, worktreeId: string): Promise { + await page.evaluate(async (id) => { + try { + await window.__store?.getState().removeWorktree(id, true) + } catch { + /* best-effort */ + } + }, worktreeId) +} + +/** + * Poll until every pane in the active tab's PaneManager has non-empty buffer + * content. A dead terminal has an empty serialize() result because the WebGL + * canvas never painted the shell prompt. + */ +export async function waitForAllPanesToHaveContent( + page: TestPage, + label: string, + timeoutMs = 15_000 +): Promise { + await expect + .poll( + async () => { + return page.evaluate(() => { + const store = window.__store + if (!store) { + return { ok: false, reason: 'no store' } + } + const state = store.getState() + const wId = state.activeWorktreeId + if (!wId) { + return { ok: false, reason: 'no active worktree' } + } + const tabs = state.tabsByWorktree[wId] ?? [] + const tabId = + state.activeTabType === 'terminal' + ? state.activeTabId + : (state.activeTabIdByWorktree?.[wId] ?? tabs[0]?.id) + if (!tabId) { + return { ok: false, reason: 'no tab' } + } + + const manager = window.__paneManagers?.get(tabId) + if (!manager) { + return { ok: false, reason: 'no manager' } + } + const panes = manager.getPanes?.() ?? [] + if (panes.length === 0) { + return { ok: false, reason: 'no panes' } + } + + const paneStates = panes.map((pane) => { + const content = pane.serializeAddon?.serialize?.() ?? '' + // oxlint-disable-next-line no-control-regex -- stripping terminal control chars is intentional + const stripped = content.replace(/[\s\x00-\x1f]/g, '') + return { id: pane.id, hasContent: stripped.length > 0 } + }) + + return { ok: paneStates.every((p) => p.hasContent), paneStates } + }) + }, + { + timeout: timeoutMs, + message: `[${label}] Not all terminal panes have rendered content (possible dead terminal)` + } + ) + .toMatchObject({ ok: true }) +} + +/** + * Log WebGL canvas state for diagnostics. In headful mode, visible panes + * should have WebGL canvases; hidden panes (suspended rendering) should not. + */ +export async function checkWebglState(page: TestPage, label: string): Promise { + const paneStates = await page.evaluate(() => { + const containers = document.querySelectorAll('.pane[data-pane-id]') + return Array.from(containers).map((c) => ({ + paneId: (c as HTMLElement).dataset.paneId, + canvasCount: c.querySelectorAll('canvas').length + })) + }) + + const hasCanvas = paneStates.some((p) => p.canvasCount > 0) + if (!hasCanvas) { + console.warn(`[${label}] No WebGL canvases — DOM renderer only.`) + } +} diff --git a/tests/e2e/helpers/runtime-types.ts b/tests/e2e/helpers/runtime-types.ts index 593cdb6d4..a376e2416 100644 --- a/tests/e2e/helpers/runtime-types.ts +++ b/tests/e2e/helpers/runtime-types.ts @@ -19,6 +19,8 @@ export type PaneManagerLike = { splitPane?(paneId: number, direction: 'vertical' | 'horizontal'): ManagedPane | null closePane?(paneId: number): void setActivePane?(paneId: number, opts?: { focus?: boolean }): void + suspendRendering?(): void + resumeRendering?(): void } export type ExplorerFileSummary = Pick