diff --git a/src/main/runtime/orca-runtime-fenced-renderer-frame-rederive.test.ts b/src/main/runtime/orca-runtime-fenced-renderer-frame-rederive.test.ts index 36d8175ef06..ebc6cebb730 100644 --- a/src/main/runtime/orca-runtime-fenced-renderer-frame-rederive.test.ts +++ b/src/main/runtime/orca-runtime-fenced-renderer-frame-rederive.test.ts @@ -1,12 +1,14 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { + RuntimeMobileSessionSnapshotTab, RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { OrcaRuntimeService } from './orca-runtime' import { setRuntimeDesktopSurface } from './runtime-desktop-surface' +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' const WORKTREE_ID = 'repo::/worktree' const REPO_ID = 'repo' @@ -57,7 +59,9 @@ function makeColdRestoredSession(): WorkspaceSessionState { } } -function makeRendererFrame(): RuntimeMobileSessionTabsSnapshot { +function makeRendererFrame( + extraTabs: readonly RuntimeMobileSessionSnapshotTab[] = [] +): RuntimeMobileSessionTabsSnapshot { const parentLayout = { root: SPLIT_ROOT, activeLeafId: LEFT, @@ -71,7 +75,9 @@ function makeRendererFrame(): RuntimeMobileSessionTabsSnapshot { activeGroupId: 'group', activeTabId: `tab::${LEFT}`, activeTabType: 'terminal', - tabGroups: [{ id: 'group', activeTabId: 'tab', tabOrder: ['tab'] }], + tabGroups: [ + { id: 'group', activeTabId: 'tab', tabOrder: ['tab', ...extraTabs.map((tab) => tab.id)] } + ], tabs: [ { type: 'terminal', @@ -92,12 +98,16 @@ function makeRendererFrame(): RuntimeMobileSessionTabsSnapshot { title: 'Right', parentLayout, isActive: false - } + }, + ...extraTabs ] } } -function publishRendererFrame(runtime: OrcaRuntimeService): void { +function publishRendererFrame( + runtime: OrcaRuntimeService, + extraTabs: readonly RuntimeMobileSessionSnapshotTab[] = [] +): void { runtime.syncWindowGraph(1, { tabs: [ { @@ -109,7 +119,7 @@ function publishRendererFrame(runtime: OrcaRuntimeService): void { } ], leaves: [], - mobileSessionTabs: [makeRendererFrame()] + mobileSessionTabs: [makeRendererFrame(extraTabs)] }) } @@ -188,3 +198,186 @@ describe('a renderer frame fenced before its PTY registered', () => { expect(await listedSurfaces(runtime)).toEqual([`tab::${LEFT}:ready`]) }) }) + +// An SSH worktree: the phone closes renderer-listed tab-y while relaunched tab-x is still fenced. +const SSH_REPO = { ...LIVE_REPO, connectionId: 'ssh-1' } +const SSH_PTY_X = 'ssh:ssh-1@@pty-x' +const SSH_PTY_Y = 'ssh:ssh-1@@pty-y' + +function makeSshSession(tabIds: readonly ('tab-x' | 'tab-y')[]): WorkspaceSessionState { + // tab-x's relaunched PTY has not bound yet, so only tab-y persists a relay binding. + const specs = { + 'tab-x': { leafId: LEFT, ptyId: null }, + 'tab-y': { leafId: RIGHT, ptyId: SSH_PTY_Y } + } + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: tabIds.map((id, index) => ({ + id, + ptyId: specs[id].ptyId, + worktreeId: WORKTREE_ID, + title: id, + customTitle: null, + color: null, + sortOrder: index, + createdAt: index + 1 + })) + }, + terminalLayoutsByTabId: Object.fromEntries( + tabIds.map((id) => [ + id, + { + root: { type: 'leaf' as const, leafId: specs[id].leafId }, + activeLeafId: specs[id].leafId, + expandedLeafId: null, + ptyIdsByLeafId: specs[id].ptyId ? { [specs[id].leafId]: specs[id].ptyId } : {} + } + ]) + ), + terminalTopologyRevisionByRepoId: { [REPO_ID]: 2 } + } +} + +function publishSshRendererFrame(runtime: OrcaRuntimeService): void { + const surfaces = [ + { tabId: 'tab-x', leafId: LEFT, ptyId: SSH_PTY_X }, + { tabId: 'tab-y', leafId: RIGHT, ptyId: SSH_PTY_Y } + ] + runtime.syncWindowGraph(1, { + tabs: surfaces.map(({ tabId, leafId }) => ({ + tabId, + worktreeId: WORKTREE_ID, + title: tabId, + activeLeafId: leafId, + layout: { type: 'leaf' as const, leafId } + })), + leaves: [], + mobileSessionTabs: [ + { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer', + snapshotVersion: 1, + activeGroupId: 'group', + activeTabId: `tab-y::${RIGHT}`, + activeTabType: 'terminal', + tabGroups: [{ id: 'group', activeTabId: 'tab-y', tabOrder: ['tab-x', 'tab-y'] }], + tabs: surfaces.map(({ tabId, leafId, ptyId }) => ({ + type: 'terminal' as const, + id: `${tabId}::${leafId}`, + parentTabId: tabId, + leafId, + ptyId, + title: tabId, + isActive: tabId === 'tab-y' + })) + } + ] + }) +} + +function sshRuntimeWithRendererCloseRelay(kill: (ptyId: string) => boolean): OrcaRuntimeService { + let session = makeSshSession(['tab-x', 'tab-y']) + setRuntimeDesktopSurface({ + showNotification: () => false, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only liveness and send off its authoritative window on these paths. + findWindowById: () => + ({ + isDestroyed: () => false, + webContents: { isDestroyed: () => false, send: () => {} } + }) as never, + onIpc: () => {}, + removeIpcListener: () => {} + }) + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list, fence and close paths read only repos and the workspace session; the rest of Store is unreached. + withDurableRuntimeStore({ + getRepos: () => [SSH_REPO], + getRepo: (id: string) => (id === REPO_ID ? SSH_REPO : undefined), + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), + getProjects: () => [], + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + }, + flushPendingOrThrowAsync: async () => {} + }) as never + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the close path uses only these two relays. + runtime.setNotifier({ + closeTerminal: vi.fn(), + // The renderer durably retires the tab and acks; its pruned frame is still in flight. + closeTerminalTab: vi.fn(async () => { + session = makeSshSession(['tab-x']) + }) + } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the close path kills and inventories only; the remote kill lands asynchronously. + runtime.setPtyController({ + write: () => true, + kill, + listProcesses: vi.fn(async () => + [SSH_PTY_X, SSH_PTY_Y].map((id) => ({ id, cwd: '/worktree', title: 'shell' })) + ), + getForegroundProcess: async () => null + } as never) + runtime.attachWindow(1) + return runtime +} + +describe('re-deriving a fenced frame after the host retired another surface', () => { + afterEach(() => setRuntimeDesktopSurface(null)) + + it('keeps a phone-closed terminal closed while its remote PTY is still exiting', async () => { + const kill = vi.fn(() => true) + const runtime = sshRuntimeWithRendererCloseRelay(kill) + publishSshRendererFrame(runtime) + runtime.registerPty(SSH_PTY_Y, WORKTREE_ID, 'ssh-1', { + tabId: 'tab-y', + leafId: RIGHT, + incarnationId: 'incarnation-y' + }) + expect(await listedSurfaces(runtime)).toEqual([`tab-y::${RIGHT}:ready`]) + + await runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, 'tab-y', { reason: 'user' }) + expect(kill).toHaveBeenCalledWith(SSH_PTY_Y) + expect(await listedSurfaces(runtime)).toEqual([]) + + runtime.registerPty(SSH_PTY_X, WORKTREE_ID, 'ssh-1', { + tabId: 'tab-x', + leafId: LEFT, + incarnationId: 'incarnation-x' + }) + + expect(await listedSurfaces(runtime)).toEqual([`tab-x::${LEFT}:ready`]) + }) + + it('keeps a phone-closed chat tab closed when the fenced terminal registers', async () => { + const runtime = coldRestoredRuntime() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the chat close path uses only this relay. + runtime.setNotifier({ closeSessionTab: vi.fn(async () => {}) } as never) + publishRendererFrame(runtime, [ + { + type: 'agent-session', + id: 'chat', + title: 'Chat', + sessionId: 'session-chat', + agent: 'claude', + isActive: false + } + ]) + expect(await listedSurfaces(runtime)).toEqual(['chat:']) + + await runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, 'chat', { reason: 'user' }) + expect(await listedSurfaces(runtime)).toEqual([]) + + runtime.registerPty('pty-left', WORKTREE_ID, null, { + tabId: 'tab', + leafId: LEFT, + incarnationId: 'incarnation-restored' + }) + + expect(await listedSurfaces(runtime)).toEqual([`tab::${LEFT}:ready`]) + }) +})