mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
test(mobile): a re-derived frame must not bring back a surface the host retired after accept
This commit is contained in:
@@ -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`])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user