Files
orca/src/main/runtime/orca-runtime-terminal-split-authority.test.ts
T

463 lines
16 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { makePaneKey } from '../../shared/stable-pane-id'
import { OrcaRuntimeService } from './orca-runtime'
const REPO_ID = 'repo-1'
const WORKTREE_ID = `${REPO_ID}::/workspace`
const TAB_ID = 'tab-remote'
const SOURCE_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SOURCE_PTY_ID = 'pty-source'
const SPLIT_PTY_ID = 'pty-split'
const SPLIT_INCARNATION_ID = '22222222-2222-4222-8222-222222222222'
const REPLACEMENT_SPLIT_INCARNATION_ID = '33333333-3333-4333-8333-333333333333'
function sourceLayout(): TerminalLayoutSnapshot {
return {
root: { type: 'leaf', leafId: SOURCE_LEAF_ID },
activeLeafId: SOURCE_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [SOURCE_LEAF_ID]: SOURCE_PTY_ID }
}
}
function persistedSession(includeSource = true): WorkspaceSessionState {
return {
...getDefaultWorkspaceSession(),
tabsByWorktree: includeSource
? {
[WORKTREE_ID]: [
{
id: TAB_ID,
ptyId: SOURCE_PTY_ID,
worktreeId: WORKTREE_ID,
title: 'Remote terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
: {},
terminalLayoutsByTabId: includeSource ? { [TAB_ID]: sourceLayout() } : {}
}
}
function remoteSnapshot(): RuntimeMobileSessionTabsSnapshot {
const layout = sourceLayout()
return {
worktree: WORKTREE_ID,
publicationEpoch: 'remote-viewer',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: `${TAB_ID}::${SOURCE_LEAF_ID}`,
activeTabType: 'terminal',
tabGroups: [{ id: 'group-1', activeTabId: TAB_ID, tabOrder: [TAB_ID] }],
tabs: [
{
type: 'terminal',
id: `${TAB_ID}::${SOURCE_LEAF_ID}`,
parentTabId: TAB_ID,
leafId: SOURCE_LEAF_ID,
ptyId: SOURCE_PTY_ID,
title: 'Remote terminal',
parentLayout: layout,
isActive: true
}
]
}
}
function createHarness(
includeSource = true,
options: {
connectionId?: string | null
deferReveal?: boolean
deferSpawn?: boolean
deferStop?: boolean
includePairedSnapshot?: boolean
rendererMounted?: boolean
sourceIncarnationId?: string
stopAndWaitResult?: boolean
} = {}
) {
let session = persistedSession(includeSource)
const connectionId = options.connectionId ?? null
const ownerHostId = connectionId ? `ssh:${connectionId}` : 'local'
const requestedSessionHostIds: (string | undefined)[] = []
const repo = {
id: REPO_ID,
path: '/workspace',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
...(connectionId ? { connectionId } : {})
}
const store = {
getRepos: () => [repo],
getRepo: (id: string) => (id === REPO_ID ? repo : undefined),
getWorkspaceSession: (hostId?: string) => {
requestedSessionHostIds.push(hostId)
return hostId === undefined || hostId === ownerHostId ? session : getDefaultWorkspaceSession()
},
setWorkspaceSession: (next: WorkspaceSessionState) => {
session = next
},
persistPtyBinding: () => true
}
let resolveSpawn: ((result: { id: string; incarnationId: string }) => void) | undefined
const spawn = options.deferSpawn
? vi.fn(
() =>
new Promise<{ id: string; incarnationId: string }>((resolve) => {
resolveSpawn = resolve
})
)
: vi.fn(async () => ({ id: SPLIT_PTY_ID, incarnationId: SPLIT_INCARNATION_ID }))
const kill = vi.fn(() => true)
const retireRejectedPty = vi.fn()
let resolveStop: ((stopped: boolean) => void) | undefined
const stopAndWait = options.deferStop
? vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveStop = resolve
})
)
: vi.fn(async () => options.stopAndWaitResult ?? true)
let resolveReveal: ((result: { tabId: string }) => void) | undefined
const revealTerminalSession = options.deferReveal
? vi.fn(
() =>
new Promise<{ tabId: string }>((resolve) => {
resolveReveal = resolve
})
)
: vi.fn().mockRejectedValue(new Error(`Terminal tab ${TAB_ID} not found`))
const runtime = new OrcaRuntimeService(store as never)
Object.assign(runtime, {
resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({
id: WORKTREE_ID,
path: '/workspace',
connectionId,
repo,
folderWorkspace: null
}))
})
runtime.setPtyController({
spawn,
write: () => true,
kill,
retireRejectedPty,
...(options.stopAndWaitResult !== undefined || options.deferStop ? { stopAndWait } : {}),
getForegroundProcess: async () => null
})
runtime.setNotifier({ revealTerminalSession } as never)
runtime.syncWindowGraph(1, {
tabs:
includeSource && options.rendererMounted
? [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
title: 'Restored terminal',
activeLeafId: SOURCE_LEAF_ID,
layout: { type: 'leaf', leafId: SOURCE_LEAF_ID } as const
}
]
: [],
leaves:
includeSource && options.rendererMounted
? [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: SOURCE_LEAF_ID,
paneRuntimeId: 1,
ptyId: SOURCE_PTY_ID
}
]
: [],
mobileSessionTabs: (options.includePairedSnapshot ?? includeSource) ? [remoteSnapshot()] : []
})
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, {
tabId: TAB_ID,
leafId: SOURCE_LEAF_ID,
...(options.sourceIncarnationId ? { incarnationId: options.sourceIncarnationId } : {})
})
const internals = runtime as unknown as {
issuePtyHandle: (pty: unknown) => string
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
ptysById: Map<string, { incarnationId?: string }>
}
const handle = internals.issuePtyHandle(internals.ptysById.get(SOURCE_PTY_ID))
return {
runtime,
handle,
spawn,
kill,
retireRejectedPty,
stopAndWait,
revealTerminalSession,
getSession: () => session,
getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID),
requestedSessionHostIds,
replaceSourceIncarnation: (incarnationId: string) =>
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, {
tabId: TAB_ID,
leafId: SOURCE_LEAF_ID,
incarnationId
}),
replacePersistedSourceIncarnation: (incarnationId: string) => {
session = {
...session,
terminalPtyIncarnationsByPaneKey: {
...session.terminalPtyIncarnationsByPaneKey,
[makePaneKey(TAB_ID, SOURCE_LEAF_ID)]: incarnationId
}
}
},
resolveReveal: () => resolveReveal?.({ tabId: TAB_ID }),
replaceSplitIncarnation: () =>
runtime.onPtySpawned(SPLIT_PTY_ID, REPLACEMENT_SPLIT_INCARNATION_ID, {
awaitsRegistration: false
}),
getSplitIncarnation: () => internals.ptysById.get(SPLIT_PTY_ID)?.incarnationId,
resolveStop: (stopped: boolean) => resolveStop?.(stopped),
resolveSpawn: () => resolveSpawn?.({ id: SPLIT_PTY_ID, incarnationId: SPLIT_INCARNATION_ID })
}
}
describe('remote runtime terminal split authority', () => {
it('splits a persisted tab without consulting an unmounted host renderer', async () => {
const harness = createHarness()
const outcome = await harness.runtime
.splitTerminal(harness.handle, { direction: 'vertical' })
.then((split) => ({ ok: true as const, split }))
.catch((error: unknown) => ({ ok: false as const, error }))
expect.soft(outcome).toMatchObject({
ok: true,
split: { tabId: TAB_ID, handle: expect.stringMatching(/^term_/) }
})
expect.soft(harness.spawn).toHaveBeenCalledTimes(1)
expect.soft(harness.kill).not.toHaveBeenCalled()
expect.soft(harness.revealTerminalSession).not.toHaveBeenCalled()
const persistedLayout = harness.getSession().terminalLayoutsByTabId[TAB_ID]
expect(persistedLayout).toMatchObject({
root: { type: 'split', direction: 'vertical' },
ptyIdsByLeafId: {
[SOURCE_LEAF_ID]: SOURCE_PTY_ID
}
})
expect(Object.values(persistedLayout!.ptyIdsByLeafId!)).toContain(SPLIT_PTY_ID)
const siblingSurfaces = harness
.getSnapshot()!
.tabs.filter(
(tab): tab is Extract<typeof tab, { type: 'terminal' }> =>
tab.type === 'terminal' && tab.parentTabId === TAB_ID
)
expect(siblingSurfaces).toHaveLength(2)
expect(siblingSurfaces.every((tab) => tab.parentLayout?.root?.type === 'split')).toBe(true)
})
it('rejects an unowned split source before spawning a PTY', async () => {
const harness = createHarness(false)
const outcome = await harness.runtime
.splitTerminal(harness.handle, { direction: 'vertical' })
.then(() => 'resolved')
.catch((error: unknown) => (error instanceof Error ? error.message : String(error)))
expect.soft(outcome).toBe('terminal_split_source_not_found')
expect.soft(harness.spawn).not.toHaveBeenCalled()
expect.soft(harness.kill).not.toHaveBeenCalled()
expect.soft(harness.revealTerminalSession).not.toHaveBeenCalled()
})
it.each([
{ label: 'local', connectionId: null, expectedHostId: 'local' },
{ label: 'SSH', connectionId: 'ssh-1', expectedHostId: 'ssh:ssh-1' }
])(
'rejects a same-ID $label source replacement when restored persistence lacks an incarnation',
async ({ connectionId, expectedHostId }) => {
const harness = createHarness(true, {
connectionId,
deferSpawn: true,
includePairedSnapshot: false,
rendererMounted: true,
sourceIncarnationId: 'source-before'
})
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' })
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
expect(
harness.getSession().terminalPtyIncarnationsByPaneKey?.[makePaneKey(TAB_ID, SOURCE_LEAF_ID)]
).toBeUndefined()
expect(harness.spawn).toHaveBeenCalledWith(
expect.objectContaining({
expectedSourceBinding: expect.objectContaining({ ptyId: SOURCE_PTY_ID })
})
)
// Why: persistence never recorded this incarnation, so sending it would make the store
// reject every split from a restored pane; the live id is fenced in-runtime instead.
expect(harness.spawn).toHaveBeenCalledWith(
expect.objectContaining({
expectedSourceBinding: expect.not.objectContaining({ incarnationId: expect.anything() })
})
)
harness.replaceSourceIncarnation('source-after')
harness.resolveSpawn()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.requestedSessionHostIds).toContain(expectedHostId)
}
)
it('rejects a same-ID paired-runtime source replacement recovered without an incarnation map', async () => {
const harness = createHarness(true, {
deferSpawn: true,
includePairedSnapshot: true,
rendererMounted: false,
sourceIncarnationId: 'remote-before'
})
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
expect(harness.spawn).toHaveBeenCalledWith(
expect.objectContaining({
expectedSourceBinding: expect.not.objectContaining({ incarnationId: expect.anything() })
})
)
harness.replaceSourceIncarnation('remote-after')
harness.resolveSpawn()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.revealTerminalSession).not.toHaveBeenCalled()
})
it('rejects a persisted-only source incarnation change during spawn', async () => {
const harness = createHarness(true, {
deferSpawn: true,
includePairedSnapshot: false,
stopAndWaitResult: true
})
harness.replacePersistedSourceIncarnation('persisted-before')
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
await vi.waitFor(() => expect(harness.spawn).toHaveBeenCalledOnce())
harness.replacePersistedSourceIncarnation('persisted-after')
harness.resolveSpawn()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.stopAndWait).toHaveBeenCalledWith(
SPLIT_PTY_ID,
expect.objectContaining({
deadlineMs: expect.any(Number),
expectedIncarnationId: SPLIT_INCARNATION_ID
})
)
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, true, SPLIT_INCARNATION_ID)
})
it('revalidates a projected paired-runtime source after renderer adoption', async () => {
const harness = createHarness(false, {
deferReveal: true,
includePairedSnapshot: true,
sourceIncarnationId: 'projected-before',
stopAndWaitResult: false
})
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce())
expect(harness.spawn).toHaveBeenCalledWith(
expect.not.objectContaining({ expectedSourceBinding: expect.anything() })
)
harness.replaceSourceIncarnation('projected-after')
harness.resolveReveal()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.stopAndWait).toHaveBeenCalledWith(
SPLIT_PTY_ID,
expect.objectContaining({
deadlineMs: expect.any(Number),
expectedIncarnationId: SPLIT_INCARNATION_ID
})
)
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.retireRejectedPty).toHaveBeenCalledWith(
SPLIT_PTY_ID,
false,
SPLIT_INCARNATION_ID
)
})
it('addresses rejected split cleanup to the spawned incarnation after same-ID reuse', async () => {
const harness = createHarness(false, {
deferReveal: true,
deferStop: true,
includePairedSnapshot: true,
sourceIncarnationId: 'projected-before'
})
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce())
harness.replaceSourceIncarnation('projected-after')
harness.resolveReveal()
await vi.waitFor(() => expect(harness.stopAndWait).toHaveBeenCalledOnce())
harness.replaceSplitIncarnation()
harness.resolveStop(false)
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.stopAndWait).toHaveBeenCalledWith(SPLIT_PTY_ID, {
deadlineMs: expect.any(Number),
expectedIncarnationId: SPLIT_INCARNATION_ID
})
expect(harness.retireRejectedPty).toHaveBeenCalledWith(
SPLIT_PTY_ID,
false,
SPLIT_INCARNATION_ID
)
expect(harness.getSplitIncarnation()).toBe(REPLACEMENT_SPLIT_INCARNATION_ID)
expect(harness.kill).not.toHaveBeenCalled()
})
it('preserves the split error when exact retirement throws', async () => {
const harness = createHarness(false, {
deferReveal: true,
includePairedSnapshot: true,
sourceIncarnationId: 'projected-before',
stopAndWaitResult: false
})
harness.retireRejectedPty.mockImplementation(() => {
throw new Error('retire failed')
})
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce())
harness.replaceSourceIncarnation('projected-after')
harness.resolveReveal()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.retireRejectedPty).toHaveBeenCalledWith(
SPLIT_PTY_ID,
false,
SPLIT_INCARNATION_ID
)
})
})