peachsim 0.1.527

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (324) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +640 -0
  3. package/cli/app-config.ts +82 -0
  4. package/cli/app-fonts.ts +388 -0
  5. package/cli/app-project.ts +59 -0
  6. package/cli/app-splash.ts +171 -0
  7. package/cli/app-state-reset.ts +26 -0
  8. package/cli/auth.ts +184 -0
  9. package/cli/bin.ts +59 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2806 -0
  12. package/cli/browser-backend.ts +44 -0
  13. package/cli/browser-evals.ts +96 -0
  14. package/cli/cli-update.ts +261 -0
  15. package/cli/cloud-client.ts +1530 -0
  16. package/cli/cloud-dispatch.ts +206 -0
  17. package/cli/cloud-session.ts +227 -0
  18. package/cli/command-contract.ts +221 -0
  19. package/cli/command-registry.ts +304 -0
  20. package/cli/commands/agent-wrapper.ts +1048 -0
  21. package/cli/commands/agent.ts +439 -0
  22. package/cli/commands/app-fonts.ts +99 -0
  23. package/cli/commands/assert.ts +542 -0
  24. package/cli/commands/auth.ts +71 -0
  25. package/cli/commands/box/bundle-plane.ts +71 -0
  26. package/cli/commands/box/checkout-plane.ts +256 -0
  27. package/cli/commands/box/plane-share.ts +208 -0
  28. package/cli/commands/box/rnx-command.ts +60 -0
  29. package/cli/commands/box/search-command.ts +157 -0
  30. package/cli/commands/box.ts +925 -0
  31. package/cli/commands/camera.ts +266 -0
  32. package/cli/commands/cleanup.ts +169 -0
  33. package/cli/commands/compat.ts +176 -0
  34. package/cli/commands/config.ts +34 -0
  35. package/cli/commands/control.ts +2154 -0
  36. package/cli/commands/cpu-profile.ts +941 -0
  37. package/cli/commands/daemon-mac-app.ts +165 -0
  38. package/cli/commands/daemon.ts +800 -0
  39. package/cli/commands/debug.ts +751 -0
  40. package/cli/commands/desktop.ts +39 -0
  41. package/cli/commands/detect.ts +201 -0
  42. package/cli/commands/detox.ts +481 -0
  43. package/cli/commands/device.ts +134 -0
  44. package/cli/commands/diagnose.ts +589 -0
  45. package/cli/commands/do-chain.ts +408 -0
  46. package/cli/commands/electron.ts +94 -0
  47. package/cli/commands/film.ts +379 -0
  48. package/cli/commands/flow.ts +1240 -0
  49. package/cli/commands/inspect/actions.ts +516 -0
  50. package/cli/commands/inspect/core.ts +2086 -0
  51. package/cli/commands/inspect/count.ts +17 -0
  52. package/cli/commands/inspect/describe.ts +119 -0
  53. package/cli/commands/inspect/env.ts +25 -0
  54. package/cli/commands/inspect/find.ts +169 -0
  55. package/cli/commands/inspect/get-layout.ts +53 -0
  56. package/cli/commands/inspect/keyboard.ts +54 -0
  57. package/cli/commands/inspect/list.ts +175 -0
  58. package/cli/commands/inspect/memory.ts +215 -0
  59. package/cli/commands/inspect/redaction.ts +39 -0
  60. package/cli/commands/inspect/resolve-target.ts +77 -0
  61. package/cli/commands/inspect/screens.ts +78 -0
  62. package/cli/commands/inspect/settle.ts +32 -0
  63. package/cli/commands/inspect/settling.ts +197 -0
  64. package/cli/commands/inspect/shared.ts +259 -0
  65. package/cli/commands/inspect/sleep.ts +15 -0
  66. package/cli/commands/inspect/tree.ts +23 -0
  67. package/cli/commands/inspect/url.ts +17 -0
  68. package/cli/commands/inspect/wait-event.ts +211 -0
  69. package/cli/commands/inspect/wait-idle.ts +25 -0
  70. package/cli/commands/inspect/wait-ready.ts +87 -0
  71. package/cli/commands/inspect/wait-selector.ts +59 -0
  72. package/cli/commands/inspect/wait.ts +32 -0
  73. package/cli/commands/inspect.ts +4738 -0
  74. package/cli/commands/install-cli.ts +275 -0
  75. package/cli/commands/install-desktop.ts +385 -0
  76. package/cli/commands/login.ts +357 -0
  77. package/cli/commands/logout.ts +24 -0
  78. package/cli/commands/maestro-generate.ts +375 -0
  79. package/cli/commands/maestro.ts +536 -0
  80. package/cli/commands/mode.ts +58 -0
  81. package/cli/commands/no-bridge-hint.ts +80 -0
  82. package/cli/commands/perf.ts +67 -0
  83. package/cli/commands/permissions.ts +203 -0
  84. package/cli/commands/platform.ts +410 -0
  85. package/cli/commands/preview.ts +103 -0
  86. package/cli/commands/profile.ts +106 -0
  87. package/cli/commands/react.ts +353 -0
  88. package/cli/commands/record.ts +1435 -0
  89. package/cli/commands/report-issue.ts +305 -0
  90. package/cli/commands/reset.ts +89 -0
  91. package/cli/commands/runtime.ts +348 -0
  92. package/cli/commands/screenshot-command.ts +106 -0
  93. package/cli/commands/screenshot-layers.ts +144 -0
  94. package/cli/commands/screenshot-mode.ts +38 -0
  95. package/cli/commands/screenshot.ts +492 -0
  96. package/cli/commands/screenshots-capture.ts +608 -0
  97. package/cli/commands/screenshots.ts +127 -0
  98. package/cli/commands/serve.ts +139 -0
  99. package/cli/commands/setup.ts +416 -0
  100. package/cli/commands/shell-boolean-mode.ts +82 -0
  101. package/cli/commands/skills.ts +468 -0
  102. package/cli/commands/slides.ts +357 -0
  103. package/cli/commands/state.ts +94 -0
  104. package/cli/commands/storage.ts +58 -0
  105. package/cli/commands/telemetry.ts +55 -0
  106. package/cli/commands/test.ts +535 -0
  107. package/cli/commands/three-mode.ts +771 -0
  108. package/cli/commands/timeline.ts +121 -0
  109. package/cli/commands/upgrade.ts +167 -0
  110. package/cli/commands/upload.ts +1203 -0
  111. package/cli/commands/version.ts +63 -0
  112. package/cli/commands/welcome.ts +17 -0
  113. package/cli/commands/what-happened.ts +328 -0
  114. package/cli/current-sim.ts +217 -0
  115. package/cli/desktop-companion.ts +315 -0
  116. package/cli/drivers/electron.ts +70 -0
  117. package/cli/drivers/index.ts +20 -0
  118. package/cli/drivers/playwright-provisioning.ts +184 -0
  119. package/cli/drivers/playwright-sim-host.ts +537 -0
  120. package/cli/drivers/playwright.ts +209 -0
  121. package/cli/drivers/registry.ts +65 -0
  122. package/cli/drivers/types.ts +102 -0
  123. package/cli/flow-export.ts +55 -0
  124. package/cli/flow-file.ts +142 -0
  125. package/cli/flow-live-status.ts +126 -0
  126. package/cli/flow-session.ts +140 -0
  127. package/cli/help-core.ts +143 -0
  128. package/cli/help.ts +44 -0
  129. package/cli/hidden-runtime-alias.ts +19 -0
  130. package/cli/hints.ts +214 -0
  131. package/cli/inspect-notice-state.ts +114 -0
  132. package/cli/internal-child.ts +176 -0
  133. package/cli/maestro-js.ts +323 -0
  134. package/cli/main.ts +704 -0
  135. package/cli/open-url.ts +8 -0
  136. package/cli/outbound-endpoints.ts +830 -0
  137. package/cli/parent-pid.ts +204 -0
  138. package/cli/parse-args.ts +189 -0
  139. package/cli/privacy.ts +53 -0
  140. package/cli/prompt.ts +56 -0
  141. package/cli/recording-access.ts +108 -0
  142. package/cli/registry.ts +2 -0
  143. package/cli/resolve-assets.ts +63 -0
  144. package/cli/rnx-in-process.ts +98 -0
  145. package/cli/run-registry.ts +226 -0
  146. package/cli/run-rnx-core.ts +148 -0
  147. package/cli/run-rnx.ts +137 -0
  148. package/cli/runtime-notes.ts +66 -0
  149. package/cli/runtime-summary.ts +25 -0
  150. package/cli/secure-file.ts +31 -0
  151. package/cli/self-invocation.ts +58 -0
  152. package/cli/send-to-box.ts +109 -0
  153. package/cli/setup-repository.ts +191 -0
  154. package/cli/shell-init.ts +98 -0
  155. package/cli/standalone.ts +4 -0
  156. package/cli/telemetry.ts +84 -0
  157. package/cli/ui.ts +68 -0
  158. package/cli/ws-bridge.ts +1020 -0
  159. package/detox/attempt-events.cjs +23 -0
  160. package/detox/colors.ts +54 -0
  161. package/detox/config-loader.ts +135 -0
  162. package/detox/element-types.ts +48 -0
  163. package/detox/expectations.ts +534 -0
  164. package/detox/gestures.ts +501 -0
  165. package/detox/index.ts +2694 -0
  166. package/detox/jest-preset.cjs +29 -0
  167. package/detox/jest-setup-after-env.cjs +22 -0
  168. package/detox/matchers.ts +30 -0
  169. package/detox/motion-change.cjs +109 -0
  170. package/detox/motion-change.d.cts +35 -0
  171. package/detox/navigation-lifecycle.ts +290 -0
  172. package/detox/navigation.ts +43 -0
  173. package/detox/proof-frame.cjs +114 -0
  174. package/detox/record-block-execution.cjs +78 -0
  175. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  176. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  177. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  178. package/detox/screenshots/theme-blue-toggle.png +0 -0
  179. package/detox/screenshots/theme-blue.png +0 -0
  180. package/detox/screenshots/theme-red-toggle.png +0 -0
  181. package/detox/screenshots/theme-red.png +0 -0
  182. package/dist-lib/agent-daemon-client.cjs +502 -0
  183. package/dist-lib/agent-events.cjs +48 -0
  184. package/dist-lib/agent-identity.cjs +53 -0
  185. package/dist-lib/agent-sessions.cjs +759 -0
  186. package/dist-lib/attached-projects.cjs +526 -0
  187. package/dist-lib/auth/shared-session.cjs +208 -0
  188. package/dist-lib/backend-origin.cjs +88 -0
  189. package/dist-lib/beta.cjs +41 -0
  190. package/dist-lib/beta.mjs +13 -0
  191. package/dist-lib/bridge-constants.cjs +161 -0
  192. package/dist-lib/bridge-contract-input.cjs +451 -0
  193. package/dist-lib/bridge-contract-input.mjs +414 -0
  194. package/dist-lib/bridge-contract.cjs +130 -0
  195. package/dist-lib/bridge-contract.mjs +94 -0
  196. package/dist-lib/capture-contract.cjs +220 -0
  197. package/dist-lib/capture-contract.mjs +190 -0
  198. package/dist-lib/cli-constants.cjs +32 -0
  199. package/dist-lib/cloud-contract.cjs +194 -0
  200. package/dist-lib/cloud-contract.mjs +145 -0
  201. package/dist-lib/cloud.cjs +670 -0
  202. package/dist-lib/cloud.mjs +639 -0
  203. package/dist-lib/config.cjs +96 -0
  204. package/dist-lib/detox/index.cjs +3789 -0
  205. package/dist-lib/detox/jest-preset.cjs +29 -0
  206. package/dist-lib/dev-bundle-resolution.cjs +290 -0
  207. package/dist-lib/home-paths.cjs +638 -0
  208. package/dist-lib/host/bridge-host.cjs +10903 -0
  209. package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
  210. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  211. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  212. package/dist-lib/host/replacement-module-handler.cjs +469 -0
  213. package/dist-lib/host/websocket-proxy.cjs +298 -0
  214. package/dist-lib/index.cjs +4268 -0
  215. package/dist-lib/jump-to-source-babel.cjs +93 -0
  216. package/dist-lib/jump-to-source-native.cjs +112 -0
  217. package/dist-lib/menu.cjs +646 -0
  218. package/dist-lib/menu.mjs +604 -0
  219. package/dist-lib/metro-fingerprint-registry.cjs +186 -0
  220. package/dist-lib/metro-fingerprint-registry.mjs +156 -0
  221. package/dist-lib/metro-production-bundle.cjs +329 -0
  222. package/dist-lib/metro-production-bundle.mjs +295 -0
  223. package/dist-lib/metro.cjs +904 -0
  224. package/dist-lib/profiles.cjs +246 -0
  225. package/dist-lib/public-brand.cjs +39 -0
  226. package/dist-lib/react-native-host-modules.cjs +51 -0
  227. package/dist-lib/react-native-host-modules.mjs +25 -0
  228. package/dist-lib/render-mode.cjs +54 -0
  229. package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
  230. package/dist-lib/sdk.cjs +2433 -0
  231. package/dist-lib/sdk.mjs +2336 -0
  232. package/dist-lib/skills.cjs +39132 -0
  233. package/dist-lib/swift-submit.cjs +92 -0
  234. package/dist-lib/swift-submit.mjs +65 -0
  235. package/dist-lib/swift.cjs +1646 -0
  236. package/dist-lib/vite.cjs +1591 -0
  237. package/package.json +290 -0
  238. package/scripts/dev-server-scanner.ts +1291 -0
  239. package/scripts/optional-demo-registry.ts +113 -0
  240. package/skills/contrast/SKILL.md +165 -0
  241. package/skills/rnx-debug/SKILL.md +433 -0
  242. package/skills/rnx-perf/SKILL.md +399 -0
  243. package/skills/rnx-setup/SKILL.md +172 -0
  244. package/skills/rnx-test/SKILL.md +321 -0
  245. package/skills/rnx-visual/SKILL.md +126 -0
  246. package/src/agent-daemon-client.ts +410 -0
  247. package/src/agent-events.ts +71 -0
  248. package/src/agent-identity.ts +23 -0
  249. package/src/agent-prompt.ts +71 -0
  250. package/src/agent-sessions.ts +580 -0
  251. package/src/app-url.ts +39 -0
  252. package/src/attached-projects.ts +539 -0
  253. package/src/auth/shared-session.ts +235 -0
  254. package/src/backend-origin.ts +70 -0
  255. package/src/beta.ts +19 -0
  256. package/src/bridge-constants.ts +175 -0
  257. package/src/bridge-contract-input.ts +502 -0
  258. package/src/bridge-contract.ts +558 -0
  259. package/src/browser-cache.ts +16 -0
  260. package/src/capture-contract.ts +587 -0
  261. package/src/cli-constants.ts +1 -0
  262. package/src/cli-version.ts +40 -0
  263. package/src/cloud-contract.ts +274 -0
  264. package/src/cloud.ts +762 -0
  265. package/src/codex-client.ts +234 -0
  266. package/src/config.ts +132 -0
  267. package/src/connect.ts +101 -0
  268. package/src/dev-bundle-resolution.ts +346 -0
  269. package/src/dev-server-open.ts +198 -0
  270. package/src/disk-cleanup.ts +540 -0
  271. package/src/flow-timeout-scale.ts +15 -0
  272. package/src/home-paths.ts +806 -0
  273. package/src/host/agent-host.ts +666 -0
  274. package/src/host/bridge-host.ts +2788 -0
  275. package/src/host/fetch-proxy-handler.ts +449 -0
  276. package/src/host/fetch-proxy-overrides.ts +51 -0
  277. package/src/host/open-url.ts +89 -0
  278. package/src/host/plane-host.ts +203 -0
  279. package/src/host/replacement-module-handler.ts +158 -0
  280. package/src/host/websocket-proxy.ts +315 -0
  281. package/src/index.ts +71 -0
  282. package/src/jump-to-source-babel.ts +135 -0
  283. package/src/jump-to-source-native.ts +102 -0
  284. package/src/menu.ts +777 -0
  285. package/src/metro-fingerprint-registry.ts +302 -0
  286. package/src/metro-plugin.ts +341 -0
  287. package/src/metro-production-bundle.ts +471 -0
  288. package/src/native-dev-bundle-url.ts +118 -0
  289. package/src/native-seam-manifest.ts +341 -0
  290. package/src/playwright-host-log.ts +16 -0
  291. package/src/plist.ts +27 -0
  292. package/src/poll-until.ts +123 -0
  293. package/src/profiles.ts +179 -0
  294. package/src/public-brand.ts +9 -0
  295. package/src/react-native-host-modules.ts +26 -0
  296. package/src/render-mode.ts +27 -0
  297. package/src/rolldown-plugin-swift.ts +240 -0
  298. package/src/runtime-assets.ts +233 -0
  299. package/src/runtime-delivery.ts +54 -0
  300. package/src/screenshots/compose.ts +422 -0
  301. package/src/screenshots/frame-compose.ts +514 -0
  302. package/src/screenshots/orchestrate.ts +256 -0
  303. package/src/screenshots/org-deck.ts +91 -0
  304. package/src/screenshots/registry.ts +58 -0
  305. package/src/screenshots/schema.ts +364 -0
  306. package/src/sdk.ts +15 -0
  307. package/src/settings-bundle.ts +418 -0
  308. package/src/shell-bridge-identity.ts +15 -0
  309. package/src/sim-client.ts +697 -0
  310. package/src/skills/builtin/a11y-review.ts +132 -0
  311. package/src/skills/builtin/compat-check.ts +71 -0
  312. package/src/skills/builtin/maestro-test.ts +121 -0
  313. package/src/skills/builtin/perf-profile.ts +90 -0
  314. package/src/skills/builtin/screenshot-all.ts +52 -0
  315. package/src/skills/builtin/visual-diff.ts +100 -0
  316. package/src/skills/registry.ts +107 -0
  317. package/src/skills/types.ts +41 -0
  318. package/src/swift-submit.ts +185 -0
  319. package/src/swift.ts +7 -0
  320. package/src/team-machine-prompt.ts +73 -0
  321. package/src/vite-plugin-one.ts +264 -0
  322. package/src/vite-plugin-swift.ts +620 -0
  323. package/src/vite-plugin.ts +1946 -0
  324. package/src/worklets-babel.ts +250 -0
@@ -0,0 +1,4738 @@
1
+ // rnx inspect — dump node tree, accessibility info, query nodes
2
+ // connects to the rnx WS bridge (no Playwright)
3
+
4
+ import { getCliInspectVerbMeta } from '@rnx/skills/cli'
5
+ import { renderCliCommandHelp, renderCliGroupHelp } from '@rnx/skills/cli/renderers'
6
+ import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants'
7
+ import { DEFAULT_SOOTSIM_SHELL_URL } from '../../src/cli-constants'
8
+ import { resetGuestAppStateEval } from '../app-state-reset'
9
+ import { printBridgeStateSummary } from '../bridge-diagnostics'
10
+ import {
11
+ buildResolveRectEval,
12
+ buildSampleColorEval,
13
+ type SampleRect,
14
+ } from '../browser-evals'
15
+ import { getCliIdentityKey } from '../current-sim'
16
+ import { rememberFlowCandidate } from '../flow-session'
17
+ import { maybeHint } from '../hints'
18
+ import { shouldPrintInspectNotice } from '../inspect-notice-state'
19
+ import { TOP_LEVEL_RUNTIME_COMMANDS } from '../parse-args'
20
+ import { rethrowIfExit, rnxExit } from '../run-rnx'
21
+ import {
22
+ type BridgeTransportOption,
23
+ callInBridge,
24
+ callInBridgeWrite,
25
+ checkSimHealth,
26
+ createBridge,
27
+ createBridgeFromParsed,
28
+ evalInBridge,
29
+ parseBridgeCliArgs,
30
+ printBridgeWorldNotice,
31
+ type WsBridge,
32
+ } from '../ws-bridge'
33
+ import {
34
+ isTapSuccess,
35
+ tapBest,
36
+ tapById,
37
+ tapByText,
38
+ tapCoordinates,
39
+ tapResolvedTarget,
40
+ type TapTextOptions,
41
+ } from './inspect/actions'
42
+ import {
43
+ clearConsole,
44
+ clearLogs,
45
+ clearRequests,
46
+ detectOpenNativeUI,
47
+ filterLogEntries,
48
+ inspectAccessibilityTree,
49
+ inspectErrors,
50
+ inspectFind,
51
+ inspectLogs,
52
+ inspectRequests,
53
+ inspectWarnings,
54
+ MERGED_CONSOLE_COUNT_EVAL,
55
+ shouldSkipAutoSettleForInspectPick,
56
+ type LogEntry,
57
+ type LogLevel,
58
+ type OpenNativeUISurface,
59
+ } from './inspect/core'
60
+ import { runCountSubcommand } from './inspect/count'
61
+ import { runDescribeSubcommand } from './inspect/describe'
62
+ import { isAgentEnv } from './inspect/env'
63
+ import { runFindSubcommand } from './inspect/find'
64
+ import { runGetLayoutSubcommand } from './inspect/get-layout'
65
+ import { runKeyboardSubcommand } from './inspect/keyboard'
66
+ import { runListSubcommand } from './inspect/list'
67
+ import { runMemorySubcommand } from './inspect/memory'
68
+ import {
69
+ SECURE_TEXT_REDACTION,
70
+ isSecureKeyboardState,
71
+ redactKeyboardStateForOutput,
72
+ redactTextForKeyboardState,
73
+ } from './inspect/redaction'
74
+ import { readTargetFlag, resolveTargetCoords } from './inspect/resolve-target'
75
+ import { runScreensSubcommand } from './inspect/screens'
76
+ import { runSettleSubcommand } from './inspect/settle'
77
+ import { waitForSootsimIdle } from './inspect/settling'
78
+ import {
79
+ callShellCommand,
80
+ callShellCommandWhenReady,
81
+ callTestBridge,
82
+ formatLogTimestamp,
83
+ getShellState,
84
+ isShellCommandUnavailable,
85
+ maybeWaitForStartedScreenTransitions,
86
+ pollForReloadReady,
87
+ printJson,
88
+ printWarn,
89
+ type ReloadReadyResult,
90
+ sleep,
91
+ SCREEN_TRANSITION_PREFLIGHT,
92
+ waitForBridgeConnected,
93
+ wantsJson,
94
+ } from './inspect/shared'
95
+ import { runSleepSubcommand } from './inspect/sleep'
96
+ import { runTreeSubcommand } from './inspect/tree'
97
+ import { runUrlSubcommand } from './inspect/url'
98
+ import { runWaitSubcommand } from './inspect/wait'
99
+ import { runWaitEventSubcommand } from './inspect/wait-event'
100
+ import { runWaitIdleSubcommand } from './inspect/wait-idle'
101
+ import { runWaitReadySubcommand } from './inspect/wait-ready'
102
+ import { runWaitSelectorSubcommand } from './inspect/wait-selector'
103
+ import { printMissingSimHint, printUnknownSimHint } from './no-bridge-hint'
104
+ import type { PerformResult, PerformStep } from '../../src/bridge-contract'
105
+ import type {
106
+ SootSimRequestEntry,
107
+ SootSimScreenTransitionWaitResult,
108
+ SootSimScrollPerformanceTrace,
109
+ } from '@rnx/globals'
110
+
111
+ interface InspectOptions extends BridgeTransportOption {
112
+ port?: number
113
+ verbose?: boolean
114
+ timeoutMs?: number
115
+ internalPerfCommand?: 'shell' | 'scroll'
116
+ }
117
+
118
+ // shape of entries in the shared observability store — mirrors
119
+ // packages/sootsim-engine/src/observability/types.ts NetworkEntry. duplicated
120
+ // here so this command doesn't cross-import from engine internals.
121
+ interface NetworkEntryPayload {
122
+ id: string
123
+ source: string
124
+ kind: 'fetch' | 'xhr' | 'resource'
125
+ method: string
126
+ url: string
127
+ displayUrl: string
128
+ startTs: number
129
+ durationMs: number | null
130
+ status: number | null
131
+ statusText: string | null
132
+ ok: boolean
133
+ error: string | null
134
+ size: number | null
135
+ type: string | null
136
+ }
137
+
138
+ function formatNetworkSize(bytes: number | null): string {
139
+ if (bytes == null) return '—'
140
+ if (bytes < 1024) return `${bytes}B`
141
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`
142
+ return `${(bytes / 1024 / 1024).toFixed(1)}M`
143
+ }
144
+
145
+ function formatNetworkDuration(ms: number | null): string {
146
+ if (ms == null) return ' …'
147
+ if (ms < 1000) return `${ms}ms`.padStart(5)
148
+ return `${(ms / 1000).toFixed(2)}s`.padStart(5)
149
+ }
150
+
151
+ // when a tap-id / tap-text query misses, walk the live tree's testIDs and
152
+ // emit the canonical "recover a wedged sim" hint. shared by the genuine-dead
153
+ // paths in the failure handler.
154
+ function printRecoverHint(): void {
155
+ process.stderr.write(
156
+ ` the sim is not responding. recover it with:\n` +
157
+ ` rnx close --sim <id> # force-close the wedged sim\n` +
158
+ ` rnx list # confirm it's gone\n`,
159
+ )
160
+ }
161
+
162
+ // cheap liveness probe: a command timing out does NOT prove the sim is dead.
163
+ // `describe` on a big screen (rainbow's loaded home is hundreds of nodes)
164
+ // blows the 15s default while `get errors` answers instantly — same sim. send
165
+ // a trivial eval on a short budget; if it answers, the sim is alive and the
166
+ // previous command just needs a narrower scope or a bigger timeout.
167
+ // send a one-step `perform` batch and flatten the result back to the
168
+ // `{ ok, value, error? }` shape these subcommands have always printed. the
169
+ // engine executes the step in-page and emits the agent-cursor action itself.
170
+ async function performSingleStep(
171
+ bridge: WsBridge,
172
+ step: PerformStep,
173
+ ): Promise<{ ok: boolean; value?: unknown; error?: string }> {
174
+ const result: PerformResult = await bridge.send({ type: 'perform', steps: [step] })
175
+ const stepResult = result?.steps?.[0]
176
+ const error = stepResult?.error ?? result?.error
177
+ return {
178
+ ok: result?.ok === true,
179
+ value: stepResult?.value,
180
+ ...(error ? { error } : {}),
181
+ }
182
+ }
183
+
184
+ async function probeSimResponsive(bridge: WsBridge): Promise<boolean> {
185
+ try {
186
+ await bridge.send({ type: 'evaluate', code: '1' }, { timeoutMs: 3000 })
187
+ return true
188
+ } catch {
189
+ return false
190
+ }
191
+ }
192
+
193
+ // surface the 5 closest candidates so callers can fix the typo without
194
+ // dumping the full tree. uses a cheap prefix + Levenshtein score so a
195
+ // trailing digit or near-miss casing still bubbles to the top.
196
+ async function printSimilarTestIds(bridge: WsBridge, query: string): Promise<void> {
197
+ try {
198
+ const found = await inspectFind(bridge, { visible: true })
199
+ const nodes = Array.isArray(found?.result) ? found.result : []
200
+ const ids = [
201
+ ...new Set(
202
+ nodes.flatMap((node) => {
203
+ const id = Reflect.get(node, 'testID')
204
+ return typeof id === 'string' && id ? [id] : []
205
+ }),
206
+ ),
207
+ ]
208
+ if (ids.length === 0) return
209
+ const q = query.toLowerCase()
210
+ const ranked = ids
211
+ .map((id) => ({
212
+ id,
213
+ score: scoreIdSimilarity(q, id.toLowerCase()),
214
+ }))
215
+ .filter((entry) => entry.score < q.length + 4) // drop obvious non-matches
216
+ .sort((a, b) => a.score - b.score)
217
+ .slice(0, 5)
218
+ if (ranked.length === 0) return
219
+ console.error(` similar testIDs:`)
220
+ for (const entry of ranked) {
221
+ console.error(` ${entry.id}`)
222
+ }
223
+ } catch {
224
+ // if the suggestion path fails the user still got the "not found" msg
225
+ }
226
+ }
227
+
228
+ function scoreIdSimilarity(needle: string, candidate: string): number {
229
+ if (candidate === needle) return 0
230
+ if (candidate.includes(needle)) return 1
231
+ if (needle.includes(candidate)) return 2
232
+ // tiny prefix/suffix bonus so `nav-back` ranks above unrelated long names
233
+ // when querying `back`.
234
+ let prefix = 0
235
+ while (
236
+ prefix < needle.length &&
237
+ prefix < candidate.length &&
238
+ needle[prefix] === candidate[prefix]
239
+ ) {
240
+ prefix += 1
241
+ }
242
+ return levenshtein(needle, candidate) - prefix
243
+ }
244
+
245
+ function levenshtein(a: string, b: string): number {
246
+ if (a === b) return 0
247
+ if (!a.length) return b.length
248
+ if (!b.length) return a.length
249
+ // standard two-row dp: prev[] is the previous row, cur[] is the row being
250
+ // computed; swap pointers per outer iteration. the earlier in-place rolling
251
+ // version overwrote prev[j-1] twice per cell and never set prev[0]=i, so
252
+ // distance(`kitten`,`sitting`) returned 8 instead of 3 and the fuzzy-suggest
253
+ // ranking was effectively random.
254
+ let prev = new Array<number>(b.length + 1)
255
+ let cur = new Array<number>(b.length + 1)
256
+ for (let j = 0; j <= b.length; j++) prev[j] = j
257
+ for (let i = 1; i <= a.length; i++) {
258
+ cur[0] = i
259
+ for (let j = 1; j <= b.length; j++) {
260
+ cur[j] = Math.min(
261
+ prev[j] + 1,
262
+ cur[j - 1] + 1,
263
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
264
+ )
265
+ }
266
+ const tmp = prev
267
+ prev = cur
268
+ cur = tmp
269
+ }
270
+ return prev[b.length]
271
+ }
272
+
273
+ function formatNetworkStatus(e: NetworkEntryPayload): string {
274
+ if (e.error) return 'err'
275
+ if (e.status == null) return ' … '
276
+ return String(e.status)
277
+ }
278
+
279
+ function describeReloadReadyBlocker(result: ReloadReadyResult): string {
280
+ if (result.externalError) return `guest app errored: ${result.externalError}`
281
+ if (result.loadingText) return `still showing "${result.loadingText}"`
282
+ if (result.externalReady === false) return 'guest app is still loading'
283
+ if (result.flag !== true) return 'guest app has not emitted sootsim:externalAppReady'
284
+ if (result.targets <= 0) {
285
+ return 'ready flag emitted but no visible app content is inspectable yet'
286
+ }
287
+ return 'node tree is still changing'
288
+ }
289
+
290
+ function printNetworkRow(e: NetworkEntryPayload) {
291
+ const time = formatLogTimestamp(e.startTs)
292
+ const status = formatNetworkStatus(e).padEnd(3)
293
+ const method = e.method.padEnd(5)
294
+ const size = formatNetworkSize(e.size).padStart(6)
295
+ const dur = formatNetworkDuration(e.durationMs)
296
+ console.log(` [${time}] ${status} ${method} ${size} ${dur} ${e.displayUrl}`)
297
+ if (e.error) console.log(` error: ${e.error}`)
298
+ }
299
+
300
+ function printNetworkDetail(e: NetworkEntryPayload) {
301
+ const rows: [string, string][] = [
302
+ ['id', e.id],
303
+ ['source', e.source],
304
+ ['kind', e.kind],
305
+ ['method', e.method],
306
+ [
307
+ 'status',
308
+ e.error ? `error: ${e.error}` : `${e.status ?? '—'} ${e.statusText ?? ''}`.trim(),
309
+ ],
310
+ ['url', e.url],
311
+ ['started', formatLogTimestamp(e.startTs)],
312
+ ['duration', formatNetworkDuration(e.durationMs).trim()],
313
+ ['size', formatNetworkSize(e.size)],
314
+ ['content-type', e.type ?? '—'],
315
+ ]
316
+ for (const [k, v] of rows) console.log(` ${k.padEnd(13)} ${v}`)
317
+ }
318
+
319
+ // ANSI colors for the `logs` verb. the LogEntry / LogLevel types + filtering
320
+ // live in inspect/core.ts so the CLI and the agent share one definition.
321
+ const LOG_LEVEL_COLOR: Record<LogLevel, string> = {
322
+ error: '\x1b[31m', // red
323
+ warn: '\x1b[33m', // yellow
324
+ info: '\x1b[36m', // cyan
325
+ debug: '\x1b[35m', // magenta
326
+ log: '\x1b[37m', // white
327
+ }
328
+ const ANSI_RESET = '\x1b[0m'
329
+ const ANSI_DIM = '\x1b[2m'
330
+
331
+ function printLogRow(e: LogEntry, useColor: boolean) {
332
+ const time = formatLogTimestamp(e.ts)
333
+ const levelTag = e.level.toUpperCase().padEnd(5)
334
+ const msg = e.args.join(' ')
335
+ if (useColor) {
336
+ const col = LOG_LEVEL_COLOR[e.level]
337
+ console.log(
338
+ ` ${ANSI_DIM}[${time}]${ANSI_RESET} ${col}${levelTag}${ANSI_RESET} ${msg}`,
339
+ )
340
+ } else {
341
+ console.log(` [${time}] ${levelTag} ${msg}`)
342
+ }
343
+ if (e.stack && e.level === 'error') {
344
+ const lines = e.stack.split('\n').slice(0, 5)
345
+ for (const line of lines) console.log(` ${line.trim()}`)
346
+ }
347
+ }
348
+
349
+ const SWITCHER_SETTLE_GRACE_MS = 120
350
+
351
+ // parse sample-color / screenshot rect args. supports several shapes so the
352
+ // command is friendly for both humans and scripted agents:
353
+ // <x> <y> single pixel (w=h=1)
354
+ // <x> <y> <w> <h> positional area
355
+ // --area x,y,w,h comma-separated area
356
+ // --x n --y n --w n --h n
357
+ // --id <testID> snap to node's bounding box
358
+ // --text <text> snap to node matching text
359
+ // returns null when no rect flags are present — callers then either fall
360
+ // back to a full-canvas capture (screenshot) or report an argument error
361
+ // (sample-color).
362
+ async function resolveSampleRect(
363
+ args: string[],
364
+ bridge: WsBridge,
365
+ ): Promise<SampleRect | null> {
366
+ const idArg = args.find((_, i) => args[i - 1] === '--id')
367
+ const textArg = args.find((_, i) => args[i - 1] === '--text')
368
+ if (idArg || textArg) {
369
+ const node = await bridge.send({
370
+ type: 'evaluate',
371
+ code: buildResolveRectEval({ id: idArg, text: textArg }),
372
+ })
373
+ if (!node) {
374
+ throw new Error(
375
+ idArg ? `no node with id "${idArg}"` : `no node matching text "${textArg}"`,
376
+ )
377
+ }
378
+ const { x, y, w, h } = node as SampleRect
379
+ return { x, y, w, h }
380
+ }
381
+
382
+ const areaArg = args.find((_, i) => args[i - 1] === '--area')
383
+ if (areaArg) {
384
+ const parts = areaArg.split(',').map((p) => Number(p.trim()))
385
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
386
+ throw new Error(`--area expects x,y,w,h (got "${areaArg}")`)
387
+ }
388
+ const [x, y, w, h] = parts
389
+ return { x, y, w, h }
390
+ }
391
+
392
+ const flagged = (name: string): number | null => {
393
+ const value = args.find((_, i) => args[i - 1] === name)
394
+ if (value == null) return null
395
+ const n = Number(value)
396
+ return Number.isFinite(n) ? n : null
397
+ }
398
+ const fx = flagged('--x')
399
+ const fy = flagged('--y')
400
+ const fw = flagged('--w')
401
+ const fh = flagged('--h')
402
+ if (fx != null || fy != null || fw != null || fh != null) {
403
+ return { x: fx ?? 0, y: fy ?? 0, w: fw ?? 1, h: fh ?? 1 }
404
+ }
405
+
406
+ // positional: "<verb> <x> <y> [w] [h]"
407
+ const positional = args.filter(
408
+ (a, i) =>
409
+ i > 0 &&
410
+ !a.startsWith('-') &&
411
+ args[i - 1] !== '--output' &&
412
+ args[i - 1] !== '--area' &&
413
+ args[i - 1] !== '--id' &&
414
+ args[i - 1] !== '--text' &&
415
+ args[i - 1] !== '--x' &&
416
+ args[i - 1] !== '--y' &&
417
+ args[i - 1] !== '--w' &&
418
+ args[i - 1] !== '--h',
419
+ )
420
+ const nums = positional.map(Number).filter((n) => Number.isFinite(n))
421
+ if (nums.length >= 2) {
422
+ const [x, y, w = 1, h = 1] = nums
423
+ return { x, y, w, h }
424
+ }
425
+
426
+ return null
427
+ }
428
+
429
+ function printRenderProfile(worker: string, rp: Record<string, any> | undefined) {
430
+ if (!rp || typeof rp !== 'object') return
431
+ console.log(``)
432
+ console.log(` render profile — ${worker} (per painted frame):`)
433
+ console.log(` node visits: ${rp.nodeVisitsPerFrame}`)
434
+ console.log(
435
+ ` boundaries: ${rp.recordsPerFrame} records (${rp.avgBoundaryRecordMs}ms) / ${rp.replaysPerFrame} replays`,
436
+ )
437
+ const recordCauses = [
438
+ rp.boundaryRecordsInvalidated ? `invalidated ${rp.boundaryRecordsInvalidated}` : '',
439
+ rp.boundaryRecordsOrigin ? `moved ${rp.boundaryRecordsOrigin}` : '',
440
+ rp.boundaryRecordsFirst ? `first-record ${rp.boundaryRecordsFirst}` : '',
441
+ rp.boundaryRecordsScheme ? `scheme ${rp.boundaryRecordsScheme}` : '',
442
+ rp.boundaryRecordsFont ? `late-font ${rp.boundaryRecordsFont}` : '',
443
+ ]
444
+ .filter(Boolean)
445
+ .join(' · ')
446
+ if (recordCauses) console.log(` why recorded: ${recordCauses}`)
447
+ // idle work, so it is a total for the capture, not a per-frame average.
448
+ if (rp.prewarmRecords) {
449
+ console.log(
450
+ ` pre-recorded: ${rp.prewarmRecords} boundaries at idle (${Number(rp.prewarmRecordMs).toFixed(1)}ms total)`,
451
+ )
452
+ }
453
+ console.log(
454
+ ` raster tier: ${rp.rasterPromotionsPerFrame} promotions / ${rp.rasterBlitsPerFrame} blits`,
455
+ )
456
+ const rejects = [
457
+ rp.rasterRejectRebuild ? `rebuild ${rp.rasterRejectRebuild}` : '',
458
+ rp.rasterRejectLinear ? `linear ${rp.rasterRejectLinear}` : '',
459
+ rp.rasterRejectAnimated ? `animated ${rp.rasterRejectAnimated}` : '',
460
+ rp.rasterRejectTransform ? `transform ${rp.rasterRejectTransform}` : '',
461
+ rp.rasterRejectOpacity ? `opacity ${rp.rasterRejectOpacity}` : '',
462
+ rp.rasterRejectCacheable ? `cacheable ${rp.rasterRejectCacheable}` : '',
463
+ rp.rasterRejectBounds ? `bounds ${rp.rasterRejectBounds}` : '',
464
+ rp.rasterRejectPixels ? `pixels ${rp.rasterRejectPixels}` : '',
465
+ rp.rasterRejectBudget ? `budget ${rp.rasterRejectBudget}` : '',
466
+ rp.rasterWarming ? `warming ${rp.rasterWarming}` : '',
467
+ ]
468
+ .filter(Boolean)
469
+ .join(' · ')
470
+ if (rejects) {
471
+ console.log(` raster skips: ${rejects} (total across run)`)
472
+ if (rp.rasterRejectAlsoBlocked) {
473
+ console.log(
474
+ ` ${''.padEnd(12)} ${rp.rasterRejectAlsoBlocked} of those also fail a later gate — widening the named gate cannot promote them`,
475
+ )
476
+ }
477
+ }
478
+ if (rp.paragraphBuilds || rp.prewarmParagraphBuilds || rp.paragraphLayouts) {
479
+ console.log(
480
+ ` 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)`,
481
+ )
482
+ }
483
+ console.log(` blur: ${rp.avgBlurMs}ms`)
484
+ if (rp.glassDownsampleActive || rp.glassBackdropCacheMisses) {
485
+ console.log(
486
+ ` glass: ${rp.glassDownsampleFactor}x downsample (${rp.glassDownsampledDraws} draws) · cache ${rp.glassBackdropCacheHits} hits / ${rp.glassBackdropCacheMisses} misses / ${rp.glassBackdropCacheInvalidations} invalidations`,
487
+ )
488
+ }
489
+ console.log(
490
+ ` draw calls: text ${rp.textDrawsPerFrame} · image ${rp.imageDrawsPerFrame} · path ${rp.pathDrawsPerFrame} · saveLayer ${rp.saveLayersPerFrame}`,
491
+ )
492
+ }
493
+
494
+ // pretty-print the shell frame profile payload (__sootsimShellPerf.stop()):
495
+ // per-painted-frame timing from the shell worker merged with the render
496
+ // profile counters. numbers arrive pre-rounded from the worker. also used by
497
+ // `rnx maestro test --profile`, which records the same payload.
498
+ export function printShellPerfReport(result: Record<string, any>) {
499
+ const fps = result.avgMs > 0 ? (1000 / result.avgMs).toFixed(1) : '?'
500
+ const jank = result.jank && typeof result.jank === 'object' ? result.jank : null
501
+ const production =
502
+ result.compositorProduction && typeof result.compositorProduction === 'object'
503
+ ? result.compositorProduction
504
+ : null
505
+ const budgetMs = production?.budgetMs ?? 8
506
+ console.log(` shell frame profile:\n`)
507
+ if (jank) {
508
+ const compositor = Array.isArray(jank.compositor) ? jank.compositor : []
509
+ const compositorJank = compositor.reduce(
510
+ (total: number, surface: Record<string, any>) => total + (surface.jankPaints ?? 0),
511
+ 0,
512
+ )
513
+ console.log(
514
+ ` 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}`,
515
+ )
516
+ console.log(` series: independent worker clocks, no per-frame pairing`)
517
+ }
518
+ console.log(
519
+ ` painted frames: ${result.frames}${result.skippedFrames ? ` (+${result.skippedFrames} skipped idle ticks)` : ''}`,
520
+ )
521
+ console.log(
522
+ ` shell work avg: ${result.avgMs}ms (${fps} fps) · worst observed ${result.maxMs}ms`,
523
+ )
524
+ console.log(` shell p50/95/99:${result.p50} / ${result.p95} / ${result.p99} ms`)
525
+ if (jank) {
526
+ console.log(
527
+ ` shell jank: ${jank.shell?.jankFrames ?? 0}/${jank.shell?.sampledFrames ?? 0} (${jank.shell?.jankPct ?? 0}%) >${budgetMs}ms · max ${jank.shell?.maxMs ?? 0}ms`,
528
+ )
529
+ for (const surface of Array.isArray(jank.compositor) ? jank.compositor : []) {
530
+ console.log(
531
+ ` ${String(surface.surfaceId).padEnd(12)} ${surface.jankPaints}/${surface.sampledPaints} compositor paints (${surface.jankPct}%) >${budgetMs}ms · max ${surface.maxMs}ms`,
532
+ )
533
+ }
534
+ } else {
535
+ console.log(
536
+ ` jank: ${result.jankFrames} frames (${result.jankPct}%) >${budgetMs}ms`,
537
+ )
538
+ }
539
+ console.log(
540
+ ` avg per frame: overlay ${result.avgOverlayMs}ms · aux ${result.avgAuxMs}ms · layout ${result.avgLayoutMs}ms`,
541
+ )
542
+ if (production) {
543
+ const frames = production.frames ?? {}
544
+ console.log(``)
545
+ console.log(` compositor production (${budgetMs}ms budget):`)
546
+ console.log(
547
+ ` 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}%)`,
548
+ )
549
+ const phaseNames = [
550
+ ['javascript', 'javascript'],
551
+ ['layout', 'layout'],
552
+ ['paint', 'paint'],
553
+ ['prewarm', 'prewarm'],
554
+ ['texture upload', 'textureUpload'],
555
+ ['gpu submit', 'gpuSubmit'],
556
+ ['composite', 'composite'],
557
+ ['capture/readback', 'capture'],
558
+ ] as const
559
+ for (const [label, key] of phaseNames) {
560
+ const phase = production.phases?.[key]
561
+ if (!phase?.count || (!phase.max && !phase.avg)) continue
562
+ console.log(
563
+ ` ${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`,
564
+ )
565
+ }
566
+ const capture = production.captureBetweenFrames
567
+ if (capture?.count && capture.max > 0) {
568
+ console.log(
569
+ ` capture between p95 ${Number(capture.p95 ?? 0).toFixed(2)} / max ${Number(capture.max ?? 0).toFixed(2)} ms between compositor rAF callbacks`,
570
+ )
571
+ }
572
+ const worstProduction = Array.isArray(production.worstFrames)
573
+ ? production.worstFrames.slice(0, 5)
574
+ : []
575
+ if (worstProduction.length > 0) {
576
+ console.log(` worst frames:`)
577
+ for (const frame of worstProduction) {
578
+ const phases = frame.phases ?? {}
579
+ console.log(
580
+ ` ${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)}`,
581
+ )
582
+ }
583
+ }
584
+ }
585
+ const cadence = result.cadence
586
+ if (cadence && typeof cadence === 'object') {
587
+ console.log(``)
588
+ console.log(` cadence (frame delivery, ${cadence.vsyncTicks} vsync ticks):`)
589
+ console.log(
590
+ ` 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)` : ''}`,
591
+ )
592
+ console.log(
593
+ ` delivery lag: p50 ${cadence.deliveryLagP50} / p95 ${cadence.deliveryLagP95} / max ${cadence.deliveryLagMax} ms (vsync postMessage → shell receipt)`,
594
+ )
595
+ console.log(
596
+ ` paint interval: p50 ${cadence.paintIntervalP50} / p95 ${cadence.paintIntervalP95} / max ${cadence.paintIntervalMax} ms${cadence.idleBreaks ? ` · ${cadence.idleBreaks} idle breaks excluded` : ''}`,
597
+ )
598
+ }
599
+ const compositorCadence = result.compositorCadence
600
+ if (compositorCadence && typeof compositorCadence === 'object') {
601
+ console.log(
602
+ ` compositor rAF: p50 ${compositorCadence.p50} / p95 ${compositorCadence.p95} / max ${compositorCadence.max} ms · ${compositorCadence.gaps} gaps >1.5x`,
603
+ )
604
+ const printPhase = (label: string, summary: Record<string, any> | undefined) => {
605
+ if (!summary?.count) return
606
+ console.log(
607
+ ` ${label}: ${summary.count} samples · p50 ${summary.p50.toFixed(2)} / p95 ${summary.p95.toFixed(2)} / max ${summary.max.toFixed(2)} ms`,
608
+ )
609
+ }
610
+ printPhase('engine-empty rAF', compositorCadence.phases?.uninterruptedEngineEmpty)
611
+ printPhase('rAF with CanvasKit flush', compositorCadence.phases?.withCanvaskitFlush)
612
+ printPhase('rAF after CanvasKit flush', compositorCadence.phases?.afterCanvaskitFlush)
613
+ printPhase('CanvasKit submission', compositorCadence.canvaskitSubmission)
614
+ }
615
+ const surfaces = Array.isArray(result.auxSurfaces) ? result.auxSurfaces : []
616
+ const graphiteCaches = result.graphiteCaches
617
+ const decodedImageCache = result.decodedImageCache
618
+ if (graphiteCaches || decodedImageCache) {
619
+ const printCache = (
620
+ label: string,
621
+ cache: { bytes?: number; limitBytes?: number; purgeableBytes?: number } | null,
622
+ ) => {
623
+ if (typeof cache?.bytes !== 'number' || typeof cache.limitBytes !== 'number') {
624
+ return
625
+ }
626
+ const purgeable =
627
+ typeof cache.purgeableBytes === 'number'
628
+ ? ` · ${(cache.purgeableBytes / 1024 / 1024).toFixed(1)}MB purgeable`
629
+ : ''
630
+ console.log(
631
+ ` ${label.padEnd(29)} ${(cache.bytes / 1024 / 1024).toFixed(1)}MB/${(cache.limitBytes / 1024 / 1024).toFixed(1)}MB${purgeable}`,
632
+ )
633
+ }
634
+ console.log(``)
635
+ console.log(` device-wide caches:`)
636
+ printCache('Graphite context resources', graphiteCaches?.contextResources ?? null)
637
+ printCache('Graphite recorder resources', graphiteCaches?.recorderResources ?? null)
638
+ printCache('Graphite image provider', graphiteCaches?.imageProvider ?? null)
639
+ printCache('decoded image pixels', decodedImageCache ?? null)
640
+ if (graphiteCaches?.imageProvider) {
641
+ console.log(
642
+ ` ${''.padEnd(29)} ${graphiteCaches.imageProvider.entries} entries · ${graphiteCaches.imageProvider.uploads} uploads · ${graphiteCaches.imageProvider.evictions} evictions`,
643
+ )
644
+ }
645
+ if (decodedImageCache) {
646
+ console.log(
647
+ ` ${''.padEnd(29)} ${decodedImageCache.entries} entries/${decodedImageCache.maxEntries} max`,
648
+ )
649
+ }
650
+ }
651
+ const liveFrames = result.liveFrames
652
+ if (liveFrames && typeof liveFrames === 'object') {
653
+ const tenant = liveFrames.tenant
654
+ const compositorLive =
655
+ liveFrames.compositor && typeof liveFrames.compositor === 'object'
656
+ ? liveFrames.compositor
657
+ : null
658
+ console.log(``)
659
+ console.log(` live frames (GL / camera / video / WebGPU / maps):`)
660
+ if (tenant && typeof tenant === 'object') {
661
+ const presentation = tenant.presentation ?? {}
662
+ console.log(
663
+ ` tenant published ${presentation.publishes ?? 0} · skipped ${presentation.backpressureSkips ?? 0} (in-flight) · stale-healed ${presentation.staleHeals ?? 0} · density ${tenant.renderDensity ?? '?'}`,
664
+ )
665
+ } else {
666
+ console.log(` tenant stats unavailable (tenant did not answer)`)
667
+ }
668
+ if (compositorLive) {
669
+ console.log(
670
+ ` 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`,
671
+ )
672
+ const shared = compositorLive.sharedDeviceImages
673
+ if (shared && typeof shared === 'object') {
674
+ console.log(
675
+ ` shared device: ${shared.bitmapUploadRequests ?? 0} upload requests · ${shared.bitmapUploadsHeld ?? 0} held (motion) · ${shared.bitmapUploadBytes ?? 0} bytes · ${shared.bitmapCacheHits ?? 0} cache hits`,
676
+ )
677
+ }
678
+ }
679
+ }
680
+ if (surfaces.length > 0) {
681
+ console.log(``)
682
+ console.log(` surfaces (last 100 frames):`)
683
+ for (const s of surfaces) {
684
+ console.log(
685
+ ` ${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`,
686
+ )
687
+ }
688
+ }
689
+ // two profiles, two workers: the shell paints chrome/overlays, the
690
+ // compositor paints home + app:one + app:two. guest-app scroll work is all
691
+ // in the compositor one — reading the shell's raster counters for an app
692
+ // workload is how a live raster tier reads as dead.
693
+ printRenderProfile('shell worker', result.renderProfile)
694
+ printRenderProfile('compositor worker', result.compositorRenderProfile)
695
+ const worst = Array.isArray(result.worstFrames) ? result.worstFrames : []
696
+ if (worst.length > 0) {
697
+ console.log(``)
698
+ console.log(` worst observations (shell and compositor series are unpaired):`)
699
+ for (const frame of worst) {
700
+ if (frame.source === 'compositor') {
701
+ console.log(
702
+ ` ${String(frame.totalMs).padStart(7)}ms compositor ${frame.surfaceId} paint ${frame.paint} · layout ${frame.layoutMs} · render ${frame.renderMs} · flush ${frame.flushMs}`,
703
+ )
704
+ continue
705
+ }
706
+ const surfaceBits = (frame.auxSurfaces ?? [])
707
+ .map(
708
+ (s: Record<string, any>) =>
709
+ `${s.surfaceId} ${s.totalMs}ms (layout ${s.layoutMs}, render ${s.renderMs})`,
710
+ )
711
+ .join(' · ')
712
+ console.log(
713
+ ` ${String(frame.totalMs).padStart(7)}ms overlay ${frame.overlayMs} · aux ${frame.auxMs} · layout ${frame.layoutMs}${surfaceBits ? ` [${surfaceBits}]` : ''}`,
714
+ )
715
+ }
716
+ }
717
+ }
718
+
719
+ // core bridge-facing helpers moved to ./inspect/shared — this file now just
720
+ // imports them (see top-of-file imports).
721
+
722
+ async function waitForShellState(
723
+ bridge: WsBridge,
724
+ timeoutMs: number,
725
+ predicate: (state: Record<string, any> | null) => boolean,
726
+ ) {
727
+ const deadline = Date.now() + timeoutMs
728
+ let state = await getShellState(bridge, timeoutMs)
729
+ while (true) {
730
+ if (predicate(state)) return { settled: true, state }
731
+ if (Date.now() >= deadline) return { settled: false, state }
732
+ await sleep(16)
733
+ state = await getShellState(bridge)
734
+ }
735
+ }
736
+
737
+ async function getKeyboardState(bridge: WsBridge) {
738
+ // focusedInput is sourced from __sootsimTest.getFocusedNode(), which reads
739
+ // through the focus-keyboard-runtime (P2). previously this scanned
740
+ // test.queryAll({}) for `n.style._focused` — a flag nothing in the engine
741
+ // actually sets — so the cli always reported focusedInput: null, even
742
+ // when an input was focused. that silently misled the 2026-04-17 bluesky
743
+ // tap-routing investigation by ~30 minutes.
744
+ //
745
+ // phase + frame now also come from the runtime snapshot (same single
746
+ // source) so cli consumers see the same state as KeyboardAvoidingView
747
+ // and the keyboard-controller compat stub.
748
+ return bridge
749
+ .send({
750
+ type: 'evaluate',
751
+ code: `(async () => {
752
+ const kb = window.__sootsimKeyboard
753
+ const test = window.__sootsimTest
754
+ if (!kb) return { error: 'keyboard bridge not available' }
755
+ const layout = typeof kb.getLayout === 'function' ? kb.getLayout() : null
756
+ const secureTextEntry =
757
+ !!layout?.spec?.secureTextEntry && layout?.spec?.keyboardType !== 'visible-password'
758
+ const visible = kb.isVisible()
759
+ const mode = kb.getMode()
760
+ let focused = null
761
+ if (test && typeof test.getFocusedNode === 'function') {
762
+ try {
763
+ focused = await test.getFocusedNode()
764
+ } catch {}
765
+ }
766
+ let runtimeSnapshot = null
767
+ if (test && typeof test.getFocusKeyboardSnapshot === 'function') {
768
+ try {
769
+ runtimeSnapshot = await test.getFocusKeyboardSnapshot()
770
+ } catch {}
771
+ }
772
+ return {
773
+ visible,
774
+ mode,
775
+ layout,
776
+ hostedEditorFocused:
777
+ typeof kb.hasHostedEditorFocus === 'function' && kb.hasHostedEditorFocus(),
778
+ focusedInput: focused ? {
779
+ nodeId: focused.nodeId ?? null,
780
+ testID: focused.testID || null,
781
+ id: focused.id || null,
782
+ placeholder: focused.placeholder || null,
783
+ secureTextEntry,
784
+ text: secureTextEntry ? ${JSON.stringify(SECURE_TEXT_REDACTION)} : (focused.text || null),
785
+ } : null,
786
+ phase: runtimeSnapshot?.keyboard?.phase ?? null,
787
+ frame: runtimeSnapshot?.keyboard?.frame ?? null,
788
+ focusedRect: runtimeSnapshot?.focused?.rect ?? null,
789
+ }
790
+ })()`,
791
+ })
792
+ .then((state) => redactKeyboardStateForOutput(state as any)) as Promise<{
793
+ visible?: boolean
794
+ mode?: string
795
+ layout?: Record<string, unknown> | null
796
+ hostedEditorFocused?: boolean
797
+ focusedInput?: Record<string, unknown> | null
798
+ phase?: string | null
799
+ frame?: Record<string, number> | null
800
+ focusedRect?: Record<string, number> | null
801
+ error?: string
802
+ }>
803
+ }
804
+
805
+ function keyboardStateMatchesTarget(
806
+ state: Awaited<ReturnType<typeof getKeyboardState>>,
807
+ targetId: string | null,
808
+ secureTextEntry: boolean | null,
809
+ ) {
810
+ if (!state.visible) return false
811
+ const focused = state.focusedInput as
812
+ | { testID?: string | null; id?: string | null }
813
+ | null
814
+ | undefined
815
+ if (targetId && focused) {
816
+ const matched = focused.testID === targetId || focused.id === targetId
817
+ if (!matched) return false
818
+ }
819
+ if (
820
+ secureTextEntry !== null &&
821
+ isSecureKeyboardState(state as any) !== secureTextEntry
822
+ ) {
823
+ return false
824
+ }
825
+ return true
826
+ }
827
+
828
+ async function waitForKeyboardVisible(
829
+ bridge: WsBridge,
830
+ timeoutMs = 600,
831
+ opts: { targetId?: string | null; secureTextEntry?: boolean | null } = {},
832
+ ) {
833
+ const deadline = Date.now() + timeoutMs
834
+ while (Date.now() <= deadline) {
835
+ const state = await getKeyboardState(bridge)
836
+ if (
837
+ keyboardStateMatchesTarget(
838
+ state,
839
+ opts.targetId ?? null,
840
+ opts.secureTextEntry ?? null,
841
+ )
842
+ ) {
843
+ return state
844
+ }
845
+ await sleep(30)
846
+ }
847
+ return getKeyboardState(bridge)
848
+ }
849
+
850
+ /**
851
+ * keys for a CPU simulator. it has no keyboard bridge to probe and refuses the
852
+ * `keyboard` command, so its `perform` presses each character against
853
+ * whatever holds focus and fails the batch when nothing does.
854
+ */
855
+ async function performCloudKeys(
856
+ bridge: WsBridge,
857
+ steps: Array<{ type: 'type'; text: string } | { type: 'key'; key: string }>,
858
+ actionLabel: string,
859
+ ): Promise<void> {
860
+ const result: PerformResult = await bridge.send({ type: 'perform', steps })
861
+ if (result.ok) return
862
+ const failed = result.steps.find((step) => !step.ok)
863
+ console.error(
864
+ ` ${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.`,
865
+ )
866
+ rnxExit(1)
867
+ }
868
+
869
+ async function requireVisualKeyboard(bridge: WsBridge, actionLabel: string) {
870
+ const state = await getKeyboardState(bridge)
871
+ if (state.visible && (state.focusedInput || state.hostedEditorFocused)) return state
872
+ if (state.visible) {
873
+ console.error(
874
+ ` ${actionLabel} requires a focused editable control. the iOS keyboard is visible, but no input owns focus.`,
875
+ )
876
+ rnxExit(1)
877
+ }
878
+ console.error(
879
+ ` ${actionLabel} requires the iOS keyboard to be visible. focus an input first with rnx do tap-id/tap-text or rnx do type-into.`,
880
+ )
881
+ rnxExit(1)
882
+ }
883
+
884
+ async function runShellVisualCommand(
885
+ bridge: WsBridge,
886
+ action: 'appearance' | 'lock' | 'shake',
887
+ value?: string,
888
+ ) {
889
+ if (action === 'appearance') {
890
+ return bridge.send({
891
+ type: 'evaluate',
892
+ code: `(async () => {
893
+ const requested = ${JSON.stringify(value ?? 'toggle')}
894
+ // the engine owns toggle + auto resolution (settingsStore is the single
895
+ // source of truth). never infer the current scheme client-side — the old
896
+ // documentElement '#333333' sniff is never set in embedded / shell-chrome
897
+ // contexts, so 'toggle' read 'light' forever and stuck on the boot scheme.
898
+ window.postMessage({ type: 'contrast-action', action: 'set-appearance', value: requested }, '*')
899
+ // let the engine's message handler apply + publish window.__sootsimColorScheme
900
+ await new Promise((r) => setTimeout(r, 60))
901
+ const applied = window.__sootsimColorScheme?.resolved
902
+ ?? (window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ? 'dark' : 'light')
903
+ const setting = window.__sootsimColorScheme?.setting ?? requested
904
+ return { ok: true, requested, setting, applied }
905
+ })()`,
906
+ })
907
+ }
908
+
909
+ if (action === 'lock') {
910
+ return bridge.send({
911
+ type: 'evaluate',
912
+ code: `(async () => {
913
+ const toggleLock = window.SootSim?.bridges?.mainShell?.toggleLock
914
+ if (typeof toggleLock !== 'function') {
915
+ throw new Error('rnx mainShell.toggleLock bridge unavailable')
916
+ }
917
+ await toggleLock()
918
+ return { ok: true, action: 'lock' }
919
+ })()`,
920
+ })
921
+ }
922
+
923
+ return bridge.send({
924
+ type: 'evaluate',
925
+ code: `(async () => {
926
+ window.dispatchEvent(new CustomEvent('sootsim:shake'))
927
+ return { ok: true, action: 'shake' }
928
+ })()`,
929
+ })
930
+ }
931
+
932
+ function keyCodeToVisualKey(code: string) {
933
+ const direct: Record<string, string> = {
934
+ Enter: 'return',
935
+ NumpadEnter: 'return',
936
+ Backspace: 'delete',
937
+ Delete: 'delete',
938
+ Space: 'space',
939
+ ShiftLeft: 'shift',
940
+ ShiftRight: 'shift',
941
+ }
942
+ if (direct[code]) return direct[code]
943
+ const digit = code.match(/^Digit([0-9])$/)
944
+ if (digit) return digit[1]
945
+ const key = code.match(/^Key([A-Z])$/)
946
+ if (key) return key[1].toLowerCase()
947
+ return null
948
+ }
949
+
950
+ function getInspectCommandName(subcommand: string, directSubcommand: string | null) {
951
+ if (directSubcommand === subcommand) return `rnx ${subcommand}`
952
+ const group = getCliInspectVerbMeta(subcommand)?.group
953
+ return group ? `rnx ${group} ${subcommand}` : `rnx ${subcommand}`
954
+ }
955
+
956
+ function getInspectUsage(
957
+ subcommand: string,
958
+ tail: string,
959
+ directSubcommand: string | null,
960
+ ) {
961
+ return ` usage: ${getInspectCommandName(subcommand, directSubcommand)}${tail ? ` ${tail}` : ''}`
962
+ }
963
+
964
+ function normalizeRecordedText(value: unknown) {
965
+ if (typeof value !== 'string') return null
966
+ const text = value.replace(/\s+/g, ' ').trim()
967
+ return text ? text.slice(0, 80) : null
968
+ }
969
+
970
+ function normalizeRecordedId(...values: unknown[]) {
971
+ for (const value of values) {
972
+ if (typeof value !== 'string') continue
973
+ const id = value.trim()
974
+ if (id) return id
975
+ }
976
+ return null
977
+ }
978
+
979
+ async function recordInspectAction(
980
+ source: string,
981
+ step: Record<string, unknown>,
982
+ summary: string,
983
+ ) {
984
+ const result = rememberFlowCandidate({ source, step, summary })
985
+ // when a flow draft is active, make the pending-candidate model visible —
986
+ // otherwise a sequence of `do` actions silently overwrites each other and
987
+ // `maestro end` exports far fewer steps than the user expects (F13-4).
988
+ if (!result.active) return
989
+ if (result.replaced) {
990
+ console.error(
991
+ ` draft: replaced unkept action "${result.replaced.summary}" — ` +
992
+ '`maestro keep` commits one action at a time',
993
+ )
994
+ } else {
995
+ console.error(' draft: action pending — `rnx maestro keep` to commit')
996
+ }
997
+ }
998
+
999
+ function buildTapCandidateFromResult(
1000
+ x: number,
1001
+ y: number,
1002
+ result: any,
1003
+ ): { step: Record<string, unknown>; summary: string } | null {
1004
+ if (!result || result.hit === false) return null
1005
+
1006
+ const responderId = normalizeRecordedId(result.responderTestID, result.testID)
1007
+ if (responderId) {
1008
+ return {
1009
+ step: { tapOn: { id: responderId } },
1010
+ summary: `tap #${responderId}`,
1011
+ }
1012
+ }
1013
+
1014
+ const text = normalizeRecordedText(result.text)
1015
+ if (text) {
1016
+ return {
1017
+ step: { tapOn: text },
1018
+ summary: `tap "${text}"`,
1019
+ }
1020
+ }
1021
+
1022
+ return {
1023
+ step: { tapAtCoords: { x, y } },
1024
+ summary: `tap @${Math.round(x)},${Math.round(y)}`,
1025
+ }
1026
+ }
1027
+
1028
+ function buildTapCandidateFromNode(
1029
+ query: string,
1030
+ node: any,
1031
+ mode: 'text' | 'id',
1032
+ ): { step: Record<string, unknown>; summary: string } {
1033
+ const stableId = normalizeRecordedId(node?.testID, node?.id)
1034
+ if (stableId) {
1035
+ return {
1036
+ step: { tapOn: { id: stableId } },
1037
+ summary: `tap #${stableId}`,
1038
+ }
1039
+ }
1040
+
1041
+ if (mode === 'id') {
1042
+ return {
1043
+ step: { tapOn: { id: query } },
1044
+ summary: `tap #${query}`,
1045
+ }
1046
+ }
1047
+
1048
+ return {
1049
+ step: { tapOn: query },
1050
+ summary: `tap "${query}"`,
1051
+ }
1052
+ }
1053
+
1054
+ export async function runInspect(args: string[], opts: InspectOptions) {
1055
+ // strip a leading grouping verb — `get`, `do`, and (when forwarded from
1056
+ // runDebug) `debug`. these are organizational prefixes and shouldn't leak
1057
+ // into subcommand dispatch. e.g.:
1058
+ // rnx get tree → effectiveArgs = ['tree']
1059
+ // rnx do tap 100 200 → effectiveArgs = ['tap', '100', '200']
1060
+ // rnx debug state shell → effectiveArgs = ['state', 'shell']
1061
+ const verbPrefix =
1062
+ args[0] === 'get' || args[0] === 'do' || args[0] === 'debug' || args[0] === 'wait'
1063
+ ? args[0]
1064
+ : null
1065
+ const effectiveArgs = verbPrefix ? args.slice(1) : args
1066
+
1067
+ const parsed = parseBridgeCliArgs(effectiveArgs, {
1068
+ port: opts.port,
1069
+ commandTimeoutMs: opts.timeoutMs,
1070
+ stripBooleanFlags: [
1071
+ '--verbose',
1072
+ '-v',
1073
+ '--help',
1074
+ '-h',
1075
+ '--clear-state',
1076
+ '--json',
1077
+ '--all',
1078
+ '--watch',
1079
+ '-w',
1080
+ '--strict',
1081
+ '--no-wait',
1082
+ // `find --verbose` / `find --dump` emits full node JSON per result.
1083
+ '--dump',
1084
+ // `network` command flags — boolean toggles
1085
+ '--failed',
1086
+ '--slow',
1087
+ '--tail',
1088
+ '-f',
1089
+ // `find --interactive-targets` (alias `find --actions`) — ranked tappable list
1090
+ '--interactive-targets',
1091
+ '--actions',
1092
+ // `logs` command: include engine-internal [sootsim] debug messages
1093
+ '--internal',
1094
+ // `describe` boolean flags — without these they leak into `positional`
1095
+ // and get mistaken for a text filter (`describe --compact` then matches
1096
+ // nothing). value-taking describe flags live in stripValueFlags below.
1097
+ '--compact',
1098
+ '--no-xy',
1099
+ ],
1100
+ stripValueFlags: [
1101
+ '--output',
1102
+ '--nth',
1103
+ '--index',
1104
+ '--testid',
1105
+ '--test-id',
1106
+ '--text',
1107
+ '--max-ms',
1108
+ // `network` / `logs` command flags that take a value
1109
+ '--filter',
1110
+ '--limit',
1111
+ '--level',
1112
+ // `network --slow` slow-threshold (ms). default 1000.
1113
+ '--threshold',
1114
+ // `wait event` filters
1115
+ '--equals',
1116
+ '--since',
1117
+ // `describe` value flags — narrowing selectors
1118
+ '--testid-like',
1119
+ '--only',
1120
+ '--subtree',
1121
+ ],
1122
+ })
1123
+ const positional = parsed.positional
1124
+ const subcommand = positional[0]
1125
+ // used by inspectCommand() below to format usage/error strings with the
1126
+ // same prefix the user actually typed. null when they typed a top-level
1127
+ // command, and inspectCommand asks the registry for the group instead.
1128
+ const invocationPrefix: 'get' | 'do' | 'debug' | 'wait' | null = verbPrefix
1129
+ const isTopLevel =
1130
+ typeof effectiveArgs[0] === 'string' &&
1131
+ TOP_LEVEL_RUNTIME_COMMANDS.has(effectiveArgs[0])
1132
+ const topLevelCommand = isTopLevel ? effectiveArgs[0] : null
1133
+ // a usage string has to name something the user can actually type. the
1134
+ // registry knows which group each verb belongs to, so an unprefixed
1135
+ // invocation still renders `rnx get tree` rather than a bare verb.
1136
+ const inspectCommand = (name: string) => {
1137
+ if (opts.internalPerfCommand && name === 'perf') {
1138
+ return `rnx perf ${opts.internalPerfCommand}`
1139
+ }
1140
+ if (isTopLevel && name === effectiveArgs[0]) return `rnx ${name}`
1141
+ const group = invocationPrefix ?? getCliInspectVerbMeta(name)?.group
1142
+ return group ? `rnx ${group} ${name}` : `rnx ${name}`
1143
+ }
1144
+ const inspectUsage = (name: string, tail: string) =>
1145
+ ` usage: ${inspectCommand(name)}${tail ? ` ${tail}` : ''}`
1146
+
1147
+ if (!subcommand || args.includes('--help') || args.includes('-h')) {
1148
+ // all help output comes from the registry in packages/rnx-skills
1149
+ // so `rnx do --help`, `rnx get --help`, the website docs, and
1150
+ // the Contrast skill markdown stay in lock-step. a drift check in
1151
+ // test/sootsimCliRegistry.test.ts fails CI if a verb lands without a
1152
+ // matching registry entry.
1153
+ const docContext = {
1154
+ bridgePort: DEFAULT_SOOTSIM_BRIDGE_PORT,
1155
+ defaultShellUrl: DEFAULT_SOOTSIM_SHELL_URL,
1156
+ }
1157
+
1158
+ // grouping-verb entry — clusters by subgroup (targeting, text input,
1159
+ // gestures, lifecycle, ...) so related verbs read as a visual family.
1160
+ if (
1161
+ invocationPrefix === 'do' ||
1162
+ invocationPrefix === 'get' ||
1163
+ invocationPrefix === 'debug' ||
1164
+ invocationPrefix === 'wait'
1165
+ ) {
1166
+ const groupHelp = renderCliGroupHelp(invocationPrefix, docContext)
1167
+ if (groupHelp) {
1168
+ console.log(`${groupHelp}\n`)
1169
+ rnxExit(0)
1170
+ }
1171
+ }
1172
+
1173
+ if (topLevelCommand === 'shell') {
1174
+ const shellHelp = renderCliCommandHelp('shell', docContext)
1175
+ if (shellHelp) {
1176
+ console.log(`${shellHelp}\n`)
1177
+ rnxExit(0)
1178
+ }
1179
+ }
1180
+
1181
+ // catch-all help — every group's listing stitched together.
1182
+ const groupSections = ['do', 'get', 'debug', 'wait']
1183
+ .map((g) => renderCliGroupHelp(g, docContext))
1184
+ .filter((s): s is string => s != null)
1185
+ .join('\n\n')
1186
+ console.log(`${groupSections}\n`)
1187
+ rnxExit(0)
1188
+ }
1189
+
1190
+ const wsPort = parsed.wsPort
1191
+ const simId = parsed.simId
1192
+ const simIdSource = parsed.simIdSource
1193
+ const commandTimeoutMs = parsed.commandTimeoutMs
1194
+
1195
+ if (invocationPrefix === 'get' && subcommand === 'diagnosis') {
1196
+ const diagnosisIndex = effectiveArgs.indexOf('diagnosis')
1197
+ const diagnosisArgs =
1198
+ diagnosisIndex >= 0
1199
+ ? [
1200
+ ...effectiveArgs.slice(0, diagnosisIndex),
1201
+ ...effectiveArgs.slice(diagnosisIndex + 1),
1202
+ ]
1203
+ : effectiveArgs
1204
+ const { runDiagnose } = await import('./diagnose')
1205
+ const code = await runDiagnose(['recent', ...diagnosisArgs], opts)
1206
+ if (code !== 0) rnxExit(code)
1207
+ return
1208
+ }
1209
+
1210
+ if (invocationPrefix === 'do' && subcommand === 'scan') {
1211
+ const { runCamera } = await import('./camera')
1212
+ // runCamera returns a code instead of exiting, so `do scan` has to carry
1213
+ // it out. dropping it made a failed scan exit 0.
1214
+ const code = await runCamera(effectiveArgs, { port: opts.port })
1215
+ if (code !== 0) rnxExit(code)
1216
+ return
1217
+ }
1218
+
1219
+ // `rnx list --drivers` — pure local registry view, no bridge needed.
1220
+ // short-circuit here so users can discover drivers even when no rnx
1221
+ // session is running.
1222
+ if (
1223
+ subcommand === 'list' &&
1224
+ effectiveArgs.some((a) => a === '--drivers' || a === '-D')
1225
+ ) {
1226
+ const { buildDriverListRows } = await import('../drivers')
1227
+ const rows = buildDriverListRows()
1228
+ console.log(` available drivers (${rows.length}):\n`)
1229
+ const idWidth = Math.max(...rows.map((r) => r.id.length), 6)
1230
+ const kindWidth = Math.max(...rows.map((r) => r.kind.length), 4)
1231
+ for (const row of rows) {
1232
+ const status = row.available ? '✓' : '✗'
1233
+ const idCol = row.id.padEnd(idWidth)
1234
+ const kindCol = row.kind.padEnd(kindWidth)
1235
+ console.log(` ${status} ${idCol} ${kindCol} ${row.description}`)
1236
+ if (row.available && row.detail) {
1237
+ console.log(` ${row.detail}`)
1238
+ } else if (!row.available && row.reason) {
1239
+ console.log(` unavailable: ${row.reason}`)
1240
+ }
1241
+ }
1242
+ return
1243
+ }
1244
+
1245
+ const bridge = (opts.createBridge ?? createBridgeFromParsed)(parsed)
1246
+ const noticeScope = simId || 'default'
1247
+ const consoleSummarySkip = new Set([
1248
+ 'errors',
1249
+ 'warnings',
1250
+ 'requests',
1251
+ 'js',
1252
+ 'reload',
1253
+ 'globals',
1254
+ 'perf',
1255
+ 'storage-clear',
1256
+ // bridge-level commands — they don't touch a specific sim, so skip the
1257
+ // per-sim console/request probe (which would otherwise claim a lease
1258
+ // and show "call · evaluated page" in the sim overlay).
1259
+ 'list',
1260
+ 'wait',
1261
+ 'sleep',
1262
+ ])
1263
+
1264
+ // failed-request dumps used to print the entire response body verbatim.
1265
+ // a backend 500 ships a ~1.5KB `<!DOCTYPE html>…` error page, repeated
1266
+ // per failed request, which buries the actual step result and makes
1267
+ // flow/maestro output unparseable (QA F20-3). collapse an HTML body to a
1268
+ // one-line summary and hard-cap everything else.
1269
+ const RESPONSE_BODY_MAX = 200
1270
+ function formatResponseBodyForLog(body: string): string {
1271
+ const flat = body.replace(/\s+/g, ' ').trim()
1272
+ if (!flat) return ''
1273
+ const looksHtml = /^<(!doctype html|html|\?xml)|<body[\s>]/i.test(flat)
1274
+ if (looksHtml) {
1275
+ const title = /<title[^>]*>([^<]+)<\/title>/i.exec(body)?.[1]?.trim()
1276
+ const firstText = /<body[^>]*>([\s\S]*?)<\//i
1277
+ .exec(body)?.[1]
1278
+ ?.replace(/<[^>]+>/g, ' ')
1279
+ .replace(/\s+/g, ' ')
1280
+ .trim()
1281
+ .slice(0, 80)
1282
+ const gist = title || firstText || 'html error page'
1283
+ return `<html ${body.length}B> "${gist}" (body elided — add --json for the full payload)`
1284
+ }
1285
+ if (flat.length <= RESPONSE_BODY_MAX) return flat
1286
+ return `${flat.slice(0, RESPONSE_BODY_MAX)}… (+${flat.length - RESPONSE_BODY_MAX} more bytes)`
1287
+ }
1288
+
1289
+ function formatRequestMessage(entry: SootSimRequestEntry) {
1290
+ const target = entry.displayUrl || entry.url
1291
+ if (entry.status != null) {
1292
+ return `${entry.method} ${target} -> ${entry.status}${entry.statusText ? ` ${entry.statusText}` : ''}`
1293
+ }
1294
+ if (entry.error) {
1295
+ return `${entry.method} ${target} -> ${entry.error}`
1296
+ }
1297
+ return `${entry.method} ${target}`
1298
+ }
1299
+
1300
+ // batched post-command probe: one bridge roundtrip for both console + request
1301
+ // counts. used by the default post-command notice loop so non-skip commands
1302
+ // don't pay 2 separate roundtrips. also usable by subcommands (e.g. describe)
1303
+ // that want to fold the probe into their own eval.
1304
+ type SummaryCounts = {
1305
+ console: { errors: number; warnings: number; total: number } | null
1306
+ requests: { failed: number; total: number } | null
1307
+ }
1308
+ // after a write command briefly poll in the sim until layout is stable.
1309
+ // agents get a longer budget (400ms) to catch short transitions;
1310
+ // interactive users get 200ms to stay snappy. logs on timeout so it's
1311
+ // obvious when the next CLI call may see mid-animation state.
1312
+ async function autoSettleAfterWrite(b: WsBridge): Promise<void> {
1313
+ // a cloud sim runs no browser page, so the idle probe's evaluate never
1314
+ // lands there. without this it settled nothing and the next read handed
1315
+ // out coordinates a sheet was still animating away from, which is how a
1316
+ // dialog button tap missed and the scrim under it answered the press.
1317
+ if (b.plane === 'cloud') {
1318
+ try {
1319
+ // a cloud settle drains the whole transition in the sim rather than
1320
+ // polling from here, so it gets a transport deadline, not the browser
1321
+ // path's poll cap: a full screen transition is seconds of cpu render.
1322
+ await b.send({ type: 'settle' }, { timeoutMs: 15_000 })
1323
+ } catch (error) {
1324
+ // the write itself already landed, so this never fails the command.
1325
+ // it does have to be said out loud: a swallowed settle looks exactly
1326
+ // like a settled sim to the next read, and that read then hands out
1327
+ // coordinates the screen is still animating away from.
1328
+ process.stderr.write(
1329
+ ` ⚠ auto-settle failed (${error instanceof Error ? error.message : String(error)}) — next command may see mid-animation state. use \`rnx do settle\` to retry.\n`,
1330
+ )
1331
+ }
1332
+ return
1333
+ }
1334
+ // budget is a CAP, not a fixed wait: waitForSootsimIdle drains any
1335
+ // screen transition then returns the instant layout is stable. longer
1336
+ // transitions should use an explicit `do settle` or `wait idle`.
1337
+ const budgetMs = isAgentEnv() ? 400 : 200
1338
+ try {
1339
+ const { settled, elapsed } = await waitForSootsimIdle({
1340
+ bridge: b,
1341
+ maxMs: budgetMs,
1342
+ pollMs: 32,
1343
+ stablePolls: 2,
1344
+ })
1345
+ if (!settled) {
1346
+ process.stderr.write(
1347
+ ` ⚠ auto-wait timed out after ${elapsed ?? budgetMs}ms — next command may see mid-animation state. use \`rnx do settle\` for a longer wait.\n`,
1348
+ )
1349
+ }
1350
+ } catch {
1351
+ // best-effort — never fail the command because of a post-wait probe.
1352
+ }
1353
+ }
1354
+
1355
+ function printTapFailure(label: string, outcome: { attempts: number; result: any }) {
1356
+ if (outcome.result?.reason === 'offscreen') {
1357
+ const { x, y, screen } = outcome.result
1358
+ console.error(
1359
+ ` tap failed: ${label} resolved to (${Math.round(x)},${Math.round(y)}), outside the ${screen?.width}x${screen?.height} screen`,
1360
+ )
1361
+ console.error(
1362
+ ' the node exists but is scrolled out of view — scroll it on-screen first (rnx do scroll / swipe), then tap.',
1363
+ )
1364
+ return
1365
+ }
1366
+ if (outcome.result?.reason === 'target-covered') {
1367
+ const covering = outcome.result.coordinateTarget
1368
+ const name =
1369
+ typeof covering?.testID === 'string' && covering.testID
1370
+ ? `#${covering.testID}`
1371
+ : covering?.type
1372
+ ? `<${covering.type}>`
1373
+ : 'another node'
1374
+ console.error(` tap failed: ${label} is covered by ${name}`)
1375
+ return
1376
+ }
1377
+ console.error(
1378
+ ` tap failed: ${label} stayed visible but did not receive a hittable press after ${outcome.attempts} attempt${outcome.attempts === 1 ? '' : 's'}`,
1379
+ )
1380
+ if (outcome.result) {
1381
+ console.error(` last result: ${JSON.stringify(outcome.result)}`)
1382
+ }
1383
+ }
1384
+
1385
+ async function fetchSummaryCounts(): Promise<SummaryCounts> {
1386
+ try {
1387
+ const raw = await bridge.send({
1388
+ type: 'evaluate',
1389
+ // console count merges the ws-bridge buffer + observability store (see
1390
+ // MERGED_CONSOLE_COUNT_EVAL) so the proactive notice agrees with what
1391
+ // `get errors` returns for forwarded render-worker failures.
1392
+ code: `(async () => ({
1393
+ console: ${MERGED_CONSOLE_COUNT_EVAL},
1394
+ requests: (await window.__sootsimTest?.getRequestCounts?.()) || null,
1395
+ }))()`,
1396
+ })
1397
+ return (raw as SummaryCounts) || { console: null, requests: null }
1398
+ } catch {
1399
+ return { console: null, requests: null }
1400
+ }
1401
+ }
1402
+
1403
+ async function printRequestSummary(
1404
+ opts: { includeTail?: boolean; counts?: SummaryCounts['requests'] } = {},
1405
+ ) {
1406
+ const counts =
1407
+ opts.counts !== undefined
1408
+ ? opts.counts
1409
+ : await callTestBridge<{ failed?: number; total?: number }>(
1410
+ bridge,
1411
+ 'getRequestCounts',
1412
+ )
1413
+ if (!counts || typeof counts !== 'object') return
1414
+
1415
+ const failed = Math.max(0, Number(counts.failed) || 0)
1416
+ if (failed === 0) return
1417
+ if (
1418
+ !opts.includeTail &&
1419
+ !shouldPrintInspectNotice('requests', noticeScope, String(failed))
1420
+ ) {
1421
+ return
1422
+ }
1423
+
1424
+ console.log(`\n network: ${failed} failed request${failed === 1 ? '' : 's'}`)
1425
+ console.log(` inspect: ${inspectCommand('requests')} 5`)
1426
+
1427
+ if (!opts.includeTail) return
1428
+
1429
+ const recentFailed = await callTestBridge<SootSimRequestEntry[]>(
1430
+ bridge,
1431
+ 'getFailedRequests',
1432
+ 5,
1433
+ )
1434
+ if (!Array.isArray(recentFailed) || recentFailed.length === 0) return
1435
+
1436
+ console.log('\n recent failed requests:\n')
1437
+ for (const entry of recentFailed) {
1438
+ const time = formatLogTimestamp(entry.timestamp)
1439
+ console.log(` [${time}] ${formatRequestMessage(entry)}`)
1440
+ if (entry.responseBody) {
1441
+ console.log(` ${formatResponseBodyForLog(entry.responseBody)}`)
1442
+ } else if (entry.error) {
1443
+ console.log(` ${entry.error}`)
1444
+ }
1445
+ }
1446
+ }
1447
+
1448
+ // proactive note when a blocking native-UI surface (Alert / ActionSheet) was
1449
+ // open at the moment a write command ran. these live in the shell worker, so
1450
+ // `describe` (tenant tree) can't see them — but they swallow every tap/drag/
1451
+ // type meant for the app underneath, so a `do` that "did nothing" is usually a
1452
+ // modal eating the input. fed from a pre-action capture so the note still
1453
+ // fires when the action itself dismissed the modal (e.g. a tap on a button).
1454
+ function printNativeUISummary(open: OpenNativeUISurface[]) {
1455
+ for (const ui of open) {
1456
+ const title = ui.title ? ` (“${ui.title}”)` : ''
1457
+ console.error(
1458
+ `\n note: ${ui.label} is open${title} — taps/drags hit it, not the app`,
1459
+ )
1460
+ }
1461
+ }
1462
+
1463
+ async function printConsoleSummary(
1464
+ opts: { includeTail?: boolean; counts?: SummaryCounts['console'] } = {},
1465
+ ) {
1466
+ const counts =
1467
+ opts.counts !== undefined
1468
+ ? opts.counts
1469
+ : await bridge.send({
1470
+ type: 'evaluate',
1471
+ code: MERGED_CONSOLE_COUNT_EVAL,
1472
+ })
1473
+ if (!counts || typeof counts !== 'object') return
1474
+
1475
+ const c = counts as { errors?: number; warnings?: number }
1476
+ const errors = Math.max(0, Number(c.errors) || 0)
1477
+ const warnings = Math.max(0, Number(c.warnings) || 0)
1478
+ if (errors === 0 && warnings === 0) return
1479
+ if (
1480
+ !opts.includeTail &&
1481
+ !shouldPrintInspectNotice('console', noticeScope, `${errors}:${warnings}`)
1482
+ ) {
1483
+ return
1484
+ }
1485
+
1486
+ const parts = []
1487
+ if (errors > 0) parts.push(`${errors} console error${errors === 1 ? '' : 's'}`)
1488
+ if (warnings > 0)
1489
+ parts.push(`${warnings} console warning${warnings === 1 ? '' : 's'}`)
1490
+ console.error(`\n console: ${parts.join(', ')}`)
1491
+ console.error(` inspect: ${inspectCommand('errors')} 5`)
1492
+ if (warnings > 0) {
1493
+ console.error(` inspect: ${inspectCommand('warnings')} 5`)
1494
+ }
1495
+
1496
+ if (!opts.includeTail || errors === 0) return
1497
+
1498
+ // merged tail (ws-bridge buffer + observability) so a render-worker-only
1499
+ // error is shown, not just dropped to "N errors" with an empty tail.
1500
+ const recentErrors = await inspectErrors(bridge, 5)
1501
+ if (!Array.isArray(recentErrors) || recentErrors.length === 0) return
1502
+
1503
+ console.error('\n recent console errors:\n')
1504
+ for (const entry of recentErrors) {
1505
+ const time = formatLogTimestamp(entry.timestamp)
1506
+ const msg = Array.isArray(entry.args)
1507
+ ? entry.args
1508
+ .map((value: any) =>
1509
+ typeof value === 'object' ? JSON.stringify(value) : String(value),
1510
+ )
1511
+ .join(' ')
1512
+ : String(entry)
1513
+ console.error(` [${time}] ${msg}`)
1514
+ }
1515
+ }
1516
+
1517
+ // cursor footer — replaces the old "console: N errors" line with a delta
1518
+ // summary of every timeline kind since this CLI identity's previous call.
1519
+ // empty → no output (clean), so commands stay quiet when nothing has
1520
+ // happened. priority-ordered kinds keep the most important signals
1521
+ // (errors, toasts, screens) leftmost in the one-liner.
1522
+ const CURSOR_FOOTER_ORDER: readonly string[] = [
1523
+ 'console',
1524
+ 'fetch',
1525
+ 'toast',
1526
+ 'alert',
1527
+ 'notification',
1528
+ 'screen',
1529
+ 'app-launch',
1530
+ 'keyboard',
1531
+ 'route',
1532
+ 'actionsheet',
1533
+ 'picker',
1534
+ 'shell',
1535
+ 'scroll',
1536
+ 'gesture',
1537
+ 'text-input',
1538
+ 'animation',
1539
+ 'reanimated',
1540
+ ]
1541
+ async function printCursorFooter(b: WsBridge): Promise<void> {
1542
+ const cliKey = getCliIdentityKey()
1543
+ // single eval roundtrip: summary buckets by kind plus a console
1544
+ // level breakdown (errors vs warnings). agents care about that
1545
+ // distinction enough that the footer should show it directly. one
1546
+ // call to keep the post-command overhead at ~1 bridge hop.
1547
+ type FooterPayload = {
1548
+ summary: { total: number; byKind: Record<string, number>; lastAt: number | null }
1549
+ consoleSplit: { error: number; warn: number } | null
1550
+ } | null
1551
+ let payload: FooterPayload = null
1552
+ try {
1553
+ payload = await evalInBridge<FooterPayload>(
1554
+ b,
1555
+ `(() => {
1556
+ const tl = window.SootSim && window.SootSim.bridges && window.SootSim.bridges.timeline
1557
+ if (!tl || typeof tl.summary !== 'function') return null
1558
+ const cursorKey = ${JSON.stringify(cliKey)}
1559
+ const summary = tl.summary({ sinceCursor: cursorKey })
1560
+ let consoleSplit = null
1561
+ if (summary && summary.byKind && summary.byKind.console) {
1562
+ const events = tl.recent({ sinceCursor: cursorKey, kinds: 'console', limit: 100000 }).events
1563
+ consoleSplit = { error: 0, warn: 0 }
1564
+ for (const ev of events) {
1565
+ const lvl = ev && ev.data && ev.data.level
1566
+ if (lvl === 'error') consoleSplit.error++
1567
+ else if (lvl === 'warn') consoleSplit.warn++
1568
+ }
1569
+ }
1570
+ return summary ? { summary, consoleSplit } : null
1571
+ })()`,
1572
+ )
1573
+ } catch {
1574
+ return
1575
+ }
1576
+ if (!payload || !payload.summary || !payload.summary.total) return
1577
+ const byKind = payload.summary.byKind ?? {}
1578
+ const parts: string[] = []
1579
+ const seen = new Set<string>()
1580
+ for (const kind of CURSOR_FOOTER_ORDER) {
1581
+ const n = byKind[kind]
1582
+ if (!n) continue
1583
+ seen.add(kind)
1584
+ if (kind === 'console' && payload.consoleSplit) {
1585
+ const { error, warn } = payload.consoleSplit
1586
+ if (error > 0) parts.push(`${error} error${error === 1 ? '' : 's'}`)
1587
+ if (warn > 0) parts.push(`${warn} warning${warn === 1 ? '' : 's'}`)
1588
+ } else {
1589
+ parts.push(`${n} ${kind}${n === 1 ? '' : 's'}`)
1590
+ }
1591
+ }
1592
+ for (const [kind, n] of Object.entries(byKind)) {
1593
+ if (!seen.has(kind) && n) parts.push(`${n} ${kind}${n === 1 ? '' : 's'}`)
1594
+ }
1595
+ if (parts.length === 0) return
1596
+ console.error(`\n since last: ${parts.join(' · ')} — rnx what-happened`)
1597
+ // advance cursor so the next CLI call only sees *new* events.
1598
+ if (payload.summary.lastAt) {
1599
+ try {
1600
+ await callInBridge(
1601
+ b,
1602
+ 'SootSim.bridges.timeline.cursorAdvance',
1603
+ cliKey,
1604
+ payload.summary.lastAt,
1605
+ )
1606
+ } catch {
1607
+ // best-effort — never fail the command because the cursor
1608
+ // couldn't be advanced.
1609
+ }
1610
+ }
1611
+ }
1612
+
1613
+ // warn if target sim is hidden — animations and rAF are throttled,
1614
+ // coordinates are wrong, launch transitions never complete.
1615
+ // only check for commands that interact with the canvas.
1616
+ const writeCommands = new Set([
1617
+ 'tap',
1618
+ 'double-tap',
1619
+ 'tap-text',
1620
+ 'tap-id',
1621
+ 'type',
1622
+ 'type-into',
1623
+ 'key',
1624
+ 'key-sequence',
1625
+ 'keycode',
1626
+ 'drag',
1627
+ 'swipe',
1628
+ 'long-press',
1629
+ 'touch',
1630
+ 'gesture',
1631
+ 'pinch',
1632
+ 'scroll',
1633
+ 'shell',
1634
+ 'storage-clear',
1635
+ ])
1636
+ const screenTransitionAwareCommands = new Set([
1637
+ 'a11y',
1638
+ 'capture',
1639
+ 'count',
1640
+ 'double-tap',
1641
+ 'drag',
1642
+ 'find',
1643
+ 'gesture',
1644
+ 'layout',
1645
+ 'long-press',
1646
+ 'node',
1647
+ 'pinch',
1648
+ 'sample-color',
1649
+ 'scroll',
1650
+ 'screenshot',
1651
+ 'swipe',
1652
+ 'tap',
1653
+ 'tap-id',
1654
+ 'tap-text',
1655
+ 'touch',
1656
+ 'tree',
1657
+ 'type-into',
1658
+ ])
1659
+ const verboseTransitions =
1660
+ (args.includes('--verbose') || args.includes('-v')) && !args.includes('--json')
1661
+ if (invocationPrefix === 'do' && subcommand === 'shell') {
1662
+ console.error(' `rnx do shell` was removed. use `rnx shell ...` instead.')
1663
+ rnxExit(1)
1664
+ }
1665
+ if (writeCommands.has(subcommand)) {
1666
+ await checkSimHealth(bridge)
1667
+ }
1668
+ if (screenTransitionAwareCommands.has(subcommand)) {
1669
+ await maybeWaitForStartedScreenTransitions(bridge, {
1670
+ verbose: verboseTransitions,
1671
+ })
1672
+ }
1673
+
1674
+ try {
1675
+ // capture any blocking native-UI modal BEFORE running a write command, so
1676
+ // the trailing note reflects the state when the action was attempted even
1677
+ // if the action itself dismissed the modal (e.g. a tap that hit a button).
1678
+ const nativeUIBeforeWrite = writeCommands.has(subcommand)
1679
+ ? await detectOpenNativeUI(bridge)
1680
+ : []
1681
+ switch (subcommand) {
1682
+ case 'list': {
1683
+ // name the bridge world so dev-bridge vs daemon is never ambiguous
1684
+ // (the two can coexist; `list` used to silently flap between them).
1685
+ printBridgeWorldNotice(parsed.wsPort)
1686
+ await runListSubcommand({ bridge, simId, args: effectiveArgs })
1687
+ break
1688
+ }
1689
+
1690
+ case 'tree': {
1691
+ await runTreeSubcommand({ bridge, args: effectiveArgs, positional })
1692
+ break
1693
+ }
1694
+
1695
+ case 'a11y': {
1696
+ const nodes = await inspectAccessibilityTree(bridge)
1697
+ if (!Array.isArray(nodes) || nodes.length === 0) {
1698
+ console.log(' no accessible nodes found')
1699
+ break
1700
+ }
1701
+ if (args.includes('--json')) {
1702
+ console.log(JSON.stringify(nodes, null, 2))
1703
+ } else {
1704
+ console.log(` accessibility tree (${nodes.length} nodes):\n`)
1705
+ for (const n of nodes) {
1706
+ const parts: string[] = []
1707
+ parts.push(`[${n.role}]`)
1708
+ if (n.label) {
1709
+ const label = n.label.length > 50 ? n.label.slice(0, 47) + '...' : n.label
1710
+ parts.push(`"${label}"`)
1711
+ }
1712
+ if (n.hint) parts.push(`(hint: "${n.hint}")`)
1713
+ if (n.testID) parts.push(`#${n.testID}`)
1714
+ if (n.state) {
1715
+ const flags: string[] = []
1716
+ if (n.state.disabled) flags.push('disabled')
1717
+ if (n.state.selected) flags.push('selected')
1718
+ if (n.state.checked === true) flags.push('checked')
1719
+ if (n.state.checked === 'mixed') flags.push('mixed')
1720
+ if (n.state.busy) flags.push('busy')
1721
+ if (n.state.expanded === true) flags.push('expanded')
1722
+ if (n.state.expanded === false) flags.push('collapsed')
1723
+ if (flags.length) parts.push(`{${flags.join(', ')}}`)
1724
+ }
1725
+ if (n.position) parts.push(`@(${n.position.x},${n.position.y})`)
1726
+ if (n.size) parts.push(`${n.size.w}x${n.size.h}`)
1727
+ console.log(' ' + parts.join(' '))
1728
+ }
1729
+ }
1730
+ break
1731
+ }
1732
+
1733
+ case 'find': {
1734
+ await runFindSubcommand({ bridge, args, effectiveArgs, positional, inspectUsage })
1735
+ break
1736
+ }
1737
+
1738
+ case 'count': {
1739
+ await runCountSubcommand(bridge, { args: effectiveArgs })
1740
+ break
1741
+ }
1742
+
1743
+ case 'keyboard': {
1744
+ await runKeyboardSubcommand(bridge, {
1745
+ json: args.includes('--json'),
1746
+ })
1747
+ break
1748
+ }
1749
+
1750
+ case 'screens': {
1751
+ await runScreensSubcommand(bridge, {
1752
+ json: args.includes('--json'),
1753
+ })
1754
+ break
1755
+ }
1756
+
1757
+ case 'memory': {
1758
+ await runMemorySubcommand(bridge, { args: effectiveArgs })
1759
+ break
1760
+ }
1761
+
1762
+ case 'wait': {
1763
+ await runWaitSubcommand({
1764
+ wsPort,
1765
+ commandTimeoutMs,
1766
+ simId,
1767
+ simIdSource,
1768
+ positional,
1769
+ })
1770
+ break
1771
+ }
1772
+
1773
+ case 'sleep': {
1774
+ await runSleepSubcommand({ positional, inspectUsage })
1775
+ break
1776
+ }
1777
+
1778
+ case 'settle': {
1779
+ await runSettleSubcommand({ bridge, args, positional })
1780
+ break
1781
+ }
1782
+
1783
+ case 'ready': {
1784
+ await runWaitReadySubcommand({ bridge, args })
1785
+ break
1786
+ }
1787
+
1788
+ case 'idle': {
1789
+ await runWaitIdleSubcommand({ bridge, args, positional })
1790
+ break
1791
+ }
1792
+
1793
+ case 'selector': {
1794
+ await runWaitSelectorSubcommand({ bridge, args, positional, inspectUsage })
1795
+ break
1796
+ }
1797
+
1798
+ case 'event': {
1799
+ // rnx wait event <kind> — timeline-backed wait. delegates
1800
+ // matcher + polling to runWaitEventSubcommand so the same
1801
+ // logic is reachable from `rnx wait event` regardless of
1802
+ // verb-group prefix (and the same shape is testable on its
1803
+ // own).
1804
+ await runWaitEventSubcommand({ bridge, args, positional, inspectUsage })
1805
+ break
1806
+ }
1807
+
1808
+ case 'layout': {
1809
+ const id = positional[1]
1810
+ // bare `rnx get layout` (no id) measures visible elements on the
1811
+ // screen by bounding box. --styling opts into box-model/style detail via
1812
+ // the shared get-layout kernel the agent's app_get_layout also uses.
1813
+ // passing an id keeps the single-node {x, y, width, height} lookup.
1814
+ if (!id) {
1815
+ await runGetLayoutSubcommand({ bridge, args: effectiveArgs })
1816
+ break
1817
+ }
1818
+ const found = await inspectFind(bridge, { testId: id })
1819
+ const node = found?.result
1820
+ if (node === null || typeof node !== 'object' || Array.isArray(node)) {
1821
+ console.log('null')
1822
+ break
1823
+ }
1824
+ const absolute = Reflect.get(node, 'absolutePosition')
1825
+ const size = Reflect.get(node, 'layout')
1826
+ console.log(
1827
+ JSON.stringify(
1828
+ absolute && size
1829
+ ? {
1830
+ x: Reflect.get(absolute, 'x'),
1831
+ y: Reflect.get(absolute, 'y'),
1832
+ width: Reflect.get(size, 'width'),
1833
+ height: Reflect.get(size, 'height'),
1834
+ }
1835
+ : null,
1836
+ null,
1837
+ 2,
1838
+ ),
1839
+ )
1840
+ break
1841
+ }
1842
+
1843
+ case 'capture':
1844
+ case 'screenshot': {
1845
+ const outputArg = args.find((_, i) => args[i - 1] === '--output')
1846
+ const outputPath = outputArg || '/tmp/rnx-inspect.png'
1847
+ const rect = await resolveSampleRect(args, bridge)
1848
+
1849
+ const request: { type: 'screenshot'; crop?: SampleRect } = { type: 'screenshot' }
1850
+ if (rect) request.crop = rect
1851
+ const dataUrl: string = await bridge.send(request)
1852
+ const base64 = dataUrl.replace(/^data:image\/png;base64,/, '')
1853
+ if (rect) {
1854
+ console.log(` area: x=${rect.x} y=${rect.y} w=${rect.w} h=${rect.h}`)
1855
+ }
1856
+ const fs = await import('fs')
1857
+ fs.writeFileSync(outputPath, Buffer.from(base64, 'base64'))
1858
+ console.log(` saved: ${outputPath}`)
1859
+ break
1860
+ }
1861
+
1862
+ case 'sample-color': {
1863
+ const rect = await resolveSampleRect(args, bridge)
1864
+ if (!rect) {
1865
+ console.error(
1866
+ inspectUsage(
1867
+ 'sample-color',
1868
+ '<x> <y> [w] [h] | --id <testID> | --text <text>',
1869
+ ),
1870
+ )
1871
+ console.error(
1872
+ ' samples an averaged color from the canvas. coords are logical rnx units.',
1873
+ )
1874
+ rnxExit(1)
1875
+ }
1876
+ const result = await bridge.send({
1877
+ type: 'evaluate',
1878
+ code: buildSampleColorEval(rect),
1879
+ })
1880
+ if (args.includes('--json')) {
1881
+ console.log(JSON.stringify(result, null, 2))
1882
+ } else {
1883
+ const { r, g, b, a, hex, samples } = result as {
1884
+ r: number
1885
+ g: number
1886
+ b: number
1887
+ a: number
1888
+ hex: string
1889
+ samples: number
1890
+ }
1891
+ const rectLabel =
1892
+ rect.w === 1 && rect.h === 1
1893
+ ? `@(${rect.x},${rect.y})`
1894
+ : `@(${rect.x},${rect.y}) ${rect.w}x${rect.h}`
1895
+ console.log(
1896
+ ` ${hex} rgba(${r}, ${g}, ${b}, ${a}) ${rectLabel} ${samples} samples`,
1897
+ )
1898
+ }
1899
+ break
1900
+ }
1901
+
1902
+ case 'node': {
1903
+ const matcher = positional[1]
1904
+ if (!matcher) {
1905
+ console.error(inspectUsage('node', '<matcher>'))
1906
+ console.error(' resolves testID, id, then text — dumps full node info as JSON')
1907
+ rnxExit(1)
1908
+ }
1909
+ if (bridge.plane === 'cloud') {
1910
+ const byTestId = await inspectFind(bridge, { testId: matcher })
1911
+ const byText = byTestId?.result
1912
+ ? null
1913
+ : await inspectFind(bridge, { text: matcher })
1914
+ const node = byTestId?.result ?? byText?.result ?? null
1915
+ console.log(
1916
+ JSON.stringify(
1917
+ node
1918
+ ? {
1919
+ matcher,
1920
+ found: true,
1921
+ resolvedVia: byTestId?.result ? 'testID' : 'text',
1922
+ node,
1923
+ }
1924
+ : { matcher, found: false },
1925
+ null,
1926
+ 2,
1927
+ ),
1928
+ )
1929
+ break
1930
+ }
1931
+ // resolve in order: testID → id → text. first hit wins, includes the
1932
+ // resolved transform and a parent chain so agents can skip the usual
1933
+ // __sootsimRoot walk.
1934
+ const result = await bridge.send({
1935
+ type: 'evaluate',
1936
+ code: `(async () => {
1937
+ const t = window.__sootsimTest
1938
+ const q = ${JSON.stringify(matcher)}
1939
+ let node = null
1940
+ let via = null
1941
+ if (t.findByTestId) { node = await t.findByTestId(q); if (node) via = 'testID' }
1942
+ if (!node && t.findById) { node = await t.findById(q); if (node) via = 'id' }
1943
+ if (!node && t.findByText) { node = await t.findByText(q); if (node) via = 'text' }
1944
+ if (!node) return { matcher: q, found: false }
1945
+
1946
+ // read the resolved transform (if any) off the style — useful
1947
+ // because canvas nodes often animate via transform and describe
1948
+ // output strips that.
1949
+ const transform =
1950
+ node.style && Array.isArray(node.style.transform)
1951
+ ? node.style.transform
1952
+ : node.style && node.style.transform
1953
+ ? node.style.transform
1954
+ : null
1955
+
1956
+ // parent chain — walk up from the node so the JSON dump is
1957
+ // self-describing.
1958
+ const parentChain = []
1959
+ const root = window.__sootsimRoot
1960
+ if (root && node.id != null) {
1961
+ const findPath = (n, targetId, path) => {
1962
+ if (!n) return null
1963
+ if (n.id === targetId) return path
1964
+ if (n.children) {
1965
+ for (const child of n.children) {
1966
+ const nextPath = [
1967
+ ...path,
1968
+ {
1969
+ type: n.type || 'view',
1970
+ testID: n.props?.testID || null,
1971
+ text: n.text || null,
1972
+ },
1973
+ ]
1974
+ const found = findPath(child, targetId, nextPath)
1975
+ if (found) return found
1976
+ }
1977
+ }
1978
+ return null
1979
+ }
1980
+ const chain = findPath(root, node.id, [])
1981
+ if (chain) parentChain.push(...chain)
1982
+ }
1983
+
1984
+ return {
1985
+ matcher: q,
1986
+ found: true,
1987
+ resolvedVia: via,
1988
+ node,
1989
+ transform,
1990
+ parentChain,
1991
+ }
1992
+ })()`,
1993
+ })
1994
+ console.log(JSON.stringify(result, null, 2))
1995
+ break
1996
+ }
1997
+
1998
+ case 'tap': {
1999
+ let x = Number(positional[1])
2000
+ let y = Number(positional[2])
2001
+ const target = readTargetFlag(args, positional)
2002
+ if (target) {
2003
+ const outcome = await tapResolvedTarget(bridge, {
2004
+ agent: isAgentEnv(),
2005
+ textFallback: target.mode === 'text' ? target.value : undefined,
2006
+ resolve: async () => {
2007
+ const resolved = await resolveTargetCoords(bridge, target)
2008
+ if (!resolved) return null
2009
+ return {
2010
+ cx: resolved.x,
2011
+ cy: resolved.y,
2012
+ match: {
2013
+ nodeId: resolved.nodeId,
2014
+ testID:
2015
+ target.mode === 'testid' ? target.value : (resolved.testID ?? null),
2016
+ text: target.mode === 'text' ? target.value : (resolved.text ?? null),
2017
+ type: resolved.type ?? null,
2018
+ },
2019
+ target: {
2020
+ nodeId: resolved.nodeId,
2021
+ testID: resolved.testID ?? null,
2022
+ text: resolved.text ?? null,
2023
+ type: resolved.type ?? null,
2024
+ },
2025
+ }
2026
+ },
2027
+ })
2028
+ const payload = outcome.payload
2029
+ if (!payload || typeof payload.cx !== 'number') {
2030
+ console.error(` not found: ${target.value}`)
2031
+ if (target.mode === 'testid') {
2032
+ maybeHint('wait-selector-for-missing-testid', target.value)
2033
+ }
2034
+ rnxExit(1)
2035
+ }
2036
+ if (!isTapSuccess(outcome.result)) {
2037
+ printTapFailure(`${target.mode} "${target.value}"`, outcome)
2038
+ rnxExit(1)
2039
+ }
2040
+ const candidate = buildTapCandidateFromResult(
2041
+ payload.cx,
2042
+ payload.cy,
2043
+ outcome.result,
2044
+ )
2045
+ if (candidate) {
2046
+ await recordInspectAction('inspect tap', candidate.step, candidate.summary)
2047
+ }
2048
+ console.log(
2049
+ JSON.stringify(
2050
+ {
2051
+ ...(outcome.attempts > 1 ? { attempts: outcome.attempts } : {}),
2052
+ ...outcome.result,
2053
+ },
2054
+ null,
2055
+ 2,
2056
+ ),
2057
+ )
2058
+ break
2059
+ }
2060
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
2061
+ console.error(
2062
+ inspectUsage('tap', '<testid> | <x> <y> | --testid <id> | --text <t>'),
2063
+ )
2064
+ rnxExit(1)
2065
+ }
2066
+ const result = await tapCoordinates(bridge, x, y)
2067
+ const candidate = buildTapCandidateFromResult(x, y, result)
2068
+ if (candidate) {
2069
+ await recordInspectAction('inspect tap', candidate.step, candidate.summary)
2070
+ }
2071
+ console.log(JSON.stringify(result, null, 2))
2072
+ break
2073
+ }
2074
+
2075
+ case 'drag':
2076
+ case 'swipe': {
2077
+ const fromX = Number(positional[1])
2078
+ const fromY = Number(positional[2])
2079
+ const toX = Number(positional[3])
2080
+ const toY = Number(positional[4])
2081
+ const defaultSteps = subcommand === 'swipe' ? 10 : 12
2082
+ const defaultStepMs = subcommand === 'swipe' ? 8 : 16
2083
+ const steps = positional[5] ? Number(positional[5]) : defaultSteps
2084
+ const stepMs = positional[6] ? Number(positional[6]) : defaultStepMs
2085
+ if (
2086
+ !Number.isFinite(fromX) ||
2087
+ !Number.isFinite(fromY) ||
2088
+ !Number.isFinite(toX) ||
2089
+ !Number.isFinite(toY) ||
2090
+ !Number.isFinite(steps) ||
2091
+ !Number.isFinite(stepMs)
2092
+ ) {
2093
+ console.error(inspectUsage(subcommand, '<x1> <y1> <x2> <y2> [steps] [stepMs]'))
2094
+ rnxExit(1)
2095
+ }
2096
+ const result = await performSingleStep(bridge, {
2097
+ type: 'drag',
2098
+ fromX,
2099
+ fromY,
2100
+ toX,
2101
+ toY,
2102
+ steps: Math.max(1, Math.round(steps)),
2103
+ stepMs: Math.max(0, Math.round(stepMs)),
2104
+ })
2105
+ if (result?.ok) {
2106
+ const duration = Math.max(
2107
+ 1,
2108
+ Math.round(Math.max(1, steps) * Math.max(0, stepMs)),
2109
+ )
2110
+ await recordInspectAction(
2111
+ `inspect ${subcommand}`,
2112
+ {
2113
+ swipe: {
2114
+ start: `${fromX}, ${fromY}`,
2115
+ end: `${toX}, ${toY}`,
2116
+ duration,
2117
+ },
2118
+ },
2119
+ `${subcommand} ${fromX},${fromY} -> ${toX},${toY}`,
2120
+ )
2121
+ }
2122
+ console.log(JSON.stringify(result, null, 2))
2123
+ break
2124
+ }
2125
+
2126
+ case 'pinch': {
2127
+ const fromX1 = Number(positional[1])
2128
+ const fromY1 = Number(positional[2])
2129
+ const fromX2 = Number(positional[3])
2130
+ const fromY2 = Number(positional[4])
2131
+ const toX1 = Number(positional[5])
2132
+ const toY1 = Number(positional[6])
2133
+ const toX2 = Number(positional[7])
2134
+ const toY2 = Number(positional[8])
2135
+ const steps = positional[9] ? Number(positional[9]) : 12
2136
+ const stepMs = positional[10] ? Number(positional[10]) : 16
2137
+ if (
2138
+ !Number.isFinite(fromX1) ||
2139
+ !Number.isFinite(fromY1) ||
2140
+ !Number.isFinite(fromX2) ||
2141
+ !Number.isFinite(fromY2) ||
2142
+ !Number.isFinite(toX1) ||
2143
+ !Number.isFinite(toY1) ||
2144
+ !Number.isFinite(toX2) ||
2145
+ !Number.isFinite(toY2) ||
2146
+ !Number.isFinite(steps) ||
2147
+ !Number.isFinite(stepMs)
2148
+ ) {
2149
+ console.error(
2150
+ inspectUsage(
2151
+ 'pinch',
2152
+ "<x1> <y1> <x2> <y2> <x1'> <y1'> <x2'> <y2'> [steps] [stepMs]",
2153
+ ),
2154
+ )
2155
+ rnxExit(1)
2156
+ }
2157
+ const result = await performSingleStep(bridge, {
2158
+ type: 'pinch',
2159
+ fromX1,
2160
+ fromY1,
2161
+ fromX2,
2162
+ fromY2,
2163
+ toX1,
2164
+ toY1,
2165
+ toX2,
2166
+ toY2,
2167
+ steps: Math.max(1, Math.round(steps)),
2168
+ stepMs: Math.max(0, Math.round(stepMs)),
2169
+ })
2170
+ if (result?.ok) {
2171
+ await recordInspectAction(
2172
+ 'inspect pinch',
2173
+ {
2174
+ pinch: {
2175
+ from: [fromX1, fromY1, fromX2, fromY2],
2176
+ to: [toX1, toY1, toX2, toY2],
2177
+ steps: Math.max(1, Math.round(steps)),
2178
+ stepMs: Math.max(0, Math.round(stepMs)),
2179
+ },
2180
+ },
2181
+ `pinch (${fromX1},${fromY1}) (${fromX2},${fromY2}) -> (${toX1},${toY1}) (${toX2},${toY2})`,
2182
+ )
2183
+ }
2184
+ console.log(JSON.stringify(result, null, 2))
2185
+ break
2186
+ }
2187
+
2188
+ case 'tap-text': {
2189
+ const query = positional[1]
2190
+ if (!query) {
2191
+ console.error(inspectUsage('tap-text', '<text>'))
2192
+ rnxExit(1)
2193
+ }
2194
+
2195
+ const findFlagValue = (name: string): string | null => {
2196
+ const i = args.indexOf(name)
2197
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : null
2198
+ }
2199
+ const hasBoolFlag = (name: string): boolean => args.includes(name)
2200
+
2201
+ const nthArg = findFlagValue('--nth') ?? findFlagValue('--index')
2202
+ const nthIndex = nthArg !== null ? Number(nthArg) : null
2203
+ if (nthIndex !== null && !Number.isFinite(nthIndex)) {
2204
+ console.error(` --nth/--index requires an integer, got: ${nthArg}`)
2205
+ rnxExit(1)
2206
+ }
2207
+ const within = findFlagValue('--within')
2208
+ const roleFlag = findFlagValue('--role')
2209
+ const exact = hasBoolFlag('--exact')
2210
+ const first = hasBoolFlag('--first')
2211
+ const minYArg = findFlagValue('--min-y')
2212
+ const maxYArg = findFlagValue('--max-y')
2213
+ const minXArg = findFlagValue('--min-x')
2214
+ const maxXArg = findFlagValue('--max-x')
2215
+ for (const [flag, val] of [
2216
+ ['--min-y', minYArg],
2217
+ ['--max-y', maxYArg],
2218
+ ['--min-x', minXArg],
2219
+ ['--max-x', maxXArg],
2220
+ ] as const) {
2221
+ if (val !== null && !Number.isFinite(Number(val))) {
2222
+ console.error(` ${flag} requires a number, got: ${val}`)
2223
+ rnxExit(1)
2224
+ }
2225
+ }
2226
+ const nearIdx = args.indexOf('--near')
2227
+ let near: { x: number; y: number } | null = null
2228
+ if (nearIdx >= 0) {
2229
+ const nx = Number(args[nearIdx + 1])
2230
+ const ny = Number(args[nearIdx + 2])
2231
+ if (!Number.isFinite(nx) || !Number.isFinite(ny)) {
2232
+ console.error(' --near requires two numbers: --near <x> <y>')
2233
+ rnxExit(1)
2234
+ }
2235
+ near = { x: nx, y: ny }
2236
+ }
2237
+
2238
+ const textOptions: TapTextOptions = {
2239
+ exact,
2240
+ role: roleFlag,
2241
+ within,
2242
+ minX: minXArg !== null ? Number(minXArg) : null,
2243
+ maxX: maxXArg !== null ? Number(maxXArg) : null,
2244
+ minY: minYArg !== null ? Number(minYArg) : null,
2245
+ maxY: maxYArg !== null ? Number(maxYArg) : null,
2246
+ near,
2247
+ nth: nthIndex,
2248
+ first,
2249
+ }
2250
+
2251
+ const outcome = await tapByText(bridge, query, textOptions, {
2252
+ agent: isAgentEnv(),
2253
+ })
2254
+ const payload = outcome.payload
2255
+
2256
+ if (payload?.error === 'bridge-not-ready') {
2257
+ console.error(' rnx test bridge not ready')
2258
+ rnxExit(1)
2259
+ }
2260
+
2261
+ if (payload?.ambiguous) {
2262
+ const candidatesOut = payload.candidates as Array<{
2263
+ idx: number
2264
+ nodeId: number | null
2265
+ type: string | null
2266
+ testID: string | null
2267
+ text: string
2268
+ abs: { x: number; y: number } | null
2269
+ layout: { width: number; height: number } | null
2270
+ ancestorTestIDs: string[]
2271
+ }>
2272
+ console.error(` ambiguous: ${payload.total} matches for "${query}"`)
2273
+ for (const c of candidatesOut) {
2274
+ const loc = c.abs ? `@(${Math.round(c.abs.x)},${Math.round(c.abs.y)})` : ''
2275
+ const size = c.layout ? ` ${c.layout.width}x${c.layout.height}` : ''
2276
+ const tid = c.testID ? ` #${c.testID}` : ''
2277
+ const text = c.text ? ` "${c.text}"` : ''
2278
+ const ancestors =
2279
+ c.ancestorTestIDs.length > 0
2280
+ ? ` within ${c.ancestorTestIDs
2281
+ .slice(0, 3)
2282
+ .map((id) => `#${id}`)
2283
+ .join(' > ')}`
2284
+ : ''
2285
+ console.error(
2286
+ ` [${c.idx}] <${c.type}>${text}${tid} ${loc}${size}${ancestors}`,
2287
+ )
2288
+ }
2289
+ if (payload.total > candidatesOut.length) {
2290
+ console.error(` ... and ${payload.total - candidatesOut.length} more`)
2291
+ }
2292
+ console.error(' pick one:')
2293
+ console.error(
2294
+ ' --nth <index> pick the nth match (top-to-bottom, left-to-right; negatives from end)',
2295
+ )
2296
+ console.error(' --within <testID> narrow to descendants of a node')
2297
+ console.error(' --min-y / --max-y geometric filter (pixels, absolute)')
2298
+ console.error(' --min-x / --max-x geometric filter (pixels, absolute)')
2299
+ console.error(' --near <x> <y> pick the closest match to a point')
2300
+ console.error(
2301
+ ' --exact exact text match (default is substring)',
2302
+ )
2303
+ console.error(' --role <role> narrow to accessibilityRole')
2304
+ console.error(
2305
+ ' --first keep the old pick-first-silently behavior',
2306
+ )
2307
+ rnxExit(2)
2308
+ }
2309
+
2310
+ if (payload?.nthOutOfRange) {
2311
+ console.error(
2312
+ ` not found: nth ${payload.nth} of ${payload.total} match${payload.total === 1 ? '' : 'es'} for "${query}"`,
2313
+ )
2314
+ rnxExit(1)
2315
+ }
2316
+
2317
+ if (!payload || typeof payload.cx !== 'number') {
2318
+ console.error(` not found: ${query}`)
2319
+ rnxExit(1)
2320
+ }
2321
+ if (!isTapSuccess(outcome.result)) {
2322
+ printTapFailure(`text "${query}"`, outcome)
2323
+ rnxExit(1)
2324
+ }
2325
+ const candidate = buildTapCandidateFromNode(
2326
+ query,
2327
+ {
2328
+ id: payload.target?.id ?? null,
2329
+ testID: payload.target?.testID ?? null,
2330
+ type: payload.target?.type ?? null,
2331
+ cx: payload.cx,
2332
+ cy: payload.cy,
2333
+ },
2334
+ 'text',
2335
+ )
2336
+ await recordInspectAction('inspect tap-text', candidate.step, candidate.summary)
2337
+ console.log(
2338
+ JSON.stringify(
2339
+ {
2340
+ matched: payload.match,
2341
+ tapped: {
2342
+ nodeId: payload.target?.nodeId ?? null,
2343
+ id: payload.target?.id ?? null,
2344
+ testID: payload.target?.testID ?? null,
2345
+ type: payload.target?.type ?? null,
2346
+ cx: payload.cx,
2347
+ cy: payload.cy,
2348
+ },
2349
+ ...(payload.strategy && payload.strategy !== 'matched-node'
2350
+ ? { strategy: payload.strategy }
2351
+ : {}),
2352
+ // expose the disambiguation outcome so agents can confirm which
2353
+ // of the N matches was tapped, or notice that only one exists.
2354
+ ...(payload.total > 1 || nthIndex !== null
2355
+ ? { nth: { index: payload.idx, total: payload.total } }
2356
+ : {}),
2357
+ ...(outcome.attempts > 1 ? { attempts: outcome.attempts } : {}),
2358
+ result: outcome.result,
2359
+ },
2360
+ null,
2361
+ 2,
2362
+ ),
2363
+ )
2364
+ break
2365
+ }
2366
+
2367
+ case 'tap-best': {
2368
+ // rnx do tap-best <query> — try tap-id, then tap-text. the
2369
+ // common agent ergonomic problem: the agent has a label like
2370
+ // "Create expense" and doesn't know whether that's a testID
2371
+ // or visible text. with tap-text it fails when the only
2372
+ // match is a testID; with tap-id it fails when the only
2373
+ // match is visible text. tap-best collapses both into one
2374
+ // command and prints which strategy hit.
2375
+ //
2376
+ // tap-best does NOT subsume tap-text's disambiguation flags
2377
+ // (--nth, --within, --near, --min-y) — those are still on
2378
+ // tap-text directly. tap-best is the "I don't care which
2379
+ // strategy works as long as one does" flavour.
2380
+ const query = positional[1]
2381
+ if (!query) {
2382
+ console.error(inspectUsage('tap-best', '<query>'))
2383
+ rnxExit(1)
2384
+ }
2385
+ const outcome = await tapBest(bridge, query, { agent: isAgentEnv() })
2386
+ const payload = outcome.payload as
2387
+ | {
2388
+ strategy: 'testid' | 'text'
2389
+ node: {
2390
+ nodeId: number | null
2391
+ id: string | null
2392
+ testID: string | null
2393
+ type: string | null
2394
+ text: string | null
2395
+ }
2396
+ cx: number
2397
+ cy: number
2398
+ }
2399
+ | { strategy: 'none' }
2400
+ | { error: string }
2401
+ | null
2402
+ if (!payload) {
2403
+ console.error(
2404
+ ` tap-best: no testID or visible text matched "${query}". try \`rnx find --interactive-targets\` to list candidates.`,
2405
+ )
2406
+ rnxExit(1)
2407
+ }
2408
+ if ('error' in payload) {
2409
+ console.error(` ${payload.error}`)
2410
+ rnxExit(1)
2411
+ }
2412
+ if (payload.strategy === 'none') {
2413
+ console.error(
2414
+ ` tap-best: no testID or visible text matched "${query}". try \`rnx find --interactive-targets\` to list candidates.`,
2415
+ )
2416
+ rnxExit(1)
2417
+ }
2418
+ const node = payload.node
2419
+ if (!isTapSuccess(outcome.result)) {
2420
+ printTapFailure(`best "${query}"`, outcome)
2421
+ rnxExit(1)
2422
+ }
2423
+ const candidate = buildTapCandidateFromNode(
2424
+ query,
2425
+ {
2426
+ id: node.id,
2427
+ testID: node.testID,
2428
+ type: node.type,
2429
+ cx: payload.cx,
2430
+ cy: payload.cy,
2431
+ },
2432
+ payload.strategy === 'testid' ? 'id' : 'text',
2433
+ )
2434
+ await recordInspectAction('inspect tap-best', candidate.step, candidate.summary)
2435
+ console.log(
2436
+ JSON.stringify(
2437
+ {
2438
+ matched: {
2439
+ strategy: payload.strategy,
2440
+ nodeId: node.nodeId,
2441
+ id: node.id,
2442
+ testID: node.testID,
2443
+ type: node.type,
2444
+ text: node.text,
2445
+ },
2446
+ tapped: { cx: payload.cx, cy: payload.cy },
2447
+ ...(outcome.attempts > 1 ? { attempts: outcome.attempts } : {}),
2448
+ result: outcome.result,
2449
+ },
2450
+ null,
2451
+ 2,
2452
+ ),
2453
+ )
2454
+ break
2455
+ }
2456
+
2457
+ case 'tap-id': {
2458
+ const query = positional[1]
2459
+ if (!query) {
2460
+ console.error(inspectUsage('tap-id', '<id>'))
2461
+ rnxExit(1)
2462
+ }
2463
+
2464
+ const outcome = await tapById(bridge, query, { agent: isAgentEnv() })
2465
+ const payload = outcome.payload
2466
+ if (!payload || typeof payload.cx !== 'number') {
2467
+ console.error(` not found: ${query}`)
2468
+ // when the testID didn't match, fall back to a fuzzy list of
2469
+ // nearby testIDs in the live tree so the agent can pick the
2470
+ // intended one without dumping the whole tree first.
2471
+ await printSimilarTestIds(bridge, query)
2472
+ rnxExit(1)
2473
+ }
2474
+ if (!isTapSuccess(outcome.result)) {
2475
+ printTapFailure(`id "${query}"`, outcome)
2476
+ rnxExit(1)
2477
+ }
2478
+ const candidate = buildTapCandidateFromNode(
2479
+ query,
2480
+ {
2481
+ id: payload.target?.id ?? null,
2482
+ testID: payload.target?.testID ?? null,
2483
+ type: payload.target?.type ?? null,
2484
+ cx: payload.cx,
2485
+ cy: payload.cy,
2486
+ },
2487
+ 'id',
2488
+ )
2489
+ await recordInspectAction('inspect tap-id', candidate.step, candidate.summary)
2490
+ console.log(
2491
+ JSON.stringify(
2492
+ {
2493
+ matched: payload.match,
2494
+ tapped: {
2495
+ nodeId: payload.target?.nodeId ?? null,
2496
+ id: payload.target?.id ?? null,
2497
+ testID: payload.target?.testID ?? null,
2498
+ type: payload.target?.type ?? null,
2499
+ cx: payload.cx,
2500
+ cy: payload.cy,
2501
+ },
2502
+ ...(payload.strategy && payload.strategy !== 'matched-node'
2503
+ ? { strategy: payload.strategy }
2504
+ : {}),
2505
+ ...(outcome.attempts > 1 ? { attempts: outcome.attempts } : {}),
2506
+ result: outcome.result,
2507
+ },
2508
+ null,
2509
+ 2,
2510
+ ),
2511
+ )
2512
+ break
2513
+ }
2514
+
2515
+ case 'type-into': {
2516
+ const targetId = positional[1]
2517
+ const text = positional.slice(2).join(' ')
2518
+ if (!targetId || !text) {
2519
+ console.error(inspectUsage('type-into', '<id> <text>'))
2520
+ rnxExit(1)
2521
+ }
2522
+ if (bridge.plane === 'cloud') {
2523
+ const outcome = await tapById(bridge, targetId, { agent: isAgentEnv() })
2524
+ if (!outcome.payload || typeof outcome.payload.cx !== 'number') {
2525
+ console.error(` not found: ${targetId}`)
2526
+ await printSimilarTestIds(bridge, targetId)
2527
+ rnxExit(1)
2528
+ }
2529
+ if (!isTapSuccess(outcome.result)) {
2530
+ printTapFailure(`id "${targetId}"`, outcome)
2531
+ rnxExit(1)
2532
+ }
2533
+ await performCloudKeys(bridge, [{ type: 'type', text }], 'type-into')
2534
+ await recordInspectAction(
2535
+ 'inspect type-into',
2536
+ { tapOn: { id: targetId }, inputText: text },
2537
+ `type-into #${targetId} ${JSON.stringify(text)}`,
2538
+ )
2539
+ console.log(` typed into ${targetId}: ${JSON.stringify(text)}`)
2540
+ break
2541
+ }
2542
+ // step 1: find the element
2543
+ const tiArg = JSON.stringify(targetId)
2544
+ const tiPayload = await bridge.send({
2545
+ type: 'evaluate',
2546
+ code: `(async () => {
2547
+ const t = window.__sootsimTest
2548
+ if (!t) return null
2549
+ const n = await (t.findByTestId(${tiArg}) || t.findById(${tiArg}))
2550
+ if (!n || !n.absolutePosition || !n.layout) return null
2551
+ return {
2552
+ cx: n.absolutePosition.x + (n.layout.width || 0) / 2,
2553
+ cy: n.absolutePosition.y + (n.layout.height || 0) / 2,
2554
+ id: n.id,
2555
+ testID: n.testID,
2556
+ type: n.type,
2557
+ isTextInput: !!n.isTextInput,
2558
+ secureTextEntry: !!n.secureTextEntry,
2559
+ placeholder: n.placeholder || null,
2560
+ }
2561
+ })()`,
2562
+ })
2563
+ if (!tiPayload || typeof tiPayload.cx !== 'number') {
2564
+ console.error(` not found: ${targetId}`)
2565
+ rnxExit(1)
2566
+ }
2567
+ if (!tiPayload.isTextInput) {
2568
+ console.error(` warning: ${targetId} is not a text input (isTextInput: false)`)
2569
+ }
2570
+ // step 2: tap to focus
2571
+ const tapResult = await bridge.send({
2572
+ type: 'tap',
2573
+ x: tiPayload.cx,
2574
+ y: tiPayload.cy,
2575
+ target: {
2576
+ id: tiPayload.id ?? targetId,
2577
+ testID: tiPayload.testID ?? targetId,
2578
+ text: null,
2579
+ type: tiPayload.type ?? null,
2580
+ },
2581
+ })
2582
+ const targetSecureTextEntry = tiPayload.secureTextEntry === true
2583
+ const keyboardState = await waitForKeyboardVisible(bridge, 1000, {
2584
+ targetId,
2585
+ secureTextEntry: targetSecureTextEntry,
2586
+ })
2587
+ if (!keyboardState.visible) {
2588
+ console.error(` keyboard did not open after tapping ${targetId}`)
2589
+ rnxExit(1)
2590
+ }
2591
+ // step 2.5: confirm the tap actually focused the requested input.
2592
+ // the keyboard being visible is necessary but not sufficient — a
2593
+ // previous input might still hold focus if the tap got routed to a
2594
+ // Pressable wrapper ancestor (see the 2026-04-17 bluesky regression).
2595
+ // reading getKeyboardState → __sootsimTest.getFocusedNode() fails
2596
+ // fast on that mismatch instead of typing into the wrong input.
2597
+ const focusCheck = keyboardState.focusedInput as
2598
+ | { testID?: string | null; id?: string | null }
2599
+ | null
2600
+ | undefined
2601
+ if (focusCheck) {
2602
+ const matched = focusCheck.testID === targetId || focusCheck.id === targetId
2603
+ if (!matched) {
2604
+ console.error(
2605
+ ` focus routing mismatch after tap: requested ${JSON.stringify(targetId)} but focus is on ${JSON.stringify(
2606
+ focusCheck.testID ?? focusCheck.id ?? null,
2607
+ )}. did the tap land on an outer Pressable wrapper?`,
2608
+ )
2609
+ rnxExit(1)
2610
+ }
2611
+ }
2612
+ // step 3: clear existing text then type through the visible keyboard
2613
+ const existingText =
2614
+ typeof keyboardState.focusedInput?.text === 'string'
2615
+ ? keyboardState.focusedInput.text
2616
+ : ''
2617
+ for (let i = 0; i < existingText.length; i++) {
2618
+ await bridge.send({ type: 'keyboard', action: 'press', key: 'delete' })
2619
+ }
2620
+ await bridge.send({ type: 'keyboard', action: 'type', text })
2621
+ const secureTextEntry =
2622
+ targetSecureTextEntry || isSecureKeyboardState(keyboardState as any)
2623
+ const outputText = secureTextEntry
2624
+ ? SECURE_TEXT_REDACTION
2625
+ : redactTextForKeyboardState(text, keyboardState as any)
2626
+ const focusedInput =
2627
+ secureTextEntry && keyboardState.focusedInput
2628
+ ? {
2629
+ ...keyboardState.focusedInput,
2630
+ secureTextEntry: true,
2631
+ text:
2632
+ typeof keyboardState.focusedInput.text === 'string'
2633
+ ? SECURE_TEXT_REDACTION
2634
+ : keyboardState.focusedInput.text,
2635
+ }
2636
+ : (keyboardState.focusedInput ?? null)
2637
+ await recordInspectAction(
2638
+ 'inspect type-into',
2639
+ { tapOn: { id: targetId }, inputText: outputText },
2640
+ outputText === SECURE_TEXT_REDACTION
2641
+ ? `type-into #${targetId} ${SECURE_TEXT_REDACTION}`
2642
+ : `type-into #${targetId} ${JSON.stringify(text)}`,
2643
+ )
2644
+ console.log(
2645
+ JSON.stringify(
2646
+ {
2647
+ target: targetId,
2648
+ isTextInput: tiPayload.isTextInput,
2649
+ secureTextEntry,
2650
+ keyboardOpened: keyboardState.visible ?? tapResult?.keyboardOpened ?? false,
2651
+ focusedInput,
2652
+ typed: outputText,
2653
+ },
2654
+ null,
2655
+ 2,
2656
+ ),
2657
+ )
2658
+ break
2659
+ }
2660
+
2661
+ case 'type': {
2662
+ const text = positional.slice(1).join(' ')
2663
+ if (!text) {
2664
+ console.error(inspectUsage('type', '<text>'))
2665
+ rnxExit(1)
2666
+ }
2667
+ if (bridge.plane === 'cloud') {
2668
+ await performCloudKeys(bridge, [{ type: 'type', text }], 'type')
2669
+ await recordInspectAction(
2670
+ 'inspect type',
2671
+ { inputText: text },
2672
+ `type ${JSON.stringify(text)}`,
2673
+ )
2674
+ console.log(` typed: ${JSON.stringify(text)}`)
2675
+ break
2676
+ }
2677
+ const keyboardState = await requireVisualKeyboard(bridge, 'type')
2678
+ await bridge.send({ type: 'keyboard', action: 'type', text })
2679
+ const outputText = redactTextForKeyboardState(text, keyboardState as any)
2680
+ await recordInspectAction(
2681
+ 'inspect type',
2682
+ { inputText: outputText },
2683
+ outputText === SECURE_TEXT_REDACTION
2684
+ ? `type ${SECURE_TEXT_REDACTION}`
2685
+ : `type ${JSON.stringify(text)}`,
2686
+ )
2687
+ console.log(
2688
+ outputText === SECURE_TEXT_REDACTION
2689
+ ? ` typed: ${SECURE_TEXT_REDACTION}`
2690
+ : ` typed: ${JSON.stringify(text)}`,
2691
+ )
2692
+ break
2693
+ }
2694
+
2695
+ case 'key': {
2696
+ const name = positional[1]
2697
+ if (!name) {
2698
+ console.error(inspectUsage('key', '<name>'))
2699
+ rnxExit(1)
2700
+ }
2701
+ if (bridge.plane === 'cloud') {
2702
+ await performCloudKeys(bridge, [{ type: 'key', key: name }], 'key')
2703
+ } else {
2704
+ await requireVisualKeyboard(bridge, 'key')
2705
+ await bridge.send({ type: 'keyboard', action: 'press', text: name })
2706
+ }
2707
+ await recordInspectAction('inspect key', { pressKey: name }, `key ${name}`)
2708
+ console.log(` pressed: ${name}`)
2709
+ break
2710
+ }
2711
+
2712
+ case 'key-sequence': {
2713
+ const keys = positional.slice(1)
2714
+ if (keys.length === 0) {
2715
+ console.error(inspectUsage('key-sequence', '<key> [<key> ...]'))
2716
+ rnxExit(1)
2717
+ }
2718
+ if (bridge.plane === 'cloud') {
2719
+ await performCloudKeys(
2720
+ bridge,
2721
+ keys.map((key) => ({ type: 'key' as const, key })),
2722
+ 'key-sequence',
2723
+ )
2724
+ } else {
2725
+ await requireVisualKeyboard(bridge, 'key-sequence')
2726
+ for (const key of keys) {
2727
+ await bridge.send({ type: 'keyboard', action: 'press', text: key })
2728
+ }
2729
+ }
2730
+ await recordInspectAction(
2731
+ 'inspect key-sequence',
2732
+ { pressKey: keys.join(' ') },
2733
+ `key-sequence ${keys.join(' ')}`,
2734
+ )
2735
+ console.log(` pressed: ${keys.join(', ')}`)
2736
+ break
2737
+ }
2738
+
2739
+ case 'keycode': {
2740
+ const codes = positional.slice(1)
2741
+ if (codes.length === 0) {
2742
+ console.error(inspectUsage('keycode', '<code> [<code> ...]'))
2743
+ rnxExit(1)
2744
+ }
2745
+ const keys = codes.map((code) => ({ code, key: keyCodeToVisualKey(code) }))
2746
+ const unsupported = keys.filter((entry) => !entry.key)
2747
+ if (unsupported.length > 0) {
2748
+ console.error(
2749
+ ` unsupported keycode(s): ${unsupported.map((entry) => entry.code).join(', ')}`,
2750
+ )
2751
+ rnxExit(1)
2752
+ }
2753
+ await requireVisualKeyboard(bridge, 'keycode')
2754
+ for (const entry of keys) {
2755
+ await bridge.send({ type: 'keyboard', action: 'press', text: entry.key })
2756
+ }
2757
+ await recordInspectAction(
2758
+ 'inspect keycode',
2759
+ { pressKey: keys.map((entry) => entry.key).join(' ') },
2760
+ `keycode ${codes.join(' ')}`,
2761
+ )
2762
+ console.log(` pressed: ${codes.join(', ')}`)
2763
+ break
2764
+ }
2765
+
2766
+ case 'dispatch': {
2767
+ const ch = positional[1]
2768
+ if (!ch) {
2769
+ console.error(inspectUsage('dispatch', '<char>'))
2770
+ rnxExit(1)
2771
+ }
2772
+ await bridge.send({ type: 'keyboard', action: 'dispatchKey', text: ch })
2773
+ await recordInspectAction(
2774
+ 'inspect dispatch',
2775
+ { dispatchKey: ch },
2776
+ `dispatch ${JSON.stringify(ch)}`,
2777
+ )
2778
+ console.log(` dispatched: ${ch}`)
2779
+ break
2780
+ }
2781
+
2782
+ case 'dismiss': {
2783
+ await bridge.send({ type: 'keyboard', action: 'dismiss' })
2784
+ await recordInspectAction(
2785
+ 'inspect dismiss',
2786
+ { hideKeyboard: true },
2787
+ 'dismiss keyboard',
2788
+ )
2789
+ console.log(' keyboard dismissed')
2790
+ break
2791
+ }
2792
+
2793
+ case 'double-tap': {
2794
+ let x = Number(positional[1])
2795
+ let y = Number(positional[2])
2796
+ const target = readTargetFlag(args, positional)
2797
+ if (target) {
2798
+ const resolved = await resolveTargetCoords(bridge, target)
2799
+ if (!resolved) {
2800
+ console.error(` not found: ${target.value}`)
2801
+ if (target.mode === 'testid') {
2802
+ maybeHint('wait-selector-for-missing-testid', target.value)
2803
+ }
2804
+ rnxExit(1)
2805
+ }
2806
+ x = resolved.x
2807
+ y = resolved.y
2808
+ }
2809
+ const gapMs = positional[3] ? Number(positional[3]) : 80
2810
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(gapMs)) {
2811
+ console.error(
2812
+ inspectUsage('double-tap', '<testid> | <x> <y> [gapMs] | --testid <id>'),
2813
+ )
2814
+ rnxExit(1)
2815
+ }
2816
+ const waitMs = Math.max(0, Math.round(gapMs))
2817
+ const single = await performSingleStep(bridge, {
2818
+ type: 'doubleTap',
2819
+ x,
2820
+ y,
2821
+ gapMs: waitMs,
2822
+ })
2823
+ const result = { ...single, gapMs: waitMs }
2824
+ if (result?.ok) {
2825
+ await recordInspectAction(
2826
+ 'inspect double-tap',
2827
+ { doubleTapAtCoords: { x, y, gapMs: waitMs } },
2828
+ `double-tap @${x},${y}`,
2829
+ )
2830
+ }
2831
+ console.log(JSON.stringify(result, null, 2))
2832
+ break
2833
+ }
2834
+
2835
+ case 'long-press': {
2836
+ let x = Number(positional[1])
2837
+ let y = Number(positional[2])
2838
+ const target = readTargetFlag(args, positional)
2839
+ let resolvedTarget: {
2840
+ testID?: string | null
2841
+ text?: string | null
2842
+ type?: string | null
2843
+ } | null = null
2844
+ if (target) {
2845
+ const resolved = await resolveTargetCoords(bridge, target)
2846
+ if (!resolved) {
2847
+ console.error(` not found: ${target.value}`)
2848
+ if (target.mode === 'testid') {
2849
+ maybeHint('wait-selector-for-missing-testid', target.value)
2850
+ }
2851
+ rnxExit(1)
2852
+ }
2853
+ x = resolved.x
2854
+ y = resolved.y
2855
+ resolvedTarget = {
2856
+ testID: resolved.testID ?? null,
2857
+ text: resolved.text ?? null,
2858
+ type: resolved.type ?? null,
2859
+ }
2860
+ }
2861
+ const durationArg = target ? positional[1] : positional[3]
2862
+ const durationMs = durationArg ? Number(durationArg) : 600
2863
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(durationMs)) {
2864
+ console.error(
2865
+ inspectUsage('long-press', '<testid> | <x> <y> [durationMs] | --testid <id>'),
2866
+ )
2867
+ rnxExit(1)
2868
+ }
2869
+ const roundedDurationMs = Math.max(0, Math.round(durationMs))
2870
+ const result = await bridge.send({
2871
+ type: 'longPress',
2872
+ x,
2873
+ y,
2874
+ durationMs: roundedDurationMs,
2875
+ target: resolvedTarget,
2876
+ })
2877
+ if (result?.ok) {
2878
+ await recordInspectAction(
2879
+ 'inspect long-press',
2880
+ { tapAtCoords: { x, y } },
2881
+ `long-press @${x},${y}`,
2882
+ )
2883
+ }
2884
+ console.log(JSON.stringify(result, null, 2))
2885
+ break
2886
+ }
2887
+
2888
+ case 'touch': {
2889
+ const phase = positional[1]
2890
+ const x = Number(positional[2])
2891
+ const y = Number(positional[3])
2892
+ const pointerId = positional[4] ? Number(positional[4]) : 999
2893
+ const method =
2894
+ phase === 'down'
2895
+ ? 'touchDown'
2896
+ : phase === 'move'
2897
+ ? 'touchMove'
2898
+ : phase === 'up'
2899
+ ? 'touchUp'
2900
+ : phase === 'cancel'
2901
+ ? 'touchCancel'
2902
+ : null
2903
+ if (!method) {
2904
+ console.error(
2905
+ inspectUsage('touch', '<down|move|up|cancel> <x> <y> [pointerId]'),
2906
+ )
2907
+ rnxExit(1)
2908
+ }
2909
+ if (phase !== 'cancel' && (!Number.isFinite(x) || !Number.isFinite(y))) {
2910
+ console.error(
2911
+ inspectUsage('touch', '<down|move|up|cancel> <x> <y> [pointerId]'),
2912
+ )
2913
+ rnxExit(1)
2914
+ }
2915
+ // the engine's perform executor emits the matching agent-cursor
2916
+ // action (tap for down, move for move) natively.
2917
+ const roundedPointerId = Math.max(1, Math.round(pointerId))
2918
+ const result = await performSingleStep(
2919
+ bridge,
2920
+ method === 'touchCancel'
2921
+ ? { type: method, pointerId: roundedPointerId }
2922
+ : { type: method, x, y, pointerId: roundedPointerId },
2923
+ )
2924
+ if (result?.ok && phase !== 'cancel') {
2925
+ await recordInspectAction(
2926
+ 'inspect touch',
2927
+ { tapAtCoords: { x, y } },
2928
+ `touch ${phase} @${x},${y}`,
2929
+ )
2930
+ }
2931
+ console.log(JSON.stringify(result, null, 2))
2932
+ break
2933
+ }
2934
+
2935
+ case 'gesture': {
2936
+ const GESTURE_PRESETS = [
2937
+ 'scroll-up',
2938
+ 'scroll-down',
2939
+ 'scroll-left',
2940
+ 'scroll-right',
2941
+ 'swipe-from-left-edge',
2942
+ 'swipe-from-right-edge',
2943
+ 'swipe-from-top-edge',
2944
+ 'swipe-from-bottom-edge',
2945
+ ] as const
2946
+ const preset = positional[1]
2947
+ const durationMs = positional[2] ? Number(positional[2]) : 220
2948
+ if (!preset || !Number.isFinite(durationMs)) {
2949
+ console.error(inspectUsage('gesture', '<preset> [durationMs]'))
2950
+ console.error(` presets: ${GESTURE_PRESETS.join(', ')}`)
2951
+ rnxExit(1)
2952
+ }
2953
+ if (!(GESTURE_PRESETS as readonly string[]).includes(preset)) {
2954
+ console.error(` unknown gesture preset: ${preset}`)
2955
+ console.error(` presets: ${GESTURE_PRESETS.join(', ')}`)
2956
+ rnxExit(1)
2957
+ }
2958
+ const frame = await bridge.send({
2959
+ type: 'evaluate',
2960
+ code: `(async () => {
2961
+ const spec = globalThis.__sootsimDeviceSpec || {}
2962
+ return {
2963
+ width: spec.width || window.innerWidth || 393,
2964
+ height: spec.height || window.innerHeight || 852,
2965
+ statusBarHeight: spec.statusBarHeight || 0,
2966
+ homeIndicatorHeight: spec.homeIndicatorHeight || 0,
2967
+ }
2968
+ })()`,
2969
+ })
2970
+ const width = Number(frame?.width) || 393
2971
+ const height = Number(frame?.height) || 852
2972
+ const statusBarHeight = Number(frame?.statusBarHeight) || 0
2973
+ const homeIndicatorHeight = Number(frame?.homeIndicatorHeight) || 0
2974
+ const centerX = Math.round(width / 2)
2975
+ const centerY = Math.round(height / 2)
2976
+ const topInset = Math.max(24, statusBarHeight + 18)
2977
+ const bottomInset = Math.max(24, homeIndicatorHeight + 18)
2978
+ const edgeInset = 18
2979
+ const travelY = Math.min(220, Math.round(height * 0.24))
2980
+ const travelX = Math.min(180, Math.round(width * 0.32))
2981
+ let fromX = centerX
2982
+ let fromY = centerY
2983
+ let toX = centerX
2984
+ let toY = centerY
2985
+ switch (preset) {
2986
+ case 'scroll-up':
2987
+ fromY = centerY + Math.round(travelY / 2)
2988
+ toY = centerY - Math.round(travelY / 2)
2989
+ break
2990
+ case 'scroll-down':
2991
+ fromY = centerY - Math.round(travelY / 2)
2992
+ toY = centerY + Math.round(travelY / 2)
2993
+ break
2994
+ case 'scroll-left':
2995
+ fromX = centerX + Math.round(travelX / 2)
2996
+ toX = centerX - Math.round(travelX / 2)
2997
+ break
2998
+ case 'scroll-right':
2999
+ fromX = centerX - Math.round(travelX / 2)
3000
+ toX = centerX + Math.round(travelX / 2)
3001
+ break
3002
+ case 'swipe-from-left-edge':
3003
+ fromX = edgeInset
3004
+ fromY = centerY
3005
+ toX = Math.min(width - edgeInset, edgeInset + travelX)
3006
+ break
3007
+ case 'swipe-from-right-edge':
3008
+ fromX = width - edgeInset
3009
+ fromY = centerY
3010
+ toX = Math.max(edgeInset, width - edgeInset - travelX)
3011
+ break
3012
+ case 'swipe-from-top-edge':
3013
+ fromX = centerX
3014
+ fromY = topInset
3015
+ toY = Math.min(height - bottomInset, topInset + travelY)
3016
+ break
3017
+ case 'swipe-from-bottom-edge':
3018
+ fromX = centerX
3019
+ fromY = height - bottomInset
3020
+ toY = Math.max(topInset, height - bottomInset - travelY)
3021
+ break
3022
+ // no default — `preset` is validated against GESTURE_PRESETS above.
3023
+ }
3024
+ const steps = Math.max(8, Math.round(durationMs / 16))
3025
+ const stepMs = Math.max(1, Math.round(durationMs / steps))
3026
+ const result = await performSingleStep(bridge, {
3027
+ type: 'drag',
3028
+ fromX,
3029
+ fromY,
3030
+ toX,
3031
+ toY,
3032
+ steps,
3033
+ stepMs,
3034
+ })
3035
+ if (result?.ok) {
3036
+ await recordInspectAction(
3037
+ 'inspect gesture',
3038
+ {
3039
+ swipe: {
3040
+ start: `${fromX}, ${fromY}`,
3041
+ end: `${toX}, ${toY}`,
3042
+ duration: Math.max(1, Math.round(durationMs)),
3043
+ },
3044
+ },
3045
+ `gesture ${preset}`,
3046
+ )
3047
+ }
3048
+ console.log(
3049
+ JSON.stringify(
3050
+ { preset, from: { x: fromX, y: fromY }, to: { x: toX, y: toY }, result },
3051
+ null,
3052
+ 2,
3053
+ ),
3054
+ )
3055
+ break
3056
+ }
3057
+
3058
+ case 'scroll': {
3059
+ if (args.some((arg) => arg === '--node-id' || arg.startsWith('--node-id='))) {
3060
+ console.error(' scroll takes a testID; --node-id is not supported')
3061
+ console.error(inspectUsage('scroll', '<id> <x> <y> | --testid <id> <x> <y>'))
3062
+ rnxExit(1)
3063
+ }
3064
+ const target = readTargetFlag(args)
3065
+ const id = target?.mode === 'testid' ? target.value : positional[1]
3066
+ const offset = target ? 1 : 2
3067
+ const x = Number(positional[offset])
3068
+ const y = Number(positional[offset + 1])
3069
+ if (!id || !Number.isFinite(x) || !Number.isFinite(y)) {
3070
+ console.error(inspectUsage('scroll', '<id> <x> <y> | --testid <id> <x> <y>'))
3071
+ rnxExit(1)
3072
+ }
3073
+ const resolved = await resolveTargetCoords(bridge, { mode: 'testid', value: id })
3074
+ const result = await performSingleStep(bridge, {
3075
+ type: 'scroll',
3076
+ id,
3077
+ x,
3078
+ y,
3079
+ animated: false,
3080
+ })
3081
+ const scrollNode = resolved ? { cx: resolved.x, cy: resolved.y } : null
3082
+ if (result?.ok) {
3083
+ await recordInspectAction(
3084
+ 'inspect scroll',
3085
+ { scrollTo: { id, x, y } },
3086
+ `scroll #${id} -> ${x},${y}`,
3087
+ )
3088
+ }
3089
+ console.log(
3090
+ JSON.stringify(
3091
+ {
3092
+ ...result,
3093
+ ...(scrollNode ? { at: { x: scrollNode.cx, y: scrollNode.cy } } : {}),
3094
+ },
3095
+ null,
3096
+ 2,
3097
+ ),
3098
+ )
3099
+ break
3100
+ }
3101
+
3102
+ case 'state': {
3103
+ const stateCmd = positional[1]
3104
+ // `rnx get state` (no sub-arg) → compact runtime dashboard.
3105
+ // `rnx debug state <kind>` continues to dispatch the raw dumps.
3106
+ if (verbPrefix === 'get' && !stateCmd) {
3107
+ const base = await callTestBridge<Record<string, any>>(
3108
+ bridge,
3109
+ 'getRuntimeState',
3110
+ )
3111
+ const diag = await bridge.send({
3112
+ type: 'evaluate',
3113
+ code: MERGED_CONSOLE_COUNT_EVAL,
3114
+ })
3115
+ if (base && typeof base === 'object' && base.diagnostics) {
3116
+ base.diagnostics.errors = diag?.errors ?? 0
3117
+ base.diagnostics.warnings = diag?.warnings ?? 0
3118
+ }
3119
+ if (base && typeof base === 'object' && base.shell == null) {
3120
+ try {
3121
+ const shell = await getShellState(bridge)
3122
+ if (shell) base.shell = shell
3123
+ } catch {}
3124
+ }
3125
+ console.log(JSON.stringify(base, null, 2))
3126
+ break
3127
+ }
3128
+ if (!stateCmd || stateCmd === '--help' || stateCmd === '-h') {
3129
+ console.log(`
3130
+ ${inspectCommand('state')} — dump raw runtime state
3131
+
3132
+ subcommands:
3133
+ shell dump shell transition/layout state
3134
+ worker dump render-worker host/animation state
3135
+ keyboard dump keyboard visibility, mode, and focused input
3136
+ ownership dump surface ownership + pointerOnSurface delivery stats
3137
+ worklets dump tenant + shell worklet-runtime slot/handler counts
3138
+ scroll-input dump shell-owned scroll gesture and momentum state
3139
+ scroll-mirror dump shell scroll registry entries and offsets
3140
+ node <id> dump raw node info by id or testID
3141
+ scroll <id> dump scroll metrics and runtime state
3142
+ scroll-hit <x> <y> dump the nearest scroll ancestor at coordinates
3143
+ hit <x> <y> dump the hit-test ancestry at coordinates
3144
+ gesture <x> <y> dump gesture routing/debug info at coordinates
3145
+ gesture-seam dump shell-hosted gesture recognizer state/events
3146
+
3147
+ examples:
3148
+ ${inspectCommand('state')} shell
3149
+ ${inspectCommand('state')} worker
3150
+ ${inspectCommand('state')} keyboard
3151
+ ${inspectCommand('state')} ownership
3152
+ ${inspectCommand('state')} worklets
3153
+ ${inspectCommand('state')} scroll-input
3154
+ ${inspectCommand('state')} scroll-mirror
3155
+ ${inspectCommand('state')} node photos
3156
+ ${inspectCommand('state')} scroll feed
3157
+ ${inspectCommand('state')} scroll-hit 360 420
3158
+ ${inspectCommand('state')} hit 200 720
3159
+ `)
3160
+ break
3161
+ }
3162
+
3163
+ let result: unknown
3164
+ switch (stateCmd) {
3165
+ case 'shell':
3166
+ result = await getShellState(bridge, 500)
3167
+ break
3168
+ case 'worker':
3169
+ result = await callInBridge(bridge, '__sootsimRenderHost.queryStats')
3170
+ break
3171
+ case 'ownership':
3172
+ // plan P1: one authoritative read model for surface ownership
3173
+ // + pointerOnSurface delivery observability. bridges through
3174
+ // __sootsimRenderHost.getOwnershipSnapshot which reads the
3175
+ // shell scene, registered aux surfaces, and
3176
+ // pointer-delivery.ts stats.
3177
+ result = await bridge.send({
3178
+ type: 'evaluate',
3179
+ code: `(() => {
3180
+ const h = window.__sootsimRenderHost
3181
+ if (!h || typeof h.getOwnershipSnapshot !== 'function') {
3182
+ return { error: 'getOwnershipSnapshot not available' }
3183
+ }
3184
+ return h.getOwnershipSnapshot()
3185
+ })()`,
3186
+ })
3187
+ break
3188
+ case 'keyboard':
3189
+ // focused input sourced from __sootsimTest.getFocusedNode() —
3190
+ // single authoritative source. getLayout() ships the full
3191
+ // keyboard state bundle (spec, mode, shifted, capsLock, accessory).
3192
+ result = await bridge.send({
3193
+ type: 'evaluate',
3194
+ code: `(async () => {
3195
+ const kb = window.__sootsimKeyboard
3196
+ const test = window.__sootsimTest
3197
+ if (!kb) return { error: 'keyboard bridge not available' }
3198
+ const layout = typeof kb.getLayout === 'function' ? kb.getLayout() : null
3199
+ const secureTextEntry =
3200
+ !!layout?.spec?.secureTextEntry && layout?.spec?.keyboardType !== 'visible-password'
3201
+ const visible = kb.isVisible()
3202
+ const mode = kb.getMode()
3203
+ let focused = null
3204
+ if (test && typeof test.getFocusedNode === 'function') {
3205
+ try {
3206
+ focused = await test.getFocusedNode()
3207
+ } catch {}
3208
+ }
3209
+ return {
3210
+ visible,
3211
+ mode,
3212
+ layout,
3213
+ focusedInput: focused ? {
3214
+ nodeId: focused.nodeId ?? null,
3215
+ testID: focused.testID || null,
3216
+ id: focused.id || null,
3217
+ placeholder: focused.placeholder || null,
3218
+ secureTextEntry,
3219
+ text: secureTextEntry ? ${JSON.stringify(SECURE_TEXT_REDACTION)} : (focused.text || null),
3220
+ } : null,
3221
+ }
3222
+ })()`,
3223
+ })
3224
+ result = redactKeyboardStateForOutput(result as any)
3225
+ break
3226
+ case 'node': {
3227
+ const id = positional[2]
3228
+ if (!id) {
3229
+ console.error(` usage: ${inspectCommand('state')} node <id>`)
3230
+ rnxExit(1)
3231
+ }
3232
+ result =
3233
+ (await callTestBridge(bridge, 'findByTestId', id)) ||
3234
+ (await callTestBridge(bridge, 'findById', id))
3235
+ break
3236
+ }
3237
+ case 'scroll': {
3238
+ const id = positional[2]
3239
+ if (!id) {
3240
+ console.error(` usage: ${inspectCommand('state')} scroll <id>`)
3241
+ rnxExit(1)
3242
+ }
3243
+ result = await callTestBridge(bridge, 'getScrollState', id)
3244
+ break
3245
+ }
3246
+ case 'scroll-hit': {
3247
+ const x = Number(positional[2])
3248
+ const y = Number(positional[3])
3249
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
3250
+ console.error(` usage: ${inspectCommand('state')} scroll-hit <x> <y>`)
3251
+ rnxExit(1)
3252
+ }
3253
+ result = await callTestBridge(bridge, 'getScrollStateAt', x, y)
3254
+ break
3255
+ }
3256
+ case 'hit': {
3257
+ const x = Number(positional[2])
3258
+ const y = Number(positional[3])
3259
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
3260
+ console.error(` usage: ${inspectCommand('state')} hit <x> <y>`)
3261
+ rnxExit(1)
3262
+ }
3263
+ result = await callTestBridge(bridge, 'debugHitAt', x, y)
3264
+ break
3265
+ }
3266
+ case 'gesture': {
3267
+ const x = Number(positional[2])
3268
+ const y = Number(positional[3])
3269
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
3270
+ console.error(` usage: ${inspectCommand('state')} gesture <x> <y>`)
3271
+ rnxExit(1)
3272
+ }
3273
+ result = await callTestBridge(bridge, 'debugGestureAt', x, y)
3274
+ break
3275
+ }
3276
+ case 'gesture-seam':
3277
+ result = await callInBridge(
3278
+ bridge,
3279
+ 'SootSim.bridges.mainShell.callTestBridge',
3280
+ 'getShellGestureSeamDebug',
3281
+ )
3282
+ break
3283
+ case 'worklets': {
3284
+ // both worklet runtimes, side by side. the shell runtime is where
3285
+ // shell-sourced events (keyboard, scroll) are dispatched from, so a
3286
+ // handler the tenant holds locally instead of forwarding will never
3287
+ // fire. compare `eventHandlerNames` across the two to see which
3288
+ // side a given event is waiting on.
3289
+ const [tenant, shell] = await Promise.all([
3290
+ callTestBridge(bridge, 'getWorkletSlotStats'),
3291
+ callInBridge(
3292
+ bridge,
3293
+ 'SootSim.bridges.mainShell.callTestBridge',
3294
+ 'getWorkletSlotStats',
3295
+ ),
3296
+ ])
3297
+ result = { tenant, shell }
3298
+ break
3299
+ }
3300
+ case 'scroll-input':
3301
+ result = await callInBridge(
3302
+ bridge,
3303
+ 'SootSim.bridges.mainShell.callTestBridge',
3304
+ 'getShellScrollInputDebug',
3305
+ )
3306
+ break
3307
+ case 'scroll-mirror':
3308
+ result = await callInBridge(
3309
+ bridge,
3310
+ 'SootSim.bridges.mainShell.callTestBridge',
3311
+ 'getScrollMirrorDebug',
3312
+ )
3313
+ break
3314
+ default:
3315
+ console.error(` unknown state subcommand: ${stateCmd}`)
3316
+ rnxExit(1)
3317
+ }
3318
+ console.log(JSON.stringify(result, null, 2))
3319
+ break
3320
+ }
3321
+
3322
+ case 'shell': {
3323
+ const shellCmd = positional[1]
3324
+ if (!shellCmd || shellCmd === '--help' || shellCmd === '-h') {
3325
+ console.log(`
3326
+ ${inspectCommand('shell')} — run built-in shell commands
3327
+
3328
+ subcommands:
3329
+ launch <appId> [waitMs] [--clear-state]
3330
+ launch app and wait for settled shell state
3331
+ home [waitMs] go home and wait for settled shell state
3332
+ switcher [waitMs] open switcher and wait for settled shell state
3333
+ open-card <appId> [waitMs]
3334
+ open a specific switcher card and wait for app settle
3335
+ appearance <light|dark|auto|toggle>
3336
+ update simulator appearance
3337
+ lock toggle device lock state
3338
+ shake trigger the simulator shake gesture
3339
+
3340
+ examples:
3341
+ ${inspectCommand('shell')} launch photos
3342
+ ${inspectCommand('shell')} launch rn --clear-state
3343
+ ${inspectCommand('shell')} launch photos 1500
3344
+ ${inspectCommand('shell')} home 500
3345
+ ${inspectCommand('shell')} switcher 800
3346
+ ${inspectCommand('shell')} open-card clock 800
3347
+ ${inspectCommand('shell')} appearance dark
3348
+ ${inspectCommand('shell')} lock
3349
+ `)
3350
+ break
3351
+ }
3352
+
3353
+ const usesSettleMs =
3354
+ shellCmd === 'launch' ||
3355
+ shellCmd === 'open-card' ||
3356
+ shellCmd === 'home' ||
3357
+ shellCmd === 'switcher'
3358
+ const settleMsRaw =
3359
+ shellCmd === 'launch' || shellCmd === 'open-card'
3360
+ ? positional[3]
3361
+ : positional[2]
3362
+ const settleMs = settleMsRaw ? Number(settleMsRaw) : 350
3363
+ if (usesSettleMs && (!Number.isFinite(settleMs) || settleMs < 0)) {
3364
+ console.error(
3365
+ inspectUsage(
3366
+ 'shell',
3367
+ shellCmd === 'launch' || shellCmd === 'open-card'
3368
+ ? '<launch|open-card> <appId> [settleMs]'
3369
+ : '<home|switcher> [settleMs]',
3370
+ ),
3371
+ )
3372
+ rnxExit(1)
3373
+ }
3374
+
3375
+ let ok = false
3376
+ let settled = false
3377
+ let state: Record<string, any> | null = null
3378
+ const clearState = args.includes('--clear-state')
3379
+ if (shellCmd === 'launch') {
3380
+ const appId = positional[2]
3381
+ if (!appId) {
3382
+ console.error(
3383
+ inspectUsage('shell', 'launch <appId> [settleMs] [--clear-state]'),
3384
+ )
3385
+ rnxExit(1)
3386
+ }
3387
+ if (clearState) {
3388
+ await bridge.send({
3389
+ type: 'evaluate',
3390
+ code: resetGuestAppStateEval(true),
3391
+ })
3392
+ }
3393
+ ok = !!(await callShellCommandWhenReady(bridge, 'launchApp', settleMs, appId))
3394
+ ;({ settled, state } = await waitForShellState(
3395
+ bridge,
3396
+ Math.round(settleMs),
3397
+ (currentState) =>
3398
+ !!currentState &&
3399
+ currentState.state === 'app' &&
3400
+ currentState.activeApp === appId &&
3401
+ currentState.showSwitcher === false &&
3402
+ currentState.switcherPhase === 'idle' &&
3403
+ typeof currentState.launchProgress === 'number' &&
3404
+ currentState.launchProgress >= 0.98,
3405
+ ))
3406
+ if (ok) {
3407
+ const step = clearState
3408
+ ? { launchApp: { clearState: true } }
3409
+ : { launchApp: {} }
3410
+ const summary = clearState ? 'launch app (clear state)' : 'launch app'
3411
+ await recordInspectAction('inspect shell launch', step, summary)
3412
+ }
3413
+ } else if (shellCmd === 'home') {
3414
+ ok = !!(await callShellCommandWhenReady(bridge, 'goHome', settleMs))
3415
+ ;({ settled, state } = await waitForShellState(
3416
+ bridge,
3417
+ Math.round(settleMs),
3418
+ (currentState) =>
3419
+ !!currentState &&
3420
+ currentState.state === 'home' &&
3421
+ currentState.activeApp == null &&
3422
+ currentState.showSwitcher === false &&
3423
+ currentState.switcherPhase === 'idle' &&
3424
+ typeof currentState.launchProgress === 'number' &&
3425
+ currentState.launchProgress >= 0.98,
3426
+ ))
3427
+ } else if (shellCmd === 'switcher') {
3428
+ ok = !!(await callShellCommandWhenReady(bridge, 'openSwitcher', settleMs))
3429
+ ;({ settled, state } = await waitForShellState(
3430
+ bridge,
3431
+ Math.round(settleMs),
3432
+ (currentState) =>
3433
+ !!currentState &&
3434
+ currentState.state === 'app' &&
3435
+ currentState.showSwitcher === true &&
3436
+ currentState.switcherPhase === 'idle' &&
3437
+ typeof currentState.zoomLevel === 'number' &&
3438
+ Math.abs(currentState.zoomLevel) <= 0.02 &&
3439
+ typeof currentState.horizontalZoom === 'number' &&
3440
+ Math.abs(currentState.horizontalZoom) <= 0.02,
3441
+ ))
3442
+ if (settled) {
3443
+ await sleep(SWITCHER_SETTLE_GRACE_MS)
3444
+ state = await getShellState(bridge)
3445
+ }
3446
+ } else if (shellCmd === 'open-card') {
3447
+ const appId = positional[2]
3448
+ if (!appId) {
3449
+ console.error(inspectUsage('shell', 'open-card <appId> [settleMs]'))
3450
+ rnxExit(1)
3451
+ }
3452
+ ok = !!(await callShellCommandWhenReady(
3453
+ bridge,
3454
+ 'openSwitcherCard',
3455
+ settleMs,
3456
+ appId,
3457
+ ))
3458
+ ;({ settled, state } = await waitForShellState(
3459
+ bridge,
3460
+ Math.round(settleMs),
3461
+ (currentState) =>
3462
+ !!currentState &&
3463
+ currentState.state === 'app' &&
3464
+ currentState.activeApp === appId &&
3465
+ currentState.showSwitcher === false &&
3466
+ currentState.switcherPhase === 'idle' &&
3467
+ typeof currentState.zoomLevel === 'number' &&
3468
+ currentState.zoomLevel >= 0.98 &&
3469
+ typeof currentState.horizontalZoom === 'number' &&
3470
+ currentState.horizontalZoom >= 0.98,
3471
+ ))
3472
+ if (ok) {
3473
+ await recordInspectAction(
3474
+ 'inspect shell open-card',
3475
+ { openSwitcherCard: { appId } },
3476
+ `open switcher card ${appId}`,
3477
+ )
3478
+ }
3479
+ } else if (shellCmd === 'appearance') {
3480
+ const mode = positional[2]
3481
+ if (!mode || !['light', 'dark', 'auto', 'toggle'].includes(mode)) {
3482
+ console.error(inspectUsage('shell', 'appearance <light|dark|auto|toggle>'))
3483
+ rnxExit(1)
3484
+ }
3485
+ const result = await runShellVisualCommand(bridge, 'appearance', mode)
3486
+ ok = !!result?.ok
3487
+ state = { appearance: result }
3488
+ // human-readable confirmation line. without this the only output
3489
+ // is the multi-line JSON blob below, whose last line is a bare
3490
+ // `}` — useless when scanned or piped through `tail -1` (F13-8).
3491
+ if (ok) {
3492
+ const applied = (result as { applied?: string } | null)?.applied ?? mode
3493
+ console.log(` appearance: ${applied}`)
3494
+ }
3495
+ } else if (shellCmd === 'lock' || shellCmd === 'shake') {
3496
+ const result = await runShellVisualCommand(bridge, shellCmd)
3497
+ ok = !!result?.ok
3498
+ state = { [shellCmd]: result }
3499
+ } else {
3500
+ console.error(` unknown shell subcommand: ${shellCmd}`)
3501
+ rnxExit(1)
3502
+ }
3503
+ console.log(JSON.stringify({ ok, settled, state }, null, 2))
3504
+ break
3505
+ }
3506
+
3507
+ case 'url': {
3508
+ await runUrlSubcommand(bridge, { args: effectiveArgs })
3509
+ break
3510
+ }
3511
+
3512
+ case 'reload': {
3513
+ // a cloud simulator is a headless runtime with no page to reload, and
3514
+ // rnx itself runs inside the box's workerd isolate rather than a node
3515
+ // process, so the full-page branch below cannot work: it reconnects
3516
+ // through `waitForBridgeConnected`, whose `createBridge` constructs the
3517
+ // `ws` package's WebSocket, and that import has no constructor in
3518
+ // workerd.
3519
+ if (bridge.plane === 'cloud') {
3520
+ const message =
3521
+ 'rnx do reload is not available on a cloud simulator; it runs headless with no page to reload. restart the simulator instead.'
3522
+ if (wantsJson(effectiveArgs)) {
3523
+ printJson({ cleared: false, ready: false, error: message })
3524
+ } else {
3525
+ console.error(` ${message}`)
3526
+ }
3527
+ rnxExit(1)
3528
+ }
3529
+ // reload waits for the guest app's `sootsim:externalAppReady` signal
3530
+ // (not just a live ws connection), with a 10s budget. fatal bundle
3531
+ // failures surface as console errors that pile up fast — bail early
3532
+ // in that case instead of waiting the full budget.
3533
+ const READY_TIMEOUT_MS = 10000
3534
+ let issuedReload = false
3535
+ let inPlaceReload = false
3536
+ try {
3537
+ await bridge.send({
3538
+ type: 'evaluate',
3539
+ code: 'window.__sootsimConsole?.clear()',
3540
+ })
3541
+ const reloadResult = await bridge.send({
3542
+ type: 'evaluate',
3543
+ code: `;(async () => {
3544
+ // in-place guest reload is only valid while the page still runs
3545
+ // the engine build the server would serve NOW. the shell dev
3546
+ // server has no HMR client, so a long-lived sim tab otherwise
3547
+ // keeps a stale engine forever while builds churn underneath it.
3548
+ // compare the inlined engine manifest against a fresh fetch of
3549
+ // this page's html; any drift means the whole page must reload.
3550
+ let engineStale = false
3551
+ try {
3552
+ const loaded = document.getElementById('__sootsim-engine-manifest')?.textContent
3553
+ if (loaded) {
3554
+ const res = await fetch(location.href, { cache: 'no-store' })
3555
+ const html = await res.text()
3556
+ const m = html.match(/<script[^>]*id="__sootsim-engine-manifest"[^>]*>([^<]*)<\\/script>/)
3557
+ if (m && m[1] !== loaded) engineStale = true
3558
+ }
3559
+ } catch {}
3560
+ const reloadExternalApp = window.SootSim?.bridges?.hotRemount?.reloadExternalApp
3561
+ if (!engineStale && typeof reloadExternalApp === 'function') {
3562
+ reloadExternalApp()
3563
+ return { kind: 'external-app' }
3564
+ }
3565
+ window.location.reload()
3566
+ return { kind: 'page', engineStale }
3567
+ })()`,
3568
+ })
3569
+ inPlaceReload = !!reloadResult && reloadResult.kind === 'external-app'
3570
+ issuedReload = true
3571
+ if (reloadResult && reloadResult.engineStale) {
3572
+ console.log(' engine build changed since page load — full page reload')
3573
+ }
3574
+ } catch {
3575
+ // the sim may already be in the middle of reloading; fall through to reconnect wait
3576
+ }
3577
+ console.log(' reloading...')
3578
+ let finalBridge: WsBridge | null = bridge
3579
+ let readyResult: ReloadReadyResult | null = null
3580
+ if (inPlaceReload) {
3581
+ readyResult = await pollForReloadReady(bridge, {
3582
+ timeoutMs: READY_TIMEOUT_MS,
3583
+ })
3584
+ } else {
3585
+ // full page reload — wait for a fresh bridge before polling for ready.
3586
+ // engine-drift escalation made page reloads the COMMON case during
3587
+ // engine iteration, and a full engine boot runs 5-8s unloaded and
3588
+ // well past 10s on a busy box — a 10s reconnect budget here reads
3589
+ // as "bridge-reconnect fails" exactly when someone is rebuilding.
3590
+ const PAGE_RELOAD_TIMEOUT_MS = 30000
3591
+ if (issuedReload) await sleep(300)
3592
+ const reconnected = await waitForBridgeConnected(
3593
+ wsPort,
3594
+ commandTimeoutMs,
3595
+ simId,
3596
+ { timeoutMs: PAGE_RELOAD_TIMEOUT_MS, simIdSource },
3597
+ )
3598
+ if (reconnected) {
3599
+ finalBridge = reconnected
3600
+ readyResult = await pollForReloadReady(reconnected, {
3601
+ timeoutMs: PAGE_RELOAD_TIMEOUT_MS,
3602
+ })
3603
+ } else {
3604
+ console.log(
3605
+ ` ⚠ reload: bridge never reconnected within ${PAGE_RELOAD_TIMEOUT_MS}ms`,
3606
+ )
3607
+ finalBridge = null
3608
+ }
3609
+ }
3610
+ if (readyResult) {
3611
+ if (readyResult.ready) {
3612
+ const suffix =
3613
+ readyResult.source === 'nodes-fallback'
3614
+ ? ' (no ready signal, node-count fallback)'
3615
+ : ''
3616
+ console.log(
3617
+ ` ready in ${readyResult.elapsedMs}ms: ${readyResult.nodes} nodes${suffix}`,
3618
+ )
3619
+ } else if (readyResult.source === 'error-bail') {
3620
+ console.log(
3621
+ ` ⚠ reload bailed after ${readyResult.elapsedMs}ms: ${readyResult.errors} console error(s), ready signal never fired`,
3622
+ )
3623
+ } else {
3624
+ const reason = describeReloadReadyBlocker(readyResult)
3625
+ console.log(
3626
+ ` ⚠ reload timed out after ${readyResult.elapsedMs}ms — ${reason} (nodes: ${readyResult.nodes}, targets: ${readyResult.targets}, errors: ${readyResult.errors})`,
3627
+ )
3628
+ }
3629
+ }
3630
+ // dump captured console errors for context, regardless of outcome.
3631
+ // merged read so a render-worker / root-error-boundary mount crash
3632
+ // (forwarded only through the observability store) is shown here.
3633
+ if (finalBridge) {
3634
+ try {
3635
+ const errors = await inspectErrors(finalBridge, 10)
3636
+ if (finalBridge !== bridge) {
3637
+ finalBridge.close()
3638
+ }
3639
+ if (Array.isArray(errors) && errors.length > 0) {
3640
+ console.log(`\n ⚠ ${errors.length} error(s) during mount:\n`)
3641
+ for (const e of errors) {
3642
+ const msg = e.args
3643
+ .map((a: any) => (typeof a === 'object' ? JSON.stringify(a) : a))
3644
+ .join(' ')
3645
+ console.log(` ${msg}`)
3646
+ if (e.stack) {
3647
+ const lines = e.stack.split('\n').slice(0, 2)
3648
+ for (const line of lines) {
3649
+ console.log(` ${line.trim()}`)
3650
+ }
3651
+ }
3652
+ }
3653
+ }
3654
+ } catch {
3655
+ // ignore errors checking for errors
3656
+ }
3657
+ }
3658
+ if (readyResult && !readyResult.ready) {
3659
+ rnxExit(1)
3660
+ }
3661
+ break
3662
+ }
3663
+
3664
+ case 'storage-clear': {
3665
+ const READY_TIMEOUT_MS = 10000
3666
+ await clearConsole(bridge)
3667
+ const cleared = await bridge.send({
3668
+ type: 'evaluate',
3669
+ code: resetGuestAppStateEval(true),
3670
+ })
3671
+ if (cleared !== true) {
3672
+ if (wantsJson(effectiveArgs)) {
3673
+ printJson({
3674
+ cleared: false,
3675
+ ready: false,
3676
+ error: 'external app reload bridge is unavailable',
3677
+ })
3678
+ } else {
3679
+ console.error(
3680
+ ' storage clear failed: external app reload bridge is unavailable',
3681
+ )
3682
+ }
3683
+ rnxExit(1)
3684
+ }
3685
+
3686
+ const readyResult = await pollForReloadReady(bridge, {
3687
+ timeoutMs: READY_TIMEOUT_MS,
3688
+ })
3689
+
3690
+ if (wantsJson(effectiveArgs)) {
3691
+ printJson({ cleared: true, ready: readyResult.ready, reload: readyResult })
3692
+ if (!readyResult.ready) rnxExit(1)
3693
+ break
3694
+ }
3695
+
3696
+ if (readyResult.ready) {
3697
+ const suffix =
3698
+ readyResult.source === 'nodes-fallback'
3699
+ ? ' (no ready signal, node-count fallback)'
3700
+ : ''
3701
+ console.log(
3702
+ ` cleared tenant storage; ready in ${readyResult.elapsedMs}ms: ${readyResult.nodes} nodes${suffix}`,
3703
+ )
3704
+ } else if (readyResult.source === 'error-bail') {
3705
+ console.log(
3706
+ ` ⚠ storage clear reloaded but bailed after ${readyResult.elapsedMs}ms: ${readyResult.errors} console error(s), ready signal never fired`,
3707
+ )
3708
+ } else {
3709
+ const reason = describeReloadReadyBlocker(readyResult)
3710
+ console.log(
3711
+ ` ⚠ storage clear reloaded but timed out after ${readyResult.elapsedMs}ms — ${reason} (nodes: ${readyResult.nodes}, targets: ${readyResult.targets}, errors: ${readyResult.errors})`,
3712
+ )
3713
+ }
3714
+
3715
+ try {
3716
+ const errors = await inspectErrors(bridge, 10)
3717
+ if (Array.isArray(errors) && errors.length > 0) {
3718
+ console.log(`\n ⚠ ${errors.length} error(s) during mount:\n`)
3719
+ for (const e of errors) {
3720
+ const msg = e.args
3721
+ .map((a: any) => (typeof a === 'object' ? JSON.stringify(a) : a))
3722
+ .join(' ')
3723
+ console.log(` ${msg}`)
3724
+ if (e.stack) {
3725
+ const lines = e.stack.split('\n').slice(0, 2)
3726
+ for (const line of lines) {
3727
+ console.log(` ${line.trim()}`)
3728
+ }
3729
+ }
3730
+ }
3731
+ }
3732
+ } catch {
3733
+ // ignore errors checking for errors
3734
+ }
3735
+ if (!readyResult.ready) {
3736
+ rnxExit(1)
3737
+ }
3738
+ break
3739
+ }
3740
+
3741
+ case 'js': {
3742
+ const code = positional.slice(1).join(' ')
3743
+ if (!code) {
3744
+ console.error(inspectUsage('js', '<javascript>'))
3745
+ console.error('')
3746
+ console.error(' runs the snippet in the engine realm. rnx is the')
3747
+ console.error(' canonical state surface — reach into it directly.')
3748
+ console.error('')
3749
+ console.error(' examples:')
3750
+ console.error(
3751
+ ` ${inspectCommand('js')} SootSim.bridges.test.findByText("Sign in")`,
3752
+ )
3753
+ console.error(
3754
+ ` ${inspectCommand('js')} SootSim.bridges.debug.snapshot("before")`,
3755
+ )
3756
+ console.error(
3757
+ ` ${inspectCommand('js')} SootSim.bridges.keyboard.type("hello")`,
3758
+ )
3759
+ console.error(` ${inspectCommand('js')} SootSim.state.root.children.length`)
3760
+ rnxExit(1)
3761
+ }
3762
+ // --tenant: evaluate inside the TENANT worker's global scope (guest
3763
+ // bundle, metro module table, compat stubs) via the test bridge's
3764
+ // evalInTenant escape hatch, instead of the host/engine realm.
3765
+ if (args.includes('--tenant')) {
3766
+ // the flag itself lands in the joined positional tail — drop it
3767
+ const tenantCode = positional
3768
+ .slice(1)
3769
+ .filter((t) => t !== '--tenant')
3770
+ .join(' ')
3771
+ const result = await bridge.send({
3772
+ type: 'evaluate',
3773
+ code: `(async () => SootSim.bridges.test.evalInTenant(${JSON.stringify(tenantCode)}))()`,
3774
+ })
3775
+ console.log(JSON.stringify(result, null, 2))
3776
+ break
3777
+ }
3778
+ // wrap in async to support await in worker mode. no identifier
3779
+ // rewriting — the snippet runs verbatim so tokens like `root`,
3780
+ // `debug`, `test` refer to whatever the user actually typed.
3781
+ // multi-statement snippets need a block body: the bare expression
3782
+ // form terminates at the first `;` (Unexpected token).
3783
+ let expandedCode = code
3784
+ if (!expandedCode.startsWith('(async')) {
3785
+ expandedCode = /[;\n]/.test(expandedCode)
3786
+ ? `(async () => { ${expandedCode} })()`
3787
+ : `(async () => ${expandedCode})()`
3788
+ }
3789
+ const result = await bridge.send({ type: 'evaluate', code: expandedCode })
3790
+ console.log(JSON.stringify(result, null, 2))
3791
+
3792
+ // suggest CLI commands for common verbose eval patterns
3793
+ const lc = code.toLowerCase()
3794
+ const suggestions: string[] = []
3795
+ if (lc.includes('sootsim:gohome') || lc.includes('gohome'))
3796
+ suggestions.push('rnx shell home')
3797
+ if (lc.includes('sootsim:appswitcher') || lc.includes('appswitcher'))
3798
+ suggestions.push('rnx shell switcher')
3799
+ if (lc.includes('keyboard.isvisible') || lc.includes('keyboard.getmode'))
3800
+ suggestions.push('rnx debug state keyboard')
3801
+ if (lc.includes('interact.tap')) suggestions.push('rnx do tap <x> <y>')
3802
+ if (lc.includes('keyboard.type')) suggestions.push('rnx do type <text>')
3803
+ if (lc.includes('keyboard.press') || lc.includes('keyboard.dispatchkey'))
3804
+ suggestions.push('rnx do key <name>')
3805
+ if (lc.includes('keyboard.dismiss')) suggestions.push('rnx do dismiss')
3806
+ if (lc.includes('dumptree')) suggestions.push('rnx get tree')
3807
+ if (lc.includes('dumpaccessibilitytree')) suggestions.push('rnx get a11y')
3808
+ if (lc.includes('getnodecount')) suggestions.push('rnx get count')
3809
+ if (lc.includes('findbytext')) suggestions.push('rnx find <text>')
3810
+ if (lc.includes('findbytestid') || lc.includes('findbyid'))
3811
+ suggestions.push('rnx find --testid <id>')
3812
+ if (lc.includes('document.hidden'))
3813
+ suggestions.push('rnx debug state keyboard (includes tab health)')
3814
+ if (suggestions.length > 0) {
3815
+ maybeHint('prefer-cli-over-eval', suggestions)
3816
+ }
3817
+
3818
+ break
3819
+ }
3820
+
3821
+ case 'globals': {
3822
+ // show all available rnx globals and their methods
3823
+ const info = await bridge.send({
3824
+ type: 'evaluate',
3825
+ code: `(async () => {
3826
+ const globals = {}
3827
+
3828
+ // test bridge (proxy in worker mode)
3829
+ const testMethods = [
3830
+ 'findById', 'findByTestId', 'findByText', 'findByLabel', 'findByRole',
3831
+ 'findAllByRole', 'findByA11yState', 'findAllByA11yState', 'findByHint',
3832
+ 'findPressable', 'getStyle', 'getLayout', 'getAbsolutePosition', 'isVisible',
3833
+ 'queryAll', 'dumpTree', 'dumpAccessibilityTree', 'getNodeCount', 'getShellState',
3834
+ 'getScrollState', 'getScrollStateAt', 'scrollTo', 'waitForTree',
3835
+ 'waitForScreenTransitions', 'debugByText', 'debugByTestId', 'debugHitAt',
3836
+ 'debugGestureAt'
3837
+ ]
3838
+ globals['test (→ __sootsimTest)'] = testMethods
3839
+
3840
+ // debug
3841
+ if (window.__sootsimDebug) {
3842
+ globals['debug (→ __sootsimDebug)'] = Object.keys(window.__sootsimDebug)
3843
+ }
3844
+
3845
+ // interact
3846
+ if (window.__sootsimInteract) {
3847
+ globals['interact (→ __sootsimInteract)'] = Object.keys(window.__sootsimInteract)
3848
+ }
3849
+
3850
+ // keyboard
3851
+ if (window.__sootsimKeyboard) {
3852
+ globals['keyboard (→ __sootsimKeyboard)'] = Object.keys(window.__sootsimKeyboard)
3853
+ }
3854
+
3855
+ // other globals
3856
+ globals['other'] = [
3857
+ 'root (→ __sootsimRoot) - live node tree',
3858
+ 'render() (→ __sootsimForceRender) - force re-render'
3859
+ ]
3860
+
3861
+ return globals
3862
+ })()`,
3863
+ })
3864
+ console.log(' rnx JS API:\n')
3865
+ for (const [name, methods] of Object.entries(info as Record<string, string[]>)) {
3866
+ console.log(` ${name}:`)
3867
+ for (const m of methods) {
3868
+ console.log(` .${m}`)
3869
+ }
3870
+ console.log('')
3871
+ }
3872
+ console.log(` use: ${inspectCommand('js')} <expression>`)
3873
+ console.log(` example: ${inspectCommand('js')} test.findByText("Sign in")`)
3874
+ break
3875
+ }
3876
+
3877
+ case 'describe': {
3878
+ await runDescribeSubcommand({ bridge, args, positional })
3879
+ break
3880
+ }
3881
+
3882
+ case 'perf': {
3883
+ if (invocationPrefix === 'debug' && !opts.internalPerfCommand) {
3884
+ console.error(' `rnx debug perf` was removed. use `rnx perf shell ...`.')
3885
+ rnxExit(1)
3886
+ }
3887
+ const perfCmd = positional[1]
3888
+ if (opts.internalPerfCommand === 'scroll') {
3889
+ if (!perfCmd || perfCmd === '--help' || perfCmd === '-h') {
3890
+ console.log(`
3891
+ ${inspectCommand('perf')} <start|stop> [options]
3892
+
3893
+ records per-frame scroll offsets from the tenant worker, shell worker,
3894
+ and compositor worker. timestamps use the shared wall clock, while frame
3895
+ sequences remain separate because the workers do not share a frame id.
3896
+
3897
+ options:
3898
+ --limit <n> maximum samples retained per layer (default 6000)
3899
+ --json emit every per-frame sample on stop
3900
+
3901
+ examples:
3902
+ ${inspectCommand('perf')} start
3903
+ # ... perform consecutive swipes ...
3904
+ ${inspectCommand('perf')} stop
3905
+ ${inspectCommand('perf')} stop --json
3906
+ `)
3907
+ break
3908
+ }
3909
+ if (perfCmd === 'start') {
3910
+ const limitValue = effectiveArgs.find(
3911
+ (_, index) => effectiveArgs[index - 1] === '--limit',
3912
+ )
3913
+ const parsedLimit = limitValue === undefined ? 6000 : Number(limitValue)
3914
+ if (!Number.isFinite(parsedLimit) || parsedLimit < 120) {
3915
+ console.error(' error: --limit must be a number of at least 120')
3916
+ rnxExit(1)
3917
+ }
3918
+ const result = await bridge.send({
3919
+ type: 'evaluate',
3920
+ code: `(async () => {
3921
+ const perf = window.SootSim?.bridges?.scrollPerf
3922
+ if (!perf) return { error: 'scroll performance profile unavailable' }
3923
+ await perf.start(${Math.floor(parsedLimit)})
3924
+ return { started: true }
3925
+ })()`,
3926
+ })
3927
+ if (result?.error) {
3928
+ console.error(` error: ${result.error}`)
3929
+ rnxExit(1)
3930
+ }
3931
+ console.log(
3932
+ ` scroll profiling started: perform consecutive swipes, then run '${inspectCommand('perf')} stop'`,
3933
+ )
3934
+ break
3935
+ }
3936
+ if (perfCmd === 'stop') {
3937
+ const result = await bridge.send({
3938
+ type: 'evaluate',
3939
+ code: `(async () => {
3940
+ const perf = window.SootSim?.bridges?.scrollPerf
3941
+ if (!perf) return { error: 'scroll performance profile unavailable' }
3942
+ return await perf.stop()
3943
+ })()`,
3944
+ })
3945
+ if (result?.error) {
3946
+ console.error(` error: ${result.error}`)
3947
+ rnxExit(1)
3948
+ }
3949
+ const trace: SootSimScrollPerformanceTrace = result
3950
+ if (wantsJson(effectiveArgs)) {
3951
+ printJson(trace)
3952
+ break
3953
+ }
3954
+ console.log(` scroll performance trace:\n`)
3955
+ console.log(
3956
+ ` duration: ${trace.stoppedAt - trace.startedAt}ms (worker series are timestamp-aligned, not frame-paired)`,
3957
+ )
3958
+ console.log(
3959
+ ` samples: tenant ${trace.layers.tenant.length} · shell ${trace.layers.shell.length} · compositor ${trace.layers.compositor.length}`,
3960
+ )
3961
+ console.log(``)
3962
+ console.log(
3963
+ ` offset and phase changes (--json includes every per-frame sample):`,
3964
+ )
3965
+ console.log(
3966
+ ` t(ms) layer surface node offsetY slotY phase paint`,
3967
+ )
3968
+ const changedSamples: SootSimScrollPerformanceTrace['layers']['tenant'] = []
3969
+ for (const samples of Object.values(trace.layers)) {
3970
+ const lastByNode = new Map<string, string>()
3971
+ for (const sample of samples) {
3972
+ const key = `${sample.surfaceId}:${sample.nodeId}`
3973
+ const signature = `${sample.offsetY}:${sample.slotY ?? ''}:${sample.phase ?? ''}`
3974
+ if (lastByNode.get(key) === signature) continue
3975
+ lastByNode.set(key, signature)
3976
+ changedSamples.push(sample)
3977
+ }
3978
+ }
3979
+ changedSamples.sort((a, b) => a.t - b.t || a.seq - b.seq)
3980
+ for (const sample of changedSamples) {
3981
+ console.log(
3982
+ ` ${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}`,
3983
+ )
3984
+ }
3985
+ break
3986
+ }
3987
+ console.error(` unknown scroll perf command: ${perfCmd}`)
3988
+ rnxExit(1)
3989
+ }
3990
+ if (!perfCmd || perfCmd === '--help' || perfCmd === '-h') {
3991
+ console.log(`
3992
+ ${inspectCommand('perf')} — shell frame profiling (the worker that paints)
3993
+
3994
+ records per-painted-frame timing in the shell worker — the surface that
3995
+ actually draws the app's pixels — merged with render-profile counters
3996
+ (node visits, boundary records/replays, raster tier, blur ms, draw calls).
3997
+
3998
+ subcommands:
3999
+ start begin recording (clears prior frames + counters)
4000
+ stop stop recording and report results
4001
+ transition <e> profile a shell transition (goHome, appSwitcher, lockScreen)
4002
+
4003
+ stop and transition accept --json for the full machine-readable payload.
4004
+
4005
+ examples:
4006
+ ${inspectCommand('perf')} start
4007
+ # ... interact with the app ...
4008
+ ${inspectCommand('perf')} stop
4009
+ ${inspectCommand('perf')} stop --json
4010
+ ${inspectCommand('perf')} transition goHome
4011
+ `)
4012
+ break
4013
+ }
4014
+
4015
+ switch (perfCmd) {
4016
+ case 'start': {
4017
+ const result = await bridge.send({
4018
+ type: 'evaluate',
4019
+ code: `(() => {
4020
+ if (!window.__sootsimShellPerf) {
4021
+ return { error: 'shell frame profile unavailable (__sootsimShellPerf missing on the page)' }
4022
+ }
4023
+ window.__sootsimShellPerf.start()
4024
+ return { started: true }
4025
+ })()`,
4026
+ })
4027
+ if (result?.error) {
4028
+ console.error(` error: ${result.error}`)
4029
+ rnxExit(1)
4030
+ }
4031
+ console.log(
4032
+ ` shell profiling started — interact with the app, then run '${inspectCommand('perf')} stop'`,
4033
+ )
4034
+ break
4035
+ }
4036
+
4037
+ case 'stop': {
4038
+ const result = await bridge.send({
4039
+ type: 'evaluate',
4040
+ code: `(async () => {
4041
+ if (!window.__sootsimShellPerf) {
4042
+ return { error: 'shell frame profile unavailable (__sootsimShellPerf missing on the page)' }
4043
+ }
4044
+ return await window.__sootsimShellPerf.stop()
4045
+ })()`,
4046
+ })
4047
+ if (result?.error) {
4048
+ console.error(` error: ${result.error}`)
4049
+ if (result.error === 'timeout') {
4050
+ console.error(
4051
+ ' (shell worker did not answer within 5s — is a sim loaded?)',
4052
+ )
4053
+ }
4054
+ rnxExit(1)
4055
+ }
4056
+ if (wantsJson(effectiveArgs)) {
4057
+ printJson(result)
4058
+ break
4059
+ }
4060
+ printShellPerfReport(result)
4061
+ break
4062
+ }
4063
+
4064
+ case 'transition': {
4065
+ const event = positional[2]
4066
+ const validEvents = ['goHome', 'appSwitcher', 'lockScreen']
4067
+ if (!event || !validEvents.includes(event)) {
4068
+ console.log(`
4069
+ ${inspectCommand('perf')} transition <event> — profile a shell transition
4070
+
4071
+ events:
4072
+ goHome swipe-to-home animation
4073
+ appSwitcher app switcher card animation
4074
+ lockScreen lock screen transition
4075
+
4076
+ note: uses 600ms capture window — may need --timeout 10000 flag
4077
+
4078
+ examples:
4079
+ ${inspectCommand('perf')} transition goHome --timeout 10000
4080
+ ${inspectCommand('perf')} transition appSwitcher
4081
+ `)
4082
+ break
4083
+ }
4084
+
4085
+ const eventName = `sootsim:${event}`
4086
+ // transition profiling needs a longer timeout due to the 600ms
4087
+ // wait. these are progress/framing lines, so keep them on stderr —
4088
+ // stdout must stay a clean JSON document under --json.
4089
+ printWarn(` profiling ${event} transition...`)
4090
+ printWarn(` (use --timeout 10000 if this times out)`)
4091
+
4092
+ const result = await bridge.send({
4093
+ type: 'evaluate',
4094
+ code: `(async () => {
4095
+ if (!window.__sootsimShellPerf) {
4096
+ return { error: 'shell frame profile unavailable (__sootsimShellPerf missing on the page)' }
4097
+ }
4098
+ window.__sootsimShellPerf.start()
4099
+ // give a frame for profiling to engage before the event
4100
+ await new Promise(r => requestAnimationFrame(() => r(undefined)))
4101
+ window.dispatchEvent(new Event('${eventName}'))
4102
+ // shell animations are ~300-500ms; fixed timing avoids complex
4103
+ // animation-end detection
4104
+ await new Promise(r => setTimeout(r, 600))
4105
+ return await window.__sootsimShellPerf.stop()
4106
+ })()`,
4107
+ })
4108
+ if (result?.error) {
4109
+ console.error(` error: ${result.error}`)
4110
+ rnxExit(1)
4111
+ }
4112
+ if (wantsJson(effectiveArgs)) {
4113
+ printJson(result)
4114
+ break
4115
+ }
4116
+ printWarn(` ${event} transition profiled:`)
4117
+ printShellPerfReport(result)
4118
+ break
4119
+ }
4120
+
4121
+ default:
4122
+ console.error(` unknown perf subcommand: ${perfCmd}`)
4123
+ console.error(` valid: start, stop, transition`)
4124
+ // people reach for `perf --reset` (or `reset`) out of habit, but
4125
+ // `perf start` already clears the frame buffer — there is no
4126
+ // separate reset step. say so instead of just erroring.
4127
+ if (/^--?reset$/.test(perfCmd)) {
4128
+ console.error(
4129
+ ` note: 'perf start' already clears prior frames — no reset needed`,
4130
+ )
4131
+ }
4132
+ if (perfCmd === 'stats' || perfCmd === 'frames' || perfCmd === 'worst') {
4133
+ console.error(
4134
+ ` note: the tenant sampler (stats/frames/worst) was removed — it hardcoded`,
4135
+ )
4136
+ console.error(
4137
+ ` layout/render/copy to zero because the shell worker owns every real paint.`,
4138
+ )
4139
+ console.error(
4140
+ ` use 'perf start' / 'perf stop' (worst frames are in the stop report).`,
4141
+ )
4142
+ }
4143
+ rnxExit(1)
4144
+ }
4145
+ break
4146
+ }
4147
+ case 'errors': {
4148
+ const subcmd = positional[1]
4149
+ if (subcmd === 'clear') {
4150
+ await clearConsole(bridge)
4151
+ if (wantsJson(effectiveArgs)) printJson({ cleared: true })
4152
+ else console.log(' error buffer cleared')
4153
+ break
4154
+ }
4155
+ const limit = subcmd ? Number(subcmd) : 20
4156
+ const list = await inspectErrors(bridge, limit)
4157
+ if (wantsJson(effectiveArgs)) {
4158
+ printJson(list)
4159
+ break
4160
+ }
4161
+ if (list.length === 0) {
4162
+ console.log(' no errors captured')
4163
+ break
4164
+ }
4165
+ console.log(` ${list.length} error(s):\n`)
4166
+ for (const e of list) {
4167
+ const time = formatLogTimestamp(e.timestamp)
4168
+ const msg = e.args
4169
+ .map((a: any) => (typeof a === 'object' ? JSON.stringify(a) : a))
4170
+ .join(' ')
4171
+ console.log(` [${time}] ${msg}`)
4172
+ if (e.stack) {
4173
+ const lines = e.stack.split('\n').slice(0, 3)
4174
+ for (const line of lines) {
4175
+ console.log(` ${line.trim()}`)
4176
+ }
4177
+ }
4178
+ }
4179
+ break
4180
+ }
4181
+
4182
+ case 'warnings': {
4183
+ const limit = positional[1] ? Number(positional[1]) : 20
4184
+ const list = await inspectWarnings(bridge, limit)
4185
+ if (wantsJson(effectiveArgs)) {
4186
+ printJson(list)
4187
+ break
4188
+ }
4189
+ if (list.length === 0) {
4190
+ console.log(' no warnings captured')
4191
+ break
4192
+ }
4193
+ console.log(` ${list.length} warning(s):\n`)
4194
+ for (const w of list) {
4195
+ const time = formatLogTimestamp(w.timestamp)
4196
+ const msg = w.args
4197
+ .map((a: any) => (typeof a === 'object' ? JSON.stringify(a) : a))
4198
+ .join(' ')
4199
+ console.log(` [${time}] ${msg}`)
4200
+ }
4201
+ break
4202
+ }
4203
+
4204
+ case 'animations': {
4205
+ const anims =
4206
+ (await callTestBridge<Array<Record<string, any>>>(bridge, 'listAnimations')) ??
4207
+ []
4208
+ if (args.includes('--json')) {
4209
+ console.log(JSON.stringify(anims, null, 2))
4210
+ break
4211
+ }
4212
+ if (anims.length === 0) {
4213
+ console.log(' no active animations')
4214
+ break
4215
+ }
4216
+ console.log(` ${anims.length} active animation(s):\n`)
4217
+ for (const a of anims) {
4218
+ const realm = String(a.realm ?? 'tenant').padEnd(6)
4219
+ const kind = String(a.kind).padEnd(10)
4220
+ // a native-driver registration without an advertised graph has no
4221
+ // knowable endpoints or progress; print what is real, not NaN.
4222
+ const range =
4223
+ typeof a.from === 'number' && typeof a.to === 'number'
4224
+ ? `${a.from.toFixed(2)}→${a.to.toFixed(2)}`
4225
+ : '—'
4226
+ const cur = Number(a.current ?? 0).toFixed(2)
4227
+ const pct =
4228
+ typeof a.progress === 'number' ? `${Math.round(a.progress * 100)}%` : '—'
4229
+ const ms = `${Math.round(a.elapsedMs ?? 0)}ms`
4230
+ const tags = [
4231
+ a.loop ? 'loop' : null,
4232
+ a.layoutBound ? 'layout' : null,
4233
+ a.remoteDriven ? 'remote-driven' : a.graphBacked ? 'graph' : null,
4234
+ a.visible === false ? 'offscreen' : null,
4235
+ ].filter(Boolean)
4236
+ const tag = tags.length > 0 ? ` [${tags.join(' ')}]` : ''
4237
+ console.log(
4238
+ ` #${a.id} ${realm} ${kind} ${range.padEnd(14)} cur=${cur.padEnd(7)} ${pct.padStart(4)} ${ms}${tag}`,
4239
+ )
4240
+ }
4241
+ break
4242
+ }
4243
+
4244
+ case 'animation': {
4245
+ const raw = positional[1]
4246
+ if (!raw || raw === '--help' || raw === '-h') {
4247
+ console.error(` usage: ${inspectCommand('animation')} <id>`)
4248
+ rnxExit(1)
4249
+ }
4250
+ const id = Number(raw)
4251
+ if (!Number.isFinite(id)) {
4252
+ console.error(` invalid id: ${raw}`)
4253
+ rnxExit(1)
4254
+ }
4255
+ const result = await callTestBridge(bridge, 'getAnimation', id)
4256
+ console.log(JSON.stringify(result, null, 2))
4257
+ break
4258
+ }
4259
+
4260
+ case 'stop-animation': {
4261
+ const raw = positional[1]
4262
+ if (!raw || raw === '--help' || raw === '-h') {
4263
+ console.error(` usage: ${inspectCommand('stop-animation')} <id|all>`)
4264
+ rnxExit(1)
4265
+ }
4266
+ const target: number | 'all' = raw === 'all' ? 'all' : Number(raw)
4267
+ if (target !== 'all' && !Number.isFinite(target)) {
4268
+ console.error(` invalid id: ${raw}`)
4269
+ rnxExit(1)
4270
+ }
4271
+ const count = await callTestBridge<number>(bridge, 'stopAnimation', target)
4272
+ console.log(` stopped ${count ?? 0} animation(s)`)
4273
+ break
4274
+ }
4275
+
4276
+ case 'requests': {
4277
+ const subcmd = positional[1]
4278
+ if (subcmd === 'clear') {
4279
+ await clearRequests(bridge)
4280
+ if (wantsJson(effectiveArgs)) printJson({ cleared: true })
4281
+ else console.log(' request buffer cleared')
4282
+ break
4283
+ }
4284
+
4285
+ const showAll = subcmd === 'all'
4286
+ const rawLimit = showAll ? positional[2] : subcmd
4287
+ const limit = rawLimit ? Number(rawLimit) : 20
4288
+ const list = await inspectRequests(bridge, { failed: !showAll, limit })
4289
+ if (wantsJson(effectiveArgs)) {
4290
+ printJson(list)
4291
+ break
4292
+ }
4293
+ if (list.length === 0) {
4294
+ console.log(
4295
+ showAll ? ' no requests captured' : ' no failed requests captured',
4296
+ )
4297
+ break
4298
+ }
4299
+
4300
+ console.log(` ${list.length} ${showAll ? 'request(s)' : 'failed request(s)'}:\n`)
4301
+ for (const entry of list) {
4302
+ const time = formatLogTimestamp(entry.timestamp)
4303
+ console.log(` [${time}] ${formatRequestMessage(entry)}`)
4304
+ if (entry.responseBody) {
4305
+ console.log(` ${entry.responseBody}`)
4306
+ } else if (entry.error) {
4307
+ console.log(` ${entry.error}`)
4308
+ }
4309
+ }
4310
+ break
4311
+ }
4312
+
4313
+ case 'network': {
4314
+ // rnx network — live request inspector backed by the shared
4315
+ // observability store (installed at engine boot, fed by worker-side
4316
+ // fetch + XHR capture over BroadcastChannel).
4317
+ //
4318
+ // shapes:
4319
+ // rnx network [limit] last N entries (default 20)
4320
+ // rnx network --failed only non-2xx / errored
4321
+ // rnx network --slow only requests slower than --threshold (default 1000ms)
4322
+ // rnx network --threshold <ms> customize slow threshold for --slow
4323
+ // rnx network --filter <substring> url substring filter
4324
+ // rnx network --json raw json output
4325
+ // rnx network tail | -f follow mode (polls)
4326
+ // rnx network get <id> detail for one entry
4327
+ // rnx network clear drop the buffer
4328
+ const sub = positional[1]
4329
+
4330
+ // manual flag scan — parseBridgeCliArgs already stripped these
4331
+ // from positional but didn't give us the values, so re-walk the
4332
+ // raw input to pull --filter / --limit / --failed / --slow / --threshold / --tail / -f / --json.
4333
+ let filter: string | null = null
4334
+ let limitFlag: number | null = null
4335
+ let failedOnly = false
4336
+ let slowOnly = false
4337
+ let slowThresholdMs = 1000
4338
+ let tail = false
4339
+ let jsonOut = false
4340
+ for (let i = 0; i < effectiveArgs.length; i++) {
4341
+ const a = effectiveArgs[i]
4342
+ if (a === '--filter') {
4343
+ filter = effectiveArgs[i + 1] ?? null
4344
+ i++
4345
+ } else if (a === '--limit') {
4346
+ const n = Number(effectiveArgs[i + 1])
4347
+ if (Number.isFinite(n)) limitFlag = n
4348
+ i++
4349
+ } else if (a === '--threshold') {
4350
+ const n = Number(effectiveArgs[i + 1])
4351
+ if (Number.isFinite(n) && n > 0) slowThresholdMs = n
4352
+ i++
4353
+ } else if (a === '--failed') failedOnly = true
4354
+ else if (a === '--slow') slowOnly = true
4355
+ else if (a === '--tail' || a === '-f') tail = true
4356
+ else if (a === '--json') jsonOut = true
4357
+ }
4358
+
4359
+ if (sub === 'clear') {
4360
+ await bridge.send({
4361
+ type: 'evaluate',
4362
+ code: 'window.__sootsimObservability?.network.clear(); "cleared"',
4363
+ })
4364
+ console.log(' network buffer cleared')
4365
+ break
4366
+ }
4367
+
4368
+ if (sub === 'get') {
4369
+ const id = positional[2]
4370
+ if (!id) {
4371
+ console.error(' usage: rnx network get <id>')
4372
+ rnxExit(1)
4373
+ }
4374
+ const entry = (await bridge.send({
4375
+ type: 'evaluate',
4376
+ code: `(() => {
4377
+ const obs = window.__sootsimObservability;
4378
+ if (!obs) return null;
4379
+ return obs.network.getSnapshot().find(e => e.id === ${JSON.stringify(id)}) || null;
4380
+ })()`,
4381
+ })) as NetworkEntryPayload | null
4382
+ if (!entry) {
4383
+ console.error(` no entry with id ${id}`)
4384
+ rnxExit(1)
4385
+ }
4386
+ if (jsonOut) {
4387
+ console.log(JSON.stringify(entry, null, 2))
4388
+ } else {
4389
+ printNetworkDetail(entry)
4390
+ }
4391
+ break
4392
+ }
4393
+
4394
+ // list / tail mode share the same fetch + format path. default
4395
+ // limit is 20; tail mode uses a larger buffer window (200) so
4396
+ // spikes don't blow past the ring.
4397
+ const limit = limitFlag ?? (tail ? 200 : sub ? Number(sub) : 20)
4398
+ if (!Number.isFinite(limit)) {
4399
+ console.error(
4400
+ ` invalid limit: ${sub} — \`network\` takes a numeric count (e.g. ${inspectCommand('network')} 100).\n` +
4401
+ ` to target a specific sim, use \`--sim ${sub}\` instead.`,
4402
+ )
4403
+ rnxExit(1)
4404
+ }
4405
+
4406
+ const fetchEntries = async () => {
4407
+ const entries = (await bridge.send({
4408
+ type: 'evaluate',
4409
+ code: `(() => {
4410
+ const obs = window.__sootsimObservability;
4411
+ if (!obs) return { ok: false };
4412
+ return { ok: true, entries: obs.network.getSnapshot() };
4413
+ })()`,
4414
+ })) as { ok: boolean; entries?: NetworkEntryPayload[] }
4415
+ if (!entries || !entries.ok) {
4416
+ throw new Error('observability bridge not installed — is the engine running?')
4417
+ }
4418
+ return entries.entries ?? []
4419
+ }
4420
+
4421
+ const applyFilter = (all: NetworkEntryPayload[]) => {
4422
+ let out = all
4423
+ if (failedOnly) {
4424
+ out = out.filter((e) => !!e.error || (e.status != null && e.status >= 400))
4425
+ }
4426
+ if (slowOnly) {
4427
+ // only completed requests can be classified as slow — durationMs
4428
+ // is null until a response (or error) arrives. don't include
4429
+ // in-flight requests in the slow list (they may finish fast).
4430
+ out = out.filter(
4431
+ (e) => e.durationMs != null && e.durationMs >= slowThresholdMs,
4432
+ )
4433
+ }
4434
+ if (filter) {
4435
+ const lf = filter.toLowerCase()
4436
+ out = out.filter((e) => (e.displayUrl || e.url).toLowerCase().includes(lf))
4437
+ }
4438
+ // when --slow is on without --tail, sort by durationMs descending
4439
+ // so the slowest requests print first regardless of finish order.
4440
+ if (slowOnly && !tail) {
4441
+ out = [...out].sort((a, b) => (b.durationMs ?? 0) - (a.durationMs ?? 0))
4442
+ }
4443
+ return out
4444
+ }
4445
+
4446
+ if (!tail) {
4447
+ const all = await fetchEntries()
4448
+ const filtered = applyFilter(all).slice(-limit)
4449
+ if (jsonOut) {
4450
+ console.log(JSON.stringify(filtered, null, 2))
4451
+ break
4452
+ }
4453
+ if (filtered.length === 0) {
4454
+ if (all.length === 0) {
4455
+ console.log(' no network requests captured')
4456
+ } else if (slowOnly) {
4457
+ console.log(
4458
+ ` no requests slower than ${slowThresholdMs}ms (${all.length} total — try --threshold <ms>)`,
4459
+ )
4460
+ } else {
4461
+ console.log(' no matching requests')
4462
+ }
4463
+ break
4464
+ }
4465
+ if (slowOnly) {
4466
+ console.log(
4467
+ ` ${filtered.length} request(s) slower than ${slowThresholdMs}ms (sorted by duration desc):\n`,
4468
+ )
4469
+ } else {
4470
+ console.log(` ${filtered.length} request(s):\n`)
4471
+ }
4472
+ for (const e of filtered) printNetworkRow(e)
4473
+ break
4474
+ }
4475
+
4476
+ // tail mode — poll every 250ms, track the set of completed ids
4477
+ // we've already printed, and emit new entries as they land. only
4478
+ // prints *completed* entries (durationMs != null) so each request
4479
+ // renders once with its final status, not twice.
4480
+ console.log(' tailing network (ctrl-c to stop)...\n')
4481
+ const seen = new Set<string>()
4482
+ let running = true
4483
+ const stop = () => {
4484
+ running = false
4485
+ }
4486
+ process.on('SIGINT', stop)
4487
+ try {
4488
+ while (running) {
4489
+ const all = await fetchEntries()
4490
+ const filtered = applyFilter(all)
4491
+ for (const e of filtered) {
4492
+ if (e.durationMs == null) continue
4493
+ if (seen.has(e.id)) continue
4494
+ seen.add(e.id)
4495
+ if (jsonOut) console.log(JSON.stringify(e))
4496
+ else printNetworkRow(e)
4497
+ }
4498
+ await sleep(250)
4499
+ }
4500
+ } finally {
4501
+ process.off('SIGINT', stop)
4502
+ }
4503
+ break
4504
+ }
4505
+
4506
+ case 'logs': {
4507
+ // rnx logs — live console inspector backed by the shared
4508
+ // observability log store (fed by worker-side console capture over
4509
+ // BroadcastChannel). mirrors `rnx network`:
4510
+ //
4511
+ // rnx logs [limit] last N entries (default 50)
4512
+ // rnx logs --level error,warn filter by level (csv)
4513
+ // rnx logs --filter <str> message substring filter
4514
+ // rnx logs --internal include engine-internal [sootsim] logs (hidden by default)
4515
+ // rnx logs --json raw json output
4516
+ // rnx logs tail | -f follow mode (polls)
4517
+ // rnx logs clear drop the buffer
4518
+ const sub = positional[1]
4519
+
4520
+ let filter: string | null = null
4521
+ let limitFlag: number | null = null
4522
+ let levelCsv: string | null = null
4523
+ let tail = false
4524
+ let jsonOut = false
4525
+ let showInternal = false
4526
+ for (let i = 0; i < effectiveArgs.length; i++) {
4527
+ const a = effectiveArgs[i]
4528
+ if (a === '--filter') {
4529
+ filter = effectiveArgs[i + 1] ?? null
4530
+ i++
4531
+ } else if (a === '--limit') {
4532
+ const n = Number(effectiveArgs[i + 1])
4533
+ if (Number.isFinite(n)) limitFlag = n
4534
+ i++
4535
+ } else if (a === '--level') {
4536
+ levelCsv = effectiveArgs[i + 1] ?? null
4537
+ i++
4538
+ } else if (a === '--tail' || a === '-f') tail = true
4539
+ else if (a === '--json') jsonOut = true
4540
+ else if (a === '--internal' || a === '--all') showInternal = true
4541
+ }
4542
+
4543
+ const levelFilter: ReadonlySet<LogLevel> | null = levelCsv
4544
+ ? new Set(
4545
+ levelCsv
4546
+ .split(',')
4547
+ .map((s) => s.trim())
4548
+ .filter(
4549
+ (s): s is LogLevel =>
4550
+ s === 'log' ||
4551
+ s === 'info' ||
4552
+ s === 'warn' ||
4553
+ s === 'error' ||
4554
+ s === 'debug',
4555
+ ),
4556
+ )
4557
+ : null
4558
+
4559
+ if (sub === 'clear') {
4560
+ await clearLogs(bridge)
4561
+ console.log(' log buffer cleared')
4562
+ break
4563
+ }
4564
+
4565
+ const useColor = !jsonOut && process.stdout.isTTY === true
4566
+ const limit = limitFlag ?? (tail ? 500 : sub ? Number(sub) : 50)
4567
+ if (!Number.isFinite(limit)) {
4568
+ console.error(
4569
+ ` invalid limit: ${sub} — \`logs\` takes a numeric count (e.g. ${inspectCommand('logs')} 100).\n` +
4570
+ ` to target a specific sim, use \`--sim ${sub}\` instead.`,
4571
+ )
4572
+ rnxExit(1)
4573
+ }
4574
+
4575
+ const fetchEntries = () => inspectLogs(bridge)
4576
+
4577
+ // engine-internal logs (bundle-loader setup, HMR trap messages, etc.)
4578
+ // are noise when debugging guest-app behavior — filterLogEntries hides
4579
+ // them by default; pass --internal to include them.
4580
+ const applyFilter = (all: LogEntry[]) =>
4581
+ filterLogEntries(all, {
4582
+ level: levelFilter,
4583
+ filter,
4584
+ showInternal,
4585
+ })
4586
+
4587
+ if (!tail) {
4588
+ const all = await fetchEntries()
4589
+ const filtered = applyFilter(all).slice(-limit)
4590
+ if (jsonOut) {
4591
+ console.log(JSON.stringify(filtered, null, 2))
4592
+ break
4593
+ }
4594
+ if (filtered.length === 0) {
4595
+ console.log(all.length === 0 ? ' no logs captured' : ' no matching logs')
4596
+ break
4597
+ }
4598
+ console.log(` ${filtered.length} log(s):\n`)
4599
+ for (const e of filtered) printLogRow(e, useColor)
4600
+ break
4601
+ }
4602
+
4603
+ console.log(' tailing logs (ctrl-c to stop)...\n')
4604
+ const seen = new Set<string>()
4605
+ let running = true
4606
+ const stop = () => {
4607
+ running = false
4608
+ }
4609
+ process.on('SIGINT', stop)
4610
+ try {
4611
+ while (running) {
4612
+ const all = await fetchEntries()
4613
+ const filtered = applyFilter(all)
4614
+ for (const e of filtered) {
4615
+ if (seen.has(e.id)) continue
4616
+ seen.add(e.id)
4617
+ if (jsonOut) console.log(JSON.stringify(e))
4618
+ else printLogRow(e, useColor)
4619
+ }
4620
+ await sleep(250)
4621
+ }
4622
+ } finally {
4623
+ process.off('SIGINT', stop)
4624
+ }
4625
+ break
4626
+ }
4627
+
4628
+ default:
4629
+ console.error(` unknown subcommand: ${subcommand}`)
4630
+ rnxExit(1)
4631
+ }
4632
+
4633
+ // auto-settle after any write command so the next CLI call doesn't see
4634
+ // mid-transition state. short deadline — if the app has a perpetual
4635
+ // background animation, we bail to layout-only stability via the same
4636
+ // logic `do settle` uses. opt-out: --no-wait or RNX_NO_AUTO_WAIT=1.
4637
+ if (
4638
+ writeCommands.has(subcommand) &&
4639
+ !args.includes('--no-wait') &&
4640
+ process.env.RNX_NO_AUTO_WAIT !== '1' &&
4641
+ !(await shouldSkipAutoSettleForInspectPick(bridge, subcommand))
4642
+ ) {
4643
+ await autoSettleAfterWrite(bridge)
4644
+ }
4645
+
4646
+ // framing prose (this note, the cursor footer, hints, console summaries)
4647
+ // goes to stderr, never stdout. a `--json` flag is not a usable signal for
4648
+ // that: `do tap-id`, `do tap-text`, and the rest of the write verbs print a
4649
+ // JSON document by default with no flag at all, so gating on the flag left
4650
+ // exactly those commands emitting `{...}` followed by `since last: …` and
4651
+ // breaking every parser. keeping payload on stdout and prose on stderr is
4652
+ // the only rule that holds for both.
4653
+ // the native-UI note flags a blocking Alert/ActionSheet that was up when an
4654
+ // interaction verb ran — it silently eats taps/drags meant for the app, the
4655
+ // single most confusing "my `do` did nothing" failure.
4656
+ if (nativeUIBeforeWrite.length > 0) {
4657
+ printNativeUISummary(nativeUIBeforeWrite)
4658
+ }
4659
+
4660
+ // cursor footer reads `bridges.timeline.summary` keyed on the agent's
4661
+ // stable cliSessionKey so every CLI call shows only what's *new* since the
4662
+ // previous call from the same agent (not a global count). after printing,
4663
+ // advance the cursor past the rendered window so the next call starts
4664
+ // fresh.
4665
+ if (!consoleSummarySkip.has(subcommand)) {
4666
+ try {
4667
+ await printCursorFooter(bridge)
4668
+ } catch {
4669
+ // ignore footer errors; the main command already succeeded
4670
+ }
4671
+ }
4672
+ } catch (err: any) {
4673
+ rethrowIfExit(err)
4674
+ const message = err instanceof Error ? err.message : String(err)
4675
+ console.error(` ${subcommand ?? 'rnx'} failed: ${message}`)
4676
+ const unknownSim = /^no sim connected with id ([^;]+)(?:; connected sims: .+)?$/.exec(
4677
+ message,
4678
+ )
4679
+ // a failure whose own message proves the sim/bridge is unresponsive must
4680
+ // NOT trigger the secondary diagnostic round-trips below — each of them
4681
+ // issues its own bridge command, which against the same dead sim just
4682
+ // re-incurs the full per-command timeout. three sequential probes turn a
4683
+ // single 15s timeout into ~60s of apparent hang (QA F19-1). there is
4684
+ // nothing useful to fetch from a sim that just timed out, so skip them.
4685
+ const timedOut = /^command timed out after (\d+)s$/.exec(message)
4686
+ // a disconnected / never-connected bridge is genuinely gone; recover is
4687
+ // right and probing it would just re-incur a full timeout.
4688
+ const disconnected =
4689
+ message.startsWith('sim disconnected:') ||
4690
+ message.startsWith('bridge never reconnected') ||
4691
+ message.startsWith('could not connect to ws://')
4692
+ if (unknownSim) {
4693
+ await printUnknownSimHint(bridge, wsPort, unknownSim[1])
4694
+ } else if (/^no sim connected$/.test(message)) {
4695
+ printMissingSimHint(wsPort)
4696
+ } else if (timedOut) {
4697
+ // a command timeout alone does not mean a wedged sim — a large screen
4698
+ // can blow the budget on `describe`'s tree serialization while the sim
4699
+ // answers everything else fine. probe once (short budget); if it's
4700
+ // alive, point at narrowing/raising the budget instead of force-closing
4701
+ // a healthy sim.
4702
+ const alive = await probeSimResponsive(bridge)
4703
+ if (alive) {
4704
+ const cmd = subcommand ?? 'describe'
4705
+ process.stderr.write(
4706
+ ` the sim is still responsive — '${cmd}' just exceeded the ${timedOut[1]}s command budget.\n` +
4707
+ ` the screen's node tree is large; narrow the query or raise the budget:\n` +
4708
+ ` rnx ${cmd} --testid <id> # scope to one subtree\n` +
4709
+ ` rnx find --testid <id> # targeted single-node lookup\n` +
4710
+ ` rnx ${cmd} --timeout 60000 # raise per-command budget (ms)\n`,
4711
+ )
4712
+ } else {
4713
+ printRecoverHint()
4714
+ }
4715
+ } else if (disconnected) {
4716
+ printRecoverHint()
4717
+ } else {
4718
+ try {
4719
+ await printBridgeStateSummary(bridge)
4720
+ } catch {
4721
+ // ignore secondary bridge errors while already failing the command
4722
+ }
4723
+ try {
4724
+ await printConsoleSummary({ includeTail: true })
4725
+ } catch {
4726
+ // ignore secondary bridge errors while already failing the command
4727
+ }
4728
+ try {
4729
+ await printRequestSummary({ includeTail: true })
4730
+ } catch {
4731
+ // ignore secondary request summary errors while already failing the command
4732
+ }
4733
+ }
4734
+ rnxExit(1)
4735
+ } finally {
4736
+ bridge.close()
4737
+ }
4738
+ }