mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
Fix hidden/parked split-pane exit stranding ghost or resurrected panes
Deterministic e2e repro of the field 'ghost blank pane' incident (a
closed/finished setup-split leaf persisted in root with no binding and
remounted as a permanently blank pane) found two teardown gaps at the
hidden-view parking boundary:
1. The kept-exit guard ('freshly split pane can lose its newborn PTY
during setup') fired for HIDDEN panes. The hidden-delivery gate
withholds their bytes, so a hidden split always looks output-less and
its dead pane was kept — a binding-less ghost that dead-session
reconcile can never reach (no PTY id to prove dead). The keep is a
visible-failure UX; gate it on isVisibleRef and close hidden panes.
2. A PTY exit landing while the tab is PARKED reached only the parked
watcher's exit sidecar (hosts' onPtyExit requires a mounted
TerminalPane), which only disposed the watcher. The leaf's stale
binding then reattached on reveal and the daemon re-created the
exited session id as a fresh shell — silent pane resurrection. The
sidecar now collapses the dead leaf out of the stored layout via
detachTerminalLayoutLeaf (the observed-exit teardown's data half).
New e2e suite terminal-pane-close-layout-consistency.spec.ts sweeps
close/shell-exit at every hidden/park lifecycle phase and asserts
leaves(root) == bindings == live panes; all 7 scenarios pass. Unit
regressions added for both fixes.
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -127,6 +127,23 @@ Three cooperating layers, innermost first:
|
||||
daemon session ids embed worktree paths. Recording happens only on rare
|
||||
transitions; the table/report is built only when read. This exists so a
|
||||
field freeze report never needs a follow-up ask.
|
||||
- **Hidden/parked exit teardown completeness** (pty-connection.ts kept-exit
|
||||
guard + `terminal-parked-tab-watchers.ts` exit sidecar): two invariants that
|
||||
keep a split pane's death near the hidden/park boundary from stranding state
|
||||
(field incident: a closed setup-split leaf persisted in `root` with no
|
||||
binding and remounted as a permanently blank pane, unreachable by
|
||||
dead-session reconcile — it skips ptyId-null panes by design).
|
||||
(1) The "keep a fresh split whose newborn PTY died" branch is **gated on
|
||||
`isVisibleRef`** — hidden panes' bytes are gate-withheld, so "no output"
|
||||
proves nothing there; a hidden newborn death must `closePane`, or the kept
|
||||
pane becomes a binding-less ghost. (2) A PTY exit that lands **while parked**
|
||||
reaches ONLY the parked watcher's exit sidecar (hosts' `onPtyExit` needs a
|
||||
mounted TerminalPane), so the sidecar itself collapses the dead leaf out of
|
||||
the stored layout via `detachTerminalLayoutLeaf` — a stale binding left
|
||||
behind reattaches on reveal and the daemon re-creates the exited session id
|
||||
as a fresh shell (silent pane resurrection). E2e:
|
||||
`terminal-pane-close-layout-consistency.spec.ts` sweeps close/exit at every
|
||||
lifecycle phase and asserts leaves(root) == bindings == live panes.
|
||||
- **Producer flow control** (protocol v19 `pausePty`/`resumePty`, 256KB pause /
|
||||
32KB resume watermarks, keyed off **pendingData only** — never renderer
|
||||
counters; kill switch `PRODUCER_FLOW_CONTROL_ENABLED`, ipc/pty.ts): when main's
|
||||
|
||||
@@ -2167,6 +2167,32 @@ describe('connectPanePty', () => {
|
||||
expect(manager.setActivePane).toHaveBeenCalledWith(1, { focus: true })
|
||||
})
|
||||
|
||||
it('closes a hidden split pane whose PTY exits before output instead of keeping a ghost', async () => {
|
||||
// Why (regression, ghost blank pane): the keep above is a visible-failure
|
||||
// UX. A hidden pane's bytes are withheld by the hidden-delivery gate, so
|
||||
// "no output" proves nothing there — keeping it strands a binding-less
|
||||
// pane that remounts as a permanently blank ghost on reveal.
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-pane-2')
|
||||
transportFactoryQueue.push(transport)
|
||||
const manager = createManager(2, 2)
|
||||
const deps = createDeps({
|
||||
restoredLeafId: LEAF_2,
|
||||
isVisibleRef: { current: false },
|
||||
paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
|
||||
})
|
||||
|
||||
connectPanePty(createPane(2) as never, manager as never, deps as never)
|
||||
const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined
|
||||
expect(onPtyExit).toBeTypeOf('function')
|
||||
|
||||
onPtyExit?.('pty-pane-2')
|
||||
|
||||
expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-pane-2')
|
||||
expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
|
||||
expect(manager.closePane).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('keeps a worktree sole terminal mounted when its freshly-spawned PTY exits before input (direnv failure)', async () => {
|
||||
// Why (regression): a PR worktree can ship an .envrc whose direnv command
|
||||
// fails, so the only terminal's login shell exits non-zero immediately. The
|
||||
|
||||
@@ -2108,6 +2108,7 @@ export function connectPanePty(
|
||||
return
|
||||
}
|
||||
if (
|
||||
deps.isVisibleRef.current &&
|
||||
hadExistingPaneTransportAtConnect &&
|
||||
!restoredPtyIdForTransport &&
|
||||
!Number.isFinite(lastTerminalInputAt) &&
|
||||
@@ -2115,6 +2116,10 @@ export function connectPanePty(
|
||||
) {
|
||||
// Why: a freshly split pane can lose its newborn PTY during setup; keep
|
||||
// the split visible so the failed session does not immediately collapse.
|
||||
// Hidden panes must close instead: the hidden-delivery gate withholds
|
||||
// their bytes, so "no output" is meaningless there, and keeping one
|
||||
// strands a binding-less pane the exit path never revisits — it remounts
|
||||
// as a permanently blank ghost on reveal.
|
||||
focusSurvivingPtyPaneAfterKeptExit()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ type MockStoreState = {
|
||||
>
|
||||
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
|
||||
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
|
||||
setTabLayout: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
let mockStoreState: MockStoreState
|
||||
@@ -103,7 +104,8 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
mockStoreState = {
|
||||
terminalLayoutsByTabId: {},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
clearRuntimePaneTitle: vi.fn()
|
||||
clearRuntimePaneTitle: vi.fn(),
|
||||
setTabLayout: vi.fn()
|
||||
}
|
||||
;(globalThis as { window?: unknown }).window = { api: { pty: { write: ptyWrite } } }
|
||||
})
|
||||
@@ -336,6 +338,38 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 1)
|
||||
})
|
||||
|
||||
it('collapses the exited leaf out of the stored layout when deferring', () => {
|
||||
// Why (regression, ghost/resurrected pane): a deferred parked exit that
|
||||
// leaves the leaf and its binding in the stored layout reattaches on
|
||||
// reveal — the daemon re-creates the exited session id as a fresh shell.
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked()
|
||||
mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: LEAF_ID },
|
||||
second: { type: 'leaf', leafId: SECOND_LEAF_ID }
|
||||
},
|
||||
activeLeafId: SECOND_LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID }
|
||||
}
|
||||
|
||||
hostOnPtyExit(TAB_ID, SECOND_PTY_ID)
|
||||
|
||||
expect(closeTab).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.setTabLayout).toHaveBeenCalledWith(TAB_ID, {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps exit→closeTab parity for a parked single-leaf tab', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
@@ -351,6 +385,53 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
expect(closeTab).toHaveBeenCalledWith(TAB_ID)
|
||||
})
|
||||
|
||||
it('collapses the exited leaf via the watcher exit sidecar (no host handler while parked)', () => {
|
||||
// Why: hosts' onPtyExit runs from a mounted TerminalPane, so an exit
|
||||
// that lands while parked reaches ONLY the watcher sidecar — it must run
|
||||
// the layout collapse itself or the leaf resurrects on reveal.
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked()
|
||||
mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: LEAF_ID },
|
||||
second: { type: 'leaf', leafId: SECOND_LEAF_ID }
|
||||
},
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID }
|
||||
}
|
||||
|
||||
exitSubscriptions.find((entry) => entry.ptyId === SECOND_PTY_ID)?.callback(0)
|
||||
|
||||
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2)
|
||||
expect(mockStoreState.setTabLayout).toHaveBeenCalledWith(TAB_ID, {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not touch the layout when the last parked watcher exits (tab-level close owns it)', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID }
|
||||
}
|
||||
|
||||
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0)
|
||||
|
||||
expect(mockStoreState.setTabLayout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes the tab when the last surviving leaf of a parked split exits', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
|
||||
@@ -14,6 +14,7 @@ import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { collectLeafIdsInOrder } from './terminal-layout-leaf-ids'
|
||||
import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach'
|
||||
import { subscribeToPtyExit } from './pty-dispatcher'
|
||||
import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher'
|
||||
import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking'
|
||||
@@ -146,6 +147,15 @@ function startParkedTabWatchers(worktreeId: string, tab: ParkableTerminalTabMode
|
||||
// Why: a PTY that exits while parked has no pane to run exit cleanup; at
|
||||
// minimum its watcher must not outlive it.
|
||||
const unsubscribeExit = subscribeToPtyExit(ptyId, () => {
|
||||
// Why: while parked this sidecar is the ONLY exit observer — the hosts'
|
||||
// onPtyExit runs from a mounted TerminalPane. Run the observed-exit
|
||||
// teardown's data half here for a multi-leaf tab, or the dead leaf's
|
||||
// stale binding reattaches on reveal and the daemon re-creates the
|
||||
// exited session id as a fresh shell, resurrecting the pane.
|
||||
if (disposersByPtyId.size > 1) {
|
||||
useAppStore.getState().clearRuntimePaneTitle(tab.id, pane.paneId)
|
||||
collapseParkedExitedLeaf(tab.id, ptyId)
|
||||
}
|
||||
disposersByPtyId.get(ptyId)?.()
|
||||
disposersByPtyId.delete(ptyId)
|
||||
// Why: with the last watcher gone there is nothing left to watch or
|
||||
@@ -198,7 +208,32 @@ export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string):
|
||||
// removes the dead PTY's watcher — so the watcher count still includes the
|
||||
// exiting PTY. More than one watcher (or an exit for an unwatched PTY)
|
||||
// means live sibling leaves remain.
|
||||
return remaining > 1 || !entry.disposersByPtyId.has(ptyId)
|
||||
const defer = remaining > 1 || !entry.disposersByPtyId.has(ptyId)
|
||||
if (defer) {
|
||||
collapseParkedExitedLeaf(tabId, ptyId)
|
||||
}
|
||||
return defer
|
||||
}
|
||||
|
||||
// Why: deferring the tab close is not enough — a stale leaf binding left in
|
||||
// the stored layout reattaches on reveal, and the daemon re-creates the exited
|
||||
// session id as a fresh shell, resurrecting a pane whose shell already ended.
|
||||
// With no PaneManager mounted, the observed-exit teardown's data half runs
|
||||
// here instead: collapse the leaf out of the stored layout so the reveal
|
||||
// replays only the surviving panes.
|
||||
function collapseParkedExitedLeaf(tabId: string, ptyId: string): void {
|
||||
const state = useAppStore.getState()
|
||||
const layout = state.terminalLayoutsByTabId[tabId]
|
||||
const leafId =
|
||||
capturedPanesByTabId.get(tabId)?.panes.find((pane) => pane.ptyId === ptyId)?.leafId ??
|
||||
Object.entries(layout?.ptyIdsByLeafId ?? {}).find(([, boundPtyId]) => boundPtyId === ptyId)?.[0]
|
||||
if (!leafId) {
|
||||
return
|
||||
}
|
||||
const detached = detachTerminalLayoutLeaf(layout, leafId)
|
||||
if (detached) {
|
||||
state.setTabLayout(tabId, detached.sourceLayout)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import {
|
||||
ensureTerminalVisible,
|
||||
getActiveTabId,
|
||||
waitForActiveWorktree,
|
||||
waitForSessionReady
|
||||
} from './helpers/store'
|
||||
import {
|
||||
sendToTerminal,
|
||||
splitActiveTerminalPane,
|
||||
waitForActiveTerminalManager,
|
||||
waitForPaneIdentitySnapshot
|
||||
} from './helpers/terminal'
|
||||
|
||||
/**
|
||||
* Repro hunt for the "ghost blank pane" field incident: a split pane whose PTY
|
||||
* and leaf→PTY binding were torn down while the persisted layout `root` kept
|
||||
* the leaf, so revisiting the tab materialized a permanently blank pane with
|
||||
* no terminal behind it.
|
||||
*
|
||||
* Field state (workspace remote-issue-2, 2026-07-09): root held 3 leaves,
|
||||
* ptyIdsByLeafId held 2, no daemon session for the third — the close/exit ran
|
||||
* near a hidden/park boundary. Each test here closes (or exits) a split pane
|
||||
* at a different phase of the hidden-view parking lifecycle and asserts the
|
||||
* invariant that broke in the field:
|
||||
*
|
||||
* leaves(persisted root) === keys(persisted ptyIdsByLeafId) === live panes
|
||||
*
|
||||
* A failing scenario IS the finding — it pins which boundary loses the layout
|
||||
* collapse.
|
||||
*/
|
||||
|
||||
// Why 2000ms: the override shrinks BOTH cold-park delay and hot-retain, and
|
||||
// the hidden-but-mounted scenario needs the shell exit to land well inside the
|
||||
// hot-retain window — 500ms let slow shell teardown race past parking and turn
|
||||
// that scenario into the exits-while-parked one.
|
||||
const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 2_000
|
||||
|
||||
test.use({
|
||||
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }
|
||||
})
|
||||
|
||||
type ParkingDebugWindow = Window & {
|
||||
__terminalParkingDebug?: { parkDelayMs?: number }
|
||||
}
|
||||
|
||||
async function skipUnlessParkingWired(page: Page): Promise<void> {
|
||||
const deadline = Date.now() + 2_000
|
||||
let present = await page.evaluate(
|
||||
() => (window as ParkingDebugWindow).__terminalParkingDebug !== undefined
|
||||
)
|
||||
while (!present && Date.now() < deadline) {
|
||||
await page.waitForTimeout(250)
|
||||
present = await page.evaluate(
|
||||
() => (window as ParkingDebugWindow).__terminalParkingDebug !== undefined
|
||||
)
|
||||
}
|
||||
test.skip(!present, 'terminal hidden view parking wiring is not compiled in')
|
||||
}
|
||||
|
||||
type LayoutConsistency = {
|
||||
rootLeafIds: string[]
|
||||
boundLeafIds: string[]
|
||||
boundPtyIds: string[]
|
||||
livePaneCount: number | null
|
||||
hasManager: boolean
|
||||
domPaneCount: number
|
||||
}
|
||||
|
||||
async function readLayoutConsistency(page: Page, tabId: string): Promise<LayoutConsistency> {
|
||||
return page.evaluate((tabId) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store unavailable')
|
||||
}
|
||||
const layout = store.getState().terminalLayoutsByTabId[tabId]
|
||||
const rootLeafIds: string[] = []
|
||||
type LayoutNode =
|
||||
| { type: 'leaf'; leafId: string }
|
||||
| { type: 'split'; first: LayoutNode; second: LayoutNode }
|
||||
const walk = (node: LayoutNode | null | undefined): void => {
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
if (node.type === 'leaf') {
|
||||
rootLeafIds.push(node.leafId)
|
||||
return
|
||||
}
|
||||
walk(node.first)
|
||||
walk(node.second)
|
||||
}
|
||||
walk((layout?.root ?? null) as LayoutNode | null)
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
const managerPanes = manager?.getPanes?.() ?? null
|
||||
const paneElements = managerPanes ? new Set(managerPanes.map((pane) => pane.container)) : null
|
||||
return {
|
||||
rootLeafIds,
|
||||
boundLeafIds: Object.keys(layout?.ptyIdsByLeafId ?? {}),
|
||||
boundPtyIds: Object.values(layout?.ptyIdsByLeafId ?? {}),
|
||||
livePaneCount: managerPanes ? managerPanes.length : null,
|
||||
hasManager: manager !== undefined,
|
||||
domPaneCount: paneElements
|
||||
? Array.from(document.querySelectorAll<HTMLElement>('.pane[data-leaf-id]')).filter(
|
||||
(element) => paneElements.has(element)
|
||||
).length
|
||||
: 0
|
||||
}
|
||||
}, tabId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The invariant under hunt. Polls so post-close persists can land, then does a
|
||||
* final full read whose diff names the divergence (stale root leaf vs dropped
|
||||
* binding vs ghost live pane).
|
||||
*/
|
||||
async function expectLayoutConsistent(
|
||||
page: Page,
|
||||
tabId: string,
|
||||
expectedPaneCount: number,
|
||||
phase: string,
|
||||
deadPtyId?: string
|
||||
): Promise<void> {
|
||||
// Why: polling a shape (not a boolean) makes a timeout print the diverged
|
||||
// state — which of root/bindings/live panes went stale is the finding.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await readLayoutConsistency(page, tabId)
|
||||
return {
|
||||
hasManager: state.hasManager,
|
||||
livePaneCount: state.livePaneCount,
|
||||
domPaneCount: state.domPaneCount,
|
||||
rootLeafCount: state.rootLeafIds.length,
|
||||
boundLeafCount: state.boundLeafIds.length,
|
||||
unboundRootLeafIds: state.rootLeafIds.filter(
|
||||
(leafId) => !state.boundLeafIds.includes(leafId)
|
||||
),
|
||||
deadPtyStillBound: deadPtyId ? state.boundPtyIds.includes(deadPtyId) : false
|
||||
}
|
||||
},
|
||||
{
|
||||
timeout: 15_000,
|
||||
message: `[${phase}] layout did not settle to ${expectedPaneCount} consistent pane(s)`
|
||||
}
|
||||
)
|
||||
.toEqual({
|
||||
hasManager: true,
|
||||
livePaneCount: expectedPaneCount,
|
||||
domPaneCount: expectedPaneCount,
|
||||
rootLeafCount: expectedPaneCount,
|
||||
boundLeafCount: expectedPaneCount,
|
||||
unboundRootLeafIds: [],
|
||||
deadPtyStillBound: false
|
||||
})
|
||||
}
|
||||
|
||||
async function closeLastPaneOnTab(page: Page, tabId: string): Promise<void> {
|
||||
await page.evaluate((tabId) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager) {
|
||||
throw new Error(`closeLastPaneOnTab: no mounted pane manager for tab ${tabId}`)
|
||||
}
|
||||
const target = manager.getPanes().at(-1)
|
||||
if (!target) {
|
||||
throw new Error('closeLastPaneOnTab: tab has no panes')
|
||||
}
|
||||
manager.closePane(target.id)
|
||||
}, tabId)
|
||||
}
|
||||
|
||||
async function createActiveTerminalTab(page: Page, worktreeId: string): Promise<string> {
|
||||
const tabId = await page.evaluate((worktreeId) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('createActiveTerminalTab: window.__store is unavailable')
|
||||
}
|
||||
const state = store.getState()
|
||||
const tab = state.createTab(worktreeId, undefined, undefined, { activate: true })
|
||||
state.setActiveTab(tab.id)
|
||||
state.setActiveTabType('terminal')
|
||||
return tab.id
|
||||
}, worktreeId)
|
||||
await expect
|
||||
.poll(() => getActiveTabId(page), {
|
||||
timeout: 5_000,
|
||||
message: 'newly created terminal tab did not become active'
|
||||
})
|
||||
.toBe(tabId)
|
||||
await waitForActiveTerminalManager(page, 30_000)
|
||||
await waitForPaneIdentitySnapshot(page, 1)
|
||||
return tabId
|
||||
}
|
||||
|
||||
async function activateTerminalTab(page: Page, tabId: string): Promise<void> {
|
||||
await page.evaluate((targetTabId) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('activateTerminalTab: window.__store is unavailable')
|
||||
}
|
||||
const state = store.getState()
|
||||
state.setActiveTabType('terminal')
|
||||
state.setActiveTab(targetTabId)
|
||||
}, tabId)
|
||||
await expect
|
||||
.poll(() => getActiveTabId(page), {
|
||||
timeout: 5_000,
|
||||
message: `terminal tab ${tabId} did not become active`
|
||||
})
|
||||
.toBe(tabId)
|
||||
}
|
||||
|
||||
// Why: TerminalPane unmount deletes its __paneManagers entry — that absence is
|
||||
// the observable "parked" signal (same detection as the parking spec).
|
||||
async function waitForTabParked(page: Page, tabId: string): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => page.evaluate((id) => window.__paneManagers?.get(id) !== undefined, tabId), {
|
||||
timeout: Math.max(20_000, PARKING_DELAY_MS * 10),
|
||||
message: `terminal tab ${tabId} did not park (pane manager still mounted)`
|
||||
})
|
||||
.toBe(false)
|
||||
}
|
||||
|
||||
async function waitForTabRemounted(page: Page, tabId: string): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => page.evaluate((id) => window.__paneManagers?.get(id) !== undefined, tabId), {
|
||||
timeout: 15_000,
|
||||
message: `terminal tab ${tabId} did not remount on reveal`
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
type SplitTabSetup = {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
splitLeafId: string
|
||||
splitPtyId: string
|
||||
}
|
||||
|
||||
// Why: every scenario starts from the field shape — a tab whose main pane got
|
||||
// a split (the "setup pane" analog) that is fully bound and settled.
|
||||
async function setUpSplitTab(page: Page): Promise<SplitTabSetup> {
|
||||
await waitForSessionReady(page)
|
||||
const worktreeId = await waitForActiveWorktree(page)
|
||||
await skipUnlessParkingWired(page)
|
||||
await ensureTerminalVisible(page)
|
||||
await waitForActiveTerminalManager(page, 30_000)
|
||||
await waitForPaneIdentitySnapshot(page, 1)
|
||||
const tabId = await getActiveTabId(page)
|
||||
if (!tabId) {
|
||||
throw new Error('setUpSplitTab: no active terminal tab')
|
||||
}
|
||||
await splitActiveTerminalPane(page, 'vertical')
|
||||
const snapshot = await waitForPaneIdentitySnapshot(page, 2)
|
||||
const splitPane = snapshot.panes.at(-1)
|
||||
if (!splitPane?.ptyId) {
|
||||
throw new Error('setUpSplitTab: split pane did not bind a PTY')
|
||||
}
|
||||
return { worktreeId, tabId, splitLeafId: splitPane.leafId, splitPtyId: splitPane.ptyId }
|
||||
}
|
||||
|
||||
test.describe('terminal pane close vs hidden/park lifecycle keeps layout consistent', () => {
|
||||
test('control: close while visible', async ({ orcaPage }) => {
|
||||
const { tabId } = await setUpSplitTab(orcaPage)
|
||||
await closeLastPaneOnTab(orcaPage, tabId)
|
||||
await expectLayoutConsistent(orcaPage, tabId, 1, 'close-visible')
|
||||
})
|
||||
|
||||
test('close and hide the tab in the same tick', async ({ orcaPage }) => {
|
||||
const { worktreeId, tabId } = await setUpSplitTab(orcaPage)
|
||||
await orcaPage.evaluate(
|
||||
({ tabId, worktreeId }) => {
|
||||
const store = window.__store
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!store || !manager) {
|
||||
throw new Error('close+hide: store/manager unavailable')
|
||||
}
|
||||
const target = manager.getPanes().at(-1)
|
||||
if (!target) {
|
||||
throw new Error('close+hide: no split pane')
|
||||
}
|
||||
manager.closePane(target.id)
|
||||
// Hide tab A before any deferred post-close work can run.
|
||||
const state = store.getState()
|
||||
const tab = state.createTab(worktreeId, undefined, undefined, { activate: true })
|
||||
state.setActiveTab(tab.id)
|
||||
state.setActiveTabType('terminal')
|
||||
},
|
||||
{ tabId, worktreeId }
|
||||
)
|
||||
await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3)
|
||||
await activateTerminalTab(orcaPage, tabId)
|
||||
await waitForTabRemounted(orcaPage, tabId)
|
||||
await expectLayoutConsistent(orcaPage, tabId, 1, 'close-then-hide-same-tick')
|
||||
})
|
||||
|
||||
test('close while hidden but still mounted (hot-retain window)', async ({ orcaPage }) => {
|
||||
const { worktreeId, tabId } = await setUpSplitTab(orcaPage)
|
||||
await createActiveTerminalTab(orcaPage, worktreeId)
|
||||
await closeLastPaneOnTab(orcaPage, tabId)
|
||||
await waitForTabParked(orcaPage, tabId)
|
||||
await activateTerminalTab(orcaPage, tabId)
|
||||
await waitForTabRemounted(orcaPage, tabId)
|
||||
await expectLayoutConsistent(orcaPage, tabId, 1, 'close-while-hidden-mounted')
|
||||
})
|
||||
|
||||
test('close immediately after reveal remount, before panes settle', async ({ orcaPage }) => {
|
||||
const { worktreeId, tabId } = await setUpSplitTab(orcaPage)
|
||||
await createActiveTerminalTab(orcaPage, worktreeId)
|
||||
await waitForTabParked(orcaPage, tabId)
|
||||
await activateTerminalTab(orcaPage, tabId)
|
||||
await waitForTabRemounted(orcaPage, tabId)
|
||||
// Close as soon as the manager exists — panes may still be attaching.
|
||||
await orcaPage.evaluate((tabId) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
const target = manager?.getPanes().at(-1)
|
||||
if (manager && target) {
|
||||
manager.closePane(target.id)
|
||||
}
|
||||
}, tabId)
|
||||
await expectLayoutConsistent(orcaPage, tabId, 1, 'close-mid-reveal')
|
||||
})
|
||||
|
||||
test('clean visible close survives a later park/reveal cycle', async ({ orcaPage }) => {
|
||||
const { worktreeId, tabId } = await setUpSplitTab(orcaPage)
|
||||
await closeLastPaneOnTab(orcaPage, tabId)
|
||||
await expectLayoutConsistent(orcaPage, tabId, 1, 'pre-park close')
|
||||
await createActiveTerminalTab(orcaPage, worktreeId)
|
||||
await waitForTabParked(orcaPage, tabId)
|
||||
await activateTerminalTab(orcaPage, tabId)
|
||||
await waitForTabRemounted(orcaPage, tabId)
|
||||
await expectLayoutConsistent(orcaPage, tabId, 1, 'post-park-reveal')
|
||||
})
|
||||
|
||||
test('split pane shell exits while hidden but still mounted', async ({ orcaPage }) => {
|
||||
const { worktreeId, tabId, splitPtyId } = await setUpSplitTab(orcaPage)
|
||||
await createActiveTerminalTab(orcaPage, worktreeId)
|
||||
// The setup-script analog: the split's shell ends on its own while the
|
||||
// tab is hidden-but-mounted.
|
||||
await sendToTerminal(orcaPage, splitPtyId, 'exit\r')
|
||||
await orcaPage.waitForTimeout(PARKING_DELAY_MS / 2)
|
||||
await activateTerminalTab(orcaPage, tabId)
|
||||
await waitForTabRemounted(orcaPage, tabId)
|
||||
await expectLayoutConsistent(orcaPage, tabId, 1, 'shell-exit-while-hidden-mounted', splitPtyId)
|
||||
})
|
||||
|
||||
test('split pane shell exits while the tab is parked', async ({ orcaPage }) => {
|
||||
const { worktreeId, tabId, splitPtyId } = await setUpSplitTab(orcaPage)
|
||||
await createActiveTerminalTab(orcaPage, worktreeId)
|
||||
await waitForTabParked(orcaPage, tabId)
|
||||
await sendToTerminal(orcaPage, splitPtyId, 'exit\r')
|
||||
await orcaPage.waitForTimeout(PARKING_DELAY_MS)
|
||||
await activateTerminalTab(orcaPage, tabId)
|
||||
await waitForTabRemounted(orcaPage, tabId)
|
||||
// Why: the parked exit is deliberately deferred (no PaneManager to promote
|
||||
// siblings) — the reveal remount owns the per-leaf teardown. This asserts
|
||||
// that ownership actually resolves instead of leaving a ghost pane.
|
||||
await expectLayoutConsistent(orcaPage, tabId, 1, 'shell-exit-while-parked', splitPtyId)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user