Files
orca/tests/e2e/terminal-cli-split-closed-before-mount.spec.ts
T
Jinwoo Hong ce71535bbf Layout groundwork (switched off): main starts every split and new pane, the window only attaches (#31258)
* feat(terminal): the window's store shows main's pane start status (layout-owner build)

A status-only frame changes one store entry and no pane tree, so the pane's view stays mounted.

* refactor(terminal): keep the frame sync within its line budget

* feat(terminal): every pane main starts shows 'starting' from before it is written (layout-owner build)

* feat(terminal): a pane main is starting waits and attaches; a failed start shows why (layout-owner build)

Fixes owner-visible row 15: a failed agent or setup start no longer opens a plain shell.

* test(terminal): iterate store listeners directly

* fix(terminal): an exit that reports no code keeps its pane, as lost contact (layout-owner build)

* test(terminal): a pane main started attaches to main's terminal at its own grid

* test(terminal): detach census tests assert main's move and frame with the switch on

* test(terminal): exited-pane tests assert main's close with the switch on

These 8 have failed switch-on since #31130 moved the exit close to main.

* feat(terminal): main writes and starts every split of a pane it holds; the window attaches (layout-owner build)

The window's split, a startup split with its command, and a CLI split all go through main's
terminal.split; main's frame mounts the pane, with or without a window intent. Deletes the
window's split wait and withdraw, the split appear intents, and the queued-split skip and cancel.
The e2e helper uses the real split. A startup split leaves focus on its source pane.

* fix(terminal): the attach-only wait leaves sleeping, resuming and held panes to their restore path

* fix(terminal): a split pane attaches to the terminal main bound it to; a taken pane id is refused

Also restores the proof that a split closed while it starts gets one answer and leaves no
pane or start status behind.

* fix(terminal): a window split main cannot start still shows its pane, failed, with why

* fix(terminal): check a taken pane id first, keep the start's own error, clear a gone pane's status

Three test stores gain the pane-start field the window's store always has.

* test(terminal): the admission stub answers whether its pane is still written; a closed pane's failed start clears

* refactor(terminal): the rebound workspaces' bindings show through one call in the bindings module

* test(terminal): a pane main wrote and the user closed mid-start drops its failed status

* fix(terminal): a split main starts is listed to phones and session.tabs without a window's report

* fix(terminal): a reconnect shows its pane starting and why it failed; a lost owner race shows no failure; a split no one asked to see keeps the phones' tab

* fix(terminal): a kept create another owner won shows no failure; the window attaches to the winner
2026-10-21 00:14:06 -04:00

191 lines
6.9 KiB
TypeScript

import { execFile } from 'node:child_process'
import { existsSync, mkdirSync } from 'node:fs'
import path from 'node:path'
import { promisify } from 'node:util'
import type { Page } from '@stablyai/playwright-test'
import { RuntimeClient } from '../../src/cli/runtime/client'
import type { RuntimeTerminalListResult } from '../../src/shared/runtime-types'
import { expect, test } from './helpers/orca-app'
import { waitForActiveTerminalManager } from './helpers/terminal'
import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
const execFileAsync = promisify(execFile)
const PARKING_DELAY_MS = 500
const LAYOUT_OWNER_BUILD = existsSync(
path.join(process.cwd(), 'out', 'main', 'ORCA_LAYOUT_OWNER_MAIN')
)
test.use({
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }
})
type SplitOutcome = { ok: true } | { ok: false; error: string }
async function runCliSplit(
userDataDir: string,
terminalHandle: string,
command: string
): Promise<SplitOutcome> {
const repoRoot = process.cwd()
try {
await execFileAsync(
process.execPath,
[
path.join(repoRoot, 'config', 'scripts', 'orca-dev.mjs'),
'terminal',
'split',
'--terminal',
terminalHandle,
'--command',
command,
'--json'
],
{
cwd: repoRoot,
env: { ...process.env, ORCA_DEV_USER_DATA_PATH: userDataDir },
timeout: 30_000
}
)
return { ok: true }
} catch (error) {
const stdout = error instanceof Error && 'stdout' in error ? String(error.stdout) : ''
const stderr = error instanceof Error && 'stderr' in error ? String(error.stderr) : ''
return { ok: false, error: [stdout, stderr].filter(Boolean).join('\n') }
}
}
async function createActiveTab(page: Page, worktreeId: string): Promise<string> {
const tabId = await page.evaluate((id) => {
const state = window.__store!.getState()
const tab = state.createTab(id, undefined, undefined, { activate: true })
state.setActiveTab(tab.id)
state.setActiveTabType('terminal', window.__store!.getState().activeWorktreeId)
return tab.id
}, worktreeId)
await waitForActiveTerminalManager(page, 30_000)
return tabId
}
async function handleFor(
client: RuntimeClient,
worktreeId: string,
tabId: string
): Promise<string> {
let handle = ''
await expect
.poll(async () => {
const listed = await client
.call<RuntimeTerminalListResult>('terminal.list', {
worktree: `id:${worktreeId}`,
limit: 200
})
.catch(() => null)
handle = listed?.result.terminals.find((t) => t.tabId === tabId && t.connected)?.handle ?? ''
return handle
})
.toBeTruthy()
return handle
}
/** Keeps the renderer busy for `ms`, as a window too busy to mount anything. */
async function stallRenderer(page: Page, ms: number): Promise<void> {
await page.evaluate((stallMs) => {
setTimeout(() => {
const until = performance.now() + stallMs
while (performance.now() < until) {
// Busy renderer.
}
}, 0)
}, ms)
}
function windowLeafIdsOf(page: Page, tabId: string): Promise<string[]> {
return page.evaluate((id) => {
const collect = (node: unknown): string[] => {
if (typeof node !== 'object' || node === null) {
return []
}
if ('leafId' in node && typeof node.leafId === 'string') {
return [node.leafId]
}
return [
...('first' in node ? collect(node.first) : []),
...('second' in node ? collect(node.second) : [])
]
}
return collect(window.__store?.getState().terminalLayoutsByTabId[id]?.root)
}, tabId)
}
// Main writes the split and starts its terminal itself, so a busy window never holds the CLI up.
test('a CLI split of a parked tab answers promptly while the window is busy', async ({
electronApp,
orcaPage
}, testInfo) => {
test.skip(!LAYOUT_OWNER_BUILD, 'layout-owner build only')
test.setTimeout(180_000)
await waitForSessionReady(orcaPage)
const worktreeId = await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
const client = new RuntimeClient(userDataDir, 30_000)
const tabId = await createActiveTab(orcaPage, worktreeId)
const source = await handleFor(client, worktreeId, tabId)
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { parkDelayMs: PARKING_DELAY_MS })
await stallRenderer(orcaPage, 8_000)
const startedAt = Date.now()
const outcome = await runCliSplit(userDataDir, source, 'echo started-by-main')
const elapsedMs = Date.now() - startedAt
await testInfo.attach('split-busy-window.json', {
body: JSON.stringify({ elapsedMs, outcome }, null, 2),
contentType: 'application/json'
})
expect(outcome).toEqual({ ok: true })
expect(elapsedMs).toBeLessThan(6_000)
})
// The window is stalled past the old 10 s wait: main still starts the split's terminal, its
// command runs, and the window shows the pane from main's layout once it is free.
test('a CLI split runs its command while the window is stalled, and the window shows the pane', async ({
electronApp,
orcaPage
}, testInfo) => {
test.skip(!LAYOUT_OWNER_BUILD, 'layout-owner build only')
test.setTimeout(180_000)
await waitForSessionReady(orcaPage)
const worktreeId = await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
const client = new RuntimeClient(userDataDir, 30_000)
const tabId = await createActiveTab(orcaPage, worktreeId)
const source = await handleFor(client, worktreeId, tabId)
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { parkDelayMs: PARKING_DELAY_MS })
await stallRenderer(orcaPage, 11_000)
const marker = testInfo.outputPath(`split-ran-${Date.now()}`)
mkdirSync(path.dirname(marker), { recursive: true })
const outcome = await runCliSplit(userDataDir, source, `touch '${marker}'`)
expect(outcome).toEqual({ ok: true })
await expect.poll(() => existsSync(marker), { timeout: 30_000 }).toBe(true)
let tabLeafIds: string[] = []
await expect
.poll(async () => {
const listed = await client.call<RuntimeTerminalListResult>('terminal.list', {
worktree: `id:${worktreeId}`,
limit: 200
})
tabLeafIds = listed.result.terminals
.filter((t) => t.tabId === tabId)
.map((t) => t.leafId)
.sort()
return tabLeafIds.length
})
.toBe(2)
// Past the stall, the window holds both panes main has.
await expect
.poll(async () => (await windowLeafIdsOf(orcaPage, tabId)).sort(), { timeout: 30_000 })
.toEqual(tabLeafIds)
})