fix(terminal): force WebGL repaint after split when dimensions match (#1272)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
a02987e604
commit
066c245d14
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ function createPane(): ManagedPaneInternal {
|
|||
webglAddon: null,
|
||||
ligaturesAddon: null,
|
||||
compositionHandler: null,
|
||||
debugLabel: null,
|
||||
pendingSplitScrollState: {
|
||||
wasAtBottom: true,
|
||||
firstVisibleLineContent: '',
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ function createPane(): ManagedPaneInternal {
|
|||
webLinksAddon: {} as never,
|
||||
webglAddon: null,
|
||||
compositionHandler: null,
|
||||
pendingSplitScrollState: null
|
||||
pendingSplitScrollState: null,
|
||||
debugLabel: null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ export type PaneManagerOptions = {
|
|||
terminalOptions?: (paneId: number) => Partial<ITerminalOptions>
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ function createPane({
|
|||
webglAddon: null,
|
||||
ligaturesAddon: null,
|
||||
compositionHandler: null,
|
||||
pendingSplitScrollState: null
|
||||
pendingSplitScrollState: null,
|
||||
debugLabel: null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ type RegisteredTerminalTab = {
|
|||
}
|
||||
|
||||
const registeredTabs = new Map<string, RegisteredTerminalTab>()
|
||||
// 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<string, number>()
|
||||
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<void> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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)}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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}`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof getActiveWorktreeId>[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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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.`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<OpenFile, 'id' | 'filePath' | 'relativePath'>
|
||||
|
|
|
|||
Loading…
Reference in New Issue