mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
* 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
191 lines
6.9 KiB
TypeScript
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)
|
|
})
|