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,2142 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
devices,
|
|
7
|
+
listSelectableDeviceModels,
|
|
8
|
+
type DeviceModel,
|
|
9
|
+
} from 'sootsim-engine/settings'
|
|
10
|
+
import { WebSocket } from 'ws'
|
|
11
|
+
import { probePort } from '../../scripts/dev-server-scanner'
|
|
12
|
+
import { isLoopbackHost } from '../../src/backend-origin'
|
|
13
|
+
import { resolveSootsimShellBaseUrlForBridgePort } from '../../src/bridge-constants'
|
|
14
|
+
import {
|
|
15
|
+
applySootSimConfigToUrl,
|
|
16
|
+
mergeSootSimConfig,
|
|
17
|
+
type SootSimConfig,
|
|
18
|
+
} from '../../src/config'
|
|
19
|
+
import {
|
|
20
|
+
resolveConnectionInput,
|
|
21
|
+
type ResolvedDevBundle,
|
|
22
|
+
} from '../../src/dev-bundle-resolution'
|
|
23
|
+
import {
|
|
24
|
+
isDaemonLockfileFresh,
|
|
25
|
+
readActiveRuntime,
|
|
26
|
+
readDaemonLockfile,
|
|
27
|
+
readLiveRuntimeVersions,
|
|
28
|
+
runtimeDir,
|
|
29
|
+
} from '../../src/home-paths'
|
|
30
|
+
import { applyHotMode } from '../../src/native-dev-bundle-url'
|
|
31
|
+
import { ensureProfile } from '../../src/profiles'
|
|
32
|
+
import { rnxPublicBrand } from '../../src/public-brand'
|
|
33
|
+
import {
|
|
34
|
+
resolveDevCheckoutRuntimeRoot,
|
|
35
|
+
runtimeVersionHostname,
|
|
36
|
+
} from '../../src/runtime-assets'
|
|
37
|
+
import { sootsimRuntime } from '../../src/runtime-delivery'
|
|
38
|
+
import { loadSootSimAppConfig } from '../app-config'
|
|
39
|
+
import {
|
|
40
|
+
type DiscoveredAppFontSpec,
|
|
41
|
+
discoverNativeLinkedAppFonts,
|
|
42
|
+
encodeAppFontsWire,
|
|
43
|
+
resolveMetroProjectRoot,
|
|
44
|
+
} from '../app-fonts'
|
|
45
|
+
import { resolveAppProject, type ResolvedAppProject } from '../app-project'
|
|
46
|
+
import { discoverConfigPluginAppSplash, encodeAppSplashWire } from '../app-splash'
|
|
47
|
+
import { printBridgeFailureDiagnostics } from '../bridge-diagnostics'
|
|
48
|
+
import {
|
|
49
|
+
clearCurrentSimId,
|
|
50
|
+
getStableOwnerPid,
|
|
51
|
+
readCurrentSimId,
|
|
52
|
+
saveCurrentSimId,
|
|
53
|
+
} from '../current-sim'
|
|
54
|
+
import { getDriver } from '../drivers/registry'
|
|
55
|
+
import { openUrl as openUrlInBrowser } from '../open-url'
|
|
56
|
+
import {
|
|
57
|
+
BridgeSimLockedError,
|
|
58
|
+
bridgePortForShellBaseUrl,
|
|
59
|
+
createBridge,
|
|
60
|
+
createBridgeFromParsed,
|
|
61
|
+
parseBridgeCliArgs,
|
|
62
|
+
printBridgeWorldNotice,
|
|
63
|
+
resolveBridgeWorld,
|
|
64
|
+
worldForBridgePort,
|
|
65
|
+
type BridgeSimInfo,
|
|
66
|
+
} from '../ws-bridge'
|
|
67
|
+
import { WAIT_READY_PROBE, readyProbeHasContent, type ReadyProbe } from './inspect/core'
|
|
68
|
+
import type { DriverId, DriverLaunchResult } from '../drivers/types'
|
|
69
|
+
|
|
70
|
+
interface ControlCommandOptions {
|
|
71
|
+
port?: number
|
|
72
|
+
timeoutMs?: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface DriverConnectWaitOptions {
|
|
76
|
+
attempts: number
|
|
77
|
+
intervalMs: number
|
|
78
|
+
timeoutMs: number
|
|
79
|
+
hostTimeoutMs: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const DEFAULT_DRIVER_CONNECT_TIMEOUT_MS = 120_000
|
|
83
|
+
const DRIVER_CONNECT_INTERVAL_MS = 100
|
|
84
|
+
const DRIVER_CONNECT_HOST_GRACE_MS = 5_000
|
|
85
|
+
const OPEN_BRIDGE_SPAWN_TIMEOUT_MS = 10_000
|
|
86
|
+
|
|
87
|
+
// built-in shell apps use pathname-based routing, not ?bundle= params
|
|
88
|
+
const SHELL_APPS: Record<string, string> = {
|
|
89
|
+
rn: '/rn',
|
|
90
|
+
connectrn: '/rn',
|
|
91
|
+
'connect-rn': '/rn',
|
|
92
|
+
clock: '/app/clock',
|
|
93
|
+
'native-ui': '/app/native-ui',
|
|
94
|
+
tamagui: '/app/tamagui',
|
|
95
|
+
settings: '/app/settings',
|
|
96
|
+
photos: '/app/photos',
|
|
97
|
+
camera: '/app/camera',
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sleep(ms: number) {
|
|
101
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parsePositiveIntegerEnv(name: string): number | null {
|
|
105
|
+
const value = process.env[name]?.trim()
|
|
106
|
+
if (!value) return null
|
|
107
|
+
const parsed = Number(value)
|
|
108
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function resolveDriverConnectWaitOptions(): DriverConnectWaitOptions {
|
|
112
|
+
const timeoutMs =
|
|
113
|
+
parsePositiveIntegerEnv('SOOTSIM_DRIVER_CONNECT_TIMEOUT_MS') ??
|
|
114
|
+
parsePositiveIntegerEnv('SOOTSIM_PW_CONNECT_TIMEOUT_MS') ??
|
|
115
|
+
DEFAULT_DRIVER_CONNECT_TIMEOUT_MS
|
|
116
|
+
return {
|
|
117
|
+
timeoutMs,
|
|
118
|
+
hostTimeoutMs: timeoutMs + DRIVER_CONNECT_HOST_GRACE_MS,
|
|
119
|
+
intervalMs: DRIVER_CONNECT_INTERVAL_MS,
|
|
120
|
+
attempts: Math.max(1, Math.ceil(timeoutMs / DRIVER_CONNECT_INTERVAL_MS)),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isDriverId(value: string): value is DriverId {
|
|
125
|
+
return value === 'electron' || value === 'playwright'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function simUsesDriver(sim: BridgeSimInfo, driverId: DriverId): boolean {
|
|
129
|
+
if (driverId === 'playwright') {
|
|
130
|
+
return sim.meta?.sootsimHostDriver === 'playwright'
|
|
131
|
+
}
|
|
132
|
+
return sim.userAgent?.includes('Electron/') === true
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// resolve headless for a driver launch. explicit flags always win
|
|
136
|
+
// (--headless → true, --headed → false). otherwise: a headed default
|
|
137
|
+
// on mac/windows. on linux with no DISPLAY/WAYLAND_DISPLAY there is no
|
|
138
|
+
// X server, so the playwright driver auto-flips to headless with a
|
|
139
|
+
// single-line note. electron ignores headless mode, so it defaults false.
|
|
140
|
+
function resolveDriverHeadless(args: string[], driverId: string): boolean {
|
|
141
|
+
if (args.includes('--headless')) return true
|
|
142
|
+
if (args.includes('--headed')) return false
|
|
143
|
+
if (
|
|
144
|
+
driverId === 'playwright' &&
|
|
145
|
+
process.platform === 'linux' &&
|
|
146
|
+
!process.env.DISPLAY &&
|
|
147
|
+
!process.env.WAYLAND_DISPLAY
|
|
148
|
+
) {
|
|
149
|
+
console.error(
|
|
150
|
+
' no display detected → running playwright headless (override with --headed)',
|
|
151
|
+
)
|
|
152
|
+
return true
|
|
153
|
+
}
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function normalizeKnownTarget(target: string): string {
|
|
158
|
+
return target.trim()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const DEEP_LINK_SCHEME_RE = /^[A-Za-z][A-Za-z0-9+.-]*:/
|
|
162
|
+
|
|
163
|
+
export function normalizeDeepLinkTarget(target: string): string {
|
|
164
|
+
const trimmed = target.trim()
|
|
165
|
+
if (!trimmed) {
|
|
166
|
+
throw new Error('expected <url-or-route>')
|
|
167
|
+
}
|
|
168
|
+
if (DEEP_LINK_SCHEME_RE.test(trimmed) || trimmed.startsWith('/')) {
|
|
169
|
+
return trimmed
|
|
170
|
+
}
|
|
171
|
+
return `/${trimmed}`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
175
|
+
return value !== null && typeof value === 'object'
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function shouldOpenAsDeepLink(target: string): boolean {
|
|
179
|
+
const trimmed = target.trim()
|
|
180
|
+
if (!trimmed) return false
|
|
181
|
+
if (trimmed.startsWith('/')) return true
|
|
182
|
+
if (!DEEP_LINK_SCHEME_RE.test(trimmed)) return false
|
|
183
|
+
return !/^https?:\/\//i.test(trimmed)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function ensureBridgeForOpen(
|
|
187
|
+
preferredPort: number,
|
|
188
|
+
explicitPort = false,
|
|
189
|
+
): Promise<number> {
|
|
190
|
+
if (await canConnectToBridge(preferredPort, 250)) return preferredPort
|
|
191
|
+
|
|
192
|
+
const lock = readDaemonLockfile()
|
|
193
|
+
assertExplicitBridgeLock(preferredPort, explicitPort, lock)
|
|
194
|
+
if (
|
|
195
|
+
lock &&
|
|
196
|
+
isDaemonLockfileFresh(lock) &&
|
|
197
|
+
(await canConnectToBridge(lock.bridgePort, 500))
|
|
198
|
+
) {
|
|
199
|
+
return lock.bridgePort
|
|
200
|
+
}
|
|
201
|
+
if (lock && isDaemonLockfileFresh(lock)) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`rnx bridge lockfile is fresh (port ${lock.bridgePort}) but the bridge is not reachable. run \`rnxsim daemon restart\` or \`rnxsim daemon uninstall\`.`,
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await ensureRuntimeForOpen()
|
|
208
|
+
const child = spawnSootsim(['serve', '--quiet', '--port', String(preferredPort)])
|
|
209
|
+
let childExit: string | null = null
|
|
210
|
+
child.once('exit', (code, signal) => {
|
|
211
|
+
childExit = String(code ?? signal ?? 'unknown')
|
|
212
|
+
})
|
|
213
|
+
child.unref()
|
|
214
|
+
|
|
215
|
+
const deadline = Date.now() + OPEN_BRIDGE_SPAWN_TIMEOUT_MS
|
|
216
|
+
while (Date.now() < deadline) {
|
|
217
|
+
const nextLock = readDaemonLockfile()
|
|
218
|
+
assertExplicitBridgeLock(preferredPort, explicitPort, nextLock)
|
|
219
|
+
if (
|
|
220
|
+
nextLock &&
|
|
221
|
+
isDaemonLockfileFresh(nextLock) &&
|
|
222
|
+
(await canConnectToBridge(nextLock.bridgePort, 250))
|
|
223
|
+
) {
|
|
224
|
+
console.error(
|
|
225
|
+
` started local bridge on port ${nextLock.bridgePort}; run \`sootsim setup\` to install the background daemon`,
|
|
226
|
+
)
|
|
227
|
+
return nextLock.bridgePort
|
|
228
|
+
}
|
|
229
|
+
if (await canConnectToBridge(preferredPort, 250)) {
|
|
230
|
+
console.error(
|
|
231
|
+
` started local bridge on port ${preferredPort}; run \`sootsim setup\` to install the background daemon`,
|
|
232
|
+
)
|
|
233
|
+
return preferredPort
|
|
234
|
+
}
|
|
235
|
+
if (childExit) {
|
|
236
|
+
throw new Error(`rnx bridge exited before becoming ready (exit ${childExit})`)
|
|
237
|
+
}
|
|
238
|
+
await sleep(150)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
throw new Error(
|
|
242
|
+
`rnx bridge did not start within ${Math.round(OPEN_BRIDGE_SPAWN_TIMEOUT_MS / 1000)}s`,
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function assertExplicitBridgeLock(
|
|
247
|
+
preferredPort: number,
|
|
248
|
+
explicitPort: boolean,
|
|
249
|
+
lock: ReturnType<typeof readDaemonLockfile>,
|
|
250
|
+
): void {
|
|
251
|
+
if (
|
|
252
|
+
!explicitPort ||
|
|
253
|
+
!lock ||
|
|
254
|
+
!isDaemonLockfileFresh(lock) ||
|
|
255
|
+
lock.bridgePort === preferredPort
|
|
256
|
+
) {
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
throw new Error(
|
|
260
|
+
`${rnxPublicBrand.commandName} open: requested bridge port ${preferredPort}, but the active daemon owns port ${lock.bridgePort}. explicit --port never selects another bridge; stop that daemon or use an isolated SOOTSIM_HOME.`,
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function ensureRuntimeForOpen() {
|
|
265
|
+
const active = readActiveRuntime()
|
|
266
|
+
if (active && fs.existsSync(runtimeDir(active))) return
|
|
267
|
+
console.error(' installing rnx engine runtime...')
|
|
268
|
+
const { runRuntime } = await import('./runtime')
|
|
269
|
+
await runRuntime(['install'], {})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function spawnSootsim(args: string[]) {
|
|
273
|
+
const entry = process.argv[1]
|
|
274
|
+
if (entry && fs.existsSync(entry)) {
|
|
275
|
+
return spawn(process.execPath, [entry, ...args], {
|
|
276
|
+
detached: true,
|
|
277
|
+
stdio: 'ignore',
|
|
278
|
+
env: process.env,
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
return spawn(rnxPublicBrand.commandName, args, {
|
|
282
|
+
detached: true,
|
|
283
|
+
stdio: 'ignore',
|
|
284
|
+
env: process.env,
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function canConnectToBridge(port: number, timeoutMs: number): Promise<boolean> {
|
|
289
|
+
return new Promise((resolve) => {
|
|
290
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}`, { handshakeTimeout: timeoutMs })
|
|
291
|
+
let settled = false
|
|
292
|
+
const finish = (ok: boolean) => {
|
|
293
|
+
if (settled) return
|
|
294
|
+
settled = true
|
|
295
|
+
if (ws.readyState === WebSocket.OPEN) ws.close()
|
|
296
|
+
else if (ws.readyState === WebSocket.CONNECTING) ws.terminate()
|
|
297
|
+
resolve(ok)
|
|
298
|
+
}
|
|
299
|
+
ws.once('open', () => finish(true))
|
|
300
|
+
ws.on('error', () => finish(false))
|
|
301
|
+
setTimeout(() => finish(false), timeoutMs)
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function looksLikeSootsimUrl(target: string): boolean {
|
|
306
|
+
try {
|
|
307
|
+
const url = new URL(target)
|
|
308
|
+
const pathname = url.pathname.replace(/\/+$/, '') || '/'
|
|
309
|
+
return (
|
|
310
|
+
url.searchParams.has('open') ||
|
|
311
|
+
url.searchParams.has('port') ||
|
|
312
|
+
url.searchParams.has('bundle') ||
|
|
313
|
+
url.searchParams.has('demo') ||
|
|
314
|
+
url.pathname.includes('/sootsim/index.html') ||
|
|
315
|
+
pathname === '/sootsim' ||
|
|
316
|
+
url.pathname === '/__soot' ||
|
|
317
|
+
url.pathname === '/__soot/' ||
|
|
318
|
+
pathname === '/rn' ||
|
|
319
|
+
/^\/rn\/[^/]+$/i.test(pathname) ||
|
|
320
|
+
/^\/app\/[^/]+$/i.test(pathname) ||
|
|
321
|
+
pathname === '/__soot/rn' ||
|
|
322
|
+
/^\/__soot\/rn\/[^/]+$/i.test(pathname) ||
|
|
323
|
+
/^\/__soot\/app\/[^/]+$/i.test(pathname)
|
|
324
|
+
)
|
|
325
|
+
} catch {
|
|
326
|
+
return false
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function looksLikeShellPathTarget(target: string): boolean {
|
|
331
|
+
try {
|
|
332
|
+
const url = new URL(target, 'http://sootsim.local')
|
|
333
|
+
if (url.origin !== 'http://sootsim.local') return false
|
|
334
|
+
const pathname = url.pathname.replace(/\/+$/, '') || '/'
|
|
335
|
+
return (
|
|
336
|
+
pathname === '/rn' ||
|
|
337
|
+
/^\/rn\/[^/]+$/i.test(pathname) ||
|
|
338
|
+
/^\/app\/[^/]+$/i.test(pathname) ||
|
|
339
|
+
pathname === '/__soot/rn' ||
|
|
340
|
+
/^\/__soot\/rn\/[^/]+$/i.test(pathname) ||
|
|
341
|
+
/^\/__soot\/app\/[^/]+$/i.test(pathname)
|
|
342
|
+
)
|
|
343
|
+
} catch {
|
|
344
|
+
return false
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function buildShellPathOpenUrl(target: string, baseUrl: string): string {
|
|
349
|
+
const targetUrl = new URL(target, 'http://sootsim.local')
|
|
350
|
+
const url = new URL(baseUrl)
|
|
351
|
+
const prefix = getShellRoutePrefix(url.pathname)
|
|
352
|
+
const routePath = targetUrl.pathname.startsWith('/__soot/')
|
|
353
|
+
? targetUrl.pathname.slice('/__soot'.length)
|
|
354
|
+
: targetUrl.pathname
|
|
355
|
+
|
|
356
|
+
url.pathname = `${prefix}${routePath}`
|
|
357
|
+
url.search = targetUrl.search
|
|
358
|
+
url.hash = targetUrl.hash
|
|
359
|
+
return url.toString()
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// SootSim share/preview/build links (e.g. https://sootsim.com/preview/<id>,
|
|
363
|
+
// https://contrast.dev/build/<owner>/<repo>/<branch>) are full player pages
|
|
364
|
+
// served by the Contrast app — NOT Metro bundles and NOT driveable shell sims.
|
|
365
|
+
// passing one to `sootsim open` used to fall through to the bundle resolver,
|
|
366
|
+
// which fetched the HTML page and choked with "Unexpected token '<'". two
|
|
367
|
+
// hard reasons they can't become a CLI-driveable sim:
|
|
368
|
+
// 1. the engine only attaches to the local CLI bridge from a localhost /
|
|
369
|
+
// 127.0.0.1 origin (see engine `mcp/ws-bridge.ts` connect gate), so a
|
|
370
|
+
// remote preview page never registers a sim.
|
|
371
|
+
// 2. a captured share's transformed bundle is pinned to the engine build
|
|
372
|
+
// that produced it — its lazy `/engine-tenant/assets/*` chunk hashes
|
|
373
|
+
// only exist on that deploy, so a remote share can't be re-hosted
|
|
374
|
+
// through the local dev shell (the dynamic chunk import 404s).
|
|
375
|
+
// recognize these URLs so `open` opens them for viewing instead.
|
|
376
|
+
function looksLikeSootSimShareUrl(target: string): boolean {
|
|
377
|
+
try {
|
|
378
|
+
const pathname = new URL(target).pathname.replace(/\/+$/, '') || '/'
|
|
379
|
+
return /^\/preview\/[^/]+$/.test(pathname) || /^\/build\/.+/.test(pathname)
|
|
380
|
+
} catch {
|
|
381
|
+
return false
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function resolveBundleTarget(target: string): Promise<string> {
|
|
386
|
+
const normalized = normalizeKnownTarget(target)
|
|
387
|
+
const resolved = await resolveConnectionInputForCli(normalized)
|
|
388
|
+
return resolved.bundleUrl
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function localBasePort(target: string): number | null {
|
|
392
|
+
try {
|
|
393
|
+
const url = new URL(target.startsWith('http') ? target : `http://${target}`)
|
|
394
|
+
const pathname = url.pathname || '/'
|
|
395
|
+
if ((pathname !== '/' && pathname !== '') || url.search || url.hash) return null
|
|
396
|
+
if (!isLoopbackHost(url.hostname)) return null
|
|
397
|
+
const port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80
|
|
398
|
+
return Number.isFinite(port) && port > 0 ? port : null
|
|
399
|
+
} catch {
|
|
400
|
+
return null
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function resolveConnectionInputForCli(target: string): Promise<ResolvedDevBundle> {
|
|
405
|
+
const normalized = normalizeKnownTarget(target)
|
|
406
|
+
const registeredPort = /^\d+$/.test(normalized)
|
|
407
|
+
? Number(normalized)
|
|
408
|
+
: localBasePort(normalized)
|
|
409
|
+
|
|
410
|
+
if (registeredPort && registeredPort > 0) {
|
|
411
|
+
const discovered = await probePort(registeredPort)
|
|
412
|
+
if (discovered) {
|
|
413
|
+
return {
|
|
414
|
+
bundleUrl: discovered.bundleUrl,
|
|
415
|
+
port: discovered.port,
|
|
416
|
+
framework: discovered.framework,
|
|
417
|
+
projectName: discovered.projectName,
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return resolveConnectionInput(normalized)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function getShellRoutePrefix(pathname: string): string {
|
|
426
|
+
const normalized = pathname.replace(/\/+$/, '') || '/'
|
|
427
|
+
if (normalized === '/__soot' || normalized.startsWith('/__soot/')) {
|
|
428
|
+
return '/__soot'
|
|
429
|
+
}
|
|
430
|
+
return ''
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function buildConnectOpenUrl(target: string, baseUrl: string): string {
|
|
434
|
+
const normalizedTarget = normalizeKnownTarget(target)
|
|
435
|
+
const url = new URL(baseUrl)
|
|
436
|
+
const prefix = getShellRoutePrefix(url.pathname)
|
|
437
|
+
|
|
438
|
+
url.pathname = `${prefix}/rn`
|
|
439
|
+
url.searchParams.delete('bundle')
|
|
440
|
+
url.searchParams.delete('demo')
|
|
441
|
+
url.searchParams.delete('app')
|
|
442
|
+
url.searchParams.delete('open')
|
|
443
|
+
url.searchParams.delete('port')
|
|
444
|
+
|
|
445
|
+
if (/^\d+$/.test(normalizedTarget)) {
|
|
446
|
+
url.pathname = `${prefix}/rn/${normalizedTarget}`
|
|
447
|
+
} else {
|
|
448
|
+
url.pathname = `${prefix}/rn`
|
|
449
|
+
url.searchParams.set('open', normalizedTarget)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return url.toString()
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function shouldResolveBundleTargetInCli(target: string): boolean {
|
|
456
|
+
const normalizedTarget = normalizeKnownTarget(target)
|
|
457
|
+
if (/^\d+$/.test(normalizedTarget)) return true
|
|
458
|
+
if (/^https?:\/\//i.test(normalizedTarget)) return true
|
|
459
|
+
return /^(localhost|127\.0\.0\.1|\[::1\]|[^/]+\.localhost):\d+(?:\/.*)?$/i.test(
|
|
460
|
+
normalizedTarget,
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function buildResolvedBundleOpenUrl(
|
|
465
|
+
target: string,
|
|
466
|
+
baseUrl: string,
|
|
467
|
+
opts: BuildShellUrlOptions = {},
|
|
468
|
+
): Promise<string> {
|
|
469
|
+
const resolved = opts.resolvedBundle ?? (await resolveConnectionInputForCli(target))
|
|
470
|
+
const url = new URL(baseUrl)
|
|
471
|
+
const prefix = getShellRoutePrefix(url.pathname)
|
|
472
|
+
url.pathname = `${prefix}/rn`
|
|
473
|
+
url.searchParams.delete('open')
|
|
474
|
+
url.searchParams.delete('port')
|
|
475
|
+
url.searchParams.delete('demo')
|
|
476
|
+
url.searchParams.delete('app')
|
|
477
|
+
const bundleUrl =
|
|
478
|
+
opts.hot === undefined
|
|
479
|
+
? resolved.bundleUrl
|
|
480
|
+
: applyHotMode(resolved.bundleUrl, opts.hot)
|
|
481
|
+
url.searchParams.set('bundle', bundleUrl)
|
|
482
|
+
return url.toString()
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function normalizeReplacementPath(filePath: string): string {
|
|
486
|
+
if (filePath.startsWith('~/')) {
|
|
487
|
+
return path.join(os.homedir(), filePath.slice(2))
|
|
488
|
+
}
|
|
489
|
+
if (path.isAbsolute(filePath)) return filePath
|
|
490
|
+
return path.resolve(process.cwd(), filePath)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function parseOpenRuntimeConfig(args: string[]): SootSimConfig | undefined {
|
|
494
|
+
const modules: NonNullable<SootSimConfig['modules']> = {}
|
|
495
|
+
const remap: Record<string, string> = {}
|
|
496
|
+
|
|
497
|
+
for (let i = 0; i < args.length; i++) {
|
|
498
|
+
if (args[i] === '--replace') {
|
|
499
|
+
const raw = args[i + 1]
|
|
500
|
+
if (!raw) {
|
|
501
|
+
console.error(' rnxsim open: --replace expects <module>=<file>')
|
|
502
|
+
process.exit(1)
|
|
503
|
+
}
|
|
504
|
+
const eqIndex = raw.indexOf('=')
|
|
505
|
+
if (eqIndex <= 0 || eqIndex === raw.length - 1) {
|
|
506
|
+
console.error(' rnxsim open: --replace expects <module>=<file>')
|
|
507
|
+
process.exit(1)
|
|
508
|
+
}
|
|
509
|
+
const moduleName = raw.slice(0, eqIndex).trim()
|
|
510
|
+
const filePath = normalizeReplacementPath(raw.slice(eqIndex + 1).trim())
|
|
511
|
+
if (!fs.existsSync(filePath)) {
|
|
512
|
+
console.error(` rnxsim open: replacement file not found: ${filePath}`)
|
|
513
|
+
process.exit(1)
|
|
514
|
+
}
|
|
515
|
+
modules[moduleName] = { file: filePath }
|
|
516
|
+
i++
|
|
517
|
+
continue
|
|
518
|
+
}
|
|
519
|
+
// --remap <host:port>=<host:port> rewrites a guest fetch's host:port at
|
|
520
|
+
// runtime (config.network.remap). repeatable. lets a flow against an app
|
|
521
|
+
// that hardcodes a port reach a differently-bound service.
|
|
522
|
+
if (args[i] === '--remap') {
|
|
523
|
+
const raw = args[i + 1]
|
|
524
|
+
const eqIndex = raw ? raw.indexOf('=') : -1
|
|
525
|
+
if (!raw || eqIndex <= 0 || eqIndex === raw.length - 1) {
|
|
526
|
+
console.error(' rnxsim open: --remap expects <host:port>=<host:port>')
|
|
527
|
+
process.exit(1)
|
|
528
|
+
}
|
|
529
|
+
remap[raw.slice(0, eqIndex).trim()] = raw.slice(eqIndex + 1).trim()
|
|
530
|
+
i++
|
|
531
|
+
continue
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const config: SootSimConfig = {}
|
|
536
|
+
if (Object.keys(modules).length > 0) config.modules = modules
|
|
537
|
+
if (Object.keys(remap).length > 0) config.network = { remap }
|
|
538
|
+
return config.modules || config.network ? config : undefined
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function resolveOpenAppProject(
|
|
542
|
+
resolvedBundle?: ResolvedDevBundle,
|
|
543
|
+
): Promise<ResolvedAppProject | null> {
|
|
544
|
+
if (resolvedBundle) {
|
|
545
|
+
const advertisedRoot = await resolveMetroProjectRoot(resolvedBundle.bundleUrl)
|
|
546
|
+
if (advertisedRoot) {
|
|
547
|
+
return resolveAppProject(advertisedRoot, { trustStart: true })
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return resolveAppProject(process.env.CONTRAST_REPO_DIR || process.cwd())
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function ensureConfiguredRuntime(version: string): Promise<void> {
|
|
554
|
+
if (fs.existsSync(path.join(runtimeDir(version), 'index.html'))) return
|
|
555
|
+
console.error(` installing configured engine runtime ${version}...`)
|
|
556
|
+
const result = await sootsimRuntime.install({
|
|
557
|
+
version,
|
|
558
|
+
setActive: false,
|
|
559
|
+
protectVersions: readLiveRuntimeVersions(),
|
|
560
|
+
})
|
|
561
|
+
console.error(` installed engine runtime ${result.version}`)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function applyRuntimeGenerationOrigin(baseUrl: string, version?: string): string {
|
|
565
|
+
const selectedVersion =
|
|
566
|
+
version ??
|
|
567
|
+
(resolveDevCheckoutRuntimeRoot() ? undefined : (readActiveRuntime() ?? undefined))
|
|
568
|
+
if (!selectedVersion) return baseUrl
|
|
569
|
+
const url = new URL(baseUrl)
|
|
570
|
+
if (!isLoopbackHost(url.hostname)) {
|
|
571
|
+
if (!version) return baseUrl
|
|
572
|
+
throw new Error(
|
|
573
|
+
`runtimeVersion ${selectedVersion} requires a local rnx host; got ${url.origin}`,
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
url.hostname = runtimeVersionHostname(selectedVersion)
|
|
577
|
+
return url.toString()
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function resolveDefaultShellBaseUrl(): string {
|
|
581
|
+
return resolveBridgeWorld().shellBaseUrl
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** the shell that belongs to a bridge port: the world a fresh lockfile claims
|
|
585
|
+
* for it, otherwise the shell that shares the port's own offset. every caller
|
|
586
|
+
* that has already settled on a bridge port goes through this, so a page can
|
|
587
|
+
* never open in one stack's shell while the CLI polls another's bridge. the
|
|
588
|
+
* development bridge registry keeps every live port addressable, while this
|
|
589
|
+
* function preserves an explicit caller's already-selected world. */
|
|
590
|
+
export function resolveShellBaseUrlForBridgePort(bridgePort: number): string {
|
|
591
|
+
return (
|
|
592
|
+
worldForBridgePort(bridgePort)?.shellBaseUrl ??
|
|
593
|
+
resolveSootsimShellBaseUrlForBridgePort(bridgePort) ??
|
|
594
|
+
resolveDefaultShellBaseUrl()
|
|
595
|
+
)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// stamp the engine page url with `?appFonts=<url[::family]>,...` so the
|
|
599
|
+
// engine's app-font-loader registers native config-only fonts at boot. fonts
|
|
600
|
+
// loaded by JS already flow through normal runtime fetch capture; this covers
|
|
601
|
+
// UIAppFonts-style assets that Metro can serve but the guest bundle never
|
|
602
|
+
// fetches itself. auto-discovery only — scans the repo for expo-font config
|
|
603
|
+
// plugin declarations, no user-facing flag.
|
|
604
|
+
//
|
|
605
|
+
// each discovered font is HEAD-checked against the metro host before we
|
|
606
|
+
// include it on the URL. fonts whose metro asset URL 404s (typo in the
|
|
607
|
+
// expo-font plugin config, missing file, or a wrong app.config picked up
|
|
608
|
+
// by the scan) are dropped silently so the engine never tries to fetch a
|
|
609
|
+
// known-bad URL at boot — that would just log a registration failure and
|
|
610
|
+
// confuse anyone reading the page console.
|
|
611
|
+
async function stampAppFonts(
|
|
612
|
+
urlStr: string,
|
|
613
|
+
project?: ResolvedAppProject | null,
|
|
614
|
+
): Promise<string> {
|
|
615
|
+
try {
|
|
616
|
+
const url = new URL(urlStr)
|
|
617
|
+
const bundleUrl = url.searchParams.get('bundle') || ''
|
|
618
|
+
if (!bundleUrl) return urlStr
|
|
619
|
+
// the dev server's project root identifies the exact app in a monorepo.
|
|
620
|
+
// CONTRAST_REPO_DIR is only the fallback for servers that do not advertise
|
|
621
|
+
// their root.
|
|
622
|
+
const repoDir =
|
|
623
|
+
project?.appDir ?? (await resolveMetroProjectRoot(bundleUrl)) ?? undefined
|
|
624
|
+
const specs = discoverNativeLinkedAppFonts({
|
|
625
|
+
bundleUrl,
|
|
626
|
+
repoDir,
|
|
627
|
+
exactProjectDir: !!project || !!repoDir,
|
|
628
|
+
})
|
|
629
|
+
if (specs.length === 0) return urlStr
|
|
630
|
+
const reachable = await filterReachableAppFonts(specs)
|
|
631
|
+
if (reachable.length === 0) return urlStr
|
|
632
|
+
url.searchParams.set('appFonts', encodeAppFontsWire(reachable))
|
|
633
|
+
return url.toString()
|
|
634
|
+
} catch {
|
|
635
|
+
return urlStr
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// HEAD-check each font URL with a short timeout. metro returns 200 (or
|
|
640
|
+
// sometimes a redirect) for assets it can resolve and 500 with an ENOENT
|
|
641
|
+
// trace for paths it cannot. anything not in the 2xx-3xx range gets dropped.
|
|
642
|
+
async function filterReachableAppFonts(
|
|
643
|
+
specs: DiscoveredAppFontSpec[],
|
|
644
|
+
): Promise<DiscoveredAppFontSpec[]> {
|
|
645
|
+
const results = await Promise.all(
|
|
646
|
+
specs.map(async (spec): Promise<DiscoveredAppFontSpec | null> => {
|
|
647
|
+
const controller = new AbortController()
|
|
648
|
+
const timer = setTimeout(() => controller.abort(), 1500)
|
|
649
|
+
try {
|
|
650
|
+
const res = await fetch(spec.url, {
|
|
651
|
+
method: 'HEAD',
|
|
652
|
+
signal: controller.signal,
|
|
653
|
+
})
|
|
654
|
+
if (res.status >= 200 && res.status < 400) return spec
|
|
655
|
+
return null
|
|
656
|
+
} catch {
|
|
657
|
+
return null
|
|
658
|
+
} finally {
|
|
659
|
+
clearTimeout(timer)
|
|
660
|
+
}
|
|
661
|
+
}),
|
|
662
|
+
)
|
|
663
|
+
const reachable: DiscoveredAppFontSpec[] = []
|
|
664
|
+
for (const spec of results) {
|
|
665
|
+
if (spec !== null) reachable.push(spec)
|
|
666
|
+
}
|
|
667
|
+
return reachable
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function resolveShellUrl(
|
|
671
|
+
target: string,
|
|
672
|
+
baseUrl: string,
|
|
673
|
+
opts: BuildShellUrlOptions = {},
|
|
674
|
+
): Promise<string> {
|
|
675
|
+
// no target = open home screen
|
|
676
|
+
if (!target) {
|
|
677
|
+
return new URL(baseUrl).toString()
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (looksLikeSootsimUrl(target)) {
|
|
681
|
+
return new URL(target).toString()
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (looksLikeShellPathTarget(target)) {
|
|
685
|
+
return buildShellPathOpenUrl(target, baseUrl)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// check if this is a shell app (pathname-based route, not bundle)
|
|
689
|
+
const shellPath = SHELL_APPS[target.toLowerCase()]
|
|
690
|
+
if (shellPath) {
|
|
691
|
+
const url = new URL(baseUrl)
|
|
692
|
+
const prefix = getShellRoutePrefix(url.pathname)
|
|
693
|
+
url.pathname = `${prefix}${shellPath}`
|
|
694
|
+
return url.toString()
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (shouldResolveBundleTargetInCli(target)) {
|
|
698
|
+
return buildResolvedBundleOpenUrl(target, baseUrl, opts)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return buildConnectOpenUrl(target, baseUrl)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// stamp the engine page url with `?appSplash=<encodeURIComponent(JSON)>` so the
|
|
705
|
+
// engine paints the app's REAL configured splash image + background on boot
|
|
706
|
+
// (instead of the contrast device-default chrome). same discovery path as
|
|
707
|
+
// fonts: scan the bundle's project for the expo-splash-screen /
|
|
708
|
+
// react-native-bootsplash config plugin (or legacy `expo.splash`). the metro
|
|
709
|
+
// manifest fallback in the engine covers the typed-URL case; this wire is the
|
|
710
|
+
// instant-first-paint optimization.
|
|
711
|
+
async function stampAppSplash(
|
|
712
|
+
urlStr: string,
|
|
713
|
+
project?: ResolvedAppProject | null,
|
|
714
|
+
): Promise<string> {
|
|
715
|
+
try {
|
|
716
|
+
const url = new URL(urlStr)
|
|
717
|
+
const bundleUrl = url.searchParams.get('bundle') || ''
|
|
718
|
+
if (!bundleUrl) return urlStr
|
|
719
|
+
const repoDir =
|
|
720
|
+
project?.appDir ?? (await resolveMetroProjectRoot(bundleUrl)) ?? undefined
|
|
721
|
+
const spec = discoverConfigPluginAppSplash({
|
|
722
|
+
bundleUrl,
|
|
723
|
+
repoDir,
|
|
724
|
+
exactProjectDir: !!project || !!repoDir,
|
|
725
|
+
})
|
|
726
|
+
if (!spec) return urlStr
|
|
727
|
+
// HEAD-check the image before stamping so a typo'd/missing splash path never
|
|
728
|
+
// makes the engine fetch a known-bad URL at boot.
|
|
729
|
+
if (spec.imageUrl) {
|
|
730
|
+
const reachable = await splashImageReachable(spec.imageUrl)
|
|
731
|
+
if (!reachable) return urlStr
|
|
732
|
+
}
|
|
733
|
+
url.searchParams.set('appSplash', encodeAppSplashWire(spec))
|
|
734
|
+
return url.toString()
|
|
735
|
+
} catch {
|
|
736
|
+
return urlStr
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function splashImageReachable(imageUrl: string): Promise<boolean> {
|
|
741
|
+
const controller = new AbortController()
|
|
742
|
+
const timer = setTimeout(() => controller.abort(), 1500)
|
|
743
|
+
try {
|
|
744
|
+
const res = await fetch(imageUrl, { method: 'HEAD', signal: controller.signal })
|
|
745
|
+
return res.status >= 200 && res.status < 400
|
|
746
|
+
} catch {
|
|
747
|
+
return false
|
|
748
|
+
} finally {
|
|
749
|
+
clearTimeout(timer)
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export interface BuildShellUrlOptions {
|
|
754
|
+
// when set, forces the metro `hot` query on the resolved bundle URL. true
|
|
755
|
+
// for interactive opens (HMR runtime in bundle, edits hot-apply), false
|
|
756
|
+
// for `--driver playwright`/agent runs (no in-bundle HMR wiring, no ws
|
|
757
|
+
// chatter, deterministic e2e). undefined leaves whatever value metro's
|
|
758
|
+
// manifest emitted — matches real iOS sims so metro's bundle cache is
|
|
759
|
+
// shared. set by `runOpenCommand` only.
|
|
760
|
+
hot?: boolean
|
|
761
|
+
// device model slug (e.g. iphone-16) stamped onto the engine URL as
|
|
762
|
+
// ?device=<model> — the shell reads it at boot (main.tsx) and live-switch
|
|
763
|
+
// uses the same param. without this, every driver-opened sim silently ran
|
|
764
|
+
// whatever profile the shell last persisted.
|
|
765
|
+
device?: string
|
|
766
|
+
resolvedBundle?: ResolvedDevBundle
|
|
767
|
+
project?: ResolvedAppProject | null
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export async function buildShellUrl(
|
|
771
|
+
target: string,
|
|
772
|
+
baseUrl: string = resolveDefaultShellBaseUrl(),
|
|
773
|
+
opts: BuildShellUrlOptions = {},
|
|
774
|
+
): Promise<string> {
|
|
775
|
+
const resolved = await resolveShellUrl(target, baseUrl, opts)
|
|
776
|
+
const stamped = await stampAppSplash(
|
|
777
|
+
await stampAppFonts(resolved, opts.project),
|
|
778
|
+
opts.project,
|
|
779
|
+
)
|
|
780
|
+
if (!opts.device) return stamped
|
|
781
|
+
const url = new URL(stamped)
|
|
782
|
+
url.searchParams.set('device', opts.device)
|
|
783
|
+
return url.toString()
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function deriveCurrentSimBaseUrl(
|
|
787
|
+
sim: BridgeSimInfo | null | undefined,
|
|
788
|
+
fallback: string,
|
|
789
|
+
): string {
|
|
790
|
+
const source = sim?.url || sim?.origin || fallback
|
|
791
|
+
try {
|
|
792
|
+
const url = new URL(source)
|
|
793
|
+
url.searchParams.delete('bundle')
|
|
794
|
+
url.searchParams.delete('demo')
|
|
795
|
+
url.searchParams.delete('app')
|
|
796
|
+
url.searchParams.delete('open')
|
|
797
|
+
url.searchParams.delete('port')
|
|
798
|
+
url.searchParams.delete('inspectOpen')
|
|
799
|
+
// appFonts is target-specific (it points at the metro asset endpoint of
|
|
800
|
+
// the bundle being launched). carrying it over from a prior sim URL
|
|
801
|
+
// means a stale font URL from an earlier launch leaks into the new one,
|
|
802
|
+
// and `stampAppFonts` doesn't get the chance to re-derive against the
|
|
803
|
+
// current bundle. drop it so the rebuild starts clean.
|
|
804
|
+
url.searchParams.delete('appFonts')
|
|
805
|
+
// appSplash is target-specific in the same way — drop it so the configured
|
|
806
|
+
// splash re-derives against the current bundle.
|
|
807
|
+
url.searchParams.delete('appSplash')
|
|
808
|
+
return url.toString()
|
|
809
|
+
} catch {
|
|
810
|
+
return fallback
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export async function buildOpenUrl(
|
|
815
|
+
target: string,
|
|
816
|
+
baseUrl: string,
|
|
817
|
+
openToken: string,
|
|
818
|
+
opts: BuildShellUrlOptions = {},
|
|
819
|
+
): Promise<string> {
|
|
820
|
+
const url = new URL(await buildShellUrl(target, baseUrl, opts))
|
|
821
|
+
url.searchParams.set('inspectOpen', openToken)
|
|
822
|
+
return url.toString()
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function waitForSimReady(
|
|
826
|
+
wsPort: number,
|
|
827
|
+
commandTimeoutMs: number,
|
|
828
|
+
simId: string,
|
|
829
|
+
opts: {
|
|
830
|
+
attempts?: number
|
|
831
|
+
intervalMs?: number
|
|
832
|
+
minNodeCount?: number
|
|
833
|
+
} = {},
|
|
834
|
+
) {
|
|
835
|
+
const attempts = opts.attempts ?? 30
|
|
836
|
+
const intervalMs = opts.intervalMs ?? 500
|
|
837
|
+
const minNodeCount = opts.minNodeCount ?? 10
|
|
838
|
+
|
|
839
|
+
for (let i = 0; i < attempts; i++) {
|
|
840
|
+
const bridge = createBridge(wsPort, {
|
|
841
|
+
commandTimeoutMs,
|
|
842
|
+
simId,
|
|
843
|
+
simIdSource: 'flag',
|
|
844
|
+
})
|
|
845
|
+
try {
|
|
846
|
+
const count = await bridge.send({
|
|
847
|
+
type: 'evaluate',
|
|
848
|
+
code: '(async () => (await window.__sootsimTest?.getNodeCount()) || 0)()',
|
|
849
|
+
})
|
|
850
|
+
if (typeof count === 'number' && count > minNodeCount) {
|
|
851
|
+
return { bridge, count }
|
|
852
|
+
}
|
|
853
|
+
} catch {
|
|
854
|
+
// still loading or disconnected
|
|
855
|
+
}
|
|
856
|
+
bridge.close()
|
|
857
|
+
await sleep(intervalMs)
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return null
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// returns a short, human-friendly description of what a sim is showing —
|
|
864
|
+
// e.g. "bundle host/...path", "connect :8081", "demo bluesky". returns null
|
|
865
|
+
// when there's no useful summary beyond the raw URL itself, so the caller
|
|
866
|
+
// can skip the redundant `loaded:` line in that case.
|
|
867
|
+
export function summarizeSimUrl(url: string | undefined): string | null {
|
|
868
|
+
if (!url) return null
|
|
869
|
+
try {
|
|
870
|
+
const parsed = new URL(url)
|
|
871
|
+
if (parsed.searchParams.has('bundle')) {
|
|
872
|
+
const bundleUrl = parsed.searchParams.get('bundle') || ''
|
|
873
|
+
try {
|
|
874
|
+
const bundle = new URL(bundleUrl)
|
|
875
|
+
const shortPath =
|
|
876
|
+
bundle.pathname.length > 36
|
|
877
|
+
? `...${bundle.pathname.slice(-36)}`
|
|
878
|
+
: bundle.pathname
|
|
879
|
+
return `bundle ${bundle.host}${shortPath}`
|
|
880
|
+
} catch {
|
|
881
|
+
return 'bundle'
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if (parsed.searchParams.has('port')) {
|
|
885
|
+
return `connect :${parsed.searchParams.get('port') || ''}`
|
|
886
|
+
}
|
|
887
|
+
if (parsed.searchParams.has('open')) {
|
|
888
|
+
return `connect ${parsed.searchParams.get('open') || ''}`
|
|
889
|
+
}
|
|
890
|
+
if (parsed.searchParams.has('demo')) {
|
|
891
|
+
return `demo ${parsed.searchParams.get('demo') || 'default'}`
|
|
892
|
+
}
|
|
893
|
+
if (
|
|
894
|
+
parsed.pathname.includes('/sootsim/index.html') ||
|
|
895
|
+
parsed.pathname === '/sootsim/' ||
|
|
896
|
+
parsed.pathname === '/sootsim'
|
|
897
|
+
) {
|
|
898
|
+
return 'embedded rnx'
|
|
899
|
+
}
|
|
900
|
+
return null
|
|
901
|
+
} catch {
|
|
902
|
+
return null
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// a lock owner is either a friendly cli label ("active user") or a raw cli
|
|
907
|
+
// identity key ("CLAUDE_CODE_SESSION_ID:<uuid>", "gppid-1234"). keep the list
|
|
908
|
+
// readable by collapsing a "<source>:<value>" identity to a short form that
|
|
909
|
+
// still says which agent holds it.
|
|
910
|
+
function formatLockOwner(owner: string): string {
|
|
911
|
+
const colon = owner.indexOf(':')
|
|
912
|
+
if (colon <= 0) return owner.length > 24 ? `${owner.slice(0, 21)}…` : owner
|
|
913
|
+
const source = owner.slice(0, colon)
|
|
914
|
+
const value = owner.slice(colon + 1)
|
|
915
|
+
const short = value.length > 12 ? `${value.slice(0, 8)}…` : value
|
|
916
|
+
if (
|
|
917
|
+
source === 'TM_SESSION' ||
|
|
918
|
+
source === 'CLAUDE_CODE_SESSION_ID' ||
|
|
919
|
+
source === 'CODEX_THREAD_ID'
|
|
920
|
+
) {
|
|
921
|
+
return `agent ${short}`
|
|
922
|
+
}
|
|
923
|
+
return `${source} ${short}`
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
export function printConnectedSims(sims: BridgeSimInfo[], selectedId?: string) {
|
|
927
|
+
if (sims.length === 0) {
|
|
928
|
+
console.log(' no sims connected')
|
|
929
|
+
return
|
|
930
|
+
}
|
|
931
|
+
console.log(` connected sims (${sims.length}):\n`)
|
|
932
|
+
for (const sim of sims) {
|
|
933
|
+
const lockTag =
|
|
934
|
+
sim.lockedBy && sim.lockExpiresAt
|
|
935
|
+
? `locked by ${formatLockOwner(sim.lockedBy)} (${Math.max(0, Math.round((sim.lockExpiresAt - Date.now()) / 1000))}s)`
|
|
936
|
+
: ''
|
|
937
|
+
const tags = [
|
|
938
|
+
sim.isPrimary ? 'primary' : '',
|
|
939
|
+
sim.id === selectedId ? 'selected' : '',
|
|
940
|
+
sim.readyState,
|
|
941
|
+
sim.userVisible === false ? 'hidden' : sim.userVisible === true ? 'visible' : '',
|
|
942
|
+
sim.attachedCliCount && sim.attachedCliCount > 0 ? 'in use' : '',
|
|
943
|
+
sim.userFocused ? 'focused' : '',
|
|
944
|
+
lockTag,
|
|
945
|
+
].filter(Boolean)
|
|
946
|
+
console.log(` ${sim.id}${tags.length ? ` [${tags.join(', ')}]` : ''}`)
|
|
947
|
+
const loaded = summarizeSimUrl(sim.url)
|
|
948
|
+
if (loaded) {
|
|
949
|
+
console.log(` loaded: ${loaded}`)
|
|
950
|
+
} else if (sim.url) {
|
|
951
|
+
// no friendly summary — show the raw url so the caller can still copy it.
|
|
952
|
+
// truncate aggressively so a 400-char metro url doesn't dominate the list.
|
|
953
|
+
const shown = sim.url.length > 96 ? `${sim.url.slice(0, 93)}…` : sim.url
|
|
954
|
+
console.log(` url: ${shown}`)
|
|
955
|
+
} else if (sim.origin) {
|
|
956
|
+
console.log(` origin: ${sim.origin}`)
|
|
957
|
+
}
|
|
958
|
+
const engine = describeEngineSource(sim)
|
|
959
|
+
if (engine) console.log(` engine: ${engine}`)
|
|
960
|
+
if (sim.title) console.log(` title: ${sim.title}`)
|
|
961
|
+
if (sim.visibilityState && sim.visibilityState !== 'visible') {
|
|
962
|
+
console.log(` visibility: ${sim.visibilityState}`)
|
|
963
|
+
}
|
|
964
|
+
console.log(` connected: ${formatRelativeAge(Date.now() - sim.connectedAt)}`)
|
|
965
|
+
if (sim.lastActiveAt && sim.lastActiveAt > 0) {
|
|
966
|
+
console.log(` last active: ${formatRelativeAge(Date.now() - sim.lastActiveAt)}`)
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// classify which engine a sim is running so dev-vs-published is never ambiguous
|
|
972
|
+
// to agents or humans reading `sootsim list`. dev shell serves local source on
|
|
973
|
+
// :5173; the Contrast app embeds sootsim on :3000; the bridge/daemon and the
|
|
974
|
+
// /__soot plugin serve the active runtime dir — which, inside a dev checkout, is
|
|
975
|
+
// the fresh dev-stack build (resolveDevCheckoutRuntimeRoot mode boundary), not a
|
|
976
|
+
// published runtime. keep this label honest so it never says "published" while
|
|
977
|
+
// actually serving local build output.
|
|
978
|
+
function describeEngineSource(sim: { url?: string; origin?: string }): string | null {
|
|
979
|
+
const ref = sim.url || sim.origin || ''
|
|
980
|
+
if (!ref) return null
|
|
981
|
+
try {
|
|
982
|
+
const port = new URL(ref).port
|
|
983
|
+
if (port === '5173') return 'dev — local source (:5173)'
|
|
984
|
+
if (port === '3000') return 'Contrast app embedded (:3000)'
|
|
985
|
+
if (resolveDevCheckoutRuntimeRoot()) return `dev — local build (:${port || '?'})`
|
|
986
|
+
return `runtime — published engine (:${port || '?'})`
|
|
987
|
+
} catch {
|
|
988
|
+
return null
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function formatRelativeAge(ms: number): string {
|
|
993
|
+
const sec = Math.max(0, Math.round(ms / 1000))
|
|
994
|
+
if (sec < 60) return `${sec}s ago`
|
|
995
|
+
if (sec < 3600) return `${Math.round(sec / 60)}m ago`
|
|
996
|
+
if (sec < 86400) return `${Math.round(sec / 3600)}h ago`
|
|
997
|
+
return `${Math.round(sec / 86400)}d ago`
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
export async function waitForSimMatch(
|
|
1001
|
+
wsPort: number,
|
|
1002
|
+
commandTimeoutMs: number,
|
|
1003
|
+
predicate: (sim: BridgeSimInfo) => boolean,
|
|
1004
|
+
opts: { attempts?: number; intervalMs?: number } = {},
|
|
1005
|
+
) {
|
|
1006
|
+
const attempts = opts.attempts ?? 30
|
|
1007
|
+
const intervalMs = opts.intervalMs ?? 500
|
|
1008
|
+
for (let i = 0; i < attempts; i++) {
|
|
1009
|
+
const bridge = createBridge(wsPort, { commandTimeoutMs })
|
|
1010
|
+
try {
|
|
1011
|
+
const sims = await bridge.listSims()
|
|
1012
|
+
const match = sims.find(predicate)
|
|
1013
|
+
if (match) return match
|
|
1014
|
+
} catch {
|
|
1015
|
+
// server/sim may still be booting
|
|
1016
|
+
} finally {
|
|
1017
|
+
bridge.close()
|
|
1018
|
+
}
|
|
1019
|
+
await sleep(intervalMs)
|
|
1020
|
+
}
|
|
1021
|
+
return null
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// same as waitForSimMatch, but also watches a detached host PID so a host
|
|
1025
|
+
// crash mid-connect is surfaced as a distinct outcome rather than waiting
|
|
1026
|
+
// out the full timeout (and silently treating a stale bridge entry as
|
|
1027
|
+
// success). 'host-exit' wins as soon as the host process is gone, even if
|
|
1028
|
+
// the bridge still lists the sim.
|
|
1029
|
+
export type WaitForSimMatchOrHostExitResult =
|
|
1030
|
+
| { kind: 'match'; sim: BridgeSimInfo }
|
|
1031
|
+
| { kind: 'host-exit' }
|
|
1032
|
+
| { kind: 'timeout' }
|
|
1033
|
+
|
|
1034
|
+
export async function waitForSimMatchOrHostExit(
|
|
1035
|
+
wsPort: number,
|
|
1036
|
+
commandTimeoutMs: number,
|
|
1037
|
+
predicate: (sim: BridgeSimInfo) => boolean,
|
|
1038
|
+
hostPid: number | undefined,
|
|
1039
|
+
opts: { attempts?: number; intervalMs?: number } = {},
|
|
1040
|
+
): Promise<WaitForSimMatchOrHostExitResult> {
|
|
1041
|
+
const attempts = opts.attempts ?? 30
|
|
1042
|
+
const intervalMs = opts.intervalMs ?? 500
|
|
1043
|
+
const watchHost = !!hostPid && Number.isInteger(hostPid) && hostPid > 1
|
|
1044
|
+
const hostExited = () => watchHost && hostPid !== undefined && !isProcessAlive(hostPid)
|
|
1045
|
+
for (let i = 0; i < attempts; i++) {
|
|
1046
|
+
if (hostExited()) {
|
|
1047
|
+
return { kind: 'host-exit' }
|
|
1048
|
+
}
|
|
1049
|
+
const bridge = createBridge(wsPort, { commandTimeoutMs })
|
|
1050
|
+
try {
|
|
1051
|
+
const sims = await bridge.listSims()
|
|
1052
|
+
const match = sims.find(predicate)
|
|
1053
|
+
if (match) {
|
|
1054
|
+
if (hostExited()) return { kind: 'host-exit' }
|
|
1055
|
+
return { kind: 'match', sim: match }
|
|
1056
|
+
}
|
|
1057
|
+
} catch {
|
|
1058
|
+
// server/sim may still be booting
|
|
1059
|
+
} finally {
|
|
1060
|
+
bridge.close()
|
|
1061
|
+
}
|
|
1062
|
+
if (hostExited()) {
|
|
1063
|
+
return { kind: 'host-exit' }
|
|
1064
|
+
}
|
|
1065
|
+
await sleep(intervalMs)
|
|
1066
|
+
}
|
|
1067
|
+
return { kind: 'timeout' }
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
async function waitForSimGone(
|
|
1071
|
+
wsPort: number,
|
|
1072
|
+
commandTimeoutMs: number,
|
|
1073
|
+
simId: string,
|
|
1074
|
+
opts: { attempts?: number; intervalMs?: number } = {},
|
|
1075
|
+
) {
|
|
1076
|
+
const attempts = opts.attempts ?? 20
|
|
1077
|
+
const intervalMs = opts.intervalMs ?? 250
|
|
1078
|
+
for (let i = 0; i < attempts; i++) {
|
|
1079
|
+
const bridge = createBridge(wsPort, { commandTimeoutMs })
|
|
1080
|
+
try {
|
|
1081
|
+
const sims = await bridge.listSims()
|
|
1082
|
+
const sim = sims.find((entry) => entry.id === simId)
|
|
1083
|
+
if (!sim || sim.readyState !== 'open') {
|
|
1084
|
+
return true
|
|
1085
|
+
}
|
|
1086
|
+
} catch {
|
|
1087
|
+
return true
|
|
1088
|
+
} finally {
|
|
1089
|
+
bridge.close()
|
|
1090
|
+
}
|
|
1091
|
+
await sleep(intervalMs)
|
|
1092
|
+
}
|
|
1093
|
+
return false
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
export function playwrightHostPidForSim(sim: BridgeSimInfo): number | null {
|
|
1097
|
+
const meta = sim.meta
|
|
1098
|
+
if (!meta || meta.sootsimHostDriver !== 'playwright') return null
|
|
1099
|
+
const pid = Number(meta.sootsimHostPid)
|
|
1100
|
+
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return null
|
|
1101
|
+
return pid
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
export function signalDriverLaunchConnected(result: DriverLaunchResult): boolean {
|
|
1105
|
+
if (!result.connectAckFile) return false
|
|
1106
|
+
try {
|
|
1107
|
+
fs.writeFileSync(
|
|
1108
|
+
result.connectAckFile,
|
|
1109
|
+
`${JSON.stringify({ connectedAt: Date.now(), pid: result.pid ?? null })}\n`,
|
|
1110
|
+
{ flag: 'w' },
|
|
1111
|
+
)
|
|
1112
|
+
return true
|
|
1113
|
+
} catch {
|
|
1114
|
+
return false
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function printDriverDiagnosticLogTail(result: DriverLaunchResult) {
|
|
1119
|
+
if (!result.diagnosticLogPath) return
|
|
1120
|
+
try {
|
|
1121
|
+
const detail = fs.readFileSync(result.diagnosticLogPath, 'utf8').trim()
|
|
1122
|
+
if (!detail) {
|
|
1123
|
+
console.error(` ${path.basename(result.diagnosticLogPath)} was empty`)
|
|
1124
|
+
return
|
|
1125
|
+
}
|
|
1126
|
+
const shown = detail.length > 4_000 ? `…\n${detail.slice(-4_000)}` : detail
|
|
1127
|
+
console.error(` ${path.basename(result.diagnosticLogPath)}:`)
|
|
1128
|
+
console.error(shown)
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1131
|
+
console.error(
|
|
1132
|
+
` failed to read ${path.basename(result.diagnosticLogPath)}: ${message}`,
|
|
1133
|
+
)
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function isProcessAlive(pid: number): boolean {
|
|
1138
|
+
try {
|
|
1139
|
+
process.kill(pid, 0)
|
|
1140
|
+
return true
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
return (err as NodeJS.ErrnoException)?.code === 'EPERM'
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
async function waitForProcessExit(pid: number, timeoutMs = 2_500): Promise<boolean> {
|
|
1147
|
+
const deadline = Date.now() + timeoutMs
|
|
1148
|
+
while (Date.now() < deadline) {
|
|
1149
|
+
if (!isProcessAlive(pid)) return true
|
|
1150
|
+
await sleep(100)
|
|
1151
|
+
}
|
|
1152
|
+
return !isProcessAlive(pid)
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// a sim record's host pid can outlive its process; after pid reuse the number
|
|
1156
|
+
// may point at an unrelated process (real incident 2026-07-14: a stale record
|
|
1157
|
+
// made `sootsim close` SIGTERM the shared vite shell dev server, taking the
|
|
1158
|
+
// whole dev stack down). only signal a pid whose argv proves it is still a
|
|
1159
|
+
// sootsim playwright host launcher.
|
|
1160
|
+
function isPlaywrightHostProcess(pid: number): boolean {
|
|
1161
|
+
// `ps eww` prints argv then the process ENVIRONMENT for same-user
|
|
1162
|
+
// processes. the marker must live in the env SUFFIX (everything past the
|
|
1163
|
+
// plain argv), because argv text can contain the string coincidentally —
|
|
1164
|
+
// e.g. a shell wrapper that exports SOOTSIM_PW_* vars in its script body.
|
|
1165
|
+
const plain = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], {
|
|
1166
|
+
encoding: 'utf8',
|
|
1167
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1168
|
+
})
|
|
1169
|
+
const withEnv = spawnSync('ps', ['eww', '-o', 'command=', '-p', String(pid)], {
|
|
1170
|
+
encoding: 'utf8',
|
|
1171
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1172
|
+
})
|
|
1173
|
+
if (plain.status !== 0 || withEnv.status !== 0) return false
|
|
1174
|
+
const argv = (plain.stdout || '').trimEnd()
|
|
1175
|
+
const full = (withEnv.stdout || '').trimEnd()
|
|
1176
|
+
const envSuffix = full.startsWith(argv) ? full.slice(argv.length) : full
|
|
1177
|
+
return envSuffix.includes('SOOTSIM_PW_URL=')
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
export async function terminatePlaywrightHostsForSims(
|
|
1181
|
+
sims: BridgeSimInfo[],
|
|
1182
|
+
ids: string[],
|
|
1183
|
+
) {
|
|
1184
|
+
const wanted = new Set(ids)
|
|
1185
|
+
const pids = new Set<number>()
|
|
1186
|
+
for (const sim of sims) {
|
|
1187
|
+
if (!wanted.has(sim.id)) continue
|
|
1188
|
+
const pid = playwrightHostPidForSim(sim)
|
|
1189
|
+
if (pid) pids.add(pid)
|
|
1190
|
+
}
|
|
1191
|
+
for (const pid of pids) {
|
|
1192
|
+
if (!isPlaywrightHostProcess(pid)) {
|
|
1193
|
+
console.log(` skipped pid ${pid}: not a rnx playwright host (stale sim record)`)
|
|
1194
|
+
continue
|
|
1195
|
+
}
|
|
1196
|
+
try {
|
|
1197
|
+
process.kill(pid, 'SIGTERM')
|
|
1198
|
+
} catch {}
|
|
1199
|
+
if (await waitForProcessExit(pid)) {
|
|
1200
|
+
console.log(` closed playwright host process ${pid}`)
|
|
1201
|
+
continue
|
|
1202
|
+
}
|
|
1203
|
+
try {
|
|
1204
|
+
process.kill(pid, 'SIGKILL')
|
|
1205
|
+
console.log(` force-closed playwright host process ${pid}`)
|
|
1206
|
+
} catch {}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function resolveTargetSim(sims: BridgeSimInfo[], requestedId?: string): BridgeSimInfo {
|
|
1211
|
+
if (requestedId) {
|
|
1212
|
+
const normalizedId = requestedId.trim()
|
|
1213
|
+
const match = sims.find((sim) => sim.id === normalizedId)
|
|
1214
|
+
if (!match) {
|
|
1215
|
+
throw new Error(`no sim connected with id ${normalizedId}`)
|
|
1216
|
+
}
|
|
1217
|
+
return match
|
|
1218
|
+
}
|
|
1219
|
+
const primary = sims.find((sim) => sim.isPrimary && sim.readyState === 'open')
|
|
1220
|
+
if (primary) return primary
|
|
1221
|
+
const firstOpen = sims.find((sim) => sim.readyState === 'open')
|
|
1222
|
+
if (firstOpen) return firstOpen
|
|
1223
|
+
throw new Error('no sim connected')
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function printOpenedSim(
|
|
1227
|
+
openUrl: string,
|
|
1228
|
+
sim: BridgeSimInfo,
|
|
1229
|
+
mode:
|
|
1230
|
+
| 'desktop companion'
|
|
1231
|
+
| 'bridge'
|
|
1232
|
+
| 'direct shell open'
|
|
1233
|
+
| 'current sim'
|
|
1234
|
+
| `${string} driver`,
|
|
1235
|
+
) {
|
|
1236
|
+
console.log(` ${mode === 'current sim' ? 'loaded' : 'opened'}: ${openUrl} [${mode}]`)
|
|
1237
|
+
// agents pipe around the JSON to grab the id; humans just want to know
|
|
1238
|
+
// the current sim got updated without scanning the JSON blob.
|
|
1239
|
+
console.log(` current sim: ${sim.id}`)
|
|
1240
|
+
console.log(JSON.stringify({ simId: sim.id, url: sim.url }, null, 2))
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// after a successful open, agents almost always want to know what's on the
|
|
1244
|
+
// screen. running describe inline saves a separate roundtrip. tightly
|
|
1245
|
+
// bounded — a cold guest bundle takes 5-15s to fully route, so we don't
|
|
1246
|
+
// try to wait for that. instead: settle for a moment to let the first
|
|
1247
|
+
// paint land, then describe whatever's on screen. for cold loads that's
|
|
1248
|
+
// a splash; the caller can `sootsim describe` again a second later. for
|
|
1249
|
+
// warm loads it's the real screen. guarded by --no-describe.
|
|
1250
|
+
async function maybeDescribeAfterOpen(wsPort: number, simId: string, args: string[]) {
|
|
1251
|
+
if (args.includes('--no-describe')) return
|
|
1252
|
+
const prevQuiet = process.env.SOOTSIM_QUIET_TARGET_NOTICE
|
|
1253
|
+
process.env.SOOTSIM_QUIET_TARGET_NOTICE = '1'
|
|
1254
|
+
try {
|
|
1255
|
+
// brief settle, then dump the tree directly. don't go through
|
|
1256
|
+
// runDescribeSubcommand — its screen-transition wait can block for many
|
|
1257
|
+
// seconds on a still-routing guest app, which is exactly what we want
|
|
1258
|
+
// to skip for the open-side describe. caller can `sootsim describe`
|
|
1259
|
+
// again later for post-routing state.
|
|
1260
|
+
await waitForTreeStable(wsPort, simId, { stableMs: 150, maxMs: 400 })
|
|
1261
|
+
const bridge = createBridge(wsPort, {
|
|
1262
|
+
commandTimeoutMs: 3000,
|
|
1263
|
+
simId,
|
|
1264
|
+
cliLabel: 'open --describe',
|
|
1265
|
+
simIdSource: 'flag',
|
|
1266
|
+
})
|
|
1267
|
+
try {
|
|
1268
|
+
let readyProbe: ReadyProbe | null = null
|
|
1269
|
+
try {
|
|
1270
|
+
readyProbe = (await bridge.send({
|
|
1271
|
+
type: 'evaluate',
|
|
1272
|
+
code: WAIT_READY_PROBE,
|
|
1273
|
+
})) as ReadyProbe | null
|
|
1274
|
+
} catch {}
|
|
1275
|
+
if (readyProbe) {
|
|
1276
|
+
const readyEnough =
|
|
1277
|
+
readyProbe.flag === true &&
|
|
1278
|
+
!readyProbe.loadingText &&
|
|
1279
|
+
readyProbeHasContent(readyProbe)
|
|
1280
|
+
if (!readyEnough) {
|
|
1281
|
+
const reason = readyProbe.loadingText
|
|
1282
|
+
? `still showing "${readyProbe.loadingText}"`
|
|
1283
|
+
: readyProbe.flag !== true
|
|
1284
|
+
? 'guest ready event has not fired'
|
|
1285
|
+
: readyProbe.targets <= 0
|
|
1286
|
+
? 'no visible app content is inspectable yet'
|
|
1287
|
+
: 'app tree is still stabilizing'
|
|
1288
|
+
console.log(
|
|
1289
|
+
` app still loading: ${reason} (nodes: ${readyProbe.nodes}, targets: ${readyProbe.targets})`,
|
|
1290
|
+
)
|
|
1291
|
+
console.log(' before interacting, run: rnxsim wait ready --max-ms 120000')
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
const dumpOpts = { describe: true, verbose: false, filter: '' }
|
|
1295
|
+
const code = `(async () => {
|
|
1296
|
+
const t = window.__sootsimTest
|
|
1297
|
+
const ms = window.SootSim?.bridges?.mainShell
|
|
1298
|
+
if (!t) return null
|
|
1299
|
+
let shell = null
|
|
1300
|
+
try { shell = ms?.getState ? await ms.getState() : null } catch {}
|
|
1301
|
+
const tree = await t.dumpTree(12, ${JSON.stringify(dumpOpts)})
|
|
1302
|
+
return { tree, shell }
|
|
1303
|
+
})()`
|
|
1304
|
+
const result = (await bridge.send({ type: 'evaluate', code })) as {
|
|
1305
|
+
tree: string | null
|
|
1306
|
+
shell: { state?: string; activeApp?: string } | null
|
|
1307
|
+
} | null
|
|
1308
|
+
if (!result?.tree) return
|
|
1309
|
+
console.log('')
|
|
1310
|
+
if (result.shell?.state) {
|
|
1311
|
+
const parts = [
|
|
1312
|
+
`state=${result.shell.state}`,
|
|
1313
|
+
result.shell.activeApp ? `app=${result.shell.activeApp}` : null,
|
|
1314
|
+
].filter(Boolean)
|
|
1315
|
+
console.log(` shell: ${parts.join(' ')}`)
|
|
1316
|
+
}
|
|
1317
|
+
console.log(result.tree)
|
|
1318
|
+
} finally {
|
|
1319
|
+
bridge.close()
|
|
1320
|
+
}
|
|
1321
|
+
} catch {
|
|
1322
|
+
// describe is a convenience — never fail the open command on it
|
|
1323
|
+
} finally {
|
|
1324
|
+
if (prevQuiet === undefined) {
|
|
1325
|
+
delete process.env.SOOTSIM_QUIET_TARGET_NOTICE
|
|
1326
|
+
} else {
|
|
1327
|
+
process.env.SOOTSIM_QUIET_TARGET_NOTICE = prevQuiet
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// poll node count and return as soon as two samples match across `stableMs`,
|
|
1333
|
+
// capped by `maxMs`. brief and bounded so this never blocks the open command.
|
|
1334
|
+
async function waitForTreeStable(
|
|
1335
|
+
wsPort: number,
|
|
1336
|
+
simId: string,
|
|
1337
|
+
opts: { stableMs: number; maxMs: number },
|
|
1338
|
+
): Promise<void> {
|
|
1339
|
+
const deadline = Date.now() + opts.maxMs
|
|
1340
|
+
const probeCode = `(async () => (await window.__sootsimTest?.getNodeCount?.()) || 0)()`
|
|
1341
|
+
const bridge = createBridge(wsPort, {
|
|
1342
|
+
commandTimeoutMs: 2000,
|
|
1343
|
+
simId,
|
|
1344
|
+
simIdSource: 'flag',
|
|
1345
|
+
})
|
|
1346
|
+
try {
|
|
1347
|
+
let lastCount = -1
|
|
1348
|
+
let stableSince = 0
|
|
1349
|
+
while (Date.now() < deadline) {
|
|
1350
|
+
let count = -1
|
|
1351
|
+
try {
|
|
1352
|
+
const c = await bridge.send({ type: 'evaluate', code: probeCode })
|
|
1353
|
+
if (typeof c === 'number') count = c
|
|
1354
|
+
} catch {
|
|
1355
|
+
// transient — try again
|
|
1356
|
+
}
|
|
1357
|
+
if (count >= 0 && count === lastCount) {
|
|
1358
|
+
if (Date.now() - stableSince >= opts.stableMs) return
|
|
1359
|
+
} else {
|
|
1360
|
+
lastCount = count
|
|
1361
|
+
stableSince = Date.now()
|
|
1362
|
+
}
|
|
1363
|
+
await sleep(50)
|
|
1364
|
+
}
|
|
1365
|
+
} finally {
|
|
1366
|
+
bridge.close()
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
export async function runLinkCommand(args: string[], opts: ControlCommandOptions = {}) {
|
|
1371
|
+
const parsed = parseBridgeCliArgs(args, {
|
|
1372
|
+
port: opts.port,
|
|
1373
|
+
commandTimeoutMs: opts.timeoutMs,
|
|
1374
|
+
})
|
|
1375
|
+
if (parsed.positional.length !== 1) {
|
|
1376
|
+
console.error(' rnxsim open: expected exactly one <url-or-route>')
|
|
1377
|
+
console.error(' examples: rnxsim open /settings | rnxsim open myapp://settings')
|
|
1378
|
+
process.exit(1)
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
let target: string
|
|
1382
|
+
try {
|
|
1383
|
+
target = normalizeDeepLinkTarget(parsed.positional[0] ?? '')
|
|
1384
|
+
} catch (err) {
|
|
1385
|
+
console.error(` rnxsim open: ${err instanceof Error ? err.message : String(err)}`)
|
|
1386
|
+
process.exit(1)
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const bridge = createBridgeFromParsed(parsed)
|
|
1390
|
+
const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
|
|
1391
|
+
try {
|
|
1392
|
+
const result = await bridge.send({
|
|
1393
|
+
type: 'call',
|
|
1394
|
+
path: 'SootSim.bridges.test.openDeepLink',
|
|
1395
|
+
args: [target],
|
|
1396
|
+
})
|
|
1397
|
+
if (isObjectRecord(result) && result.ok === false) {
|
|
1398
|
+
throw new Error(
|
|
1399
|
+
typeof result.error === 'string' ? result.error : 'deep-link dispatch failed',
|
|
1400
|
+
)
|
|
1401
|
+
}
|
|
1402
|
+
console.log(` opened deep link: ${target}`)
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
console.error(` link failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
1405
|
+
await printBridgeFailureDiagnostics(bridge, {
|
|
1406
|
+
errorsCommand: `rnxsim get errors 5${simHint}`,
|
|
1407
|
+
warningsCommand: `rnxsim get warnings 5${simHint}`,
|
|
1408
|
+
requestsCommand: `rnxsim get requests 5${simHint}`,
|
|
1409
|
+
})
|
|
1410
|
+
process.exit(1)
|
|
1411
|
+
} finally {
|
|
1412
|
+
bridge.close()
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
export async function runOpenCommand(args: string[], opts: ControlCommandOptions = {}) {
|
|
1417
|
+
const parsed = parseBridgeCliArgs(args, {
|
|
1418
|
+
port: opts.port,
|
|
1419
|
+
commandTimeoutMs: opts.timeoutMs,
|
|
1420
|
+
stripBooleanFlags: [
|
|
1421
|
+
'--new',
|
|
1422
|
+
'--headless',
|
|
1423
|
+
'--headed',
|
|
1424
|
+
'--ephemeral',
|
|
1425
|
+
'--hot',
|
|
1426
|
+
'--no-hmr',
|
|
1427
|
+
'--no-describe',
|
|
1428
|
+
],
|
|
1429
|
+
stripValueFlags: [
|
|
1430
|
+
'--base-url',
|
|
1431
|
+
'--replace',
|
|
1432
|
+
'--driver',
|
|
1433
|
+
'--profile',
|
|
1434
|
+
'--cdp-port',
|
|
1435
|
+
'--device',
|
|
1436
|
+
'--viewport',
|
|
1437
|
+
],
|
|
1438
|
+
})
|
|
1439
|
+
const requestedProfileId = args.find((_, i) => args[i - 1] === '--profile')
|
|
1440
|
+
const ephemeralProfile = args.includes('--ephemeral')
|
|
1441
|
+
// --cdp-port exposes Chrome's remote-debugging endpoint on the opened sim so
|
|
1442
|
+
// it can be cpu-profiled while bridge-driven (playwright driver only).
|
|
1443
|
+
const cdpPortArg = args.find((_, i) => args[i - 1] === '--cdp-port')
|
|
1444
|
+
const cdpPort = cdpPortArg ? Number(cdpPortArg) : undefined
|
|
1445
|
+
if (cdpPortArg && (!Number.isFinite(cdpPort) || cdpPort! <= 0)) {
|
|
1446
|
+
console.error(
|
|
1447
|
+
` rnxsim open: --cdp-port must be a positive port number, got "${cdpPortArg}"`,
|
|
1448
|
+
)
|
|
1449
|
+
process.exit(1)
|
|
1450
|
+
}
|
|
1451
|
+
if (requestedProfileId && ephemeralProfile) {
|
|
1452
|
+
console.error(' rnxsim open: --profile cannot be combined with --ephemeral')
|
|
1453
|
+
process.exit(1)
|
|
1454
|
+
}
|
|
1455
|
+
const profileId = requestedProfileId ? ensureProfile(requestedProfileId).id : undefined
|
|
1456
|
+
const openInNewSim = args.includes('--new') || !!profileId || ephemeralProfile
|
|
1457
|
+
const commandConfig = parseOpenRuntimeConfig(args)
|
|
1458
|
+
if (openInNewSim && parsed.simIdSource === 'flag') {
|
|
1459
|
+
console.error(
|
|
1460
|
+
' rnxsim open: --new, --profile, and --ephemeral cannot be combined with --sim',
|
|
1461
|
+
)
|
|
1462
|
+
process.exit(1)
|
|
1463
|
+
}
|
|
1464
|
+
const target = parsed.positional[0] || ''
|
|
1465
|
+
|
|
1466
|
+
if (shouldOpenAsDeepLink(target)) {
|
|
1467
|
+
if (openInNewSim) {
|
|
1468
|
+
console.error(
|
|
1469
|
+
' rnxsim open: deep links target the current app; remove --new, --profile, or --ephemeral',
|
|
1470
|
+
)
|
|
1471
|
+
process.exit(1)
|
|
1472
|
+
}
|
|
1473
|
+
await runLinkCommand(args, opts)
|
|
1474
|
+
return
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// a preview/build link is a viewing page, not a driveable sim target —
|
|
1478
|
+
// open it in the browser and explain rather than mis-loading it as a
|
|
1479
|
+
// bundle. see looksLikeSootSimShareUrl for why these can't be driven.
|
|
1480
|
+
if (looksLikeSootSimShareUrl(target)) {
|
|
1481
|
+
const isLocal = (() => {
|
|
1482
|
+
try {
|
|
1483
|
+
return isLoopbackHost(new URL(target).hostname)
|
|
1484
|
+
} catch {
|
|
1485
|
+
return false
|
|
1486
|
+
}
|
|
1487
|
+
})()
|
|
1488
|
+
console.log(
|
|
1489
|
+
' that’s an rnxsim preview/build link — a full player page, not a driveable sim.',
|
|
1490
|
+
)
|
|
1491
|
+
console.log(' opening it in your browser for viewing…')
|
|
1492
|
+
try {
|
|
1493
|
+
await openUrlInBrowser(target, { background: false })
|
|
1494
|
+
} catch (err) {
|
|
1495
|
+
console.error(
|
|
1496
|
+
` could not launch a browser: ${err instanceof Error ? err.message : String(err)}`,
|
|
1497
|
+
)
|
|
1498
|
+
console.error(` open it yourself: ${target}`)
|
|
1499
|
+
process.exit(1)
|
|
1500
|
+
}
|
|
1501
|
+
console.log(
|
|
1502
|
+
isLocal
|
|
1503
|
+
? ' to drive it under the CLI, point `rnxsim open` at the app’s dev port instead (e.g. `rnxsim open 8081`).'
|
|
1504
|
+
: ' the CLI can’t drive a remote preview (the engine only attaches to the local bridge from localhost). to drive the app, run it locally and `rnxsim open <port>`.',
|
|
1505
|
+
)
|
|
1506
|
+
return
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
const resolvedBundle = shouldResolveBundleTargetInCli(target)
|
|
1510
|
+
? await resolveConnectionInputForCli(target)
|
|
1511
|
+
: undefined
|
|
1512
|
+
const project = await resolveOpenAppProject(resolvedBundle)
|
|
1513
|
+
const appConfig = await loadSootSimAppConfig(project)
|
|
1514
|
+
const configuredRuntimeVersion = appConfig?.runtimeVersion
|
|
1515
|
+
const runtimeConfig = mergeSootSimConfig(
|
|
1516
|
+
appConfig ? { ...appConfig, runtimeVersion: undefined } : undefined,
|
|
1517
|
+
commandConfig,
|
|
1518
|
+
)
|
|
1519
|
+
if (configuredRuntimeVersion) {
|
|
1520
|
+
await ensureConfiguredRuntime(configuredRuntimeVersion)
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// keep the bridge port and the shell base URL in one world: an explicit
|
|
1524
|
+
// --base-url without --port retargets polling at THAT world's bridge
|
|
1525
|
+
// (otherwise open builds the page against one runtime while polling the
|
|
1526
|
+
// other bridge for registration and times out after ~125s).
|
|
1527
|
+
const explicitBaseUrl = args.find((_, i) => args[i - 1] === '--base-url')
|
|
1528
|
+
if (explicitBaseUrl && !parsed.explicitPort) {
|
|
1529
|
+
const matched = bridgePortForShellBaseUrl(explicitBaseUrl)
|
|
1530
|
+
if (matched) parsed.wsPort = matched
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
parsed.wsPort = await ensureBridgeForOpen(parsed.wsPort, parsed.explicitPort)
|
|
1534
|
+
|
|
1535
|
+
// playwright is the browser-sim launcher. it owns an isolated profile and
|
|
1536
|
+
// can reap exactly its own browser tree. electron remains available for the
|
|
1537
|
+
// native desktop surface. neither path opens the user's browser profile.
|
|
1538
|
+
const requestedDriverId = args.find((_, i) => args[i - 1] === '--driver') || ''
|
|
1539
|
+
if (requestedDriverId && !isDriverId(requestedDriverId)) {
|
|
1540
|
+
console.error(
|
|
1541
|
+
` unknown driver "${requestedDriverId}" — run \`rnxsim list --drivers\``,
|
|
1542
|
+
)
|
|
1543
|
+
process.exit(1)
|
|
1544
|
+
}
|
|
1545
|
+
const driverId: DriverId = isDriverId(requestedDriverId)
|
|
1546
|
+
? requestedDriverId
|
|
1547
|
+
: 'playwright'
|
|
1548
|
+
const driverHeadless = resolveDriverHeadless(args, driverId)
|
|
1549
|
+
if (cdpPort && driverId !== 'playwright') {
|
|
1550
|
+
console.error(' rnxsim open: --cdp-port is only honored by the playwright driver')
|
|
1551
|
+
process.exit(1)
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// metro HMR mode. interactive opens default to hot=true so edits hot-apply
|
|
1555
|
+
// through sootsim's HmrClient — expo CLI's createBundleUrlSearchParams
|
|
1556
|
+
// hardcodes hot=false on every launchAsset URL (a leftover toggle real iOS
|
|
1557
|
+
// sims tolerate but sootsim couldn't until now), so without this flip the
|
|
1558
|
+
// bundle prelude doesn't include metro's HMR runtime + react-refresh and
|
|
1559
|
+
// edits silently no-op. `--driver playwright` (agent / CI runs) defaults
|
|
1560
|
+
// to hot=false for deterministic e2e. explicit `--hot` / `--no-hmr` win.
|
|
1561
|
+
const hotFlag = args.includes('--hot')
|
|
1562
|
+
const noHmrFlag = args.includes('--no-hmr')
|
|
1563
|
+
if (hotFlag && noHmrFlag) {
|
|
1564
|
+
console.error(' rnxsim open: --hot and --no-hmr cannot be combined')
|
|
1565
|
+
process.exit(1)
|
|
1566
|
+
}
|
|
1567
|
+
const hotMode = hotFlag ? true : noHmrFlag ? false : requestedDriverId !== 'playwright'
|
|
1568
|
+
|
|
1569
|
+
// --device <model> seeds the device profile for the opened sim (stamped on
|
|
1570
|
+
// the engine URL, so both playwright and electron honor it). without it
|
|
1571
|
+
// the sim runs whatever profile the
|
|
1572
|
+
// shell last persisted, which silently skews browser comparisons.
|
|
1573
|
+
const deviceArg = args.find((_, i) => args[i - 1] === '--device')
|
|
1574
|
+
if (deviceArg && !(deviceArg in devices)) {
|
|
1575
|
+
console.error(` rnxsim open: unknown device "${deviceArg}"`)
|
|
1576
|
+
console.error(` known devices: ${listSelectableDeviceModels().join(', ')}`)
|
|
1577
|
+
process.exit(1)
|
|
1578
|
+
}
|
|
1579
|
+
const deviceSpec = deviceArg ? devices[deviceArg as DeviceModel] : undefined
|
|
1580
|
+
|
|
1581
|
+
// --viewport <WxH> sizes the driver-launched page explicitly (film and
|
|
1582
|
+
// promo captures frame the 3d stage in landscape). takes precedence over
|
|
1583
|
+
// the device-derived window; without either, the driver keeps its own
|
|
1584
|
+
// default.
|
|
1585
|
+
const viewportArg = args.find((_, i) => args[i - 1] === '--viewport')
|
|
1586
|
+
const viewportMatch = viewportArg ? /^(\d+)x(\d+)$/.exec(viewportArg) : null
|
|
1587
|
+
if (viewportArg && !viewportMatch) {
|
|
1588
|
+
console.error(
|
|
1589
|
+
` rnxsim open: --viewport must be <width>x<height>, got "${viewportArg}"`,
|
|
1590
|
+
)
|
|
1591
|
+
process.exit(1)
|
|
1592
|
+
}
|
|
1593
|
+
const explicitViewport = viewportMatch
|
|
1594
|
+
? { width: Number(viewportMatch[1]), height: Number(viewportMatch[2]) }
|
|
1595
|
+
: undefined
|
|
1596
|
+
|
|
1597
|
+
const buildShellUrlOpts: BuildShellUrlOptions = {
|
|
1598
|
+
hot: hotMode,
|
|
1599
|
+
device: deviceArg,
|
|
1600
|
+
resolvedBundle,
|
|
1601
|
+
project,
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// resolve the shell base URL from the bridge port we settled on, so the page
|
|
1605
|
+
// and the polling never split across worlds.
|
|
1606
|
+
const selectedShellBaseUrl = configuredRuntimeVersion
|
|
1607
|
+
? `http://localhost:${parsed.wsPort}/`
|
|
1608
|
+
: explicitBaseUrl || resolveShellBaseUrlForBridgePort(parsed.wsPort)
|
|
1609
|
+
const baseUrl = applyRuntimeGenerationOrigin(
|
|
1610
|
+
selectedShellBaseUrl,
|
|
1611
|
+
configuredRuntimeVersion,
|
|
1612
|
+
)
|
|
1613
|
+
const hasExplicitBaseUrl = args.includes('--base-url')
|
|
1614
|
+
printBridgeWorldNotice(parsed.wsPort, baseUrl)
|
|
1615
|
+
const savedSimId = readCurrentSimId()
|
|
1616
|
+
const shouldReuseCurrentSim = !openInNewSim
|
|
1617
|
+
|
|
1618
|
+
if (shouldReuseCurrentSim) {
|
|
1619
|
+
const bridge = createBridgeFromParsed(parsed)
|
|
1620
|
+
const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
|
|
1621
|
+
let fallBackToNewSim = false
|
|
1622
|
+
|
|
1623
|
+
try {
|
|
1624
|
+
let targetSim: BridgeSimInfo | null = null
|
|
1625
|
+
let targetShellUrl: string | null = null
|
|
1626
|
+
try {
|
|
1627
|
+
const sims = await bridge.listSims()
|
|
1628
|
+
if (parsed.simIdSource === 'saved') {
|
|
1629
|
+
targetSim =
|
|
1630
|
+
sims.find(
|
|
1631
|
+
(sim) =>
|
|
1632
|
+
sim.id === savedSimId &&
|
|
1633
|
+
sim.readyState === 'open' &&
|
|
1634
|
+
simUsesDriver(sim, driverId),
|
|
1635
|
+
) ?? null
|
|
1636
|
+
if (!targetSim) {
|
|
1637
|
+
clearCurrentSimId()
|
|
1638
|
+
}
|
|
1639
|
+
} else if (parsed.simIdSource === 'flag') {
|
|
1640
|
+
targetSim = resolveTargetSim(sims, parsed.simId)
|
|
1641
|
+
if (!simUsesDriver(targetSim, driverId)) {
|
|
1642
|
+
throw new Error(
|
|
1643
|
+
`sim ${targetSim.id} is not managed by the ${driverId} driver`,
|
|
1644
|
+
)
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
if (!targetSim && parsed.simIdSource !== 'flag') {
|
|
1648
|
+
targetShellUrl = await buildShellUrl(
|
|
1649
|
+
target,
|
|
1650
|
+
applySootSimConfigToUrl(baseUrl, runtimeConfig),
|
|
1651
|
+
buildShellUrlOpts,
|
|
1652
|
+
)
|
|
1653
|
+
const normalizedTarget = new URL(targetShellUrl)
|
|
1654
|
+
normalizedTarget.searchParams.delete('inspectOpen')
|
|
1655
|
+
targetSim =
|
|
1656
|
+
sims.find((sim) => {
|
|
1657
|
+
if (
|
|
1658
|
+
sim.readyState !== 'open' ||
|
|
1659
|
+
!sim.url ||
|
|
1660
|
+
!simUsesDriver(sim, driverId)
|
|
1661
|
+
) {
|
|
1662
|
+
return false
|
|
1663
|
+
}
|
|
1664
|
+
try {
|
|
1665
|
+
const normalizedCurrent = new URL(sim.url)
|
|
1666
|
+
normalizedCurrent.searchParams.delete('inspectOpen')
|
|
1667
|
+
return normalizedCurrent.toString() === normalizedTarget.toString()
|
|
1668
|
+
} catch {
|
|
1669
|
+
return false
|
|
1670
|
+
}
|
|
1671
|
+
}) ?? null
|
|
1672
|
+
}
|
|
1673
|
+
if (!targetSim) {
|
|
1674
|
+
if (parsed.simIdSource !== 'flag') {
|
|
1675
|
+
fallBackToNewSim = true
|
|
1676
|
+
} else {
|
|
1677
|
+
throw new Error('no sim connected')
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
if (!fallBackToNewSim && targetSim) {
|
|
1681
|
+
const resolvedBaseUrl =
|
|
1682
|
+
hasExplicitBaseUrl || looksLikeSootsimUrl(target)
|
|
1683
|
+
? baseUrl
|
|
1684
|
+
: deriveCurrentSimBaseUrl(targetSim, baseUrl)
|
|
1685
|
+
const shellUrl =
|
|
1686
|
+
targetShellUrl ??
|
|
1687
|
+
(await buildShellUrl(
|
|
1688
|
+
target,
|
|
1689
|
+
applySootSimConfigToUrl(resolvedBaseUrl, runtimeConfig),
|
|
1690
|
+
buildShellUrlOpts,
|
|
1691
|
+
))
|
|
1692
|
+
bridge
|
|
1693
|
+
.send({
|
|
1694
|
+
type: 'evaluate',
|
|
1695
|
+
simId: targetSim.id,
|
|
1696
|
+
code: `window.location.href = ${JSON.stringify(shellUrl)}`,
|
|
1697
|
+
})
|
|
1698
|
+
.catch(() => {})
|
|
1699
|
+
}
|
|
1700
|
+
} catch (err) {
|
|
1701
|
+
console.error(
|
|
1702
|
+
` open failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1703
|
+
)
|
|
1704
|
+
await printBridgeFailureDiagnostics(bridge, {
|
|
1705
|
+
errorsCommand: `rnxsim get errors 5${simHint}`,
|
|
1706
|
+
warningsCommand: `rnxsim get warnings 5${simHint}`,
|
|
1707
|
+
requestsCommand: `rnxsim get requests 5${simHint}`,
|
|
1708
|
+
})
|
|
1709
|
+
process.exit(1)
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
if (!fallBackToNewSim && targetSim) {
|
|
1713
|
+
await sleep(1500)
|
|
1714
|
+
const ready = await waitForSimReady(
|
|
1715
|
+
parsed.wsPort,
|
|
1716
|
+
parsed.commandTimeoutMs,
|
|
1717
|
+
targetSim.id,
|
|
1718
|
+
)
|
|
1719
|
+
if (!ready) {
|
|
1720
|
+
console.error(' timed out waiting for current sim to load target')
|
|
1721
|
+
process.exit(1)
|
|
1722
|
+
}
|
|
1723
|
+
ready.bridge.close()
|
|
1724
|
+
saveCurrentSimId(targetSim.id)
|
|
1725
|
+
const resolvedBaseUrl =
|
|
1726
|
+
hasExplicitBaseUrl || looksLikeSootsimUrl(target)
|
|
1727
|
+
? baseUrl
|
|
1728
|
+
: deriveCurrentSimBaseUrl(targetSim, baseUrl)
|
|
1729
|
+
const shellUrl =
|
|
1730
|
+
targetShellUrl ??
|
|
1731
|
+
(await buildShellUrl(
|
|
1732
|
+
target,
|
|
1733
|
+
applySootSimConfigToUrl(resolvedBaseUrl, runtimeConfig),
|
|
1734
|
+
buildShellUrlOpts,
|
|
1735
|
+
))
|
|
1736
|
+
printOpenedSim(shellUrl, { ...targetSim, url: shellUrl }, 'current sim')
|
|
1737
|
+
await maybeDescribeAfterOpen(parsed.wsPort, targetSim.id, args)
|
|
1738
|
+
return
|
|
1739
|
+
}
|
|
1740
|
+
} finally {
|
|
1741
|
+
bridge.close()
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
const configuredBaseUrl = applySootSimConfigToUrl(baseUrl, runtimeConfig)
|
|
1746
|
+
const token = `cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
1747
|
+
const openUrl = await buildOpenUrl(target, configuredBaseUrl, token, buildShellUrlOpts)
|
|
1748
|
+
const ownerPid = getStableOwnerPid()
|
|
1749
|
+
if (!ownerPid) {
|
|
1750
|
+
console.error(
|
|
1751
|
+
' could not resolve a stable owning process; refusing unsupervised sim host',
|
|
1752
|
+
)
|
|
1753
|
+
process.exit(1)
|
|
1754
|
+
}
|
|
1755
|
+
const simMatchesToken = (sim: BridgeSimInfo) =>
|
|
1756
|
+
sim.url?.includes(`inspectOpen=${token}`) ?? false
|
|
1757
|
+
|
|
1758
|
+
const driver = getDriver(driverId)
|
|
1759
|
+
if (!driver) {
|
|
1760
|
+
console.error(` unknown driver "${driverId}" — run \`rnxsim list --drivers\``)
|
|
1761
|
+
process.exit(1)
|
|
1762
|
+
}
|
|
1763
|
+
const connectWait = resolveDriverConnectWaitOptions()
|
|
1764
|
+
const result = await driver.launch({
|
|
1765
|
+
url: openUrl,
|
|
1766
|
+
// the shell serves the sim page but its WS bridge may have drifted off
|
|
1767
|
+
// the shell-port-implied default. inject the resolved bridge port so the
|
|
1768
|
+
// page does not reconnect to a stale world.
|
|
1769
|
+
bridgePort: bridgePortForShellBaseUrl(baseUrl) ?? parsed.wsPort,
|
|
1770
|
+
headless: driverHeadless,
|
|
1771
|
+
profileId,
|
|
1772
|
+
ephemeralProfile,
|
|
1773
|
+
cdpPort,
|
|
1774
|
+
device: deviceArg,
|
|
1775
|
+
ownerPid,
|
|
1776
|
+
viewport:
|
|
1777
|
+
explicitViewport ??
|
|
1778
|
+
(deviceSpec
|
|
1779
|
+
? { width: deviceSpec.width + 120, height: deviceSpec.height + 60 }
|
|
1780
|
+
: undefined),
|
|
1781
|
+
connectTimeoutMs: connectWait.hostTimeoutMs,
|
|
1782
|
+
})
|
|
1783
|
+
if (!result.launched) {
|
|
1784
|
+
console.error(` ${driver.name} driver: ${result.message}`)
|
|
1785
|
+
process.exit(1)
|
|
1786
|
+
}
|
|
1787
|
+
const outcome = await waitForSimMatchOrHostExit(
|
|
1788
|
+
parsed.wsPort,
|
|
1789
|
+
parsed.commandTimeoutMs,
|
|
1790
|
+
simMatchesToken,
|
|
1791
|
+
result.pid,
|
|
1792
|
+
{ attempts: connectWait.attempts, intervalMs: connectWait.intervalMs },
|
|
1793
|
+
)
|
|
1794
|
+
if (outcome.kind === 'host-exit') {
|
|
1795
|
+
printDriverDiagnosticLogTail(result)
|
|
1796
|
+
console.error(` ${driver.name} host crashed — sim is no longer attached`)
|
|
1797
|
+
process.exit(1)
|
|
1798
|
+
}
|
|
1799
|
+
if (outcome.kind === 'timeout') {
|
|
1800
|
+
if (result.pid) {
|
|
1801
|
+
try {
|
|
1802
|
+
process.kill(result.pid, 'SIGTERM')
|
|
1803
|
+
console.error(` closed ${driver.name} host process ${result.pid}`)
|
|
1804
|
+
} catch {}
|
|
1805
|
+
}
|
|
1806
|
+
printDriverDiagnosticLogTail(result)
|
|
1807
|
+
console.error(
|
|
1808
|
+
` timed out after ${connectWait.timeoutMs}ms waiting for opened sim to connect`,
|
|
1809
|
+
)
|
|
1810
|
+
process.exit(1)
|
|
1811
|
+
}
|
|
1812
|
+
const match = outcome.sim
|
|
1813
|
+
saveCurrentSimId(match.id)
|
|
1814
|
+
signalDriverLaunchConnected(result)
|
|
1815
|
+
printOpenedSim(openUrl, match, `${driver.name} driver`)
|
|
1816
|
+
await maybeDescribeAfterOpen(parsed.wsPort, match.id, args)
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
async function runUseLikeCommand(args: string[], opts: ControlCommandOptions = {}) {
|
|
1820
|
+
const parsed = parseBridgeCliArgs(args, {
|
|
1821
|
+
port: opts.port,
|
|
1822
|
+
commandTimeoutMs: opts.timeoutMs,
|
|
1823
|
+
stripBooleanFlags: ['--force'],
|
|
1824
|
+
})
|
|
1825
|
+
const force = args.includes('--force')
|
|
1826
|
+
const bridge = createBridgeFromParsed(parsed)
|
|
1827
|
+
const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
|
|
1828
|
+
|
|
1829
|
+
try {
|
|
1830
|
+
try {
|
|
1831
|
+
const sims = await bridge.listSims()
|
|
1832
|
+
const target = resolveTargetSim(sims, parsed.positional[0] || parsed.simId)
|
|
1833
|
+
// `use`/`focus` retarget the CLI at a sim — silently grabbing one a
|
|
1834
|
+
// human is actively driving is the same footgun `claim` guards against,
|
|
1835
|
+
// so honor the lock here too (this command historically did not, which
|
|
1836
|
+
// let `sootsim use <id>` steal the user's live tab).
|
|
1837
|
+
if (target.lockedBy && target.lockExpiresAt && target.lockExpiresAt > Date.now()) {
|
|
1838
|
+
const secs = Math.max(0, Math.round((target.lockExpiresAt - Date.now()) / 1000))
|
|
1839
|
+
if (target.lockedByKind === 'user-active') {
|
|
1840
|
+
// the active human is interacting with this tab. never steal it,
|
|
1841
|
+
// even with --force — mirrors `claim`, which refuses to even
|
|
1842
|
+
// report a user-active takeover. open a fresh sim instead.
|
|
1843
|
+
console.error(
|
|
1844
|
+
` refused: ${target.id} is locked by the active user (${secs}s) — that's a live human tab`,
|
|
1845
|
+
)
|
|
1846
|
+
console.error(` run \`rnxsim open --new\` for a fresh investigation sim`)
|
|
1847
|
+
process.exit(1)
|
|
1848
|
+
}
|
|
1849
|
+
if (!force) {
|
|
1850
|
+
// another CLI agent leases it. don't hijack its target silently;
|
|
1851
|
+
// a cli lease is short (60s, refreshes per command) so this is
|
|
1852
|
+
// usually a real conflict, not a stale lock.
|
|
1853
|
+
console.error(
|
|
1854
|
+
` refused: ${target.id} is leased by ${target.lockedBy} (${secs}s)`,
|
|
1855
|
+
)
|
|
1856
|
+
console.error(
|
|
1857
|
+
` \`sootsim use ${target.id} --force\` to take it, or \`rnxsim open --new\` for a fresh sim`,
|
|
1858
|
+
)
|
|
1859
|
+
process.exit(1)
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
await bridge.focusSim(target.id)
|
|
1863
|
+
saveCurrentSimId(target.id)
|
|
1864
|
+
console.log(` using: ${target.id}`)
|
|
1865
|
+
} catch (err) {
|
|
1866
|
+
console.error(` use failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
1867
|
+
await printBridgeFailureDiagnostics(bridge, {
|
|
1868
|
+
errorsCommand: `rnxsim get errors 5${simHint}`,
|
|
1869
|
+
warningsCommand: `rnxsim get warnings 5${simHint}`,
|
|
1870
|
+
requestsCommand: `rnxsim get requests 5${simHint}`,
|
|
1871
|
+
})
|
|
1872
|
+
process.exit(1)
|
|
1873
|
+
}
|
|
1874
|
+
} finally {
|
|
1875
|
+
bridge.close()
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
export async function runUseCommand(args: string[], opts: ControlCommandOptions = {}) {
|
|
1880
|
+
await runUseLikeCommand(args, opts)
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
export async function runFocusCommand(args: string[], opts: ControlCommandOptions = {}) {
|
|
1884
|
+
await runUseLikeCommand(args, opts)
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
export async function runClaimCommand(args: string[], opts: ControlCommandOptions = {}) {
|
|
1888
|
+
const parsed = parseBridgeCliArgs(args, {
|
|
1889
|
+
port: opts.port,
|
|
1890
|
+
commandTimeoutMs: opts.timeoutMs,
|
|
1891
|
+
stripBooleanFlags: ['--force'],
|
|
1892
|
+
})
|
|
1893
|
+
const force = args.includes('--force')
|
|
1894
|
+
const bridge = createBridgeFromParsed(parsed)
|
|
1895
|
+
|
|
1896
|
+
try {
|
|
1897
|
+
try {
|
|
1898
|
+
const sims = await bridge.listSims()
|
|
1899
|
+
const target = resolveTargetSim(sims, parsed.positional[0] || parsed.simId)
|
|
1900
|
+
const priorHolder =
|
|
1901
|
+
force && target.lockedBy && target.lockedByKind !== 'user-active'
|
|
1902
|
+
? target.lockedBy
|
|
1903
|
+
: null
|
|
1904
|
+
const result = await bridge.claim(target.id, { force })
|
|
1905
|
+
saveCurrentSimId(target.id)
|
|
1906
|
+
const ttl = Math.max(0, Math.round((result.lockExpiresAt - Date.now()) / 1000))
|
|
1907
|
+
const booted = result.bootedCount > 0 ? ` (booted ${result.bootedCount})` : ''
|
|
1908
|
+
console.log(` claimed: ${result.simId} [${ttl}s]${booted}`)
|
|
1909
|
+
if (priorHolder) {
|
|
1910
|
+
console.log(` took over from: ${priorHolder}`)
|
|
1911
|
+
}
|
|
1912
|
+
} catch (err) {
|
|
1913
|
+
if (err instanceof BridgeSimLockedError) {
|
|
1914
|
+
const secs = Math.max(0, Math.round(err.lock.expiresInMs / 1000))
|
|
1915
|
+
console.error(` claim failed: locked by ${err.lock.by} for ${secs}s more`)
|
|
1916
|
+
console.error(
|
|
1917
|
+
` use --force to take it, or \`rnxsim open --new\` for a fresh sim`,
|
|
1918
|
+
)
|
|
1919
|
+
process.exit(1)
|
|
1920
|
+
}
|
|
1921
|
+
console.error(` claim failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
1922
|
+
process.exit(1)
|
|
1923
|
+
}
|
|
1924
|
+
} finally {
|
|
1925
|
+
bridge.close()
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// close many sims at once. fires every close, then polls listSims until the
|
|
1930
|
+
// whole set is gone or the timeout elapses — far faster than awaiting
|
|
1931
|
+
// waitForSimGone serially when clearing dozens of leaked sims.
|
|
1932
|
+
export async function closeSimsBulk(
|
|
1933
|
+
bridge: ReturnType<typeof createBridgeFromParsed>,
|
|
1934
|
+
wsPort: number,
|
|
1935
|
+
commandTimeoutMs: number,
|
|
1936
|
+
ids: string[],
|
|
1937
|
+
): Promise<{ closed: string[]; remaining: string[] }> {
|
|
1938
|
+
if (ids.length === 0) return { closed: [], remaining: [] }
|
|
1939
|
+
await Promise.all(ids.map((id) => bridge.closeSim(id).catch(() => {})))
|
|
1940
|
+
const wanted = new Set(ids)
|
|
1941
|
+
for (let i = 0; i < 40; i++) {
|
|
1942
|
+
let stillOpen: string[] = []
|
|
1943
|
+
try {
|
|
1944
|
+
const probe = createBridge(wsPort, { commandTimeoutMs })
|
|
1945
|
+
try {
|
|
1946
|
+
const sims = await probe.listSims()
|
|
1947
|
+
stillOpen = sims
|
|
1948
|
+
.filter((s) => wanted.has(s.id) && s.readyState === 'open')
|
|
1949
|
+
.map((s) => s.id)
|
|
1950
|
+
} finally {
|
|
1951
|
+
probe.close()
|
|
1952
|
+
}
|
|
1953
|
+
} catch {
|
|
1954
|
+
stillOpen = []
|
|
1955
|
+
}
|
|
1956
|
+
if (stillOpen.length === 0) {
|
|
1957
|
+
return { closed: [...wanted], remaining: [] }
|
|
1958
|
+
}
|
|
1959
|
+
if (i === 39) {
|
|
1960
|
+
return {
|
|
1961
|
+
closed: [...wanted].filter((id) => !stillOpen.includes(id)),
|
|
1962
|
+
remaining: stillOpen,
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
await sleep(250)
|
|
1966
|
+
}
|
|
1967
|
+
return { closed: [...wanted], remaining: [] }
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
export interface BulkClosePlan {
|
|
1971
|
+
openIds: string[]
|
|
1972
|
+
keepId: string | null
|
|
1973
|
+
targets: string[]
|
|
1974
|
+
staleSavedId: string | null
|
|
1975
|
+
missingExplicitKeepId: string | null
|
|
1976
|
+
fallbackKeepId: string | null
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
export function planBulkCloseTargets(
|
|
1980
|
+
sims: BridgeSimInfo[],
|
|
1981
|
+
opts: {
|
|
1982
|
+
closeOthers: boolean
|
|
1983
|
+
explicitKeepId?: string
|
|
1984
|
+
savedKeepId?: string | null
|
|
1985
|
+
},
|
|
1986
|
+
): BulkClosePlan {
|
|
1987
|
+
const open = sims.filter((sim) => sim.readyState === 'open')
|
|
1988
|
+
const openIds = open.map((sim) => sim.id)
|
|
1989
|
+
const explicitKeepId = opts.explicitKeepId?.trim() || ''
|
|
1990
|
+
const savedKeepId = opts.savedKeepId?.trim() || ''
|
|
1991
|
+
const savedIsOpen = savedKeepId ? open.some((sim) => sim.id === savedKeepId) : false
|
|
1992
|
+
const staleSavedId = savedKeepId && !savedIsOpen ? savedKeepId : null
|
|
1993
|
+
let keepId: string | null = null
|
|
1994
|
+
let missingExplicitKeepId: string | null = null
|
|
1995
|
+
let fallbackKeepId: string | null = null
|
|
1996
|
+
|
|
1997
|
+
if (opts.closeOthers) {
|
|
1998
|
+
if (explicitKeepId) {
|
|
1999
|
+
const match = open.find((sim) => sim.id === explicitKeepId)
|
|
2000
|
+
if (match) {
|
|
2001
|
+
keepId = match.id
|
|
2002
|
+
} else {
|
|
2003
|
+
missingExplicitKeepId = explicitKeepId
|
|
2004
|
+
}
|
|
2005
|
+
} else if (savedKeepId && savedIsOpen) {
|
|
2006
|
+
keepId = savedKeepId
|
|
2007
|
+
} else {
|
|
2008
|
+
const fallback = open.find((sim) => sim.isPrimary) ?? open[0] ?? null
|
|
2009
|
+
if (fallback) {
|
|
2010
|
+
keepId = fallback.id
|
|
2011
|
+
if (savedKeepId && !savedIsOpen) fallbackKeepId = fallback.id
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
return {
|
|
2017
|
+
openIds,
|
|
2018
|
+
keepId,
|
|
2019
|
+
targets: missingExplicitKeepId ? [] : openIds.filter((id) => id !== keepId),
|
|
2020
|
+
staleSavedId,
|
|
2021
|
+
missingExplicitKeepId,
|
|
2022
|
+
fallbackKeepId,
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
export async function runCloseCommand(args: string[], opts: ControlCommandOptions = {}) {
|
|
2027
|
+
const parsed = parseBridgeCliArgs(args, {
|
|
2028
|
+
port: opts.port,
|
|
2029
|
+
commandTimeoutMs: opts.timeoutMs,
|
|
2030
|
+
stripBooleanFlags: ['--all', '--others'],
|
|
2031
|
+
})
|
|
2032
|
+
const bridge = createBridgeFromParsed(parsed)
|
|
2033
|
+
const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
|
|
2034
|
+
const closeAll = args.includes('--all')
|
|
2035
|
+
const closeOthers = args.includes('--others')
|
|
2036
|
+
|
|
2037
|
+
if (closeAll || closeOthers) {
|
|
2038
|
+
try {
|
|
2039
|
+
const sims = await bridge.listSims()
|
|
2040
|
+
const savedId = readCurrentSimId()
|
|
2041
|
+
const explicitKeepId =
|
|
2042
|
+
parsed.positional[0] || (parsed.simIdSource === 'flag' ? parsed.simId : undefined)
|
|
2043
|
+
// `--others` keeps the explicitly-targeted sim, else the saved/current
|
|
2044
|
+
// sim, else primary. if an explicit keep target is gone, abort instead
|
|
2045
|
+
// of treating "keep nothing" as permission to close every open sim.
|
|
2046
|
+
const plan = planBulkCloseTargets(sims, {
|
|
2047
|
+
closeOthers,
|
|
2048
|
+
explicitKeepId,
|
|
2049
|
+
savedKeepId: savedId,
|
|
2050
|
+
})
|
|
2051
|
+
if (plan.staleSavedId) {
|
|
2052
|
+
clearCurrentSimId()
|
|
2053
|
+
}
|
|
2054
|
+
if (plan.fallbackKeepId) {
|
|
2055
|
+
saveCurrentSimId(plan.fallbackKeepId)
|
|
2056
|
+
console.log(
|
|
2057
|
+
` saved sim ${plan.staleSavedId} is gone — keeping primary ${plan.fallbackKeepId}`,
|
|
2058
|
+
)
|
|
2059
|
+
}
|
|
2060
|
+
if (plan.missingExplicitKeepId) {
|
|
2061
|
+
console.error(
|
|
2062
|
+
` close failed: keep sim ${plan.missingExplicitKeepId} is not connected; not closing other sims`,
|
|
2063
|
+
)
|
|
2064
|
+
process.exit(1)
|
|
2065
|
+
}
|
|
2066
|
+
if (plan.targets.length === 0) {
|
|
2067
|
+
console.log(
|
|
2068
|
+
plan.keepId
|
|
2069
|
+
? ` nothing to close — only the kept sim ${plan.keepId} is connected`
|
|
2070
|
+
: plan.staleSavedId
|
|
2071
|
+
? ` nothing to close — no sims connected (cleared stale current sim ${plan.staleSavedId})`
|
|
2072
|
+
: ' nothing to close — no sims connected',
|
|
2073
|
+
)
|
|
2074
|
+
return
|
|
2075
|
+
}
|
|
2076
|
+
const result = await closeSimsBulk(
|
|
2077
|
+
bridge,
|
|
2078
|
+
parsed.wsPort,
|
|
2079
|
+
parsed.commandTimeoutMs,
|
|
2080
|
+
plan.targets,
|
|
2081
|
+
)
|
|
2082
|
+
await terminatePlaywrightHostsForSims(sims, plan.targets)
|
|
2083
|
+
// saved current sim may have just been closed — repoint or clear it.
|
|
2084
|
+
const nextSavedId = readCurrentSimId()
|
|
2085
|
+
if (nextSavedId && result.closed.includes(nextSavedId)) {
|
|
2086
|
+
if (plan.keepId) saveCurrentSimId(plan.keepId)
|
|
2087
|
+
else clearCurrentSimId()
|
|
2088
|
+
}
|
|
2089
|
+
const summary = ` closed ${result.closed.length} sim(s)${
|
|
2090
|
+
plan.keepId ? ` (kept ${plan.keepId})` : ''
|
|
2091
|
+
}`
|
|
2092
|
+
console.log(summary)
|
|
2093
|
+
if (result.remaining.length > 0) {
|
|
2094
|
+
console.error(` close failed: still connected: ${result.remaining.join(', ')}`)
|
|
2095
|
+
process.exit(1)
|
|
2096
|
+
}
|
|
2097
|
+
} catch (err) {
|
|
2098
|
+
console.error(` close failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
2099
|
+
process.exit(1)
|
|
2100
|
+
} finally {
|
|
2101
|
+
bridge.close()
|
|
2102
|
+
}
|
|
2103
|
+
return
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
try {
|
|
2107
|
+
try {
|
|
2108
|
+
const sims = await bridge.listSims()
|
|
2109
|
+
const target = resolveTargetSim(sims, parsed.positional[0] || parsed.simId)
|
|
2110
|
+
const fallbackTarget = sims.find(
|
|
2111
|
+
(sim) => sim.id !== target.id && sim.readyState === 'open',
|
|
2112
|
+
)
|
|
2113
|
+
await bridge.closeSim(target.id)
|
|
2114
|
+
const closed = await waitForSimGone(
|
|
2115
|
+
parsed.wsPort,
|
|
2116
|
+
parsed.commandTimeoutMs,
|
|
2117
|
+
target.id,
|
|
2118
|
+
)
|
|
2119
|
+
if (!closed) {
|
|
2120
|
+
await terminatePlaywrightHostsForSims(sims, [target.id])
|
|
2121
|
+
console.error(` close failed: ${target.id} is still connected`)
|
|
2122
|
+
process.exit(1)
|
|
2123
|
+
}
|
|
2124
|
+
await terminatePlaywrightHostsForSims(sims, [target.id])
|
|
2125
|
+
if (readCurrentSimId() === target.id) {
|
|
2126
|
+
if (fallbackTarget) saveCurrentSimId(fallbackTarget.id)
|
|
2127
|
+
else clearCurrentSimId()
|
|
2128
|
+
}
|
|
2129
|
+
console.log(` closed: ${target.id}`)
|
|
2130
|
+
} catch (err) {
|
|
2131
|
+
console.error(` close failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
2132
|
+
await printBridgeFailureDiagnostics(bridge, {
|
|
2133
|
+
errorsCommand: `rnxsim get errors 5${simHint}`,
|
|
2134
|
+
warningsCommand: `rnxsim get warnings 5${simHint}`,
|
|
2135
|
+
requestsCommand: `rnxsim get requests 5${simHint}`,
|
|
2136
|
+
})
|
|
2137
|
+
process.exit(1)
|
|
2138
|
+
}
|
|
2139
|
+
} finally {
|
|
2140
|
+
bridge.close()
|
|
2141
|
+
}
|
|
2142
|
+
}
|