Files
orca/tests/e2e/cross-version-wire/versioned-terminal-wire.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

152 lines
5.2 KiB
TypeScript

import { materializeReleaseCheckout, type ReleaseCheckout } from './release-checkout'
/**
* Structural views of the three modules that make up the remote terminal wire.
* Kept minimal on purpose: the harness pairs two builds of these modules, so it
* must not depend on internals that legitimately differ between versions.
*/
export type TerminalStreamFrame = {
opcode: number
streamId: number
seq: number
payload: Uint8Array
}
export type WireCodec = {
TerminalStreamOpcode: Record<string, number | string>
encodeTerminalStreamFrame: (frame: TerminalStreamFrame) => Uint8Array
decodeTerminalStreamFrame: (bytes: Uint8Array) => TerminalStreamFrame | null
encodeTerminalStreamJson: (value: unknown) => Uint8Array
decodeTerminalStreamJson: <T>(payload: Uint8Array) => T | null
encodeTerminalStreamText: (value: string) => Uint8Array
decodeTerminalStreamText: (payload: Uint8Array) => string
}
export type HostRpcContext = {
connectionId: string
sendBinary: (bytes: Uint8Array) => boolean | void
registerBinaryStreamHandler: (
streamId: number,
handler: (frame: TerminalStreamFrame) => void
) => () => void
signal?: AbortSignal
}
export type HostWire = {
RpcDispatcher: new (options: { runtime: unknown; methods: unknown[] }) => {
dispatchStreaming: (
request: { id: string; authToken: string; method: string; params?: unknown },
onMessage: (message: string) => void,
context: HostRpcContext
) => Promise<unknown>
}
TERMINAL_METHODS: unknown[]
}
export type ClientTerminalCallbacks = {
onData: (data: string, meta?: { seq?: number; rawLength?: number }) => void
onSnapshot: (data: string, meta?: { pendingEscapeTailAnsi?: string }) => void
onSubscribed?: () => void
onOutputPauseCapability?: () => void
onEnd?: () => void
onError?: (message: string) => void
onTransportClose?: (event: { recoverable: boolean; retryWithBackoff?: boolean }) => void
}
export type ClientTerminal = {
streamId: number
sendInput: (text: string) => boolean
resize: (cols: number, rows: number) => boolean
setOutputPaused: (paused: boolean) => boolean
serializeBuffer: (opts?: { scrollbackRows?: number }) => Promise<{
data: string
cols: number
rows: number
seq?: number
source?: string
} | null>
close: () => void
}
export type ClientWire = {
getRemoteRuntimeTerminalMultiplexer: (runtimeId: string) => {
subscribeTerminal: (args: {
terminal: string
client: { id: string; type: 'desktop' | 'mobile' }
viewport?: { cols: number; rows: number }
callbacks: ClientTerminalCallbacks
}) => Promise<ClientTerminal>
}
resetRemoteRuntimeTerminalMultiplexersForTests: () => void
}
export type TerminalWireBuild = {
/** Human label used in test names and failure messages. */
label: string
/** `working-tree` for current code, otherwise the resolved release commit. */
revision: string
codec: WireCodec
host: HostWire
client: ClientWire
}
export const WORKING_TREE = 'working-tree' as const
async function loadWorkingTreeBuild(): Promise<TerminalWireBuild> {
const [codec, dispatcher, terminalMethods, client] = await Promise.all([
import('../../../src/shared/terminal-stream-protocol'),
import('../../../src/main/runtime/rpc/dispatcher'),
import('../../../src/main/runtime/rpc/methods/terminal'),
import('../../../src/renderer/src/runtime/remote-runtime-terminal-multiplexer')
])
return {
label: WORKING_TREE,
revision: WORKING_TREE,
codec: codec as unknown as WireCodec,
host: {
RpcDispatcher: dispatcher.RpcDispatcher as unknown as HostWire['RpcDispatcher'],
TERMINAL_METHODS: terminalMethods.TERMINAL_METHODS as unknown[]
},
client: client as unknown as ClientWire
}
}
// Why @vite-ignore: the checkout is created at run time, so Vite cannot glob it at
// transform time. Vite-node still resolves and transforms the target on demand.
function importFromCheckout(specifier: string): Promise<Record<string, unknown>> {
return import(/* @vite-ignore */ specifier) as Promise<Record<string, unknown>>
}
async function loadReleaseBuild(checkout: ReleaseCheckout): Promise<TerminalWireBuild> {
const base = `${checkout.root}/src`
const [codec, dispatcher, terminalMethods, client] = await Promise.all([
importFromCheckout(`${base}/shared/terminal-stream-protocol.ts`),
importFromCheckout(`${base}/main/runtime/rpc/dispatcher.ts`),
importFromCheckout(`${base}/main/runtime/rpc/methods/terminal.ts`),
importFromCheckout(`${base}/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts`)
])
return {
label: checkout.ref,
revision: checkout.commit,
codec: codec as WireCodec,
host: {
RpcDispatcher: dispatcher.RpcDispatcher as HostWire['RpcDispatcher'],
TERMINAL_METHODS: terminalMethods.TERMINAL_METHODS as unknown[]
},
client: client as ClientWire
}
}
/**
* Load the wire modules for one build. `WORKING_TREE` imports current source (so a
* locally injected violation is exercised); any other value is a git ref extracted
* into a cached checkout.
*/
export async function loadTerminalWireBuild(ref: string): Promise<TerminalWireBuild> {
if (ref === WORKING_TREE) {
return loadWorkingTreeBuild()
}
return loadReleaseBuild(materializeReleaseCheckout(ref))
}