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