Files
orca/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts
T
Jinwoo Hong 06780260c0 test(remote-runtime): run an old client and an old server against current code (#12682)
Mixed versions are the normal state of the remote-server feature: users update clients and servers independently. Until now nothing tested that. Every cross-version claim was made by code reading plus unit tests with hand-written old/new shapes — enough to catch design problems, not enough to catch a real skew regression.

This runs the REAL protocol implementations from two builds against each other in one process: the actual host methods and RPC dispatcher on one side, the actual renderer multiplexer on the other, with a transport that reproduces the production asymmetry — each side decodes with its OWN codec and drops frames whose opcode it does not know. A frame survives only if the RECEIVING build understands it, which is what makes this level sufficient without launching two apps. The old side is a genuine checkout extracted from the release tag; the extracted client was confirmed to lack a symbol that exists only on main.

Journey: subscribe, first snapshot, input reaching the process, live output, hide/reveal snapshot, transport drop, resubscribe, input landing again — across old->new, new->old, and a current/current control. Every step ends on an observed-state barrier; no sleeps. The oracle asserts the recorded step list, the exact 16-frame named sequence, negotiated capabilities, the exact input the host wrote to the PTY, rendered content, and zero decoder-rejected frames. A host method the stub lacks is recorded by name and asserted empty, so a harness gap cannot masquerade as a wire break.

Detection is proven per violation shape, and it attributes each to the correct side: an unnegotiated opcode goes red only where a decoder would reject it, a removed published field goes red only where an old client consumes it, and a legal additive field stays green in all three pairings so the harness will not cry wolf on safe changes.

It also documents the three compatibility rules in docs/reference/remote-wire-compatibility.md, linked from AGENTS.md, since they previously existed only as folklore — notably that "decoders reject unknown opcodes" is true for the desktop decoder but NOT for mobile, which silently drops them.

Deliberately scoped: terminal stream only. The session-tab sync channel is not covered, nor agent-session publications, file/Git RPCs, mobile E2EE framing, or the relay transport. Two version points, so a regression introduced and reverted between them is invisible.

CI selection was verified rather than assumed — `vitest list` confirms 0 matches under the shard's exclude and 4 under the dedicated job — because a lane silently running zero tests is precisely how a host-side defect escaped CI earlier in this series. Closes STA-3469.
2026-08-05 01:31:29 -07:00

188 lines
6.8 KiB
TypeScript

export type HostTerminalDataMeta = {
seq?: number
rawLength?: number
cwd?: string
}
/**
* The authoritative side of the journey: one terminal handle backed by a fake PTY.
* It records what the host was actually asked to do (input written, snapshots
* serialized) so the oracle can prove the journey reached the process, not just
* that frames moved.
*/
export type HostTerminalRuntimeStub = {
runtime: unknown
ptyId: string
terminalHandle: string
/** Every text the host wrote to the PTY, in order. */
writtenInput: string[]
/** Scrollback the client would see in a snapshot. */
buffer: string
/** How many times the host serialized a buffer for a snapshot. */
serializeCount: number
/** Push PTY output to every host-side data listener. */
emitOutput: (data: string, meta?: HostTerminalDataMeta) => void
/** Names of runtime methods the host called that the stub does not implement. */
missingRuntimeMethods: string[]
/** Run the host's registered teardown for one connection, as a socket close does. */
closeConnection: (connectionId: string) => void
}
export function createHostTerminalRuntimeStub(
options: {
terminalHandle?: string
ptyId?: string
cols?: number
rows?: number
initialBuffer?: string
} = {}
): HostTerminalRuntimeStub {
const terminalHandle = options.terminalHandle ?? 'terminal-journey'
const ptyId = options.ptyId ?? 'pty-journey'
const cols = options.cols ?? 120
const rows = options.rows ?? 40
const dataListeners = new Set<(data: string, meta?: HostTerminalDataMeta) => void>()
const cleanups = new Map<string, { connectionId: string | undefined; run: () => void }>()
const stub: HostTerminalRuntimeStub = {
runtime: null,
ptyId,
terminalHandle,
writtenInput: [],
buffer: options.initialBuffer ?? '',
serializeCount: 0,
emitOutput: () => {},
missingRuntimeMethods: [],
closeConnection: () => {}
}
stub.closeConnection = (connectionId) => {
const pending: (() => void)[] = []
for (const [id, entry] of cleanups) {
if (entry.connectionId === connectionId) {
cleanups.delete(id)
pending.push(entry.run)
}
}
for (const run of pending) {
run()
}
}
let outputSequence = 0
stub.emitOutput = (data, meta) => {
stub.buffer += data
outputSequence += data.length
const resolved: HostTerminalDataMeta = {
seq: outputSequence,
rawLength: data.length,
...meta
}
// Snapshot: a listener may unsubscribe while the host fans this out.
for (const listener of Array.from(dataListeners)) {
listener(data, resolved)
}
}
const serialize = async (): Promise<{
data: string
cols: number
rows: number
seq: number
source: 'headless'
}> => {
stub.serializeCount++
return { data: stub.buffer, cols, rows, seq: outputSequence, source: 'headless' }
}
const runtime: Record<string, unknown> = {
getRuntimeId: () => 'cross-version-host',
resolveLiveLeafForHandle: (handle: string) => (handle === terminalHandle ? { ptyId } : null),
resolveLeafForHandle: (handle: string) => (handle === terminalHandle ? { ptyId } : null),
registerRemoteTerminalViewSubscriber: () => () => {},
requestRendererTerminalTabMount: () => true,
updateRemoteDesktopViewer: async () => true,
unregisterRemoteDesktopViewer: async () => true,
unregisterRemoteDesktopViewers: async () => true,
isPtyResizeDrivenRemotely: () => false,
getRemoteDesktopFitHold: () => ({ mode: 'desktop-fit', cols, rows }),
isRemoteDesktopViewerOwner: () => false,
getPtyOutputSequence: () => outputSequence,
serializeTerminalBuffer: serialize,
serializeAuthoritativeTerminalBuffer: serialize,
serializeRendererTerminalBuffer: serialize,
readTerminal: async () => ({ tail: [], truncated: false }),
getTerminalSize: () => ({ cols, rows }),
getMobileDisplayMode: () => 'auto',
getLayout: () => ({ seq: 1 }),
getTerminalFitOverride: () => null,
getDriver: () => ({ kind: 'idle' }),
subscribeToTerminalData: (
_ptyId: string,
listener: (d: string, m?: HostTerminalDataMeta) => void
) => {
dataListeners.add(listener)
return () => dataListeners.delete(listener)
},
subscribeToTerminalResize: () => () => {},
subscribeToFitOverrideChanges: () => () => {},
subscribeToDriverChanges: () => () => {},
registerSubscriptionCleanup: (id: string, cleanup: () => void, connectionId?: string) => {
cleanups.set(id, { connectionId, run: cleanup })
},
cleanupSubscription: (id: string) => {
const entry = cleanups.get(id)
cleanups.delete(id)
entry?.run()
},
waitForTerminal: () => new Promise(() => {}),
// The input oracle: the host reached the process with exactly this text.
sendTerminal: async (_handle: string, action: { text?: string }) => {
if (typeof action?.text === 'string') {
stub.writtenInput.push(action.text)
}
return { accepted: true }
},
beginMobileInputFloor: () => ({ commit: () => {}, rollback: () => {} }),
isTerminalInputLocked: () => false,
getTerminalInputLock: () => null,
// Source-range accounting is a host-internal ledger, not part of the wire; decline it.
attachRemoteTerminalSourceRangeConsumer: () => false,
cancelRemoteTerminalSourceRanges: () => {},
settleRemoteTerminalSourceRanges: () => {},
reserveRemoteTerminalSourceRangeReplacement: () => null,
commitRemoteTerminalSourceRangeReplacement: () => {},
rollbackRemoteTerminalSourceRangeReplacement: () => {},
getRendererTerminalSerializerGeneration: () => 0,
getRendererTerminalSerializerGenerationForHandle: () => 0,
hasHeadlessTerminalState: () => true,
isTerminalAlternateScreen: () => false,
isTerminalRunningAgent: () => false,
getTerminalAgentStatus: () => null,
isMobileTerminalQueryReplyAuthority: () => false,
markMobileActor: () => {},
refreshRemoteDesktopViewer: async () => true,
resizeForClient: async () => ({ cols, rows }),
waitForLeafPtyId: async () => ptyId,
recoverTerminalPane: async () => null,
getMobileAutoRestoreFitMs: () => null,
isMobileSubscriberActive: () => false
}
// Why: the two builds may ask the host for different methods. Record the gap by
// name and return undefined, so the oracle fails naming the method that needs
// adding here — instead of an unhandled TypeError that reads like a wire break.
stub.runtime = new Proxy(runtime, {
get(target, property, receiver) {
if (typeof property === 'string' && !(property in target)) {
if (!stub.missingRuntimeMethods.includes(property)) {
stub.missingRuntimeMethods.push(property)
}
return () => undefined
}
return Reflect.get(target, property, receiver)
}
})
return stub
}