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.
This commit is contained in:
Jinwoo Hong 2026-08-05 01:31:29 -07:00 committed by GitHub
parent a766ee4bcd
commit 06780260c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1395 additions and 1 deletions

View File

@ -233,8 +233,32 @@ jobs:
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
cross-version-wire:
name: cross-version wire compatibility
runs-on: ubuntu-latest
steps:
# Why fetch-depth 0: the harness extracts the newest release tag to skew
# current code against it. The default shallow clone has no tags, which is
# why this cannot ride along in the sharded `test` job.
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
# A path filter that matches nothing exits 1 ("No test files found"), so this
# lane cannot report success while running zero tests.
- name: Old/new client and server terminal journey
run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
package:
name: package
runs-on: ubuntu-latest

4
.gitignore vendored
View File

@ -100,6 +100,7 @@ docs/**
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/remote-wire-compatibility.md
!docs/reference/windows-setup-shell.md
# Stably CLI (only docs/ are tracked)
@ -135,3 +136,6 @@ src/renderer/src/i18n/locales/.es-catalog-cache.json
# Bench result JSONs are working artifacts
tests/tools/benchmarks/results/terminal-pipeline-*.json
# Old release trees the cross-version wire harness extracts on demand
tests/e2e/.cross-version-checkouts/

View File

@ -150,5 +150,10 @@
}
}
],
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
"ignorePatterns": [
"**/node_modules",
"**/dist",
"**/out",
"tests/e2e/.cross-version-checkouts"
]
}

View File

@ -40,6 +40,10 @@ All changes must consider the SSH use case. Don't assume local-only execution.
All changes must consider folder workspaces as well as git worktrees. Don't assume every workspace is a git worktree.
## Remote Wire Compatibility
Clients and remote Orca servers update independently, so mixed versions are the normal state. Before changing anything a paired client and host exchange — RPC params, stream frames, or the content either side publishes over them — follow [`docs/reference/remote-wire-compatibility.md`](./docs/reference/remote-wire-compatibility.md). A new optional field is safe; a new stream opcode must be capability-negotiated because decoders drop unknown opcodes silently; and changing what the host publishes reaches old clients even with no wire change.
## Git Binary Compatibility
Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md).

View File

@ -9557,6 +9557,88 @@
"The live topology uses an SSH Git worktree; folder-workspace behavior is covered by target-scoped renderer reconciliation rather than a second headed topology."
],
"demotionRule": "Keep experimental or demote if same-authority snapshots can overwrite newer pushes, reconnect silently loses persisted intent, one forward operation disturbs unrelated forwards, live HTTP diverges from renderer/main inventory, or either transport topology flakes without an identified product or harness fault."
},
{
"id": "remote-wire.cross-version-terminal-journey",
"title": "A released client and a current server still complete one terminal journey in both skew directions",
"maturity": "experimental",
"protection": "partial",
"owner": "remote-runtime",
"layer": "cross-version-protocol-integration",
"surfaces": [
"terminal binary stream framing",
"terminal multiplex subscribe handshake and capability negotiation",
"host-published snapshot and output projection",
"remote terminal reconnect"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["paired-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["paired-runtime"],
"coverageNotes": "Loads the real host RPC methods, the real RpcDispatcher, and the real renderer terminal multiplexer from two builds (current working tree and the newest release tag) and drives them against each other over an in-process transport that reproduces production frame routing, including the host-side decode that silently drops unknown opcodes. Covers the terminal stream only; the session-tab sync channel, agent-session publications, file/Git RPCs, mobile E2EE framing, and the relay transport are uncovered.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/12641",
"https://github.com/stablyai/orca/pull/12655"
],
"invariant": "A client and a server built from different releases must complete subscribe, input delivery to the process, hide/reveal buffer snapshot, transport drop, and resubscribe with no frame refused by the receiving build's decoder, the same negotiated capabilities, and the same published snapshot content — so a new optional field stays safe, a new opcode is only sent after negotiation, and a change in what the host publishes is visible before release.",
"oracle": "Run one fixed journey per pairing (old client/new server, new client/old server, and current/current as control) and assert the recorded step list, the exact named frame sequence, both subscribed events with their negotiated capabilities, the exact input texts the host wrote to the PTY before and after reconnect, the rendered snapshot and live-output content, and an empty set of decoder-rejected frames in either direction. Missing host runtime methods are reported by name so a harness gap can never be read as a wire incompatibility.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts"
],
"testFiles": ["tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts"],
"assertionRefs": [
{
"file": "tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts",
"assertions": [
"expect(record.completed).toEqual([...JOURNEY_STEPS])",
"expect(record.frameSequence).toEqual(EXPECTED_JOURNEY_FRAMES)",
"expect(record.rejected).toEqual([])",
"expect(record.inputAtProcess).toEqual([JOURNEY_INPUTS.first, JOURNEY_INPUTS.second])",
"expect(event.capabilities).toEqual({ outputPause: 1 })"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-05",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts",
"durationSeconds": 6,
"summary": "v1.4.169 against working tree c4d5a535f2; all three pairings produced the identical 16-frame journey with zero rejected frames."
}
],
"runtimeBudget": {
"p95Seconds": 60,
"scope": "one baseline checkout extraction plus three in-process journeys"
},
"flakeHistory": {
"status": "not-started",
"evidence": "New gate; no soak history yet. The journey uses observed-state barriers only, with no sleeps or elapsed-time oracles."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "Red proven separately for each rule by injecting the violation into the working tree and reverting it. Rule 2: adding opcode 17 and sending it ungated from the client turned new-client/old-server red with rejected rawOpcode 17 in the client-to-host direction, while old-client/new-server stayed green. Rule 3: making the host stop publishing the snapshot `source` field turned both new-server pairings red and left the old-server pairing green; trimming the published initial buffer removed the SnapshotChunk frame and failed the frame-sequence oracle. Rule 1: adding an optional `hiddenOutputReason` field to the snapshot frame kept all pairings green, and making the client require that field turned only new-client/old-server red."
},
"performanceBudget": {
"required": false,
"evidence": "Test-only infrastructure; it adds no product code path. The extracted baseline tree is cached by resolved commit, so repeat runs skip extraction and each journey completes in roughly 35ms."
},
"promotionCriteria": [
"Extend the matrix beyond two version points, for example the previous two minor releases.",
"Cover a second wire surface, starting with the session-tab sync channel that PR #12641 changed.",
"Collect 100 consecutive CI passes on the dedicated cross-version-wire job.",
"Run the job on Linux and Windows runners, not only macOS locally."
],
"knownGaps": [
"Only the terminal stream is covered; session tabs, agent sessions, file/Git RPCs, mobile E2EE framing, and the relay transport are not.",
"Only two version points are compared, so a regression introduced and reverted between them is invisible.",
"The host runtime is a stub around a fake PTY, so real PTY, daemon, and SSH provider behavior is out of scope.",
"The baseline is the newest release tag by default, so the compared pair changes when a new release is cut unless ORCA_CROSS_VERSION_BASELINE_REF pins it.",
"tests/ is outside every tsconfig include, so the harness is linted and executed but not typechecked."
],
"demotionRule": "Demote if the baseline checkout cannot be materialized in CI, if a pairing has to be skipped to keep the lane green, or if the journey stops asserting the full step list and frame sequence."
}
]
}

View File

@ -0,0 +1,101 @@
# Remote wire compatibility
Orca's remote-server feature pairs a desktop client to a remote Orca runtime, and
users update the two independently. **Mixed versions are the normal state**, not an
edge case. This page is the contract for changing anything a paired client and host
exchange: the runtime RPC envelope, the terminal binary stream, and the content
either side publishes over them.
`src/shared/protocol-version.ts` says when to bump `RUNTIME_PROTOCOL_VERSION`. This
page covers the changes that do _not_ bump it and are therefore easy to get wrong.
## Rule 1 — a new optional JSON field on an existing frame is safe
Every JSON payload is parsed with a decoder that ignores unknown keys (zod `.strip()`
on RPC params, `JSON.parse` on stream frames). An older peer that has never heard of
the field simply does not read it.
Safe:
```ts
// host adds a field; older clients ignore it
encodeTerminalStreamJson({ kind, cols, rows, hiddenOutputReason })
```
**The field is safe only for as long as every reader treats it as optional.** The
moment a newer client _requires_ it, that client is broken against every host that
predates the field — which is the same defect as removing a field, just discovered
later. If new behavior depends on the field being present, that is Rule 2: negotiate
it, or make the reader fall back.
## Rule 2 — a new stream opcode is NOT safe; negotiate it
`decodeTerminalStreamFrame` returns `null` for an opcode it does not know, and
`runtime-rpc.ts` drops that frame without an error:
```ts
const frame = decodeTerminalStreamFrame(bytes)
if (!frame) {
return // silently dropped — the sender never learns
}
```
So a new opcode sent to an older peer does not fail loudly. It vanishes, and the
feature behind it appears to hang. Input sent under a new opcode is swallowed.
A new opcode must be announced in the subscribe handshake and sent only after the
peer confirms it. The existing pattern is `SetOutputPaused` (opcode 16):
- the client advertises support in the `Subscribe` frame's `capabilities`;
- the host echoes `capabilities: { outputPause: 1 }` on the `subscribed` event;
- the client sends opcode 16 only after that echo (`stream.supportsOutputPause`);
- the host only acts on opcode 16 when it negotiated it (`stream.supportsOutputPause`).
Reuse an existing opcode with a new optional payload field (Rule 1) whenever that
expresses the change; reach for a new opcode only when framing genuinely differs.
Opcode numbers are permanent. See the `Ack = 13` and `ClaimViewport = 14` comments
in `src/shared/terminal-stream-protocol.ts` for why a shipped number cannot be
reused even if the feature behind it is removed.
## Rule 3 — changing what the host publishes breaks old clients with no wire change
The frame shape can be untouched and the skew still real, because clients react to
frame _content_. PR #12641 is the worked example: the host stopped synthesizing a
finished agent status, and clients running older code saw different content in an
identical frame.
Treat these as wire changes even though nothing in the codec moves:
- a field the host stops populating (an old client reading it now sees `undefined`);
- a value whose meaning, units, or nullability changes;
- content the host stops synthesizing, trims, or starts deriving from a new source;
- a frame the host stops sending, or starts sending, on an existing path.
If old clients cannot interpret the new projection correctly, gate it behind a
runtime capability the same way Rule 2 gates an opcode.
## Enforcement
`tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts` runs the real
host RPC methods and the real renderer multiplexer from two builds against each
other — current working tree against the newest release tag, in both skew
directions — over one scripted terminal journey (subscribe, input, hide/reveal
snapshot, drop, reconnect).
Run it with:
```bash
pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
```
It fails when a frame is refused by the receiving build's decoder (Rule 2), when the
observed frame sequence changes (Rule 3), or when published snapshot content or
negotiated capabilities differ from the contract. Adding an optional field keeps it
green (Rule 1); making a client depend on that field turns the new-client/old-host
pairing red.
The harness covers the terminal stream only. It does **not** cover the session-tab
sync channel, agent-session publications, file or Git RPCs, mobile/E2EE framing, or
the relay transport. A change on those paths still needs its own reasoning against
the three rules above.

View File

@ -0,0 +1,136 @@
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { resolveBaselineReleaseRef } from './release-checkout'
import {
JOURNEY_INPUTS,
JOURNEY_STEPS,
runTerminalSkewJourney,
type JourneyRecord
} from './terminal-skew-journey'
import {
loadTerminalWireBuild,
WORKING_TREE,
type TerminalWireBuild
} from './versioned-terminal-wire'
// Why: a cold CI run extracts the baseline checkout before the first journey.
const SUITE_TIMEOUT_MS = 180_000
/**
* The frames one journey must produce, named rather than numbered so a diff reads
* as a protocol change. Any deviation is a change in what a peer publishes or
* accepts, and needs a human decision against docs/reference/remote-wire-compatibility.md.
*/
const EXPECTED_JOURNEY_FRAMES = [
'C>H Subscribe',
'H>C SnapshotStart',
'H>C SnapshotChunk',
'H>C SnapshotEnd',
'C>H Input',
'H>C Output',
'C>H SnapshotRequest',
'H>C SnapshotStart',
'H>C SnapshotChunk',
'H>C SnapshotEnd',
'C>H Subscribe',
'H>C SnapshotStart',
'H>C SnapshotChunk',
'H>C SnapshotEnd',
'C>H Input',
'C>H Unsubscribe'
]
let baselineRef: string
let current: TerminalWireBuild
let baseline: TerminalWireBuild
beforeAll(async () => {
baselineRef = resolveBaselineReleaseRef()
current = await loadTerminalWireBuild(WORKING_TREE)
baseline = await loadTerminalWireBuild(baselineRef)
}, SUITE_TIMEOUT_MS)
afterEach(() => {
// Each journey installs and removes its own window stub; fail loudly if one leaked.
expect(typeof globalThis.window).toBe('undefined')
})
function expectJourneyActuallyRan(record: JourneyRecord): void {
// The anti-vacuous-pass oracle. A harness that connects and then does nothing
// fails here, because "nothing threw" is never enough to call a pairing green.
expect(record.completed).toEqual([...JOURNEY_STEPS])
expect(record.frameSequence).toEqual(EXPECTED_JOURNEY_FRAMES)
expect(record.subscribedEvents).toHaveLength(2)
expect(record.snapshotStarts).toHaveLength(3)
expect(record.missingRuntimeMethods).toEqual([])
}
function expectWireCompatible(record: JourneyRecord): void {
// Rule 2 — no frame may be refused by the receiving build's decoder. An opcode
// the peer does not know is dropped silently, so this is the only signal.
expect(record.rejected).toEqual([])
expect(record.clientErrors).toEqual([])
// The subscribe handshake still negotiates the optional output-pause opcode,
// which is what keeps opcode 16 legal to send on this pairing.
for (const event of record.subscribedEvents) {
expect(event.capabilities).toEqual({ outputPause: 1 })
}
// Input reached the process, before and after the reconnect.
expect(record.inputAtProcess).toEqual([JOURNEY_INPUTS.first, JOURNEY_INPUTS.second])
// Rule 3 — what the host publishes, as the client actually rendered it.
expect(record.snapshotsRendered[0]).toBe(JOURNEY_INPUTS.initialBuffer)
expect(record.dataRendered.join('')).toBe(JOURNEY_INPUTS.output)
expect(record.revealSnapshot?.data).toBe(
`${JOURNEY_INPUTS.initialBuffer}${JOURNEY_INPUTS.output}`
)
expect(record.revealSnapshot).toMatchObject({ cols: 120, rows: 40 })
for (const start of record.snapshotStarts) {
expect(start).toMatchObject({ kind: 'scrollback', cols: 120, rows: 40, source: 'headless' })
}
}
describe('cross-version remote terminal wire', () => {
it(
'skews current code against a real published release',
() => {
expect(baselineRef).toMatch(/^v?\d/)
expect(baseline.revision).toMatch(/^[0-9a-f]{40}$/)
expect(baseline.revision).not.toBe(current.revision)
},
SUITE_TIMEOUT_MS
)
it(
'current client against current server completes the journey',
async () => {
const record = await runTerminalSkewJourney({ hostBuild: current, clientBuild: current })
expectJourneyActuallyRan(record)
expectWireCompatible(record)
},
SUITE_TIMEOUT_MS
)
it(
'old client against new server completes the journey',
async () => {
const record = await runTerminalSkewJourney({ hostBuild: current, clientBuild: baseline })
expect(record.clientRevision).toBe(baseline.revision)
expectJourneyActuallyRan(record)
expectWireCompatible(record)
},
SUITE_TIMEOUT_MS
)
it(
'new client against old server completes the journey',
async () => {
const record = await runTerminalSkewJourney({ hostBuild: baseline, clientBuild: current })
expect(record.hostRevision).toBe(baseline.revision)
expectJourneyActuallyRan(record)
expectWireCompatible(record)
},
SUITE_TIMEOUT_MS
)
})

View File

@ -0,0 +1,187 @@
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
}

View File

@ -0,0 +1,232 @@
import { execFileSync } from 'node:child_process'
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
writeFileSync
} from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
export const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..')
const CACHE_ROOT = join(REPO_ROOT, 'tests', 'e2e', '.cross-version-checkouts')
// Bump when extraction or the alias rewrite changes so cached trees are rebuilt.
const CHECKOUT_FORMAT = 1
// Why: the wire endpoints only need the runtime RPC host, the renderer client, and
// the shared codec. Skipping cli/relay keeps a cold CI extraction a few seconds.
const ARCHIVE_PATHS = ['src/main', 'src/shared', 'src/preload', 'src/renderer', 'src/types']
const BASELINE_REF_ENV = 'ORCA_CROSS_VERSION_BASELINE_REF'
export type ReleaseCheckout = {
/** The ref as requested, e.g. `v1.4.169`. */
ref: string
/** Resolved commit the tree was extracted from. */
commit: string
/** Directory name under the cache root; also the dynamic-import path segment. */
label: string
/** Absolute path to the extracted checkout root (contains `src/`). */
root: string
}
function git(args: string[]): string {
return execFileSync('git', args, {
cwd: REPO_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
}).trim()
}
function compareReleaseTags(a: string, b: string): number {
const parts = (tag: string): number[] =>
tag
.replace(/^v/, '')
.split('.')
.map((part) => Number.parseInt(part, 10))
.map((value) => (Number.isFinite(value) ? value : 0))
const left = parts(a)
const right = parts(b)
for (let index = 0; index < Math.max(left.length, right.length); index++) {
const diff = (left[index] ?? 0) - (right[index] ?? 0)
if (diff !== 0) {
return diff
}
}
return 0
}
/**
* The version point the harness pairs current code against. An explicit
* {@link BASELINE_REF_ENV} wins; otherwise the newest non-prerelease `v*` tag.
*
* Throws rather than skipping: a cross-version lane that quietly runs nothing is
* the exact failure this harness exists to prevent.
*/
export function resolveBaselineReleaseRef(): string {
const override = process.env[BASELINE_REF_ENV]?.trim()
if (override) {
return override
}
let tags: string[]
try {
tags = git(['tag', '--list', 'v[0-9]*']).split('\n').filter(Boolean)
} catch (error) {
throw new Error(
`Cross-version harness could not list git tags in ${REPO_ROOT}: ${String(error)}. ` +
`Run it inside a git checkout, or pin a ref with ${BASELINE_REF_ENV}.`
)
}
const releases = tags.filter((tag) => !tag.includes('-')).sort(compareReleaseTags)
const latest = releases.at(-1)
if (!latest) {
throw new Error(
`Cross-version harness found no release tags matching v[0-9]* (saw ${tags.length} tag(s) total). ` +
'CI checkouts default to a shallow clone with no tags: use `actions/checkout` with `fetch-depth: 0`, ' +
`or pin a ref with ${BASELINE_REF_ENV}.`
)
}
return latest
}
function resolveCommit(ref: string): string {
try {
return git(['rev-parse', `${ref}^{commit}`])
} catch (error) {
throw new Error(
`Cross-version harness could not resolve ref "${ref}" to a commit: ${String(error)}. ` +
'The ref must exist locally; a shallow CI clone needs `fetch-depth: 0`.'
)
}
}
function isRewritableSource(name: string): boolean {
return name.endsWith('.ts') || name.endsWith('.tsx')
}
function isTestSource(name: string): boolean {
return /\.(test|bench|spec)\.(ts|tsx)$/.test(name)
}
const ALIAS_SPECIFIER =
/(\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)(['"])@(renderer)?\/([^'"]+)\2/g
/**
* The extracted tree is imported directly, so `@/…` must resolve inside that tree.
* Vite's alias is global and points at the working tree, which would silently run
* current renderer code inside the "old" client. Rewrite to relative paths instead.
*/
function rewriteRendererAliases(file: string, rendererRoot: string): boolean {
const source = readFileSync(file, 'utf8')
if (!source.includes("'@/") && !source.includes('"@/') && !source.includes('@renderer/')) {
return false
}
const rewritten = source.replace(
ALIAS_SPECIFIER,
(_match, keyword: string, quote: string, _renderer: string | undefined, target: string) => {
const absolute = join(rendererRoot, target)
let relativePath = relative(dirname(file), absolute).split('\\').join('/')
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`
}
return `${keyword}${quote}${relativePath}${quote}`
}
)
if (rewritten === source) {
return false
}
writeFileSync(file, rewritten)
return true
}
function prepareExtractedTree(root: string): { rewritten: number; pruned: number } {
const rendererRoot = join(root, 'src', 'renderer', 'src')
let rewritten = 0
let pruned = 0
const walk = (directory: string): void => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const full = join(directory, entry.name)
if (entry.isDirectory()) {
walk(full)
continue
}
if (!entry.isFile()) {
continue
}
// Why: the old tree is imported, never collected. Dropping its tests keeps the
// cache small and keeps stale specs out of every repo-wide tool's file walk.
if (isTestSource(entry.name)) {
rmSync(full)
pruned++
continue
}
if (isRewritableSource(entry.name) && rewriteRendererAliases(full, rendererRoot)) {
rewritten++
}
}
}
walk(join(root, 'src'))
return { rewritten, pruned }
}
type CheckoutStamp = { commit: string; format: number }
function readStamp(root: string): CheckoutStamp | null {
try {
return JSON.parse(readFileSync(join(root, 'checkout-stamp.json'), 'utf8')) as CheckoutStamp
} catch {
return null
}
}
/**
* Extract `src/` at `ref` into a cached, gitignored checkout the test can import.
* Cached by resolved commit, so a moved tag or a bumped rewrite format re-extracts.
*/
export function materializeReleaseCheckout(ref: string): ReleaseCheckout {
const commit = resolveCommit(ref)
const label = ref.replace(/[^A-Za-z0-9._-]/g, '_')
const root = join(CACHE_ROOT, label)
const stamp = readStamp(root)
if (stamp?.commit === commit && stamp.format === CHECKOUT_FORMAT) {
return { ref, commit, label, root }
}
mkdirSync(CACHE_ROOT, { recursive: true })
const staging = join(CACHE_ROOT, `.staging-${label}-${process.pid}`)
rmSync(staging, { recursive: true, force: true })
mkdirSync(staging, { recursive: true })
try {
// `git archive | tar -x` keeps the extraction independent of the working tree,
// so an injected violation in the working tree cannot leak into the old side.
execFileSync(
'sh',
['-c', `git archive ${commit} ${ARCHIVE_PATHS.join(' ')} | tar -x -C "${staging}"`],
{ cwd: REPO_ROOT, stdio: ['ignore', 'ignore', 'pipe'] }
)
prepareExtractedTree(staging)
writeFileSync(
join(staging, 'checkout-stamp.json'),
`${JSON.stringify({ commit, format: CHECKOUT_FORMAT } satisfies CheckoutStamp, null, 2)}\n`
)
rmSync(root, { recursive: true, force: true })
renameSync(staging, root)
} catch (error) {
rmSync(staging, { recursive: true, force: true })
if (readStamp(root)?.commit === commit) {
return { ref, commit, label, root }
}
throw new Error(`Cross-version harness failed to extract ${ref} (${commit}): ${String(error)}`)
}
if (!existsSync(join(root, 'src', 'shared', 'terminal-stream-protocol.ts'))) {
throw new Error(
`Cross-version checkout for ${ref} is missing the terminal stream protocol; ` +
'the wire surface moved and the harness needs updating.'
)
}
return { ref, commit, label, root }
}

View File

@ -0,0 +1,243 @@
import { expect, vi } from 'vitest'
import {
createHostTerminalRuntimeStub,
type HostTerminalRuntimeStub
} from './host-terminal-runtime-stub'
import {
createTerminalWireLink,
type ObservedFrame,
type RejectedFrame
} from './terminal-wire-link'
import type { ClientTerminal, TerminalWireBuild } from './versioned-terminal-wire'
export const JOURNEY_STEPS = [
'subscribe',
'first-snapshot',
'input-reaches-process',
'live-output',
'reveal-snapshot',
'transport-drop',
'resubscribe',
'input-after-reconnect'
] as const
export type JourneyStep = (typeof JOURNEY_STEPS)[number]
const TERMINAL_HANDLE = 'terminal-journey'
const FIRST_INPUT = 'echo cross-version\r'
const SECOND_INPUT = 'echo after-reconnect\r'
const LIVE_OUTPUT = 'cross-version live output\r\n'
const INITIAL_BUFFER = 'initial scrollback\r\n'
const BARRIER_TIMEOUT_MS = 10_000
export type JourneyRecord = {
hostLabel: string
clientLabel: string
hostRevision: string
clientRevision: string
/** Steps that actually completed, in order. The liveness oracle. */
completed: JourneyStep[]
/** `subscribed` events the client accepted, including negotiated capabilities. */
subscribedEvents: Record<string, unknown>[]
/** SnapshotStart payloads as the CLIENT decoded them — the published projection. */
snapshotStarts: Record<string, unknown>[]
/** Snapshot bodies handed to the pane. */
snapshotsRendered: string[]
/** Live output the client's pane received. */
dataRendered: string[]
/** Exact texts the host wrote to the PTY. */
inputAtProcess: string[]
/** Snapshot the reveal step resolved with. */
revealSnapshot: { data: string; cols: number; rows: number } | null
transportCloses: number
clientErrors: string[]
observed: ObservedFrame[]
/** Observed frames as `C>H Input` / `H>C SnapshotStart`, in delivery order. */
frameSequence: string[]
rejected: RejectedFrame[]
missingRuntimeMethods: string[]
}
function nameOpcode(build: TerminalWireBuild, opcode: number): string {
const name = build.codec.TerminalStreamOpcode[opcode]
return typeof name === 'string' ? name : `Opcode${opcode}`
}
async function barrier(label: string, predicate: () => boolean): Promise<void> {
try {
await vi.waitFor(() => expect(predicate()).toBe(true), {
timeout: BARRIER_TIMEOUT_MS,
interval: 5
})
} catch {
throw new Error(`Cross-version journey stalled at barrier: ${label}`)
}
}
/**
* Drive one terminal journey with a fixed script, so the same byte-identical oracle
* runs for every host/client version pairing:
*
* subscribe -> first snapshot -> input reaches the process -> live output ->
* hide/reveal snapshot -> transport drop -> resubscribe -> input still lands.
*
* Every step ends on an observed-state barrier, never on elapsed time.
*/
export async function runTerminalSkewJourney(args: {
hostBuild: TerminalWireBuild
clientBuild: TerminalWireBuild
}): Promise<JourneyRecord> {
const { hostBuild, clientBuild } = args
const hostStub: HostTerminalRuntimeStub = createHostTerminalRuntimeStub({
terminalHandle: TERMINAL_HANDLE,
initialBuffer: INITIAL_BUFFER
})
const link = createTerminalWireLink({ hostBuild, clientBuild, hostStub })
const record: JourneyRecord = {
hostLabel: hostBuild.label,
clientLabel: clientBuild.label,
hostRevision: hostBuild.revision,
clientRevision: clientBuild.revision,
completed: [],
subscribedEvents: [],
snapshotStarts: [],
snapshotsRendered: [],
dataRendered: [],
inputAtProcess: hostStub.writtenInput,
revealSnapshot: null,
transportCloses: 0,
clientErrors: [],
observed: link.observed,
frameSequence: [],
rejected: link.rejected,
missingRuntimeMethods: hostStub.missingRuntimeMethods
}
// Name opcodes with whichever build knows more of them, so an unknown opcode in
// the journey reads as `Opcode17` instead of silently borrowing a wrong name.
const namingBuild =
Object.keys(clientBuild.codec.TerminalStreamOpcode).length >=
Object.keys(hostBuild.codec.TerminalStreamOpcode).length
? clientBuild
: hostBuild
const collectFrameSequence = (): void => {
record.frameSequence = link.observed.map(
(frame) =>
`${frame.direction === 'host-to-client' ? 'H>C' : 'C>H'} ${nameOpcode(namingBuild, frame.opcode)}`
)
}
const snapshotStartOpcode = Number(clientBuild.codec.TerminalStreamOpcode.SnapshotStart)
const collectSnapshotStarts = (): void => {
record.snapshotStarts = link.observed
.filter(
(frame) => frame.direction === 'host-to-client' && frame.opcode === snapshotStartOpcode
)
.map((frame) => frame.json ?? {})
}
let subscribedCount = 0
const callbacks = {
onData: (data: string) => {
record.dataRendered.push(data)
},
onSnapshot: (data: string) => {
record.snapshotsRendered.push(data)
},
onSubscribed: () => {
subscribedCount++
},
onError: (message: string) => {
record.clientErrors.push(message)
},
onTransportClose: () => {
record.transportCloses++
}
}
const subscribe = async (): Promise<ClientTerminal> =>
clientBuild.client
.getRemoteRuntimeTerminalMultiplexer('cross-version-runtime')
.subscribeTerminal({
terminal: TERMINAL_HANDLE,
client: { id: 'cross-version-client', type: 'desktop' },
viewport: { cols: 120, rows: 40 },
callbacks
})
try {
let terminal = await subscribe()
await barrier('subscribe: client never saw a `subscribed` event', () => subscribedCount >= 1)
record.subscribedEvents = link.connections.flatMap((connection) =>
connection.events.filter((event) => event.type === 'subscribed')
)
record.completed.push('subscribe')
await barrier(
'first-snapshot: client never rendered the initial buffer snapshot',
() => record.snapshotsRendered.length >= 1
)
record.completed.push('first-snapshot')
terminal.sendInput(FIRST_INPUT)
await barrier('input-reaches-process: host never wrote the client input to the PTY', () =>
hostStub.writtenInput.includes(FIRST_INPUT)
)
record.completed.push('input-reaches-process')
hostStub.emitOutput(LIVE_OUTPUT)
await barrier('live-output: client never rendered host output', () =>
record.dataRendered.join('').includes(LIVE_OUTPUT.trim())
)
record.completed.push('live-output')
// Hide/reveal: the pane drops xterm and asks the host to re-publish the buffer.
const revealed = await terminal.serializeBuffer({ scrollbackRows: 200 })
if (!revealed) {
throw new Error('reveal-snapshot: host returned no buffer snapshot on reveal')
}
record.revealSnapshot = { data: revealed.data, cols: revealed.cols, rows: revealed.rows }
record.completed.push('reveal-snapshot')
const closesBeforeDrop = record.transportCloses
link.disconnect()
await barrier(
'transport-drop: client never observed the transport close',
() => record.transportCloses > closesBeforeDrop
)
record.completed.push('transport-drop')
const subscribedBeforeReconnect = subscribedCount
terminal = await subscribe()
await barrier(
'resubscribe: client never re-established the stream after reconnect',
() => subscribedCount > subscribedBeforeReconnect
)
record.subscribedEvents = link.connections.flatMap((connection) =>
connection.events.filter((event) => event.type === 'subscribed')
)
record.completed.push('resubscribe')
terminal.sendInput(SECOND_INPUT)
await barrier('input-after-reconnect: host never wrote post-reconnect input to the PTY', () =>
hostStub.writtenInput.includes(SECOND_INPUT)
)
record.completed.push('input-after-reconnect')
terminal.close()
} finally {
collectSnapshotStarts()
collectFrameSequence()
await link.dispose()
}
return record
}
export const JOURNEY_INPUTS = {
first: FIRST_INPUT,
second: SECOND_INPUT,
output: LIVE_OUTPUT,
initialBuffer: INITIAL_BUFFER
}

View File

@ -0,0 +1,225 @@
import { vi } from 'vitest'
import type { HostTerminalRuntimeStub } from './host-terminal-runtime-stub'
import type { TerminalStreamFrame, TerminalWireBuild } from './versioned-terminal-wire'
export type ObservedFrame = {
direction: 'host-to-client' | 'client-to-host'
opcode: number
streamId: number
seq: number
/** JSON payload when the receiving side could parse one. */
json: Record<string, unknown> | null
text: string
}
export type RejectedFrame = {
direction: 'host-to-client' | 'client-to-host'
/** Opcode byte as written by the sender, even though the receiver refused it. */
rawOpcode: number
byteLength: number
}
export type HostConnection = {
connectionId: string
events: Record<string, unknown>[]
alive: boolean
}
export type TerminalWireLink = {
/** Frames each side accepted, in delivery order. */
observed: ObservedFrame[]
/** Frames the receiving build's decoder refused — the unknown-opcode failure mode. */
rejected: RejectedFrame[]
connections: HostConnection[]
/** Drop the live transport the way a socket close would. */
disconnect: () => void
dispose: () => Promise<void>
}
function rawOpcodeOf(bytes: Uint8Array): number {
return bytes.length > 2 ? bytes[2]! : -1
}
function describeFrame(
direction: ObservedFrame['direction'],
frame: TerminalStreamFrame,
codec: TerminalWireBuild['codec']
): ObservedFrame {
const json = codec.decodeTerminalStreamJson<Record<string, unknown>>(frame.payload)
return {
direction,
opcode: frame.opcode,
streamId: frame.streamId,
seq: frame.seq,
json: json && typeof json === 'object' ? json : null,
text: codec.decodeTerminalStreamText(frame.payload)
}
}
/**
* Pair one client build to one host build over an in-process transport that copies
* the production routing exactly:
*
* - client -> host: the HOST decodes with its own codec and drops the frame when
* the opcode is unknown (`runtime-rpc.ts` `handleWebSocketBinaryMessage`);
* - host -> client: raw bytes reach the client, which decodes with ITS codec.
*
* That asymmetry is the whole point: a frame only survives if the receiving build
* understands it, so a new opcode against an old peer disappears silently.
*/
export function createTerminalWireLink(args: {
hostBuild: TerminalWireBuild
clientBuild: TerminalWireBuild
hostStub: HostTerminalRuntimeStub
}): TerminalWireLink {
const { hostBuild, clientBuild, hostStub } = args
const observed: ObservedFrame[] = []
const rejected: RejectedFrame[] = []
const connections: HostConnection[] = []
const dispatchPromises: Promise<unknown>[] = []
let connectionCounter = 0
type LiveConnection = {
record: HostConnection
handlers: Map<number, (frame: TerminalStreamFrame) => void>
clientCallbacks: {
onResponse: (response: unknown) => void
onBinary: (bytes: Uint8Array) => void
onError?: (error: { code?: string; message: string }) => void
onClose?: () => void
}
}
let live: LiveConnection | null = null
const closeHostSideByConnection = new Map<string, () => void>()
const subscribe = async (
_args: unknown,
clientCallbacks: LiveConnection['clientCallbacks']
): Promise<{ unsubscribe: () => void; sendBinary: (bytes: Uint8Array) => void }> => {
connectionCounter++
const connectionId = `cross-version-conn-${connectionCounter}`
const record: HostConnection = { connectionId, events: [], alive: true }
const handlers = new Map<number, (frame: TerminalStreamFrame) => void>()
const connection: LiveConnection = { record, handlers, clientCallbacks }
connections.push(record)
live = connection
const abort = new AbortController()
const closeHostSide = (): void => {
record.alive = false
if (live === connection) {
live = null
}
abort.abort()
// The socket layer runs the host's registered teardown on close; without it
// the multiplex handler never settles and the harness would hang, not fail.
hostStub.closeConnection(connectionId)
}
closeHostSideByConnection.set(connectionId, closeHostSide)
const dispatch = new hostBuild.host.RpcDispatcher({
runtime: hostStub.runtime,
methods: hostBuild.host.TERMINAL_METHODS
}).dispatchStreaming(
{
id: `req-${connectionCounter}`,
authToken: 'cross-version-token',
method: 'terminal.multiplex',
params: {}
},
(message) => {
if (!record.alive) {
return
}
const envelope = JSON.parse(message) as Record<string, unknown>
const result = envelope.result
if (result && typeof result === 'object') {
record.events.push(result as Record<string, unknown>)
}
clientCallbacks.onResponse(envelope)
},
{
connectionId,
sendBinary: (bytes) => {
if (!record.alive) {
return false
}
const asClientSees = clientBuild.codec.decodeTerminalStreamFrame(bytes)
if (!asClientSees) {
rejected.push({
direction: 'host-to-client',
rawOpcode: rawOpcodeOf(bytes),
byteLength: bytes.byteLength
})
} else {
observed.push(describeFrame('host-to-client', asClientSees, clientBuild.codec))
}
// Bytes always go out; only the receiving decoder decides survival.
clientCallbacks.onBinary(bytes)
return true
},
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => {
if (handlers.get(streamId) === handler) {
handlers.delete(streamId)
}
}
},
signal: abort.signal
}
)
dispatchPromises.push(dispatch.catch(() => {}))
return {
unsubscribe: closeHostSide,
sendBinary: (bytes) => {
if (!record.alive) {
return
}
const frame = hostBuild.codec.decodeTerminalStreamFrame(bytes)
if (!frame) {
rejected.push({
direction: 'client-to-host',
rawOpcode: rawOpcodeOf(bytes),
byteLength: bytes.byteLength
})
return
}
observed.push(describeFrame('client-to-host', frame, hostBuild.codec))
handlers.get(frame.streamId)?.(frame)
}
}
}
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
subscribe: vi.fn(subscribe)
}
},
location: { search: '' }
})
return {
observed,
rejected,
connections,
disconnect: () => {
const connection = live
if (!connection) {
return
}
closeHostSideByConnection.get(connection.record.connectionId)?.()
connection.clientCallbacks.onClose?.()
},
dispose: async () => {
for (const record of connections) {
closeHostSideByConnection.get(record.connectionId)?.()
}
live = null
clientBuild.client.resetRemoteRuntimeTerminalMultiplexersForTests()
vi.unstubAllGlobals()
await Promise.all(dispatchPromises)
}
}
}

View File

@ -0,0 +1,151 @@
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))
}