test(mobile): a re-derived frame must not bring back a surface the host retired after accept

This commit is contained in:
Brennan Benson
2026-09-27 12:11:31 -07:00
parent beede484b8
commit b390e48da8
@@ -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`])
})
})