// the wire contract for the sootsim ws bridge. the engine implements it in // sootsim-engine/src/mcp/ws-bridge.ts; the rnxsim cli client, the SimClient // sdk, and the flow runner speak it. it is the single owner of these shapes, // and neither side redeclares them. // // it lives in rnxsim rather than the engine because rnxsim is published and // sootsim-engine is `private: true`. the engine already depends on rnxsim // (see its `rnxsim` dependency and its `rnxsim/bridge-constants` import), so // this is the direction that ships. import type { SootSimAgentActionDetail, SootSimCaptureFilter, SootSimExternalAppLifecycleOptions, SootSimStorageResetStrategy, } from '@rnx/globals' export type BridgeCommandType = | 'evaluate' | 'call' | 'screenshot' | 'capture' | 'captureRegions' | 'settle' | 'setAppearance' | 'openUrl' | 'memory' | 'tap' | 'longPress' | 'keyboard' | 'tree' | 'focus' | 'close' | 'flowStatus' | 'perform' | 'waitFor' | 'state' | 'query' | 'resolve' | 'reset' | 'camera' | 'diagnostics' | 'storageSnapshot' // the bridge host answers these itself, before the engine's handleCommand ever // sees them. they are not engine commands and the cloud plane does not route // them, so they stay out of BridgeCommandType and out of its cloud scope table. export type BridgeHostCommandType = 'bridge:list-sims' | 'bridge:claim' // what a caller hands a bridge's `send`: the command minus the correlation id, // which the transport assigns. every send seam names this type rather than an // index-signature bag, because an index signature erases excess-property // checking and a field spelled wrong then compiles and arrives as `undefined`. export type WsBridgeCommand = Omit & { type: BridgeCommandType | BridgeHostCommandType } export interface WsCommand { id: string | number type: BridgeCommandType // local daemon routing; cloud transport strips it before execution. simId?: string // take or refresh the cli lease on the target sim before running. reads omit // it; anything that mutates user-facing state sets it. read in bridge-host. acquireLock?: boolean // bridge:claim — take the lease even when another cli holds it. force?: boolean // flowStatus — live flow-runner progress payload (see flow-run-store) status?: Record code?: string path?: string args?: unknown[] x?: number y?: number durationMs?: number maxFrames?: number target?: SootSimAgentActionDetail['target'] action?: string text?: string appearance?: 'light' | 'dark' url?: string includeVisual?: boolean includeFonts?: boolean includeRasterAtlas?: boolean regions?: import('./capture-contract').RnxCaptureRegionRequest[] depth?: number // screenshot — which canvas layers to composite. omit for full (today's // default: everything including iOS chrome). 'tenant' excludes the shell // overlay for a clean app capture. layers?: 'full' | 'tenant' | 'shell' // screenshot — engine-side layer isolation (hide subtrees / solo one // subtree on transparency). see SootSimCaptureOptions.captureFilter. captureFilter?: SootSimCaptureFilter // screenshot — optional crop rect in logical sootsim coords. the capture // module scales x/y/w/h by device pixel ratio before cropping. crop?: { x: number; y: number; w: number; h: number } // perform — ordered batch executed inside the page, no round trips steps?: PerformStep[] performOptions?: PerformOptions // waitFor — one engine-side polling loop and one transport round trip waitForOptions?: SemanticWaitOptions // state — compound screenshot + tree + route + errors stateOptions?: SimStateOptions // query / resolve — structured visible-tree reads and selector geometry query?: SimSemanticQuery selector?: SimSemanticResolveSelector // reset — two-tier app state wipe resetOptions?: ResetOptions // camera — fixture injection into the host camera pipeline camera?: CameraCommand // diagnostics — read-only engine debug state: channel recording, the sheet / // portal / boundary finders, and the recent event ring. diagnostics?: DiagnosticsOptions // storageSnapshot — the running app's own storage and route, as // `StorageSnapshot` from the engine's preview/storage-snapshot module. the // default result is share-safe; `includeCredentials` carries the signed-in // session and belongs only to a send to the owner's own box. storageSnapshotOptions?: { includeCredentials?: boolean } } // --------------------------------------------------------------------------- // perform — batched input // --------------------------------------------------------------------------- // every step maps onto a primitive that already exists on // SootSim.bridges.interact / .keyboard / .test. the batch runs entirely inside // the page so gesture timing is real wall-clock timing, not ws round-trips. // // deliberately absent: text-based tap resolution. matching by visible text is // fuzzy, needs ranking and failure diagnostics, and lives once in the cli's // tapByText. the sdk resolves text to coordinates before it builds a batch. // testID lookup is exact, so tapId resolves in-engine. export type PerformStep = // raw pointer steps — full control over gesture physics | { type: 'touchDown'; x: number; y: number; pointerId?: number } | { type: 'touchMove'; x: number; y: number; pointerId?: number } | { type: 'touchUp'; x: number; y: number; pointerId?: number } | { type: 'touchCancel'; pointerId?: number } // semantic steps | { type: 'tap'; x: number; y: number; target?: SootSimAgentActionDetail['target'] } | { type: 'tapId'; id: string } | { type: 'doubleTap'; x: number; y: number; gapMs?: number } | { type: 'longPress' x: number y: number durationMs?: number target?: SootSimAgentActionDetail['target'] } | { type: 'drag' fromX: number fromY: number toX: number toY: number steps?: number stepMs?: number } | { type: 'pinch' fromX1: number fromY1: number fromX2: number fromY2: number toX1: number toY1: number toX2: number toY2: number steps?: number stepMs?: number } | { type: 'scroll'; id: string; x?: number; y?: number; animated?: boolean } | { type: 'type'; text: string } | { type: 'key'; key: string } | { type: 'dismissKeyboard' } | { type: 'wait'; ms: number } | { type: 'waitFor' testID?: string id?: string condition?: 'present' | 'mounted' | 'absent' timeoutMs?: number } // limrun parity steps. each maps onto an engine primitive and reports // per step; the refused ones below parse but never run. | { type: 'setElementValue'; id: string; text: string; submit?: boolean } | { type: 'toggleKeyboard' } | { type: 'openUrl'; url: string } | { type: 'keyDown'; key: string } | { type: 'keyUp'; key: string } | { type: 'setOrientation'; orientation: 'portrait' | 'landscape' } | { type: 'deviceInfo' } // refused with a named reason, never emulated: no hardware-button model, // no storekit simulation, no stepper primitive, and key/orientation state // the engine does not hold. | { type: 'buttonDown'; button?: string } | { type: 'buttonUp'; button?: string } | { type: 'incrementElement'; id: string } | { type: 'decrementElement'; id: string } | { type: 'discoverStoreKitConfig' } | { type: 'clearStoreKitConfig' } export interface PerformOptions { // stop the batch at the first failing step. default true — a gesture whose // touchDown missed should not keep dragging. stopOnError?: boolean // ceiling on batch PROGRESS, checked between steps: once it passes, the // batch starts no further steps and reports which one it reached. a step // already running is always awaited to completion, never abandoned, because // a gesture that keeps dispatching pointer events after the caller has been // told the batch failed is worse than a late reply. every step is bounded on // its own (drag and pinch by steps * stepMs, wait by ms, waitFor by its own // timeout), and a wedged worker is caught by the CLI client's per-command // timeout one layer up. default 30000. timeoutMs?: number } // observed device identity for the `deviceInfo` step: the live device spec // in both runtimes, so every field is measured, never a default. export interface PerformDeviceInfo { platform: string model?: string width: number height: number scale: number } // steps that parse but never run. one owner for the refusal vocabulary so // the browser page and the cpu runtime refuse with the same named reason and // neither plane emulates what the engine cannot do. export const REFUSED_PERFORM_STEP_TYPES = [ 'keyDown', 'keyUp', 'setOrientation', 'buttonDown', 'buttonUp', 'incrementElement', 'decrementElement', 'discoverStoreKitConfig', 'clearStoreKitConfig', ] as const export type RefusedPerformStepType = (typeof REFUSED_PERFORM_STEP_TYPES)[number] export function refusedPerformStepReason(type: RefusedPerformStepType): string { switch (type) { case 'keyDown': case 'keyUp': return `${type} is not supported: keys dispatch atomically; use the key step` case 'setOrientation': return 'setOrientation is not supported: the simulator viewport is fixed at boot' case 'buttonDown': case 'buttonUp': return `${type} is not supported: the simulator has no hardware buttons` case 'incrementElement': case 'decrementElement': return `${type} is not supported: steppers have no engine primitive; drive them with tapId` case 'discoverStoreKitConfig': case 'clearStoreKitConfig': return `${type} is not supported: storekit has no simulator model` } } // returns the refusal for a refused step, null when the step is runnable. // every variant is listed so adding a step forces its classification here; // the executors mirror the refused cases in their own switches. export function performStepRefusal(type: PerformStep['type']): string | null { switch (type) { case 'keyDown': case 'keyUp': case 'setOrientation': case 'buttonDown': case 'buttonUp': case 'incrementElement': case 'decrementElement': case 'discoverStoreKitConfig': case 'clearStoreKitConfig': return refusedPerformStepReason(type) case 'touchDown': case 'touchMove': case 'touchUp': case 'touchCancel': case 'tap': case 'tapId': case 'doubleTap': case 'longPress': case 'drag': case 'pinch': case 'scroll': case 'type': case 'key': case 'dismissKeyboard': case 'setElementValue': case 'toggleKeyboard': case 'openUrl': case 'deviceInfo': case 'wait': case 'waitFor': return null } } export interface PerformStepResult { index: number type: PerformStep['type'] ok: boolean // step-specific payload: tap hit info, waitFor match, resolved coordinates value?: unknown error?: string // wall-clock ms this step took inside the page durationMs: number } export interface PerformResult { ok: boolean steps: PerformStepResult[] // number of steps that ran. less than steps.length when the batch aborted. completed: number durationMs: number error?: string } // --------------------------------------------------------------------------- // waitFor — engine-side semantic condition polling // --------------------------------------------------------------------------- // `present` is what a person can see: the node must survive the occlusion pass, // so a button behind a sheet does not count. `mounted` asks only whether the // node exists and is laid out, which is the right question for a marker the app // renders as proof of something (a build id, a launch phase) rather than as // something to look at — those are 1x1 and painted under the whole app, so they // are permanently occluded and `present` can never match one. export type SemanticWaitCondition = | { type: 'ready' } | { type: 'present'; selector: SimSemanticResolveSelector } | { type: 'mounted'; selector: SimSemanticResolveSelector } | { type: 'absent'; selector: SimSemanticResolveSelector } export interface SemanticWaitOptions { condition: SemanticWaitCondition timeoutMs?: number } export interface SemanticReadyProbe { flag: unknown at: number nodes: number targets: number liveFrameActive: boolean liveFrameChannels: number liveFramePublishes: number errors: number loadingText: string externalReady: boolean | null externalStatus: string externalError: string suppressedEntryError: string } // react native's own Pressable/Touchable delayLongPress default, and the // ceiling any transport accepts for a press-and-hold. a driver that holds // longer than this is describing a drag, not a long press. export const SIM_LONG_PRESS_DEFAULT_MS = 500 export const SIM_LONG_PRESS_MAX_MS = 5_000 export const SEMANTIC_READY_CONTENT_NODE_FLOOR = 100 export const SEMANTIC_READY_NODE_STABLE_MS = 750 export const SEMANTIC_READY_POLL_MS = 150 export const PUBLIC_NO_NETWORK_MARKER = 'public RNX Cloud simulators have no network' export const HEADLESS_TENANT_FETCH_HUNG_MARKER = 'headless tenant fetch hung' export function readyProbeHasContent( probe: Pick & Partial>, ): boolean { return ( probe.liveFrameActive || probe.targets > 0 || probe.nodes >= SEMANTIC_READY_CONTENT_NODE_FLOOR ) } export function readyProbeHasTargetContent( probe: Pick & Partial>, ): boolean { return probe.liveFrameActive || probe.targets > 0 } export interface SemanticWaitResult { matched: boolean elapsedMs: number polls: number probe?: SemanticReadyProbe node?: SimSemanticNode error?: string } // --------------------------------------------------------------------------- // state — one round-trip situational awareness // --------------------------------------------------------------------------- export interface DiagnosticsOptions { // debug channels to record from this point on. omitted leaves recording as // it is; an empty list stops recording. channels?: readonly string[] find?: 'sheets' | 'portals' | 'boundaries' recentChannel?: string recentLimit?: number } export interface DiagnosticsResult { channels: string[] runtime: unknown found: unknown[] | null events: unknown[] } export interface SimStateOptions { // omit a section an agent does not need this turn screenshot?: boolean tree?: boolean route?: boolean errors?: boolean // include the bounded guest console tail. omitted by the ordinary state // command so one-shot state remains compact; `rnx logs` requests it. logs?: boolean // tree depth, matching the standalone `tree` command. default 5. depth?: number layers?: WsCommand['layers'] // cap on recentErrors entries. default 20. errorLimit?: number // cap on recentLogs entries. default and maximum 100. logLimit?: number } export interface SimStateLogEntry { level: string text: string at?: number } export interface SimStateResult { // png data uri, same encoding the `screenshot` command returns screenshot?: unknown tree?: SimSemanticNode[] route?: { url?: string; screen?: string | null; stack?: string[] } | null recentErrors?: SimStateLogEntry[] recentLogs?: SimStateLogEntry[] keyboard?: { visible: boolean; mode?: string | null } capturedAt: number } // --------------------------------------------------------------------------- // semantic tree — pruned visible structure shared by local and cloud runtimes // --------------------------------------------------------------------------- export interface SimSemanticGeometry { x: number y: number width: number height: number } export interface SimSemanticNode { nodeId: number type: string testID?: string text?: string role?: string label?: string pressable?: true geometry: SimSemanticGeometry children?: SimSemanticNode[] } export type SimSemanticSelector = | { testID: string } | { text: string } | { role: string } | { type: string } | { pressable: true } | { visible: true } export type SimSemanticQuery = | { kind: 'tree'; depth?: number } | { kind: 'find'; selector: SimSemanticSelector } | { kind: 'count' } export interface SimSemanticQueryResult { nodes: SimSemanticNode[] total: number } export type SimSemanticResolveSelector = { testID: string } | { text: string } export interface SimSemanticResolveResult { match: SimSemanticNode target: SimSemanticNode point: { x: number; y: number } } // --------------------------------------------------------------------------- // reset — two-tier app state wipe // --------------------------------------------------------------------------- // 'data' clears what a user could clear from inside the app: async storage, // MMKV, sqlite, cache dirs. 'full' also clears what only a fresh install would: // simulated keychain, user defaults, granted permissions. // // reset replaces the tenant worker before clearing storage, so background app // work cannot refill a store between its clear and verification. export type ResetStrategy = SootSimStorageResetStrategy export interface ResetOptions extends Pick< SootSimExternalAppLifecycleOptions, 'initialUrl' | 'launchArguments' > { strategy?: ResetStrategy // relaunch the app after wiping. default true. relaunch?: boolean // boot the wiped simulator on a live copy of another simulator's storage // instead of empty stores: the cheap clean-but-not-cold seam. the id names // a simulator on the same account; runtimes that cannot reach another // simulator's storage refuse it by name rather than wiping without forking. from?: string } export interface ResetResult { ok: boolean strategy: ResetStrategy // stores verified empty after the wipe, read back rather than assumed cleared: string[] // stores this strategy deliberately did NOT clear, each with the reason. // a named gap is not a failure, so this leaves `ok` true and stays out of // `error`; a caller that cares can branch on it. skipped?: Array<{ store: string; reason: string }> relaunched: boolean // true when reset had to restart the tenant worker instead of doing an // in-worker cold remount. a restart is correct but slow, so a caller running // reset between tests needs to see which path it got. workerReloaded?: boolean error?: string } // --------------------------------------------------------------------------- // camera — fixture injection // --------------------------------------------------------------------------- // the host thread owns the camera pipeline (hidden