// SimClient — the typed, promise-based client every rnx frontend drives a // sim through. it wraps a bridge transport (the CLI's ws bridge, or any // InspectBridge-shaped channel) and reuses the existing inspect verbs as its // implementation; it never re-implements tap resolution, find, settling, or // readiness. // // MUST stay browser-safe: no node builtins, no `process`, no `ws`. the ws // transport and `connect()` live in ./connect (node-only) so this class can // also back in-page transports later. runtime imports here may only reach the // browser-safe inspect kernels. import { isTapSuccess, tapBest, tapById, tapByText, type TapOutcome, type TapTextOptions, } from '../cli/commands/inspect/actions' import { filterLogEntries, getShellState, inspectAccessibilityTree, inspectDescribe, inspectErrors, inspectFind, inspectKeyboard, inspectLogs, inspectTree, inspectUrl, inspectWaitReady, inspectWarnings, waitReadyReason, type AccessibilityTreeNode, type ConsoleEntry, type DescribeResult, type FindQuery, type InspectBridge, type InteractiveNode, type KeyboardState, type LogEntry, type LogFilterOptions, type WaitReadyStatus, } from '../cli/commands/inspect/core' import { resolveTargetCoords } from '../cli/commands/inspect/resolve-target' import { waitForSootsimIdle, type WaitForSootsimIdleResult, } from '../cli/commands/inspect/settling' import { openAppUrl } from './app-url' import type { BridgeClaimResult, BridgeSimInfo, WsBridge } from '../cli/ws-bridge' import type { PerformOptions, PerformResult, PerformStep, ResetOptions, ResetResult, SimSemanticNode, SimSemanticResolveResult, SimSemanticResolveSelector, SimStateOptions, SimStateResult, WsBridgeCommand, } from './bridge-contract' // --------------------------------------------------------------------------- // typed errors — extend the BridgeSimLockedError precedent so scripts can // branch with instanceof instead of parsing message strings. // --------------------------------------------------------------------------- export class SimClientError extends Error { constructor(message: string) { super(message) this.name = new.target.name } } /** no daemon/bridge reachable, no sim connected, or the target sim is gone. */ export class SimConnectError extends SimClientError {} /** `waitReady` ran out of budget. carries the last probe status. */ export class SimReadyTimeoutError extends SimClientError { status: WaitReadyStatus constructor(status: WaitReadyStatus) { super(`sim not ready: ${waitReadyReason(status)}`) this.status = status } } /** a tap could not resolve its target or the dispatched press missed. */ export class SimTapError extends SimClientError { outcome: TapOutcome | null constructor(message: string, outcome: TapOutcome | null = null) { super(message) this.outcome = outcome } } /** `type`/`press` with no visible keyboard (nothing focused to receive it). */ export class SimKeyboardError extends SimClientError {} /** a `perform` batch reported failure. carries the full step-by-step result. */ export class SimPerformError extends SimClientError { result: PerformResult constructor(result: PerformResult) { const failed = result.steps.find((step) => !step.ok) const at = failed ? `${failed.index} (${failed.type})` : String(result.completed) const cause = failed?.error ?? result.error super(`perform failed at step ${at}${cause ? `: ${cause}` : ''}`) this.result = result } } /** a `reset` reported failure. carries the engine's result. */ export class SimResetError extends SimClientError { result: ResetResult constructor(result: ResetResult) { super(`reset (${result.strategy}) failed${result.error ? `: ${result.error}` : ''}`) this.result = result } } // --------------------------------------------------------------------------- // public option/target shapes // --------------------------------------------------------------------------- export interface SimFindOptions { testId?: string text?: string role?: string type?: string pressable?: boolean } export type SimTapTarget = | string | { x: number; y: number } | { testId: string } | ({ text: string } & TapTextOptions) | InteractiveNode // the contract deliberately has no text-targeted step (text matching is fuzzy // and lives once in the CLI's tap resolution). the SDK accepts one as sugar and // resolves it to a coordinate `tap` step client-side before sending ONE batch. export type SimPerformStep = PerformStep | { type: 'tapText'; text: string } export interface SimScreenshot { dataUrl: string bytes: Uint8Array } export interface SimScreenshotOptions { layers?: 'full' | 'tenant' | 'shell' crop?: { x: number; y: number; w: number; h: number } } export interface SimClientOptions { /** default per-command bridge timeout. */ commandTimeoutMs?: number } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } function decodePngDataUrl(dataUrl: unknown): SimScreenshot { if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) { throw new SimClientError( `screenshot bridge returned a non-png payload: ${JSON.stringify( typeof dataUrl === 'string' ? dataUrl.slice(0, 80) : dataUrl, )}`, ) } const base64 = dataUrl.slice('data:image/png;base64,'.length) const binary = atob(base64) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) return { dataUrl, bytes } } // derive the shell base for navigating this sim to a new target, from the // sim's current URL: keep the origin and every non-target param (device, // renderMode, debug…), drop the params and `/rn` path segment that name the // current target. mirrors the CLI open command's base derivation; the shared // module extraction is tracked with the driver. const TARGET_SEARCH_PARAMS = [ 'bundle', 'demo', 'app', 'open', 'port', 'inspectOpen', 'appFonts', 'appSplash', ] export function buildSimOpenUrl(currentUrl: string, target: number | string): string { const url = new URL(currentUrl) for (const param of TARGET_SEARCH_PARAMS) url.searchParams.delete(param) // shell route grammar: an optional /__soot prefix, then /rn, /rn/, or // /app/. the current route is replaced wholesale, keeping the prefix. const pathname = url.pathname.replace(/\/+$/, '') || '/' const prefix = pathname === '/__soot' || pathname.startsWith('/__soot/') ? '/__soot' : '' const normalized = String(target).trim() if (/^\d+$/.test(normalized)) { // `/rn/` — the shell's own connect route resolves the metro bundle // in-page, so the SDK never grows a second bundle-resolution path. url.pathname = `${prefix}/rn/${normalized}` } else { url.pathname = `${prefix}/rn` url.searchParams.set('open', normalized) } return url.toString() } // a `{ text, ...TapTextOptions }` target, as distinct from a node returned by // find() (which carries layout/absolutePosition alongside its text). function isTextTapTarget( target: SimTapTarget, ): target is { text: string } & TapTextOptions { return ( typeof target === 'object' && 'text' in target && typeof target.text === 'string' && !('layout' in target) && !('absolutePosition' in target) ) } // --------------------------------------------------------------------------- // SimClient // --------------------------------------------------------------------------- // implements the full WsBridge surface (not just InspectBridge) so it can be // handed to every existing verb and CLI helper unchanged, with each command // pinned to the sim this client was connected to. export class SimClient implements WsBridge, InspectBridge { readonly simId: string private readonly bridge: WsBridge private closed = false constructor(bridge: WsBridge, simId: string, _options: SimClientOptions = {}) { this.bridge = bridge this.simId = simId } get plane(): WsBridge['plane'] { return this.bridge.plane } // --- transport (WsBridge/InspectBridge) --- send(cmd: WsBridgeCommand, opts?: { timeoutMs?: number }): Promise { if (this.closed) { return Promise.reject(new SimConnectError('sim client is closed')) } const payload = cmd.simId === undefined ? { ...cmd, simId: this.simId } : cmd return this.bridge.send(payload, opts) } listSims(): Promise { return this.bridge.listSims() } resolveReloadedSim(retiredSimId: string): Promise { return this.bridge.resolveReloadedSim(retiredSimId) } focusSim(simId: string = this.simId): Promise { return this.bridge.focusSim(simId) } closeSim(simId: string = this.simId): Promise { return this.bridge.closeSim(simId) } claim( simId: string = this.simId, opts?: { force?: boolean }, ): Promise { return this.bridge.claim(simId, opts) } /** release the socket. a script that connected must close to exit cleanly. */ close(): void { if (this.closed) return this.closed = true this.bridge.close() } // --- navigation + readiness --- /** * point this sim at a dev target: a metro port (`8081`), or anything the * shell's connect flow accepts (`localhost:8081`, an app slug…). resolves * once the reloaded page answers on the new target route. */ async open(target: number | string, opts: { timeoutMs?: number } = {}): Promise { const sims = await this.listSims() const sim = sims.find((entry) => entry.id === this.simId) if (!sim) { throw new SimConnectError(`sim ${this.simId} is no longer connected`) } const currentUrl = sim.url ?? sim.origin if (!currentUrl) { throw new SimConnectError( `sim ${this.simId} reported no URL to derive the shell from`, ) } const nextUrl = new URL(buildSimOpenUrl(currentUrl, target)) // the page being replaced keeps answering until the browser commits the // navigation, and it may already sit on this target. only a one-off token // in the new URL proves the reloaded page is the one answering. const token = `sim-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` nextUrl.searchParams.set('inspectOpen', token) await this.send({ type: 'evaluate', code: `window.location.href = ${JSON.stringify(nextUrl.toString())}`, }) const deadline = Date.now() + (opts.timeoutMs ?? 30_000) while (Date.now() < deadline) { await sleep(250) try { const { url } = await inspectUrl(this) if (new URL(url).searchParams.get('inspectOpen') === token) return } catch { // page is mid-reload; keep polling until the deadline. } } throw new SimConnectError( `timed out waiting for sim ${this.simId} to load ${nextUrl}`, ) } /** dispatch a route or app URL through React Native Linking in this guest. */ openUrl(target: string): Promise { return openAppUrl(this, target) } async setAppearance(appearance: 'light' | 'dark'): Promise { await this.send({ type: 'setAppearance', appearance }) } /** * block until the guest app has mounted and painted real content. * throws SimReadyTimeoutError (with the last probe status) on timeout. */ async waitReady( opts: { timeoutMs?: number onProgress?: (status: WaitReadyStatus) => void } = {}, ): Promise { const status = await inspectWaitReady(this, opts.timeoutMs ?? 20_000, { onProgress: opts.onProgress, }) if (!status.ready) throw new SimReadyTimeoutError(status) return status } /** wait for layout/animation/transition settle after an interaction. */ settle( opts: { maxMs?: number; strict?: boolean } = {}, ): Promise { return waitForSootsimIdle({ bridge: this, maxMs: opts.maxMs ?? 3_000, strict: opts.strict, }) } // --- read verbs --- /** first matching node, or null. query precedence matches the CLI's find. */ async find(query: SimFindOptions): Promise { const nodes = await this.findAll(query) return nodes[0] ?? null } async findAll(query: SimFindOptions): Promise { const mapped: FindQuery = { testId: query.testId ?? null, role: query.role ?? null, type: query.type ?? null, text: query.text ?? null, pressable: query.pressable, } const found = await inspectFind(this, mapped) if (!found) { throw new SimClientError( 'find() needs at least one of testId, text, role, type, or pressable', ) } const { result } = found if (Array.isArray(result)) return result return result ? [result as InteractiveNode] : [] } async tree(depth = 5): Promise { const { tree } = await inspectTree(this, depth) return Array.isArray(tree) ? tree : [] } resolve( selector: SimSemanticResolveSelector, ): Promise { return this.send({ type: 'resolve', selector }) } /** compact visible semantic tree, formatted as the CLI prints it. */ describe(opts: { filter?: string; verbose?: boolean } = {}): Promise { return inspectDescribe(this, { describe: true, verbose: opts.verbose ?? false, filter: opts.filter ?? '', compact: !opts.verbose, hideXy: false, fullText: true, }) } accessibilityTree(): Promise { return inspectAccessibilityTree(this) } async logs(opts: LogFilterOptions = {}): Promise { return filterLogEntries(await inspectLogs(this), opts) } errors(limit = 20): Promise { return inspectErrors(this, limit) } warnings(limit = 20): Promise { return inspectWarnings(this, limit) } keyboard(): Promise { return inspectKeyboard(this) } shellState(readyTimeoutMs = 0): Promise | null> { return getShellState(this, readyTimeoutMs) } async url(): Promise { const { url } = await inspectUrl(this) return url } async screenshot(opts: SimScreenshotOptions = {}): Promise { const result = await this.send({ type: 'screenshot', ...(opts.layers ? { layers: opts.layers } : {}), ...(opts.crop ? { crop: opts.crop } : {}), }) return decodePngDataUrl(result) } // --- input verbs --- /** * tap by testID, visible text, node, or coordinates. resolution, retry, and * hit verification are the CLI's shared tap kernels. throws SimTapError when * the target is missing, covered, offscreen, or the press missed. */ async tap(target: SimTapTarget): Promise { if (typeof target === 'string') { return this.throwUnlessTapped(await tapBest(this, target), target) } if ('testId' in target && typeof target.testId === 'string') { return this.throwUnlessTapped(await tapById(this, target.testId), target.testId) } if (isTextTapTarget(target)) { const { text, ...textOpts } = target return this.throwUnlessTapped(await tapByText(this, text, textOpts), text) } if ( 'testID' in target && typeof target.testID === 'string' && typeof target.nodeId !== 'number' ) { return this.throwUnlessTapped(await tapById(this, target.testID), target.testID) } const coords = this.coordsForTapTarget(target) const result = await this.send({ type: 'tap', x: coords.x, y: coords.y, ...('nodeId' in target && typeof target.nodeId === 'number' ? { target: { nodeId: target.nodeId } } : {}), }) if (!isTapSuccess(result)) { throw new SimTapError( `tap at ${coords.x},${coords.y} missed: ${JSON.stringify(result)}`, ) } } async longPress( target: SimTapTarget, opts: { durationMs?: number } = {}, ): Promise { const coords = await this.resolveCoords(target) const result = await this.send( { type: 'longPress', x: coords.x, y: coords.y, durationMs: opts.durationMs ?? 600, }, { timeoutMs: (opts.durationMs ?? 600) + 15_000 }, ) if (!result?.ok) { throw new SimTapError(`longPress at ${coords.x},${coords.y} missed`) } } /** type through the visual iOS keyboard. requires a focused input. */ async type(text: string): Promise { await this.requireVisibleKeyboard('type') await this.send({ type: 'keyboard', action: 'type', text }) } /** press a keyboard key ('return', 'delete', 'space', …). */ async press(key: string): Promise { await this.requireVisibleKeyboard('press') await this.send({ type: 'keyboard', action: 'press', text: key }) } async dismissKeyboard(): Promise { await this.send({ type: 'keyboard', action: 'dismiss' }) } // --- batched input, compound state, reset --- /** * execute an ordered step batch inside the page in ONE round-trip, so waits * and gesture physics run on real wall-clock timing. `tapText` steps are * resolved to coordinates client-side before the batch is sent. throws * SimPerformError (carrying the per-step result) when the batch fails. */ async perform( steps: SimPerformStep[], options?: PerformOptions, ): Promise { const resolved: PerformStep[] = [] for (const step of steps) { if (step.type !== 'tapText') { resolved.push(step) continue } const target = await resolveTargetCoords(this, { mode: 'text', value: step.text, }) if (!target) { throw new SimTapError( `perform tapText: no node with text ${JSON.stringify(step.text)}`, ) } resolved.push({ type: 'tap', x: target.x, y: target.y, target: { nodeId: target.nodeId, testID: target.testID ?? undefined, text: target.text ?? undefined, type: target.type ?? undefined, }, }) } const batchTimeoutMs = options?.timeoutMs ?? 30_000 const result = (await this.send( { type: 'perform', steps: resolved, ...(options ? { performOptions: options } : {}), }, { timeoutMs: batchTimeoutMs + 5_000 }, )) as PerformResult if (!result?.ok) throw new SimPerformError(result) return result } /** screenshot + tree + route + recent errors in one round-trip. */ state(options?: SimStateOptions): Promise { return this.send({ type: 'state', ...(options ? { stateOptions: options } : {}), }) as Promise } /** * two-tier app state wipe ('data' clears storage/caches, 'full' also clears * keychain/permissions/native-module state). throws SimResetError on failure. */ async reset(options?: ResetOptions): Promise { const result = (await this.send( { type: 'reset', ...(options ? { resetOptions: options } : {}) }, { timeoutMs: 60_000 }, )) as ResetResult if (!result?.ok) throw new SimResetError(result) return result } // --- internals --- private throwUnlessTapped(outcome: TapOutcome, label: string): void { if (!outcome.failure && isTapSuccess(outcome.result)) return const reason = outcome.failure === 'not-found' ? 'no matching node' : outcome.failure === 'missed' ? (outcome.result?.reason ?? 'press missed') : outcome.payload?.ambiguous ? `ambiguous: ${outcome.payload.total} matches` : (outcome.result?.reason ?? outcome.payload?.error ?? 'tap failed') throw new SimTapError(`tap ${JSON.stringify(label)} failed: ${reason}`, outcome) } private coordsForTapTarget(target: SimTapTarget): { x: number; y: number } { if ( typeof target === 'object' && 'x' in target && 'y' in target && typeof target.x === 'number' && typeof target.y === 'number' ) { return { x: target.x, y: target.y } } if ( typeof target === 'object' && 'absolutePosition' in target && target.absolutePosition && target.layout ) { return { x: target.absolutePosition.x + (target.layout.width ?? 0) / 2, y: target.absolutePosition.y + (target.layout.height ?? 0) / 2, } } throw new SimClientError( 'tap target needs coordinates, a testId, text, or a node from find()', ) } private async resolveCoords(target: SimTapTarget): Promise<{ x: number; y: number }> { if (typeof target === 'string') { const resolved = (await resolveTargetCoords(this, { mode: 'testid', value: target })) ?? (await resolveTargetCoords(this, { mode: 'text', value: target })) if (!resolved) throw new SimTapError(`no node matching ${JSON.stringify(target)}`) return resolved } if ('testId' in target && typeof target.testId === 'string') { const resolved = await resolveTargetCoords(this, { mode: 'testid', value: target.testId, }) if (!resolved) { throw new SimTapError(`no node with testID ${JSON.stringify(target.testId)}`) } return resolved } if ('text' in target && typeof target.text === 'string' && !('layout' in target)) { const resolved = await resolveTargetCoords(this, { mode: 'text', value: target.text, }) if (!resolved) { throw new SimTapError(`no node with text ${JSON.stringify(target.text)}`) } return resolved } return this.coordsForTapTarget(target) } private async requireVisibleKeyboard(action: string): Promise { const deadline = Date.now() + 1_500 for (;;) { const state = await inspectKeyboard(this) if (!('error' in state) && state.visible) return if (Date.now() >= deadline) { throw new SimKeyboardError( `${action} requires the iOS keyboard to be visible — focus an input first (tap a TextInput)`, ) } await sleep(150) } } }