execbro 0.0.1-reserve → 1.10.13
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 +32 -0
- package/README.md +496 -0
- package/build/__tests__/helpers/fake-cdp-server.d.ts +56 -0
- package/build/__tests__/helpers/fake-cdp-server.d.ts.map +1 -0
- package/build/__tests__/helpers/fake-cdp-server.js +120 -0
- package/build/__tests__/helpers/fake-cdp-server.js.map +1 -0
- package/build/__tests__/helpers/tap-test-helpers.d.ts +36 -0
- package/build/__tests__/helpers/tap-test-helpers.d.ts.map +1 -0
- package/build/__tests__/helpers/tap-test-helpers.js +130 -0
- package/build/__tests__/helpers/tap-test-helpers.js.map +1 -0
- package/build/__tests__/integration/auto-reconnect.test.d.ts +2 -0
- package/build/__tests__/integration/auto-reconnect.test.d.ts.map +1 -0
- package/build/__tests__/integration/auto-reconnect.test.js +118 -0
- package/build/__tests__/integration/auto-reconnect.test.js.map +1 -0
- package/build/__tests__/integration/connection-health.test.d.ts +2 -0
- package/build/__tests__/integration/connection-health.test.d.ts.map +1 -0
- package/build/__tests__/integration/connection-health.test.js +157 -0
- package/build/__tests__/integration/connection-health.test.js.map +1 -0
- package/build/__tests__/integration/execute-in-app.test.d.ts +2 -0
- package/build/__tests__/integration/execute-in-app.test.d.ts.map +1 -0
- package/build/__tests__/integration/execute-in-app.test.js +124 -0
- package/build/__tests__/integration/execute-in-app.test.js.map +1 -0
- package/build/__tests__/integration/per-call-timeout.test.d.ts +2 -0
- package/build/__tests__/integration/per-call-timeout.test.d.ts.map +1 -0
- package/build/__tests__/integration/per-call-timeout.test.js +103 -0
- package/build/__tests__/integration/per-call-timeout.test.js.map +1 -0
- package/build/__tests__/integration/tap-strategies.test.d.ts +2 -0
- package/build/__tests__/integration/tap-strategies.test.d.ts.map +1 -0
- package/build/__tests__/integration/tap-strategies.test.js +330 -0
- package/build/__tests__/integration/tap-strategies.test.js.map +1 -0
- package/build/__tests__/integration/tools.test.d.ts +2 -0
- package/build/__tests__/integration/tools.test.d.ts.map +1 -0
- package/build/__tests__/integration/tools.test.js +227 -0
- package/build/__tests__/integration/tools.test.js.map +1 -0
- package/build/__tests__/setup.d.ts +2 -0
- package/build/__tests__/setup.d.ts.map +1 -0
- package/build/__tests__/setup.js +15 -0
- package/build/__tests__/setup.js.map +1 -0
- package/build/__tests__/unit/androidEmulatorAvds.test.d.ts +2 -0
- package/build/__tests__/unit/androidEmulatorAvds.test.d.ts.map +1 -0
- package/build/__tests__/unit/androidEmulatorAvds.test.js +98 -0
- package/build/__tests__/unit/androidEmulatorAvds.test.js.map +1 -0
- package/build/__tests__/unit/buildToken.test.d.ts +2 -0
- package/build/__tests__/unit/buildToken.test.d.ts.map +1 -0
- package/build/__tests__/unit/buildToken.test.js +22 -0
- package/build/__tests__/unit/buildToken.test.js.map +1 -0
- package/build/__tests__/unit/bundle.test.d.ts +2 -0
- package/build/__tests__/unit/bundle.test.d.ts.map +1 -0
- package/build/__tests__/unit/bundle.test.js +53 -0
- package/build/__tests__/unit/bundle.test.js.map +1 -0
- package/build/__tests__/unit/burst.test.d.ts +2 -0
- package/build/__tests__/unit/burst.test.d.ts.map +1 -0
- package/build/__tests__/unit/burst.test.js +54 -0
- package/build/__tests__/unit/burst.test.js.map +1 -0
- package/build/__tests__/unit/connection-health.test.d.ts +2 -0
- package/build/__tests__/unit/connection-health.test.d.ts.map +1 -0
- package/build/__tests__/unit/connection-health.test.js +38 -0
- package/build/__tests__/unit/connection-health.test.js.map +1 -0
- package/build/__tests__/unit/debugGlobals.test.d.ts +2 -0
- package/build/__tests__/unit/debugGlobals.test.d.ts.map +1 -0
- package/build/__tests__/unit/debugGlobals.test.js +14 -0
- package/build/__tests__/unit/debugGlobals.test.js.map +1 -0
- package/build/__tests__/unit/deviceDiscovery.test.d.ts +2 -0
- package/build/__tests__/unit/deviceDiscovery.test.d.ts.map +1 -0
- package/build/__tests__/unit/deviceDiscovery.test.js +119 -0
- package/build/__tests__/unit/deviceDiscovery.test.js.map +1 -0
- package/build/__tests__/unit/deviceResolver.test.d.ts +2 -0
- package/build/__tests__/unit/deviceResolver.test.d.ts.map +1 -0
- package/build/__tests__/unit/deviceResolver.test.js +261 -0
- package/build/__tests__/unit/deviceResolver.test.js.map +1 -0
- package/build/__tests__/unit/escapeNonAscii.test.d.ts +2 -0
- package/build/__tests__/unit/escapeNonAscii.test.d.ts.map +1 -0
- package/build/__tests__/unit/escapeNonAscii.test.js +76 -0
- package/build/__tests__/unit/escapeNonAscii.test.js.map +1 -0
- package/build/__tests__/unit/exec.test.d.ts +2 -0
- package/build/__tests__/unit/exec.test.d.ts.map +1 -0
- package/build/__tests__/unit/exec.test.js +54 -0
- package/build/__tests__/unit/exec.test.js.map +1 -0
- package/build/__tests__/unit/executor.test.d.ts +2 -0
- package/build/__tests__/unit/executor.test.d.ts.map +1 -0
- package/build/__tests__/unit/executor.test.js +220 -0
- package/build/__tests__/unit/executor.test.js.map +1 -0
- package/build/__tests__/unit/failureArtifact.test.d.ts +2 -0
- package/build/__tests__/unit/failureArtifact.test.d.ts.map +1 -0
- package/build/__tests__/unit/failureArtifact.test.js +207 -0
- package/build/__tests__/unit/failureArtifact.test.js.map +1 -0
- package/build/__tests__/unit/fastRefreshRecorder.test.d.ts +2 -0
- package/build/__tests__/unit/fastRefreshRecorder.test.d.ts.map +1 -0
- package/build/__tests__/unit/fastRefreshRecorder.test.js +68 -0
- package/build/__tests__/unit/fastRefreshRecorder.test.js.map +1 -0
- package/build/__tests__/unit/fastRefreshTools.test.d.ts +2 -0
- package/build/__tests__/unit/fastRefreshTools.test.d.ts.map +1 -0
- package/build/__tests__/unit/fastRefreshTools.test.js +112 -0
- package/build/__tests__/unit/fastRefreshTools.test.js.map +1 -0
- package/build/__tests__/unit/fiberMatch.test.d.ts +2 -0
- package/build/__tests__/unit/fiberMatch.test.d.ts.map +1 -0
- package/build/__tests__/unit/fiberMatch.test.js +114 -0
- package/build/__tests__/unit/fiberMatch.test.js.map +1 -0
- package/build/__tests__/unit/fingerprint.test.d.ts +2 -0
- package/build/__tests__/unit/fingerprint.test.d.ts.map +1 -0
- package/build/__tests__/unit/fingerprint.test.js +99 -0
- package/build/__tests__/unit/fingerprint.test.js.map +1 -0
- package/build/__tests__/unit/focusedInput.test.d.ts +2 -0
- package/build/__tests__/unit/focusedInput.test.d.ts.map +1 -0
- package/build/__tests__/unit/focusedInput.test.js +58 -0
- package/build/__tests__/unit/focusedInput.test.js.map +1 -0
- package/build/__tests__/unit/focusedInputTools.test.d.ts +2 -0
- package/build/__tests__/unit/focusedInputTools.test.d.ts.map +1 -0
- package/build/__tests__/unit/focusedInputTools.test.js +79 -0
- package/build/__tests__/unit/focusedInputTools.test.js.map +1 -0
- package/build/__tests__/unit/iconSemantics.test.d.ts +2 -0
- package/build/__tests__/unit/iconSemantics.test.d.ts.map +1 -0
- package/build/__tests__/unit/iconSemantics.test.js +80 -0
- package/build/__tests__/unit/iconSemantics.test.js.map +1 -0
- package/build/__tests__/unit/imageBuffer.test.d.ts +2 -0
- package/build/__tests__/unit/imageBuffer.test.d.ts.map +1 -0
- package/build/__tests__/unit/imageBuffer.test.js +128 -0
- package/build/__tests__/unit/imageBuffer.test.js.map +1 -0
- package/build/__tests__/unit/logs.test.d.ts +2 -0
- package/build/__tests__/unit/logs.test.d.ts.map +1 -0
- package/build/__tests__/unit/logs.test.js +90 -0
- package/build/__tests__/unit/logs.test.js.map +1 -0
- package/build/__tests__/unit/measureComponent.test.d.ts +2 -0
- package/build/__tests__/unit/measureComponent.test.d.ts.map +1 -0
- package/build/__tests__/unit/measureComponent.test.js +66 -0
- package/build/__tests__/unit/measureComponent.test.js.map +1 -0
- package/build/__tests__/unit/measureComponentTools.test.d.ts +2 -0
- package/build/__tests__/unit/measureComponentTools.test.d.ts.map +1 -0
- package/build/__tests__/unit/measureComponentTools.test.js +94 -0
- package/build/__tests__/unit/measureComponentTools.test.js.map +1 -0
- package/build/__tests__/unit/metro.test.d.ts +2 -0
- package/build/__tests__/unit/metro.test.d.ts.map +1 -0
- package/build/__tests__/unit/metro.test.js +61 -0
- package/build/__tests__/unit/metro.test.js.map +1 -0
- package/build/__tests__/unit/network.test.d.ts +2 -0
- package/build/__tests__/unit/network.test.d.ts.map +1 -0
- package/build/__tests__/unit/network.test.js +102 -0
- package/build/__tests__/unit/network.test.js.map +1 -0
- package/build/__tests__/unit/networkInterceptor.test.d.ts +2 -0
- package/build/__tests__/unit/networkInterceptor.test.d.ts.map +1 -0
- package/build/__tests__/unit/networkInterceptor.test.js +104 -0
- package/build/__tests__/unit/networkInterceptor.test.js.map +1 -0
- package/build/__tests__/unit/probe.test.d.ts +2 -0
- package/build/__tests__/unit/probe.test.d.ts.map +1 -0
- package/build/__tests__/unit/probe.test.js +62 -0
- package/build/__tests__/unit/probe.test.js.map +1 -0
- package/build/__tests__/unit/rnGlobalsBootstrap.test.d.ts +2 -0
- package/build/__tests__/unit/rnGlobalsBootstrap.test.d.ts.map +1 -0
- package/build/__tests__/unit/rnGlobalsBootstrap.test.js +29 -0
- package/build/__tests__/unit/rnGlobalsBootstrap.test.js.map +1 -0
- package/build/__tests__/unit/screenState.test.d.ts +2 -0
- package/build/__tests__/unit/screenState.test.d.ts.map +1 -0
- package/build/__tests__/unit/screenState.test.js +384 -0
- package/build/__tests__/unit/screenState.test.js.map +1 -0
- package/build/__tests__/unit/screenshot-diff.test.d.ts +2 -0
- package/build/__tests__/unit/screenshot-diff.test.d.ts.map +1 -0
- package/build/__tests__/unit/screenshot-diff.test.js +97 -0
- package/build/__tests__/unit/screenshot-diff.test.js.map +1 -0
- package/build/__tests__/unit/tap-evidence.test.d.ts +2 -0
- package/build/__tests__/unit/tap-evidence.test.d.ts.map +1 -0
- package/build/__tests__/unit/tap-evidence.test.js +89 -0
- package/build/__tests__/unit/tap-evidence.test.js.map +1 -0
- package/build/__tests__/unit/tap.test.d.ts +2 -0
- package/build/__tests__/unit/tap.test.d.ts.map +1 -0
- package/build/__tests__/unit/tap.test.js +767 -0
- package/build/__tests__/unit/tap.test.js.map +1 -0
- package/build/__tests__/unit/timeoutClamp.test.d.ts +2 -0
- package/build/__tests__/unit/timeoutClamp.test.d.ts.map +1 -0
- package/build/__tests__/unit/timeoutClamp.test.js +17 -0
- package/build/__tests__/unit/timeoutClamp.test.js.map +1 -0
- package/build/__tests__/unit/toolDescriptions.test.d.ts +2 -0
- package/build/__tests__/unit/toolDescriptions.test.d.ts.map +1 -0
- package/build/__tests__/unit/toolDescriptions.test.js +145 -0
- package/build/__tests__/unit/toolDescriptions.test.js.map +1 -0
- package/build/__tests__/unit/transportClassify.test.d.ts +2 -0
- package/build/__tests__/unit/transportClassify.test.d.ts.map +1 -0
- package/build/__tests__/unit/transportClassify.test.js +60 -0
- package/build/__tests__/unit/transportClassify.test.js.map +1 -0
- package/build/__tests__/unit/verifyAction.test.d.ts +2 -0
- package/build/__tests__/unit/verifyAction.test.d.ts.map +1 -0
- package/build/__tests__/unit/verifyAction.test.js +26 -0
- package/build/__tests__/unit/verifyAction.test.js.map +1 -0
- package/build/core/android.d.ts +299 -0
- package/build/core/android.d.ts.map +1 -0
- package/build/core/android.js +1582 -0
- package/build/core/android.js.map +1 -0
- package/build/core/appDetection.d.ts +20 -0
- package/build/core/appDetection.d.ts.map +1 -0
- package/build/core/appDetection.js +167 -0
- package/build/core/appDetection.js.map +1 -0
- package/build/core/bundle.d.ts +49 -0
- package/build/core/bundle.d.ts.map +1 -0
- package/build/core/bundle.js +356 -0
- package/build/core/bundle.js.map +1 -0
- package/build/core/componentSearch.d.ts +41 -0
- package/build/core/componentSearch.d.ts.map +1 -0
- package/build/core/componentSearch.js +588 -0
- package/build/core/componentSearch.js.map +1 -0
- package/build/core/componentTree.d.ts +13 -0
- package/build/core/componentTree.d.ts.map +1 -0
- package/build/core/componentTree.js +275 -0
- package/build/core/componentTree.js.map +1 -0
- package/build/core/config.d.ts +9 -0
- package/build/core/config.d.ts.map +1 -0
- package/build/core/config.js +28 -0
- package/build/core/config.js.map +1 -0
- package/build/core/connection.d.ts +102 -0
- package/build/core/connection.d.ts.map +1 -0
- package/build/core/connection.js +1710 -0
- package/build/core/connection.js.map +1 -0
- package/build/core/connectionState.d.ts +122 -0
- package/build/core/connectionState.d.ts.map +1 -0
- package/build/core/connectionState.js +313 -0
- package/build/core/connectionState.js.map +1 -0
- package/build/core/debugGlobals.d.ts +6 -0
- package/build/core/debugGlobals.d.ts.map +1 -0
- package/build/core/debugGlobals.js +370 -0
- package/build/core/debugGlobals.js.map +1 -0
- package/build/core/deviceDiscovery.d.ts +52 -0
- package/build/core/deviceDiscovery.d.ts.map +1 -0
- package/build/core/deviceDiscovery.js +74 -0
- package/build/core/deviceDiscovery.js.map +1 -0
- package/build/core/deviceResolver.d.ts +44 -0
- package/build/core/deviceResolver.d.ts.map +1 -0
- package/build/core/deviceResolver.js +225 -0
- package/build/core/deviceResolver.js.map +1 -0
- package/build/core/errorScreenParser.d.ts +30 -0
- package/build/core/errorScreenParser.d.ts.map +1 -0
- package/build/core/errorScreenParser.js +198 -0
- package/build/core/errorScreenParser.js.map +1 -0
- package/build/core/errors.d.ts +14 -0
- package/build/core/errors.d.ts.map +1 -0
- package/build/core/errors.js +17 -0
- package/build/core/errors.js.map +1 -0
- package/build/core/escapeNonAscii.d.ts +18 -0
- package/build/core/escapeNonAscii.d.ts.map +1 -0
- package/build/core/escapeNonAscii.js +352 -0
- package/build/core/escapeNonAscii.js.map +1 -0
- package/build/core/exec.d.ts +22 -0
- package/build/core/exec.d.ts.map +1 -0
- package/build/core/exec.js +54 -0
- package/build/core/exec.js.map +1 -0
- package/build/core/executor.d.ts +9 -0
- package/build/core/executor.d.ts.map +1 -0
- package/build/core/executor.js +12 -0
- package/build/core/executor.js.map +1 -0
- package/build/core/failureArtifact.d.ts +120 -0
- package/build/core/failureArtifact.d.ts.map +1 -0
- package/build/core/failureArtifact.js +113 -0
- package/build/core/failureArtifact.js.map +1 -0
- package/build/core/fastRefreshRecorder.d.ts +23 -0
- package/build/core/fastRefreshRecorder.d.ts.map +1 -0
- package/build/core/fastRefreshRecorder.js +83 -0
- package/build/core/fastRefreshRecorder.js.map +1 -0
- package/build/core/fastRefreshTools.d.ts +19 -0
- package/build/core/fastRefreshTools.d.ts.map +1 -0
- package/build/core/fastRefreshTools.js +71 -0
- package/build/core/fastRefreshTools.js.map +1 -0
- package/build/core/feedback.d.ts +18 -0
- package/build/core/feedback.d.ts.map +1 -0
- package/build/core/feedback.js +73 -0
- package/build/core/feedback.js.map +1 -0
- package/build/core/fiberMatch.d.ts +25 -0
- package/build/core/fiberMatch.d.ts.map +1 -0
- package/build/core/fiberMatch.js +35 -0
- package/build/core/fiberMatch.js.map +1 -0
- package/build/core/fingerprint.d.ts +4 -0
- package/build/core/fingerprint.d.ts.map +1 -0
- package/build/core/fingerprint.js +47 -0
- package/build/core/fingerprint.js.map +1 -0
- package/build/core/focusedInput.d.ts +27 -0
- package/build/core/focusedInput.d.ts.map +1 -0
- package/build/core/focusedInput.js +110 -0
- package/build/core/focusedInput.js.map +1 -0
- package/build/core/focusedInputTools.d.ts +21 -0
- package/build/core/focusedInputTools.d.ts.map +1 -0
- package/build/core/focusedInputTools.js +51 -0
- package/build/core/focusedInputTools.js.map +1 -0
- package/build/core/format.d.ts +8 -0
- package/build/core/format.d.ts.map +1 -0
- package/build/core/format.js +34 -0
- package/build/core/format.js.map +1 -0
- package/build/core/guides.d.ts +21 -0
- package/build/core/guides.d.ts.map +1 -0
- package/build/core/guides.js +397 -0
- package/build/core/guides.js.map +1 -0
- package/build/core/iconSemantics.d.ts +18 -0
- package/build/core/iconSemantics.d.ts.map +1 -0
- package/build/core/iconSemantics.js +114 -0
- package/build/core/iconSemantics.js.map +1 -0
- package/build/core/imageBuffer.d.ts +37 -0
- package/build/core/imageBuffer.d.ts.map +1 -0
- package/build/core/imageBuffer.js +58 -0
- package/build/core/imageBuffer.js.map +1 -0
- package/build/core/index.d.ts +37 -0
- package/build/core/index.d.ts.map +1 -0
- package/build/core/index.js +66 -0
- package/build/core/index.js.map +1 -0
- package/build/core/inspector.d.ts +55 -0
- package/build/core/inspector.d.ts.map +1 -0
- package/build/core/inspector.js +603 -0
- package/build/core/inspector.js.map +1 -0
- package/build/core/ios.d.ts +263 -0
- package/build/core/ios.d.ts.map +1 -0
- package/build/core/ios.js +1705 -0
- package/build/core/ios.js.map +1 -0
- package/build/core/jsExecute.d.ts +62 -0
- package/build/core/jsExecute.d.ts.map +1 -0
- package/build/core/jsExecute.js +696 -0
- package/build/core/jsExecute.js.map +1 -0
- package/build/core/license.d.ts +48 -0
- package/build/core/license.d.ts.map +1 -0
- package/build/core/license.js +267 -0
- package/build/core/license.js.map +1 -0
- package/build/core/logbox.d.ts +58 -0
- package/build/core/logbox.d.ts.map +1 -0
- package/build/core/logbox.js +330 -0
- package/build/core/logbox.js.map +1 -0
- package/build/core/logs.d.ts +44 -0
- package/build/core/logs.d.ts.map +1 -0
- package/build/core/logs.js +149 -0
- package/build/core/logs.js.map +1 -0
- package/build/core/measureComponent.d.ts +19 -0
- package/build/core/measureComponent.d.ts.map +1 -0
- package/build/core/measureComponent.js +94 -0
- package/build/core/measureComponent.js.map +1 -0
- package/build/core/measureComponentTools.d.ts +6 -0
- package/build/core/measureComponentTools.d.ts.map +1 -0
- package/build/core/measureComponentTools.js +43 -0
- package/build/core/measureComponentTools.js.map +1 -0
- package/build/core/metro.d.ts +31 -0
- package/build/core/metro.d.ts.map +1 -0
- package/build/core/metro.js +124 -0
- package/build/core/metro.js.map +1 -0
- package/build/core/nativeOnlyHints.d.ts +14 -0
- package/build/core/nativeOnlyHints.d.ts.map +1 -0
- package/build/core/nativeOnlyHints.js +58 -0
- package/build/core/nativeOnlyHints.js.map +1 -0
- package/build/core/network.d.ts +43 -0
- package/build/core/network.d.ts.map +1 -0
- package/build/core/network.js +217 -0
- package/build/core/network.js.map +1 -0
- package/build/core/networkInterceptor.d.ts +30 -0
- package/build/core/networkInterceptor.d.ts.map +1 -0
- package/build/core/networkInterceptor.js +211 -0
- package/build/core/networkInterceptor.js.map +1 -0
- package/build/core/ocr.d.ts +87 -0
- package/build/core/ocr.d.ts.map +1 -0
- package/build/core/ocr.js +335 -0
- package/build/core/ocr.js.map +1 -0
- package/build/core/paths.d.ts +2 -0
- package/build/core/paths.d.ts.map +1 -0
- package/build/core/paths.js +28 -0
- package/build/core/paths.js.map +1 -0
- package/build/core/posthog.d.ts +5 -0
- package/build/core/posthog.d.ts.map +1 -0
- package/build/core/posthog.js +52 -0
- package/build/core/posthog.js.map +1 -0
- package/build/core/pressables.d.ts +46 -0
- package/build/core/pressables.d.ts.map +1 -0
- package/build/core/pressables.js +1719 -0
- package/build/core/pressables.js.map +1 -0
- package/build/core/probe.d.ts +12 -0
- package/build/core/probe.d.ts.map +1 -0
- package/build/core/probe.js +56 -0
- package/build/core/probe.js.map +1 -0
- package/build/core/redux.d.ts +22 -0
- package/build/core/redux.d.ts.map +1 -0
- package/build/core/redux.js +116 -0
- package/build/core/redux.js.map +1 -0
- package/build/core/register.d.ts +8 -0
- package/build/core/register.d.ts.map +1 -0
- package/build/core/register.js +202 -0
- package/build/core/register.js.map +1 -0
- package/build/core/rnGlobalsBootstrap.d.ts +16 -0
- package/build/core/rnGlobalsBootstrap.d.ts.map +1 -0
- package/build/core/rnGlobalsBootstrap.js +116 -0
- package/build/core/rnGlobalsBootstrap.js.map +1 -0
- package/build/core/screenLayout.d.ts +53 -0
- package/build/core/screenLayout.d.ts.map +1 -0
- package/build/core/screenLayout.js +878 -0
- package/build/core/screenLayout.js.map +1 -0
- package/build/core/screenState.d.ts +166 -0
- package/build/core/screenState.d.ts.map +1 -0
- package/build/core/screenState.js +1352 -0
- package/build/core/screenState.js.map +1 -0
- package/build/core/sdkBridge.d.ts +67 -0
- package/build/core/sdkBridge.d.ts.map +1 -0
- package/build/core/sdkBridge.js +148 -0
- package/build/core/sdkBridge.js.map +1 -0
- package/build/core/state.d.ts +37 -0
- package/build/core/state.d.ts.map +1 -0
- package/build/core/state.js +124 -0
- package/build/core/state.js.map +1 -0
- package/build/core/telemetry.d.ts +50 -0
- package/build/core/telemetry.d.ts.map +1 -0
- package/build/core/telemetry.js +653 -0
- package/build/core/telemetry.js.map +1 -0
- package/build/core/toolHelpers.d.ts +24 -0
- package/build/core/toolHelpers.d.ts.map +1 -0
- package/build/core/toolHelpers.js +118 -0
- package/build/core/toolHelpers.js.map +1 -0
- package/build/core/types.d.ts +162 -0
- package/build/core/types.d.ts.map +1 -0
- package/build/core/types.js +2 -0
- package/build/core/types.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +132 -0
- package/build/index.js.map +1 -0
- package/build/pro/screenshot-diff.d.ts +10 -0
- package/build/pro/screenshot-diff.d.ts.map +1 -0
- package/build/pro/screenshot-diff.js +38 -0
- package/build/pro/screenshot-diff.js.map +1 -0
- package/build/pro/tap.d.ts +291 -0
- package/build/pro/tap.d.ts.map +1 -0
- package/build/pro/tap.js +1839 -0
- package/build/pro/tap.js.map +1 -0
- package/build/pro/verifyAction.d.ts +50 -0
- package/build/pro/verifyAction.d.ts.map +1 -0
- package/build/pro/verifyAction.js +285 -0
- package/build/pro/verifyAction.js.map +1 -0
- package/build/tools/_deviceArg.d.ts +45 -0
- package/build/tools/_deviceArg.d.ts.map +1 -0
- package/build/tools/_deviceArg.js +58 -0
- package/build/tools/_deviceArg.js.map +1 -0
- package/build/tools/accountTools.d.ts +46 -0
- package/build/tools/accountTools.d.ts.map +1 -0
- package/build/tools/accountTools.js +223 -0
- package/build/tools/accountTools.js.map +1 -0
- package/build/tools/bundleTools.d.ts +3 -0
- package/build/tools/bundleTools.d.ts.map +1 -0
- package/build/tools/bundleTools.js +375 -0
- package/build/tools/bundleTools.js.map +1 -0
- package/build/tools/componentTools.d.ts +3 -0
- package/build/tools/componentTools.d.ts.map +1 -0
- package/build/tools/componentTools.js +746 -0
- package/build/tools/componentTools.js.map +1 -0
- package/build/tools/connectionTools.d.ts +3 -0
- package/build/tools/connectionTools.d.ts.map +1 -0
- package/build/tools/connectionTools.js +625 -0
- package/build/tools/connectionTools.js.map +1 -0
- package/build/tools/deviceTools.d.ts +3 -0
- package/build/tools/deviceTools.d.ts.map +1 -0
- package/build/tools/deviceTools.js +423 -0
- package/build/tools/deviceTools.js.map +1 -0
- package/build/tools/executionTools.d.ts +3 -0
- package/build/tools/executionTools.d.ts.map +1 -0
- package/build/tools/executionTools.js +243 -0
- package/build/tools/executionTools.js.map +1 -0
- package/build/tools/interactionTools.d.ts +3 -0
- package/build/tools/interactionTools.d.ts.map +1 -0
- package/build/tools/interactionTools.js +539 -0
- package/build/tools/interactionTools.js.map +1 -0
- package/build/tools/logTools.d.ts +3 -0
- package/build/tools/logTools.d.ts.map +1 -0
- package/build/tools/logTools.js +371 -0
- package/build/tools/logTools.js.map +1 -0
- package/build/tools/metaTools.d.ts +7 -0
- package/build/tools/metaTools.d.ts.map +1 -0
- package/build/tools/metaTools.js +186 -0
- package/build/tools/metaTools.js.map +1 -0
- package/build/tools/networkTools.d.ts +3 -0
- package/build/tools/networkTools.d.ts.map +1 -0
- package/build/tools/networkTools.js +478 -0
- package/build/tools/networkTools.js.map +1 -0
- package/build/tools/reduxTools.d.ts +3 -0
- package/build/tools/reduxTools.d.ts.map +1 -0
- package/build/tools/reduxTools.js +83 -0
- package/build/tools/reduxTools.js.map +1 -0
- package/build/tools/screenshotTools.d.ts +3 -0
- package/build/tools/screenshotTools.d.ts.map +1 -0
- package/build/tools/screenshotTools.js +703 -0
- package/build/tools/screenshotTools.js.map +1 -0
- package/package.json +63 -2
|
@@ -0,0 +1,1710 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { connectedApps, pendingExecutions, getNextMessageId, getLogBuffer, getNetworkBuffer, setActiveSimulatorUdid, clearActiveSimulatorIfSource, updateLastCDPMessageTime, getLastCDPMessageTime, clearLastCDPMessageTime } from "./state.js";
|
|
3
|
+
import { mapConsoleType } from "./logs.js";
|
|
4
|
+
import { injectNetworkInterceptor, sendNetworkEnable, isInterceptorEvent, applyInterceptedEvent } from "./networkInterceptor.js";
|
|
5
|
+
import { findSimulatorByName } from "./ios.js";
|
|
6
|
+
import { getAdbIdForAvd } from "./android.js";
|
|
7
|
+
import { fetchDevices, selectMainDevice, scanMetroPorts } from "./metro.js";
|
|
8
|
+
import { probeCdpAlive } from "./probe.js";
|
|
9
|
+
import { UserInputError } from "./errors.js";
|
|
10
|
+
import { scheduleAppDetection } from "./appDetection.js";
|
|
11
|
+
import { markConnectionEstablished } from "./jsExecute.js";
|
|
12
|
+
import { DEFAULT_RECONNECTION_CONFIG, MIN_STABLE_CONNECTION_MS, initConnectionState, updateConnectionState, getConnectionState, recordConnectionGap, closeConnectionGap, saveConnectionMetadata, clearConnectionMetadata, getConnectionMetadata, saveReconnectionTimer, cancelReconnectionTimer, calculateBackoffDelay, initContextHealth, markContextHealthy, markContextStale, getContextHealth, updateContextHealth, formatDuration, recordConnectionEvent, } from "./connectionState.js";
|
|
13
|
+
// Connection locks to prevent concurrent connection attempts to the same device
|
|
14
|
+
const connectionLocks = new Set();
|
|
15
|
+
// Track Network.enable message IDs to detect CDP network support
|
|
16
|
+
const pendingNetworkEnableIds = new Set();
|
|
17
|
+
// Track SDK-probe message IDs so we can decode their responses and set
|
|
18
|
+
// app.sdkPresent. The probe runs on connect and then periodically because
|
|
19
|
+
// the SDK's init() may execute after we attach.
|
|
20
|
+
const pendingSdkProbeIds = new Map(); // msgId -> appKey
|
|
21
|
+
const SDK_PROBE_INITIAL_DELAY_MS = 200;
|
|
22
|
+
const SDK_PROBE_INTERVAL_MS = 3000;
|
|
23
|
+
// Consecutive SDK-absent probes required to flip sdkPresent true→false.
|
|
24
|
+
// Hysteresis against the post-reload window where the JS context exists but
|
|
25
|
+
// the SDK's init() hasn't re-run yet (see ConnectedApp.sdkMissCount).
|
|
26
|
+
const SDK_ABSENCE_CONFIRM_COUNT = 2;
|
|
27
|
+
// Fast re-probe schedule after a context is (re)created, so the SDK is
|
|
28
|
+
// re-detected within a few hundred ms instead of waiting up to a full
|
|
29
|
+
// SDK_PROBE_INTERVAL_MS tick. Covers reloads not driven by reload_app
|
|
30
|
+
// (shake-to-reload, Metro "r").
|
|
31
|
+
const SDK_REPROBE_DELAYS_MS = [200, 500, 1000, 2000];
|
|
32
|
+
function sendSdkProbe(ws, appKey) {
|
|
33
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
34
|
+
return;
|
|
35
|
+
const id = getNextMessageId();
|
|
36
|
+
pendingSdkProbeIds.set(id, appKey);
|
|
37
|
+
try {
|
|
38
|
+
ws.send(JSON.stringify({
|
|
39
|
+
id,
|
|
40
|
+
method: "Runtime.evaluate",
|
|
41
|
+
params: {
|
|
42
|
+
expression: 'typeof (globalThis.__EXECBRO__ ?? globalThis.__RN_AI_DEVTOOLS__)?.getNetworkEntries === "function"',
|
|
43
|
+
returnByValue: true
|
|
44
|
+
}
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
pendingSdkProbeIds.delete(id);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function startSdkProbeLoop(ws, appKey) {
|
|
52
|
+
const initial = setTimeout(() => sendSdkProbe(ws, appKey), SDK_PROBE_INITIAL_DELAY_MS);
|
|
53
|
+
const interval = setInterval(() => {
|
|
54
|
+
const app = connectedApps.get(appKey);
|
|
55
|
+
if (!app || ws.readyState !== WebSocket.OPEN) {
|
|
56
|
+
clearInterval(interval);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
sendSdkProbe(ws, appKey);
|
|
60
|
+
}, SDK_PROBE_INTERVAL_MS);
|
|
61
|
+
const app = connectedApps.get(appKey);
|
|
62
|
+
if (app) {
|
|
63
|
+
app.sdkProbeTimer = interval;
|
|
64
|
+
}
|
|
65
|
+
// Best-effort cleanup if the socket closes before the initial probe fires
|
|
66
|
+
ws.once("close", () => {
|
|
67
|
+
clearTimeout(initial);
|
|
68
|
+
clearInterval(interval);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// Fire a burst of SDK probes on the heels of a (re)created execution context.
|
|
72
|
+
// After a reload the context is recreated but the SDK's init() re-runs slightly
|
|
73
|
+
// later; without this, sdkPresent only recovers on the next 3s interval tick,
|
|
74
|
+
// widening the window where get_network_requests falls off the SDK buffer.
|
|
75
|
+
function scheduleFastSdkReprobe(ws, appKey) {
|
|
76
|
+
for (const delay of SDK_REPROBE_DELAYS_MS) {
|
|
77
|
+
const t = setTimeout(() => {
|
|
78
|
+
if (!connectedApps.has(appKey) || ws.readyState !== WebSocket.OPEN)
|
|
79
|
+
return;
|
|
80
|
+
sendSdkProbe(ws, appKey);
|
|
81
|
+
}, delay);
|
|
82
|
+
ws.once("close", () => clearTimeout(t));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Suppress auto-reconnection for intentionally disconnected devices
|
|
86
|
+
const reconnectionSuppressed = new Set();
|
|
87
|
+
/**
|
|
88
|
+
* Suppress auto-reconnection for all current connections.
|
|
89
|
+
* Used by disconnect_metro to prevent close handlers from re-connecting.
|
|
90
|
+
*/
|
|
91
|
+
export function suppressReconnection() {
|
|
92
|
+
for (const key of connectedApps.keys()) {
|
|
93
|
+
reconnectionSuppressed.add(key);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Suppress auto-reconnection for a specific connection key.
|
|
98
|
+
* Used by disconnect_metro with device targeting.
|
|
99
|
+
*/
|
|
100
|
+
export function suppressReconnectionForKey(appKey) {
|
|
101
|
+
reconnectionSuppressed.add(appKey);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Clear reconnection suppression (called when user explicitly reconnects via scan_metro).
|
|
105
|
+
*/
|
|
106
|
+
export function clearReconnectionSuppression() {
|
|
107
|
+
reconnectionSuppressed.clear();
|
|
108
|
+
}
|
|
109
|
+
const STALE_ACTIVITY_THRESHOLD_MS = 30_000;
|
|
110
|
+
const PING_INTERVAL_MS = 1_000; // WebSocket ping/pong keepalive interval
|
|
111
|
+
/**
|
|
112
|
+
* Max time (ms) to wait for a Runtime.evaluate("1+1") reply during liveness probing.
|
|
113
|
+
* Tight budget: live targets respond in under 50ms; zombie CDP targets never respond.
|
|
114
|
+
*/
|
|
115
|
+
const PROBE_TIMEOUT_MS = 1500;
|
|
116
|
+
const RECONNECT_SETTLE_MS = 500;
|
|
117
|
+
/**
|
|
118
|
+
* Creates a WebSocket connection with Origin header fallback.
|
|
119
|
+
* RN 0.85+ Metro requires the Origin header, but Expo SDK 55 Bridgeless
|
|
120
|
+
* rejects connections that include one. The rejection can manifest as:
|
|
121
|
+
* - Connection refused before open (error/close before open event)
|
|
122
|
+
* - Immediate close after open (open fires, then close with code 1006)
|
|
123
|
+
* Tries with Origin first, falls back to without on quick failure.
|
|
124
|
+
*/
|
|
125
|
+
export function createWebSocketWithOriginFallback(url, timeoutMs = 5000) {
|
|
126
|
+
const STABILIZE_MS = 500; // Wait after open to detect immediate close
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const wsUrl = new URL(url);
|
|
129
|
+
const origin = `http://${wsUrl.hostname}:${wsUrl.port}`;
|
|
130
|
+
let settled = false;
|
|
131
|
+
const tryConnect = (withOrigin) => {
|
|
132
|
+
const options = withOrigin ? { headers: { Origin: origin } } : undefined;
|
|
133
|
+
const ws = new WebSocket(url, options);
|
|
134
|
+
let opened = false;
|
|
135
|
+
ws.on("open", () => {
|
|
136
|
+
opened = true;
|
|
137
|
+
if (withOrigin) {
|
|
138
|
+
// Wait briefly to detect immediate close (Expo SDK 55 pattern)
|
|
139
|
+
setTimeout(() => {
|
|
140
|
+
if (settled)
|
|
141
|
+
return;
|
|
142
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
143
|
+
settled = true;
|
|
144
|
+
console.error("[execbro] WebSocket connected with Origin header");
|
|
145
|
+
resolve(ws);
|
|
146
|
+
}
|
|
147
|
+
// If not OPEN, the close handler will trigger fallback
|
|
148
|
+
}, STABILIZE_MS);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
if (settled)
|
|
152
|
+
return;
|
|
153
|
+
settled = true;
|
|
154
|
+
console.error("[execbro] WebSocket connected without Origin header");
|
|
155
|
+
resolve(ws);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
const handleFailure = (error) => {
|
|
159
|
+
if (settled)
|
|
160
|
+
return;
|
|
161
|
+
ws.removeAllListeners();
|
|
162
|
+
ws.terminate();
|
|
163
|
+
if (withOrigin) {
|
|
164
|
+
const reason = opened ? "immediately closed after open" : "rejected before open";
|
|
165
|
+
console.error(`[execbro] Origin header connection ${reason}, retrying without...`);
|
|
166
|
+
tryConnect(false);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
settled = true;
|
|
170
|
+
reject(error || new Error("WebSocket connection failed"));
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
ws.on("error", (err) => handleFailure(err));
|
|
174
|
+
ws.on("close", () => handleFailure());
|
|
175
|
+
};
|
|
176
|
+
tryConnect(true);
|
|
177
|
+
setTimeout(() => {
|
|
178
|
+
if (!settled) {
|
|
179
|
+
settled = true;
|
|
180
|
+
reject(new Error("WebSocket connection timed out"));
|
|
181
|
+
}
|
|
182
|
+
}, timeoutMs);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
// Helper to find appKey from device info by searching connectedApps
|
|
186
|
+
function findAppKeyForDevice(device) {
|
|
187
|
+
for (const [key, app] of connectedApps.entries()) {
|
|
188
|
+
if (app.deviceInfo.id === device.id) {
|
|
189
|
+
return key;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
// Helper to convert WebSocket readyState to readable name
|
|
195
|
+
export function getWebSocketStateName(state) {
|
|
196
|
+
switch (state) {
|
|
197
|
+
case WebSocket.CONNECTING: return "CONNECTING";
|
|
198
|
+
case WebSocket.OPEN: return "OPEN";
|
|
199
|
+
case WebSocket.CLOSING: return "CLOSING";
|
|
200
|
+
case WebSocket.CLOSED: return "CLOSED";
|
|
201
|
+
default: return `UNKNOWN(${state})`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Purge stale connections for scanned ports.
|
|
206
|
+
* When Metro restarts with a different app, /json returns new device IDs.
|
|
207
|
+
* Old connections keyed by the previous device IDs remain in connectedApps.
|
|
208
|
+
* This function removes connections on scanned ports whose device IDs
|
|
209
|
+
* are no longer present in the fresh device list from Metro.
|
|
210
|
+
*/
|
|
211
|
+
export function purgeStaleConnectionsForPorts(freshDevicesByPort) {
|
|
212
|
+
const purged = [];
|
|
213
|
+
const scannedPorts = new Set(freshDevicesByPort.keys());
|
|
214
|
+
for (const [appKey, app] of connectedApps.entries()) {
|
|
215
|
+
// Only check connections on ports we just scanned
|
|
216
|
+
if (!scannedPorts.has(app.port))
|
|
217
|
+
continue;
|
|
218
|
+
const freshDevices = freshDevicesByPort.get(app.port) || [];
|
|
219
|
+
// Check both device ID and appId — device IDs are per-physical-device
|
|
220
|
+
// (stable across Metro restarts), so a different app on the same device
|
|
221
|
+
// would have the same ID but a different appId
|
|
222
|
+
const matchingDevice = freshDevices.find(d => d.id === app.deviceInfo.id);
|
|
223
|
+
const stillValid = matchingDevice && matchingDevice.appId === app.deviceInfo.appId;
|
|
224
|
+
if (!stillValid) {
|
|
225
|
+
const name = app.deviceInfo.deviceName || app.deviceInfo.title;
|
|
226
|
+
const reason = !matchingDevice
|
|
227
|
+
? "device no longer in Metro /json"
|
|
228
|
+
: `appId changed: ${app.deviceInfo.appId} → ${matchingDevice.appId}`;
|
|
229
|
+
console.error(`[execbro] Purging stale connection: ${name} (port ${app.port}, id ${app.deviceInfo.id}) — ${reason}`);
|
|
230
|
+
// Suppress reconnection so close handler doesn't try to reconnect
|
|
231
|
+
reconnectionSuppressed.add(appKey);
|
|
232
|
+
cancelReconnectionTimer(appKey);
|
|
233
|
+
clearLastCDPMessageTime(appKey);
|
|
234
|
+
clearActiveSimulatorIfSource(appKey);
|
|
235
|
+
try {
|
|
236
|
+
app.ws.close();
|
|
237
|
+
}
|
|
238
|
+
catch { /* ignore */ }
|
|
239
|
+
connectedApps.delete(appKey);
|
|
240
|
+
purged.push(name);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return purged;
|
|
244
|
+
}
|
|
245
|
+
// Format CDP RemoteObject to readable string
|
|
246
|
+
export function formatRemoteObject(result) {
|
|
247
|
+
if (result.type === "undefined") {
|
|
248
|
+
return "undefined";
|
|
249
|
+
}
|
|
250
|
+
if (result.subtype === "null") {
|
|
251
|
+
return "null";
|
|
252
|
+
}
|
|
253
|
+
// For objects/arrays with a value, stringify it
|
|
254
|
+
if (result.value !== undefined) {
|
|
255
|
+
if (typeof result.value === "object") {
|
|
256
|
+
return JSON.stringify(result.value, null, 2);
|
|
257
|
+
}
|
|
258
|
+
return String(result.value);
|
|
259
|
+
}
|
|
260
|
+
// Use description for complex objects
|
|
261
|
+
if (result.description) {
|
|
262
|
+
return result.description;
|
|
263
|
+
}
|
|
264
|
+
// Handle unserializable values (NaN, Infinity, etc.)
|
|
265
|
+
if (result.unserializableValue) {
|
|
266
|
+
return result.unserializableValue;
|
|
267
|
+
}
|
|
268
|
+
return `[${result.type}${result.subtype ? ` ${result.subtype}` : ""}]`;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Extract a clean, informative error message from CDP exception details
|
|
272
|
+
* Handles various error formats from Hermes and other JS engines
|
|
273
|
+
*/
|
|
274
|
+
function extractExceptionMessage(exceptionDetails) {
|
|
275
|
+
const parts = [];
|
|
276
|
+
// Get the exception object if available
|
|
277
|
+
const exc = exceptionDetails.exception;
|
|
278
|
+
if (exc) {
|
|
279
|
+
// For error objects, className tells us the error type (ReferenceError, TypeError, etc.)
|
|
280
|
+
const errorType = exc.className || (exc.subtype === 'error' ? 'Error' : '');
|
|
281
|
+
// The description usually contains "ErrorType: message" or full stack trace
|
|
282
|
+
// We want to extract just the first line (the actual error message)
|
|
283
|
+
if (exc.description) {
|
|
284
|
+
const firstLine = exc.description.split('\n')[0].trim();
|
|
285
|
+
// If description already includes the error type, use it directly
|
|
286
|
+
if (firstLine.includes(':')) {
|
|
287
|
+
parts.push(firstLine);
|
|
288
|
+
}
|
|
289
|
+
else if (errorType) {
|
|
290
|
+
// Combine error type with description
|
|
291
|
+
parts.push(`${errorType}: ${firstLine}`);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
parts.push(firstLine);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
else if (exc.value !== undefined) {
|
|
298
|
+
// For primitive exceptions (throw "string" or throw 123)
|
|
299
|
+
const valueStr = typeof exc.value === 'string' ? exc.value : JSON.stringify(exc.value);
|
|
300
|
+
if (errorType) {
|
|
301
|
+
parts.push(`${errorType}: ${valueStr}`);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
parts.push(valueStr);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else if (errorType) {
|
|
308
|
+
// Just the error type, no message
|
|
309
|
+
parts.push(errorType);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// Fall back to exceptionDetails.text if we couldn't extract from exception object
|
|
313
|
+
// But avoid just "Uncaught" which is not helpful
|
|
314
|
+
if (parts.length === 0) {
|
|
315
|
+
const text = exceptionDetails.text;
|
|
316
|
+
if (text && text.toLowerCase() !== 'uncaught') {
|
|
317
|
+
parts.push(text);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Add location info for syntax/compilation errors (helps identify the problem)
|
|
321
|
+
if (exceptionDetails.lineNumber !== undefined && exceptionDetails.columnNumber !== undefined) {
|
|
322
|
+
// Only add location if it's meaningful (not 0:0 which is often just wrapper)
|
|
323
|
+
if (exceptionDetails.lineNumber > 0 || exceptionDetails.columnNumber > 0) {
|
|
324
|
+
parts.push(`at line ${exceptionDetails.lineNumber}:${exceptionDetails.columnNumber}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// If we still have nothing, provide a generic message
|
|
328
|
+
if (parts.length === 0) {
|
|
329
|
+
return 'JavaScript execution failed (no error details available)';
|
|
330
|
+
}
|
|
331
|
+
return parts.join(' ');
|
|
332
|
+
}
|
|
333
|
+
// Format a CDP object preview recursively
|
|
334
|
+
function formatPreview(preview) {
|
|
335
|
+
const isArray = preview.subtype === "array";
|
|
336
|
+
const props = preview.properties || [];
|
|
337
|
+
const formatted = props.map((p) => {
|
|
338
|
+
let value;
|
|
339
|
+
if (p.valuePreview) {
|
|
340
|
+
value = formatPreview(p.valuePreview);
|
|
341
|
+
}
|
|
342
|
+
else if (p.subtype === "null") {
|
|
343
|
+
value = "null";
|
|
344
|
+
}
|
|
345
|
+
else if (p.type === "string") {
|
|
346
|
+
value = `"${p.value}"`;
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
value = p.value ?? "undefined";
|
|
350
|
+
}
|
|
351
|
+
return isArray ? value : `${p.name}: ${value}`;
|
|
352
|
+
});
|
|
353
|
+
const overflow = preview.overflow ? ", ..." : "";
|
|
354
|
+
return isArray
|
|
355
|
+
? `[${formatted.join(", ")}${overflow}]`
|
|
356
|
+
: `{${formatted.join(", ")}${overflow}}`;
|
|
357
|
+
}
|
|
358
|
+
// Format a single CDP console argument (sync — without object resolution)
|
|
359
|
+
function formatConsoleArg(arg) {
|
|
360
|
+
// Explicit undefined (matches browser: console.log(undefined) → "undefined")
|
|
361
|
+
if (arg.type === "undefined") {
|
|
362
|
+
return "undefined";
|
|
363
|
+
}
|
|
364
|
+
// Primitives
|
|
365
|
+
if (arg.type === "string" || arg.type === "number" || arg.type === "boolean") {
|
|
366
|
+
return String(arg.value);
|
|
367
|
+
}
|
|
368
|
+
// Objects/arrays with preview — expand inline
|
|
369
|
+
if (arg.preview?.properties) {
|
|
370
|
+
return formatPreview(arg.preview);
|
|
371
|
+
}
|
|
372
|
+
// Raw value (e.g. null sent as value)
|
|
373
|
+
if (arg.value !== undefined) {
|
|
374
|
+
return JSON.stringify(arg.value);
|
|
375
|
+
}
|
|
376
|
+
// Description fallback (functions, symbols, errors without preview)
|
|
377
|
+
if (arg.description) {
|
|
378
|
+
return arg.description;
|
|
379
|
+
}
|
|
380
|
+
// Object without any resolved data
|
|
381
|
+
if (arg.type === "object") {
|
|
382
|
+
return "[Object]";
|
|
383
|
+
}
|
|
384
|
+
return `[${arg.type || "unknown"}]`;
|
|
385
|
+
}
|
|
386
|
+
// Fetch object properties via CDP Runtime.getProperties
|
|
387
|
+
function fetchObjectProperties(ws, objectId, depth = 2) {
|
|
388
|
+
return new Promise((resolve) => {
|
|
389
|
+
const msgId = getNextMessageId();
|
|
390
|
+
const timeout = setTimeout(() => {
|
|
391
|
+
resolve("Object"); // Fallback on timeout
|
|
392
|
+
}, 3000);
|
|
393
|
+
const handler = (data) => {
|
|
394
|
+
try {
|
|
395
|
+
const response = JSON.parse(data.toString());
|
|
396
|
+
if (response.id === msgId) {
|
|
397
|
+
clearTimeout(timeout);
|
|
398
|
+
ws.removeListener("message", handler);
|
|
399
|
+
if (response.result?.result) {
|
|
400
|
+
resolve(formatCDPProperties(ws, response.result.result, depth));
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
resolve("Object");
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
// Ignore parse errors
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
ws.on("message", handler);
|
|
412
|
+
ws.send(JSON.stringify({
|
|
413
|
+
id: msgId,
|
|
414
|
+
method: "Runtime.getProperties",
|
|
415
|
+
params: { objectId, ownProperties: true, generatePreview: true }
|
|
416
|
+
}));
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
// Format CDP property descriptors into a readable string
|
|
420
|
+
async function formatCDPProperties(ws, properties, depth) {
|
|
421
|
+
const parts = [];
|
|
422
|
+
let isArrayLike = false;
|
|
423
|
+
// Detect arrays by checking for numeric keys and "length" property
|
|
424
|
+
const propNames = properties.filter((p) => !p.isAccessor && p.name !== "__proto__").map((p) => p.name);
|
|
425
|
+
const hasLength = propNames.includes("length");
|
|
426
|
+
const numericKeys = propNames.filter((n) => /^\d+$/.test(n));
|
|
427
|
+
if (hasLength && numericKeys.length > 0 && numericKeys.length >= propNames.length - 1) {
|
|
428
|
+
isArrayLike = true;
|
|
429
|
+
}
|
|
430
|
+
const filteredProps = properties.filter((p) => !p.isAccessor && p.name !== "__proto__" && (!isArrayLike || p.name !== "length"));
|
|
431
|
+
for (const prop of filteredProps) {
|
|
432
|
+
const val = prop.value;
|
|
433
|
+
if (!val)
|
|
434
|
+
continue;
|
|
435
|
+
const formatted = await formatPropertyValue(ws, val, depth - 1);
|
|
436
|
+
if (isArrayLike) {
|
|
437
|
+
parts.push(formatted);
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
parts.push(`${prop.name}: ${formatted}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return isArrayLike
|
|
444
|
+
? `[${parts.join(", ")}]`
|
|
445
|
+
: `{${parts.join(", ")}}`;
|
|
446
|
+
}
|
|
447
|
+
// Format a single property value
|
|
448
|
+
async function formatPropertyValue(ws, val, remainingDepth) {
|
|
449
|
+
const type = val.type;
|
|
450
|
+
const subtype = val.subtype;
|
|
451
|
+
if (subtype === "null")
|
|
452
|
+
return "null";
|
|
453
|
+
if (type === "undefined")
|
|
454
|
+
return "undefined";
|
|
455
|
+
if (type === "string")
|
|
456
|
+
return `"${val.value}"`;
|
|
457
|
+
if (type === "number" || type === "boolean")
|
|
458
|
+
return String(val.value);
|
|
459
|
+
if (type === "function")
|
|
460
|
+
return "[Function]";
|
|
461
|
+
// Nested object — recurse if depth allows
|
|
462
|
+
if (type === "object" && val.objectId && remainingDepth > 0) {
|
|
463
|
+
return fetchObjectProperties(ws, val.objectId, remainingDepth);
|
|
464
|
+
}
|
|
465
|
+
// Object but no depth left — use description
|
|
466
|
+
if (type === "object") {
|
|
467
|
+
return val.description || "[Object]";
|
|
468
|
+
}
|
|
469
|
+
if (val.value !== undefined)
|
|
470
|
+
return JSON.stringify(val.value);
|
|
471
|
+
if (val.description)
|
|
472
|
+
return val.description;
|
|
473
|
+
return `[${type}]`;
|
|
474
|
+
}
|
|
475
|
+
// Resolve all object args in a console message, returning formatted text
|
|
476
|
+
async function resolveConsoleArgs(ws, args) {
|
|
477
|
+
const parts = await Promise.all(args.map(async (arg) => {
|
|
478
|
+
// Primitives — format synchronously
|
|
479
|
+
if (arg.type === "string" || arg.type === "number" || arg.type === "boolean") {
|
|
480
|
+
return String(arg.value);
|
|
481
|
+
}
|
|
482
|
+
// Objects/arrays with preview — expand inline
|
|
483
|
+
if (arg.preview?.properties) {
|
|
484
|
+
return formatPreview(arg.preview);
|
|
485
|
+
}
|
|
486
|
+
// Object with objectId — fetch properties via CDP
|
|
487
|
+
if (arg.type === "object" && arg.objectId) {
|
|
488
|
+
return fetchObjectProperties(ws, arg.objectId);
|
|
489
|
+
}
|
|
490
|
+
// Raw value
|
|
491
|
+
if (arg.value !== undefined) {
|
|
492
|
+
return JSON.stringify(arg.value);
|
|
493
|
+
}
|
|
494
|
+
// Description fallback
|
|
495
|
+
if (arg.description) {
|
|
496
|
+
return arg.description;
|
|
497
|
+
}
|
|
498
|
+
return "[object]";
|
|
499
|
+
}));
|
|
500
|
+
return parts.join(" ");
|
|
501
|
+
}
|
|
502
|
+
// Handle CDP messages
|
|
503
|
+
export function handleCDPMessage(message, device, ws) {
|
|
504
|
+
// Track last CDP activity for connection liveness detection (per-device)
|
|
505
|
+
const cdpAppKey = findAppKeyForDevice(device);
|
|
506
|
+
if (cdpAppKey) {
|
|
507
|
+
updateLastCDPMessageTime(cdpAppKey, new Date());
|
|
508
|
+
}
|
|
509
|
+
const deviceName = device.deviceName || device.title || "unknown";
|
|
510
|
+
// Handle responses to our requests (e.g., Runtime.evaluate)
|
|
511
|
+
if (typeof message.id === "number") {
|
|
512
|
+
const pending = pendingExecutions.get(message.id);
|
|
513
|
+
if (pending) {
|
|
514
|
+
clearTimeout(pending.timeoutId);
|
|
515
|
+
pendingExecutions.delete(message.id);
|
|
516
|
+
// Check for CDP-level error (protocol error, not JS exception)
|
|
517
|
+
if (message.error) {
|
|
518
|
+
const error = message.error;
|
|
519
|
+
// Build comprehensive error message including code and data if available
|
|
520
|
+
const parts = [];
|
|
521
|
+
if (error.message)
|
|
522
|
+
parts.push(error.message);
|
|
523
|
+
if (error.code !== undefined)
|
|
524
|
+
parts.push(`(code: ${error.code})`);
|
|
525
|
+
if (error.data)
|
|
526
|
+
parts.push(`- ${error.data}`);
|
|
527
|
+
const errorMessage = parts.length > 0 ? parts.join(' ') : 'Unknown CDP protocol error';
|
|
528
|
+
pending.resolve({ success: false, error: errorMessage });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
// Check for JavaScript exception in result
|
|
532
|
+
const result = message.result;
|
|
533
|
+
if (result?.exceptionDetails) {
|
|
534
|
+
const errorMessage = extractExceptionMessage(result.exceptionDetails);
|
|
535
|
+
pending.resolve({ success: false, error: errorMessage });
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
// Success - format the result
|
|
539
|
+
if (result?.result) {
|
|
540
|
+
pending.resolve({ success: true, result: formatRemoteObject(result.result) });
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
pending.resolve({ success: true, result: "undefined" });
|
|
544
|
+
}
|
|
545
|
+
// Track SDK-probe responses
|
|
546
|
+
if (pendingSdkProbeIds.has(message.id)) {
|
|
547
|
+
const appKey = pendingSdkProbeIds.get(message.id);
|
|
548
|
+
pendingSdkProbeIds.delete(message.id);
|
|
549
|
+
const app = connectedApps.get(appKey);
|
|
550
|
+
if (app) {
|
|
551
|
+
const result = message.result?.result;
|
|
552
|
+
const present = result?.value === true;
|
|
553
|
+
const wasPresent = app.sdkPresent === true;
|
|
554
|
+
// Hysteresis on the true→false edge: tolerate transient SDK
|
|
555
|
+
// absence right after a reload (context recreated, init() not
|
|
556
|
+
// re-run yet) so we don't restore duplicate-prone CDP/interceptor
|
|
557
|
+
// writes and bounce get_network_requests off the SDK buffer.
|
|
558
|
+
let nextPresent = present;
|
|
559
|
+
if (!present && wasPresent) {
|
|
560
|
+
app.sdkMissCount = (app.sdkMissCount ?? 0) + 1;
|
|
561
|
+
if (app.sdkMissCount < SDK_ABSENCE_CONFIRM_COUNT) {
|
|
562
|
+
nextPresent = true; // hold "present" until absence is confirmed
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
app.sdkMissCount = 0;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
app.sdkMissCount = 0;
|
|
570
|
+
}
|
|
571
|
+
app.sdkPresent = nextPresent;
|
|
572
|
+
if (nextPresent !== wasPresent) {
|
|
573
|
+
console.error(`[execbro] SDK ${present ? "detected" : "no longer detected"} on ${app.deviceInfo.title}; CDP/JS-interceptor buffer writes ${present ? "suppressed" : "restored"}`);
|
|
574
|
+
// Toggle the in-app interceptor's emit flag so it stops
|
|
575
|
+
// (or resumes) producing console.debug lines and CDP
|
|
576
|
+
// traffic. Fire-and-forget — the flag is idempotent.
|
|
577
|
+
if (app.ws.readyState === WebSocket.OPEN) {
|
|
578
|
+
try {
|
|
579
|
+
app.ws.send(JSON.stringify({
|
|
580
|
+
id: getNextMessageId(),
|
|
581
|
+
method: "Runtime.evaluate",
|
|
582
|
+
params: {
|
|
583
|
+
expression: `globalThis.__RN_NET_DISABLED__ = ${present ? "true" : "false"}`,
|
|
584
|
+
returnByValue: true
|
|
585
|
+
}
|
|
586
|
+
}));
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
// ignore — next probe will retry
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
// Track Network.enable responses to detect CDP network support
|
|
597
|
+
if (pendingNetworkEnableIds.has(message.id)) {
|
|
598
|
+
pendingNetworkEnableIds.delete(message.id);
|
|
599
|
+
const nAppKey = findAppKeyForDevice(device);
|
|
600
|
+
if (nAppKey) {
|
|
601
|
+
const app = connectedApps.get(nAppKey);
|
|
602
|
+
if (app) {
|
|
603
|
+
if (message.error) {
|
|
604
|
+
app.cdpNetworkSupported = false;
|
|
605
|
+
console.error(`[execbro] CDP Network domain not supported, using JS interceptor`);
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
app.cdpNetworkSupported = true;
|
|
609
|
+
console.error(`[execbro] CDP Network domain supported`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const method = message.method;
|
|
617
|
+
// Handle Runtime.consoleAPICalled
|
|
618
|
+
if (method === "Runtime.consoleAPICalled") {
|
|
619
|
+
const params = message.params;
|
|
620
|
+
const type = params.type || "log";
|
|
621
|
+
const level = mapConsoleType(type);
|
|
622
|
+
const args = params.args || [];
|
|
623
|
+
// Check for network interceptor events before processing as logs
|
|
624
|
+
const interceptorJson = isInterceptorEvent(args);
|
|
625
|
+
if (interceptorJson !== null) {
|
|
626
|
+
const iAppKey = findAppKeyForDevice(device);
|
|
627
|
+
const iApp = iAppKey ? connectedApps.get(iAppKey) : null;
|
|
628
|
+
// Skip when CDP Network domain is the source, OR when the in-app
|
|
629
|
+
// SDK is the source — both would otherwise produce duplicate
|
|
630
|
+
// entries for every request.
|
|
631
|
+
if (!iApp?.cdpNetworkSupported && !iApp?.sdkPresent) {
|
|
632
|
+
applyInterceptedEvent(interceptorJson, getNetworkBuffer(deviceName));
|
|
633
|
+
}
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
// Check if any args need async object resolution
|
|
637
|
+
const hasObjectArgs = ws && args.some((a) => a.type === "object" && a.objectId && !a.preview?.properties);
|
|
638
|
+
if (hasObjectArgs) {
|
|
639
|
+
// Resolve object args asynchronously via CDP
|
|
640
|
+
resolveConsoleArgs(ws, args).then((messageText) => {
|
|
641
|
+
getLogBuffer(deviceName).add({
|
|
642
|
+
timestamp: new Date(),
|
|
643
|
+
level,
|
|
644
|
+
message: messageText || "[console call with empty resolution]",
|
|
645
|
+
args: args.map((a) => a.value)
|
|
646
|
+
});
|
|
647
|
+
}).catch(() => {
|
|
648
|
+
// Fallback to sync formatting on error
|
|
649
|
+
const messageText = args.map(formatConsoleArg).join(" ");
|
|
650
|
+
getLogBuffer(deviceName).add({
|
|
651
|
+
timestamp: new Date(),
|
|
652
|
+
level,
|
|
653
|
+
message: messageText || "[console call with unresolvable args]",
|
|
654
|
+
args: args.map((a) => a.value)
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
const messageText = args.map(formatConsoleArg).join(" ");
|
|
660
|
+
if (messageText.trim()) {
|
|
661
|
+
getLogBuffer(deviceName).add({
|
|
662
|
+
timestamp: new Date(),
|
|
663
|
+
level,
|
|
664
|
+
message: messageText,
|
|
665
|
+
args: args.map((a) => a.value)
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
// Handle Log.entryAdded
|
|
671
|
+
if (method === "Log.entryAdded") {
|
|
672
|
+
const params = message.params;
|
|
673
|
+
if (params.entry) {
|
|
674
|
+
const level = mapConsoleType(params.entry.level || "log");
|
|
675
|
+
getLogBuffer(deviceName).add({
|
|
676
|
+
timestamp: new Date(),
|
|
677
|
+
level,
|
|
678
|
+
message: params.entry.text || ""
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
// Handle Network.requestWillBeSent
|
|
683
|
+
if (method === "Network.requestWillBeSent") {
|
|
684
|
+
// Skip CDP capture when the in-app SDK is the source of truth —
|
|
685
|
+
// otherwise every request lands in both buffers under different ids.
|
|
686
|
+
// The follow-up Network.responseReceived/loadingFinished/loadingFailed
|
|
687
|
+
// handlers below only mutate existing entries, so they self-skip.
|
|
688
|
+
const nAppKey = findAppKeyForDevice(device);
|
|
689
|
+
const nApp = nAppKey ? connectedApps.get(nAppKey) : null;
|
|
690
|
+
if (nApp?.sdkPresent) {
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
// An arriving Network.requestWillBeSent is proof the CDP Network domain
|
|
694
|
+
// is active. Some backends (e.g. RN 0.85 Bridgeless/Hermes) enable the
|
|
695
|
+
// domain but never send a response to Network.enable, so the
|
|
696
|
+
// response-based detection above never fires and cdpNetworkSupported
|
|
697
|
+
// stays undefined — leaving the JS interceptor un-suppressed and every
|
|
698
|
+
// request double-captured (CDP + interceptor). Mark support here so the
|
|
699
|
+
// interceptor dedup guard (see Runtime.consoleAPICalled handler) trips.
|
|
700
|
+
if (nApp && !nApp.cdpNetworkSupported) {
|
|
701
|
+
nApp.cdpNetworkSupported = true;
|
|
702
|
+
console.error(`[execbro] CDP Network domain active (detected via requestWillBeSent on ${nApp.deviceInfo.title})`);
|
|
703
|
+
}
|
|
704
|
+
const params = message.params;
|
|
705
|
+
const request = {
|
|
706
|
+
requestId: params.requestId,
|
|
707
|
+
timestamp: new Date(),
|
|
708
|
+
method: params.request.method,
|
|
709
|
+
url: params.request.url,
|
|
710
|
+
headers: params.request.headers || {},
|
|
711
|
+
postData: params.request.postData,
|
|
712
|
+
timing: {
|
|
713
|
+
requestTime: params.timestamp
|
|
714
|
+
},
|
|
715
|
+
completed: false
|
|
716
|
+
};
|
|
717
|
+
getNetworkBuffer(deviceName).set(params.requestId, request);
|
|
718
|
+
}
|
|
719
|
+
// Handle Network.responseReceived
|
|
720
|
+
if (method === "Network.responseReceived") {
|
|
721
|
+
const params = message.params;
|
|
722
|
+
const existing = getNetworkBuffer(deviceName).get(params.requestId);
|
|
723
|
+
if (existing) {
|
|
724
|
+
existing.status = params.response.status;
|
|
725
|
+
existing.statusText = params.response.statusText;
|
|
726
|
+
existing.responseHeaders = params.response.headers || {};
|
|
727
|
+
existing.mimeType = params.response.mimeType;
|
|
728
|
+
if (params.timestamp && existing.timing?.requestTime) {
|
|
729
|
+
existing.timing.responseTime = params.timestamp;
|
|
730
|
+
}
|
|
731
|
+
getNetworkBuffer(deviceName).set(params.requestId, existing);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// Handle Network.loadingFinished
|
|
735
|
+
if (method === "Network.loadingFinished") {
|
|
736
|
+
const params = message.params;
|
|
737
|
+
const existing = getNetworkBuffer(deviceName).get(params.requestId);
|
|
738
|
+
if (existing) {
|
|
739
|
+
existing.completed = true;
|
|
740
|
+
existing.contentLength = params.encodedDataLength;
|
|
741
|
+
if (params.timestamp && existing.timing?.requestTime) {
|
|
742
|
+
existing.timing.duration = Math.round((params.timestamp - existing.timing.requestTime) * 1000);
|
|
743
|
+
}
|
|
744
|
+
getNetworkBuffer(deviceName).set(params.requestId, existing);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
// Handle Network.loadingFailed
|
|
748
|
+
if (method === "Network.loadingFailed") {
|
|
749
|
+
const params = message.params;
|
|
750
|
+
const existing = getNetworkBuffer(deviceName).get(params.requestId);
|
|
751
|
+
if (existing) {
|
|
752
|
+
existing.completed = true;
|
|
753
|
+
existing.error = params.canceled ? "Canceled" : (params.errorText || "Request failed");
|
|
754
|
+
getNetworkBuffer(deviceName).set(params.requestId, existing);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
// Handle Runtime context lifecycle events for health tracking
|
|
758
|
+
const appKey = findAppKeyForDevice(device);
|
|
759
|
+
if (appKey) {
|
|
760
|
+
// Handle Runtime.executionContextCreated
|
|
761
|
+
if (method === "Runtime.executionContextCreated") {
|
|
762
|
+
const params = message.params;
|
|
763
|
+
markContextHealthy(appKey, params.context.id);
|
|
764
|
+
console.error(`[execbro] Context created: ${params.context.id}`);
|
|
765
|
+
// Re-inject network interceptor and re-check CDP Network support
|
|
766
|
+
const ctxApp = connectedApps.get(appKey);
|
|
767
|
+
if (ctxApp?.ws?.readyState === WebSocket.OPEN) {
|
|
768
|
+
injectNetworkInterceptor(ctxApp.ws);
|
|
769
|
+
const nEnableId = sendNetworkEnable(ctxApp.ws);
|
|
770
|
+
pendingNetworkEnableIds.add(nEnableId);
|
|
771
|
+
// The context was just recreated (e.g. reload). Re-probe the SDK
|
|
772
|
+
// quickly so sdkPresent re-establishes within a few hundred ms
|
|
773
|
+
// instead of on the next 3s interval tick.
|
|
774
|
+
scheduleFastSdkReprobe(ctxApp.ws, appKey);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// Handle Runtime.executionContextDestroyed
|
|
778
|
+
if (method === "Runtime.executionContextDestroyed") {
|
|
779
|
+
markContextStale(appKey);
|
|
780
|
+
console.error(`[execbro] Context destroyed`);
|
|
781
|
+
}
|
|
782
|
+
// Handle Runtime.executionContextsCleared
|
|
783
|
+
if (method === "Runtime.executionContextsCleared") {
|
|
784
|
+
markContextStale(appKey);
|
|
785
|
+
console.error(`[execbro] All contexts cleared`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// Connect to a device via CDP WebSocket
|
|
790
|
+
export async function connectToDevice(device, port, options = {}) {
|
|
791
|
+
const { isReconnection = false, reconnectionConfig = DEFAULT_RECONNECTION_CONFIG } = options;
|
|
792
|
+
return new Promise(async (resolve, reject) => {
|
|
793
|
+
const appKey = `${port}-${device.id}`;
|
|
794
|
+
// Check if already connected with a valid WebSocket
|
|
795
|
+
const existingApp = connectedApps.get(appKey);
|
|
796
|
+
if (existingApp) {
|
|
797
|
+
if (existingApp.ws.readyState === WebSocket.OPEN) {
|
|
798
|
+
// Verify WebSocket is actually alive with a ping/pong check.
|
|
799
|
+
// readyState can show OPEN even when the remote end is gone
|
|
800
|
+
// (TCP hasn't timed out yet, especially after Metro restart).
|
|
801
|
+
const isAlive = await new Promise((pongResolve) => {
|
|
802
|
+
const timeout = setTimeout(() => pongResolve(false), 2000);
|
|
803
|
+
existingApp.ws.once("pong", () => {
|
|
804
|
+
clearTimeout(timeout);
|
|
805
|
+
pongResolve(true);
|
|
806
|
+
});
|
|
807
|
+
try {
|
|
808
|
+
existingApp.ws.ping();
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
clearTimeout(timeout);
|
|
812
|
+
pongResolve(false);
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
if (isAlive) {
|
|
816
|
+
resolve(`Already connected to ${device.title}`);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
// Ping failed — connection is dead, clean up
|
|
820
|
+
console.error(`[execbro] Existing connection to ${device.title} failed liveness check (no pong), reconnecting`);
|
|
821
|
+
recordConnectionEvent("liveness-failed", appKey, device.title, "no pong within 2000ms");
|
|
822
|
+
reconnectionSuppressed.add(appKey);
|
|
823
|
+
try {
|
|
824
|
+
existingApp.ws.terminate();
|
|
825
|
+
}
|
|
826
|
+
catch { /* ignore */ }
|
|
827
|
+
connectedApps.delete(appKey);
|
|
828
|
+
clearLastCDPMessageTime(appKey);
|
|
829
|
+
clearActiveSimulatorIfSource(appKey);
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
// WebSocket exists but not OPEN - clean up stale entry
|
|
833
|
+
console.error(`[execbro] Cleaning up stale connection for ${device.title} (state: ${getWebSocketStateName(existingApp.ws.readyState)})`);
|
|
834
|
+
connectedApps.delete(appKey);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
// Skip if this device is already connected on a different port
|
|
838
|
+
// (Metro's /json advertises all devices it can see, even ones built by other Metro servers)
|
|
839
|
+
const deviceName = device.deviceName || device.title;
|
|
840
|
+
for (const [existingKey, existingApp] of connectedApps.entries()) {
|
|
841
|
+
if (existingKey !== appKey && existingApp.ws.readyState === WebSocket.OPEN) {
|
|
842
|
+
const existingName = existingApp.deviceInfo.deviceName || existingApp.deviceInfo.title;
|
|
843
|
+
if (existingName === deviceName) {
|
|
844
|
+
resolve(`Skipped ${deviceName} (already connected on port ${existingApp.port})`);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
// Prevent concurrent connection attempts to the same device
|
|
850
|
+
if (connectionLocks.has(appKey)) {
|
|
851
|
+
resolve(`Connection already in progress for ${device.title}`);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
connectionLocks.add(appKey);
|
|
855
|
+
// Cancel any pending reconnection timer for this appKey
|
|
856
|
+
cancelReconnectionTimer(appKey);
|
|
857
|
+
// Save connection metadata for potential reconnection
|
|
858
|
+
saveConnectionMetadata(appKey, {
|
|
859
|
+
port,
|
|
860
|
+
deviceInfo: device,
|
|
861
|
+
webSocketUrl: device.webSocketDebuggerUrl
|
|
862
|
+
});
|
|
863
|
+
try {
|
|
864
|
+
const ws = await createWebSocketWithOriginFallback(device.webSocketDebuggerUrl);
|
|
865
|
+
// Register close/error handlers IMMEDIATELY — before any async work
|
|
866
|
+
// below (probeCdpAlive, Runtime.enable, simctl/adb lookups). Previously
|
|
867
|
+
// they were attached only at the end of the setup block, so any close
|
|
868
|
+
// event during that window was silently swallowed: the entry stayed
|
|
869
|
+
// in connectedApps, readyState flipped to CLOSED, but no reconnect
|
|
870
|
+
// was scheduled and no `closed` event was recorded.
|
|
871
|
+
let pingInterval = null;
|
|
872
|
+
let staleRejected = false;
|
|
873
|
+
const handleClose = () => {
|
|
874
|
+
if (staleRejected)
|
|
875
|
+
return; // probe-fail path handles its own cleanup
|
|
876
|
+
if (pingInterval) {
|
|
877
|
+
clearInterval(pingInterval);
|
|
878
|
+
pingInterval = null;
|
|
879
|
+
}
|
|
880
|
+
// Release connection lock if still held
|
|
881
|
+
connectionLocks.delete(appKey);
|
|
882
|
+
connectedApps.delete(appKey);
|
|
883
|
+
clearLastCDPMessageTime(appKey);
|
|
884
|
+
// Clear active simulator UDID if this connection set it
|
|
885
|
+
clearActiveSimulatorIfSource(appKey);
|
|
886
|
+
// Keep log and network buffers across reconnections
|
|
887
|
+
// They are only cleared on demand via clear_logs / clear_network tools
|
|
888
|
+
// Check if connection was stable before resetting attempts
|
|
889
|
+
const state = getConnectionState(appKey);
|
|
890
|
+
let wasStable = false;
|
|
891
|
+
if (state?.lastConnectedTime) {
|
|
892
|
+
const connectionDuration = Date.now() - state.lastConnectedTime.getTime();
|
|
893
|
+
wasStable = connectionDuration >= MIN_STABLE_CONNECTION_MS;
|
|
894
|
+
if (wasStable) {
|
|
895
|
+
// Connection was stable - reset attempts for fresh start
|
|
896
|
+
updateConnectionState(appKey, { reconnectionAttempts: 0 });
|
|
897
|
+
console.error(`[execbro] Connection was stable for ${Math.round(connectionDuration / 1000)}s, resetting reconnection attempts`);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
// Record the gap and trigger reconnection
|
|
901
|
+
recordConnectionGap(appKey, "Connection closed");
|
|
902
|
+
if (state) {
|
|
903
|
+
updateConnectionState(appKey, {
|
|
904
|
+
status: "disconnected",
|
|
905
|
+
lastDisconnectTime: new Date()
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
const uptimeMs = state?.lastConnectedTime ? Date.now() - state.lastConnectedTime.getTime() : 0;
|
|
909
|
+
const earlyPhase = state ? "" : " (before state init)";
|
|
910
|
+
console.error(`[execbro] Disconnected from ${device.title}${earlyPhase}`);
|
|
911
|
+
recordConnectionEvent("closed", appKey, device.title, `uptime ${formatDuration(uptimeMs)}${wasStable ? " (stable)" : " (unstable)"}${earlyPhase}`);
|
|
912
|
+
// Schedule auto-reconnection if enabled (skip if intentionally disconnected)
|
|
913
|
+
if (reconnectionConfig.enabled && !reconnectionSuppressed.has(appKey)) {
|
|
914
|
+
// If close fired before initConnectionState, scheduleReconnection
|
|
915
|
+
// would early-return (it requires existing state). Init it now
|
|
916
|
+
// so the reconnect loop can still run.
|
|
917
|
+
if (!state)
|
|
918
|
+
initConnectionState(appKey);
|
|
919
|
+
scheduleReconnection(appKey, reconnectionConfig);
|
|
920
|
+
}
|
|
921
|
+
else if (reconnectionSuppressed.has(appKey)) {
|
|
922
|
+
reconnectionSuppressed.delete(appKey);
|
|
923
|
+
console.error(`[execbro] Reconnection suppressed for ${device.title} (intentional disconnect)`);
|
|
924
|
+
recordConnectionEvent("reconnect-suppressed", appKey, device.title, "intentional disconnect");
|
|
925
|
+
}
|
|
926
|
+
else if (!reconnectionConfig.enabled) {
|
|
927
|
+
recordConnectionEvent("reconnect-suppressed", appKey, device.title, "reconnect disabled by caller");
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
ws.on("close", handleClose);
|
|
931
|
+
ws.on("error", (error) => {
|
|
932
|
+
console.error(`[execbro] WebSocket error for ${device.title}: ${error?.message || error}`);
|
|
933
|
+
});
|
|
934
|
+
// Verify the JS context is actually alive. Metro's /json can advertise
|
|
935
|
+
// zombie CDP pages that complete the WS handshake but no longer execute.
|
|
936
|
+
// A stale target here causes every downstream tool to time out or return
|
|
937
|
+
// degenerate state (e.g. __DEV__ === false on a debug bundle).
|
|
938
|
+
const alive = await probeCdpAlive(ws, PROBE_TIMEOUT_MS);
|
|
939
|
+
if (!alive) {
|
|
940
|
+
staleRejected = true;
|
|
941
|
+
ws.off("close", handleClose);
|
|
942
|
+
connectionLocks.delete(appKey);
|
|
943
|
+
try {
|
|
944
|
+
ws.terminate();
|
|
945
|
+
}
|
|
946
|
+
catch { /* ignore */ }
|
|
947
|
+
clearConnectionMetadata(appKey);
|
|
948
|
+
console.error(`[execbro] Rejecting stale CDP target for ${device.title} (no probe response)`);
|
|
949
|
+
recordConnectionEvent("stale-target", appKey, device.title, `no probe response within ${PROBE_TIMEOUT_MS}ms${isReconnection ? " (during reconnect)" : ""}`);
|
|
950
|
+
resolve(`Skipped ${device.deviceName || device.title} (stale CDP target — no response from JS context)`);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
// Connection established — run setup
|
|
954
|
+
connectionLocks.delete(appKey);
|
|
955
|
+
connectedApps.set(appKey, { ws, deviceInfo: device, port, platform: "android" });
|
|
956
|
+
markConnectionEstablished();
|
|
957
|
+
// Initialize or update connection state
|
|
958
|
+
// Note: We do NOT reset reconnectionAttempts here - that happens
|
|
959
|
+
// only when connection has been stable for MIN_STABLE_CONNECTION_MS
|
|
960
|
+
if (isReconnection) {
|
|
961
|
+
closeConnectionGap(appKey);
|
|
962
|
+
updateConnectionState(appKey, {
|
|
963
|
+
status: "connected",
|
|
964
|
+
lastConnectedTime: new Date()
|
|
965
|
+
// reconnectionAttempts NOT reset here - see ws.on("close") for stable connection check
|
|
966
|
+
});
|
|
967
|
+
// Reset context health for reconnection
|
|
968
|
+
initContextHealth(appKey);
|
|
969
|
+
console.error(`[execbro] Reconnected to ${device.title}`);
|
|
970
|
+
recordConnectionEvent("reconnect-success", appKey, device.title);
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
initConnectionState(appKey);
|
|
974
|
+
initContextHealth(appKey);
|
|
975
|
+
console.error(`[execbro] Connected to ${device.title}`);
|
|
976
|
+
recordConnectionEvent("connect-success", appKey, device.title);
|
|
977
|
+
}
|
|
978
|
+
// Enable Runtime domain to receive console messages
|
|
979
|
+
ws.send(JSON.stringify({
|
|
980
|
+
id: getNextMessageId(),
|
|
981
|
+
method: "Runtime.enable"
|
|
982
|
+
}));
|
|
983
|
+
// Also enable Log domain
|
|
984
|
+
ws.send(JSON.stringify({
|
|
985
|
+
id: getNextMessageId(),
|
|
986
|
+
method: "Log.enable"
|
|
987
|
+
}));
|
|
988
|
+
// Inject JS network interceptor (immediate capture, may fail if context not ready)
|
|
989
|
+
injectNetworkInterceptor(ws);
|
|
990
|
+
// Also try CDP Network.enable (takes priority if supported)
|
|
991
|
+
const networkEnableId = sendNetworkEnable(ws);
|
|
992
|
+
pendingNetworkEnableIds.add(networkEnableId);
|
|
993
|
+
// Resolve native identifiers from the device name in parallel:
|
|
994
|
+
// - iOS: simulator UDID via findSimulatorByName
|
|
995
|
+
// - Android: adb serial via getAdbIdForAvd (maps AVD name → emulator-NNNN)
|
|
996
|
+
// Both are best-effort; failures are swallowed. Identifiers enable
|
|
997
|
+
// automatic device scoping and power the registry-first resolver fast path.
|
|
998
|
+
if (device.deviceName) {
|
|
999
|
+
const [simulatorUdid, adbSerial] = await Promise.all([
|
|
1000
|
+
findSimulatorByName(device.deviceName).catch(() => null),
|
|
1001
|
+
getAdbIdForAvd(device.deviceName).catch(() => null)
|
|
1002
|
+
]);
|
|
1003
|
+
const connectedApp = connectedApps.get(appKey);
|
|
1004
|
+
if (connectedApp) {
|
|
1005
|
+
if (simulatorUdid) {
|
|
1006
|
+
setActiveSimulatorUdid(simulatorUdid, appKey);
|
|
1007
|
+
connectedApp.platform = "ios";
|
|
1008
|
+
connectedApp.simulatorUdid = simulatorUdid;
|
|
1009
|
+
console.error(`[execbro] Linked to iOS simulator: ${simulatorUdid}`);
|
|
1010
|
+
}
|
|
1011
|
+
if (adbSerial) {
|
|
1012
|
+
connectedApp.platform = "android";
|
|
1013
|
+
connectedApp.adbSerial = adbSerial;
|
|
1014
|
+
console.error(`[execbro] Linked to Android emulator: ${adbSerial}`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
// Fire-and-forget app detection (500ms delayed, non-blocking)
|
|
1019
|
+
const appForDetection = connectedApps.get(appKey);
|
|
1020
|
+
if (appForDetection) {
|
|
1021
|
+
scheduleAppDetection(appForDetection);
|
|
1022
|
+
}
|
|
1023
|
+
// Start periodic SDK probe. When __RN_AI_DEVTOOLS__ is detected,
|
|
1024
|
+
// app.sdkPresent flips to true and CDP/JS-interceptor buffer
|
|
1025
|
+
// writes are suppressed (SDK becomes the single source).
|
|
1026
|
+
startSdkProbeLoop(ws, appKey);
|
|
1027
|
+
// Start WebSocket ping/pong keepalive to detect dead connections
|
|
1028
|
+
// (especially important for physical devices over Wi-Fi).
|
|
1029
|
+
// pingInterval was declared up-top so the early-registered close
|
|
1030
|
+
// handler can clear it; close/error handlers are already attached.
|
|
1031
|
+
let pongReceived = true;
|
|
1032
|
+
pingInterval = setInterval(() => {
|
|
1033
|
+
if (!pongReceived) {
|
|
1034
|
+
console.error(`[execbro] No pong from ${device.title}, terminating connection`);
|
|
1035
|
+
if (pingInterval) {
|
|
1036
|
+
clearInterval(pingInterval);
|
|
1037
|
+
pingInterval = null;
|
|
1038
|
+
}
|
|
1039
|
+
ws.terminate();
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
pongReceived = false;
|
|
1043
|
+
ws.ping();
|
|
1044
|
+
}, PING_INTERVAL_MS);
|
|
1045
|
+
ws.on("pong", () => {
|
|
1046
|
+
pongReceived = true;
|
|
1047
|
+
});
|
|
1048
|
+
ws.on("message", (data) => {
|
|
1049
|
+
try {
|
|
1050
|
+
const message = JSON.parse(data.toString());
|
|
1051
|
+
handleCDPMessage(message, device, ws);
|
|
1052
|
+
}
|
|
1053
|
+
catch {
|
|
1054
|
+
// Ignore non-JSON messages
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
resolve(`Connected to ${device.title} (${device.deviceName})`);
|
|
1058
|
+
}
|
|
1059
|
+
catch (error) {
|
|
1060
|
+
// Connection failed (both with and without Origin header)
|
|
1061
|
+
connectionLocks.delete(appKey);
|
|
1062
|
+
cancelReconnectionTimer(appKey);
|
|
1063
|
+
connectedApps.delete(appKey);
|
|
1064
|
+
clearLastCDPMessageTime(appKey);
|
|
1065
|
+
clearActiveSimulatorIfSource(appKey);
|
|
1066
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1067
|
+
if (!isReconnection) {
|
|
1068
|
+
reject(`Failed to connect to ${device.title}: ${errorMsg}`);
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
console.error(`[execbro] Reconnection error: ${errorMsg}`);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Schedule a reconnection attempt with exponential backoff
|
|
1078
|
+
*/
|
|
1079
|
+
function scheduleReconnection(appKey, config = DEFAULT_RECONNECTION_CONFIG) {
|
|
1080
|
+
const state = getConnectionState(appKey);
|
|
1081
|
+
if (!state)
|
|
1082
|
+
return;
|
|
1083
|
+
const meta = getConnectionMetadata(appKey);
|
|
1084
|
+
const deviceTitle = meta?.deviceInfo.title;
|
|
1085
|
+
const attempts = state.reconnectionAttempts;
|
|
1086
|
+
if (attempts >= config.maxAttempts) {
|
|
1087
|
+
console.error(`[execbro] Max reconnection attempts (${config.maxAttempts}) reached for ${appKey}`);
|
|
1088
|
+
updateConnectionState(appKey, { status: "disconnected" });
|
|
1089
|
+
recordConnectionEvent("max-attempts-reached", appKey, deviceTitle, `${config.maxAttempts} attempts exhausted`);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
const delay = calculateBackoffDelay(attempts, config);
|
|
1093
|
+
console.error(`[execbro] Scheduling reconnection attempt ${attempts + 1}/${config.maxAttempts} in ${delay}ms`);
|
|
1094
|
+
recordConnectionEvent("reconnect-scheduled", appKey, deviceTitle, `attempt ${attempts + 1}/${config.maxAttempts} in ${delay}ms`);
|
|
1095
|
+
updateConnectionState(appKey, {
|
|
1096
|
+
status: "reconnecting",
|
|
1097
|
+
reconnectionAttempts: attempts + 1
|
|
1098
|
+
});
|
|
1099
|
+
const timer = setTimeout(() => {
|
|
1100
|
+
attemptReconnection(appKey, config);
|
|
1101
|
+
}, delay);
|
|
1102
|
+
saveReconnectionTimer(appKey, timer);
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Attempt to reconnect to a previously connected device
|
|
1106
|
+
*/
|
|
1107
|
+
async function attemptReconnection(appKey, config = DEFAULT_RECONNECTION_CONFIG) {
|
|
1108
|
+
const metadata = getConnectionMetadata(appKey);
|
|
1109
|
+
if (!metadata) {
|
|
1110
|
+
console.error(`[execbro] No metadata for reconnection: ${appKey}`);
|
|
1111
|
+
recordConnectionEvent("reconnect-failed", appKey, undefined, "no metadata");
|
|
1112
|
+
return false;
|
|
1113
|
+
}
|
|
1114
|
+
const deviceTitle = metadata.deviceInfo.title;
|
|
1115
|
+
recordConnectionEvent("reconnect-attempt", appKey, deviceTitle);
|
|
1116
|
+
// Quick check: is Metro even running on this port?
|
|
1117
|
+
const { scanMetroPorts } = await import("./metro.js");
|
|
1118
|
+
const openPorts = await scanMetroPorts(metadata.port, metadata.port);
|
|
1119
|
+
if (openPorts.length === 0) {
|
|
1120
|
+
console.error(`[execbro] Metro not running on port ${metadata.port}, stopping reconnection for ${appKey}`);
|
|
1121
|
+
updateConnectionState(appKey, { status: "disconnected" });
|
|
1122
|
+
recordConnectionEvent("metro-down", appKey, deviceTitle, `port ${metadata.port} not open`);
|
|
1123
|
+
return false;
|
|
1124
|
+
}
|
|
1125
|
+
try {
|
|
1126
|
+
// Re-fetch devices to get fresh WebSocket URL (may have changed)
|
|
1127
|
+
const devices = await fetchDevices(metadata.port);
|
|
1128
|
+
// Try to find the same device first, otherwise select main device
|
|
1129
|
+
const device = devices.find(d => d.id === metadata.deviceInfo.id)
|
|
1130
|
+
|| selectMainDevice(devices);
|
|
1131
|
+
if (!device) {
|
|
1132
|
+
console.error(`[execbro] Device no longer available for ${appKey}`);
|
|
1133
|
+
recordConnectionEvent("reconnect-failed", appKey, deviceTitle, "device no longer advertised by Metro");
|
|
1134
|
+
// Schedule next attempt
|
|
1135
|
+
scheduleReconnection(appKey, config);
|
|
1136
|
+
return false;
|
|
1137
|
+
}
|
|
1138
|
+
await connectToDevice(device, metadata.port, { isReconnection: true, reconnectionConfig: config });
|
|
1139
|
+
return true;
|
|
1140
|
+
}
|
|
1141
|
+
catch (error) {
|
|
1142
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1143
|
+
console.error(`[execbro] Reconnection failed: ${msg}`);
|
|
1144
|
+
recordConnectionEvent("reconnect-failed", appKey, deviceTitle, msg);
|
|
1145
|
+
// Schedule next attempt
|
|
1146
|
+
scheduleReconnection(appKey, config);
|
|
1147
|
+
return false;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
// Get list of connected apps
|
|
1151
|
+
export function getConnectedApps() {
|
|
1152
|
+
return Array.from(connectedApps.entries()).map(([key, app]) => ({
|
|
1153
|
+
key,
|
|
1154
|
+
app,
|
|
1155
|
+
isConnected: app.ws.readyState === WebSocket.OPEN
|
|
1156
|
+
}));
|
|
1157
|
+
}
|
|
1158
|
+
// Get first connected app with an OPEN WebSocket (or null if none)
|
|
1159
|
+
export function getFirstConnectedApp() {
|
|
1160
|
+
// Find first app with OPEN WebSocket, cleaning up stale entries
|
|
1161
|
+
for (const [key, app] of connectedApps.entries()) {
|
|
1162
|
+
if (app.ws.readyState === WebSocket.OPEN) {
|
|
1163
|
+
return app;
|
|
1164
|
+
}
|
|
1165
|
+
// Clean up stale entry
|
|
1166
|
+
console.error(`[execbro] Cleaning up stale connection in getFirstConnectedApp: ${key} (state: ${getWebSocketStateName(app.ws.readyState)})`);
|
|
1167
|
+
connectedApps.delete(key);
|
|
1168
|
+
}
|
|
1169
|
+
return null;
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Find the connected RN app running on a specific iOS simulator (by UDID).
|
|
1173
|
+
* Returns null if no connected app matches — caller should skip any CDP-based
|
|
1174
|
+
* enrichment to avoid pulling data from a different simulator's app.
|
|
1175
|
+
*/
|
|
1176
|
+
export function getConnectedAppBySimulatorUdid(udid) {
|
|
1177
|
+
for (const [key, app] of connectedApps.entries()) {
|
|
1178
|
+
if (app.ws.readyState !== WebSocket.OPEN) {
|
|
1179
|
+
connectedApps.delete(key);
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
if (app.platform === "ios" && app.simulatorUdid === udid) {
|
|
1183
|
+
return app;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
return null;
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Find the connected RN app running on a specific Android device.
|
|
1190
|
+
* Falls back to the sole connected Android app when `deviceId` is not provided
|
|
1191
|
+
* or cannot be resolved via deviceName substring match — this keeps the common
|
|
1192
|
+
* single-device case working while preventing cross-device data leakage when
|
|
1193
|
+
* multiple Android apps are connected.
|
|
1194
|
+
*/
|
|
1195
|
+
export function getConnectedAppByAndroidDeviceId(deviceId) {
|
|
1196
|
+
const androidApps = [];
|
|
1197
|
+
for (const [key, app] of connectedApps.entries()) {
|
|
1198
|
+
if (app.ws.readyState !== WebSocket.OPEN) {
|
|
1199
|
+
connectedApps.delete(key);
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
if (app.platform === "android")
|
|
1203
|
+
androidApps.push(app);
|
|
1204
|
+
}
|
|
1205
|
+
if (androidApps.length === 0)
|
|
1206
|
+
return null;
|
|
1207
|
+
if (androidApps.length === 1)
|
|
1208
|
+
return androidApps[0];
|
|
1209
|
+
if (deviceId) {
|
|
1210
|
+
const lower = deviceId.toLowerCase();
|
|
1211
|
+
const match = androidApps.find(a => {
|
|
1212
|
+
const name = (a.deviceInfo.deviceName || a.deviceInfo.title || "").toLowerCase();
|
|
1213
|
+
return name.includes(lower) || lower.includes(name);
|
|
1214
|
+
});
|
|
1215
|
+
if (match)
|
|
1216
|
+
return match;
|
|
1217
|
+
}
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Lowercase and strip separators (whitespace, `_`, `-`) so substring matches
|
|
1222
|
+
* survive punctuation drift between caller input and the device's reported
|
|
1223
|
+
* identifier (e.g. "SM_A356N" vs "SM-A356N - 15 - API 35").
|
|
1224
|
+
*/
|
|
1225
|
+
function normalizeDeviceId(value) {
|
|
1226
|
+
if (!value)
|
|
1227
|
+
return "";
|
|
1228
|
+
return value.toLowerCase().replace(/[\s_\-]+/g, "");
|
|
1229
|
+
}
|
|
1230
|
+
export function getConnectedAppByDevice(device) {
|
|
1231
|
+
if (!device) {
|
|
1232
|
+
return getFirstConnectedApp();
|
|
1233
|
+
}
|
|
1234
|
+
const lowerDevice = device.toLowerCase();
|
|
1235
|
+
const normDevice = normalizeDeviceId(device);
|
|
1236
|
+
const matches = [];
|
|
1237
|
+
const allOpenNames = [];
|
|
1238
|
+
for (const [key, app] of connectedApps.entries()) {
|
|
1239
|
+
if (app.ws.readyState !== WebSocket.OPEN) {
|
|
1240
|
+
connectedApps.delete(key);
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "";
|
|
1244
|
+
allOpenNames.push(deviceName);
|
|
1245
|
+
// Match against deviceName + the underlying hardware identifiers, with
|
|
1246
|
+
// separator-insensitive normalization so "SM_A356N" finds
|
|
1247
|
+
// "SM-A356N - 15 - API 35" and "emulator-5554" finds the Android app
|
|
1248
|
+
// attached to that serial.
|
|
1249
|
+
const haystacks = [
|
|
1250
|
+
normalizeDeviceId(deviceName),
|
|
1251
|
+
normalizeDeviceId(app.simulatorUdid),
|
|
1252
|
+
normalizeDeviceId(app.adbSerial)
|
|
1253
|
+
].filter((s) => s.length > 0);
|
|
1254
|
+
if (haystacks.some((h) => h.includes(normDevice))) {
|
|
1255
|
+
matches.push(app);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
// Prefer exact (case-insensitive) match when present — disambiguates
|
|
1259
|
+
// "iPhone 17 Pro" from "iPhone 17 Pro Max" when both are connected.
|
|
1260
|
+
const exact = matches.find(a => {
|
|
1261
|
+
const name = a.deviceInfo.deviceName || a.deviceInfo.title || "";
|
|
1262
|
+
return name.toLowerCase() === lowerDevice;
|
|
1263
|
+
});
|
|
1264
|
+
if (exact)
|
|
1265
|
+
return exact;
|
|
1266
|
+
if (matches.length === 1) {
|
|
1267
|
+
return matches[0];
|
|
1268
|
+
}
|
|
1269
|
+
if (matches.length > 1) {
|
|
1270
|
+
const names = matches.map(a => a.deviceInfo.deviceName || a.deviceInfo.title).join(", ");
|
|
1271
|
+
throw new UserInputError(`Multiple devices match "${device}": ${names}. Be more specific (use the full device name for an exact match).`);
|
|
1272
|
+
}
|
|
1273
|
+
if (allOpenNames.length === 0) {
|
|
1274
|
+
throw new UserInputError(`No connected device matches "${device}". No devices are currently connected — run scan_metro to discover and connect to Metro servers.`);
|
|
1275
|
+
}
|
|
1276
|
+
const quoted = allOpenNames.map(n => `"${n}"`).join(", ");
|
|
1277
|
+
throw new UserInputError(`No connected device matches "${device}". Connected devices: ${quoted}. Retry with one of these names (substring match, case-insensitive).`);
|
|
1278
|
+
}
|
|
1279
|
+
// Check if any app is connected with an OPEN WebSocket
|
|
1280
|
+
export function hasConnectedApp() {
|
|
1281
|
+
for (const [, app] of connectedApps.entries()) {
|
|
1282
|
+
if (app.ws.readyState === WebSocket.OPEN) {
|
|
1283
|
+
return true;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
return false;
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Run a quick health check to verify the page context is responsive
|
|
1290
|
+
* Returns true if the context can execute code, false otherwise
|
|
1291
|
+
*/
|
|
1292
|
+
export async function runQuickHealthCheck(app) {
|
|
1293
|
+
const HEALTH_CHECK_TIMEOUT = 2000;
|
|
1294
|
+
const messageId = getNextMessageId();
|
|
1295
|
+
return new Promise((resolve) => {
|
|
1296
|
+
const timeoutId = setTimeout(() => {
|
|
1297
|
+
pendingExecutions.delete(messageId);
|
|
1298
|
+
resolve(false);
|
|
1299
|
+
}, HEALTH_CHECK_TIMEOUT);
|
|
1300
|
+
pendingExecutions.set(messageId, {
|
|
1301
|
+
resolve: (result) => {
|
|
1302
|
+
clearTimeout(timeoutId);
|
|
1303
|
+
pendingExecutions.delete(messageId);
|
|
1304
|
+
// Update context health tracking
|
|
1305
|
+
const appKey = findAppKeyForDevice(app.deviceInfo);
|
|
1306
|
+
if (appKey) {
|
|
1307
|
+
updateContextHealth(appKey, {
|
|
1308
|
+
lastHealthCheck: new Date(),
|
|
1309
|
+
lastHealthCheckSuccess: result.success,
|
|
1310
|
+
isStale: !result.success,
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
resolve(result.success);
|
|
1314
|
+
},
|
|
1315
|
+
timeoutId,
|
|
1316
|
+
});
|
|
1317
|
+
try {
|
|
1318
|
+
app.ws.send(JSON.stringify({
|
|
1319
|
+
id: messageId,
|
|
1320
|
+
method: "Runtime.evaluate",
|
|
1321
|
+
params: { expression: "1+1", returnByValue: true },
|
|
1322
|
+
}));
|
|
1323
|
+
}
|
|
1324
|
+
catch {
|
|
1325
|
+
clearTimeout(timeoutId);
|
|
1326
|
+
pendingExecutions.delete(messageId);
|
|
1327
|
+
resolve(false);
|
|
1328
|
+
}
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
const LOG_PIPELINE_MARKER_PREFIX = "__rn_devtools_health_";
|
|
1332
|
+
/**
|
|
1333
|
+
* Inject a sentinel console.log via CDP and wait for it to appear in the log buffer.
|
|
1334
|
+
* Returns true if the marker arrives within TIMEOUT_MS.
|
|
1335
|
+
*/
|
|
1336
|
+
async function sendAndWaitForMarker(app, buffer) {
|
|
1337
|
+
const marker = `${LOG_PIPELINE_MARKER_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1338
|
+
const TIMEOUT_MS = 3000;
|
|
1339
|
+
try {
|
|
1340
|
+
app.ws.send(JSON.stringify({
|
|
1341
|
+
id: getNextMessageId(),
|
|
1342
|
+
method: "Runtime.evaluate",
|
|
1343
|
+
params: { expression: `console.log("${marker}")`, returnByValue: true },
|
|
1344
|
+
}));
|
|
1345
|
+
}
|
|
1346
|
+
catch {
|
|
1347
|
+
return false;
|
|
1348
|
+
}
|
|
1349
|
+
// Poll the buffer for the marker (check every 50ms, up to TIMEOUT_MS)
|
|
1350
|
+
const startTime = Date.now();
|
|
1351
|
+
while (Date.now() - startTime < TIMEOUT_MS) {
|
|
1352
|
+
if (buffer.getAll().some(entry => entry.message.includes(marker))) {
|
|
1353
|
+
buffer.removeByText(marker);
|
|
1354
|
+
return true;
|
|
1355
|
+
}
|
|
1356
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1357
|
+
}
|
|
1358
|
+
return false;
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* End-to-end log pipeline verification with automatic recovery.
|
|
1362
|
+
*
|
|
1363
|
+
* 1. Injects a sentinel console.log via CDP and verifies it appears in the buffer.
|
|
1364
|
+
* 2. If the marker doesn't arrive, attempts recovery:
|
|
1365
|
+
* a. Re-sends Runtime.enable + Log.enable (fixes missing domain subscription)
|
|
1366
|
+
* b. Retries the marker once
|
|
1367
|
+
* 3. If still broken, force-reconnects via ensureConnection and retries.
|
|
1368
|
+
*
|
|
1369
|
+
* Returns { ok, recovered, message } — the caller decides what to show.
|
|
1370
|
+
*/
|
|
1371
|
+
export async function verifyLogPipeline(app) {
|
|
1372
|
+
const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
|
|
1373
|
+
const buffer = getLogBuffer(deviceName);
|
|
1374
|
+
// --- Attempt 1: quick check ---
|
|
1375
|
+
if (await sendAndWaitForMarker(app, buffer)) {
|
|
1376
|
+
return { ok: true, recovered: false, message: null };
|
|
1377
|
+
}
|
|
1378
|
+
// --- Attempt 2: re-enable Runtime/Log domains and retry ---
|
|
1379
|
+
try {
|
|
1380
|
+
app.ws.send(JSON.stringify({ id: getNextMessageId(), method: "Runtime.enable" }));
|
|
1381
|
+
app.ws.send(JSON.stringify({ id: getNextMessageId(), method: "Log.enable" }));
|
|
1382
|
+
}
|
|
1383
|
+
catch {
|
|
1384
|
+
// WS send failed — fall through to reconnect
|
|
1385
|
+
}
|
|
1386
|
+
// Brief pause for domain activation
|
|
1387
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
1388
|
+
if (await sendAndWaitForMarker(app, buffer)) {
|
|
1389
|
+
return { ok: true, recovered: true, message: "[CONNECTION] Log pipeline was stale — re-enabled CDP domains. Logs are flowing again." };
|
|
1390
|
+
}
|
|
1391
|
+
// --- Attempt 3: force reconnect and retry ---
|
|
1392
|
+
const appKey = `${app.port}-${app.deviceInfo.id}`;
|
|
1393
|
+
const port = app.port;
|
|
1394
|
+
cancelReconnectionTimer(appKey);
|
|
1395
|
+
try {
|
|
1396
|
+
app.ws.close();
|
|
1397
|
+
}
|
|
1398
|
+
catch { /* ignore */ }
|
|
1399
|
+
connectedApps.delete(appKey);
|
|
1400
|
+
const result = await ensureConnection({ port, forceRefresh: false, healthCheck: true });
|
|
1401
|
+
if (!result.connected || !result.healthCheckPassed) {
|
|
1402
|
+
return {
|
|
1403
|
+
ok: false,
|
|
1404
|
+
recovered: false,
|
|
1405
|
+
message: "[CONNECTION] Log pipeline broken and reconnection failed. Ensure the app is running, then call scan_metro.",
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
// Wait for the new connection to settle
|
|
1409
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1410
|
+
// Get the fresh app reference after reconnection
|
|
1411
|
+
const freshApp = getFirstConnectedApp();
|
|
1412
|
+
if (!freshApp) {
|
|
1413
|
+
return {
|
|
1414
|
+
ok: false,
|
|
1415
|
+
recovered: false,
|
|
1416
|
+
message: "[CONNECTION] Reconnected but no app available. Call scan_metro.",
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
const freshDeviceName = freshApp.deviceInfo.deviceName || freshApp.deviceInfo.title || "unknown";
|
|
1420
|
+
const freshBuffer = getLogBuffer(freshDeviceName);
|
|
1421
|
+
if (await sendAndWaitForMarker(freshApp, freshBuffer)) {
|
|
1422
|
+
return {
|
|
1423
|
+
ok: true,
|
|
1424
|
+
recovered: true,
|
|
1425
|
+
message: "[CONNECTION] Log pipeline was broken — reconnected and verified. Logs are flowing again.",
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
return {
|
|
1429
|
+
ok: false,
|
|
1430
|
+
recovered: false,
|
|
1431
|
+
message: "[WARNING] Log pipeline verification failed after reconnection. Console events are not reaching the buffer. Try reload_app or scan_metro.",
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Check if a log message is a health check marker (for filtering from output).
|
|
1436
|
+
*/
|
|
1437
|
+
export function isHealthCheckMarker(message) {
|
|
1438
|
+
return message.includes(LOG_PIPELINE_MARKER_PREFIX);
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Find the first available Metro port
|
|
1442
|
+
*/
|
|
1443
|
+
async function findFirstMetroPort() {
|
|
1444
|
+
const ports = await scanMetroPorts();
|
|
1445
|
+
return ports.length > 0 ? ports[0] : null;
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Ensure a healthy connection to a React Native app
|
|
1449
|
+
* This will verify or establish a connection, optionally running a health check
|
|
1450
|
+
*/
|
|
1451
|
+
export async function ensureConnection(options = {}) {
|
|
1452
|
+
const { port, healthCheck = true, forceRefresh = false } = options;
|
|
1453
|
+
let wasReconnected = false;
|
|
1454
|
+
// forceRefresh: close every currently-connected app, then fall through to
|
|
1455
|
+
// single-device fresh connect. Full multi-device re-discovery is scan_metro's
|
|
1456
|
+
// job — ensureConnection only fresh-connects one when nothing is open.
|
|
1457
|
+
if (forceRefresh) {
|
|
1458
|
+
for (const { key, app } of getConnectedApps()) {
|
|
1459
|
+
cancelReconnectionTimer(key);
|
|
1460
|
+
try {
|
|
1461
|
+
app.ws.close();
|
|
1462
|
+
}
|
|
1463
|
+
catch { /* ignore */ }
|
|
1464
|
+
connectedApps.delete(key);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
let openApps = getConnectedApps()
|
|
1468
|
+
.filter(a => a.isConnected)
|
|
1469
|
+
.map(a => a.app);
|
|
1470
|
+
// Nothing connected — establish a single-device connection from scratch.
|
|
1471
|
+
if (openApps.length === 0) {
|
|
1472
|
+
const targetPort = port ?? await findFirstMetroPort();
|
|
1473
|
+
if (!targetPort) {
|
|
1474
|
+
return {
|
|
1475
|
+
connected: false,
|
|
1476
|
+
wasReconnected: false,
|
|
1477
|
+
healthCheckPassed: false,
|
|
1478
|
+
connectionInfos: [],
|
|
1479
|
+
error: "No Metro server found. Make sure Metro bundler is running.",
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
const devices = await fetchDevices(targetPort);
|
|
1483
|
+
const mainDevice = selectMainDevice(devices);
|
|
1484
|
+
if (!mainDevice) {
|
|
1485
|
+
return {
|
|
1486
|
+
connected: false,
|
|
1487
|
+
wasReconnected: false,
|
|
1488
|
+
healthCheckPassed: false,
|
|
1489
|
+
connectionInfos: [],
|
|
1490
|
+
error: `No debuggable devices found on port ${targetPort}. Make sure the app is running.`,
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
try {
|
|
1494
|
+
// Clear any prior reconnection suppression — without this a stale
|
|
1495
|
+
// suppression entry from a previous disconnect_metro keeps the
|
|
1496
|
+
// close-handler from auto-recovering if Metro drops the WS just
|
|
1497
|
+
// after probe.
|
|
1498
|
+
clearReconnectionSuppression();
|
|
1499
|
+
const connectResult = await connectToDevice(mainDevice, targetPort);
|
|
1500
|
+
wasReconnected = true;
|
|
1501
|
+
if (connectResult.includes("stale CDP target")) {
|
|
1502
|
+
return {
|
|
1503
|
+
connected: false,
|
|
1504
|
+
wasReconnected: false,
|
|
1505
|
+
healthCheckPassed: false,
|
|
1506
|
+
connectionInfos: [],
|
|
1507
|
+
error: `${connectResult}. The CDP page advertised by Metro is no longer responsive — restart the React Native app, then retry.`,
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
// Race window: settle briefly and retry getting the open app list.
|
|
1511
|
+
for (let i = 0; i < 5 && openApps.length === 0; i++) {
|
|
1512
|
+
if (i > 0)
|
|
1513
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
1514
|
+
openApps = getConnectedApps().filter(a => a.isConnected).map(a => a.app);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
catch (error) {
|
|
1518
|
+
let errorMessage;
|
|
1519
|
+
if (error instanceof Error) {
|
|
1520
|
+
errorMessage = error.message;
|
|
1521
|
+
}
|
|
1522
|
+
else if (error !== undefined && error !== null) {
|
|
1523
|
+
errorMessage = String(error);
|
|
1524
|
+
}
|
|
1525
|
+
else {
|
|
1526
|
+
errorMessage = "WebSocket connection failed with no error details";
|
|
1527
|
+
}
|
|
1528
|
+
return {
|
|
1529
|
+
connected: false,
|
|
1530
|
+
wasReconnected: false,
|
|
1531
|
+
healthCheckPassed: false,
|
|
1532
|
+
connectionInfos: [],
|
|
1533
|
+
error: `Connection failed: ${errorMessage}`,
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
if (openApps.length === 0) {
|
|
1538
|
+
return {
|
|
1539
|
+
connected: false,
|
|
1540
|
+
wasReconnected: false,
|
|
1541
|
+
healthCheckPassed: false,
|
|
1542
|
+
connectionInfos: [],
|
|
1543
|
+
error: "Connection succeeded but app is not available",
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
// Per-device health-check; reconnect failed devices individually so a dead
|
|
1547
|
+
// Android doesn't take down a healthy iOS report and vice versa.
|
|
1548
|
+
const perApp = [];
|
|
1549
|
+
for (const candidate of openApps) {
|
|
1550
|
+
let app = candidate;
|
|
1551
|
+
let healthy = true;
|
|
1552
|
+
if (healthCheck) {
|
|
1553
|
+
healthy = await runQuickHealthCheck(app);
|
|
1554
|
+
if (!healthy && !wasReconnected) {
|
|
1555
|
+
const failedTitle = app.deviceInfo.title;
|
|
1556
|
+
console.error(`[execbro] Health check failed for ${failedTitle}, attempting reconnection...`);
|
|
1557
|
+
const appKey = `${app.port}-${app.deviceInfo.id}`;
|
|
1558
|
+
const appPort = app.port;
|
|
1559
|
+
cancelReconnectionTimer(appKey);
|
|
1560
|
+
try {
|
|
1561
|
+
app.ws.close();
|
|
1562
|
+
}
|
|
1563
|
+
catch { /* ignore */ }
|
|
1564
|
+
connectedApps.delete(appKey);
|
|
1565
|
+
const devices = await fetchDevices(appPort);
|
|
1566
|
+
const mainDevice = selectMainDevice(devices);
|
|
1567
|
+
if (mainDevice) {
|
|
1568
|
+
try {
|
|
1569
|
+
await connectToDevice(mainDevice, appPort);
|
|
1570
|
+
wasReconnected = true;
|
|
1571
|
+
const fresh = getConnectedApps()
|
|
1572
|
+
.filter(a => a.isConnected)
|
|
1573
|
+
.map(a => a.app);
|
|
1574
|
+
const replacement = fresh.find(a => a.deviceInfo.id === mainDevice.id);
|
|
1575
|
+
if (replacement) {
|
|
1576
|
+
app = replacement;
|
|
1577
|
+
healthy = await runQuickHealthCheck(app);
|
|
1578
|
+
}
|
|
1579
|
+
else {
|
|
1580
|
+
healthy = false;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
catch {
|
|
1584
|
+
healthy = false;
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
else {
|
|
1588
|
+
healthy = false;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
perApp.push({ app, healthy });
|
|
1593
|
+
}
|
|
1594
|
+
const connectionInfos = perApp.map(({ app, healthy }) => {
|
|
1595
|
+
const appKey = `${app.port}-${app.deviceInfo.id}`;
|
|
1596
|
+
const connectionState = getConnectionState(appKey);
|
|
1597
|
+
const contextHealth = getContextHealth(appKey);
|
|
1598
|
+
let uptime = "unknown";
|
|
1599
|
+
if (connectionState?.lastConnectedTime) {
|
|
1600
|
+
uptime = formatDuration(Date.now() - connectionState.lastConnectedTime.getTime());
|
|
1601
|
+
}
|
|
1602
|
+
return {
|
|
1603
|
+
deviceName: app.deviceInfo.deviceName || app.deviceInfo.title,
|
|
1604
|
+
deviceTitle: app.deviceInfo.title,
|
|
1605
|
+
platform: app.platform,
|
|
1606
|
+
port: app.port,
|
|
1607
|
+
uptime,
|
|
1608
|
+
contextId: contextHealth?.contextId ?? null,
|
|
1609
|
+
healthCheckPassed: healthy,
|
|
1610
|
+
};
|
|
1611
|
+
});
|
|
1612
|
+
const anyOpen = perApp.some(r => r.app.ws.readyState === WebSocket.OPEN);
|
|
1613
|
+
const allHealthy = perApp.every(r => r.healthy);
|
|
1614
|
+
return {
|
|
1615
|
+
connected: anyOpen,
|
|
1616
|
+
wasReconnected,
|
|
1617
|
+
healthCheckPassed: allHealthy,
|
|
1618
|
+
connectionInfos,
|
|
1619
|
+
...(!anyOpen && {
|
|
1620
|
+
error: "App became unavailable after reconnection attempt. Try running scan_metro then ensure_connection."
|
|
1621
|
+
}),
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
export function getPassiveConnectionStatus(targetAppKey) {
|
|
1625
|
+
if (targetAppKey) {
|
|
1626
|
+
// Check a specific device's connection
|
|
1627
|
+
const app = connectedApps.get(targetAppKey);
|
|
1628
|
+
if (!app || app.ws.readyState !== WebSocket.OPEN) {
|
|
1629
|
+
return { connected: false, needsPing: false, reason: "no_connection" };
|
|
1630
|
+
}
|
|
1631
|
+
const health = getContextHealth(targetAppKey);
|
|
1632
|
+
if (health?.isStale) {
|
|
1633
|
+
return { connected: false, needsPing: false, reason: "context_stale" };
|
|
1634
|
+
}
|
|
1635
|
+
const lastMessage = getLastCDPMessageTime(targetAppKey);
|
|
1636
|
+
if (!lastMessage) {
|
|
1637
|
+
return { connected: false, needsPing: false, reason: "no_activity" };
|
|
1638
|
+
}
|
|
1639
|
+
const elapsed = Date.now() - lastMessage.getTime();
|
|
1640
|
+
if (elapsed > STALE_ACTIVITY_THRESHOLD_MS) {
|
|
1641
|
+
return { connected: true, needsPing: true, reason: "activity_stale" };
|
|
1642
|
+
}
|
|
1643
|
+
return { connected: true, needsPing: false, reason: "ok" };
|
|
1644
|
+
}
|
|
1645
|
+
// Default: check first connected app (backwards compatible)
|
|
1646
|
+
if (!hasConnectedApp()) {
|
|
1647
|
+
return { connected: false, needsPing: false, reason: "no_connection" };
|
|
1648
|
+
}
|
|
1649
|
+
const app = getFirstConnectedApp();
|
|
1650
|
+
if (app) {
|
|
1651
|
+
const appKey = `${app.port}-${app.deviceInfo.id}`;
|
|
1652
|
+
const health = getContextHealth(appKey);
|
|
1653
|
+
if (health?.isStale) {
|
|
1654
|
+
return { connected: false, needsPing: false, reason: "context_stale" };
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
const lastMessage = getLastCDPMessageTime();
|
|
1658
|
+
if (!lastMessage) {
|
|
1659
|
+
return { connected: false, needsPing: false, reason: "no_activity" };
|
|
1660
|
+
}
|
|
1661
|
+
const elapsed = Date.now() - lastMessage.getTime();
|
|
1662
|
+
if (elapsed > STALE_ACTIVITY_THRESHOLD_MS) {
|
|
1663
|
+
return { connected: true, needsPing: true, reason: "activity_stale" };
|
|
1664
|
+
}
|
|
1665
|
+
return { connected: true, needsPing: false, reason: "ok" };
|
|
1666
|
+
}
|
|
1667
|
+
export async function checkAndEnsureConnection(device) {
|
|
1668
|
+
// Resolve targeted device to appKey if specified
|
|
1669
|
+
let targetAppKey;
|
|
1670
|
+
let targetApp = null;
|
|
1671
|
+
if (device) {
|
|
1672
|
+
try {
|
|
1673
|
+
targetApp = getConnectedAppByDevice(device);
|
|
1674
|
+
if (targetApp) {
|
|
1675
|
+
targetAppKey = `${targetApp.port}-${targetApp.deviceInfo.id}`;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
catch {
|
|
1679
|
+
// Device not found — fall through to reconnection
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
const passive = getPassiveConnectionStatus(targetAppKey);
|
|
1683
|
+
if (passive.connected && !passive.needsPing) {
|
|
1684
|
+
return { connected: true, wasReconnected: false, message: null };
|
|
1685
|
+
}
|
|
1686
|
+
if (passive.connected && passive.needsPing) {
|
|
1687
|
+
const app = targetApp ?? getFirstConnectedApp();
|
|
1688
|
+
if (app) {
|
|
1689
|
+
const healthy = await runQuickHealthCheck(app);
|
|
1690
|
+
if (healthy) {
|
|
1691
|
+
return { connected: true, wasReconnected: false, message: null };
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
const result = await ensureConnection({ forceRefresh: true, healthCheck: true });
|
|
1696
|
+
if (result.connected && result.healthCheckPassed) {
|
|
1697
|
+
await new Promise(resolve => setTimeout(resolve, RECONNECT_SETTLE_MS));
|
|
1698
|
+
return {
|
|
1699
|
+
connected: true,
|
|
1700
|
+
wasReconnected: true,
|
|
1701
|
+
message: "[CONNECTION] Was stale, re-established. Earlier data may be incomplete; new data will appear on next call.",
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
return {
|
|
1705
|
+
connected: false,
|
|
1706
|
+
wasReconnected: false,
|
|
1707
|
+
message: "[CONNECTION] No active connection. Could not reconnect. Ensure Metro and the app are running, then call scan_metro.",
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
//# sourceMappingURL=connection.js.map
|