rnxsim 0.1.366 → 0.1.368
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/README.md +14 -2
- package/cli/bin.ts +18 -0
- package/cli/bridge-flow-runner.ts +14 -151
- package/cli/commands/assert.ts +1 -1
- package/cli/commands/box/checkout-plane.ts +126 -0
- package/cli/commands/box/rnx-command.ts +68 -0
- package/cli/commands/box.ts +200 -0
- package/cli/commands/control.ts +12 -32
- package/cli/commands/do-chain.ts +412 -0
- package/cli/commands/flow.ts +0 -10
- package/cli/commands/inspect/core.ts +302 -725
- package/cli/commands/inspect/describe.ts +3 -76
- package/cli/commands/inspect/find.ts +3 -6
- package/cli/commands/inspect/get-layout.ts +14 -1
- package/cli/commands/inspect/resolve-target.ts +41 -47
- package/cli/commands/inspect/shared.ts +28 -123
- package/cli/commands/inspect/tree.ts +6 -15
- package/cli/commands/inspect/wait-ready.ts +2 -2
- package/cli/commands/inspect/wait-selector.ts +4 -8
- package/cli/commands/inspect.ts +23 -17
- package/cli/commands/install-desktop.ts +44 -7
- package/cli/commands/maestro-generate.ts +0 -2
- package/cli/commands/platform.ts +68 -0
- package/cli/commands/state.ts +5 -2
- package/cli/outbound-endpoints.ts +6 -1
- package/cli/parse-args.ts +13 -2
- package/cli/ws-bridge.ts +30 -2
- package/detox/index.ts +34 -17
- package/dist-cli/bin.js +5 -5
- package/dist-cli/chunks/{agent-SO6IOPRK.js → agent-GXCQMYV4.js} +2 -2
- package/dist-cli/chunks/{agent-wrapper-PMYCB2KO.js → agent-wrapper-Y3PQAL6I.js} +2 -2
- package/dist-cli/chunks/{app-fonts-LULQGFY7.js → app-fonts-MDD3W4VN.js} +2 -2
- package/dist-cli/chunks/{assert-WGNCD4ZU.js → assert-BSRQBRH7.js} +3 -3
- package/dist-cli/chunks/{auth-G7CCHH3M.js → auth-SLPHDPCF.js} +2 -2
- package/dist-cli/chunks/{beta-N4LXL5TO.js → beta-R4IZ5SWO.js} +2 -2
- package/dist-cli/chunks/box-2NQYYY3B.js +1709 -0
- package/dist-cli/chunks/{camera-4JHCYBQS.js → camera-ER66WWAX.js} +2 -2
- package/dist-cli/chunks/chunk-2YK5J2D2.js +24 -0
- package/dist-cli/chunks/{chunk-I6KIM6PN.js → chunk-3MPFC3DS.js} +1 -1
- package/dist-cli/chunks/{chunk-77ZHQJZR.js → chunk-3S2J6IVC.js} +2 -2
- package/dist-cli/chunks/chunk-3XMEIZEV.js +4 -0
- package/dist-cli/chunks/chunk-42TLK75A.js +497 -0
- package/dist-cli/chunks/{chunk-OW5XHAA4.js → chunk-4FLQYRGN.js} +1 -1
- package/dist-cli/chunks/{chunk-EFR5DVNP.js → chunk-4VA2ISWX.js} +1 -1
- package/dist-cli/chunks/chunk-57LITN73.js +7 -0
- package/dist-cli/chunks/{chunk-EQK5MJCU.js → chunk-7B22IPSC.js} +2 -2
- package/dist-cli/chunks/{chunk-EGAGZHO6.js → chunk-7V3S2DY2.js} +1 -1
- package/dist-cli/chunks/{chunk-IZKHOM37.js → chunk-AUPJX4KI.js} +1 -1
- package/dist-cli/chunks/{chunk-2S7BKUP2.js → chunk-BWJOLJOK.js} +2 -2
- package/dist-cli/chunks/{chunk-CN6XYYEA.js → chunk-CKNNO6ZG.js} +27 -27
- package/dist-cli/chunks/{chunk-MGDSJZ37.js → chunk-DJSPNOF6.js} +2 -2
- package/dist-cli/chunks/{chunk-YXXKSL6R.js → chunk-E6XYIIJK.js} +1 -1
- package/dist-cli/chunks/{chunk-ONORMKWX.js → chunk-ECXDCED5.js} +2 -2
- package/dist-cli/chunks/chunk-EHA65UVJ.js +5 -0
- package/dist-cli/chunks/chunk-ESVUVW3R.js +32 -0
- package/dist-cli/chunks/{chunk-XJU2QIRY.js → chunk-FDAFSRQE.js} +2 -2
- package/dist-cli/chunks/{chunk-NM2L2QV4.js → chunk-FSPZY4AI.js} +2 -2
- package/dist-cli/chunks/chunk-FVODUDPS.js +4 -0
- package/dist-cli/chunks/{chunk-H23KWIKA.js → chunk-GFWUIGGY.js} +2 -2
- package/dist-cli/chunks/{chunk-CTG7HBCA.js → chunk-GS5VUDCF.js} +1 -1
- package/dist-cli/chunks/{chunk-ORWP2E7R.js → chunk-GXHCJRKE.js} +2 -2
- package/dist-cli/chunks/{chunk-TTASWLKM.js → chunk-HKQXGCQT.js} +1 -1
- package/dist-cli/chunks/{chunk-5OK3LCEJ.js → chunk-IXRVFQMT.js} +2 -2
- package/dist-cli/chunks/{chunk-TIAEYO6I.js → chunk-JFGW75UL.js} +2 -2
- package/dist-cli/chunks/{chunk-RYIMSKDO.js → chunk-JHYXRORU.js} +2 -2
- package/dist-cli/chunks/chunk-LAKWOJ5Y.js +5 -0
- package/dist-cli/chunks/{chunk-GAJQSITE.js → chunk-LPAOOJIW.js} +1 -1
- package/dist-cli/chunks/chunk-M7COA4BE.js +312 -0
- package/dist-cli/chunks/{chunk-6PL2CCGP.js → chunk-MCO7V4VN.js} +2 -2
- package/dist-cli/chunks/{chunk-YGMEDJKJ.js → chunk-MGOAQHMO.js} +2 -2
- package/dist-cli/chunks/chunk-NERY6HGZ.js +4 -0
- package/dist-cli/chunks/chunk-NGWUGNPV.js +4 -0
- package/dist-cli/chunks/{chunk-ZMJ75EDG.js → chunk-NPSPWMBO.js} +2 -2
- package/dist-cli/chunks/{chunk-2UXU53MG.js → chunk-OXCDE3DD.js} +1 -1
- package/dist-cli/chunks/{chunk-ZLVRYXZ7.js → chunk-OZJMWTGA.js} +1 -1
- package/dist-cli/chunks/{chunk-HT6GFZOZ.js → chunk-PNBQBBG5.js} +1 -1
- package/dist-cli/chunks/{chunk-JHXETDVC.js → chunk-Q65KH25G.js} +1 -1
- package/dist-cli/chunks/{chunk-NQFJOCSF.js → chunk-QKOY7UW5.js} +2 -2
- package/dist-cli/chunks/{chunk-5DAIA77C.js → chunk-QMVBWZH2.js} +2 -2
- package/dist-cli/chunks/chunk-RRALQ4WM.js +38 -0
- package/dist-cli/chunks/{chunk-GXAB52K7.js → chunk-SHMLYTNH.js} +2 -2
- package/dist-cli/chunks/chunk-SSUPPM6K.js +130 -0
- package/dist-cli/chunks/chunk-T2MGLXY5.js +27 -0
- package/dist-cli/chunks/{chunk-ZJIS3CTW.js → chunk-T4ZZ26A2.js} +2 -2
- package/dist-cli/chunks/{chunk-YCCCPFWT.js → chunk-TC6DNQL6.js} +2 -2
- package/dist-cli/chunks/chunk-TE645QCO.js +423 -0
- package/dist-cli/chunks/{chunk-UCHW7ZHR.js → chunk-TOE5O6JE.js} +2 -2
- package/dist-cli/chunks/{chunk-N3UC7HIX.js → chunk-TPIPKXJL.js} +1 -1
- package/dist-cli/chunks/chunk-UBH2BWCY.js +4 -0
- package/dist-cli/chunks/{chunk-AFV32WZT.js → chunk-UXROQPIN.js} +3 -3
- package/dist-cli/chunks/{chunk-SA6XVI55.js → chunk-WF2CRNYF.js} +1 -1
- package/dist-cli/chunks/chunk-WFAFAGMG.js +4 -0
- package/dist-cli/chunks/{chunk-A5FXX5PJ.js → chunk-WJQB6AXZ.js} +1 -1
- package/dist-cli/chunks/{chunk-5NMCF5VF.js → chunk-WVVAYLWZ.js} +1 -1
- package/dist-cli/chunks/chunk-WXRDFUR7.js +4 -0
- package/dist-cli/chunks/{chunk-M3BNCF7E.js → chunk-X5MXQB3L.js} +2 -2
- package/dist-cli/chunks/{chunk-LN3RNW6X.js → chunk-YSHJ3LMQ.js} +2 -2
- package/dist-cli/chunks/{chunk-34IQM22N.js → chunk-YYPTSF6W.js} +15 -15
- package/dist-cli/chunks/{cleanup-KRIATKFH.js → cleanup-WHGLBAJ2.js} +2 -2
- package/dist-cli/chunks/cli-version-DMXIXBIM.js +4 -0
- package/dist-cli/chunks/{compat-6PTQ3QEA.js → compat-MGBKGYGQ.js} +2 -2
- package/dist-cli/chunks/{config-ETGDUANO.js → config-TRDOMP4T.js} +2 -2
- package/dist-cli/chunks/connect-N4FV3DDV.js +4 -0
- package/dist-cli/chunks/control-5GRBZVYK.js +4 -0
- package/dist-cli/chunks/daemon-MBT5E3ZC.js +4 -0
- package/dist-cli/chunks/{debug-WIIB6ZKC.js → debug-AHEK43AJ.js} +3 -3
- package/dist-cli/chunks/{desktop-TD7Q5LNB.js → desktop-LDRIXOVV.js} +3 -3
- package/dist-cli/chunks/{detox-7GNR33VM.js → detox-6HAKYISO.js} +2 -2
- package/dist-cli/chunks/{device-QOR3PGUF.js → device-QM3S7OVU.js} +2 -2
- package/dist-cli/chunks/{diagnose-RRCBLP6E.js → diagnose-WDP3CVIX.js} +2 -2
- package/dist-cli/chunks/{disk-cleanup-MJUGJPXT.js → disk-cleanup-JRT5MKFW.js} +2 -2
- package/dist-cli/chunks/do-chain-7FDMO56C.js +5 -0
- package/dist-cli/chunks/drivers-AJL2EVXH.js +4 -0
- package/dist-cli/chunks/env-YIBOMB24.js +4 -0
- package/dist-cli/chunks/{film-D7Y64LID.js → film-R3EQADMG.js} +3 -3
- package/dist-cli/chunks/flow-BDAA6BWL.js +4 -0
- package/dist-cli/chunks/help-5EJD6LJF.js +4 -0
- package/dist-cli/chunks/{hidden-runtime-alias-JGURVR2Z.js → hidden-runtime-alias-6M4UERRQ.js} +2 -2
- package/dist-cli/chunks/{home-paths-4ICFIGXO.js → home-paths-QJNOHZJO.js} +2 -2
- package/dist-cli/chunks/inspect-S3LR3R2A.js +4 -0
- package/dist-cli/chunks/install-desktop-GXE6K7BJ.js +4 -0
- package/dist-cli/chunks/{login-RU33QR2G.js → login-ISWDW4WU.js} +2 -2
- package/dist-cli/chunks/{logout-2IUG3Y5J.js → logout-VNJX2IEW.js} +2 -2
- package/dist-cli/chunks/{maestro-2MXTGG7I.js → maestro-SQS2OS2D.js} +3 -3
- package/dist-cli/chunks/{maestro-generate-2PYIFGLR.js → maestro-generate-GWVPISG5.js} +5 -5
- package/dist-cli/chunks/{mode-3DAPG3HF.js → mode-3URDGBZB.js} +2 -2
- package/dist-cli/chunks/{optional-demo-registry-BWSDJF2Z.js → optional-demo-registry-L3LRBQHG.js} +2 -2
- package/dist-cli/chunks/{perf-2TKPWB4N.js → perf-XLPC4UKR.js} +2 -2
- package/dist-cli/chunks/{permissions-5CSMHYNA.js → permissions-QRHWVMDP.js} +2 -2
- package/dist-cli/chunks/platform-F47URZNB.js +6 -0
- package/dist-cli/chunks/privacy-BF23T67T.js +4 -0
- package/dist-cli/chunks/{record-I2HJCFP3.js → record-2DN3OUE5.js} +3 -3
- package/dist-cli/chunks/{report-issue-A4FZWBOC.js → report-issue-D3BQPSLU.js} +2 -2
- package/dist-cli/chunks/reset-T5CNZ7D3.js +4 -0
- package/dist-cli/chunks/resolve-target-KAAS274S.js +4 -0
- package/dist-cli/chunks/runtime-AQJDBR3X.js +4 -0
- package/dist-cli/chunks/{screenshot-command-UFW5BFHP.js → screenshot-command-RK7IJHVK.js} +3 -3
- package/dist-cli/chunks/{screenshot-layers-5BIAL4YN.js → screenshot-layers-DP2QKSMN.js} +2 -2
- package/dist-cli/chunks/{screenshots-capture-EHYS3BV2.js → screenshots-capture-MA7P3FUT.js} +2 -2
- package/dist-cli/chunks/{serve-O4FDM3AC.js → serve-UBXNGLNP.js} +3 -3
- package/dist-cli/chunks/setup-GH7OW4GC.js +4 -0
- package/dist-cli/chunks/sim-client-VTKFEDJX.js +4 -0
- package/dist-cli/chunks/{skills-ZK55SY32.js → skills-HPK25R4B.js} +2 -2
- package/dist-cli/chunks/state-273XJPUE.js +14 -0
- package/dist-cli/chunks/{storage-WP33ISEM.js → storage-RTDFWIY7.js} +2 -2
- package/dist-cli/chunks/store-DLKNNM4L.js +4 -0
- package/dist-cli/chunks/telemetry-7CWDXEOO.js +4 -0
- package/dist-cli/chunks/{timeline-DKGW5S4I.js → timeline-IF2PGK7C.js} +2 -2
- package/dist-cli/chunks/{upgrade-LL7G4B7G.js → upgrade-R74NVHKG.js} +2 -2
- package/dist-cli/chunks/upload-3JFFVCZ4.js +4 -0
- package/dist-cli/chunks/{version-YLWR5G4F.js → version-RTFCYQZ7.js} +2 -2
- package/dist-cli/chunks/{web-QELZ4CP6.js → web-35SSKBRU.js} +2 -2
- package/dist-cli/chunks/welcome-N5V352SE.js +5 -0
- package/dist-cli/chunks/{what-happened-MIOMZWM5.js → what-happened-WXTP6EPR.js} +2 -2
- package/dist-cli/chunks/ws-bridge-JXQUAWFA.js +4 -0
- 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-input.cjs +336 -0
- package/dist-lib/bridge-contract-input.mjs +303 -0
- package/dist-lib/bridge-contract.cjs +29 -1
- package/dist-lib/bridge-contract.mjs +19 -0
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +32 -18
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +5 -1
- 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/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +214 -594
- 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-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.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/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +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 +273 -657
- package/dist-lib/sdk.mjs +271 -652
- package/dist-lib/skills.cjs +190 -287
- package/dist-lib/vite.cjs +1 -1
- package/package.json +9 -6
- package/skills/contrast/SKILL.md +3 -0
- package/skills/rnx-debug/SKILL.md +8 -0
- package/skills/rnx-test/SKILL.md +21 -0
- package/src/bridge-contract-input.ts +359 -0
- package/src/bridge-contract.ts +121 -1
- package/src/host/bridge-host.ts +7 -12
- package/src/index.ts +7 -0
- package/src/sim-client.ts +12 -3
- package/dist-cli/chunks/chunk-3X6IBUBX.js +0 -38
- package/dist-cli/chunks/chunk-7B37IZBN.js +0 -15
- package/dist-cli/chunks/chunk-DMPF2D24.js +0 -4
- package/dist-cli/chunks/chunk-DW37EVX5.js +0 -767
- package/dist-cli/chunks/chunk-DWFIH7CB.js +0 -32
- package/dist-cli/chunks/chunk-GUI3PDH2.js +0 -27
- package/dist-cli/chunks/chunk-KLF3K6ZZ.js +0 -4
- package/dist-cli/chunks/chunk-M5QCGLXR.js +0 -944
- package/dist-cli/chunks/chunk-M6SKQGAN.js +0 -5
- package/dist-cli/chunks/chunk-NAYLPOC5.js +0 -24
- package/dist-cli/chunks/chunk-VIND4SP3.js +0 -124
- package/dist-cli/chunks/chunk-XXNLYR64.js +0 -5
- package/dist-cli/chunks/chunk-ZFM24DOX.js +0 -4
- package/dist-cli/chunks/cli-version-SYGNHGFR.js +0 -4
- package/dist-cli/chunks/control-RBTNOLQM.js +0 -4
- package/dist-cli/chunks/daemon-N3SC5DK6.js +0 -4
- package/dist-cli/chunks/drivers-W6E3BZH4.js +0 -4
- package/dist-cli/chunks/flow-7QAE2V5Y.js +0 -4
- package/dist-cli/chunks/help-NLD6KWVG.js +0 -4
- package/dist-cli/chunks/inspect-C347BG52.js +0 -4
- package/dist-cli/chunks/install-desktop-YR6TWZYK.js +0 -4
- package/dist-cli/chunks/privacy-PWWC2BXO.js +0 -4
- package/dist-cli/chunks/reset-Y3PBPFYA.js +0 -4
- package/dist-cli/chunks/runtime-IC6NVVGA.js +0 -4
- package/dist-cli/chunks/setup-Y4QGSJBM.js +0 -4
- package/dist-cli/chunks/state-KFLAZNEY.js +0 -14
- package/dist-cli/chunks/store-VEE22CAY.js +0 -4
- package/dist-cli/chunks/telemetry-PZV4BISJ.js +0 -4
- package/dist-cli/chunks/upload-Y4C3YZDK.js +0 -4
- package/dist-cli/chunks/welcome-ZPKA5PZJ.js +0 -5
- package/dist-lib/mcp.cjs +0 -673
- package/dist-lib/mcp.mjs +0 -641
- package/src/mcp.ts +0 -791
package/README.md
CHANGED
|
@@ -23,7 +23,8 @@ What this package gives you:
|
|
|
23
23
|
editor
|
|
24
24
|
- `rnxsim/detox` — a drop-in Detox driver + jest preset
|
|
25
25
|
- `rnxsim/sdk` — the inspect/interact verbs as a programmatic API
|
|
26
|
-
- `rnxsim/
|
|
26
|
+
- `rnxsim/bridge-contract-input` — parsers turning untrusted JSON into
|
|
27
|
+
bridge-contract arguments
|
|
27
28
|
- `rnxsim/skills` — a registry of Contrast-flavored automation skills
|
|
28
29
|
|
|
29
30
|
Platform status: iOS is the parity baseline. Android is bootstrapped and usable
|
|
@@ -189,7 +190,7 @@ bridge always addresses the **shell worker**, never an undifferentiated sim.
|
|
|
189
190
|
| rnx CLI (bin + dispatcher) | Short-lived terminal entry point. Parses argv, owns the private-by-default one-time choice, lazy-imports one command per run, and routes direct open/runtime/inspection flows. | `cli/bin.ts`, `cli/privacy.ts`, `cli/parse-args.ts`, `cli/help.ts`, `cli/commands/setup.ts`, `cli/commands/control.ts` |
|
|
190
191
|
| WsBridge client | Client side of the CLI→daemon link. Resolves the daemon port from the lockfile, opens a WS to `:7668`, sends JSON commands and awaits `{id,result}`/`{id,error}`. Used by every interactive command. | `cli/ws-bridge.ts`, `src/bridge-constants.ts`, `cli/current-sim.ts` |
|
|
191
192
|
| inspect / SDK command surface | Runtime inspection + interaction verbs (describe, find, do tap, get errors, wait, layout, settle). Re-exported as `rnxsim/sdk` so programmatic callers drive a sim the same way the CLI does. | `cli/commands/inspect/core.ts`, `cli/commands/inspect/actions.ts`, `cli/commands/inspect.ts`, `src/sdk.ts` |
|
|
192
|
-
|
|
|
193
|
+
| bridge-contract input parsers | Turn untrusted JSON into typed bridge-contract arguments, so a transport in front of a sim validates request bodies once instead of writing a second reader. | `src/bridge-contract-input.ts`, `src/bridge-contract.ts` |
|
|
193
194
|
| Maestro / Detox runners | Drop-in test compatibility. `bridge-flow-runner.ts` runs Maestro YAML and recorded flows over the bridge; `detox/` provides a `by`/`element`/`expect`/`device` driver + jest preset so existing Detox suites run unchanged. | `cli/bridge-flow-runner.ts`, `cli/commands/maestro.ts`, `cli/commands/detox.ts`, `detox/index.ts`, `detox/jest-preset.cjs` |
|
|
194
195
|
| sim drivers | Launch or attach the actual sim page. Playwright owns isolated browser profiles; Electron owns the native desktop surface. The current sim is reused unless `--new`, `--profile`, or `--ephemeral` requests a separate one. | `cli/drivers/index.ts`, `cli/drivers/registry.ts`, `cli/drivers/electron.ts`, `cli/drivers/playwright.ts` |
|
|
195
196
|
| SootSimBridgeHost (serve/daemon) | The bridge process (`rnx serve`, or autostarted as the daemon via launchd/systemd). HTTP+WS hub on `:7668`: relays commands between CLI clients and registered sims; also serves runtime HTTP, `/__server-scan`, and fetch/WS proxies. | `src/host/bridge-host.ts`, `cli/commands/serve.ts`, `cli/commands/daemon.ts`, `src/home-paths.ts` |
|
|
@@ -306,6 +307,7 @@ rnx find --testid cta # inspect one node deeply
|
|
|
306
307
|
rnx debug enable animated,layout
|
|
307
308
|
rnx debug trace shell on 240
|
|
308
309
|
rnx do tap-text "..." # interact
|
|
310
|
+
rnx do tap email --then type user@example.com --then tap submit
|
|
309
311
|
rnx debug snapshot before
|
|
310
312
|
rnx debug snapshot after
|
|
311
313
|
rnx debug diff before after
|
|
@@ -323,6 +325,16 @@ connected. With multiple driveable sims, the bridge lists their ids and refuses
|
|
|
323
325
|
to choose. Run `rnx use <id>` to pin later commands, or pass `--sim <id>` to
|
|
324
326
|
target one command.
|
|
325
327
|
|
|
328
|
+
Use an inline `rnx do` chain for a short action sequence that should execute as
|
|
329
|
+
one engine batch. Repeat `--then` before each additional action. Each phrase
|
|
330
|
+
uses the same positional grammar as its standalone `do` command. Touch timing
|
|
331
|
+
stays inside the sim, the batch stops at the first failure, and `--json` prints
|
|
332
|
+
the complete per-step result. Reusable file-based flows stay in Maestro YAML.
|
|
333
|
+
|
|
334
|
+
```sh
|
|
335
|
+
rnx do tap email --then type user@example.com --then tap submit
|
|
336
|
+
```
|
|
337
|
+
|
|
326
338
|
## Bundler plugins (`rnxsim/vite` and `rnxsim/metro`)
|
|
327
339
|
|
|
328
340
|
The Vite plugin serves the installed RNX runtime from your existing dev server.
|
package/cli/bin.ts
CHANGED
|
@@ -318,6 +318,12 @@ if (
|
|
|
318
318
|
exitIfStandaloneBlocked(command)
|
|
319
319
|
|
|
320
320
|
if (TOP_LEVEL_RUNTIME_COMMANDS.has(command)) {
|
|
321
|
+
if (command === 'do') {
|
|
322
|
+
const { hasDoChain, runDoChain } = await import('./commands/do-chain')
|
|
323
|
+
if (hasDoChain(commandArgsWithSim)) {
|
|
324
|
+
await exitWithFlush(await runDoChain(commandArgsWithSim, { port }))
|
|
325
|
+
}
|
|
326
|
+
}
|
|
321
327
|
const { runInspect } = await import('./commands/inspect')
|
|
322
328
|
await runInspect([command, ...commandArgsWithSim], { port, verbose })
|
|
323
329
|
} else {
|
|
@@ -434,6 +440,18 @@ if (TOP_LEVEL_RUNTIME_COMMANDS.has(command)) {
|
|
|
434
440
|
break
|
|
435
441
|
}
|
|
436
442
|
|
|
443
|
+
case 'ios':
|
|
444
|
+
case 'android': {
|
|
445
|
+
const { runPlatformCommand } = await import('./commands/platform')
|
|
446
|
+
const code = await runPlatformCommand(command, commandArgsWithSim, { port })
|
|
447
|
+
await exitWithFlush(typeof code === 'number' ? code : 0)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
case 'box': {
|
|
451
|
+
const { runBox } = await import('./commands/box')
|
|
452
|
+
await exitWithFlush(await runBox(commandArgsWithSim, { port, verbose }))
|
|
453
|
+
}
|
|
454
|
+
|
|
437
455
|
case 'use': {
|
|
438
456
|
const { runUseCommand } = await import('./commands/control')
|
|
439
457
|
await runUseCommand(commandArgsWithSim, { port })
|
|
@@ -7,14 +7,7 @@ import {
|
|
|
7
7
|
framesIdentical,
|
|
8
8
|
} from '../detox/proof-frame.cjs'
|
|
9
9
|
import { composeFramedScreenshot } from '../src/screenshots/frame-compose'
|
|
10
|
-
import {
|
|
11
|
-
READY_NODE_STABLE_MS,
|
|
12
|
-
READY_PROBE_POLL_MS,
|
|
13
|
-
WAIT_READY_PROBE,
|
|
14
|
-
readyProbeHasContent,
|
|
15
|
-
readyProbeHasTargetContent,
|
|
16
|
-
type ReadyProbe,
|
|
17
|
-
} from './commands/inspect/core'
|
|
10
|
+
import { inspectWaitReady, waitReadyReason } from './commands/inspect/core'
|
|
18
11
|
import { waitForSootsimIdle } from './commands/inspect/settling'
|
|
19
12
|
import { callShellCommandWhenReady, getShellState } from './commands/inspect/shared'
|
|
20
13
|
import { FlowLiveStatusReporter } from './flow-live-status'
|
|
@@ -424,7 +417,6 @@ export class SootSimBridgeFlowRunner {
|
|
|
424
417
|
private lastRecordingDurationMs: number | null = null
|
|
425
418
|
private lastRecordingFrameStats: unknown = null
|
|
426
419
|
private flowTraceSteps: SootSimFlowTraceStep[] = []
|
|
427
|
-
private simRouteHint: string | null = null
|
|
428
420
|
// set by runStep when a step throws — lets the outer flow command
|
|
429
421
|
// dump a rich per-step failure bundle (screenshot + describe.json +
|
|
430
422
|
// console + network) keyed by the failing step.
|
|
@@ -432,8 +424,8 @@ export class SootSimBridgeFlowRunner {
|
|
|
432
424
|
|
|
433
425
|
constructor(
|
|
434
426
|
readonly bridge: WsBridge,
|
|
435
|
-
// simId
|
|
436
|
-
// the rest of opts is stable.
|
|
427
|
+
// simId identifies the browser tab for the whole flow, including reloads;
|
|
428
|
+
// the rest of opts is stable too.
|
|
437
429
|
private readonly opts: {
|
|
438
430
|
screenshotDir: string
|
|
439
431
|
flowDir: string
|
|
@@ -473,58 +465,6 @@ export class SootSimBridgeFlowRunner {
|
|
|
473
465
|
return this.opts.simId
|
|
474
466
|
}
|
|
475
467
|
|
|
476
|
-
setSimRouteHint(urlLike: string | null | undefined) {
|
|
477
|
-
this.simRouteHint = this.normalizeSimRoute(urlLike)
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
private normalizeSimRoute(urlLike: string | null | undefined): string | null {
|
|
481
|
-
if (!urlLike) return null
|
|
482
|
-
try {
|
|
483
|
-
const url = new URL(urlLike)
|
|
484
|
-
url.searchParams.delete('inspectOpen')
|
|
485
|
-
const search = url.searchParams.toString()
|
|
486
|
-
return `${url.origin}${url.pathname}${search ? `?${search}` : ''}`
|
|
487
|
-
} catch {
|
|
488
|
-
return urlLike
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// sim ids rotate on `window.location.reload()` when the shell reconnects.
|
|
493
|
-
// after launchApp we re-resolve the primary sim so the rest of the flow
|
|
494
|
-
// doesn't send commands to a dead id.
|
|
495
|
-
private async refreshSimId(): Promise<void> {
|
|
496
|
-
if (!this.opts.simId) return
|
|
497
|
-
try {
|
|
498
|
-
const sims = await this.bridge.listSims()
|
|
499
|
-
const current = sims.find((b) => b.id === this.opts.simId)
|
|
500
|
-
if (current) {
|
|
501
|
-
this.simRouteHint = this.normalizeSimRoute(current.url || current.origin)
|
|
502
|
-
}
|
|
503
|
-
const stillThere = sims.find(
|
|
504
|
-
(b) => b.id === this.opts.simId && b.readyState === 'open',
|
|
505
|
-
)
|
|
506
|
-
if (stillThere) return
|
|
507
|
-
if (!this.simRouteHint) return
|
|
508
|
-
const hinted =
|
|
509
|
-
this.simRouteHint &&
|
|
510
|
-
sims.find(
|
|
511
|
-
(b) =>
|
|
512
|
-
b.readyState === 'open' &&
|
|
513
|
-
this.normalizeSimRoute(b.url || b.origin) === this.simRouteHint,
|
|
514
|
-
)
|
|
515
|
-
if (!hinted) return
|
|
516
|
-
const primary = sims.find((b) => b.isPrimary && b.readyState === 'open')
|
|
517
|
-
const firstOpen = sims.find((b) => b.readyState === 'open')
|
|
518
|
-
const next = hinted ?? primary ?? firstOpen
|
|
519
|
-
if (next) {
|
|
520
|
-
this.opts.simId = next.id
|
|
521
|
-
console.log(` [flow] sim rotated to ${next.id}`)
|
|
522
|
-
}
|
|
523
|
-
} catch {
|
|
524
|
-
// bridge unreachable — let the next command surface the real error
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
|
|
528
468
|
private async evaluate<T = unknown>(code: string, timeoutMs?: number): Promise<T> {
|
|
529
469
|
return this.bridge.send(
|
|
530
470
|
{
|
|
@@ -556,90 +496,19 @@ export class SootSimBridgeFlowRunner {
|
|
|
556
496
|
}
|
|
557
497
|
|
|
558
498
|
async waitForTree(timeout: number = DEFAULT_TIMEOUT) {
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
// `{count, loading}`); a sim that can't even run the probe — bridge
|
|
564
|
-
// disconnect, command timeout — is a stale orphan or a dead tab. count
|
|
565
|
-
// those distinctly so we can fail fast instead of polling the full
|
|
566
|
-
// (up to 120s) timeout with no output, which reads as a dead hang (B1).
|
|
567
|
-
let consecutiveBridgeFailures = 0
|
|
568
|
-
let lastProgressAt = 0
|
|
569
|
-
// readiness mirrors `sootsim wait ready` (shared WAIT_READY_PROBE + the
|
|
570
|
-
// readyProbeHasContent decision). the previous inline probe used
|
|
571
|
-
// findByText('loading'/'capturing'/'opening app'), which substring-matched
|
|
572
|
-
// ordinary forum copy ("...still loading the card...") and reported a
|
|
573
|
-
// fully-painted screen as perpetually loading. WAIT_READY_PROBE only treats
|
|
574
|
-
// an exact, isolated loading label as "loading" and prefers the guest app's
|
|
575
|
-
// own ready signal, so a content-rich screen no longer false-positives.
|
|
576
|
-
let lastNodes = -1
|
|
577
|
-
let nodeStableSince = startedAt
|
|
578
|
-
while (Date.now() < deadline) {
|
|
579
|
-
try {
|
|
580
|
-
const probe = await this.evaluate<ReadyProbe | null>(
|
|
581
|
-
WAIT_READY_PROBE,
|
|
582
|
-
// the readiness probe is cheap — cap it well under the bridge-wide
|
|
583
|
-
// command timeout so a non-responsive sim is detected in seconds,
|
|
584
|
-
// not after a full 15s-per-poll stall.
|
|
585
|
-
8000,
|
|
586
|
-
)
|
|
587
|
-
consecutiveBridgeFailures = 0
|
|
588
|
-
if (probe?.externalError) {
|
|
589
|
-
throw new Error(`app failed to load: ${probe.externalError}`)
|
|
590
|
-
}
|
|
591
|
-
if (probe) {
|
|
592
|
-
const now = Date.now()
|
|
593
|
-
if (probe.nodes !== lastNodes) {
|
|
594
|
-
lastNodes = probe.nodes
|
|
595
|
-
nodeStableSince = now
|
|
596
|
-
}
|
|
597
|
-
const hasReadySignal = probe.flag === true && readyProbeHasContent(probe)
|
|
598
|
-
const hasRenderedContentWithoutReadySignal =
|
|
599
|
-
probe.flag !== true && readyProbeHasTargetContent(probe)
|
|
600
|
-
const ready =
|
|
601
|
-
(hasReadySignal || hasRenderedContentWithoutReadySignal) &&
|
|
602
|
-
probe.externalReady !== false &&
|
|
603
|
-
!probe.externalError &&
|
|
604
|
-
!probe.loadingText &&
|
|
605
|
-
now - nodeStableSince >= READY_NODE_STABLE_MS
|
|
606
|
-
if (ready) return
|
|
607
|
-
}
|
|
608
|
-
} catch (err) {
|
|
609
|
-
lastError = err instanceof Error ? err : new Error(String(err))
|
|
610
|
-
// a real load error reported by the probe body — not a bridge
|
|
611
|
-
// failure. surface it immediately rather than retrying.
|
|
612
|
-
if (lastError.message.startsWith('app failed to load:')) {
|
|
613
|
-
throw lastError
|
|
614
|
-
}
|
|
615
|
-
consecutiveBridgeFailures++
|
|
616
|
-
if (consecutiveBridgeFailures >= 4) {
|
|
617
|
-
throw new Error(
|
|
618
|
-
`target sim is not responding to the bridge (${lastError.message}). ` +
|
|
619
|
-
`it is likely a stale orphan — run \`rnx list\`, then close it ` +
|
|
620
|
-
`with \`rnx close <id>\` or start a fresh one with \`rnx open --new <port>\`.`,
|
|
621
|
-
)
|
|
622
|
-
}
|
|
623
|
-
await this.refreshSimId()
|
|
624
|
-
}
|
|
625
|
-
// surface progress every ~5s so a slow-loading app or a sim mid-boot
|
|
626
|
-
// does not look like a dead hang while waitForTree polls (B1).
|
|
627
|
-
const elapsed = Date.now() - startedAt
|
|
628
|
-
if (elapsed - lastProgressAt >= 5000) {
|
|
629
|
-
lastProgressAt = elapsed
|
|
630
|
-
console.log(
|
|
631
|
-
` [flow] waiting for app to be ready… (${Math.round(elapsed / 1000)}s)`,
|
|
632
|
-
)
|
|
633
|
-
}
|
|
634
|
-
// same cadence as inspectWaitReady, which runs this identical readiness
|
|
635
|
-
// decision for `sootsim wait ready`. polling slower here only delayed
|
|
636
|
-
// every launchApp by up to a poll interval.
|
|
637
|
-
await sleep(READY_PROBE_POLL_MS)
|
|
499
|
+
const status = await inspectWaitReady(this.bridge, scaledTimeout(timeout))
|
|
500
|
+
if (status.ready) return
|
|
501
|
+
if (status.externalError) {
|
|
502
|
+
throw new Error(`app failed to load: ${status.externalError}`)
|
|
638
503
|
}
|
|
639
|
-
if (
|
|
640
|
-
throw
|
|
504
|
+
if (status.bridgeError) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`target sim is not responding to the bridge (${status.bridgeError}). ` +
|
|
507
|
+
`it is likely a stale orphan; run \`rnx list\`, then close it ` +
|
|
508
|
+
`with \`rnx close <id>\` or start a fresh one with \`rnx open --new <port>\`.`,
|
|
509
|
+
)
|
|
641
510
|
}
|
|
642
|
-
throw new Error(`app not ready after ${timeout}ms`)
|
|
511
|
+
throw new Error(`app not ready after ${timeout}ms: ${waitReadyReason(status)}`)
|
|
643
512
|
}
|
|
644
513
|
|
|
645
514
|
private async ensureRecordingStarted() {
|
|
@@ -1966,10 +1835,6 @@ export class SootSimBridgeFlowRunner {
|
|
|
1966
1835
|
private async reloadGuestApp() {
|
|
1967
1836
|
// hard page reload resets the tenant worker.
|
|
1968
1837
|
await this.evaluate('window.location.reload()').catch(() => {})
|
|
1969
|
-
// the sim id rotates on a page reload; pick up the new primary before any
|
|
1970
|
-
// further command, or waitForTree loops against a dead id.
|
|
1971
|
-
await sleep(500)
|
|
1972
|
-
await this.refreshSimId()
|
|
1973
1838
|
}
|
|
1974
1839
|
|
|
1975
1840
|
private async resetGuestAppData(
|
|
@@ -2014,7 +1879,6 @@ export class SootSimBridgeFlowRunner {
|
|
|
2014
1879
|
const deadline = Date.now() + 10000
|
|
2015
1880
|
let lastError: unknown = null
|
|
2016
1881
|
while (Date.now() < deadline) {
|
|
2017
|
-
await this.refreshSimId()
|
|
2018
1882
|
try {
|
|
2019
1883
|
if (this.recordingEnabled) await this.ensureRecordingStarted()
|
|
2020
1884
|
if (this.profilingEnabled) await this.startProfile()
|
|
@@ -2032,7 +1896,6 @@ export class SootSimBridgeFlowRunner {
|
|
|
2032
1896
|
}
|
|
2033
1897
|
|
|
2034
1898
|
async launchApp(opts: any) {
|
|
2035
|
-
await this.refreshSimId()
|
|
2036
1899
|
let hasLaunchArguments = false
|
|
2037
1900
|
let launchArguments: SootSimLaunchArguments | undefined
|
|
2038
1901
|
if (isObjectRecord(opts) && Object.prototype.hasOwnProperty.call(opts, 'arguments')) {
|
package/cli/commands/assert.ts
CHANGED
|
@@ -536,6 +536,6 @@ examples:
|
|
|
536
536
|
rnx assert describe --contains "swap-form-header"
|
|
537
537
|
rnx assert get errors --count 0
|
|
538
538
|
rnx assert find --testid submit --exists --within 3000
|
|
539
|
-
rnx assert describe --jq '.
|
|
539
|
+
rnx assert describe --jq '.tree | contains("swap-form-header")'
|
|
540
540
|
`)
|
|
541
541
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// ProjectFilePlane over a real local checkout — the third provider behind the
|
|
2
|
+
// seam Contrast's browser host and its server host already implement. writes
|
|
3
|
+
// land on the user's actual disk, so this plane refuses anything it cannot
|
|
4
|
+
// represent faithfully rather than round-tripping it through a string.
|
|
5
|
+
//
|
|
6
|
+
// the file set comes from `git ls-files -co --exclude-standard`, which is the
|
|
7
|
+
// project's own definition of what belongs to it. a hardcoded skip list was
|
|
8
|
+
// measured against this repo and is not viable: it walked 230,229 files
|
|
9
|
+
// (16.5 GB) in 4.9 s, where git returns the same project as 14,984 paths in
|
|
10
|
+
// 67 ms. `index()` is a synchronous whole-project map, so the size of that set
|
|
11
|
+
// is the whole cost model.
|
|
12
|
+
|
|
13
|
+
import { execFile } from 'node:child_process'
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { promisify } from 'node:util'
|
|
17
|
+
import type { ProjectFileMeta, ProjectFilePlane } from '@contrast/harness'
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFile)
|
|
20
|
+
|
|
21
|
+
export class CheckoutRequiresGitError extends Error {}
|
|
22
|
+
|
|
23
|
+
// a NUL byte is the usual "this is not text" test. the plane serves strings,
|
|
24
|
+
// so a binary file cannot survive a read/write round trip. reading one gives
|
|
25
|
+
// the same garbage real `cat` gives, which is fine; WRITING one back would
|
|
26
|
+
// corrupt a file on the user's actual disk, so binary files are published as
|
|
27
|
+
// read-only and the write path refuses them by name. detecting this at read
|
|
28
|
+
// time is not an option: just-bash's `cat` discards every readFile error and
|
|
29
|
+
// reports "No such file or directory" whatever the error says, so the refusal
|
|
30
|
+
// has to live in the index where the filesystem layer can act on it.
|
|
31
|
+
const BINARY_SNIFF_BYTES = 8192
|
|
32
|
+
|
|
33
|
+
function looksBinary(filePath: string, size: number): boolean {
|
|
34
|
+
if (size === 0) return false
|
|
35
|
+
let fd: number | undefined
|
|
36
|
+
try {
|
|
37
|
+
fd = fs.openSync(filePath, 'r')
|
|
38
|
+
const buffer = Buffer.alloc(Math.min(BINARY_SNIFF_BYTES, size))
|
|
39
|
+
const read = fs.readSync(fd, buffer, 0, buffer.length, 0)
|
|
40
|
+
return buffer.subarray(0, read).includes(0)
|
|
41
|
+
} catch {
|
|
42
|
+
return false
|
|
43
|
+
} finally {
|
|
44
|
+
if (fd !== undefined) fs.closeSync(fd)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class CheckoutFilePlane implements ProjectFilePlane {
|
|
49
|
+
readonly root: string
|
|
50
|
+
private entries = new Map<string, ProjectFileMeta>()
|
|
51
|
+
|
|
52
|
+
constructor(root: string) {
|
|
53
|
+
this.root = path.resolve(root)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
index(): ReadonlyMap<string, ProjectFileMeta> {
|
|
57
|
+
return this.entries
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async refresh(): Promise<void> {
|
|
61
|
+
let stdout: string
|
|
62
|
+
try {
|
|
63
|
+
// -z so paths containing spaces or newlines survive; -co --exclude-standard
|
|
64
|
+
// is tracked plus untracked-not-ignored, which is what a person means by
|
|
65
|
+
// "the files in my project".
|
|
66
|
+
const result = await execFileAsync(
|
|
67
|
+
'git',
|
|
68
|
+
['ls-files', '-co', '--exclude-standard', '-z'],
|
|
69
|
+
{ cwd: this.root, maxBuffer: 64 * 1024 * 1024 },
|
|
70
|
+
)
|
|
71
|
+
stdout = result.stdout
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
74
|
+
throw new CheckoutRequiresGitError(
|
|
75
|
+
`could not list the project's files with git in ${this.root}: ${message}`,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const next = new Map<string, ProjectFileMeta>()
|
|
80
|
+
for (const relative of stdout.split('\0')) {
|
|
81
|
+
if (!relative) continue
|
|
82
|
+
let stat: fs.Stats
|
|
83
|
+
try {
|
|
84
|
+
stat = fs.statSync(path.join(this.root, relative))
|
|
85
|
+
} catch {
|
|
86
|
+
// listed but gone (a deleted-but-tracked file); it is simply not there
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (!stat.isFile()) continue
|
|
90
|
+
next.set(relative, {
|
|
91
|
+
size: stat.size,
|
|
92
|
+
readOnly: looksBinary(path.join(this.root, relative), stat.size),
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
this.entries = next
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private absolute(planePath: string): string {
|
|
99
|
+
const resolved = path.resolve(this.root, planePath)
|
|
100
|
+
// the shell resolves "." and ".." before calling us, but the plane owns the
|
|
101
|
+
// checkout boundary and must not depend on that.
|
|
102
|
+
if (resolved !== this.root && !resolved.startsWith(`${this.root}${path.sep}`)) {
|
|
103
|
+
throw new Error(`path escapes the project: ${planePath}`)
|
|
104
|
+
}
|
|
105
|
+
return resolved
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async read(planePath: string): Promise<string> {
|
|
109
|
+
return fs.promises.readFile(this.absolute(planePath), 'utf8')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async write(planePath: string, content: string): Promise<void> {
|
|
113
|
+
const absolute = this.absolute(planePath)
|
|
114
|
+
await fs.promises.mkdir(path.dirname(absolute), { recursive: true })
|
|
115
|
+
await fs.promises.writeFile(absolute, content, 'utf8')
|
|
116
|
+
this.entries.set(planePath, {
|
|
117
|
+
size: Buffer.byteLength(content, 'utf8'),
|
|
118
|
+
readOnly: false,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async delete(planePath: string): Promise<void> {
|
|
123
|
+
await fs.promises.rm(this.absolute(planePath), { force: true })
|
|
124
|
+
this.entries.delete(planePath)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// step 4: RNX operations registered as just-bash commands.
|
|
2
|
+
//
|
|
3
|
+
// `rnx` inside a box shell is the same CLI, re-invoked as a child process with
|
|
4
|
+
// the shell's own cwd. that is deliberate — spelling the verbs a second time
|
|
5
|
+
// in shell-command form would be a second implementation of every command, and
|
|
6
|
+
// the spec is explicit that the CLI and the in-shell commands are clients of
|
|
7
|
+
// one implementation, not two.
|
|
8
|
+
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { defineCommand, type CustomCommand } from '@contrast/harness'
|
|
12
|
+
import { PROJECT_MOUNT } from '@contrast/harness'
|
|
13
|
+
import { rnxPublicBrand } from '../../../src/public-brand'
|
|
14
|
+
import { RNX_PLACEMENT_ENV } from '../../ws-bridge'
|
|
15
|
+
|
|
16
|
+
// the standalone binary is its own entry; the npm package runs through node
|
|
17
|
+
// with the CLI as argv[1]. this is the same shape bin.ts uses to respawn
|
|
18
|
+
// itself for automatic cleanup.
|
|
19
|
+
function selfInvocation(): { command: string; prefix: string[] } {
|
|
20
|
+
const entry = process.argv[1]
|
|
21
|
+
if (!entry) return { command: process.execPath, prefix: [] }
|
|
22
|
+
return { command: process.execPath, prefix: [entry] }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// map a shell path under /project back onto the real checkout, so a child
|
|
26
|
+
// process started from `cd src && rnx describe` sees the directory the user
|
|
27
|
+
// is actually standing in.
|
|
28
|
+
function hostCwd(root: string, shellCwd: string): string {
|
|
29
|
+
if (shellCwd === PROJECT_MOUNT) return root
|
|
30
|
+
if (!shellCwd.startsWith(`${PROJECT_MOUNT}/`)) return root
|
|
31
|
+
return path.join(root, shellCwd.slice(PROJECT_MOUNT.length + 1))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createRnxCommand(options: {
|
|
35
|
+
root: string
|
|
36
|
+
shellCwd: () => string
|
|
37
|
+
}): CustomCommand {
|
|
38
|
+
return defineCommand(rnxPublicBrand.commandName, async (args) => {
|
|
39
|
+
const { command, prefix } = selfInvocation()
|
|
40
|
+
const child = spawn(command, [...prefix, ...args], {
|
|
41
|
+
cwd: hostCwd(options.root, options.shellCwd()),
|
|
42
|
+
// mark the child as running inside a local box. this is the only thing
|
|
43
|
+
// that sets a placement — nothing infers one from cwd — and it is what
|
|
44
|
+
// lets `rnx box` refuse to nest inside itself.
|
|
45
|
+
env: { ...process.env, [RNX_PLACEMENT_ENV]: 'local-box' },
|
|
46
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
let stdout = ''
|
|
50
|
+
let stderr = ''
|
|
51
|
+
child.stdout.on('data', (chunk) => {
|
|
52
|
+
stdout += String(chunk)
|
|
53
|
+
})
|
|
54
|
+
child.stderr.on('data', (chunk) => {
|
|
55
|
+
stderr += String(chunk)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
59
|
+
child.once('error', (error) => {
|
|
60
|
+
stderr += `${rnxPublicBrand.commandName}: ${error.message}\n`
|
|
61
|
+
resolve(1)
|
|
62
|
+
})
|
|
63
|
+
child.once('close', (code) => resolve(code ?? 0))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
return { stdout, stderr, exitCode }
|
|
67
|
+
})
|
|
68
|
+
}
|