Files
orca/src/main/runtime/rpc/methods/workspace-layout.test.ts
T
Jinwoo HongandE2E Test a2e0f20b1e Main sends one layout update per action, with only what changed (dark, layout-owner build only) (#31109)
* A respawned terminal's pane rebinds in one write, and the window shows a binding-only frame without a rebuild (dark, layout-owner build only)

Main: when the window's spawn for a restored pane finds its process gone, the spawn that holds
the pane's reservation keeps the dead binding and its fresh bind swaps it (the restart swap), so
the pane is never published unbound: a same-id respawn publishes no frame, a new id one. A failed
spawn still retires the dead binding. Other callers and window-owned builds retire early as before.

Window: a frame that differs from the shown one only in panes' terminal bindings updates the
store's pane trees as a rebuild would, falling back to a rebuild for holds and shared terminals.

* Dead-owner respawn tests expect the layout-owner build's one swapped bind instead of the early retire

* Main publishes one records frame per layout operation, and the window resubscribes on a missed frame (dark, layout-owner build only)

Main: a durable mutation (a layout.apply, the renameTabs batch, a binding write, a repair) is
one operation. It names the partitions it wrote, and at its end the layout stream publishes
once: one `records` frame holding only the changed records (tab rows, workspace groups, a pane
tree per tab, a binding record per pane, editor files, browser tabs, removed workspaces), each
keyed by main's own ids. Writes no operation scopes (window saves) still publish when their
task ends. The snapshot and every records frame carry a stream epoch. Each pane's binding
carries `ptyGeneration`, which rises on every binding write in this process, so a same-id
respawn is one frame with one binding record. Readers beside the stream get the end of each
operation and the stream's own per-partition loaded model (`workspaceLayoutReads`), so they
need no second Loader pass, with or without a window.

Window: the cache applies records frames to its full layouts, so the existing flush and the
binding-only path are unchanged. A records frame of another epoch or out of sequence, a
malformed frame, or one the cache cannot apply resubscribes at once for a fresh snapshot.

Tests install main's model check, which applies each frame to what subscribers held and
compares it with a fresh projection; dev builds log a mismatch, release builds run none.

* E1 review fixes: keep a swapped binding, bound the window's resyncs, guard operation listeners (dark, layout-owner build only)

- A spawn whose swap bind persisted no longer retires that pane's binding when a later commit
  step fails; a pane on an SSH host keeps the early retire, since its dead owner lives there.
- The window resubscribes at once at most three times before a frame applies cleanly, then takes
  the loss path's backoff. A skipped frame of a newer type still advances its position.
- A throwing operation listener is logged; it no longer fails a committed mutation.
- Tests freeze the shared partition model a reader gets; the dev-build model check skips frames
  that only change live titles. Generation and model-check comments say what is true.

* Keep the layout stream's backoff reset after a single resync; only a resync storm holds it

* A layout stream that recovers from a resync storm reopens on the normal delay again

* Move the replaced pane owner into its own module so the stable owner no longer imports its replacement (dark, layout-owner build only)

---------

Co-authored-by: E2E Test <e2e@test.local>
2026-10-20 08:19:53 -04:00

167 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { WorkspaceLayoutStreamEvent } from '../../../../shared/workspace-layout/workspace-layout-stream-frames'
import { eraseRpcMethods, isStreamingMethod, type RpcContext } from '../core'
import { ALL_RPC_METHODS } from './index'
import { RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version'
import { MOBILE_RPC_METHOD_ALLOWLIST } from '../../runtime-rpc/runtime-rpc-mobile-method-allowlist'
const methods = eraseRpcMethods(ALL_RPC_METHODS)
const subscribe = methods.find((method) => method.name === 'layout.subscribe')
const unsubscribeMethod = methods.find((method) => method.name === 'layout.unsubscribe')
const getRemoteSnapshots = methods.find((method) => method.name === 'layout.getRemoteSnapshots')
function harness(epoch?: string) {
const cleanups = new Map<string, () => void>()
let publish: (event: WorkspaceLayoutStreamEvent) => void = () => {}
const unsubscribe = vi.fn()
const fake = {
subscribeWorkspaceLayouts: (listener: (event: WorkspaceLayoutStreamEvent) => void) => {
publish = listener
return {
snapshot: [
{ key: 'a', layout: { worktreeId: 'a' } },
{ key: 'b', layout: { worktreeId: 'b' } }
],
seq: 4,
...(epoch ? { epoch } : {}),
unsubscribe
}
},
registerSubscriptionCleanup: (id: string, cleanup: () => void) => cleanups.set(id, cleanup),
cleanupSubscription: (id: string) => cleanups.get(id)?.()
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler calls only the three runtime methods this fake implements.
const runtime = fake as unknown as OrcaRuntimeService
return {
runtime,
cleanups,
unsubscribe,
publish: (event: WorkspaceLayoutStreamEvent) => publish(event)
}
}
describe('layout.subscribe', () => {
it('is served but neither advertised nor open to phones (inert)', () => {
expect(subscribe && isStreamingMethod(subscribe)).toBe(true)
expect(RUNTIME_CAPABILITIES.some((capability) => capability.startsWith('layout'))).toBe(false)
expect(MOBILE_RPC_METHOD_ALLOWLIST.has('layout.subscribe')).toBe(false)
})
it('sends the snapshot, then the named workspaces’ changes, and ends on unsubscribe', async () => {
const { runtime, cleanups, unsubscribe, publish } = harness()
const emitted: unknown[] = []
if (!subscribe || !isStreamingMethod(subscribe)) {
throw new Error('layout.subscribe is not a streaming method')
}
const done = subscribe.handler(
{ workspaces: ['b'] },
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime and connectionId from its context.
{ runtime, connectionId: 'conn-1' } as RpcContext,
(event) => emitted.push(event)
)
const [cleanupKey] = [...cleanups.keys()]
expect(emitted).toEqual([
{
type: 'snapshot',
subscriptionId: expect.any(String),
workspaces: [{ key: 'b', layout: { worktreeId: 'b' } }],
seq: 4
}
])
publish({ type: 'removed', key: 'a', seq: 5 })
publish({ type: 'removed', key: 'b', seq: 6 })
runtime.cleanupSubscription(cleanupKey!)
await done
expect(emitted.slice(1)).toEqual([{ type: 'removed', key: 'b', seq: 6 }, { type: 'end' }])
expect(unsubscribe).toHaveBeenCalledTimes(1)
})
it('sends each records frame with only the named workspaces, an empty one too', async () => {
const { runtime, cleanups, publish } = harness('epoch-1')
const emitted: unknown[] = []
if (!subscribe || !isStreamingMethod(subscribe)) {
throw new Error('layout.subscribe is not a streaming method')
}
const done = subscribe.handler(
{ workspaces: ['b'] },
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime and connectionId from its context.
{ runtime, connectionId: 'conn-1' } as RpcContext,
(event) => emitted.push(event)
)
const removed = (key: string) => ({ key, change: 'removed' as const })
publish({ type: 'records', epoch: 'epoch-1', seq: 5, workspaces: [removed('a'), removed('b')] })
// Sent though it names none of them, so the subscriber's positions stay gapless.
publish({ type: 'records', epoch: 'epoch-1', seq: 6, workspaces: [removed('a')] })
runtime.cleanupSubscription([...cleanups.keys()][0]!)
await done
expect(emitted).toEqual([
expect.objectContaining({ type: 'snapshot', seq: 4, epoch: 'epoch-1' }),
{ type: 'records', epoch: 'epoch-1', seq: 5, workspaces: [removed('b')] },
{ type: 'records', epoch: 'epoch-1', seq: 6, workspaces: [] },
{ type: 'end' }
])
})
it('ends a subscription only for the connection that opened it', async () => {
const { runtime, unsubscribe } = harness()
const emitted: unknown[] = []
if (!subscribe || !isStreamingMethod(subscribe) || !unsubscribeMethod) {
throw new Error('layout methods are missing')
}
if (isStreamingMethod(unsubscribeMethod)) {
throw new Error('layout.unsubscribe is not a request method')
}
const done = subscribe.handler(
undefined,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime and connectionId from its context.
{ runtime, connectionId: 'conn-1' } as RpcContext,
(event) => emitted.push(event)
)
const [snapshot] = emitted
const subscriptionId =
snapshot && typeof snapshot === 'object' && 'subscriptionId' in snapshot
? snapshot.subscriptionId
: undefined
expect(subscriptionId).toEqual(expect.any(String))
await unsubscribeMethod.handler(
{ subscriptionId },
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime and connectionId from its context.
{ runtime, connectionId: 'conn-2' } as RpcContext
)
expect(unsubscribe).not.toHaveBeenCalled()
await unsubscribeMethod.handler(
{ subscriptionId },
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime and connectionId from its context.
{ runtime, connectionId: 'conn-1' } as RpcContext
)
await done
expect(unsubscribe).toHaveBeenCalledTimes(1)
expect(emitted.at(-1)).toEqual({ type: 'end' })
})
})
describe('layout.getRemoteSnapshots', () => {
it('is served but neither advertised nor open to phones (inert)', () => {
expect(getRemoteSnapshots?.permission).toBe('workspace')
expect(MOBILE_RPC_METHOD_ALLOWLIST.has('layout.getRemoteSnapshots')).toBe(false)
})
it('returns the stored snapshot of each known server', async () => {
const snapshots = {
one: { workspaces: { a: { worktree: 'a', tabs: [] } } },
two: { workspaces: {} }
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler calls only getRemoteLayoutSnapshots.
const runtime = { getRemoteLayoutSnapshots: () => snapshots } as unknown as OrcaRuntimeService
if (!getRemoteSnapshots || isStreamingMethod(getRemoteSnapshots)) {
throw new Error('layout.getRemoteSnapshots is not a request method')
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime from its context.
const result = await getRemoteSnapshots.handler(undefined, { runtime } as RpcContext)
expect(result).toEqual({ snapshots })
})
})