// rnx inspect — dump node tree, accessibility info, query nodes // connects to the rnx WS bridge (no Playwright) import { getCliInspectVerbMeta } from '@rnx/skills/cli' import { renderCliCommandHelp, renderCliGroupHelp } from '@rnx/skills/cli/renderers' import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants' import { DEFAULT_SOOTSIM_SHELL_URL } from '../../src/cli-constants' import { resetGuestAppStateEval } from '../app-state-reset' import { printBridgeStateSummary } from '../bridge-diagnostics' import { buildResolveRectEval, buildSampleColorEval, type SampleRect, } from '../browser-evals' import { getCliIdentityKey } from '../current-sim' import { rememberFlowCandidate } from '../flow-session' import { maybeHint } from '../hints' import { shouldPrintInspectNotice } from '../inspect-notice-state' import { TOP_LEVEL_RUNTIME_COMMANDS } from '../parse-args' import { rethrowIfExit, rnxExit } from '../run-rnx' import { type BridgeTransportOption, callInBridge, callInBridgeWrite, checkSimHealth, createBridge, createBridgeFromParsed, evalInBridge, parseBridgeCliArgs, printBridgeWorldNotice, type WsBridge, } from '../ws-bridge' import { isTapSuccess, tapBest, tapById, tapByText, tapCoordinates, tapResolvedTarget, type TapTextOptions, } from './inspect/actions' import { clearConsole, clearLogs, clearRequests, detectOpenNativeUI, filterLogEntries, inspectAccessibilityTree, inspectErrors, inspectFind, inspectLogs, inspectRequests, inspectWarnings, MERGED_CONSOLE_COUNT_EVAL, shouldSkipAutoSettleForInspectPick, type LogEntry, type LogLevel, type OpenNativeUISurface, } from './inspect/core' import { runCountSubcommand } from './inspect/count' import { runDescribeSubcommand } from './inspect/describe' import { isAgentEnv } from './inspect/env' import { runFindSubcommand } from './inspect/find' import { runGetLayoutSubcommand } from './inspect/get-layout' import { runKeyboardSubcommand } from './inspect/keyboard' import { runListSubcommand } from './inspect/list' import { runMemorySubcommand } from './inspect/memory' import { SECURE_TEXT_REDACTION, isSecureKeyboardState, redactKeyboardStateForOutput, redactTextForKeyboardState, } from './inspect/redaction' import { readTargetFlag, resolveTargetCoords } from './inspect/resolve-target' import { runScreensSubcommand } from './inspect/screens' import { runSettleSubcommand } from './inspect/settle' import { waitForSootsimIdle } from './inspect/settling' import { callShellCommand, callShellCommandWhenReady, callTestBridge, formatLogTimestamp, getShellState, isShellCommandUnavailable, maybeWaitForStartedScreenTransitions, pollForReloadReady, printJson, printWarn, type ReloadReadyResult, sleep, SCREEN_TRANSITION_PREFLIGHT, waitForBridgeConnected, wantsJson, } from './inspect/shared' import { runSleepSubcommand } from './inspect/sleep' import { runTreeSubcommand } from './inspect/tree' import { runUrlSubcommand } from './inspect/url' import { runWaitSubcommand } from './inspect/wait' import { runWaitEventSubcommand } from './inspect/wait-event' import { runWaitIdleSubcommand } from './inspect/wait-idle' import { runWaitReadySubcommand } from './inspect/wait-ready' import { runWaitSelectorSubcommand } from './inspect/wait-selector' import { printMissingSimHint, printUnknownSimHint } from './no-bridge-hint' import type { PerformResult, PerformStep } from '../../src/bridge-contract' import type { SootSimReplayTarget, SootSimRequestEntry, SootSimScreenTransitionWaitResult, SootSimScrollPerformanceTrace, } from '@rnx/globals' interface InspectOptions extends BridgeTransportOption { port?: number verbose?: boolean timeoutMs?: number internalPerfCommand?: 'shell' | 'scroll' } // shape of entries in the shared observability store — mirrors // packages/sootsim-engine/src/observability/types.ts NetworkEntry. duplicated // here so this command doesn't cross-import from engine internals. interface NetworkEntryPayload { id: string source: string kind: 'fetch' | 'xhr' | 'resource' method: string url: string displayUrl: string startTs: number durationMs: number | null status: number | null statusText: string | null ok: boolean error: string | null size: number | null type: string | null } function formatNetworkSize(bytes: number | null): string { if (bytes == null) return '—' if (bytes < 1024) return `${bytes}B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K` return `${(bytes / 1024 / 1024).toFixed(1)}M` } function formatNetworkDuration(ms: number | null): string { if (ms == null) return ' …' if (ms < 1000) return `${ms}ms`.padStart(5) return `${(ms / 1000).toFixed(2)}s`.padStart(5) } // when a tap-id / tap-text query misses, walk the live tree's testIDs and // emit the canonical "recover a wedged sim" hint. shared by the genuine-dead // paths in the failure handler. function printRecoverHint(): void { process.stderr.write( ` the sim is not responding. recover it with:\n` + ` rnx close --sim # force-close the wedged sim\n` + ` rnx list # confirm it's gone\n`, ) } // cheap liveness probe: a command timing out does NOT prove the sim is dead. // `describe` on a big screen (rainbow's loaded home is hundreds of nodes) // blows the 15s default while `get errors` answers instantly — same sim. send // a trivial eval on a short budget; if it answers, the sim is alive and the // previous command just needs a narrower scope or a bigger timeout. // send a one-step `perform` batch and flatten the result back to the // `{ ok, value, error? }` shape these subcommands have always printed. the // engine executes the step in-page and emits the agent-cursor action itself. async function performSingleStep( bridge: WsBridge, step: PerformStep, ): Promise<{ ok: boolean; value?: unknown; error?: string }> { const result: PerformResult = await bridge.send({ type: 'perform', steps: [step] }) const stepResult = result?.steps?.[0] const error = stepResult?.error ?? result?.error return { ok: result?.ok === true, value: stepResult?.value, ...(error ? { error } : {}), } } async function probeSimResponsive(bridge: WsBridge): Promise { try { await bridge.send({ type: 'evaluate', code: '1' }, { timeoutMs: 3000 }) return true } catch { return false } } // surface the 5 closest candidates so callers can fix the typo without // dumping the full tree. uses a cheap prefix + Levenshtein score so a // trailing digit or near-miss casing still bubbles to the top. async function printSimilarTestIds(bridge: WsBridge, query: string): Promise { try { const found = await inspectFind(bridge, { visible: true }) const nodes = Array.isArray(found?.result) ? found.result : [] const ids = [ ...new Set( nodes.flatMap((node) => { const id = Reflect.get(node, 'testID') return typeof id === 'string' && id ? [id] : [] }), ), ] if (ids.length === 0) return const q = query.toLowerCase() const ranked = ids .map((id) => ({ id, score: scoreIdSimilarity(q, id.toLowerCase()), })) .filter((entry) => entry.score < q.length + 4) // drop obvious non-matches .sort((a, b) => a.score - b.score) .slice(0, 5) if (ranked.length === 0) return console.error(` similar testIDs:`) for (const entry of ranked) { console.error(` ${entry.id}`) } } catch { // if the suggestion path fails the user still got the "not found" msg } } function scoreIdSimilarity(needle: string, candidate: string): number { if (candidate === needle) return 0 if (candidate.includes(needle)) return 1 if (needle.includes(candidate)) return 2 // tiny prefix/suffix bonus so `nav-back` ranks above unrelated long names // when querying `back`. let prefix = 0 while ( prefix < needle.length && prefix < candidate.length && needle[prefix] === candidate[prefix] ) { prefix += 1 } return levenshtein(needle, candidate) - prefix } function levenshtein(a: string, b: string): number { if (a === b) return 0 if (!a.length) return b.length if (!b.length) return a.length // standard two-row dp: prev[] is the previous row, cur[] is the row being // computed; swap pointers per outer iteration. the earlier in-place rolling // version overwrote prev[j-1] twice per cell and never set prev[0]=i, so // distance(`kitten`,`sitting`) returned 8 instead of 3 and the fuzzy-suggest // ranking was effectively random. let prev = new Array(b.length + 1) let cur = new Array(b.length + 1) for (let j = 0; j <= b.length; j++) prev[j] = j for (let i = 1; i <= a.length; i++) { cur[0] = i for (let j = 1; j <= b.length; j++) { cur[j] = Math.min( prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), ) } const tmp = prev prev = cur cur = tmp } return prev[b.length] } function formatNetworkStatus(e: NetworkEntryPayload): string { if (e.error) return 'err' if (e.status == null) return ' … ' return String(e.status) } function describeReloadReadyBlocker(result: ReloadReadyResult): string { if (result.externalError) return `guest app errored: ${result.externalError}` if (result.loadingText) return `still showing "${result.loadingText}"` if (result.externalReady === false) return 'guest app is still loading' if (result.flag !== true) return 'guest app has not emitted sootsim:externalAppReady' if (result.targets <= 0) { return 'ready flag emitted but no visible app content is inspectable yet' } return 'node tree is still changing' } function printNetworkRow(e: NetworkEntryPayload) { const time = formatLogTimestamp(e.startTs) const status = formatNetworkStatus(e).padEnd(3) const method = e.method.padEnd(5) const size = formatNetworkSize(e.size).padStart(6) const dur = formatNetworkDuration(e.durationMs) console.log(` [${time}] ${status} ${method} ${size} ${dur} ${e.displayUrl}`) if (e.error) console.log(` error: ${e.error}`) } function printNetworkDetail(e: NetworkEntryPayload) { const rows: [string, string][] = [ ['id', e.id], ['source', e.source], ['kind', e.kind], ['method', e.method], [ 'status', e.error ? `error: ${e.error}` : `${e.status ?? '—'} ${e.statusText ?? ''}`.trim(), ], ['url', e.url], ['started', formatLogTimestamp(e.startTs)], ['duration', formatNetworkDuration(e.durationMs).trim()], ['size', formatNetworkSize(e.size)], ['content-type', e.type ?? '—'], ] for (const [k, v] of rows) console.log(` ${k.padEnd(13)} ${v}`) } // ANSI colors for the `logs` verb. the LogEntry / LogLevel types + filtering // live in inspect/core.ts so the CLI and the agent share one definition. const LOG_LEVEL_COLOR: Record = { error: '\x1b[31m', // red warn: '\x1b[33m', // yellow info: '\x1b[36m', // cyan debug: '\x1b[35m', // magenta log: '\x1b[37m', // white } const ANSI_RESET = '\x1b[0m' const ANSI_DIM = '\x1b[2m' function printLogRow(e: LogEntry, useColor: boolean) { const time = formatLogTimestamp(e.ts) const levelTag = e.level.toUpperCase().padEnd(5) const msg = e.args.join(' ') if (useColor) { const col = LOG_LEVEL_COLOR[e.level] console.log( ` ${ANSI_DIM}[${time}]${ANSI_RESET} ${col}${levelTag}${ANSI_RESET} ${msg}`, ) } else { console.log(` [${time}] ${levelTag} ${msg}`) } if (e.stack && e.level === 'error') { const lines = e.stack.split('\n').slice(0, 5) for (const line of lines) console.log(` ${line.trim()}`) } } const SWITCHER_SETTLE_GRACE_MS = 120 // parse sample-color / screenshot rect args. supports several shapes so the // command is friendly for both humans and scripted agents: // single pixel (w=h=1) // positional area // --area x,y,w,h comma-separated area // --x n --y n --w n --h n // --id snap to node's bounding box // --text snap to node matching text // returns null when no rect flags are present — callers then either fall // back to a full-canvas capture (screenshot) or report an argument error // (sample-color). async function resolveSampleRect( args: string[], bridge: WsBridge, ): Promise { const idArg = args.find((_, i) => args[i - 1] === '--id') const textArg = args.find((_, i) => args[i - 1] === '--text') if (idArg || textArg) { const node = await bridge.send({ type: 'evaluate', code: buildResolveRectEval({ id: idArg, text: textArg }), }) if (!node) { throw new Error( idArg ? `no node with id "${idArg}"` : `no node matching text "${textArg}"`, ) } const { x, y, w, h } = node as SampleRect return { x, y, w, h } } const areaArg = args.find((_, i) => args[i - 1] === '--area') if (areaArg) { const parts = areaArg.split(',').map((p) => Number(p.trim())) if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) { throw new Error(`--area expects x,y,w,h (got "${areaArg}")`) } const [x, y, w, h] = parts return { x, y, w, h } } const flagged = (name: string): number | null => { const value = args.find((_, i) => args[i - 1] === name) if (value == null) return null const n = Number(value) return Number.isFinite(n) ? n : null } const fx = flagged('--x') const fy = flagged('--y') const fw = flagged('--w') const fh = flagged('--h') if (fx != null || fy != null || fw != null || fh != null) { return { x: fx ?? 0, y: fy ?? 0, w: fw ?? 1, h: fh ?? 1 } } // positional: " [w] [h]" const positional = args.filter( (a, i) => i > 0 && !a.startsWith('-') && args[i - 1] !== '--output' && args[i - 1] !== '--area' && args[i - 1] !== '--id' && args[i - 1] !== '--text' && args[i - 1] !== '--x' && args[i - 1] !== '--y' && args[i - 1] !== '--w' && args[i - 1] !== '--h', ) const nums = positional.map(Number).filter((n) => Number.isFinite(n)) if (nums.length >= 2) { const [x, y, w = 1, h = 1] = nums return { x, y, w, h } } return null } function printRenderProfile(worker: string, rp: Record | undefined) { if (!rp || typeof rp !== 'object') return console.log(``) console.log(` render profile — ${worker} (per painted frame):`) console.log(` node visits: ${rp.nodeVisitsPerFrame}`) console.log( ` boundaries: ${rp.recordsPerFrame} records (${rp.avgBoundaryRecordMs}ms) / ${rp.replaysPerFrame} replays`, ) const recordCauses = [ rp.boundaryRecordsInvalidated ? `invalidated ${rp.boundaryRecordsInvalidated}` : '', rp.boundaryRecordsOrigin ? `moved ${rp.boundaryRecordsOrigin}` : '', rp.boundaryRecordsFirst ? `first-record ${rp.boundaryRecordsFirst}` : '', rp.boundaryRecordsScheme ? `scheme ${rp.boundaryRecordsScheme}` : '', rp.boundaryRecordsFont ? `late-font ${rp.boundaryRecordsFont}` : '', ] .filter(Boolean) .join(' · ') if (recordCauses) console.log(` why recorded: ${recordCauses}`) // idle work, so it is a total for the capture, not a per-frame average. if (rp.prewarmRecords) { console.log( ` pre-recorded: ${rp.prewarmRecords} boundaries at idle (${Number(rp.prewarmRecordMs).toFixed(1)}ms total)`, ) } console.log( ` raster tier: ${rp.rasterPromotionsPerFrame} promotions / ${rp.rasterBlitsPerFrame} blits`, ) const rejects = [ rp.rasterRejectRebuild ? `rebuild ${rp.rasterRejectRebuild}` : '', rp.rasterRejectLinear ? `linear ${rp.rasterRejectLinear}` : '', rp.rasterRejectAnimated ? `animated ${rp.rasterRejectAnimated}` : '', rp.rasterRejectTransform ? `transform ${rp.rasterRejectTransform}` : '', rp.rasterRejectOpacity ? `opacity ${rp.rasterRejectOpacity}` : '', rp.rasterRejectCacheable ? `cacheable ${rp.rasterRejectCacheable}` : '', rp.rasterRejectBounds ? `bounds ${rp.rasterRejectBounds}` : '', rp.rasterRejectPixels ? `pixels ${rp.rasterRejectPixels}` : '', rp.rasterRejectBudget ? `budget ${rp.rasterRejectBudget}` : '', rp.rasterWarming ? `warming ${rp.rasterWarming}` : '', ] .filter(Boolean) .join(' · ') if (rejects) { console.log(` raster skips: ${rejects} (total across run)`) if (rp.rasterRejectAlsoBlocked) { console.log( ` ${''.padEnd(12)} ${rp.rasterRejectAlsoBlocked} of those also fail a later gate — widening the named gate cannot promote them`, ) } } if (rp.paragraphBuilds || rp.prewarmParagraphBuilds || rp.paragraphLayouts) { console.log( ` text shaping: ${rp.paragraphBuilds} shaped on frame ${Number(rp.paragraphBuildMs ?? 0).toFixed(1)}ms · ${rp.prewarmParagraphBuilds ?? 0} shaped on idle ${Number(rp.prewarmParagraphBuildMs ?? 0).toFixed(1)}ms · ${rp.paragraphLayouts} re-broken ${Number(rp.paragraphLayoutMs ?? 0).toFixed(1)}ms (all phases)`, ) } console.log(` blur: ${rp.avgBlurMs}ms`) if (rp.glassDownsampleActive || rp.glassBackdropCacheMisses) { console.log( ` glass: ${rp.glassDownsampleFactor}x downsample (${rp.glassDownsampledDraws} draws) · cache ${rp.glassBackdropCacheHits} hits / ${rp.glassBackdropCacheMisses} misses / ${rp.glassBackdropCacheInvalidations} invalidations`, ) } console.log( ` draw calls: text ${rp.textDrawsPerFrame} · image ${rp.imageDrawsPerFrame} · path ${rp.pathDrawsPerFrame} · saveLayer ${rp.saveLayersPerFrame}`, ) } // pretty-print the shell frame profile payload (__sootsimShellPerf.stop()): // per-painted-frame timing from the shell worker merged with the render // profile counters. numbers arrive pre-rounded from the worker. also used by // `rnx maestro test --profile`, which records the same payload. export function printShellPerfReport(result: Record) { const fps = result.avgMs > 0 ? (1000 / result.avgMs).toFixed(1) : '?' const jank = result.jank && typeof result.jank === 'object' ? result.jank : null const production = result.compositorProduction && typeof result.compositorProduction === 'object' ? result.compositorProduction : null const budgetMs = production?.budgetMs ?? 8 console.log(` shell frame profile:\n`) if (jank) { const compositor = Array.isArray(jank.compositor) ? jank.compositor : [] const compositorJank = compositor.reduce( (total: number, surface: Record) => total + (surface.jankPaints ?? 0), 0, ) console.log( ` health: ${jank.detected ? 'jank or cadence gaps observed' : 'no jank or cadence gaps observed'} · shell ${jank.shell?.jankFrames ?? 0} · compositor ${compositorJank} · host clock gaps ${jank.hostGaps ?? 0}`, ) console.log(` series: independent worker clocks, no per-frame pairing`) } console.log( ` painted frames: ${result.frames}${result.skippedFrames ? ` (+${result.skippedFrames} skipped idle ticks)` : ''}`, ) console.log( ` shell work avg: ${result.avgMs}ms (${fps} fps) · worst observed ${result.maxMs}ms`, ) console.log(` shell p50/95/99:${result.p50} / ${result.p95} / ${result.p99} ms`) if (jank) { console.log( ` shell jank: ${jank.shell?.jankFrames ?? 0}/${jank.shell?.sampledFrames ?? 0} (${jank.shell?.jankPct ?? 0}%) >${budgetMs}ms · max ${jank.shell?.maxMs ?? 0}ms`, ) for (const surface of Array.isArray(jank.compositor) ? jank.compositor : []) { console.log( ` ${String(surface.surfaceId).padEnd(12)} ${surface.jankPaints}/${surface.sampledPaints} compositor paints (${surface.jankPct}%) >${budgetMs}ms · max ${surface.maxMs}ms`, ) } } else { console.log( ` jank: ${result.jankFrames} frames (${result.jankPct}%) >${budgetMs}ms`, ) } console.log( ` avg per frame: overlay ${result.avgOverlayMs}ms · aux ${result.avgAuxMs}ms · layout ${result.avgLayoutMs}ms`, ) if (production) { const frames = production.frames ?? {} console.log(``) console.log(` compositor production (${budgetMs}ms budget):`) console.log( ` frames: ${frames.count ?? 0} · avg ${Number(frames.avg ?? 0).toFixed(2)} / p95 ${Number(frames.p95 ?? 0).toFixed(2)} / max ${Number(frames.max ?? 0).toFixed(2)} ms · ${production.overBudget ?? 0} over budget (${production.overBudgetPct ?? 0}%)`, ) const phaseNames = [ ['javascript', 'javascript'], ['layout', 'layout'], ['paint', 'paint'], ['prewarm', 'prewarm'], ['texture upload', 'textureUpload'], ['gpu submit', 'gpuSubmit'], ['composite', 'composite'], ['capture/readback', 'capture'], ] as const for (const [label, key] of phaseNames) { const phase = production.phases?.[key] if (!phase?.count || (!phase.max && !phase.avg)) continue console.log( ` ${label.padEnd(16)} avg ${Number(phase.avg ?? 0).toFixed(2)} / p95 ${Number(phase.p95 ?? 0).toFixed(2)} / max ${Number(phase.max ?? 0).toFixed(2)} ms`, ) } const capture = production.captureBetweenFrames if (capture?.count && capture.max > 0) { console.log( ` capture between p95 ${Number(capture.p95 ?? 0).toFixed(2)} / max ${Number(capture.max ?? 0).toFixed(2)} ms between compositor rAF callbacks`, ) } const worstProduction = Array.isArray(production.worstFrames) ? production.worstFrames.slice(0, 5) : [] if (worstProduction.length > 0) { console.log(` worst frames:`) for (const frame of worstProduction) { const phases = frame.phases ?? {} console.log( ` ${Number(frame.productionMs ?? 0).toFixed(2)}ms frame ${frame.frameToken ?? '?'} · js ${Number(phases.javascriptMs ?? 0).toFixed(2)} · layout ${Number(phases.layoutMs ?? 0).toFixed(2)} · paint ${Number(phases.paintMs ?? 0).toFixed(2)} · prewarm ${Number(phases.prewarmMs ?? 0).toFixed(2)} · upload ${Number(phases.textureUploadMs ?? 0).toFixed(2)} · submit ${Number(phases.gpuSubmitMs ?? 0).toFixed(2)} · composite ${Number(phases.compositeMs ?? 0).toFixed(2)} · capture ${Number(phases.captureMs ?? 0).toFixed(2)}`, ) } } } const cadence = result.cadence if (cadence && typeof cadence === 'object') { console.log(``) console.log(` cadence (frame delivery, ${cadence.vsyncTicks} vsync ticks):`) console.log( ` display clock: ${cadence.displayHz}hz · host rAF interval p50 ${cadence.hostIntervalP50} / p95 ${cadence.hostIntervalP95} / max ${cadence.hostIntervalMax} ms${cadence.hostGaps ? ` · ${cadence.hostGaps} gaps >1.5x (host rAF starved)` : ''}`, ) console.log( ` delivery lag: p50 ${cadence.deliveryLagP50} / p95 ${cadence.deliveryLagP95} / max ${cadence.deliveryLagMax} ms (vsync postMessage → shell receipt)`, ) console.log( ` paint interval: p50 ${cadence.paintIntervalP50} / p95 ${cadence.paintIntervalP95} / max ${cadence.paintIntervalMax} ms${cadence.idleBreaks ? ` · ${cadence.idleBreaks} idle breaks excluded` : ''}`, ) } const compositorCadence = result.compositorCadence if (compositorCadence && typeof compositorCadence === 'object') { console.log( ` compositor rAF: p50 ${compositorCadence.p50} / p95 ${compositorCadence.p95} / max ${compositorCadence.max} ms · ${compositorCadence.gaps} gaps >1.5x`, ) const printPhase = (label: string, summary: Record | undefined) => { if (!summary?.count) return console.log( ` ${label}: ${summary.count} samples · p50 ${summary.p50.toFixed(2)} / p95 ${summary.p95.toFixed(2)} / max ${summary.max.toFixed(2)} ms`, ) } printPhase('engine-empty rAF', compositorCadence.phases?.uninterruptedEngineEmpty) printPhase('rAF with CanvasKit flush', compositorCadence.phases?.withCanvaskitFlush) printPhase('rAF after CanvasKit flush', compositorCadence.phases?.afterCanvaskitFlush) printPhase('CanvasKit submission', compositorCadence.canvaskitSubmission) } const surfaces = Array.isArray(result.auxSurfaces) ? result.auxSurfaces : [] const graphiteCaches = result.graphiteCaches const decodedImageCache = result.decodedImageCache if (graphiteCaches || decodedImageCache) { const printCache = ( label: string, cache: { bytes?: number; limitBytes?: number; purgeableBytes?: number } | null, ) => { if (typeof cache?.bytes !== 'number' || typeof cache.limitBytes !== 'number') { return } const purgeable = typeof cache.purgeableBytes === 'number' ? ` · ${(cache.purgeableBytes / 1024 / 1024).toFixed(1)}MB purgeable` : '' console.log( ` ${label.padEnd(29)} ${(cache.bytes / 1024 / 1024).toFixed(1)}MB/${(cache.limitBytes / 1024 / 1024).toFixed(1)}MB${purgeable}`, ) } console.log(``) console.log(` device-wide caches:`) printCache('Graphite context resources', graphiteCaches?.contextResources ?? null) printCache('Graphite recorder resources', graphiteCaches?.recorderResources ?? null) printCache('Graphite image provider', graphiteCaches?.imageProvider ?? null) printCache('decoded image pixels', decodedImageCache ?? null) if (graphiteCaches?.imageProvider) { console.log( ` ${''.padEnd(29)} ${graphiteCaches.imageProvider.entries} entries · ${graphiteCaches.imageProvider.uploads} uploads · ${graphiteCaches.imageProvider.evictions} evictions`, ) } if (decodedImageCache) { console.log( ` ${''.padEnd(29)} ${decodedImageCache.entries} entries/${decodedImageCache.maxEntries} max`, ) } } const liveFrames = result.liveFrames if (liveFrames && typeof liveFrames === 'object') { const tenant = liveFrames.tenant const compositorLive = liveFrames.compositor && typeof liveFrames.compositor === 'object' ? liveFrames.compositor : null console.log(``) console.log(` live frames (GL / camera / video / WebGPU / maps):`) if (tenant && typeof tenant === 'object') { const presentation = tenant.presentation ?? {} console.log( ` tenant published ${presentation.publishes ?? 0} · skipped ${presentation.backpressureSkips ?? 0} (in-flight) · stale-healed ${presentation.staleHeals ?? 0} · density ${tenant.renderDensity ?? '?'}`, ) } else { console.log(` tenant stats unavailable (tenant did not answer)`) } if (compositorLive) { console.log( ` compositor received ${compositorLive.publishes ?? 0} on ${compositorLive.activeChannels ?? 0} channel(s) · ${compositorLive.uploads ?? 0} texture uploads · ${compositorLive.uploadMsTotal ?? 0}ms total / ${compositorLive.uploadMsMax ?? 0}ms max`, ) const shared = compositorLive.sharedDeviceImages if (shared && typeof shared === 'object') { console.log( ` shared device: ${shared.bitmapUploadRequests ?? 0} upload requests · ${shared.bitmapUploadsHeld ?? 0} held (motion) · ${shared.bitmapUploadBytes ?? 0} bytes · ${shared.bitmapCacheHits ?? 0} cache hits`, ) } } } if (surfaces.length > 0) { console.log(``) console.log(` surfaces (last 100 frames):`) for (const s of surfaces) { console.log( ` ${String(s.surfaceId).padEnd(10)} ${String(s.frames).padStart(4)} paints avg ${s.avgMs}ms (layout ${s.avgLayoutMs}, render ${s.avgRenderMs}, flush ${s.avgFlushMs}) max ${s.maxMs}ms`, ) } } // two profiles, two workers: the shell paints chrome/overlays, the // compositor paints home + app:one + app:two. guest-app scroll work is all // in the compositor one — reading the shell's raster counters for an app // workload is how a live raster tier reads as dead. printRenderProfile('shell worker', result.renderProfile) printRenderProfile('compositor worker', result.compositorRenderProfile) const worst = Array.isArray(result.worstFrames) ? result.worstFrames : [] if (worst.length > 0) { console.log(``) console.log(` worst observations (shell and compositor series are unpaired):`) for (const frame of worst) { if (frame.source === 'compositor') { console.log( ` ${String(frame.totalMs).padStart(7)}ms compositor ${frame.surfaceId} paint ${frame.paint} · layout ${frame.layoutMs} · render ${frame.renderMs} · flush ${frame.flushMs}`, ) continue } const surfaceBits = (frame.auxSurfaces ?? []) .map( (s: Record) => `${s.surfaceId} ${s.totalMs}ms (layout ${s.layoutMs}, render ${s.renderMs})`, ) .join(' · ') console.log( ` ${String(frame.totalMs).padStart(7)}ms overlay ${frame.overlayMs} · aux ${frame.auxMs} · layout ${frame.layoutMs}${surfaceBits ? ` [${surfaceBits}]` : ''}`, ) } } } // core bridge-facing helpers moved to ./inspect/shared — this file now just // imports them (see top-of-file imports). async function waitForShellState( bridge: WsBridge, timeoutMs: number, predicate: (state: Record | null) => boolean, ) { const deadline = Date.now() + timeoutMs let state = await getShellState(bridge, timeoutMs) while (true) { if (predicate(state)) return { settled: true, state } if (Date.now() >= deadline) return { settled: false, state } await sleep(16) state = await getShellState(bridge) } } async function getKeyboardState(bridge: WsBridge) { // focusedInput is sourced from __sootsimTest.getFocusedNode(), which reads // through the focus-keyboard-runtime (P2). previously this scanned // test.queryAll({}) for `n.style._focused` — a flag nothing in the engine // actually sets — so the cli always reported focusedInput: null, even // when an input was focused. that silently misled the 2026-04-17 bluesky // tap-routing investigation by ~30 minutes. // // phase + frame now also come from the runtime snapshot (same single // source) so cli consumers see the same state as KeyboardAvoidingView // and the keyboard-controller compat stub. return bridge .send({ type: 'evaluate', code: `(async () => { const kb = window.__sootsimKeyboard const test = window.__sootsimTest if (!kb) return { error: 'keyboard bridge not available' } const layout = typeof kb.getLayout === 'function' ? kb.getLayout() : null const secureTextEntry = !!layout?.spec?.secureTextEntry && layout?.spec?.keyboardType !== 'visible-password' const visible = kb.isVisible() const mode = kb.getMode() let focused = null if (test && typeof test.getFocusedNode === 'function') { try { focused = await test.getFocusedNode() } catch {} } let runtimeSnapshot = null if (test && typeof test.getFocusKeyboardSnapshot === 'function') { try { runtimeSnapshot = await test.getFocusKeyboardSnapshot() } catch {} } return { visible, mode, layout, hostedEditorFocused: typeof kb.hasHostedEditorFocus === 'function' && kb.hasHostedEditorFocus(), focusedInput: focused ? { nodeId: focused.nodeId ?? null, testID: focused.testID || null, id: focused.id || null, placeholder: focused.placeholder || null, secureTextEntry, text: secureTextEntry ? ${JSON.stringify(SECURE_TEXT_REDACTION)} : (focused.text || null), } : null, phase: runtimeSnapshot?.keyboard?.phase ?? null, frame: runtimeSnapshot?.keyboard?.frame ?? null, focusedRect: runtimeSnapshot?.focused?.rect ?? null, } })()`, }) .then((state) => redactKeyboardStateForOutput(state as any)) as Promise<{ visible?: boolean mode?: string layout?: Record | null hostedEditorFocused?: boolean focusedInput?: Record | null phase?: string | null frame?: Record | null focusedRect?: Record | null error?: string }> } function keyboardStateMatchesTarget( state: Awaited>, targetId: string | null, secureTextEntry: boolean | null, ) { if (!state.visible) return false const focused = state.focusedInput as | { testID?: string | null; id?: string | null } | null | undefined if (targetId && focused) { const matched = focused.testID === targetId || focused.id === targetId if (!matched) return false } if ( secureTextEntry !== null && isSecureKeyboardState(state as any) !== secureTextEntry ) { return false } return true } async function waitForKeyboardVisible( bridge: WsBridge, timeoutMs = 600, opts: { targetId?: string | null; secureTextEntry?: boolean | null } = {}, ) { const deadline = Date.now() + timeoutMs while (Date.now() <= deadline) { const state = await getKeyboardState(bridge) if ( keyboardStateMatchesTarget( state, opts.targetId ?? null, opts.secureTextEntry ?? null, ) ) { return state } await sleep(30) } return getKeyboardState(bridge) } /** * keys for a CPU simulator. it has no keyboard bridge to probe and refuses the * `keyboard` command, so its `perform` presses each character against * whatever holds focus and fails the batch when nothing does. */ async function performCloudKeys( bridge: WsBridge, steps: Array<{ type: 'type'; text: string } | { type: 'key'; key: string }>, actionLabel: string, ): Promise { const result: PerformResult = await bridge.send({ type: 'perform', steps }) if (result.ok) return const failed = result.steps.find((step) => !step.ok) console.error( ` ${actionLabel} failed: ${failed?.error ?? result.error ?? 'no text input had focus'}. focus an input first with rnx do tap-id or rnx do type-into.`, ) rnxExit(1) } async function requireVisualKeyboard(bridge: WsBridge, actionLabel: string) { const state = await getKeyboardState(bridge) if (state.visible && (state.focusedInput || state.hostedEditorFocused)) return state if (state.visible) { console.error( ` ${actionLabel} requires a focused editable control. the iOS keyboard is visible, but no input owns focus.`, ) rnxExit(1) } console.error( ` ${actionLabel} requires the iOS keyboard to be visible. focus an input first with rnx do tap-id/tap-text or rnx do type-into.`, ) rnxExit(1) } async function runShellVisualCommand( bridge: WsBridge, action: 'appearance' | 'lock' | 'shake', value?: string, ) { if (action === 'appearance') { return bridge.send({ type: 'evaluate', code: `(async () => { const requested = ${JSON.stringify(value ?? 'toggle')} // the engine owns toggle + auto resolution (settingsStore is the single // source of truth). never infer the current scheme client-side — the old // documentElement '#333333' sniff is never set in embedded / shell-chrome // contexts, so 'toggle' read 'light' forever and stuck on the boot scheme. window.postMessage({ type: 'contrast-action', action: 'set-appearance', value: requested }, '*') // let the engine's message handler apply + publish window.__sootsimColorScheme await new Promise((r) => setTimeout(r, 60)) const applied = window.__sootsimColorScheme?.resolved ?? (window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ? 'dark' : 'light') const setting = window.__sootsimColorScheme?.setting ?? requested return { ok: true, requested, setting, applied } })()`, }) } if (action === 'lock') { return bridge.send({ type: 'evaluate', code: `(async () => { const toggleLock = window.SootSim?.bridges?.mainShell?.toggleLock if (typeof toggleLock !== 'function') { throw new Error('rnx mainShell.toggleLock bridge unavailable') } await toggleLock() return { ok: true, action: 'lock' } })()`, }) } return bridge.send({ type: 'evaluate', code: `(async () => { window.dispatchEvent(new CustomEvent('sootsim:shake')) return { ok: true, action: 'shake' } })()`, }) } function keyCodeToVisualKey(code: string) { const direct: Record = { Enter: 'return', NumpadEnter: 'return', Backspace: 'delete', Delete: 'delete', Space: 'space', ShiftLeft: 'shift', ShiftRight: 'shift', } if (direct[code]) return direct[code] const digit = code.match(/^Digit([0-9])$/) if (digit) return digit[1] const key = code.match(/^Key([A-Z])$/) if (key) return key[1].toLowerCase() return null } function getInspectCommandName(subcommand: string, directSubcommand: string | null) { if (directSubcommand === subcommand) return `rnx ${subcommand}` const group = getCliInspectVerbMeta(subcommand)?.group return group ? `rnx ${group} ${subcommand}` : `rnx ${subcommand}` } function getInspectUsage( subcommand: string, tail: string, directSubcommand: string | null, ) { return ` usage: ${getInspectCommandName(subcommand, directSubcommand)}${tail ? ` ${tail}` : ''}` } function normalizeRecordedText(value: unknown) { if (typeof value !== 'string') return null const text = value.replace(/\s+/g, ' ').trim() return text ? text.slice(0, 80) : null } function normalizeRecordedId(...values: unknown[]) { for (const value of values) { if (typeof value !== 'string') continue const id = value.trim() if (id) return id } return null } async function recordInspectAction( source: string, step: Record, summary: string, ) { const result = rememberFlowCandidate({ source, step, summary }) // when a flow draft is active, make the pending-candidate model visible — // otherwise a sequence of `do` actions silently overwrites each other and // `maestro end` exports far fewer steps than the user expects (F13-4). if (!result.active) return if (result.replaced) { console.error( ` draft: replaced unkept action "${result.replaced.summary}" — ` + '`maestro keep` commits one action at a time', ) } else { console.error(' draft: action pending — `rnx maestro keep` to commit') } } function buildTapCandidateFromResult( x: number, y: number, result: any, ): { step: Record; summary: string } | null { if (!result || result.hit === false) return null const responderId = normalizeRecordedId(result.responderTestID, result.testID) if (responderId) { return { step: { tapOn: { id: responderId } }, summary: `tap #${responderId}`, } } const text = normalizeRecordedText(result.text) if (text) { return { step: { tapOn: text }, summary: `tap "${text}"`, } } return { step: { tapAtCoords: { x, y } }, summary: `tap @${Math.round(x)},${Math.round(y)}`, } } function buildTapCandidateFromNode( query: string, node: any, mode: 'text' | 'id', ): { step: Record; summary: string } { const stableId = normalizeRecordedId(node?.testID, node?.id) if (stableId) { return { step: { tapOn: { id: stableId } }, summary: `tap #${stableId}`, } } if (mode === 'id') { return { step: { tapOn: { id: query } }, summary: `tap #${query}`, } } return { step: { tapOn: query }, summary: `tap "${query}"`, } } export async function runInspect(args: string[], opts: InspectOptions) { // strip a leading grouping verb — `get`, `do`, and (when forwarded from // runDebug) `debug`. these are organizational prefixes and shouldn't leak // into subcommand dispatch. e.g.: // rnx get tree → effectiveArgs = ['tree'] // rnx do tap 100 200 → effectiveArgs = ['tap', '100', '200'] // rnx debug state shell → effectiveArgs = ['state', 'shell'] const verbPrefix = args[0] === 'get' || args[0] === 'do' || args[0] === 'debug' || args[0] === 'wait' ? args[0] : null const effectiveArgs = verbPrefix ? args.slice(1) : args const parsed = parseBridgeCliArgs(effectiveArgs, { port: opts.port, commandTimeoutMs: opts.timeoutMs, stripBooleanFlags: [ '--verbose', '-v', '--help', '-h', '--clear-state', '--json', '--all', '--watch', '-w', '--strict', '--no-wait', // `find --verbose` / `find --dump` emits full node JSON per result. '--dump', // `network` command flags — boolean toggles '--failed', '--slow', '--tail', '-f', // `find --interactive-targets` (alias `find --actions`) — ranked tappable list '--interactive-targets', '--actions', // `logs` command: include engine-internal [sootsim] debug messages '--internal', // `describe` boolean flags — without these they leak into `positional` // and get mistaken for a text filter (`describe --compact` then matches // nothing). value-taking describe flags live in stripValueFlags below. '--compact', '--no-xy', ], stripValueFlags: [ '--output', '--nth', '--index', '--testid', '--test-id', '--text', '--max-ms', // `network` / `logs` command flags that take a value '--filter', '--limit', '--level', // `network --slow` slow-threshold (ms). default 1000. '--threshold', // `wait event` filters '--equals', '--since', // `describe` value flags — narrowing selectors '--testid-like', '--only', '--subtree', ], }) const positional = parsed.positional const subcommand = positional[0] // used by inspectCommand() below to format usage/error strings with the // same prefix the user actually typed. null when they typed a top-level // command, and inspectCommand asks the registry for the group instead. const invocationPrefix: 'get' | 'do' | 'debug' | 'wait' | null = verbPrefix const isTopLevel = typeof effectiveArgs[0] === 'string' && TOP_LEVEL_RUNTIME_COMMANDS.has(effectiveArgs[0]) const topLevelCommand = isTopLevel ? effectiveArgs[0] : null // a usage string has to name something the user can actually type. the // registry knows which group each verb belongs to, so an unprefixed // invocation still renders `rnx get tree` rather than a bare verb. const inspectCommand = (name: string) => { if (opts.internalPerfCommand && name === 'perf') { return `rnx perf ${opts.internalPerfCommand}` } if (isTopLevel && name === effectiveArgs[0]) return `rnx ${name}` const group = invocationPrefix ?? getCliInspectVerbMeta(name)?.group return group ? `rnx ${group} ${name}` : `rnx ${name}` } const inspectUsage = (name: string, tail: string) => ` usage: ${inspectCommand(name)}${tail ? ` ${tail}` : ''}` if (!subcommand || args.includes('--help') || args.includes('-h')) { // all help output comes from the registry in packages/rnx-skills // so `rnx do --help`, `rnx get --help`, the website docs, and // the Contrast skill markdown stay in lock-step. a drift check in // test/sootsimCliRegistry.test.ts fails CI if a verb lands without a // matching registry entry. const docContext = { bridgePort: DEFAULT_SOOTSIM_BRIDGE_PORT, defaultShellUrl: DEFAULT_SOOTSIM_SHELL_URL, } // grouping-verb entry — clusters by subgroup (targeting, text input, // gestures, lifecycle, ...) so related verbs read as a visual family. if ( invocationPrefix === 'do' || invocationPrefix === 'get' || invocationPrefix === 'debug' || invocationPrefix === 'wait' ) { const groupHelp = renderCliGroupHelp(invocationPrefix, docContext) if (groupHelp) { console.log(`${groupHelp}\n`) rnxExit(0) } } if (topLevelCommand === 'shell') { const shellHelp = renderCliCommandHelp('shell', docContext) if (shellHelp) { console.log(`${shellHelp}\n`) rnxExit(0) } } // catch-all help — every group's listing stitched together. const groupSections = ['do', 'get', 'debug', 'wait'] .map((g) => renderCliGroupHelp(g, docContext)) .filter((s): s is string => s != null) .join('\n\n') console.log(`${groupSections}\n`) rnxExit(0) } const wsPort = parsed.wsPort const simId = parsed.simId const simIdSource = parsed.simIdSource const commandTimeoutMs = parsed.commandTimeoutMs if (invocationPrefix === 'get' && subcommand === 'diagnosis') { const diagnosisIndex = effectiveArgs.indexOf('diagnosis') const diagnosisArgs = diagnosisIndex >= 0 ? [ ...effectiveArgs.slice(0, diagnosisIndex), ...effectiveArgs.slice(diagnosisIndex + 1), ] : effectiveArgs const { runDiagnose } = await import('./diagnose') const code = await runDiagnose(['recent', ...diagnosisArgs], opts) if (code !== 0) rnxExit(code) return } if (invocationPrefix === 'do' && subcommand === 'scan') { const { runCamera } = await import('./camera') // runCamera returns a code instead of exiting, so `do scan` has to carry // it out. dropping it made a failed scan exit 0. const code = await runCamera(effectiveArgs, { port: opts.port }) if (code !== 0) rnxExit(code) return } // `rnx list --drivers` — pure local registry view, no bridge needed. // short-circuit here so users can discover drivers even when no rnx // session is running. if ( subcommand === 'list' && effectiveArgs.some((a) => a === '--drivers' || a === '-D') ) { const { buildDriverListRows } = await import('../drivers') const rows = buildDriverListRows() console.log(` available drivers (${rows.length}):\n`) const idWidth = Math.max(...rows.map((r) => r.id.length), 6) const kindWidth = Math.max(...rows.map((r) => r.kind.length), 4) for (const row of rows) { const status = row.available ? '✓' : '✗' const idCol = row.id.padEnd(idWidth) const kindCol = row.kind.padEnd(kindWidth) console.log(` ${status} ${idCol} ${kindCol} ${row.description}`) if (row.available && row.detail) { console.log(` ${row.detail}`) } else if (!row.available && row.reason) { console.log(` unavailable: ${row.reason}`) } } return } const bridge = (opts.createBridge ?? createBridgeFromParsed)(parsed) const noticeScope = simId || 'default' const consoleSummarySkip = new Set([ 'errors', 'warnings', 'requests', 'js', 'reload', 'globals', 'perf', 'storage-clear', // bridge-level commands — they don't touch a specific sim, so skip the // per-sim console/request probe (which would otherwise claim a lease // and show "call · evaluated page" in the sim overlay). 'list', 'wait', 'sleep', ]) // failed-request dumps used to print the entire response body verbatim. // a backend 500 ships a ~1.5KB `…` error page, repeated // per failed request, which buries the actual step result and makes // flow/maestro output unparseable (QA F20-3). collapse an HTML body to a // one-line summary and hard-cap everything else. const RESPONSE_BODY_MAX = 200 function formatResponseBodyForLog(body: string): string { const flat = body.replace(/\s+/g, ' ').trim() if (!flat) return '' const looksHtml = /^<(!doctype html|html|\?xml)|]/i.test(flat) if (looksHtml) { const title = /]*>([^<]+)<\/title>/i.exec(body)?.[1]?.trim() const firstText = /]*>([\s\S]*?)<\//i .exec(body)?.[1] ?.replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 80) const gist = title || firstText || 'html error page' return ` "${gist}" (body elided — add --json for the full payload)` } if (flat.length <= RESPONSE_BODY_MAX) return flat return `${flat.slice(0, RESPONSE_BODY_MAX)}… (+${flat.length - RESPONSE_BODY_MAX} more bytes)` } function formatRequestMessage(entry: SootSimRequestEntry) { const target = entry.displayUrl || entry.url if (entry.status != null) { return `${entry.method} ${target} -> ${entry.status}${entry.statusText ? ` ${entry.statusText}` : ''}` } if (entry.error) { return `${entry.method} ${target} -> ${entry.error}` } return `${entry.method} ${target}` } // batched post-command probe: one bridge roundtrip for both console + request // counts. used by the default post-command notice loop so non-skip commands // don't pay 2 separate roundtrips. also usable by subcommands (e.g. describe) // that want to fold the probe into their own eval. type SummaryCounts = { console: { errors: number; warnings: number; total: number } | null requests: { failed: number; total: number } | null } // after a write command briefly poll in the sim until layout is stable. // agents get a longer budget (400ms) to catch short transitions; // interactive users get 200ms to stay snappy. logs on timeout so it's // obvious when the next CLI call may see mid-animation state. async function autoSettleAfterWrite(b: WsBridge): Promise { // a cloud sim runs no browser page, so the idle probe's evaluate never // lands there. without this it settled nothing and the next read handed // out coordinates a sheet was still animating away from, which is how a // dialog button tap missed and the scrim under it answered the press. if (b.plane === 'cloud') { try { // a cloud settle drains the whole transition in the sim rather than // polling from here, so it gets a transport deadline, not the browser // path's poll cap: a full screen transition is seconds of cpu render. await b.send({ type: 'settle' }, { timeoutMs: 15_000 }) } catch (error) { // the write itself already landed, so this never fails the command. // it does have to be said out loud: a swallowed settle looks exactly // like a settled sim to the next read, and that read then hands out // coordinates the screen is still animating away from. process.stderr.write( ` ⚠ auto-settle failed (${error instanceof Error ? error.message : String(error)}) — next command may see mid-animation state. use \`rnx do settle\` to retry.\n`, ) } return } // budget is a CAP, not a fixed wait: waitForSootsimIdle drains any // screen transition then returns the instant layout is stable. longer // transitions should use an explicit `do settle` or `wait idle`. const budgetMs = isAgentEnv() ? 400 : 200 try { const { settled, elapsed } = await waitForSootsimIdle({ bridge: b, maxMs: budgetMs, pollMs: 32, stablePolls: 2, }) if (!settled) { process.stderr.write( ` ⚠ auto-wait timed out after ${elapsed ?? budgetMs}ms — next command may see mid-animation state. use \`rnx do settle\` for a longer wait.\n`, ) } } catch { // best-effort — never fail the command because of a post-wait probe. } } function printTapFailure(label: string, outcome: { attempts: number; result: any }) { if (outcome.result?.reason === 'offscreen') { const { x, y, screen } = outcome.result console.error( ` tap failed: ${label} resolved to (${Math.round(x)},${Math.round(y)}), outside the ${screen?.width}x${screen?.height} screen`, ) console.error( ' the node exists but is scrolled out of view — scroll it on-screen first (rnx do scroll / swipe), then tap.', ) return } if (outcome.result?.reason === 'target-covered') { const covering = outcome.result.coordinateTarget const name = typeof covering?.testID === 'string' && covering.testID ? `#${covering.testID}` : covering?.type ? `<${covering.type}>` : 'another node' console.error(` tap failed: ${label} is covered by ${name}`) return } console.error( ` tap failed: ${label} stayed visible but did not receive a hittable press after ${outcome.attempts} attempt${outcome.attempts === 1 ? '' : 's'}`, ) if (outcome.result) { console.error(` last result: ${JSON.stringify(outcome.result)}`) } } async function fetchSummaryCounts(): Promise { try { const raw = await bridge.send({ type: 'evaluate', // console count merges the ws-bridge buffer + observability store (see // MERGED_CONSOLE_COUNT_EVAL) so the proactive notice agrees with what // `get errors` returns for forwarded render-worker failures. code: `(async () => ({ console: ${MERGED_CONSOLE_COUNT_EVAL}, requests: (await window.__sootsimTest?.getRequestCounts?.()) || null, }))()`, }) return (raw as SummaryCounts) || { console: null, requests: null } } catch { return { console: null, requests: null } } } async function printRequestSummary( opts: { includeTail?: boolean; counts?: SummaryCounts['requests'] } = {}, ) { const counts = opts.counts !== undefined ? opts.counts : await callTestBridge<{ failed?: number; total?: number }>( bridge, 'getRequestCounts', ) if (!counts || typeof counts !== 'object') return const failed = Math.max(0, Number(counts.failed) || 0) if (failed === 0) return if ( !opts.includeTail && !shouldPrintInspectNotice('requests', noticeScope, String(failed)) ) { return } console.log(`\n network: ${failed} failed request${failed === 1 ? '' : 's'}`) console.log(` inspect: ${inspectCommand('requests')} 5`) if (!opts.includeTail) return const recentFailed = await callTestBridge( bridge, 'getFailedRequests', 5, ) if (!Array.isArray(recentFailed) || recentFailed.length === 0) return console.log('\n recent failed requests:\n') for (const entry of recentFailed) { const time = formatLogTimestamp(entry.timestamp) console.log(` [${time}] ${formatRequestMessage(entry)}`) if (entry.responseBody) { console.log(` ${formatResponseBodyForLog(entry.responseBody)}`) } else if (entry.error) { console.log(` ${entry.error}`) } } } // proactive note when a blocking native-UI surface (Alert / ActionSheet) was // open at the moment a write command ran. these live in the shell worker, so // `describe` (tenant tree) can't see them — but they swallow every tap/drag/ // type meant for the app underneath, so a `do` that "did nothing" is usually a // modal eating the input. fed from a pre-action capture so the note still // fires when the action itself dismissed the modal (e.g. a tap on a button). function printNativeUISummary(open: OpenNativeUISurface[]) { for (const ui of open) { const title = ui.title ? ` (“${ui.title}”)` : '' console.error( `\n note: ${ui.label} is open${title} — taps/drags hit it, not the app`, ) } } async function printConsoleSummary( opts: { includeTail?: boolean; counts?: SummaryCounts['console'] } = {}, ) { const counts = opts.counts !== undefined ? opts.counts : await bridge.send({ type: 'evaluate', code: MERGED_CONSOLE_COUNT_EVAL, }) if (!counts || typeof counts !== 'object') return const c = counts as { errors?: number; warnings?: number } const errors = Math.max(0, Number(c.errors) || 0) const warnings = Math.max(0, Number(c.warnings) || 0) if (errors === 0 && warnings === 0) return if ( !opts.includeTail && !shouldPrintInspectNotice('console', noticeScope, `${errors}:${warnings}`) ) { return } const parts = [] if (errors > 0) parts.push(`${errors} console error${errors === 1 ? '' : 's'}`) if (warnings > 0) parts.push(`${warnings} console warning${warnings === 1 ? '' : 's'}`) console.error(`\n console: ${parts.join(', ')}`) console.error(` inspect: ${inspectCommand('errors')} 5`) if (warnings > 0) { console.error(` inspect: ${inspectCommand('warnings')} 5`) } if (!opts.includeTail || errors === 0) return // merged tail (ws-bridge buffer + observability) so a render-worker-only // error is shown, not just dropped to "N errors" with an empty tail. const recentErrors = await inspectErrors(bridge, 5) if (!Array.isArray(recentErrors) || recentErrors.length === 0) return console.error('\n recent console errors:\n') for (const entry of recentErrors) { const time = formatLogTimestamp(entry.timestamp) const msg = Array.isArray(entry.args) ? entry.args .map((value: any) => typeof value === 'object' ? JSON.stringify(value) : String(value), ) .join(' ') : String(entry) console.error(` [${time}] ${msg}`) } } // cursor footer — replaces the old "console: N errors" line with a delta // summary of every timeline kind since this CLI identity's previous call. // empty → no output (clean), so commands stay quiet when nothing has // happened. priority-ordered kinds keep the most important signals // (errors, toasts, screens) leftmost in the one-liner. const CURSOR_FOOTER_ORDER: readonly string[] = [ 'console', 'fetch', 'toast', 'alert', 'notification', 'screen', 'app-launch', 'keyboard', 'route', 'actionsheet', 'picker', 'shell', 'scroll', 'gesture', 'text-input', 'animation', 'reanimated', ] async function printCursorFooter(b: WsBridge): Promise { const cliKey = getCliIdentityKey() // single eval roundtrip: summary buckets by kind plus a console // level breakdown (errors vs warnings). agents care about that // distinction enough that the footer should show it directly. one // call to keep the post-command overhead at ~1 bridge hop. type FooterPayload = { summary: { total: number; byKind: Record; lastAt: number | null } consoleSplit: { error: number; warn: number } | null } | null let payload: FooterPayload = null try { payload = await evalInBridge( b, `(() => { const tl = window.SootSim && window.SootSim.bridges && window.SootSim.bridges.timeline if (!tl || typeof tl.summary !== 'function') return null const cursorKey = ${JSON.stringify(cliKey)} const summary = tl.summary({ sinceCursor: cursorKey }) let consoleSplit = null if (summary && summary.byKind && summary.byKind.console) { const events = tl.recent({ sinceCursor: cursorKey, kinds: 'console', limit: 100000 }).events consoleSplit = { error: 0, warn: 0 } for (const ev of events) { const lvl = ev && ev.data && ev.data.level if (lvl === 'error') consoleSplit.error++ else if (lvl === 'warn') consoleSplit.warn++ } } return summary ? { summary, consoleSplit } : null })()`, ) } catch { return } if (!payload || !payload.summary || !payload.summary.total) return const byKind = payload.summary.byKind ?? {} const parts: string[] = [] const seen = new Set() for (const kind of CURSOR_FOOTER_ORDER) { const n = byKind[kind] if (!n) continue seen.add(kind) if (kind === 'console' && payload.consoleSplit) { const { error, warn } = payload.consoleSplit if (error > 0) parts.push(`${error} error${error === 1 ? '' : 's'}`) if (warn > 0) parts.push(`${warn} warning${warn === 1 ? '' : 's'}`) } else { parts.push(`${n} ${kind}${n === 1 ? '' : 's'}`) } } for (const [kind, n] of Object.entries(byKind)) { if (!seen.has(kind) && n) parts.push(`${n} ${kind}${n === 1 ? '' : 's'}`) } if (parts.length === 0) return console.error(`\n since last: ${parts.join(' · ')} — rnx what-happened`) // advance cursor so the next CLI call only sees *new* events. if (payload.summary.lastAt) { try { await callInBridge( b, 'SootSim.bridges.timeline.cursorAdvance', cliKey, payload.summary.lastAt, ) } catch { // best-effort — never fail the command because the cursor // couldn't be advanced. } } } // warn if target sim is hidden — animations and rAF are throttled, // coordinates are wrong, launch transitions never complete. // only check for commands that interact with the canvas. const writeCommands = new Set([ 'tap', 'double-tap', 'tap-text', 'tap-id', 'type', 'type-into', 'key', 'key-sequence', 'keycode', 'drag', 'swipe', 'long-press', 'touch', 'gesture', 'pinch', 'scroll', 'shell', 'storage-clear', ]) const screenTransitionAwareCommands = new Set([ 'a11y', 'capture', 'count', 'double-tap', 'drag', 'find', 'gesture', 'layout', 'long-press', 'node', 'pinch', 'sample-color', 'scroll', 'screenshot', 'swipe', 'tap', 'tap-id', 'tap-text', 'touch', 'tree', 'type-into', ]) const verboseTransitions = (args.includes('--verbose') || args.includes('-v')) && !args.includes('--json') if (invocationPrefix === 'do' && subcommand === 'shell') { console.error(' `rnx do shell` was removed. use `rnx shell ...` instead.') rnxExit(1) } if (writeCommands.has(subcommand)) { await checkSimHealth(bridge) } if (screenTransitionAwareCommands.has(subcommand)) { await maybeWaitForStartedScreenTransitions(bridge, { verbose: verboseTransitions, }) } try { // capture any blocking native-UI modal BEFORE running a write command, so // the trailing note reflects the state when the action was attempted even // if the action itself dismissed the modal (e.g. a tap that hit a button). const nativeUIBeforeWrite = writeCommands.has(subcommand) ? await detectOpenNativeUI(bridge) : [] switch (subcommand) { case 'list': { // name the bridge world so dev-bridge vs daemon is never ambiguous // (the two can coexist; `list` used to silently flap between them). printBridgeWorldNotice(parsed.wsPort) await runListSubcommand({ bridge, simId, args: effectiveArgs }) break } case 'tree': { await runTreeSubcommand({ bridge, args: effectiveArgs, positional }) break } case 'a11y': { const nodes = await inspectAccessibilityTree(bridge) if (!Array.isArray(nodes) || nodes.length === 0) { console.log(' no accessible nodes found') break } if (args.includes('--json')) { console.log(JSON.stringify(nodes, null, 2)) } else { console.log(` accessibility tree (${nodes.length} nodes):\n`) for (const n of nodes) { const parts: string[] = [] parts.push(`[${n.role}]`) if (n.label) { const label = n.label.length > 50 ? n.label.slice(0, 47) + '...' : n.label parts.push(`"${label}"`) } if (n.hint) parts.push(`(hint: "${n.hint}")`) if (n.testID) parts.push(`#${n.testID}`) if (n.state) { const flags: string[] = [] if (n.state.disabled) flags.push('disabled') if (n.state.selected) flags.push('selected') if (n.state.checked === true) flags.push('checked') if (n.state.checked === 'mixed') flags.push('mixed') if (n.state.busy) flags.push('busy') if (n.state.expanded === true) flags.push('expanded') if (n.state.expanded === false) flags.push('collapsed') if (flags.length) parts.push(`{${flags.join(', ')}}`) } if (n.position) parts.push(`@(${n.position.x},${n.position.y})`) if (n.size) parts.push(`${n.size.w}x${n.size.h}`) console.log(' ' + parts.join(' ')) } } break } case 'find': { await runFindSubcommand({ bridge, args, effectiveArgs, positional, inspectUsage }) break } case 'count': { await runCountSubcommand(bridge, { args: effectiveArgs }) break } case 'keyboard': { await runKeyboardSubcommand(bridge, { json: args.includes('--json'), }) break } case 'screens': { await runScreensSubcommand(bridge, { json: args.includes('--json'), }) break } case 'memory': { await runMemorySubcommand(bridge, { args: effectiveArgs }) break } case 'wait': { await runWaitSubcommand({ wsPort, commandTimeoutMs, simId, simIdSource, positional, }) break } case 'sleep': { await runSleepSubcommand({ positional, inspectUsage }) break } case 'settle': { await runSettleSubcommand({ bridge, args, positional }) break } case 'ready': { await runWaitReadySubcommand({ bridge, args }) break } case 'idle': { await runWaitIdleSubcommand({ bridge, args, positional }) break } case 'selector': { await runWaitSelectorSubcommand({ bridge, args, positional, inspectUsage }) break } case 'event': { // rnx wait event — timeline-backed wait. delegates // matcher + polling to runWaitEventSubcommand so the same // logic is reachable from `rnx wait event` regardless of // verb-group prefix (and the same shape is testable on its // own). await runWaitEventSubcommand({ bridge, args, positional, inspectUsage }) break } case 'layout': { const id = positional[1] // bare `rnx get layout` (no id) measures visible elements on the // screen by bounding box. --styling opts into box-model/style detail via // the shared get-layout kernel the agent's app_get_layout also uses. // passing an id keeps the single-node {x, y, width, height} lookup. if (!id) { await runGetLayoutSubcommand({ bridge, args: effectiveArgs }) break } const found = await inspectFind(bridge, { testId: id }) const node = found?.result if (node === null || typeof node !== 'object' || Array.isArray(node)) { console.log('null') break } const absolute = Reflect.get(node, 'absolutePosition') const size = Reflect.get(node, 'layout') console.log( JSON.stringify( absolute && size ? { x: Reflect.get(absolute, 'x'), y: Reflect.get(absolute, 'y'), width: Reflect.get(size, 'width'), height: Reflect.get(size, 'height'), } : null, null, 2, ), ) break } case 'capture': case 'screenshot': { const outputArg = args.find((_, i) => args[i - 1] === '--output') const outputPath = outputArg || '/tmp/rnx-inspect.png' const rect = await resolveSampleRect(args, bridge) const request: { type: 'screenshot'; crop?: SampleRect } = { type: 'screenshot' } if (rect) request.crop = rect const dataUrl: string = await bridge.send(request) const base64 = dataUrl.replace(/^data:image\/png;base64,/, '') if (rect) { console.log(` area: x=${rect.x} y=${rect.y} w=${rect.w} h=${rect.h}`) } const fs = await import('fs') fs.writeFileSync(outputPath, Buffer.from(base64, 'base64')) console.log(` saved: ${outputPath}`) break } case 'sample-color': { const rect = await resolveSampleRect(args, bridge) if (!rect) { console.error( inspectUsage( 'sample-color', ' [w] [h] | --id | --text ', ), ) console.error( ' samples an averaged color from the canvas. coords are logical rnx units.', ) rnxExit(1) } const result = await bridge.send({ type: 'evaluate', code: buildSampleColorEval(rect), }) if (args.includes('--json')) { console.log(JSON.stringify(result, null, 2)) } else { const { r, g, b, a, hex, samples } = result as { r: number g: number b: number a: number hex: string samples: number } const rectLabel = rect.w === 1 && rect.h === 1 ? `@(${rect.x},${rect.y})` : `@(${rect.x},${rect.y}) ${rect.w}x${rect.h}` console.log( ` ${hex} rgba(${r}, ${g}, ${b}, ${a}) ${rectLabel} ${samples} samples`, ) } break } case 'node': { const matcher = positional[1] if (!matcher) { console.error(inspectUsage('node', '')) console.error(' resolves testID, id, then text — dumps full node info as JSON') rnxExit(1) } if (bridge.plane === 'cloud') { const byTestId = await inspectFind(bridge, { testId: matcher }) const byText = byTestId?.result ? null : await inspectFind(bridge, { text: matcher }) const node = byTestId?.result ?? byText?.result ?? null console.log( JSON.stringify( node ? { matcher, found: true, resolvedVia: byTestId?.result ? 'testID' : 'text', node, } : { matcher, found: false }, null, 2, ), ) break } // resolve in order: testID → id → text. first hit wins, includes the // resolved transform and a parent chain so agents can skip the usual // __sootsimRoot walk. const result = await bridge.send({ type: 'evaluate', code: `(async () => { const t = window.__sootsimTest const q = ${JSON.stringify(matcher)} let node = null let via = null if (t.findByTestId) { node = await t.findByTestId(q); if (node) via = 'testID' } if (!node && t.findById) { node = await t.findById(q); if (node) via = 'id' } if (!node && t.findByText) { node = await t.findByText(q); if (node) via = 'text' } if (!node) return { matcher: q, found: false } // read the resolved transform (if any) off the style — useful // because canvas nodes often animate via transform and describe // output strips that. const transform = node.style && Array.isArray(node.style.transform) ? node.style.transform : node.style && node.style.transform ? node.style.transform : null // parent chain — walk up from the node so the JSON dump is // self-describing. const parentChain = [] const root = window.__sootsimRoot if (root && node.id != null) { const findPath = (n, targetId, path) => { if (!n) return null if (n.id === targetId) return path if (n.children) { for (const child of n.children) { const nextPath = [ ...path, { type: n.type || 'view', testID: n.props?.testID || null, text: n.text || null, }, ] const found = findPath(child, targetId, nextPath) if (found) return found } } return null } const chain = findPath(root, node.id, []) if (chain) parentChain.push(...chain) } return { matcher: q, found: true, resolvedVia: via, node, transform, parentChain, } })()`, }) console.log(JSON.stringify(result, null, 2)) break } case 'tap': { let x = Number(positional[1]) let y = Number(positional[2]) const target = readTargetFlag(args, positional) if (target) { const outcome = await tapResolvedTarget(bridge, { agent: isAgentEnv(), textFallback: target.mode === 'text' ? target.value : undefined, resolve: async () => { const resolved = await resolveTargetCoords(bridge, target) if (!resolved) return null return { cx: resolved.x, cy: resolved.y, match: { nodeId: resolved.nodeId, testID: target.mode === 'testid' ? target.value : (resolved.testID ?? null), text: target.mode === 'text' ? target.value : (resolved.text ?? null), type: resolved.type ?? null, }, target: { nodeId: resolved.nodeId, testID: resolved.testID ?? null, text: resolved.text ?? null, type: resolved.type ?? null, }, } }, }) const payload = outcome.payload if (!payload || typeof payload.cx !== 'number') { console.error(` not found: ${target.value}`) if (target.mode === 'testid') { maybeHint('wait-selector-for-missing-testid', target.value) } rnxExit(1) } if (!isTapSuccess(outcome.result)) { printTapFailure(`${target.mode} "${target.value}"`, outcome) rnxExit(1) } const candidate = buildTapCandidateFromResult( payload.cx, payload.cy, outcome.result, ) if (candidate) { await recordInspectAction('inspect tap', candidate.step, candidate.summary) } console.log( JSON.stringify( { ...(outcome.attempts > 1 ? { attempts: outcome.attempts } : {}), ...outcome.result, }, null, 2, ), ) break } if (!Number.isFinite(x) || !Number.isFinite(y)) { console.error( inspectUsage('tap', ' | | --testid | --text '), ) rnxExit(1) } const result = await tapCoordinates(bridge, x, y) const candidate = buildTapCandidateFromResult(x, y, result) if (candidate) { await recordInspectAction('inspect tap', candidate.step, candidate.summary) } console.log(JSON.stringify(result, null, 2)) break } case 'drag': case 'swipe': { const fromX = Number(positional[1]) const fromY = Number(positional[2]) const toX = Number(positional[3]) const toY = Number(positional[4]) const defaultSteps = subcommand === 'swipe' ? 10 : 12 const defaultStepMs = subcommand === 'swipe' ? 8 : 16 const steps = positional[5] ? Number(positional[5]) : defaultSteps const stepMs = positional[6] ? Number(positional[6]) : defaultStepMs if ( !Number.isFinite(fromX) || !Number.isFinite(fromY) || !Number.isFinite(toX) || !Number.isFinite(toY) || !Number.isFinite(steps) || !Number.isFinite(stepMs) ) { console.error(inspectUsage(subcommand, ' [steps] [stepMs]')) rnxExit(1) } const result = await performSingleStep(bridge, { type: 'drag', fromX, fromY, toX, toY, steps: Math.max(1, Math.round(steps)), stepMs: Math.max(0, Math.round(stepMs)), }) if (result?.ok) { const duration = Math.max( 1, Math.round(Math.max(1, steps) * Math.max(0, stepMs)), ) await recordInspectAction( `inspect ${subcommand}`, { swipe: { start: `${fromX}, ${fromY}`, end: `${toX}, ${toY}`, duration, }, }, `${subcommand} ${fromX},${fromY} -> ${toX},${toY}`, ) } console.log(JSON.stringify(result, null, 2)) break } case 'pinch': { const fromX1 = Number(positional[1]) const fromY1 = Number(positional[2]) const fromX2 = Number(positional[3]) const fromY2 = Number(positional[4]) const toX1 = Number(positional[5]) const toY1 = Number(positional[6]) const toX2 = Number(positional[7]) const toY2 = Number(positional[8]) const steps = positional[9] ? Number(positional[9]) : 12 const stepMs = positional[10] ? Number(positional[10]) : 16 if ( !Number.isFinite(fromX1) || !Number.isFinite(fromY1) || !Number.isFinite(fromX2) || !Number.isFinite(fromY2) || !Number.isFinite(toX1) || !Number.isFinite(toY1) || !Number.isFinite(toX2) || !Number.isFinite(toY2) || !Number.isFinite(steps) || !Number.isFinite(stepMs) ) { console.error( inspectUsage( 'pinch', " [steps] [stepMs]", ), ) rnxExit(1) } const result = await performSingleStep(bridge, { type: 'pinch', fromX1, fromY1, fromX2, fromY2, toX1, toY1, toX2, toY2, steps: Math.max(1, Math.round(steps)), stepMs: Math.max(0, Math.round(stepMs)), }) if (result?.ok) { await recordInspectAction( 'inspect pinch', { pinch: { from: [fromX1, fromY1, fromX2, fromY2], to: [toX1, toY1, toX2, toY2], steps: Math.max(1, Math.round(steps)), stepMs: Math.max(0, Math.round(stepMs)), }, }, `pinch (${fromX1},${fromY1}) (${fromX2},${fromY2}) -> (${toX1},${toY1}) (${toX2},${toY2})`, ) } console.log(JSON.stringify(result, null, 2)) break } case 'tap-text': { const query = positional[1] if (!query) { console.error(inspectUsage('tap-text', '')) rnxExit(1) } const findFlagValue = (name: string): string | null => { const i = args.indexOf(name) return i >= 0 && i + 1 < args.length ? args[i + 1] : null } const hasBoolFlag = (name: string): boolean => args.includes(name) const nthArg = findFlagValue('--nth') ?? findFlagValue('--index') const nthIndex = nthArg !== null ? Number(nthArg) : null if (nthIndex !== null && !Number.isFinite(nthIndex)) { console.error(` --nth/--index requires an integer, got: ${nthArg}`) rnxExit(1) } const within = findFlagValue('--within') const roleFlag = findFlagValue('--role') const exact = hasBoolFlag('--exact') const first = hasBoolFlag('--first') const minYArg = findFlagValue('--min-y') const maxYArg = findFlagValue('--max-y') const minXArg = findFlagValue('--min-x') const maxXArg = findFlagValue('--max-x') for (const [flag, val] of [ ['--min-y', minYArg], ['--max-y', maxYArg], ['--min-x', minXArg], ['--max-x', maxXArg], ] as const) { if (val !== null && !Number.isFinite(Number(val))) { console.error(` ${flag} requires a number, got: ${val}`) rnxExit(1) } } const nearIdx = args.indexOf('--near') let near: { x: number; y: number } | null = null if (nearIdx >= 0) { const nx = Number(args[nearIdx + 1]) const ny = Number(args[nearIdx + 2]) if (!Number.isFinite(nx) || !Number.isFinite(ny)) { console.error(' --near requires two numbers: --near ') rnxExit(1) } near = { x: nx, y: ny } } const textOptions: TapTextOptions = { exact, role: roleFlag, within, minX: minXArg !== null ? Number(minXArg) : null, maxX: maxXArg !== null ? Number(maxXArg) : null, minY: minYArg !== null ? Number(minYArg) : null, maxY: maxYArg !== null ? Number(maxYArg) : null, near, nth: nthIndex, first, } const outcome = await tapByText(bridge, query, textOptions, { agent: isAgentEnv(), }) const payload = outcome.payload if (payload?.error === 'bridge-not-ready') { console.error(' rnx test bridge not ready') rnxExit(1) } if (payload?.ambiguous) { const candidatesOut = payload.candidates as Array<{ idx: number nodeId: number | null type: string | null testID: string | null text: string abs: { x: number; y: number } | null layout: { width: number; height: number } | null ancestorTestIDs: string[] }> console.error(` ambiguous: ${payload.total} matches for "${query}"`) for (const c of candidatesOut) { const loc = c.abs ? `@(${Math.round(c.abs.x)},${Math.round(c.abs.y)})` : '' const size = c.layout ? ` ${c.layout.width}x${c.layout.height}` : '' const tid = c.testID ? ` #${c.testID}` : '' const text = c.text ? ` "${c.text}"` : '' const ancestors = c.ancestorTestIDs.length > 0 ? ` within ${c.ancestorTestIDs .slice(0, 3) .map((id) => `#${id}`) .join(' > ')}` : '' console.error( ` [${c.idx}] <${c.type}>${text}${tid} ${loc}${size}${ancestors}`, ) } if (payload.total > candidatesOut.length) { console.error(` ... and ${payload.total - candidatesOut.length} more`) } console.error(' pick one:') console.error( ' --nth pick the nth match (top-to-bottom, left-to-right; negatives from end)', ) console.error(' --within narrow to descendants of a node') console.error(' --min-y / --max-y geometric filter (pixels, absolute)') console.error(' --min-x / --max-x geometric filter (pixels, absolute)') console.error(' --near pick the closest match to a point') console.error( ' --exact exact text match (default is substring)', ) console.error(' --role narrow to accessibilityRole') console.error( ' --first keep the old pick-first-silently behavior', ) rnxExit(2) } if (payload?.nthOutOfRange) { console.error( ` not found: nth ${payload.nth} of ${payload.total} match${payload.total === 1 ? '' : 'es'} for "${query}"`, ) rnxExit(1) } if (!payload || typeof payload.cx !== 'number') { console.error(` not found: ${query}`) rnxExit(1) } if (!isTapSuccess(outcome.result)) { printTapFailure(`text "${query}"`, outcome) rnxExit(1) } const candidate = buildTapCandidateFromNode( query, { id: payload.target?.id ?? null, testID: payload.target?.testID ?? null, type: payload.target?.type ?? null, cx: payload.cx, cy: payload.cy, }, 'text', ) await recordInspectAction('inspect tap-text', candidate.step, candidate.summary) console.log( JSON.stringify( { matched: payload.match, tapped: { nodeId: payload.target?.nodeId ?? null, id: payload.target?.id ?? null, testID: payload.target?.testID ?? null, type: payload.target?.type ?? null, cx: payload.cx, cy: payload.cy, }, ...(payload.strategy && payload.strategy !== 'matched-node' ? { strategy: payload.strategy } : {}), // expose the disambiguation outcome so agents can confirm which // of the N matches was tapped, or notice that only one exists. ...(payload.total > 1 || nthIndex !== null ? { nth: { index: payload.idx, total: payload.total } } : {}), ...(outcome.attempts > 1 ? { attempts: outcome.attempts } : {}), result: outcome.result, }, null, 2, ), ) break } case 'tap-best': { // rnx do tap-best — try tap-id, then tap-text. the // common agent ergonomic problem: the agent has a label like // "Create expense" and doesn't know whether that's a testID // or visible text. with tap-text it fails when the only // match is a testID; with tap-id it fails when the only // match is visible text. tap-best collapses both into one // command and prints which strategy hit. // // tap-best does NOT subsume tap-text's disambiguation flags // (--nth, --within, --near, --min-y) — those are still on // tap-text directly. tap-best is the "I don't care which // strategy works as long as one does" flavour. const query = positional[1] if (!query) { console.error(inspectUsage('tap-best', '')) rnxExit(1) } const outcome = await tapBest(bridge, query, { agent: isAgentEnv() }) const payload = outcome.payload as | { strategy: 'testid' | 'text' node: { nodeId: number | null id: string | null testID: string | null type: string | null text: string | null } cx: number cy: number } | { strategy: 'none' } | { error: string } | null if (!payload) { console.error( ` tap-best: no testID or visible text matched "${query}". try \`rnx find --interactive-targets\` to list candidates.`, ) rnxExit(1) } if ('error' in payload) { console.error(` ${payload.error}`) rnxExit(1) } if (payload.strategy === 'none') { console.error( ` tap-best: no testID or visible text matched "${query}". try \`rnx find --interactive-targets\` to list candidates.`, ) rnxExit(1) } const node = payload.node if (!isTapSuccess(outcome.result)) { printTapFailure(`best "${query}"`, outcome) rnxExit(1) } const candidate = buildTapCandidateFromNode( query, { id: node.id, testID: node.testID, type: node.type, cx: payload.cx, cy: payload.cy, }, payload.strategy === 'testid' ? 'id' : 'text', ) await recordInspectAction('inspect tap-best', candidate.step, candidate.summary) console.log( JSON.stringify( { matched: { strategy: payload.strategy, nodeId: node.nodeId, id: node.id, testID: node.testID, type: node.type, text: node.text, }, tapped: { cx: payload.cx, cy: payload.cy }, ...(outcome.attempts > 1 ? { attempts: outcome.attempts } : {}), result: outcome.result, }, null, 2, ), ) break } case 'tap-id': { const query = positional[1] if (!query) { console.error(inspectUsage('tap-id', '')) rnxExit(1) } const outcome = await tapById(bridge, query, { agent: isAgentEnv() }) const payload = outcome.payload if (!payload || typeof payload.cx !== 'number') { console.error(` not found: ${query}`) // when the testID didn't match, fall back to a fuzzy list of // nearby testIDs in the live tree so the agent can pick the // intended one without dumping the whole tree first. await printSimilarTestIds(bridge, query) rnxExit(1) } if (!isTapSuccess(outcome.result)) { printTapFailure(`id "${query}"`, outcome) rnxExit(1) } const candidate = buildTapCandidateFromNode( query, { id: payload.target?.id ?? null, testID: payload.target?.testID ?? null, type: payload.target?.type ?? null, cx: payload.cx, cy: payload.cy, }, 'id', ) await recordInspectAction('inspect tap-id', candidate.step, candidate.summary) console.log( JSON.stringify( { matched: payload.match, tapped: { nodeId: payload.target?.nodeId ?? null, id: payload.target?.id ?? null, testID: payload.target?.testID ?? null, type: payload.target?.type ?? null, cx: payload.cx, cy: payload.cy, }, ...(payload.strategy && payload.strategy !== 'matched-node' ? { strategy: payload.strategy } : {}), ...(outcome.attempts > 1 ? { attempts: outcome.attempts } : {}), result: outcome.result, }, null, 2, ), ) break } case 'type-into': { const targetId = positional[1] const text = positional.slice(2).join(' ') if (!targetId || !text) { console.error(inspectUsage('type-into', ' ')) rnxExit(1) } if (bridge.plane === 'cloud') { const outcome = await tapById(bridge, targetId, { agent: isAgentEnv() }) if (!outcome.payload || typeof outcome.payload.cx !== 'number') { console.error(` not found: ${targetId}`) await printSimilarTestIds(bridge, targetId) rnxExit(1) } if (!isTapSuccess(outcome.result)) { printTapFailure(`id "${targetId}"`, outcome) rnxExit(1) } await performCloudKeys(bridge, [{ type: 'type', text }], 'type-into') await recordInspectAction( 'inspect type-into', { tapOn: { id: targetId }, inputText: text }, `type-into #${targetId} ${JSON.stringify(text)}`, ) console.log(` typed into ${targetId}: ${JSON.stringify(text)}`) break } // step 1: find the element const tiArg = JSON.stringify(targetId) const tiPayload = await bridge.send({ type: 'evaluate', code: `(async () => { const t = window.__sootsimTest if (!t) return null const n = await (t.findByTestId(${tiArg}) || t.findById(${tiArg})) if (!n || !n.absolutePosition || !n.layout) return null return { cx: n.absolutePosition.x + (n.layout.width || 0) / 2, cy: n.absolutePosition.y + (n.layout.height || 0) / 2, id: n.id, testID: n.testID, type: n.type, isTextInput: !!n.isTextInput, secureTextEntry: !!n.secureTextEntry, placeholder: n.placeholder || null, } })()`, }) if (!tiPayload || typeof tiPayload.cx !== 'number') { console.error(` not found: ${targetId}`) rnxExit(1) } if (!tiPayload.isTextInput) { console.error(` warning: ${targetId} is not a text input (isTextInput: false)`) } // step 2: tap to focus const tapResult = await bridge.send({ type: 'tap', x: tiPayload.cx, y: tiPayload.cy, target: { id: tiPayload.id ?? targetId, testID: tiPayload.testID ?? targetId, text: null, type: tiPayload.type ?? null, }, }) const targetSecureTextEntry = tiPayload.secureTextEntry === true const keyboardState = await waitForKeyboardVisible(bridge, 1000, { targetId, secureTextEntry: targetSecureTextEntry, }) if (!keyboardState.visible) { console.error(` keyboard did not open after tapping ${targetId}`) rnxExit(1) } // step 2.5: confirm the tap actually focused the requested input. // the keyboard being visible is necessary but not sufficient — a // previous input might still hold focus if the tap got routed to a // Pressable wrapper ancestor (see the 2026-04-17 bluesky regression). // reading getKeyboardState → __sootsimTest.getFocusedNode() fails // fast on that mismatch instead of typing into the wrong input. const focusCheck = keyboardState.focusedInput as | { testID?: string | null; id?: string | null } | null | undefined if (focusCheck) { const matched = focusCheck.testID === targetId || focusCheck.id === targetId if (!matched) { console.error( ` focus routing mismatch after tap: requested ${JSON.stringify(targetId)} but focus is on ${JSON.stringify( focusCheck.testID ?? focusCheck.id ?? null, )}. did the tap land on an outer Pressable wrapper?`, ) rnxExit(1) } } // step 3: clear existing text then type through the visible keyboard const existingText = typeof keyboardState.focusedInput?.text === 'string' ? keyboardState.focusedInput.text : '' for (let i = 0; i < existingText.length; i++) { await bridge.send({ type: 'keyboard', action: 'press', text: 'delete' }) } await bridge.send({ type: 'keyboard', action: 'type', text }) const secureTextEntry = targetSecureTextEntry || isSecureKeyboardState(keyboardState as any) const outputText = secureTextEntry ? SECURE_TEXT_REDACTION : redactTextForKeyboardState(text, keyboardState as any) const focusedInput = secureTextEntry && keyboardState.focusedInput ? { ...keyboardState.focusedInput, secureTextEntry: true, text: typeof keyboardState.focusedInput.text === 'string' ? SECURE_TEXT_REDACTION : keyboardState.focusedInput.text, } : (keyboardState.focusedInput ?? null) await recordInspectAction( 'inspect type-into', { tapOn: { id: targetId }, inputText: outputText }, outputText === SECURE_TEXT_REDACTION ? `type-into #${targetId} ${SECURE_TEXT_REDACTION}` : `type-into #${targetId} ${JSON.stringify(text)}`, ) console.log( JSON.stringify( { target: targetId, isTextInput: tiPayload.isTextInput, secureTextEntry, keyboardOpened: keyboardState.visible ?? tapResult?.keyboardOpened ?? false, focusedInput, typed: outputText, }, null, 2, ), ) break } case 'type': { const text = positional.slice(1).join(' ') if (!text) { console.error(inspectUsage('type', '')) rnxExit(1) } if (bridge.plane === 'cloud') { await performCloudKeys(bridge, [{ type: 'type', text }], 'type') await recordInspectAction( 'inspect type', { inputText: text }, `type ${JSON.stringify(text)}`, ) console.log(` typed: ${JSON.stringify(text)}`) break } const keyboardState = await requireVisualKeyboard(bridge, 'type') await bridge.send({ type: 'keyboard', action: 'type', text }) const outputText = redactTextForKeyboardState(text, keyboardState as any) await recordInspectAction( 'inspect type', { inputText: outputText }, outputText === SECURE_TEXT_REDACTION ? `type ${SECURE_TEXT_REDACTION}` : `type ${JSON.stringify(text)}`, ) console.log( outputText === SECURE_TEXT_REDACTION ? ` typed: ${SECURE_TEXT_REDACTION}` : ` typed: ${JSON.stringify(text)}`, ) break } case 'key': { const name = positional[1] if (!name) { console.error(inspectUsage('key', '')) rnxExit(1) } if (bridge.plane === 'cloud') { await performCloudKeys(bridge, [{ type: 'key', key: name }], 'key') } else { await requireVisualKeyboard(bridge, 'key') await bridge.send({ type: 'keyboard', action: 'press', text: name }) } await recordInspectAction('inspect key', { pressKey: name }, `key ${name}`) console.log(` pressed: ${name}`) break } case 'key-sequence': { const keys = positional.slice(1) if (keys.length === 0) { console.error(inspectUsage('key-sequence', ' [ ...]')) rnxExit(1) } if (bridge.plane === 'cloud') { await performCloudKeys( bridge, keys.map((key) => ({ type: 'key' as const, key })), 'key-sequence', ) } else { await requireVisualKeyboard(bridge, 'key-sequence') for (const key of keys) { await bridge.send({ type: 'keyboard', action: 'press', text: key }) } } await recordInspectAction( 'inspect key-sequence', { pressKey: keys.join(' ') }, `key-sequence ${keys.join(' ')}`, ) console.log(` pressed: ${keys.join(', ')}`) break } case 'keycode': { const codes = positional.slice(1) if (codes.length === 0) { console.error(inspectUsage('keycode', ' [ ...]')) rnxExit(1) } const mapped = codes.map((code) => ({ code, key: keyCodeToVisualKey(code) })) const unsupported = mapped.filter((entry) => !entry.key) const keys = mapped.filter( (entry): entry is { code: string; key: string } => typeof entry.key === 'string', ) if (unsupported.length > 0) { console.error( ` unsupported keycode(s): ${unsupported.map((entry) => entry.code).join(', ')}`, ) rnxExit(1) } await requireVisualKeyboard(bridge, 'keycode') for (const entry of keys) { await bridge.send({ type: 'keyboard', action: 'press', text: entry.key }) } await recordInspectAction( 'inspect keycode', { pressKey: keys.map((entry) => entry.key).join(' ') }, `keycode ${codes.join(' ')}`, ) console.log(` pressed: ${codes.join(', ')}`) break } case 'dispatch': { const ch = positional[1] if (!ch) { console.error(inspectUsage('dispatch', '')) rnxExit(1) } await bridge.send({ type: 'keyboard', action: 'dispatchKey', text: ch }) await recordInspectAction( 'inspect dispatch', { dispatchKey: ch }, `dispatch ${JSON.stringify(ch)}`, ) console.log(` dispatched: ${ch}`) break } case 'dismiss': { await bridge.send({ type: 'keyboard', action: 'dismiss' }) await recordInspectAction( 'inspect dismiss', { hideKeyboard: true }, 'dismiss keyboard', ) console.log(' keyboard dismissed') break } case 'double-tap': { let x = Number(positional[1]) let y = Number(positional[2]) const target = readTargetFlag(args, positional) if (target) { const resolved = await resolveTargetCoords(bridge, target) if (!resolved) { console.error(` not found: ${target.value}`) if (target.mode === 'testid') { maybeHint('wait-selector-for-missing-testid', target.value) } rnxExit(1) } x = resolved.x y = resolved.y } const gapMs = positional[3] ? Number(positional[3]) : 80 if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(gapMs)) { console.error( inspectUsage('double-tap', ' | [gapMs] | --testid '), ) rnxExit(1) } const waitMs = Math.max(0, Math.round(gapMs)) const single = await performSingleStep(bridge, { type: 'doubleTap', x, y, gapMs: waitMs, }) const result = { ...single, gapMs: waitMs } if (result?.ok) { await recordInspectAction( 'inspect double-tap', { doubleTapAtCoords: { x, y, gapMs: waitMs } }, `double-tap @${x},${y}`, ) } console.log(JSON.stringify(result, null, 2)) break } case 'long-press': { let x = Number(positional[1]) let y = Number(positional[2]) const target = readTargetFlag(args, positional) let resolvedTarget: SootSimReplayTarget | undefined if (target) { const resolved = await resolveTargetCoords(bridge, target) if (!resolved) { console.error(` not found: ${target.value}`) if (target.mode === 'testid') { maybeHint('wait-selector-for-missing-testid', target.value) } rnxExit(1) } x = resolved.x y = resolved.y resolvedTarget = { testID: resolved.testID ?? null, text: resolved.text ?? null, type: resolved.type ?? null, } } const durationArg = target ? positional[1] : positional[3] const durationMs = durationArg ? Number(durationArg) : 600 if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(durationMs)) { console.error( inspectUsage('long-press', ' | [durationMs] | --testid '), ) rnxExit(1) } const roundedDurationMs = Math.max(0, Math.round(durationMs)) const result = await bridge.send({ type: 'longPress', x, y, durationMs: roundedDurationMs, target: resolvedTarget, }) if (result?.ok) { await recordInspectAction( 'inspect long-press', { tapAtCoords: { x, y } }, `long-press @${x},${y}`, ) } console.log(JSON.stringify(result, null, 2)) break } case 'touch': { const phase = positional[1] const x = Number(positional[2]) const y = Number(positional[3]) const pointerId = positional[4] ? Number(positional[4]) : 999 const method = phase === 'down' ? 'touchDown' : phase === 'move' ? 'touchMove' : phase === 'up' ? 'touchUp' : phase === 'cancel' ? 'touchCancel' : null if (!method) { console.error( inspectUsage('touch', ' [pointerId]'), ) rnxExit(1) } if (phase !== 'cancel' && (!Number.isFinite(x) || !Number.isFinite(y))) { console.error( inspectUsage('touch', ' [pointerId]'), ) rnxExit(1) } // the engine's perform executor emits the matching agent-cursor // action (tap for down, move for move) natively. const roundedPointerId = Math.max(1, Math.round(pointerId)) const result = await performSingleStep( bridge, method === 'touchCancel' ? { type: method, pointerId: roundedPointerId } : { type: method, x, y, pointerId: roundedPointerId }, ) if (result?.ok && phase !== 'cancel') { await recordInspectAction( 'inspect touch', { tapAtCoords: { x, y } }, `touch ${phase} @${x},${y}`, ) } console.log(JSON.stringify(result, null, 2)) break } case 'gesture': { const GESTURE_PRESETS = [ 'scroll-up', 'scroll-down', 'scroll-left', 'scroll-right', 'swipe-from-left-edge', 'swipe-from-right-edge', 'swipe-from-top-edge', 'swipe-from-bottom-edge', ] as const const preset = positional[1] const durationMs = positional[2] ? Number(positional[2]) : 220 if (!preset || !Number.isFinite(durationMs)) { console.error(inspectUsage('gesture', ' [durationMs]')) console.error(` presets: ${GESTURE_PRESETS.join(', ')}`) rnxExit(1) } if (!(GESTURE_PRESETS as readonly string[]).includes(preset)) { console.error(` unknown gesture preset: ${preset}`) console.error(` presets: ${GESTURE_PRESETS.join(', ')}`) rnxExit(1) } const frame = await bridge.send({ type: 'evaluate', code: `(async () => { const spec = globalThis.__sootsimDeviceSpec || {} return { width: spec.width || window.innerWidth || 393, height: spec.height || window.innerHeight || 852, statusBarHeight: spec.statusBarHeight || 0, homeIndicatorHeight: spec.homeIndicatorHeight || 0, } })()`, }) const width = Number(frame?.width) || 393 const height = Number(frame?.height) || 852 const statusBarHeight = Number(frame?.statusBarHeight) || 0 const homeIndicatorHeight = Number(frame?.homeIndicatorHeight) || 0 const centerX = Math.round(width / 2) const centerY = Math.round(height / 2) const topInset = Math.max(24, statusBarHeight + 18) const bottomInset = Math.max(24, homeIndicatorHeight + 18) const edgeInset = 18 const travelY = Math.min(220, Math.round(height * 0.24)) const travelX = Math.min(180, Math.round(width * 0.32)) let fromX = centerX let fromY = centerY let toX = centerX let toY = centerY switch (preset) { case 'scroll-up': fromY = centerY + Math.round(travelY / 2) toY = centerY - Math.round(travelY / 2) break case 'scroll-down': fromY = centerY - Math.round(travelY / 2) toY = centerY + Math.round(travelY / 2) break case 'scroll-left': fromX = centerX + Math.round(travelX / 2) toX = centerX - Math.round(travelX / 2) break case 'scroll-right': fromX = centerX - Math.round(travelX / 2) toX = centerX + Math.round(travelX / 2) break case 'swipe-from-left-edge': fromX = edgeInset fromY = centerY toX = Math.min(width - edgeInset, edgeInset + travelX) break case 'swipe-from-right-edge': fromX = width - edgeInset fromY = centerY toX = Math.max(edgeInset, width - edgeInset - travelX) break case 'swipe-from-top-edge': fromX = centerX fromY = topInset toY = Math.min(height - bottomInset, topInset + travelY) break case 'swipe-from-bottom-edge': fromX = centerX fromY = height - bottomInset toY = Math.max(topInset, height - bottomInset - travelY) break // no default — `preset` is validated against GESTURE_PRESETS above. } const steps = Math.max(8, Math.round(durationMs / 16)) const stepMs = Math.max(1, Math.round(durationMs / steps)) const result = await performSingleStep(bridge, { type: 'drag', fromX, fromY, toX, toY, steps, stepMs, }) if (result?.ok) { await recordInspectAction( 'inspect gesture', { swipe: { start: `${fromX}, ${fromY}`, end: `${toX}, ${toY}`, duration: Math.max(1, Math.round(durationMs)), }, }, `gesture ${preset}`, ) } console.log( JSON.stringify( { preset, from: { x: fromX, y: fromY }, to: { x: toX, y: toY }, result }, null, 2, ), ) break } case 'scroll': { if (args.some((arg) => arg === '--node-id' || arg.startsWith('--node-id='))) { console.error(' scroll takes a testID; --node-id is not supported') console.error(inspectUsage('scroll', ' | --testid ')) rnxExit(1) } const target = readTargetFlag(args) const id = target?.mode === 'testid' ? target.value : positional[1] const offset = target ? 1 : 2 const x = Number(positional[offset]) const y = Number(positional[offset + 1]) if (!id || !Number.isFinite(x) || !Number.isFinite(y)) { console.error(inspectUsage('scroll', ' | --testid ')) rnxExit(1) } const resolved = await resolveTargetCoords(bridge, { mode: 'testid', value: id }) const result = await performSingleStep(bridge, { type: 'scroll', id, x, y, animated: false, }) const scrollNode = resolved ? { cx: resolved.x, cy: resolved.y } : null if (result?.ok) { await recordInspectAction( 'inspect scroll', { scrollTo: { id, x, y } }, `scroll #${id} -> ${x},${y}`, ) } console.log( JSON.stringify( { ...result, ...(scrollNode ? { at: { x: scrollNode.cx, y: scrollNode.cy } } : {}), }, null, 2, ), ) break } case 'state': { const stateCmd = positional[1] // `rnx get state` (no sub-arg) → compact runtime dashboard. // `rnx debug state ` continues to dispatch the raw dumps. if (verbPrefix === 'get' && !stateCmd) { const base = await callTestBridge>( bridge, 'getRuntimeState', ) const diag = await bridge.send({ type: 'evaluate', code: MERGED_CONSOLE_COUNT_EVAL, }) if (base && typeof base === 'object' && base.diagnostics) { base.diagnostics.errors = diag?.errors ?? 0 base.diagnostics.warnings = diag?.warnings ?? 0 } if (base && typeof base === 'object' && base.shell == null) { try { const shell = await getShellState(bridge) if (shell) base.shell = shell } catch {} } console.log(JSON.stringify(base, null, 2)) break } if (!stateCmd || stateCmd === '--help' || stateCmd === '-h') { console.log(` ${inspectCommand('state')} — dump raw runtime state subcommands: shell dump shell transition/layout state worker dump render-worker host/animation state keyboard dump keyboard visibility, mode, and focused input ownership dump surface ownership + pointerOnSurface delivery stats worklets dump tenant + shell worklet-runtime slot/handler counts scroll-input dump shell-owned scroll gesture and momentum state scroll-mirror dump shell scroll registry entries and offsets node dump raw node info by id or testID scroll dump scroll metrics and runtime state scroll-hit dump the nearest scroll ancestor at coordinates hit dump the hit-test ancestry at coordinates gesture dump gesture routing/debug info at coordinates gesture-seam dump shell-hosted gesture recognizer state/events examples: ${inspectCommand('state')} shell ${inspectCommand('state')} worker ${inspectCommand('state')} keyboard ${inspectCommand('state')} ownership ${inspectCommand('state')} worklets ${inspectCommand('state')} scroll-input ${inspectCommand('state')} scroll-mirror ${inspectCommand('state')} node photos ${inspectCommand('state')} scroll feed ${inspectCommand('state')} scroll-hit 360 420 ${inspectCommand('state')} hit 200 720 `) break } let result: unknown switch (stateCmd) { case 'shell': result = await getShellState(bridge, 500) break case 'worker': result = await callInBridge(bridge, '__sootsimRenderHost.queryStats') break case 'ownership': // plan P1: one authoritative read model for surface ownership // + pointerOnSurface delivery observability. bridges through // __sootsimRenderHost.getOwnershipSnapshot which reads the // shell scene, registered aux surfaces, and // pointer-delivery.ts stats. result = await bridge.send({ type: 'evaluate', code: `(() => { const h = window.__sootsimRenderHost if (!h || typeof h.getOwnershipSnapshot !== 'function') { return { error: 'getOwnershipSnapshot not available' } } return h.getOwnershipSnapshot() })()`, }) break case 'keyboard': // focused input sourced from __sootsimTest.getFocusedNode() — // single authoritative source. getLayout() ships the full // keyboard state bundle (spec, mode, shifted, capsLock, accessory). result = await bridge.send({ type: 'evaluate', code: `(async () => { const kb = window.__sootsimKeyboard const test = window.__sootsimTest if (!kb) return { error: 'keyboard bridge not available' } const layout = typeof kb.getLayout === 'function' ? kb.getLayout() : null const secureTextEntry = !!layout?.spec?.secureTextEntry && layout?.spec?.keyboardType !== 'visible-password' const visible = kb.isVisible() const mode = kb.getMode() let focused = null if (test && typeof test.getFocusedNode === 'function') { try { focused = await test.getFocusedNode() } catch {} } return { visible, mode, layout, focusedInput: focused ? { nodeId: focused.nodeId ?? null, testID: focused.testID || null, id: focused.id || null, placeholder: focused.placeholder || null, secureTextEntry, text: secureTextEntry ? ${JSON.stringify(SECURE_TEXT_REDACTION)} : (focused.text || null), } : null, } })()`, }) result = redactKeyboardStateForOutput(result as any) break case 'node': { const id = positional[2] if (!id) { console.error(` usage: ${inspectCommand('state')} node `) rnxExit(1) } result = (await callTestBridge(bridge, 'findByTestId', id)) || (await callTestBridge(bridge, 'findById', id)) break } case 'scroll': { const id = positional[2] if (!id) { console.error(` usage: ${inspectCommand('state')} scroll `) rnxExit(1) } result = await callTestBridge(bridge, 'getScrollState', id) break } case 'scroll-hit': { const x = Number(positional[2]) const y = Number(positional[3]) if (!Number.isFinite(x) || !Number.isFinite(y)) { console.error(` usage: ${inspectCommand('state')} scroll-hit `) rnxExit(1) } result = await callTestBridge(bridge, 'getScrollStateAt', x, y) break } case 'hit': { const x = Number(positional[2]) const y = Number(positional[3]) if (!Number.isFinite(x) || !Number.isFinite(y)) { console.error(` usage: ${inspectCommand('state')} hit `) rnxExit(1) } result = await callTestBridge(bridge, 'debugHitAt', x, y) break } case 'gesture': { const x = Number(positional[2]) const y = Number(positional[3]) if (!Number.isFinite(x) || !Number.isFinite(y)) { console.error(` usage: ${inspectCommand('state')} gesture `) rnxExit(1) } result = await callTestBridge(bridge, 'debugGestureAt', x, y) break } case 'gesture-seam': result = await callInBridge( bridge, 'SootSim.bridges.mainShell.callTestBridge', 'getShellGestureSeamDebug', ) break case 'worklets': { // both worklet runtimes, side by side. the shell runtime is where // shell-sourced events (keyboard, scroll) are dispatched from, so a // handler the tenant holds locally instead of forwarding will never // fire. compare `eventHandlerNames` across the two to see which // side a given event is waiting on. const [tenant, shell] = await Promise.all([ callTestBridge(bridge, 'getWorkletSlotStats'), callInBridge( bridge, 'SootSim.bridges.mainShell.callTestBridge', 'getWorkletSlotStats', ), ]) result = { tenant, shell } break } case 'scroll-input': result = await callInBridge( bridge, 'SootSim.bridges.mainShell.callTestBridge', 'getShellScrollInputDebug', ) break case 'scroll-mirror': result = await callInBridge( bridge, 'SootSim.bridges.mainShell.callTestBridge', 'getScrollMirrorDebug', ) break default: console.error(` unknown state subcommand: ${stateCmd}`) rnxExit(1) } console.log(JSON.stringify(result, null, 2)) break } case 'shell': { const shellCmd = positional[1] if (!shellCmd || shellCmd === '--help' || shellCmd === '-h') { console.log(` ${inspectCommand('shell')} — run built-in shell commands subcommands: launch [waitMs] [--clear-state] launch app and wait for settled shell state home [waitMs] go home and wait for settled shell state switcher [waitMs] open switcher and wait for settled shell state open-card [waitMs] open a specific switcher card and wait for app settle appearance update simulator appearance lock toggle device lock state shake trigger the simulator shake gesture examples: ${inspectCommand('shell')} launch photos ${inspectCommand('shell')} launch rn --clear-state ${inspectCommand('shell')} launch photos 1500 ${inspectCommand('shell')} home 500 ${inspectCommand('shell')} switcher 800 ${inspectCommand('shell')} open-card clock 800 ${inspectCommand('shell')} appearance dark ${inspectCommand('shell')} lock `) break } const usesSettleMs = shellCmd === 'launch' || shellCmd === 'open-card' || shellCmd === 'home' || shellCmd === 'switcher' const settleMsRaw = shellCmd === 'launch' || shellCmd === 'open-card' ? positional[3] : positional[2] const settleMs = settleMsRaw ? Number(settleMsRaw) : 350 if (usesSettleMs && (!Number.isFinite(settleMs) || settleMs < 0)) { console.error( inspectUsage( 'shell', shellCmd === 'launch' || shellCmd === 'open-card' ? ' [settleMs]' : ' [settleMs]', ), ) rnxExit(1) } let ok = false let settled = false let state: Record | null = null const clearState = args.includes('--clear-state') if (shellCmd === 'launch') { const appId = positional[2] if (!appId) { console.error( inspectUsage('shell', 'launch [settleMs] [--clear-state]'), ) rnxExit(1) } if (clearState) { await bridge.send({ type: 'evaluate', code: resetGuestAppStateEval(true), }) } ok = !!(await callShellCommandWhenReady(bridge, 'launchApp', settleMs, appId)) ;({ settled, state } = await waitForShellState( bridge, Math.round(settleMs), (currentState) => !!currentState && currentState.state === 'app' && currentState.activeApp === appId && currentState.showSwitcher === false && currentState.switcherPhase === 'idle' && typeof currentState.launchProgress === 'number' && currentState.launchProgress >= 0.98, )) if (ok) { const step = clearState ? { launchApp: { clearState: true } } : { launchApp: {} } const summary = clearState ? 'launch app (clear state)' : 'launch app' await recordInspectAction('inspect shell launch', step, summary) } } else if (shellCmd === 'home') { ok = !!(await callShellCommandWhenReady(bridge, 'goHome', settleMs)) ;({ settled, state } = await waitForShellState( bridge, Math.round(settleMs), (currentState) => !!currentState && currentState.state === 'home' && currentState.activeApp == null && currentState.showSwitcher === false && currentState.switcherPhase === 'idle' && typeof currentState.launchProgress === 'number' && currentState.launchProgress >= 0.98, )) } else if (shellCmd === 'switcher') { ok = !!(await callShellCommandWhenReady(bridge, 'openSwitcher', settleMs)) ;({ settled, state } = await waitForShellState( bridge, Math.round(settleMs), (currentState) => !!currentState && currentState.state === 'app' && currentState.showSwitcher === true && currentState.switcherPhase === 'idle' && typeof currentState.zoomLevel === 'number' && Math.abs(currentState.zoomLevel) <= 0.02 && typeof currentState.horizontalZoom === 'number' && Math.abs(currentState.horizontalZoom) <= 0.02, )) if (settled) { await sleep(SWITCHER_SETTLE_GRACE_MS) state = await getShellState(bridge) } } else if (shellCmd === 'open-card') { const appId = positional[2] if (!appId) { console.error(inspectUsage('shell', 'open-card [settleMs]')) rnxExit(1) } ok = !!(await callShellCommandWhenReady( bridge, 'openSwitcherCard', settleMs, appId, )) ;({ settled, state } = await waitForShellState( bridge, Math.round(settleMs), (currentState) => !!currentState && currentState.state === 'app' && currentState.activeApp === appId && currentState.showSwitcher === false && currentState.switcherPhase === 'idle' && typeof currentState.zoomLevel === 'number' && currentState.zoomLevel >= 0.98 && typeof currentState.horizontalZoom === 'number' && currentState.horizontalZoom >= 0.98, )) if (ok) { await recordInspectAction( 'inspect shell open-card', { openSwitcherCard: { appId } }, `open switcher card ${appId}`, ) } } else if (shellCmd === 'appearance') { const mode = positional[2] if (!mode || !['light', 'dark', 'auto', 'toggle'].includes(mode)) { console.error(inspectUsage('shell', 'appearance ')) rnxExit(1) } const result = await runShellVisualCommand(bridge, 'appearance', mode) ok = !!result?.ok state = { appearance: result } // human-readable confirmation line. without this the only output // is the multi-line JSON blob below, whose last line is a bare // `}` — useless when scanned or piped through `tail -1` (F13-8). if (ok) { const applied = (result as { applied?: string } | null)?.applied ?? mode console.log(` appearance: ${applied}`) } } else if (shellCmd === 'lock' || shellCmd === 'shake') { const result = await runShellVisualCommand(bridge, shellCmd) ok = !!result?.ok state = { [shellCmd]: result } } else { console.error(` unknown shell subcommand: ${shellCmd}`) rnxExit(1) } console.log(JSON.stringify({ ok, settled, state }, null, 2)) break } case 'url': { await runUrlSubcommand(bridge, { args: effectiveArgs }) break } case 'reload': { // a cloud simulator is a headless runtime with no page to reload, and // rnx itself runs inside the box's workerd isolate rather than a node // process, so the full-page branch below cannot work: it reconnects // through `waitForBridgeConnected`, whose `createBridge` constructs the // `ws` package's WebSocket, and that import has no constructor in // workerd. if (bridge.plane === 'cloud') { const message = 'rnx do reload is not available on a cloud simulator; it runs headless with no page to reload. restart the simulator instead.' if (wantsJson(effectiveArgs)) { printJson({ cleared: false, ready: false, error: message }) } else { console.error(` ${message}`) } rnxExit(1) } // reload waits for the guest app's `sootsim:externalAppReady` signal // (not just a live ws connection), with a 10s budget. fatal bundle // failures surface as console errors that pile up fast — bail early // in that case instead of waiting the full budget. const READY_TIMEOUT_MS = 10000 let issuedReload = false let inPlaceReload = false try { await bridge.send({ type: 'evaluate', code: 'window.__sootsimConsole?.clear()', }) const reloadResult = await bridge.send({ type: 'evaluate', code: `;(async () => { // in-place guest reload is only valid while the page still runs // the engine build the server would serve NOW. the shell dev // server has no HMR client, so a long-lived sim tab otherwise // keeps a stale engine forever while builds churn underneath it. // compare the inlined engine manifest against a fresh fetch of // this page's html; any drift means the whole page must reload. let engineStale = false try { const loaded = document.getElementById('__sootsim-engine-manifest')?.textContent if (loaded) { const res = await fetch(location.href, { cache: 'no-store' }) const html = await res.text() const m = html.match(/]*id="__sootsim-engine-manifest"[^>]*>([^<]*)<\\/script>/) if (m && m[1] !== loaded) engineStale = true } } catch {} const reloadExternalApp = window.SootSim?.bridges?.hotRemount?.reloadExternalApp if (!engineStale && typeof reloadExternalApp === 'function') { reloadExternalApp() return { kind: 'external-app' } } window.location.reload() return { kind: 'page', engineStale } })()`, }) inPlaceReload = !!reloadResult && reloadResult.kind === 'external-app' issuedReload = true if (reloadResult && reloadResult.engineStale) { console.log(' engine build changed since page load — full page reload') } } catch { // the sim may already be in the middle of reloading; fall through to reconnect wait } console.log(' reloading...') let finalBridge: WsBridge | null = bridge let readyResult: ReloadReadyResult | null = null if (inPlaceReload) { readyResult = await pollForReloadReady(bridge, { timeoutMs: READY_TIMEOUT_MS, }) } else { // full page reload — wait for a fresh bridge before polling for ready. // engine-drift escalation made page reloads the COMMON case during // engine iteration, and a full engine boot runs 5-8s unloaded and // well past 10s on a busy box — a 10s reconnect budget here reads // as "bridge-reconnect fails" exactly when someone is rebuilding. const PAGE_RELOAD_TIMEOUT_MS = 30000 if (issuedReload) await sleep(300) const reconnected = await waitForBridgeConnected( wsPort, commandTimeoutMs, simId, { timeoutMs: PAGE_RELOAD_TIMEOUT_MS, simIdSource }, ) if (reconnected) { finalBridge = reconnected readyResult = await pollForReloadReady(reconnected, { timeoutMs: PAGE_RELOAD_TIMEOUT_MS, }) } else { console.log( ` ⚠ reload: bridge never reconnected within ${PAGE_RELOAD_TIMEOUT_MS}ms`, ) finalBridge = null } } if (readyResult) { if (readyResult.ready) { const suffix = readyResult.source === 'nodes-fallback' ? ' (no ready signal, node-count fallback)' : '' console.log( ` ready in ${readyResult.elapsedMs}ms: ${readyResult.nodes} nodes${suffix}`, ) } else if (readyResult.source === 'error-bail') { console.log( ` ⚠ reload bailed after ${readyResult.elapsedMs}ms: ${readyResult.errors} console error(s), ready signal never fired`, ) } else { const reason = describeReloadReadyBlocker(readyResult) console.log( ` ⚠ reload timed out after ${readyResult.elapsedMs}ms — ${reason} (nodes: ${readyResult.nodes}, targets: ${readyResult.targets}, errors: ${readyResult.errors})`, ) } } // dump captured console errors for context, regardless of outcome. // merged read so a render-worker / root-error-boundary mount crash // (forwarded only through the observability store) is shown here. if (finalBridge) { try { const errors = await inspectErrors(finalBridge, 10) if (finalBridge !== bridge) { finalBridge.close() } if (Array.isArray(errors) && errors.length > 0) { console.log(`\n ⚠ ${errors.length} error(s) during mount:\n`) for (const e of errors) { const msg = e.args .map((a: any) => (typeof a === 'object' ? JSON.stringify(a) : a)) .join(' ') console.log(` ${msg}`) if (e.stack) { const lines = e.stack.split('\n').slice(0, 2) for (const line of lines) { console.log(` ${line.trim()}`) } } } } } catch { // ignore errors checking for errors } } if (readyResult && !readyResult.ready) { rnxExit(1) } break } case 'storage-clear': { const READY_TIMEOUT_MS = 10000 await clearConsole(bridge) const cleared = await bridge.send({ type: 'evaluate', code: resetGuestAppStateEval(true), }) if (cleared !== true) { if (wantsJson(effectiveArgs)) { printJson({ cleared: false, ready: false, error: 'external app reload bridge is unavailable', }) } else { console.error( ' storage clear failed: external app reload bridge is unavailable', ) } rnxExit(1) } const readyResult = await pollForReloadReady(bridge, { timeoutMs: READY_TIMEOUT_MS, }) if (wantsJson(effectiveArgs)) { printJson({ cleared: true, ready: readyResult.ready, reload: readyResult }) if (!readyResult.ready) rnxExit(1) break } if (readyResult.ready) { const suffix = readyResult.source === 'nodes-fallback' ? ' (no ready signal, node-count fallback)' : '' console.log( ` cleared tenant storage; ready in ${readyResult.elapsedMs}ms: ${readyResult.nodes} nodes${suffix}`, ) } else if (readyResult.source === 'error-bail') { console.log( ` ⚠ storage clear reloaded but bailed after ${readyResult.elapsedMs}ms: ${readyResult.errors} console error(s), ready signal never fired`, ) } else { const reason = describeReloadReadyBlocker(readyResult) console.log( ` ⚠ storage clear reloaded but timed out after ${readyResult.elapsedMs}ms — ${reason} (nodes: ${readyResult.nodes}, targets: ${readyResult.targets}, errors: ${readyResult.errors})`, ) } try { const errors = await inspectErrors(bridge, 10) if (Array.isArray(errors) && errors.length > 0) { console.log(`\n ⚠ ${errors.length} error(s) during mount:\n`) for (const e of errors) { const msg = e.args .map((a: any) => (typeof a === 'object' ? JSON.stringify(a) : a)) .join(' ') console.log(` ${msg}`) if (e.stack) { const lines = e.stack.split('\n').slice(0, 2) for (const line of lines) { console.log(` ${line.trim()}`) } } } } } catch { // ignore errors checking for errors } if (!readyResult.ready) { rnxExit(1) } break } case 'js': { const code = positional.slice(1).join(' ') if (!code) { console.error(inspectUsage('js', '')) console.error('') console.error(' runs the snippet in the engine realm. rnx is the') console.error(' canonical state surface — reach into it directly.') console.error('') console.error(' examples:') console.error( ` ${inspectCommand('js')} SootSim.bridges.test.findByText("Sign in")`, ) console.error( ` ${inspectCommand('js')} SootSim.bridges.debug.snapshot("before")`, ) console.error( ` ${inspectCommand('js')} SootSim.bridges.keyboard.type("hello")`, ) console.error(` ${inspectCommand('js')} SootSim.state.root.children.length`) rnxExit(1) } // --tenant: evaluate inside the TENANT worker's global scope (guest // bundle, metro module table, compat stubs) via the test bridge's // evalInTenant escape hatch, instead of the host/engine realm. if (args.includes('--tenant')) { // the flag itself lands in the joined positional tail — drop it const tenantCode = positional .slice(1) .filter((t) => t !== '--tenant') .join(' ') const result = await bridge.send({ type: 'evaluate', code: `(async () => SootSim.bridges.test.evalInTenant(${JSON.stringify(tenantCode)}))()`, }) console.log(JSON.stringify(result, null, 2)) break } // wrap in async to support await in worker mode. no identifier // rewriting — the snippet runs verbatim so tokens like `root`, // `debug`, `test` refer to whatever the user actually typed. // multi-statement snippets need a block body: the bare expression // form terminates at the first `;` (Unexpected token). let expandedCode = code if (!expandedCode.startsWith('(async')) { expandedCode = /[;\n]/.test(expandedCode) ? `(async () => { ${expandedCode} })()` : `(async () => ${expandedCode})()` } const result = await bridge.send({ type: 'evaluate', code: expandedCode }) console.log(JSON.stringify(result, null, 2)) // suggest CLI commands for common verbose eval patterns const lc = code.toLowerCase() const suggestions: string[] = [] if (lc.includes('sootsim:gohome') || lc.includes('gohome')) suggestions.push('rnx shell home') if (lc.includes('sootsim:appswitcher') || lc.includes('appswitcher')) suggestions.push('rnx shell switcher') if (lc.includes('keyboard.isvisible') || lc.includes('keyboard.getmode')) suggestions.push('rnx debug state keyboard') if (lc.includes('interact.tap')) suggestions.push('rnx do tap ') if (lc.includes('keyboard.type')) suggestions.push('rnx do type ') if (lc.includes('keyboard.press') || lc.includes('keyboard.dispatchkey')) suggestions.push('rnx do key ') if (lc.includes('keyboard.dismiss')) suggestions.push('rnx do dismiss') if (lc.includes('dumptree')) suggestions.push('rnx get tree') if (lc.includes('dumpaccessibilitytree')) suggestions.push('rnx get a11y') if (lc.includes('getnodecount')) suggestions.push('rnx get count') if (lc.includes('findbytext')) suggestions.push('rnx find ') if (lc.includes('findbytestid') || lc.includes('findbyid')) suggestions.push('rnx find --testid ') if (lc.includes('document.hidden')) suggestions.push('rnx debug state keyboard (includes tab health)') if (suggestions.length > 0) { maybeHint('prefer-cli-over-eval', suggestions) } break } case 'globals': { // show all available rnx globals and their methods const info = await bridge.send({ type: 'evaluate', code: `(async () => { const globals = {} // test bridge (proxy in worker mode) const testMethods = [ 'findById', 'findByTestId', 'findByText', 'findByLabel', 'findByRole', 'findAllByRole', 'findByA11yState', 'findAllByA11yState', 'findByHint', 'findPressable', 'getStyle', 'getLayout', 'getAbsolutePosition', 'isVisible', 'queryAll', 'dumpTree', 'dumpAccessibilityTree', 'getNodeCount', 'getShellState', 'getScrollState', 'getScrollStateAt', 'scrollTo', 'waitForTree', 'waitForScreenTransitions', 'debugByText', 'debugByTestId', 'debugHitAt', 'debugGestureAt' ] globals['test (→ __sootsimTest)'] = testMethods // debug if (window.__sootsimDebug) { globals['debug (→ __sootsimDebug)'] = Object.keys(window.__sootsimDebug) } // interact if (window.__sootsimInteract) { globals['interact (→ __sootsimInteract)'] = Object.keys(window.__sootsimInteract) } // keyboard if (window.__sootsimKeyboard) { globals['keyboard (→ __sootsimKeyboard)'] = Object.keys(window.__sootsimKeyboard) } // other globals globals['other'] = [ 'root (→ __sootsimRoot) - live node tree', 'render() (→ __sootsimForceRender) - force re-render' ] return globals })()`, }) console.log(' rnx JS API:\n') for (const [name, methods] of Object.entries(info as Record)) { console.log(` ${name}:`) for (const m of methods) { console.log(` .${m}`) } console.log('') } console.log(` use: ${inspectCommand('js')} `) console.log(` example: ${inspectCommand('js')} test.findByText("Sign in")`) break } case 'describe': { await runDescribeSubcommand({ bridge, args, positional }) break } case 'perf': { if (invocationPrefix === 'debug' && !opts.internalPerfCommand) { console.error(' `rnx debug perf` was removed. use `rnx perf shell ...`.') rnxExit(1) } const perfCmd = positional[1] if (opts.internalPerfCommand === 'scroll') { if (!perfCmd || perfCmd === '--help' || perfCmd === '-h') { console.log(` ${inspectCommand('perf')} [options] records per-frame scroll offsets from the tenant worker, shell worker, and compositor worker. timestamps use the shared wall clock, while frame sequences remain separate because the workers do not share a frame id. options: --limit maximum samples retained per layer (default 6000) --json emit every per-frame sample on stop examples: ${inspectCommand('perf')} start # ... perform consecutive swipes ... ${inspectCommand('perf')} stop ${inspectCommand('perf')} stop --json `) break } if (perfCmd === 'start') { const limitValue = effectiveArgs.find( (_, index) => effectiveArgs[index - 1] === '--limit', ) const parsedLimit = limitValue === undefined ? 6000 : Number(limitValue) if (!Number.isFinite(parsedLimit) || parsedLimit < 120) { console.error(' error: --limit must be a number of at least 120') rnxExit(1) } const result = await bridge.send({ type: 'evaluate', code: `(async () => { const perf = window.SootSim?.bridges?.scrollPerf if (!perf) return { error: 'scroll performance profile unavailable' } await perf.start(${Math.floor(parsedLimit)}) return { started: true } })()`, }) if (result?.error) { console.error(` error: ${result.error}`) rnxExit(1) } console.log( ` scroll profiling started: perform consecutive swipes, then run '${inspectCommand('perf')} stop'`, ) break } if (perfCmd === 'stop') { const result = await bridge.send({ type: 'evaluate', code: `(async () => { const perf = window.SootSim?.bridges?.scrollPerf if (!perf) return { error: 'scroll performance profile unavailable' } return await perf.stop() })()`, }) if (result?.error) { console.error(` error: ${result.error}`) rnxExit(1) } const trace: SootSimScrollPerformanceTrace = result if (wantsJson(effectiveArgs)) { printJson(trace) break } console.log(` scroll performance trace:\n`) console.log( ` duration: ${trace.stoppedAt - trace.startedAt}ms (worker series are timestamp-aligned, not frame-paired)`, ) console.log( ` samples: tenant ${trace.layers.tenant.length} · shell ${trace.layers.shell.length} · compositor ${trace.layers.compositor.length}`, ) console.log(``) console.log( ` offset and phase changes (--json includes every per-frame sample):`, ) console.log( ` t(ms) layer surface node offsetY slotY phase paint`, ) const changedSamples: SootSimScrollPerformanceTrace['layers']['tenant'] = [] for (const samples of Object.values(trace.layers)) { const lastByNode = new Map() for (const sample of samples) { const key = `${sample.surfaceId}:${sample.nodeId}` const signature = `${sample.offsetY}:${sample.slotY ?? ''}:${sample.phase ?? ''}` if (lastByNode.get(key) === signature) continue lastByNode.set(key, signature) changedSamples.push(sample) } } changedSamples.sort((a, b) => a.t - b.t || a.seq - b.seq) for (const sample of changedSamples) { console.log( ` ${String(sample.t - trace.startedAt).padStart(5)} ${sample.layer.padEnd(10)} ${sample.surfaceId.padEnd(8)} ${String(sample.nodeId).padStart(5)} ${sample.offsetY.toFixed(2).padStart(8)} ${sample.slotY === undefined ? ' -' : sample.slotY.toFixed(2).padStart(8)} ${(sample.phase ?? '-').padEnd(8)} ${sample.paint === undefined ? '-' : sample.paint}`, ) } break } console.error(` unknown scroll perf command: ${perfCmd}`) rnxExit(1) } if (!perfCmd || perfCmd === '--help' || perfCmd === '-h') { console.log(` ${inspectCommand('perf')} — shell frame profiling (the worker that paints) records per-painted-frame timing in the shell worker — the surface that actually draws the app's pixels — merged with render-profile counters (node visits, boundary records/replays, raster tier, blur ms, draw calls). subcommands: start begin recording (clears prior frames + counters) stop stop recording and report results transition profile a shell transition (goHome, appSwitcher, lockScreen) stop and transition accept --json for the full machine-readable payload. examples: ${inspectCommand('perf')} start # ... interact with the app ... ${inspectCommand('perf')} stop ${inspectCommand('perf')} stop --json ${inspectCommand('perf')} transition goHome `) break } switch (perfCmd) { case 'start': { const result = await bridge.send({ type: 'evaluate', code: `(() => { if (!window.__sootsimShellPerf) { return { error: 'shell frame profile unavailable (__sootsimShellPerf missing on the page)' } } window.__sootsimShellPerf.start() return { started: true } })()`, }) if (result?.error) { console.error(` error: ${result.error}`) rnxExit(1) } console.log( ` shell profiling started — interact with the app, then run '${inspectCommand('perf')} stop'`, ) break } case 'stop': { const result = await bridge.send({ type: 'evaluate', code: `(async () => { if (!window.__sootsimShellPerf) { return { error: 'shell frame profile unavailable (__sootsimShellPerf missing on the page)' } } return await window.__sootsimShellPerf.stop() })()`, }) if (result?.error) { console.error(` error: ${result.error}`) if (result.error === 'timeout') { console.error( ' (shell worker did not answer within 5s — is a sim loaded?)', ) } rnxExit(1) } if (wantsJson(effectiveArgs)) { printJson(result) break } printShellPerfReport(result) break } case 'transition': { const event = positional[2] const validEvents = ['goHome', 'appSwitcher', 'lockScreen'] if (!event || !validEvents.includes(event)) { console.log(` ${inspectCommand('perf')} transition — profile a shell transition events: goHome swipe-to-home animation appSwitcher app switcher card animation lockScreen lock screen transition note: uses 600ms capture window — may need --timeout 10000 flag examples: ${inspectCommand('perf')} transition goHome --timeout 10000 ${inspectCommand('perf')} transition appSwitcher `) break } const eventName = `sootsim:${event}` // transition profiling needs a longer timeout due to the 600ms // wait. these are progress/framing lines, so keep them on stderr — // stdout must stay a clean JSON document under --json. printWarn(` profiling ${event} transition...`) printWarn(` (use --timeout 10000 if this times out)`) const result = await bridge.send({ type: 'evaluate', code: `(async () => { if (!window.__sootsimShellPerf) { return { error: 'shell frame profile unavailable (__sootsimShellPerf missing on the page)' } } window.__sootsimShellPerf.start() // give a frame for profiling to engage before the event await new Promise(r => requestAnimationFrame(() => r(undefined))) window.dispatchEvent(new Event('${eventName}')) // shell animations are ~300-500ms; fixed timing avoids complex // animation-end detection await new Promise(r => setTimeout(r, 600)) return await window.__sootsimShellPerf.stop() })()`, }) if (result?.error) { console.error(` error: ${result.error}`) rnxExit(1) } if (wantsJson(effectiveArgs)) { printJson(result) break } printWarn(` ${event} transition profiled:`) printShellPerfReport(result) break } default: console.error(` unknown perf subcommand: ${perfCmd}`) console.error(` valid: start, stop, transition`) // people reach for `perf --reset` (or `reset`) out of habit, but // `perf start` already clears the frame buffer — there is no // separate reset step. say so instead of just erroring. if (/^--?reset$/.test(perfCmd)) { console.error( ` note: 'perf start' already clears prior frames — no reset needed`, ) } if (perfCmd === 'stats' || perfCmd === 'frames' || perfCmd === 'worst') { console.error( ` note: the tenant sampler (stats/frames/worst) was removed — it hardcoded`, ) console.error( ` layout/render/copy to zero because the shell worker owns every real paint.`, ) console.error( ` use 'perf start' / 'perf stop' (worst frames are in the stop report).`, ) } rnxExit(1) } break } case 'errors': { const subcmd = positional[1] if (subcmd === 'clear') { await clearConsole(bridge) if (wantsJson(effectiveArgs)) printJson({ cleared: true }) else console.log(' error buffer cleared') break } const limit = subcmd ? Number(subcmd) : 20 const list = await inspectErrors(bridge, limit) if (wantsJson(effectiveArgs)) { printJson(list) break } if (list.length === 0) { console.log(' no errors captured') break } console.log(` ${list.length} error(s):\n`) for (const e of list) { const time = formatLogTimestamp(e.timestamp) const msg = e.args .map((a: any) => (typeof a === 'object' ? JSON.stringify(a) : a)) .join(' ') console.log(` [${time}] ${msg}`) if (e.stack) { const lines = e.stack.split('\n').slice(0, 3) for (const line of lines) { console.log(` ${line.trim()}`) } } } break } case 'warnings': { const limit = positional[1] ? Number(positional[1]) : 20 const list = await inspectWarnings(bridge, limit) if (wantsJson(effectiveArgs)) { printJson(list) break } if (list.length === 0) { console.log(' no warnings captured') break } console.log(` ${list.length} warning(s):\n`) for (const w of list) { const time = formatLogTimestamp(w.timestamp) const msg = w.args .map((a: any) => (typeof a === 'object' ? JSON.stringify(a) : a)) .join(' ') console.log(` [${time}] ${msg}`) } break } case 'animations': { const anims = (await callTestBridge>>(bridge, 'listAnimations')) ?? [] if (args.includes('--json')) { console.log(JSON.stringify(anims, null, 2)) break } if (anims.length === 0) { console.log(' no active animations') break } console.log(` ${anims.length} active animation(s):\n`) for (const a of anims) { const realm = String(a.realm ?? 'tenant').padEnd(6) const kind = String(a.kind).padEnd(10) // a native-driver registration without an advertised graph has no // knowable endpoints or progress; print what is real, not NaN. const range = typeof a.from === 'number' && typeof a.to === 'number' ? `${a.from.toFixed(2)}→${a.to.toFixed(2)}` : '—' const cur = Number(a.current ?? 0).toFixed(2) const pct = typeof a.progress === 'number' ? `${Math.round(a.progress * 100)}%` : '—' const ms = `${Math.round(a.elapsedMs ?? 0)}ms` const tags = [ a.loop ? 'loop' : null, a.layoutBound ? 'layout' : null, a.remoteDriven ? 'remote-driven' : a.graphBacked ? 'graph' : null, a.visible === false ? 'offscreen' : null, ].filter(Boolean) const tag = tags.length > 0 ? ` [${tags.join(' ')}]` : '' console.log( ` #${a.id} ${realm} ${kind} ${range.padEnd(14)} cur=${cur.padEnd(7)} ${pct.padStart(4)} ${ms}${tag}`, ) } break } case 'animation': { const raw = positional[1] if (!raw || raw === '--help' || raw === '-h') { console.error(` usage: ${inspectCommand('animation')} `) rnxExit(1) } const id = Number(raw) if (!Number.isFinite(id)) { console.error(` invalid id: ${raw}`) rnxExit(1) } const result = await callTestBridge(bridge, 'getAnimation', id) console.log(JSON.stringify(result, null, 2)) break } case 'stop-animation': { const raw = positional[1] if (!raw || raw === '--help' || raw === '-h') { console.error(` usage: ${inspectCommand('stop-animation')} `) rnxExit(1) } const target: number | 'all' = raw === 'all' ? 'all' : Number(raw) if (target !== 'all' && !Number.isFinite(target)) { console.error(` invalid id: ${raw}`) rnxExit(1) } const count = await callTestBridge(bridge, 'stopAnimation', target) console.log(` stopped ${count ?? 0} animation(s)`) break } case 'requests': { const subcmd = positional[1] if (subcmd === 'clear') { await clearRequests(bridge) if (wantsJson(effectiveArgs)) printJson({ cleared: true }) else console.log(' request buffer cleared') break } const showAll = subcmd === 'all' const rawLimit = showAll ? positional[2] : subcmd const limit = rawLimit ? Number(rawLimit) : 20 const list = await inspectRequests(bridge, { failed: !showAll, limit }) if (wantsJson(effectiveArgs)) { printJson(list) break } if (list.length === 0) { console.log( showAll ? ' no requests captured' : ' no failed requests captured', ) break } console.log(` ${list.length} ${showAll ? 'request(s)' : 'failed request(s)'}:\n`) for (const entry of list) { const time = formatLogTimestamp(entry.timestamp) console.log(` [${time}] ${formatRequestMessage(entry)}`) if (entry.responseBody) { console.log(` ${entry.responseBody}`) } else if (entry.error) { console.log(` ${entry.error}`) } } break } case 'network': { // rnx network — live request inspector backed by the shared // observability store (installed at engine boot, fed by worker-side // fetch + XHR capture over BroadcastChannel). // // shapes: // rnx network [limit] last N entries (default 20) // rnx network --failed only non-2xx / errored // rnx network --slow only requests slower than --threshold (default 1000ms) // rnx network --threshold customize slow threshold for --slow // rnx network --filter url substring filter // rnx network --json raw json output // rnx network tail | -f follow mode (polls) // rnx network get detail for one entry // rnx network clear drop the buffer const sub = positional[1] // manual flag scan — parseBridgeCliArgs already stripped these // from positional but didn't give us the values, so re-walk the // raw input to pull --filter / --limit / --failed / --slow / --threshold / --tail / -f / --json. let filter: string | null = null let limitFlag: number | null = null let failedOnly = false let slowOnly = false let slowThresholdMs = 1000 let tail = false let jsonOut = false for (let i = 0; i < effectiveArgs.length; i++) { const a = effectiveArgs[i] if (a === '--filter') { filter = effectiveArgs[i + 1] ?? null i++ } else if (a === '--limit') { const n = Number(effectiveArgs[i + 1]) if (Number.isFinite(n)) limitFlag = n i++ } else if (a === '--threshold') { const n = Number(effectiveArgs[i + 1]) if (Number.isFinite(n) && n > 0) slowThresholdMs = n i++ } else if (a === '--failed') failedOnly = true else if (a === '--slow') slowOnly = true else if (a === '--tail' || a === '-f') tail = true else if (a === '--json') jsonOut = true } if (sub === 'clear') { await bridge.send({ type: 'evaluate', code: 'window.__sootsimObservability?.network.clear(); "cleared"', }) console.log(' network buffer cleared') break } if (sub === 'get') { const id = positional[2] if (!id) { console.error(' usage: rnx network get ') rnxExit(1) } const entry = (await bridge.send({ type: 'evaluate', code: `(() => { const obs = window.__sootsimObservability; if (!obs) return null; return obs.network.getSnapshot().find(e => e.id === ${JSON.stringify(id)}) || null; })()`, })) as NetworkEntryPayload | null if (!entry) { console.error(` no entry with id ${id}`) rnxExit(1) } if (jsonOut) { console.log(JSON.stringify(entry, null, 2)) } else { printNetworkDetail(entry) } break } // list / tail mode share the same fetch + format path. default // limit is 20; tail mode uses a larger buffer window (200) so // spikes don't blow past the ring. const limit = limitFlag ?? (tail ? 200 : sub ? Number(sub) : 20) if (!Number.isFinite(limit)) { console.error( ` invalid limit: ${sub} — \`network\` takes a numeric count (e.g. ${inspectCommand('network')} 100).\n` + ` to target a specific sim, use \`--sim ${sub}\` instead.`, ) rnxExit(1) } const fetchEntries = async () => { const entries = (await bridge.send({ type: 'evaluate', code: `(() => { const obs = window.__sootsimObservability; if (!obs) return { ok: false }; return { ok: true, entries: obs.network.getSnapshot() }; })()`, })) as { ok: boolean; entries?: NetworkEntryPayload[] } if (!entries || !entries.ok) { throw new Error('observability bridge not installed — is the engine running?') } return entries.entries ?? [] } const applyFilter = (all: NetworkEntryPayload[]) => { let out = all if (failedOnly) { out = out.filter((e) => !!e.error || (e.status != null && e.status >= 400)) } if (slowOnly) { // only completed requests can be classified as slow — durationMs // is null until a response (or error) arrives. don't include // in-flight requests in the slow list (they may finish fast). out = out.filter( (e) => e.durationMs != null && e.durationMs >= slowThresholdMs, ) } if (filter) { const lf = filter.toLowerCase() out = out.filter((e) => (e.displayUrl || e.url).toLowerCase().includes(lf)) } // when --slow is on without --tail, sort by durationMs descending // so the slowest requests print first regardless of finish order. if (slowOnly && !tail) { out = [...out].sort((a, b) => (b.durationMs ?? 0) - (a.durationMs ?? 0)) } return out } if (!tail) { const all = await fetchEntries() const filtered = applyFilter(all).slice(-limit) if (jsonOut) { console.log(JSON.stringify(filtered, null, 2)) break } if (filtered.length === 0) { if (all.length === 0) { console.log(' no network requests captured') } else if (slowOnly) { console.log( ` no requests slower than ${slowThresholdMs}ms (${all.length} total — try --threshold )`, ) } else { console.log(' no matching requests') } break } if (slowOnly) { console.log( ` ${filtered.length} request(s) slower than ${slowThresholdMs}ms (sorted by duration desc):\n`, ) } else { console.log(` ${filtered.length} request(s):\n`) } for (const e of filtered) printNetworkRow(e) break } // tail mode — poll every 250ms, track the set of completed ids // we've already printed, and emit new entries as they land. only // prints *completed* entries (durationMs != null) so each request // renders once with its final status, not twice. console.log(' tailing network (ctrl-c to stop)...\n') const seen = new Set() let running = true const stop = () => { running = false } process.on('SIGINT', stop) try { while (running) { const all = await fetchEntries() const filtered = applyFilter(all) for (const e of filtered) { if (e.durationMs == null) continue if (seen.has(e.id)) continue seen.add(e.id) if (jsonOut) console.log(JSON.stringify(e)) else printNetworkRow(e) } await sleep(250) } } finally { process.off('SIGINT', stop) } break } case 'logs': { // rnx logs — live console inspector backed by the shared // observability log store (fed by worker-side console capture over // BroadcastChannel). mirrors `rnx network`: // // rnx logs [limit] last N entries (default 50) // rnx logs --level error,warn filter by level (csv) // rnx logs --filter message substring filter // rnx logs --internal include engine-internal [sootsim] logs (hidden by default) // rnx logs --json raw json output // rnx logs tail | -f follow mode (polls) // rnx logs clear drop the buffer const sub = positional[1] let filter: string | null = null let limitFlag: number | null = null let levelCsv: string | null = null let tail = false let jsonOut = false let showInternal = false for (let i = 0; i < effectiveArgs.length; i++) { const a = effectiveArgs[i] if (a === '--filter') { filter = effectiveArgs[i + 1] ?? null i++ } else if (a === '--limit') { const n = Number(effectiveArgs[i + 1]) if (Number.isFinite(n)) limitFlag = n i++ } else if (a === '--level') { levelCsv = effectiveArgs[i + 1] ?? null i++ } else if (a === '--tail' || a === '-f') tail = true else if (a === '--json') jsonOut = true else if (a === '--internal' || a === '--all') showInternal = true } const levelFilter: ReadonlySet | null = levelCsv ? new Set( levelCsv .split(',') .map((s) => s.trim()) .filter( (s): s is LogLevel => s === 'log' || s === 'info' || s === 'warn' || s === 'error' || s === 'debug', ), ) : null if (sub === 'clear') { await clearLogs(bridge) console.log(' log buffer cleared') break } const useColor = !jsonOut && process.stdout.isTTY === true const limit = limitFlag ?? (tail ? 500 : sub ? Number(sub) : 50) if (!Number.isFinite(limit)) { console.error( ` invalid limit: ${sub} — \`logs\` takes a numeric count (e.g. ${inspectCommand('logs')} 100).\n` + ` to target a specific sim, use \`--sim ${sub}\` instead.`, ) rnxExit(1) } const fetchEntries = () => inspectLogs(bridge) // engine-internal logs (bundle-loader setup, HMR trap messages, etc.) // are noise when debugging guest-app behavior — filterLogEntries hides // them by default; pass --internal to include them. const applyFilter = (all: LogEntry[]) => filterLogEntries(all, { level: levelFilter, filter, showInternal, }) if (!tail) { const all = await fetchEntries() const filtered = applyFilter(all).slice(-limit) if (jsonOut) { console.log(JSON.stringify(filtered, null, 2)) break } if (filtered.length === 0) { console.log(all.length === 0 ? ' no logs captured' : ' no matching logs') break } console.log(` ${filtered.length} log(s):\n`) for (const e of filtered) printLogRow(e, useColor) break } console.log(' tailing logs (ctrl-c to stop)...\n') const seen = new Set() let running = true const stop = () => { running = false } process.on('SIGINT', stop) try { while (running) { const all = await fetchEntries() const filtered = applyFilter(all) for (const e of filtered) { if (seen.has(e.id)) continue seen.add(e.id) if (jsonOut) console.log(JSON.stringify(e)) else printLogRow(e, useColor) } await sleep(250) } } finally { process.off('SIGINT', stop) } break } default: console.error(` unknown subcommand: ${subcommand}`) rnxExit(1) } // auto-settle after any write command so the next CLI call doesn't see // mid-transition state. short deadline — if the app has a perpetual // background animation, we bail to layout-only stability via the same // logic `do settle` uses. opt-out: --no-wait or RNX_NO_AUTO_WAIT=1. if ( writeCommands.has(subcommand) && !args.includes('--no-wait') && process.env.RNX_NO_AUTO_WAIT !== '1' && !(await shouldSkipAutoSettleForInspectPick(bridge, subcommand)) ) { await autoSettleAfterWrite(bridge) } // framing prose (this note, the cursor footer, hints, console summaries) // goes to stderr, never stdout. a `--json` flag is not a usable signal for // that: `do tap-id`, `do tap-text`, and the rest of the write verbs print a // JSON document by default with no flag at all, so gating on the flag left // exactly those commands emitting `{...}` followed by `since last: …` and // breaking every parser. keeping payload on stdout and prose on stderr is // the only rule that holds for both. // the native-UI note flags a blocking Alert/ActionSheet that was up when an // interaction verb ran — it silently eats taps/drags meant for the app, the // single most confusing "my `do` did nothing" failure. if (nativeUIBeforeWrite.length > 0) { printNativeUISummary(nativeUIBeforeWrite) } // cursor footer reads `bridges.timeline.summary` keyed on the agent's // stable cliSessionKey so every CLI call shows only what's *new* since the // previous call from the same agent (not a global count). after printing, // advance the cursor past the rendered window so the next call starts // fresh. if (!consoleSummarySkip.has(subcommand)) { try { await printCursorFooter(bridge) } catch { // ignore footer errors; the main command already succeeded } } } catch (err: any) { rethrowIfExit(err) const message = err instanceof Error ? err.message : String(err) console.error(` ${subcommand ?? 'rnx'} failed: ${message}`) const unknownSim = /^no sim connected with id ([^;]+)(?:; connected sims: .+)?$/.exec( message, ) // a failure whose own message proves the sim/bridge is unresponsive must // NOT trigger the secondary diagnostic round-trips below — each of them // issues its own bridge command, which against the same dead sim just // re-incurs the full per-command timeout. three sequential probes turn a // single 15s timeout into ~60s of apparent hang (QA F19-1). there is // nothing useful to fetch from a sim that just timed out, so skip them. const timedOut = /^command timed out after (\d+)s$/.exec(message) // a disconnected / never-connected bridge is genuinely gone; recover is // right and probing it would just re-incur a full timeout. const disconnected = message.startsWith('sim disconnected:') || message.startsWith('bridge never reconnected') || message.startsWith('could not connect to ws://') if (unknownSim) { await printUnknownSimHint(bridge, wsPort, unknownSim[1]) } else if (/^no sim connected$/.test(message)) { printMissingSimHint(wsPort) } else if (timedOut) { // a command timeout alone does not mean a wedged sim — a large screen // can blow the budget on `describe`'s tree serialization while the sim // answers everything else fine. probe once (short budget); if it's // alive, point at narrowing/raising the budget instead of force-closing // a healthy sim. const alive = await probeSimResponsive(bridge) if (alive) { const cmd = subcommand ?? 'describe' process.stderr.write( ` the sim is still responsive — '${cmd}' just exceeded the ${timedOut[1]}s command budget.\n` + ` the screen's node tree is large; narrow the query or raise the budget:\n` + ` rnx ${cmd} --testid # scope to one subtree\n` + ` rnx find --testid # targeted single-node lookup\n` + ` rnx ${cmd} --timeout 60000 # raise per-command budget (ms)\n`, ) } else { printRecoverHint() } } else if (disconnected) { printRecoverHint() } else { try { await printBridgeStateSummary(bridge) } catch { // ignore secondary bridge errors while already failing the command } try { await printConsoleSummary({ includeTail: true }) } catch { // ignore secondary bridge errors while already failing the command } try { await printRequestSummary({ includeTail: true }) } catch { // ignore secondary request summary errors while already failing the command } } rnxExit(1) } finally { bridge.close() } }