execbro 0.0.1-reserve → 1.10.12
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 +186 -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 +769 -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 +98 -0
- package/build/core/screenState.d.ts.map +1 -0
- package/build/core/screenState.js +1121 -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 +295 -0
- package/build/pro/tap.d.ts.map +1 -0
- package/build/pro/tap.js +1937 -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 +745 -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
package/build/pro/tap.js
ADDED
|
@@ -0,0 +1,1937 @@
|
|
|
1
|
+
import { connectedApps } from "../core/state.js";
|
|
2
|
+
import { executeInApp } from "../core/executor.js";
|
|
3
|
+
import { pressElement } from "../core/executor.js";
|
|
4
|
+
import { iosTap, iosFindElement, iosScreenshot, isUiDriverAvailable, getUiDriverInstallHint, getIOSSafeAreaTop } from "../core/ios.js";
|
|
5
|
+
import { androidTap, androidFindElement } from "../core/android.js";
|
|
6
|
+
import { compareScreenshots } from "./screenshot-diff.js";
|
|
7
|
+
import { scanMetroPorts, fetchDevices, selectMainDevice } from "../core/metro.js";
|
|
8
|
+
import { connectToDevice, clearReconnectionSuppression, getConnectedAppByDevice } from "../core/connection.js";
|
|
9
|
+
import { resolveDeviceTarget, formatResolverError } from "../core/deviceResolver.js";
|
|
10
|
+
import { notifyDriverMissing } from "../core/logbox.js";
|
|
11
|
+
import { captureFailureArtifact } from "../core/failureArtifact.js";
|
|
12
|
+
import { captureScreenshot, verifyAndCapture, burstCaptureAndVerify, } from "./verifyAction.js";
|
|
13
|
+
export function buildVerificationExplanation(v) {
|
|
14
|
+
const pct = (rate) => (rate * 100).toFixed(1) + "%";
|
|
15
|
+
const action = v.action ?? "tap";
|
|
16
|
+
const Action = action[0].toUpperCase() + action.slice(1);
|
|
17
|
+
const target = action === "swipe" ? "scroll surface" : "element";
|
|
18
|
+
// Burst path with typed verdict
|
|
19
|
+
if (v.kind === "settled_elsewhere") {
|
|
20
|
+
return `${Action} caused a visible UI change (${pct(v.changeRate)} pixel diff). The screen updated as expected.`;
|
|
21
|
+
}
|
|
22
|
+
if (v.kind === "snap_back") {
|
|
23
|
+
if (action === "swipe") {
|
|
24
|
+
return (`Snap-back detected: content moved during the drag (frame ${v.peakFrame} peak ${pct(v.peakChangeRate || 0)} diff) ` +
|
|
25
|
+
`but returned to the starting position. Classic 'content fits inside the viewport' pattern — ` +
|
|
26
|
+
`check contentSize vs layoutSize on the ScrollView, not gesture handling.`);
|
|
27
|
+
}
|
|
28
|
+
return (`Transient visual feedback detected (frame ${v.peakFrame} peak ${pct(v.peakChangeRate || 0)} diff) ` +
|
|
29
|
+
`but no persistent change. ${Action} triggered a momentary animation that settled back.`);
|
|
30
|
+
}
|
|
31
|
+
if (v.kind === "missed") {
|
|
32
|
+
return (`No visual change detected — neither persistent nor transient across burst frames. ` +
|
|
33
|
+
`The ${target} may not respond visually or the ${action} may have missed its target.`);
|
|
34
|
+
}
|
|
35
|
+
// Legacy non-burst path
|
|
36
|
+
if (v.meaningful) {
|
|
37
|
+
return `${Action} caused a visible UI change (${pct(v.changeRate)} pixel diff). The screen updated as expected.`;
|
|
38
|
+
}
|
|
39
|
+
return (`No visual change detected between before and after screenshots. ` +
|
|
40
|
+
`The ${target} may not respond visually or the ${action} may have missed.`);
|
|
41
|
+
}
|
|
42
|
+
const BURST_CHANGE_THRESHOLD = 0.005;
|
|
43
|
+
export async function analyzeBurstFrames(frames, options) {
|
|
44
|
+
if (frames.length < 2) {
|
|
45
|
+
return {
|
|
46
|
+
meaningful: false,
|
|
47
|
+
persistentChangeRate: 0,
|
|
48
|
+
transientChangeDetected: false,
|
|
49
|
+
peakChangeRate: 0,
|
|
50
|
+
peakFrame: 0,
|
|
51
|
+
framesWithChange: [],
|
|
52
|
+
kind: "missed"
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
let peakChangeRate = 0;
|
|
56
|
+
let peakFrame = 0;
|
|
57
|
+
const framesWithChange = [];
|
|
58
|
+
for (let i = 1; i < frames.length; i++) {
|
|
59
|
+
const diff = await compareScreenshots(frames[i - 1], frames[i], options);
|
|
60
|
+
if (diff.changeRate > BURST_CHANGE_THRESHOLD) {
|
|
61
|
+
framesWithChange.push(i);
|
|
62
|
+
}
|
|
63
|
+
if (diff.changeRate > peakChangeRate) {
|
|
64
|
+
peakChangeRate = diff.changeRate;
|
|
65
|
+
peakFrame = i;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const persistentDiff = await compareScreenshots(frames[0], frames[frames.length - 1], options);
|
|
69
|
+
const persistentChangeRate = persistentDiff.changeRate;
|
|
70
|
+
const transientChangeDetected = !persistentDiff.changed && framesWithChange.length > 0;
|
|
71
|
+
// Snap-back (transient motion that reverts) is NOT a successful gesture —
|
|
72
|
+
// the content moved during the drag but returned to its starting position.
|
|
73
|
+
// Callers diagnosing scroll failures need this to read as "didn't work".
|
|
74
|
+
const meaningful = persistentDiff.changed;
|
|
75
|
+
let kind;
|
|
76
|
+
if (persistentDiff.changed) {
|
|
77
|
+
kind = "settled_elsewhere";
|
|
78
|
+
}
|
|
79
|
+
else if (transientChangeDetected) {
|
|
80
|
+
kind = "snap_back";
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
kind = "missed";
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
meaningful,
|
|
87
|
+
persistentChangeRate,
|
|
88
|
+
transientChangeDetected,
|
|
89
|
+
peakChangeRate,
|
|
90
|
+
peakFrame,
|
|
91
|
+
framesWithChange,
|
|
92
|
+
kind
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// --- Helpers ---
|
|
96
|
+
export function buildQuery(options) {
|
|
97
|
+
const query = {};
|
|
98
|
+
if (options.text !== undefined)
|
|
99
|
+
query.text = options.text;
|
|
100
|
+
if (options.testID !== undefined)
|
|
101
|
+
query.testID = options.testID;
|
|
102
|
+
if (options.component !== undefined)
|
|
103
|
+
query.component = options.component;
|
|
104
|
+
if (options.x !== undefined)
|
|
105
|
+
query.x = options.x;
|
|
106
|
+
if (options.y !== undefined)
|
|
107
|
+
query.y = options.y;
|
|
108
|
+
return query;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Check if text contains characters that break Hermes Runtime.evaluate.
|
|
112
|
+
* Standard accented Latin characters (Polish, Vietnamese, French, German, etc.)
|
|
113
|
+
* and Cyrillic work fine in Hermes. Only emoji and special Unicode ranges cause issues.
|
|
114
|
+
*/
|
|
115
|
+
export function hasProblematicUnicode(text) {
|
|
116
|
+
const emojiPattern = /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|[\u{1F900}-\u{1F9FF}]|[\u{200D}]|[\u{20E3}]|[\u{E0020}-\u{E007F}]/u;
|
|
117
|
+
return emojiPattern.test(text);
|
|
118
|
+
}
|
|
119
|
+
function normalizeForMatch(text) {
|
|
120
|
+
return text.trim().replace(/\s+/g, " ").toLowerCase();
|
|
121
|
+
}
|
|
122
|
+
export function reconstructLinesFromWords(words) {
|
|
123
|
+
if (!words.length)
|
|
124
|
+
return [];
|
|
125
|
+
const sorted = [...words].sort((a, b) => a.bbox.y0 - b.bbox.y0);
|
|
126
|
+
const groups = [];
|
|
127
|
+
let current = [];
|
|
128
|
+
let runningMidY = -Infinity;
|
|
129
|
+
let runningHeight = 0;
|
|
130
|
+
for (const w of sorted) {
|
|
131
|
+
const wMid = (w.bbox.y0 + w.bbox.y1) / 2;
|
|
132
|
+
const wHeight = Math.max(1, w.bbox.y1 - w.bbox.y0);
|
|
133
|
+
const tolerance = Math.min(wHeight, runningHeight || wHeight) * 0.5;
|
|
134
|
+
if (current.length === 0 || Math.abs(wMid - runningMidY) <= tolerance) {
|
|
135
|
+
current.push(w);
|
|
136
|
+
const n = current.length;
|
|
137
|
+
runningMidY = runningMidY === -Infinity ? wMid : (runningMidY * (n - 1) + wMid) / n;
|
|
138
|
+
runningHeight = (runningHeight * (n - 1) + wHeight) / n;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
groups.push(current);
|
|
142
|
+
current = [w];
|
|
143
|
+
runningMidY = wMid;
|
|
144
|
+
runningHeight = wHeight;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (current.length > 0)
|
|
148
|
+
groups.push(current);
|
|
149
|
+
return groups.map(group => {
|
|
150
|
+
const sortedWords = [...group].sort((a, b) => a.bbox.x0 - b.bbox.x0);
|
|
151
|
+
const text = sortedWords.map(w => w.text).join(" ");
|
|
152
|
+
const x0 = Math.min(...sortedWords.map(w => w.bbox.x0));
|
|
153
|
+
const y0 = Math.min(...sortedWords.map(w => w.bbox.y0));
|
|
154
|
+
const x1 = Math.max(...sortedWords.map(w => w.bbox.x1));
|
|
155
|
+
const y1 = Math.max(...sortedWords.map(w => w.bbox.y1));
|
|
156
|
+
const tapX = sortedWords.reduce((s, w) => s + w.tapCenter.x, 0) / sortedWords.length;
|
|
157
|
+
const tapY = sortedWords.reduce((s, w) => s + w.tapCenter.y, 0) / sortedWords.length;
|
|
158
|
+
return { text, bbox: { x0, y0, x1, y1 }, tapCenter: { x: tapX, y: tapY }, words: sortedWords };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Narrow the tap point to just the words that produced the substring match.
|
|
163
|
+
* Without this, a query for "Remotely" against a reconstructed line "Yes Remotely No"
|
|
164
|
+
* would tap at the line's centroid (middle of "Remotely" in this case, but easily wrong
|
|
165
|
+
* for asymmetric layouts). Tracks character offsets through the joined text so we know
|
|
166
|
+
* which constituent words contributed to the matched range.
|
|
167
|
+
*/
|
|
168
|
+
function refineTapCenterToMatchingWords(line, normalizedNeedle) {
|
|
169
|
+
const normalizedFull = normalizeForMatch(line.text);
|
|
170
|
+
const startChar = normalizedFull.indexOf(normalizedNeedle);
|
|
171
|
+
if (startChar < 0)
|
|
172
|
+
return line.tapCenter;
|
|
173
|
+
const endChar = startChar + normalizedNeedle.length;
|
|
174
|
+
let offset = 0;
|
|
175
|
+
const covered = [];
|
|
176
|
+
for (const w of line.words) {
|
|
177
|
+
const wNorm = normalizeForMatch(w.text);
|
|
178
|
+
const wordStart = offset;
|
|
179
|
+
const wordEnd = offset + wNorm.length;
|
|
180
|
+
// Word overlaps the matched range
|
|
181
|
+
if (wordEnd > startChar && wordStart < endChar) {
|
|
182
|
+
covered.push(w);
|
|
183
|
+
}
|
|
184
|
+
offset = wordEnd + 1; // +1 for the joining space
|
|
185
|
+
}
|
|
186
|
+
if (covered.length === 0)
|
|
187
|
+
return line.tapCenter;
|
|
188
|
+
const tapX = covered.reduce((s, w) => s + w.tapCenter.x, 0) / covered.length;
|
|
189
|
+
const tapY = covered.reduce((s, w) => s + w.tapCenter.y, 0) / covered.length;
|
|
190
|
+
return { x: tapX, y: tapY };
|
|
191
|
+
}
|
|
192
|
+
export function findOcrMatch(ocrResult, query) {
|
|
193
|
+
const needle = normalizeForMatch(query);
|
|
194
|
+
if (!needle)
|
|
195
|
+
return null;
|
|
196
|
+
const words = ocrResult.words ?? [];
|
|
197
|
+
const lines = ocrResult.lines ?? [];
|
|
198
|
+
const exactWord = words.find((w) => normalizeForMatch(w.text) === needle);
|
|
199
|
+
if (exactWord)
|
|
200
|
+
return { text: exactWord.text, tapCenter: exactWord.tapCenter };
|
|
201
|
+
const exactLine = lines.find((l) => normalizeForMatch(l.text) === needle);
|
|
202
|
+
if (exactLine)
|
|
203
|
+
return { text: exactLine.text, tapCenter: exactLine.tapCenter };
|
|
204
|
+
const substringLine = lines.find((l) => normalizeForMatch(l.text).includes(needle));
|
|
205
|
+
if (substringLine)
|
|
206
|
+
return { text: substringLine.text, tapCenter: substringLine.tapCenter };
|
|
207
|
+
const substringWord = words.find((w) => normalizeForMatch(w.text).includes(needle));
|
|
208
|
+
if (substringWord)
|
|
209
|
+
return { text: substringWord.text, tapCenter: substringWord.tapCenter };
|
|
210
|
+
// Fall through to phrases reconstructed from word detections. Tap point is
|
|
211
|
+
// refined to only the words covering the matched substring, so a query for
|
|
212
|
+
// "Continue with Google" inside "[icon] Continue with Google" lands on the
|
|
213
|
+
// text run instead of the line centroid (and won't drift onto the icon).
|
|
214
|
+
const reconstructed = reconstructLinesFromWords(words);
|
|
215
|
+
const exactRecon = reconstructed.find((l) => normalizeForMatch(l.text) === needle);
|
|
216
|
+
if (exactRecon) {
|
|
217
|
+
return { text: exactRecon.text, tapCenter: refineTapCenterToMatchingWords(exactRecon, needle) };
|
|
218
|
+
}
|
|
219
|
+
const substringRecon = reconstructed.find((l) => normalizeForMatch(l.text).includes(needle));
|
|
220
|
+
if (substringRecon) {
|
|
221
|
+
return { text: substringRecon.text, tapCenter: refineTapCenterToMatchingWords(substringRecon, needle) };
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
export function getAvailableStrategies(query, strategy) {
|
|
226
|
+
if (query.x !== undefined && query.y !== undefined) {
|
|
227
|
+
return ["coordinate"];
|
|
228
|
+
}
|
|
229
|
+
if (strategy !== "auto") {
|
|
230
|
+
// Always fallback to OCR for text queries — explicit strategy may miss visible text
|
|
231
|
+
if (query.text && strategy !== "ocr" && strategy !== "coordinate") {
|
|
232
|
+
return [strategy, "ocr"];
|
|
233
|
+
}
|
|
234
|
+
return [strategy];
|
|
235
|
+
}
|
|
236
|
+
if (query.component && !query.text && !query.testID) {
|
|
237
|
+
return ["fiber"];
|
|
238
|
+
}
|
|
239
|
+
if (query.testID && !query.text) {
|
|
240
|
+
return ["accessibility", "fiber"];
|
|
241
|
+
}
|
|
242
|
+
if (query.text) {
|
|
243
|
+
const strategies = [];
|
|
244
|
+
strategies.push("accessibility");
|
|
245
|
+
if (!hasProblematicUnicode(query.text)) {
|
|
246
|
+
strategies.push("fiber");
|
|
247
|
+
}
|
|
248
|
+
strategies.push("ocr");
|
|
249
|
+
return strategies;
|
|
250
|
+
}
|
|
251
|
+
return ["fiber", "accessibility", "ocr"];
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Convert screenshot image coordinates to platform-native tap coordinates.
|
|
255
|
+
*
|
|
256
|
+
* For iOS: screenshot pixels → device pixels (undo downscale) → points (÷ DPR)
|
|
257
|
+
* For Android: screenshot pixels → device pixels (undo downscale)
|
|
258
|
+
*
|
|
259
|
+
* IMPORTANT: Only use this for EXTERNAL coordinates from screenshots.
|
|
260
|
+
* Internal strategies (OCR, accessibility, fiber) produce tap-ready coordinates
|
|
261
|
+
* and call iosTap/androidTap directly — they must NOT go through this function.
|
|
262
|
+
*/
|
|
263
|
+
export function convertScreenshotToTapCoords(pixelX, pixelY, platform, devicePixelRatio, scaleFactor = 1) {
|
|
264
|
+
const deviceX = pixelX * scaleFactor;
|
|
265
|
+
const deviceY = pixelY * scaleFactor;
|
|
266
|
+
if (platform === "android") {
|
|
267
|
+
return { x: Math.round(deviceX), y: Math.round(deviceY) };
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
x: Math.round(deviceX / devicePixelRatio),
|
|
271
|
+
y: Math.round(deviceY / devicePixelRatio)
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/** @deprecated Use convertScreenshotToTapCoords instead */
|
|
275
|
+
export const convertPixelsToPoints = convertScreenshotToTapCoords;
|
|
276
|
+
export async function getCurrentScreen() {
|
|
277
|
+
try {
|
|
278
|
+
// Note: Uses var instead of let/const because Hermes Runtime.evaluate
|
|
279
|
+
// sometimes has issues with block-scoped declarations
|
|
280
|
+
const expression = `(function() {
|
|
281
|
+
var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
282
|
+
if (!hook) return null;
|
|
283
|
+
var roots = [];
|
|
284
|
+
if (hook.getFiberRoots) {
|
|
285
|
+
hook.renderers.forEach(function(r, id) {
|
|
286
|
+
var fiberRoots = hook.getFiberRoots(id);
|
|
287
|
+
if (fiberRoots) fiberRoots.forEach(function(root) { roots.push(root); });
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
if (roots.length === 0) return null;
|
|
291
|
+
|
|
292
|
+
function findScreen(fiber, depth) {
|
|
293
|
+
if (!fiber || depth > 5000) return null;
|
|
294
|
+
var name = fiber.type && (fiber.type.displayName || fiber.type.name || (typeof fiber.type === 'string' ? fiber.type : null));
|
|
295
|
+
|
|
296
|
+
if (name === 'RNSScreen') {
|
|
297
|
+
var props = fiber.memoizedProps || {};
|
|
298
|
+
if (props['aria-hidden'] === true) return null;
|
|
299
|
+
var child = fiber.child;
|
|
300
|
+
while (child) {
|
|
301
|
+
var childName = child.type && (child.type.displayName || child.type.name);
|
|
302
|
+
if (childName && typeof child.type !== 'string' && childName !== 'RNSScreenContentWrapper') {
|
|
303
|
+
return childName;
|
|
304
|
+
}
|
|
305
|
+
child = child.child;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
var child = fiber.child;
|
|
310
|
+
while (child) {
|
|
311
|
+
var found = findScreen(child, depth + 1);
|
|
312
|
+
if (found) return found;
|
|
313
|
+
child = child.sibling;
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
for (var i = 0; i < roots.length; i++) {
|
|
319
|
+
var root = roots[i].current;
|
|
320
|
+
var screen = findScreen(root, 0);
|
|
321
|
+
if (screen) return screen;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function findFirstUserComponent(fiber, depth) {
|
|
325
|
+
if (!fiber || depth > 5000) return null;
|
|
326
|
+
var name = fiber.type && (fiber.type.displayName || fiber.type.name);
|
|
327
|
+
if (name && typeof fiber.type !== 'string') return name;
|
|
328
|
+
var child = fiber.child;
|
|
329
|
+
while (child) {
|
|
330
|
+
var found = findFirstUserComponent(child, depth + 1);
|
|
331
|
+
if (found) return found;
|
|
332
|
+
child = child.sibling;
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
for (var i = 0; i < roots.length; i++) {
|
|
338
|
+
var fallback = findFirstUserComponent(roots[i].current, 0);
|
|
339
|
+
if (fallback) return fallback;
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
})()`;
|
|
343
|
+
const result = await executeInApp(expression, false, { originatingToolName: "tap" });
|
|
344
|
+
if (result.success && result.result && result.result !== "null" && result.result !== "undefined") {
|
|
345
|
+
return result.result.replace(/^"|"$/g, "");
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
export function formatTapSuccess(data) {
|
|
354
|
+
const { screenshot, verification, ...rest } = data;
|
|
355
|
+
// I3 (2026-05-16): when the tap landed on a TextInput-like element and the diff
|
|
356
|
+
// reports no visual change, that does NOT mean the focus failed. iOS simulators with
|
|
357
|
+
// the hardware keyboard connected (Cmd+K) suppress the on-screen keyboard, so the
|
|
358
|
+
// input is focused but the screen looks identical. Surface this so the agent doesn't
|
|
359
|
+
// assume the tap missed and retry the wrong thing.
|
|
360
|
+
const isTextInputComponent = (() => {
|
|
361
|
+
const name = data.component ?? data.pressed ?? "";
|
|
362
|
+
return /textfield|textinput|edittext|searchfield/i.test(name);
|
|
363
|
+
})();
|
|
364
|
+
const note = verification && !verification.skipped && verification.meaningful === false && isTextInputComponent
|
|
365
|
+
? "TextInput focused but no visual change detected. If the simulator has a hardware keyboard connected (Cmd+K), the software keyboard is suppressed even though the input is focused — proceed with text entry via ios_input_text / android_input_text."
|
|
366
|
+
: undefined;
|
|
367
|
+
return {
|
|
368
|
+
success: true,
|
|
369
|
+
...rest,
|
|
370
|
+
...(verification && { verification }),
|
|
371
|
+
...(screenshot && { screenshot }),
|
|
372
|
+
...(note && { warning: note })
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
export function formatTapFailure(data) {
|
|
376
|
+
const errorMsg = data.error || buildErrorMessage(data.query);
|
|
377
|
+
const warning = data.verification && !data.verification.skipped && data.verification.meaningful === false
|
|
378
|
+
? "Tap executed but no visual change detected. The element may not exist at these coordinates. Examine the screenshot to verify and retry with adjusted coordinates."
|
|
379
|
+
: undefined;
|
|
380
|
+
const lastStrategy = data.attempted.length > 0 ? data.attempted[data.attempted.length - 1].strategy : undefined;
|
|
381
|
+
return {
|
|
382
|
+
success: false,
|
|
383
|
+
method: lastStrategy,
|
|
384
|
+
query: data.query,
|
|
385
|
+
screen: data.screen,
|
|
386
|
+
error: errorMsg,
|
|
387
|
+
attempted: data.attempted,
|
|
388
|
+
suggestion: data.suggestion,
|
|
389
|
+
matches: data.matches,
|
|
390
|
+
...(data.ambiguous && { ambiguous: true }),
|
|
391
|
+
...(data.device && { device: data.device }),
|
|
392
|
+
...(data.verification && { verification: data.verification }),
|
|
393
|
+
...(data.screenshot && { screenshot: data.screenshot }),
|
|
394
|
+
...(warning && { warning })
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function buildErrorMessage(query) {
|
|
398
|
+
const parts = [];
|
|
399
|
+
if (query.text)
|
|
400
|
+
parts.push(`text="${query.text}"`);
|
|
401
|
+
if (query.testID)
|
|
402
|
+
parts.push(`testID="${query.testID}"`);
|
|
403
|
+
if (query.component)
|
|
404
|
+
parts.push(`component="${query.component}"`);
|
|
405
|
+
return `No element found matching ${parts.join(", ")}`;
|
|
406
|
+
}
|
|
407
|
+
// I2 (2026-05-16): RN component composition routinely produces N matches at the same
|
|
408
|
+
// coordinates for the same testID/text (e.g. ThemedButton → TouchableOpacity → TouchableOpacity).
|
|
409
|
+
// The C1 ambiguity guard refuses all such taps and demands `index=`. That's correct in the
|
|
410
|
+
// spatial-ambiguity case (two distinct buttons share a label) but wrong in the
|
|
411
|
+
// wrapper-ambiguity case — there's only one logical button, just stacked components.
|
|
412
|
+
//
|
|
413
|
+
// Collapse matches that share the same (testID, text) and overlap geometrically
|
|
414
|
+
// (centers within TOLERANCE_PX). Keeps the first occurrence — fiber/a11y walks parent-first,
|
|
415
|
+
// so the outermost wrapper survives (its onPress almost always proxies to inner handlers).
|
|
416
|
+
// Spatial ambiguity (different x/y) is preserved and continues to surface the C1 refusal.
|
|
417
|
+
function collapseGeometricallyEquivalentMatches(matches, getCenter, getKey) {
|
|
418
|
+
if (matches.length <= 1)
|
|
419
|
+
return matches;
|
|
420
|
+
const TOLERANCE_PX = 2;
|
|
421
|
+
const out = [];
|
|
422
|
+
for (const m of matches) {
|
|
423
|
+
const mc = getCenter(m);
|
|
424
|
+
const mk = getKey(m);
|
|
425
|
+
const isDuplicate = out.some(o => {
|
|
426
|
+
if (getKey(o) !== mk)
|
|
427
|
+
return false;
|
|
428
|
+
const oc = getCenter(o);
|
|
429
|
+
return Math.abs((mc.x ?? 0) - (oc.x ?? 0)) <= TOLERANCE_PX &&
|
|
430
|
+
Math.abs((mc.y ?? 0) - (oc.y ?? 0)) <= TOLERANCE_PX;
|
|
431
|
+
});
|
|
432
|
+
if (!isDuplicate)
|
|
433
|
+
out.push(m);
|
|
434
|
+
}
|
|
435
|
+
return out;
|
|
436
|
+
}
|
|
437
|
+
export function makeEmptyEvidenceSink() {
|
|
438
|
+
return {
|
|
439
|
+
fiber: { ran: false, durationMs: 0, metroConnected: false, pressables: [] },
|
|
440
|
+
accessibility: { ran: false, durationMs: 0, elements: [] },
|
|
441
|
+
ocr: { ran: false, durationMs: 0, detections: [], closestMatch: null, bestCandidate: null }
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
function ocrSimilarity(a, b) {
|
|
445
|
+
if (!a || !b)
|
|
446
|
+
return 0;
|
|
447
|
+
if (a === b)
|
|
448
|
+
return 1;
|
|
449
|
+
// Dice coefficient over character bigrams (case/space-insensitive)
|
|
450
|
+
const bigrams = (s) => {
|
|
451
|
+
const m = new Map();
|
|
452
|
+
if (s.length < 2) {
|
|
453
|
+
if (s)
|
|
454
|
+
m.set(s, 1);
|
|
455
|
+
return m;
|
|
456
|
+
}
|
|
457
|
+
for (let i = 0; i < s.length - 1; i++) {
|
|
458
|
+
const bg = s.slice(i, i + 2);
|
|
459
|
+
m.set(bg, (m.get(bg) ?? 0) + 1);
|
|
460
|
+
}
|
|
461
|
+
return m;
|
|
462
|
+
};
|
|
463
|
+
const ag = bigrams(a);
|
|
464
|
+
const bg = bigrams(b);
|
|
465
|
+
let intersection = 0;
|
|
466
|
+
for (const [k, va] of ag) {
|
|
467
|
+
const vb = bg.get(k);
|
|
468
|
+
if (vb)
|
|
469
|
+
intersection += Math.min(va, vb);
|
|
470
|
+
}
|
|
471
|
+
const total = Array.from(ag.values()).reduce((s, v) => s + v, 0)
|
|
472
|
+
+ Array.from(bg.values()).reduce((s, v) => s + v, 0);
|
|
473
|
+
if (!total)
|
|
474
|
+
return 0;
|
|
475
|
+
return (2 * intersection) / total;
|
|
476
|
+
}
|
|
477
|
+
export function findClosestOcrText(ocrResult, query) {
|
|
478
|
+
if (!query)
|
|
479
|
+
return null;
|
|
480
|
+
const needle = normalizeForMatch(query);
|
|
481
|
+
if (!needle)
|
|
482
|
+
return null;
|
|
483
|
+
const candidates = [];
|
|
484
|
+
if (ocrResult?.words?.length)
|
|
485
|
+
candidates.push(...ocrResult.words.map(w => ({ text: w.text })));
|
|
486
|
+
if (ocrResult?.lines?.length)
|
|
487
|
+
candidates.push(...ocrResult.lines.map(l => ({ text: l.text })));
|
|
488
|
+
// Include reconstructed phrases so the diagnostic surfaces "Continue with Google@1.00"
|
|
489
|
+
// instead of "Continue@0.54" when the engine fragmented a multi-word phrase into
|
|
490
|
+
// separate word detections.
|
|
491
|
+
if (ocrResult?.words?.length) {
|
|
492
|
+
candidates.push(...reconstructLinesFromWords(ocrResult.words).map(l => ({ text: l.text })));
|
|
493
|
+
}
|
|
494
|
+
if (!candidates.length)
|
|
495
|
+
return null;
|
|
496
|
+
let best = null;
|
|
497
|
+
for (const c of candidates) {
|
|
498
|
+
const norm = normalizeForMatch(c.text);
|
|
499
|
+
if (!norm)
|
|
500
|
+
continue;
|
|
501
|
+
const score = ocrSimilarity(needle, norm);
|
|
502
|
+
if (!best || score > best.score)
|
|
503
|
+
best = { text: c.text, score };
|
|
504
|
+
}
|
|
505
|
+
return best;
|
|
506
|
+
}
|
|
507
|
+
// --- Strategy Functions ---
|
|
508
|
+
async function tryFiberStrategy(query, index, maxTraversalDepth, sink, device) {
|
|
509
|
+
if (sink) {
|
|
510
|
+
sink.fiber.ran = true;
|
|
511
|
+
sink.fiber.metroConnected = connectedApps.size > 0;
|
|
512
|
+
}
|
|
513
|
+
const startedAt = Date.now();
|
|
514
|
+
try {
|
|
515
|
+
// Retry with increasing depth if the initial traversal finds nothing
|
|
516
|
+
const baseDepth = maxTraversalDepth ?? 15;
|
|
517
|
+
const depthAttempts = [baseDepth];
|
|
518
|
+
// Only add deeper retries if user didn't explicitly set a high depth
|
|
519
|
+
if (baseDepth <= 15) {
|
|
520
|
+
depthAttempts.push(30, 45);
|
|
521
|
+
}
|
|
522
|
+
else if (baseDepth <= 30) {
|
|
523
|
+
depthAttempts.push(baseDepth * 2);
|
|
524
|
+
}
|
|
525
|
+
let lastResult = null;
|
|
526
|
+
for (const depth of depthAttempts) {
|
|
527
|
+
const result = await tryFiberAtDepth(query, index, depth, device);
|
|
528
|
+
if (sink && result.matches?.length) {
|
|
529
|
+
sink.fiber.pressables = result.matches.slice(0, 50).map(m => ({
|
|
530
|
+
label: m.text || undefined,
|
|
531
|
+
testID: m.testID ?? undefined,
|
|
532
|
+
componentName: m.component,
|
|
533
|
+
bounds: (m.x !== undefined && m.y !== undefined)
|
|
534
|
+
? { x: m.x, y: m.y, width: 0, height: 0 }
|
|
535
|
+
: undefined
|
|
536
|
+
}));
|
|
537
|
+
}
|
|
538
|
+
if (result.success || result.matches) {
|
|
539
|
+
return result;
|
|
540
|
+
}
|
|
541
|
+
lastResult = result;
|
|
542
|
+
}
|
|
543
|
+
return lastResult;
|
|
544
|
+
}
|
|
545
|
+
finally {
|
|
546
|
+
if (sink)
|
|
547
|
+
sink.fiber.durationMs = Date.now() - startedAt;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
async function tryFiberAtDepth(query, index, maxTraversalDepth, device) {
|
|
551
|
+
try {
|
|
552
|
+
const result = await pressElement({
|
|
553
|
+
text: query.text,
|
|
554
|
+
testID: query.testID,
|
|
555
|
+
component: query.component,
|
|
556
|
+
index,
|
|
557
|
+
maxTraversalDepth,
|
|
558
|
+
device
|
|
559
|
+
});
|
|
560
|
+
if (!result.success) {
|
|
561
|
+
return { success: false, reason: result.error || "pressElement failed" };
|
|
562
|
+
}
|
|
563
|
+
if (!result.result) {
|
|
564
|
+
return { success: false, reason: "No result from pressElement" };
|
|
565
|
+
}
|
|
566
|
+
const parsed = JSON.parse(result.result);
|
|
567
|
+
if (parsed.error) {
|
|
568
|
+
const strategyResult = {
|
|
569
|
+
success: false,
|
|
570
|
+
reason: parsed.error
|
|
571
|
+
};
|
|
572
|
+
if (parsed.matches) {
|
|
573
|
+
strategyResult.matches = parsed.matches;
|
|
574
|
+
}
|
|
575
|
+
return strategyResult;
|
|
576
|
+
}
|
|
577
|
+
// Fiber finds the element by text/testID/component, then measures its
|
|
578
|
+
// host component's screen position for a native tap. This ensures the tap
|
|
579
|
+
// goes through React's event pipeline, executing any onPress wrappers
|
|
580
|
+
// (analytics, debouncing, state tracking) inside the component.
|
|
581
|
+
if (parsed.needsNativeTap) {
|
|
582
|
+
// Ambiguity guard: if multiple elements match and the caller didn't
|
|
583
|
+
// specify an explicit index, refuse to tap and surface the full list
|
|
584
|
+
// so the agent can pick the right one.
|
|
585
|
+
if ((parsed.totalMatches ?? 1) > 1 && index === undefined) {
|
|
586
|
+
// I2: collapse geometrically-identical wrapper matches before refusing.
|
|
587
|
+
const collapsed = collapseGeometricallyEquivalentMatches(parsed.allMatches ?? [], (m) => ({ x: m.x, y: m.y }), (m) => `${m.testID ?? ""}::${m.text ?? ""}`);
|
|
588
|
+
if (collapsed.length > 1) {
|
|
589
|
+
return {
|
|
590
|
+
success: false,
|
|
591
|
+
reason: `Ambiguous: ${collapsed.length} elements match this query — use index= to pick one`,
|
|
592
|
+
matches: collapsed,
|
|
593
|
+
ambiguous: true
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
// Collapsed to one logical element — fall through and tap. The JS-side
|
|
597
|
+
// search already picked allMatches[0] (the outermost wrapper) for
|
|
598
|
+
// parsed.pressed/parsed.nativeTapTarget.
|
|
599
|
+
}
|
|
600
|
+
const elementType = parsed.isInput ? "input element" : "pressable element";
|
|
601
|
+
if (parsed.nativeTapTarget && parsed.nativeTapTarget.x && parsed.nativeTapTarget.y) {
|
|
602
|
+
return {
|
|
603
|
+
success: false,
|
|
604
|
+
reason: `Found ${parsed.pressed} (${elementType}) — measured coordinates for native tap`,
|
|
605
|
+
pressed: parsed.pressed,
|
|
606
|
+
text: parsed.text,
|
|
607
|
+
path: parsed.path || null,
|
|
608
|
+
component: parsed.pressed || null,
|
|
609
|
+
convertedTo: {
|
|
610
|
+
x: parsed.nativeTapTarget.x,
|
|
611
|
+
y: parsed.nativeTapTarget.y,
|
|
612
|
+
unit: parsed.nativeTapTarget.unit || "points"
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
success: false,
|
|
618
|
+
reason: `Found ${parsed.pressed} (${elementType}) but could not measure coordinates — falling through to next strategy`
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
// All elements now use needsNativeTap — this shouldn't be reached
|
|
622
|
+
return {
|
|
623
|
+
success: false,
|
|
624
|
+
reason: "Unexpected: element did not request native tap"
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
catch (err) {
|
|
628
|
+
return {
|
|
629
|
+
success: false,
|
|
630
|
+
reason: `Fiber strategy error: ${err instanceof Error ? err.message : String(err)}`
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
async function tryAccessibilityStrategy(query, index, platform, udid, sink, signal) {
|
|
635
|
+
if (sink)
|
|
636
|
+
sink.accessibility.ran = true;
|
|
637
|
+
const startedAt = Date.now();
|
|
638
|
+
try {
|
|
639
|
+
const hasTestID = !!query.testID;
|
|
640
|
+
const hasText = !!query.text;
|
|
641
|
+
if (!hasTestID && !hasText) {
|
|
642
|
+
return {
|
|
643
|
+
success: false,
|
|
644
|
+
reason: "No text or testID for accessibility search"
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
if (platform === "ios") {
|
|
648
|
+
// iOS: testID maps to accessibilityIdentifier — search by identifier first,
|
|
649
|
+
// then fall back to labelContains for text-based searches
|
|
650
|
+
let result;
|
|
651
|
+
if (hasTestID && !hasText) {
|
|
652
|
+
// Try exact identifier match first (testID → accessibilityIdentifier)
|
|
653
|
+
result = await iosFindElement({
|
|
654
|
+
identifier: query.testID,
|
|
655
|
+
index
|
|
656
|
+
}, udid);
|
|
657
|
+
// Fall back to identifierContains if exact match fails
|
|
658
|
+
if (!result.success || !result.allMatches || result.allMatches.length === 0) {
|
|
659
|
+
result = await iosFindElement({
|
|
660
|
+
identifierContains: query.testID,
|
|
661
|
+
index
|
|
662
|
+
}, udid);
|
|
663
|
+
}
|
|
664
|
+
// Last resort: try labelContains in case testID is reflected in label
|
|
665
|
+
if (!result.success || !result.allMatches || result.allMatches.length === 0) {
|
|
666
|
+
result = await iosFindElement({
|
|
667
|
+
labelContains: query.testID,
|
|
668
|
+
index
|
|
669
|
+
}, udid);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
const searchText = query.text;
|
|
674
|
+
// Exact label match first to avoid matching dialog titles like "Clear All"
|
|
675
|
+
// when the query is a single word that happens to be a substring ("Clear").
|
|
676
|
+
result = await iosFindElement({
|
|
677
|
+
label: searchText,
|
|
678
|
+
index
|
|
679
|
+
}, udid);
|
|
680
|
+
if (!result.success || !result.allMatches || result.allMatches.length === 0) {
|
|
681
|
+
result = await iosFindElement({
|
|
682
|
+
labelContains: searchText,
|
|
683
|
+
index
|
|
684
|
+
}, udid);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (sink && result.allMatches?.length) {
|
|
688
|
+
sink.accessibility.elements = result.allMatches.slice(0, 50).map(el => ({
|
|
689
|
+
label: el.label || undefined,
|
|
690
|
+
testID: el.identifier || undefined,
|
|
691
|
+
frame: el.frame
|
|
692
|
+
? { x: el.frame.x, y: el.frame.y, width: el.frame.width, height: el.frame.height }
|
|
693
|
+
: undefined
|
|
694
|
+
}));
|
|
695
|
+
}
|
|
696
|
+
if (!result.success || !result.allMatches || result.allMatches.length === 0) {
|
|
697
|
+
return {
|
|
698
|
+
success: false,
|
|
699
|
+
reason: result.error ?? "No iOS accessibility match"
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
// Ambiguity guard: when the predicate matches multiple a11y elements
|
|
703
|
+
// (e.g. text="My Events" hits both the header title AND the bottom tab),
|
|
704
|
+
// refuse to tap without an explicit index — picking allMatches[0] silently
|
|
705
|
+
// landed on non-interactive header elements in production telemetry.
|
|
706
|
+
// Mirrors the fiber-strategy guard further up the file.
|
|
707
|
+
// I2: collapse geometrically-identical wrapper matches before refusing.
|
|
708
|
+
const iosEffectiveMatches = result.allMatches.length > 1
|
|
709
|
+
? collapseGeometricallyEquivalentMatches(result.allMatches, (m) => ({ x: m.center?.x, y: m.center?.y }), (m) => `${m.identifier ?? ""}::${m.label ?? ""}`)
|
|
710
|
+
: result.allMatches;
|
|
711
|
+
if (iosEffectiveMatches.length > 1 && index === undefined) {
|
|
712
|
+
return {
|
|
713
|
+
success: false,
|
|
714
|
+
reason: `Ambiguous: ${iosEffectiveMatches.length} elements match this query — use index= to pick one`,
|
|
715
|
+
matches: iosEffectiveMatches.map((m, i) => ({
|
|
716
|
+
index: i,
|
|
717
|
+
component: m.type ?? "",
|
|
718
|
+
text: m.label ?? "",
|
|
719
|
+
testID: m.identifier ?? null,
|
|
720
|
+
x: m.center?.x,
|
|
721
|
+
y: m.center?.y
|
|
722
|
+
})),
|
|
723
|
+
ambiguous: true
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
const match = iosEffectiveMatches[index ?? 0];
|
|
727
|
+
if (!match) {
|
|
728
|
+
return {
|
|
729
|
+
success: false,
|
|
730
|
+
reason: `Index ${index} out of bounds (${iosEffectiveMatches.length} matches)`
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
await iosTap(match.center.x, match.center.y, { udid });
|
|
734
|
+
return {
|
|
735
|
+
success: true,
|
|
736
|
+
reason: "Tapped via iOS accessibility",
|
|
737
|
+
pressed: match.label || match.type,
|
|
738
|
+
text: match.label || undefined,
|
|
739
|
+
component: match.type || null,
|
|
740
|
+
convertedTo: { x: match.center.x, y: match.center.y, unit: "points" }
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
// Android: testID maps to resource-id, text maps to text content
|
|
745
|
+
const searchOptions = { index };
|
|
746
|
+
if (hasTestID && !hasText) {
|
|
747
|
+
searchOptions.resourceId = query.testID;
|
|
748
|
+
}
|
|
749
|
+
else if (hasText) {
|
|
750
|
+
searchOptions.textContains = query.text;
|
|
751
|
+
}
|
|
752
|
+
let result = await androidFindElement(searchOptions, undefined, signal);
|
|
753
|
+
// If testID search via resourceId failed, try contentDescContains
|
|
754
|
+
// (older RN versions map testID to content-description)
|
|
755
|
+
if (hasTestID && !hasText && (!result.success || !result.allMatches || result.allMatches.length === 0)) {
|
|
756
|
+
result = await androidFindElement({
|
|
757
|
+
contentDescContains: query.testID,
|
|
758
|
+
index
|
|
759
|
+
}, undefined, signal);
|
|
760
|
+
}
|
|
761
|
+
if (sink && result.allMatches?.length) {
|
|
762
|
+
sink.accessibility.elements = result.allMatches.slice(0, 50).map(el => ({
|
|
763
|
+
label: el.text || el.contentDesc || undefined,
|
|
764
|
+
testID: el.resourceId || undefined,
|
|
765
|
+
frame: el.bounds
|
|
766
|
+
? { x: el.bounds.left, y: el.bounds.top, width: el.bounds.width, height: el.bounds.height }
|
|
767
|
+
: undefined
|
|
768
|
+
}));
|
|
769
|
+
}
|
|
770
|
+
if (!result.success || !result.allMatches || result.allMatches.length === 0) {
|
|
771
|
+
return {
|
|
772
|
+
success: false,
|
|
773
|
+
reason: result.error ?? "No Android accessibility match"
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
// Ambiguity guard — see iOS branch above for rationale.
|
|
777
|
+
// I2: collapse geometrically-identical wrapper matches before refusing.
|
|
778
|
+
const androidEffectiveMatches = result.allMatches.length > 1
|
|
779
|
+
? collapseGeometricallyEquivalentMatches(result.allMatches, (m) => ({ x: m.center?.x, y: m.center?.y }), (m) => `${m.resourceId ?? ""}::${m.text ?? m.contentDesc ?? ""}`)
|
|
780
|
+
: result.allMatches;
|
|
781
|
+
if (androidEffectiveMatches.length > 1 && index === undefined) {
|
|
782
|
+
return {
|
|
783
|
+
success: false,
|
|
784
|
+
reason: `Ambiguous: ${androidEffectiveMatches.length} elements match this query — use index= to pick one`,
|
|
785
|
+
matches: androidEffectiveMatches.map((m, i) => ({
|
|
786
|
+
index: i,
|
|
787
|
+
component: m.className ?? "",
|
|
788
|
+
text: m.text ?? m.contentDesc ?? "",
|
|
789
|
+
testID: m.resourceId ?? null,
|
|
790
|
+
x: m.center?.x,
|
|
791
|
+
y: m.center?.y
|
|
792
|
+
})),
|
|
793
|
+
ambiguous: true
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
const match = androidEffectiveMatches[index ?? 0];
|
|
797
|
+
if (!match) {
|
|
798
|
+
return {
|
|
799
|
+
success: false,
|
|
800
|
+
reason: `Index ${index} out of bounds (${androidEffectiveMatches.length} matches)`
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
await androidTap(match.center.x, match.center.y);
|
|
804
|
+
return {
|
|
805
|
+
success: true,
|
|
806
|
+
reason: "Tapped via Android accessibility",
|
|
807
|
+
pressed: match.text || match.className || undefined,
|
|
808
|
+
text: match.text || undefined,
|
|
809
|
+
component: match.className || undefined,
|
|
810
|
+
convertedTo: { x: match.center.x, y: match.center.y, unit: "pixels" }
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
catch (err) {
|
|
815
|
+
return {
|
|
816
|
+
success: false,
|
|
817
|
+
reason: `Accessibility strategy error: ${err instanceof Error ? err.message : String(err)}`
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
finally {
|
|
821
|
+
if (sink)
|
|
822
|
+
sink.accessibility.durationMs = Date.now() - startedAt;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Run OCR sense (capture + recognize + match) and tap the match. Also records
|
|
827
|
+
* `sink.ocr.bestCandidate` (matched text + tap coords) into the evidence sink,
|
|
828
|
+
* which is serialized into the R2 failure bundle for diagnostics.
|
|
829
|
+
*/
|
|
830
|
+
async function tryOcrStrategy(query, platform, udid, sink, signal) {
|
|
831
|
+
if (sink)
|
|
832
|
+
sink.ocr.ran = true;
|
|
833
|
+
const ocrStartedAt = Date.now();
|
|
834
|
+
try {
|
|
835
|
+
const searchText = query.text;
|
|
836
|
+
if (!searchText) {
|
|
837
|
+
return { success: false, reason: "OCR strategy requires text query" };
|
|
838
|
+
}
|
|
839
|
+
let imageBuffer;
|
|
840
|
+
let scaleFactor = 1;
|
|
841
|
+
if (platform === "ios") {
|
|
842
|
+
const screenshot = await iosScreenshot(undefined, udid);
|
|
843
|
+
if (!screenshot.success || !screenshot.data) {
|
|
844
|
+
return {
|
|
845
|
+
success: false,
|
|
846
|
+
reason: "Failed to capture iOS screenshot for OCR"
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
imageBuffer = screenshot.data;
|
|
850
|
+
scaleFactor = screenshot.scaleFactor ?? 1;
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
const { androidScreenshot } = await import("../core/android.js");
|
|
854
|
+
const screenshot = await androidScreenshot(undefined, undefined, signal);
|
|
855
|
+
if (!screenshot.success || !screenshot.data) {
|
|
856
|
+
return {
|
|
857
|
+
success: false,
|
|
858
|
+
reason: "Failed to capture Android screenshot for OCR"
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
imageBuffer = screenshot.data;
|
|
862
|
+
scaleFactor = screenshot.scaleFactor ?? 1;
|
|
863
|
+
}
|
|
864
|
+
const { recognizeText } = await import("../core/ocr.js");
|
|
865
|
+
const ocrResult = await recognizeText(imageBuffer, {
|
|
866
|
+
scaleFactor,
|
|
867
|
+
platform,
|
|
868
|
+
signal
|
|
869
|
+
});
|
|
870
|
+
if (sink && ocrResult) {
|
|
871
|
+
const allDetections = [
|
|
872
|
+
...(ocrResult.words ?? []),
|
|
873
|
+
...(ocrResult.lines ?? [])
|
|
874
|
+
];
|
|
875
|
+
sink.ocr.detections = allDetections.slice(0, 100).map(r => ({
|
|
876
|
+
text: r.text,
|
|
877
|
+
bbox: [r.bbox.x0, r.bbox.y0, r.bbox.x1 - r.bbox.x0, r.bbox.y1 - r.bbox.y0],
|
|
878
|
+
conf: r.confidence ?? 0
|
|
879
|
+
}));
|
|
880
|
+
sink.ocr.closestMatch = findClosestOcrText(ocrResult, searchText);
|
|
881
|
+
}
|
|
882
|
+
const match = findOcrMatch(ocrResult, searchText);
|
|
883
|
+
// Record the best candidate (matched text + tap coords) into the
|
|
884
|
+
// evidence sink. This is serialized into the R2 failure bundle and is
|
|
885
|
+
// useful when diagnosing OCR taps (e.g. OCR found the match at these
|
|
886
|
+
// coords but the tap didn't register a visual change).
|
|
887
|
+
if (sink && match) {
|
|
888
|
+
const closest = sink.ocr.closestMatch;
|
|
889
|
+
sink.ocr.bestCandidate = {
|
|
890
|
+
text: match.text,
|
|
891
|
+
score: closest && closest.text === match.text ? closest.score : 1,
|
|
892
|
+
tapCenter: { x: match.tapCenter.x, y: match.tapCenter.y },
|
|
893
|
+
scaleFactor
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
if (!match) {
|
|
897
|
+
return {
|
|
898
|
+
success: false,
|
|
899
|
+
reason: `OCR did not find text "${searchText}" on screen`
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
if (platform === "ios") {
|
|
903
|
+
// tapCenter is in image-pixel space (downscaled) — convert to points
|
|
904
|
+
const { getDevicePixelRatio } = await import("../core/ios.js");
|
|
905
|
+
const dpr = await getDevicePixelRatio(udid);
|
|
906
|
+
const tapResult = await iosTap(Math.round((match.tapCenter.x * scaleFactor) / dpr), Math.round((match.tapCenter.y * scaleFactor) / dpr), { udid });
|
|
907
|
+
if (!tapResult.success) {
|
|
908
|
+
return {
|
|
909
|
+
success: false,
|
|
910
|
+
reason: `OCR found "${match.text}" but tap failed: ${tapResult.error}`
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
else {
|
|
915
|
+
// Android: image-pixel → device-pixel (undo downscale), ADB accepts pixels
|
|
916
|
+
await androidTap(Math.round(match.tapCenter.x * scaleFactor), Math.round(match.tapCenter.y * scaleFactor));
|
|
917
|
+
}
|
|
918
|
+
return {
|
|
919
|
+
success: true,
|
|
920
|
+
reason: "Tapped via OCR text recognition",
|
|
921
|
+
text: match.text,
|
|
922
|
+
convertedTo: {
|
|
923
|
+
x: match.tapCenter.x,
|
|
924
|
+
y: match.tapCenter.y,
|
|
925
|
+
unit: "pixels"
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
catch (err) {
|
|
930
|
+
return {
|
|
931
|
+
success: false,
|
|
932
|
+
reason: `OCR strategy error: ${err instanceof Error ? err.message : String(err)}`
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
finally {
|
|
936
|
+
if (sink)
|
|
937
|
+
sink.ocr.durationMs = Date.now() - ocrStartedAt;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
async function tryCoordinateStrategy(pixelX, pixelY, platform, lastScreenshot, udid) {
|
|
941
|
+
try {
|
|
942
|
+
if (platform === "ios") {
|
|
943
|
+
const scaleFactor = lastScreenshot?.scaleFactor ?? 1;
|
|
944
|
+
const { getDevicePixelRatio } = await import("../core/ios.js");
|
|
945
|
+
const devicePixelRatio = await getDevicePixelRatio(udid);
|
|
946
|
+
const converted = convertScreenshotToTapCoords(pixelX, pixelY, "ios", devicePixelRatio, scaleFactor);
|
|
947
|
+
const tapResult = await iosTap(converted.x, converted.y, { udid });
|
|
948
|
+
if (!tapResult.success) {
|
|
949
|
+
return {
|
|
950
|
+
success: false,
|
|
951
|
+
reason: `Coordinate tap failed: ${tapResult.error}`
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
// Best-effort: identify what was tapped via fiber tree
|
|
955
|
+
let screen = null;
|
|
956
|
+
try {
|
|
957
|
+
screen = await getCurrentScreen();
|
|
958
|
+
}
|
|
959
|
+
catch {
|
|
960
|
+
// Inspection failure is non-fatal
|
|
961
|
+
}
|
|
962
|
+
return {
|
|
963
|
+
success: true,
|
|
964
|
+
reason: "Tapped at coordinates (iOS)",
|
|
965
|
+
screen,
|
|
966
|
+
convertedTo: { x: converted.x, y: converted.y, unit: "points" }
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
else {
|
|
970
|
+
const scaleFactor = lastScreenshot?.scaleFactor ?? 1;
|
|
971
|
+
const converted = convertScreenshotToTapCoords(pixelX, pixelY, "android", 1, scaleFactor);
|
|
972
|
+
await androidTap(converted.x, converted.y);
|
|
973
|
+
// Best-effort: identify what was tapped via fiber tree
|
|
974
|
+
let screen = null;
|
|
975
|
+
try {
|
|
976
|
+
screen = await getCurrentScreen();
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
// Inspection failure is non-fatal
|
|
980
|
+
}
|
|
981
|
+
return {
|
|
982
|
+
success: true,
|
|
983
|
+
reason: "Tapped at coordinates (Android)",
|
|
984
|
+
screen,
|
|
985
|
+
convertedTo: { x: converted.x, y: converted.y, unit: "pixels" }
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
catch (err) {
|
|
990
|
+
return {
|
|
991
|
+
success: false,
|
|
992
|
+
reason: `Coordinate strategy error: ${err instanceof Error ? err.message : String(err)}`
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
const TAP_TIMEOUT_MS = 25000;
|
|
997
|
+
const MIN_STRATEGY_BUDGET_MS = 500;
|
|
998
|
+
// Per-strategy budget. OCR cap on Android is bumped via maxStrategyMs() because
|
|
999
|
+
// the ADB screencap+pull leg has ~2s variance on real devices; iOS stays at 5s
|
|
1000
|
+
// where xcrun simctl screenshot is consistent.
|
|
1001
|
+
// Heavy strategies (fiber on deep trees with multi-depth retries, axe accessibility
|
|
1002
|
+
// dumps on dense iOS screens) need more headroom — previous caps produced spurious
|
|
1003
|
+
// timeouts that the agent read as "element missing" when the strategy simply didn't
|
|
1004
|
+
// get to finish. The overall TAP_TIMEOUT_MS budget still bounds the worst case.
|
|
1005
|
+
// Coordinate looks "light" (one tap subprocess) but on iOS it pairs an axe/idb
|
|
1006
|
+
// invocation with a CDP getDevicePixelRatio + best-effort fiber inspection;
|
|
1007
|
+
// the 3000ms cap surfaced as spurious "coordinate timed out" failures while
|
|
1008
|
+
// 20+ seconds of the overall budget were still unused. Align with the other
|
|
1009
|
+
// strategies — the global TAP_TIMEOUT_MS still bounds the worst case.
|
|
1010
|
+
const MAX_STRATEGY_MS = {
|
|
1011
|
+
fiber: 8000,
|
|
1012
|
+
accessibility: 6000,
|
|
1013
|
+
ocr: 6000,
|
|
1014
|
+
coordinate: 8000
|
|
1015
|
+
};
|
|
1016
|
+
function maxStrategyMs(strategy, platform) {
|
|
1017
|
+
if (strategy === "ocr" && platform === "android")
|
|
1018
|
+
return 9000;
|
|
1019
|
+
return MAX_STRATEGY_MS[strategy] ?? 5000;
|
|
1020
|
+
}
|
|
1021
|
+
// Matches only the outer withTimeout wrapper message for a tap strategy.
|
|
1022
|
+
// Nested sub-operation errors inside a strategy (e.g. "CDP getProperties timed out after 150ms")
|
|
1023
|
+
// must NOT be classified as a tap-level timeout.
|
|
1024
|
+
const STRATEGY_TIMEOUT_RE = /^(fiber|accessibility|ocr|coordinate) timed out after \d+ms$/;
|
|
1025
|
+
export function isTapTimeout(attempted) {
|
|
1026
|
+
return attempted.some((a) => a.outcome === "timeout" || a.outcome === "skipped" || STRATEGY_TIMEOUT_RE.test(a.reason) || a.reason.startsWith("Skipped —"));
|
|
1027
|
+
}
|
|
1028
|
+
function withTimeout(promise, ms, label) {
|
|
1029
|
+
return new Promise((resolve, reject) => {
|
|
1030
|
+
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
1031
|
+
promise.then((val) => {
|
|
1032
|
+
clearTimeout(timer);
|
|
1033
|
+
resolve(val);
|
|
1034
|
+
}, (err) => {
|
|
1035
|
+
clearTimeout(timer);
|
|
1036
|
+
reject(err);
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Like withTimeout, but creates an AbortController whose signal is handed to
|
|
1042
|
+
* the inner factory. On timeout the signal aborts so subprocess work
|
|
1043
|
+
* (uiautomator dump, OCR fetch, screencap) can be killed instead of running
|
|
1044
|
+
* past the strategy cap and bleeding into total tap duration.
|
|
1045
|
+
*/
|
|
1046
|
+
function withCancelableTimeout(make, ms, label) {
|
|
1047
|
+
const ctrl = new AbortController();
|
|
1048
|
+
const timer = setTimeout(() => ctrl.abort(), ms);
|
|
1049
|
+
return make(ctrl.signal).then((val) => { clearTimeout(timer); return val; }, (err) => {
|
|
1050
|
+
clearTimeout(timer);
|
|
1051
|
+
if (ctrl.signal.aborted)
|
|
1052
|
+
throw new Error(`${label} timed out after ${ms}ms`);
|
|
1053
|
+
throw err;
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Compute marker coordinates in screenshot-pixel space (what the returned PNG uses).
|
|
1058
|
+
* Unit rules by strategy, assuming screenshotScale = downscale factor:
|
|
1059
|
+
* coordinate : input is already screenshot pixels → pass through
|
|
1060
|
+
* ocr : match.tapCenter is image-pixel (= screenshot-pixel) → pass through
|
|
1061
|
+
* iOS points : point * DPR / screenshotScale
|
|
1062
|
+
* android dp : dp * densityScale / screenshotScale
|
|
1063
|
+
* android devicePx : devicePx / screenshotScale
|
|
1064
|
+
*/
|
|
1065
|
+
function computeMarkerPx(args) {
|
|
1066
|
+
const { strategy, input, convertedTo, platform, screenshotScale, devicePixelRatio, androidDensityScale } = args;
|
|
1067
|
+
const scale = screenshotScale || 1;
|
|
1068
|
+
if (strategy === "coordinate" || strategy === "native-coordinate") {
|
|
1069
|
+
return input ? { x: input.x, y: input.y } : undefined;
|
|
1070
|
+
}
|
|
1071
|
+
if (!convertedTo)
|
|
1072
|
+
return undefined;
|
|
1073
|
+
if (strategy === "ocr") {
|
|
1074
|
+
return { x: Math.round(convertedTo.x), y: Math.round(convertedTo.y) };
|
|
1075
|
+
}
|
|
1076
|
+
if (platform === "ios") {
|
|
1077
|
+
// fiber/accessibility on iOS return points
|
|
1078
|
+
const dpr = devicePixelRatio || 3;
|
|
1079
|
+
return {
|
|
1080
|
+
x: Math.round((convertedTo.x * dpr) / scale),
|
|
1081
|
+
y: Math.round((convertedTo.y * dpr) / scale)
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
// Android
|
|
1085
|
+
if (strategy === "fiber+native" && androidDensityScale) {
|
|
1086
|
+
// Fabric fiber returns dp; fiber+native path scales to device pixels before tap
|
|
1087
|
+
return {
|
|
1088
|
+
x: Math.round((convertedTo.x * androidDensityScale) / scale),
|
|
1089
|
+
y: Math.round((convertedTo.y * androidDensityScale) / scale)
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
// accessibility android: convertedTo is device pixels
|
|
1093
|
+
return {
|
|
1094
|
+
x: Math.round(convertedTo.x / scale),
|
|
1095
|
+
y: Math.round(convertedTo.y / scale)
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
// D2 (Step 6): in-memory ring buffer of recent coord-strategy artifact keys
|
|
1099
|
+
// `${round(x/20)}:${round(y/20)}`. Entries expire after 60s. The first
|
|
1100
|
+
// failed coord tap at a grid cell uploads its artifact; retries within the
|
|
1101
|
+
// window get suppressed.
|
|
1102
|
+
const recentCoordArtifacts = new Map();
|
|
1103
|
+
const COORD_DEDUP_WINDOW_MS = 60_000;
|
|
1104
|
+
const COORD_DEDUP_BUCKET = 20;
|
|
1105
|
+
function shouldDedupArtifact(ctx) {
|
|
1106
|
+
// Only applies to coordinate-strategy artifacts where we have x,y coords.
|
|
1107
|
+
const winningStrat = ctx.attempted.find(a => a.reason === "success")?.strategy
|
|
1108
|
+
?? ctx.attempted[ctx.attempted.length - 1]?.strategy;
|
|
1109
|
+
if (winningStrat !== "coordinate")
|
|
1110
|
+
return false;
|
|
1111
|
+
const x = ctx.chosenTapPoint?.x ?? ctx.query.x;
|
|
1112
|
+
const y = ctx.chosenTapPoint?.y ?? ctx.query.y;
|
|
1113
|
+
if (typeof x !== "number" || typeof y !== "number")
|
|
1114
|
+
return false;
|
|
1115
|
+
const key = `${Math.round(x / COORD_DEDUP_BUCKET)}:${Math.round(y / COORD_DEDUP_BUCKET)}`;
|
|
1116
|
+
const now = Date.now();
|
|
1117
|
+
// Sweep stale entries opportunistically — keeps the map from growing.
|
|
1118
|
+
for (const [k, ts] of recentCoordArtifacts) {
|
|
1119
|
+
if (now - ts > COORD_DEDUP_WINDOW_MS)
|
|
1120
|
+
recentCoordArtifacts.delete(k);
|
|
1121
|
+
}
|
|
1122
|
+
const last = recentCoordArtifacts.get(key);
|
|
1123
|
+
if (last !== undefined && (now - last) < COORD_DEDUP_WINDOW_MS)
|
|
1124
|
+
return true;
|
|
1125
|
+
recentCoordArtifacts.set(key, now);
|
|
1126
|
+
return false;
|
|
1127
|
+
}
|
|
1128
|
+
/** Test-only: drop the dedup state so a fresh tap always uploads. */
|
|
1129
|
+
export function resetCoordArtifactDedup() {
|
|
1130
|
+
recentCoordArtifacts.clear();
|
|
1131
|
+
}
|
|
1132
|
+
async function captureTapArtifact(ctx) {
|
|
1133
|
+
try {
|
|
1134
|
+
const { getServerVersion, categorizeError } = await import("../core/telemetry.js");
|
|
1135
|
+
const strategyChain = ctx.attempted.map(a => `${a.strategy}:${a.reason.slice(0, 40)}`).join("|");
|
|
1136
|
+
// Resolve error category up-front so we can short-circuit driver-missing failures.
|
|
1137
|
+
// These aren't tap-tool bugs — they're host setup problems (no idb/axe/adb on PATH).
|
|
1138
|
+
// Skipping the artifact upload saves R2 storage and stops these from polluting the
|
|
1139
|
+
// failure dashboard, which is the signal we use to triage genuine tap regressions.
|
|
1140
|
+
const errorCategory = ctx.errorCategory
|
|
1141
|
+
?? categorizeError(ctx.errorMessage ?? "", strategyChain);
|
|
1142
|
+
if (errorCategory === "driver_missing") {
|
|
1143
|
+
return undefined;
|
|
1144
|
+
}
|
|
1145
|
+
// D1 (Step 6): no-metro unmeaningful taps have no diagnostic signal
|
|
1146
|
+
// beyond a screenshot diff that's already below threshold by definition.
|
|
1147
|
+
// ~50% of recent failure-artifact rows were these — they crowd out real
|
|
1148
|
+
// failures in the dashboard. Telemetry row still flows; just no R2 bundle.
|
|
1149
|
+
const noMetro = ctx.evidence ? !ctx.evidence.fiber.metroConnected : connectedApps.size === 0;
|
|
1150
|
+
if (ctx.outcome === "unmeaningful" && noMetro) {
|
|
1151
|
+
return undefined;
|
|
1152
|
+
}
|
|
1153
|
+
// D2 (Step 6): retry deduplication — coord-strategy taps at the same
|
|
1154
|
+
// grid cell within 60s upload the same near-identical artifact. The
|
|
1155
|
+
// first capture is enough; subsequent ones are noise.
|
|
1156
|
+
if (shouldDedupArtifact(ctx)) {
|
|
1157
|
+
return undefined;
|
|
1158
|
+
}
|
|
1159
|
+
const result = await captureFailureArtifact({
|
|
1160
|
+
outcome: ctx.outcome,
|
|
1161
|
+
predicate: ctx.query,
|
|
1162
|
+
errorMessage: ctx.errorMessage,
|
|
1163
|
+
errorCategory,
|
|
1164
|
+
strategyChain,
|
|
1165
|
+
sessionId: "",
|
|
1166
|
+
version: getServerVersion(),
|
|
1167
|
+
changeRate: ctx.verification?.changeRate,
|
|
1168
|
+
meaningful: ctx.verification?.meaningful,
|
|
1169
|
+
senses: ctx.evidence
|
|
1170
|
+
? {
|
|
1171
|
+
ocr: {
|
|
1172
|
+
ran: ctx.evidence.ocr.ran,
|
|
1173
|
+
durationMs: ctx.evidence.ocr.durationMs,
|
|
1174
|
+
detections: ctx.evidence.ocr.detections,
|
|
1175
|
+
closestMatch: ctx.evidence.ocr.closestMatch
|
|
1176
|
+
},
|
|
1177
|
+
fiber: {
|
|
1178
|
+
ran: ctx.evidence.fiber.ran,
|
|
1179
|
+
durationMs: ctx.evidence.fiber.durationMs,
|
|
1180
|
+
metroConnected: ctx.evidence.fiber.metroConnected,
|
|
1181
|
+
pressables: ctx.evidence.fiber.pressables.map(p => ({
|
|
1182
|
+
label: p.label,
|
|
1183
|
+
testID: p.testID,
|
|
1184
|
+
componentName: p.componentName,
|
|
1185
|
+
bounds: p.bounds
|
|
1186
|
+
? [p.bounds.x, p.bounds.y, p.bounds.width, p.bounds.height]
|
|
1187
|
+
: undefined
|
|
1188
|
+
}))
|
|
1189
|
+
},
|
|
1190
|
+
accessibility: {
|
|
1191
|
+
ran: ctx.evidence.accessibility.ran,
|
|
1192
|
+
durationMs: ctx.evidence.accessibility.durationMs,
|
|
1193
|
+
elements: ctx.evidence.accessibility.elements.map(el => ({
|
|
1194
|
+
label: el.label,
|
|
1195
|
+
testID: el.testID,
|
|
1196
|
+
frame: el.frame
|
|
1197
|
+
? [el.frame.x, el.frame.y, el.frame.width, el.frame.height]
|
|
1198
|
+
: undefined
|
|
1199
|
+
}))
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
: {
|
|
1203
|
+
ocr: { ran: false, durationMs: 0, detections: [], closestMatch: null },
|
|
1204
|
+
fiber: {
|
|
1205
|
+
ran: ctx.attempted.some(a => a.strategy === "fiber"),
|
|
1206
|
+
durationMs: 0,
|
|
1207
|
+
metroConnected: connectedApps.size > 0,
|
|
1208
|
+
pressables: (ctx.fiberMatches || []).slice(0, 10).map(m => ({
|
|
1209
|
+
label: m.text || undefined,
|
|
1210
|
+
testID: m.testID || undefined,
|
|
1211
|
+
componentName: m.component
|
|
1212
|
+
}))
|
|
1213
|
+
},
|
|
1214
|
+
accessibility: {
|
|
1215
|
+
ran: ctx.attempted.some(a => a.strategy === "accessibility"),
|
|
1216
|
+
durationMs: 0,
|
|
1217
|
+
elements: []
|
|
1218
|
+
}
|
|
1219
|
+
},
|
|
1220
|
+
chosenTapPoint: ctx.chosenTapPoint ?? null,
|
|
1221
|
+
chosenElement: null,
|
|
1222
|
+
screenshots: {
|
|
1223
|
+
before: ctx.screenshotBuffer ?? null,
|
|
1224
|
+
afterWithMarker: ctx.afterWithMarker ?? null
|
|
1225
|
+
},
|
|
1226
|
+
deviceMeta: {
|
|
1227
|
+
platform: ctx.platform,
|
|
1228
|
+
driver: ctx.iosDriver,
|
|
1229
|
+
screenSize: { w: ctx.screenshotMeta?.width || 0, h: ctx.screenshotMeta?.height || 0 }
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
return result.signals;
|
|
1233
|
+
}
|
|
1234
|
+
catch {
|
|
1235
|
+
return undefined;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
function attachArtifactSignals(result, signals) {
|
|
1239
|
+
if (!signals)
|
|
1240
|
+
return result;
|
|
1241
|
+
if (signals.artifactKey)
|
|
1242
|
+
result.artifactKey = signals.artifactKey;
|
|
1243
|
+
if (signals.ocrClosestMatch)
|
|
1244
|
+
result.ocrClosestMatch = signals.ocrClosestMatch;
|
|
1245
|
+
if (signals.fiberPressableCount)
|
|
1246
|
+
result.fiberPressableCount = signals.fiberPressableCount;
|
|
1247
|
+
if (signals.accessibilityMatchCount)
|
|
1248
|
+
result.accessibilityMatchCount = signals.accessibilityMatchCount;
|
|
1249
|
+
if (signals.appRoute)
|
|
1250
|
+
result.appRoute = signals.appRoute;
|
|
1251
|
+
// Append agent-facing hints to error message
|
|
1252
|
+
if (result.error) {
|
|
1253
|
+
if (signals.ocrClosestMatch) {
|
|
1254
|
+
result.error = `${result.error}\nClosest OCR match: ${signals.ocrClosestMatch}`;
|
|
1255
|
+
}
|
|
1256
|
+
if (signals.nearbyPressables.length > 0) {
|
|
1257
|
+
const labels = signals.nearbyPressables
|
|
1258
|
+
.map(p => p.testID || p.label)
|
|
1259
|
+
.filter(Boolean)
|
|
1260
|
+
.slice(0, 3)
|
|
1261
|
+
.join(", ");
|
|
1262
|
+
if (labels)
|
|
1263
|
+
result.error = `${result.error}\nNearby pressables: ${labels}`;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
return result;
|
|
1267
|
+
}
|
|
1268
|
+
// --- Orchestrator ---
|
|
1269
|
+
export async function tap(options) {
|
|
1270
|
+
const query = buildQuery(options);
|
|
1271
|
+
const strategy = options.strategy || "auto";
|
|
1272
|
+
const index = options.index;
|
|
1273
|
+
const maxTraversalDepth = options.maxTraversalDepth;
|
|
1274
|
+
const deadline = Date.now() + TAP_TIMEOUT_MS;
|
|
1275
|
+
const remainingMs = () => Math.max(0, deadline - Date.now());
|
|
1276
|
+
// Validate inputs
|
|
1277
|
+
const hasSearchParam = query.text || query.testID || query.component;
|
|
1278
|
+
const hasCoordinates = query.x !== undefined || query.y !== undefined;
|
|
1279
|
+
if (!hasSearchParam && !hasCoordinates) {
|
|
1280
|
+
return {
|
|
1281
|
+
success: false,
|
|
1282
|
+
query,
|
|
1283
|
+
error: "Must provide at least one of: text, testID, component, or x/y coordinates"
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
if (hasCoordinates && (query.x === undefined || query.y === undefined)) {
|
|
1287
|
+
return {
|
|
1288
|
+
success: false,
|
|
1289
|
+
query,
|
|
1290
|
+
error: "Both x and y coordinates must be provided"
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
// Native mode: bypass React Native connection, tap directly via ADB/simctl
|
|
1294
|
+
if (options.native && hasCoordinates) {
|
|
1295
|
+
const nativeResolved = await resolveDeviceTarget(options.device);
|
|
1296
|
+
if (!nativeResolved.ok) {
|
|
1297
|
+
return {
|
|
1298
|
+
success: false,
|
|
1299
|
+
query,
|
|
1300
|
+
error: formatResolverError(nativeResolved.error)
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
const platform = nativeResolved.target.platform;
|
|
1304
|
+
const nativeUdid = nativeResolved.target.iosUdid;
|
|
1305
|
+
const nativeShouldScreenshot = options.screenshot !== false;
|
|
1306
|
+
// Decoupled (I5, 2026-05-16): verify runs even when image bytes aren't returned.
|
|
1307
|
+
const nativeShouldVerify = options.verify !== false;
|
|
1308
|
+
let nativeBeforeBuffer = null;
|
|
1309
|
+
let nativeScreenshotMeta;
|
|
1310
|
+
if (nativeShouldVerify) {
|
|
1311
|
+
const before = await captureScreenshot(platform, nativeUdid);
|
|
1312
|
+
nativeBeforeBuffer = before?.buffer || null;
|
|
1313
|
+
if (before) {
|
|
1314
|
+
nativeScreenshotMeta = {
|
|
1315
|
+
originalWidth: before.width,
|
|
1316
|
+
originalHeight: before.height,
|
|
1317
|
+
scaleFactor: before.scaleFactor
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
// If no screenshot was taken for verification, take one just for scaleFactor
|
|
1322
|
+
if (!nativeScreenshotMeta) {
|
|
1323
|
+
const ref = await captureScreenshot(platform, nativeUdid);
|
|
1324
|
+
if (ref) {
|
|
1325
|
+
nativeScreenshotMeta = {
|
|
1326
|
+
originalWidth: ref.width,
|
|
1327
|
+
originalHeight: ref.height,
|
|
1328
|
+
scaleFactor: ref.scaleFactor
|
|
1329
|
+
};
|
|
1330
|
+
// Also use it for verification if buffer is needed
|
|
1331
|
+
if (!nativeBeforeBuffer) {
|
|
1332
|
+
nativeBeforeBuffer = ref.buffer;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
let result;
|
|
1337
|
+
try {
|
|
1338
|
+
result = await withTimeout(tryCoordinateStrategy(query.x, query.y, platform, nativeScreenshotMeta, nativeUdid), remainingMs(), "native-coordinate");
|
|
1339
|
+
}
|
|
1340
|
+
catch (err) {
|
|
1341
|
+
return formatTapFailure({
|
|
1342
|
+
query,
|
|
1343
|
+
attempted: [
|
|
1344
|
+
{
|
|
1345
|
+
strategy: "native-coordinate",
|
|
1346
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
1347
|
+
}
|
|
1348
|
+
],
|
|
1349
|
+
suggestion: `Tap timed out. Take a screenshot (${platform === "ios" ? "ios_screenshot" : "android_screenshot"}) and retry with coordinates.`
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
if (result.success) {
|
|
1353
|
+
let screenshot;
|
|
1354
|
+
let verification;
|
|
1355
|
+
const nativeMarker = computeMarkerPx({
|
|
1356
|
+
strategy: "native-coordinate",
|
|
1357
|
+
input: { x: query.x, y: query.y },
|
|
1358
|
+
platform,
|
|
1359
|
+
screenshotScale: nativeScreenshotMeta?.scaleFactor || 1
|
|
1360
|
+
});
|
|
1361
|
+
if (options.burst && nativeShouldVerify && nativeBeforeBuffer) {
|
|
1362
|
+
({ screenshot, verification } = await burstCaptureAndVerify({
|
|
1363
|
+
platform,
|
|
1364
|
+
beforeBuffer: nativeBeforeBuffer,
|
|
1365
|
+
udid: nativeUdid,
|
|
1366
|
+
beforeScaleFactor: nativeScreenshotMeta?.scaleFactor,
|
|
1367
|
+
markerPx: nativeMarker
|
|
1368
|
+
}));
|
|
1369
|
+
if (!nativeShouldScreenshot)
|
|
1370
|
+
screenshot = undefined;
|
|
1371
|
+
}
|
|
1372
|
+
else {
|
|
1373
|
+
({ screenshot, verification } = await verifyAndCapture({
|
|
1374
|
+
platform,
|
|
1375
|
+
shouldVerify: nativeShouldVerify,
|
|
1376
|
+
shouldScreenshot: nativeShouldScreenshot,
|
|
1377
|
+
beforeBuffer: nativeBeforeBuffer,
|
|
1378
|
+
udid: nativeUdid,
|
|
1379
|
+
beforeScaleFactor: nativeScreenshotMeta?.scaleFactor,
|
|
1380
|
+
markerPx: nativeMarker
|
|
1381
|
+
}));
|
|
1382
|
+
}
|
|
1383
|
+
return formatTapSuccess({
|
|
1384
|
+
method: "native-coordinate",
|
|
1385
|
+
query,
|
|
1386
|
+
pressed: result.pressed,
|
|
1387
|
+
convertedTo: result.convertedTo,
|
|
1388
|
+
platform,
|
|
1389
|
+
screenshot,
|
|
1390
|
+
verification
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
return formatTapFailure({
|
|
1394
|
+
query,
|
|
1395
|
+
attempted: [{ strategy: "native-coordinate", reason: result.reason }],
|
|
1396
|
+
suggestion: `Take a screenshot (${platform === "ios" ? "ios_screenshot" : "android_screenshot"}) to verify coordinates.`
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
// Resolve device target via the unified resolver. Replaces the prior chain
|
|
1400
|
+
// of (udid → connectedApp → findSimulatorByName) lookups and the standalone
|
|
1401
|
+
// "no platform → probe OS for devices" branch. Returns a structured error
|
|
1402
|
+
// when ambiguous; surface that to the caller so they can disambiguate.
|
|
1403
|
+
const resolved = await resolveDeviceTarget(options.device);
|
|
1404
|
+
if (!resolved.ok) {
|
|
1405
|
+
return {
|
|
1406
|
+
success: false,
|
|
1407
|
+
query,
|
|
1408
|
+
error: formatResolverError(resolved.error)
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
const platform = resolved.target.platform;
|
|
1412
|
+
let targetUdid = resolved.target.iosUdid;
|
|
1413
|
+
// Pick the connected app to bias strategy selection. Prefer the registry
|
|
1414
|
+
// entry whose identifier matches the resolved target; fall back to a
|
|
1415
|
+
// device-name match (handy when registry entries lack identifiers yet);
|
|
1416
|
+
// last resort is the first connected app on the matching platform.
|
|
1417
|
+
const allApps = Array.from(connectedApps.values());
|
|
1418
|
+
let hasMetro = allApps.length > 0;
|
|
1419
|
+
let app;
|
|
1420
|
+
if (resolved.target.iosUdid) {
|
|
1421
|
+
app = allApps.find((a) => a.platform === "ios" && a.simulatorUdid === resolved.target.iosUdid);
|
|
1422
|
+
}
|
|
1423
|
+
else if (resolved.target.androidSerial) {
|
|
1424
|
+
app = allApps.find((a) => a.platform === "android" && a.adbSerial === resolved.target.androidSerial);
|
|
1425
|
+
}
|
|
1426
|
+
if (!app && options.device) {
|
|
1427
|
+
try {
|
|
1428
|
+
app = getConnectedAppByDevice(options.device) ?? undefined;
|
|
1429
|
+
}
|
|
1430
|
+
catch {
|
|
1431
|
+
// getConnectedAppByDevice throws on ambiguous matches; the resolver
|
|
1432
|
+
// would have already returned that error, so reaching here means
|
|
1433
|
+
// the resolved target's identifier just doesn't appear in the
|
|
1434
|
+
// registry yet — fall through to the platform-first pick below.
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
if (!app) {
|
|
1438
|
+
app = allApps.find((a) => a.platform === platform) ?? allApps[0];
|
|
1439
|
+
}
|
|
1440
|
+
// Try to auto-connect to Metro (for fiber strategy), but don't fail if it doesn't work.
|
|
1441
|
+
if (!hasMetro) {
|
|
1442
|
+
try {
|
|
1443
|
+
await withTimeout((async () => {
|
|
1444
|
+
clearReconnectionSuppression();
|
|
1445
|
+
const openPorts = await scanMetroPorts();
|
|
1446
|
+
for (const port of openPorts) {
|
|
1447
|
+
const devices = await fetchDevices(port);
|
|
1448
|
+
const mainDevice = selectMainDevice(devices);
|
|
1449
|
+
if (mainDevice) {
|
|
1450
|
+
await connectToDevice(mainDevice, port);
|
|
1451
|
+
break;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
})(), Math.min(remainingMs(), 3000), "auto-connect");
|
|
1455
|
+
const apps = Array.from(connectedApps.values());
|
|
1456
|
+
hasMetro = apps.length > 0;
|
|
1457
|
+
app = apps.find((a) => a.platform === platform) ?? apps[0];
|
|
1458
|
+
if (platform === "ios" && !targetUdid) {
|
|
1459
|
+
targetUdid = app?.simulatorUdid ?? targetUdid;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
catch {
|
|
1463
|
+
// Auto-connect failed — Metro-dependent strategies will be skipped
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
// The response's `device` label must reflect the RESOLVED target, not the
|
|
1467
|
+
// registry-app fallback. When the registry lookup couldn't find a matching
|
|
1468
|
+
// app (e.g. simulatorUdid hadn't been backfilled yet), `app` lands on the
|
|
1469
|
+
// first iOS entry — which on a multi-sim setup is the WRONG device. The
|
|
1470
|
+
// resolver already knows the right deviceName; trust it. Bug #5 (2026-05-20).
|
|
1471
|
+
const deviceName = resolved.target.deviceName || app?.deviceInfo?.deviceName;
|
|
1472
|
+
// Determine strategies
|
|
1473
|
+
const strategies = getAvailableStrategies(query, strategy);
|
|
1474
|
+
const attempted = [];
|
|
1475
|
+
const evidence = makeEmptyEvidenceSink();
|
|
1476
|
+
// Early UI driver check for iOS — fail fast instead of falling through every strategy
|
|
1477
|
+
const UI_DRIVER_REQUIRED_STRATEGIES = ["accessibility", "ocr", "coordinate"];
|
|
1478
|
+
let uiDriverMissing = false;
|
|
1479
|
+
if (platform === "ios") {
|
|
1480
|
+
uiDriverMissing = !(await isUiDriverAvailable());
|
|
1481
|
+
}
|
|
1482
|
+
// Filter strategies by available capabilities
|
|
1483
|
+
const filteredStrategies = strategies.filter((strat) => {
|
|
1484
|
+
if (strat === "fiber" && !hasMetro) {
|
|
1485
|
+
attempted.push({
|
|
1486
|
+
strategy: "fiber",
|
|
1487
|
+
reason: "Skipped — no Metro connection (required for fiber)",
|
|
1488
|
+
outcome: "skipped"
|
|
1489
|
+
});
|
|
1490
|
+
return false;
|
|
1491
|
+
}
|
|
1492
|
+
if (uiDriverMissing && UI_DRIVER_REQUIRED_STRATEGIES.includes(strat)) {
|
|
1493
|
+
attempted.push({
|
|
1494
|
+
strategy: strat,
|
|
1495
|
+
reason: "Skipped — iOS UI driver is not installed (required for iOS tap/accessibility/OCR)",
|
|
1496
|
+
outcome: "skipped"
|
|
1497
|
+
});
|
|
1498
|
+
return false;
|
|
1499
|
+
}
|
|
1500
|
+
return true;
|
|
1501
|
+
});
|
|
1502
|
+
if (filteredStrategies.length === 0) {
|
|
1503
|
+
if (uiDriverMissing) {
|
|
1504
|
+
notifyDriverMissing("ios");
|
|
1505
|
+
}
|
|
1506
|
+
const errorMessage = uiDriverMissing
|
|
1507
|
+
? `Cannot tap on iOS Simulator — ${getUiDriverInstallHint()}\n\nThe iOS UI driver is required for tapping, swiping, text input, and accessibility queries on iOS Simulators.\n\nAfter installing, retry the tap.`
|
|
1508
|
+
: "All strategies require Metro connection, which is unavailable.\n\nTo fix:\n1. Make sure your React Native app is running\n2. Run scan_metro to connect\n3. Or use tap(x, y, native=true) for coordinate-based taps";
|
|
1509
|
+
return {
|
|
1510
|
+
success: false,
|
|
1511
|
+
query,
|
|
1512
|
+
attempted,
|
|
1513
|
+
error: errorMessage
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
// Determine screenshot and verification behavior.
|
|
1517
|
+
// Decoupled (I5, 2026-05-16): verify can run without returning image bytes —
|
|
1518
|
+
// capture cost is paid either way; bandwidth cost is what `screenshot` toggles.
|
|
1519
|
+
const shouldScreenshot = options.screenshot !== false;
|
|
1520
|
+
const canVerify = options.verify !== false;
|
|
1521
|
+
// Capture "before" screenshot. Always attempted (independent of canVerify)
|
|
1522
|
+
// so failure artifacts always carry a before.png — the diagnostic value of
|
|
1523
|
+
// the before frame doesn't depend on whether we run the post-tap diff.
|
|
1524
|
+
let beforeBuffer = null;
|
|
1525
|
+
let beforeScaleFactor;
|
|
1526
|
+
{
|
|
1527
|
+
const before = await captureScreenshot(platform, targetUdid);
|
|
1528
|
+
beforeBuffer = before?.buffer || null;
|
|
1529
|
+
beforeScaleFactor = before?.scaleFactor;
|
|
1530
|
+
}
|
|
1531
|
+
// OCR runs lazily when the loop reaches it (see the `case "ocr"` branch).
|
|
1532
|
+
// A concurrent pre-warm probe used to fire here to shave ~5s off OCR-win
|
|
1533
|
+
// rows, but it dispatched a paid Google Vision request at t=0 on every
|
|
1534
|
+
// text-predicate tap — and since cloud OCR (~200ms) finishes well before
|
|
1535
|
+
// the higher-priority strategy that usually wins, the post-win abort came
|
|
1536
|
+
// too late to cancel the billed request. OCR wins only ~1.9% of taps, so
|
|
1537
|
+
// the pre-warm paid for cloud Vision on ~42% of eligible taps to help a
|
|
1538
|
+
// tiny minority. Removed 2026-06-02; the timeout-recovery path below still
|
|
1539
|
+
// salvages the perfect-match-past-cap case.
|
|
1540
|
+
// Execute strategies in order with per-strategy caps and overall budget
|
|
1541
|
+
for (const strat of filteredStrategies) {
|
|
1542
|
+
const remaining = remainingMs();
|
|
1543
|
+
if (remaining < MIN_STRATEGY_BUDGET_MS) {
|
|
1544
|
+
attempted.push({
|
|
1545
|
+
strategy: strat,
|
|
1546
|
+
reason: `Skipped — only ${remaining}ms remaining (budget ${TAP_TIMEOUT_MS}ms)`,
|
|
1547
|
+
outcome: "skipped"
|
|
1548
|
+
});
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
const cap = maxStrategyMs(strat, platform);
|
|
1552
|
+
const budget = Math.min(cap, remaining);
|
|
1553
|
+
let result;
|
|
1554
|
+
try {
|
|
1555
|
+
switch (strat) {
|
|
1556
|
+
case "fiber":
|
|
1557
|
+
// Fiber is JS-only against a CDP target — no subprocess to cancel,
|
|
1558
|
+
// so the cheaper non-cancellable wrapper is fine.
|
|
1559
|
+
result = await withTimeout(tryFiberStrategy(query, index, maxTraversalDepth, evidence, deviceName), budget, `fiber`);
|
|
1560
|
+
break;
|
|
1561
|
+
case "accessibility":
|
|
1562
|
+
result = await withCancelableTimeout((signal) => tryAccessibilityStrategy(query, index, platform, targetUdid, evidence, signal), budget, `accessibility`);
|
|
1563
|
+
break;
|
|
1564
|
+
case "ocr":
|
|
1565
|
+
result = await withCancelableTimeout((signal) => tryOcrStrategy(query, platform, targetUdid, evidence, signal), budget, `ocr`);
|
|
1566
|
+
break;
|
|
1567
|
+
case "coordinate":
|
|
1568
|
+
// Prefer `beforeScaleFactor` (captured against `targetUdid` this turn)
|
|
1569
|
+
// over `app.lastScreenshot.scaleFactor` (stale and may belong to a
|
|
1570
|
+
// different device on a multi-sim setup). Bug #5 (2026-05-20).
|
|
1571
|
+
result = await withTimeout(tryCoordinateStrategy(query.x, query.y, platform, beforeScaleFactor != null
|
|
1572
|
+
? { originalWidth: 0, originalHeight: 0, scaleFactor: beforeScaleFactor }
|
|
1573
|
+
: app?.lastScreenshot, targetUdid), budget, `coordinate`);
|
|
1574
|
+
break;
|
|
1575
|
+
default:
|
|
1576
|
+
result = { success: false, reason: `Unknown strategy: ${strat}` };
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
catch (err) {
|
|
1580
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1581
|
+
// Classify the throw: a strategy-level timeout means "result UNKNOWN"
|
|
1582
|
+
// (the strategy didn't finish), distinct from "ran clean and found
|
|
1583
|
+
// nothing". Agents must not infer absence from timeouts. The
|
|
1584
|
+
// `outcome` field carries that distinction; `reason` stays the raw
|
|
1585
|
+
// message so existing log-matchers / classifiers keep working.
|
|
1586
|
+
const outcome = STRATEGY_TIMEOUT_RE.test(reason) ? "timeout" : "error";
|
|
1587
|
+
attempted.push({ strategy: strat, reason, outcome });
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1590
|
+
if (result.success) {
|
|
1591
|
+
let screenshot;
|
|
1592
|
+
let verification;
|
|
1593
|
+
let afterWithMarkerBuffer;
|
|
1594
|
+
let dprForMarker;
|
|
1595
|
+
if (platform === "ios") {
|
|
1596
|
+
try {
|
|
1597
|
+
const { getDevicePixelRatio } = await import("../core/ios.js");
|
|
1598
|
+
dprForMarker = await getDevicePixelRatio(targetUdid);
|
|
1599
|
+
}
|
|
1600
|
+
catch {
|
|
1601
|
+
dprForMarker = 3;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
const strategyMarker = computeMarkerPx({
|
|
1605
|
+
strategy: strat,
|
|
1606
|
+
input: strat === "coordinate" ? { x: query.x, y: query.y } : undefined,
|
|
1607
|
+
convertedTo: result.convertedTo,
|
|
1608
|
+
platform,
|
|
1609
|
+
screenshotScale: beforeScaleFactor || app?.lastScreenshot?.scaleFactor || 1,
|
|
1610
|
+
devicePixelRatio: dprForMarker
|
|
1611
|
+
});
|
|
1612
|
+
if (options.burst && canVerify && beforeBuffer) {
|
|
1613
|
+
({ screenshot, verification, afterWithMarkerBuffer } = await burstCaptureAndVerify({
|
|
1614
|
+
platform,
|
|
1615
|
+
beforeBuffer,
|
|
1616
|
+
udid: targetUdid,
|
|
1617
|
+
beforeScaleFactor,
|
|
1618
|
+
markerPx: strategyMarker
|
|
1619
|
+
}));
|
|
1620
|
+
// Burst always produces a screenshot for diff visualization. Drop the
|
|
1621
|
+
// bytes when the caller didn't ask for them AND the tap was meaningful
|
|
1622
|
+
// — keeping the screenshot on unmeaningful taps saves the agent a
|
|
1623
|
+
// round-trip when it needs to diagnose. Frames remain in imageBuffer
|
|
1624
|
+
// either way (accessible via get_images(groupId=verification.burstGroupId)).
|
|
1625
|
+
if (!shouldScreenshot && verification?.meaningful !== false)
|
|
1626
|
+
screenshot = undefined;
|
|
1627
|
+
}
|
|
1628
|
+
else {
|
|
1629
|
+
({ screenshot, verification, afterWithMarkerBuffer } = await verifyAndCapture({
|
|
1630
|
+
platform,
|
|
1631
|
+
shouldVerify: canVerify,
|
|
1632
|
+
shouldScreenshot: true,
|
|
1633
|
+
beforeBuffer,
|
|
1634
|
+
udid: targetUdid,
|
|
1635
|
+
beforeScaleFactor,
|
|
1636
|
+
markerPx: strategyMarker
|
|
1637
|
+
}));
|
|
1638
|
+
// Mirror the burst-path gate: drop the screenshot only when the
|
|
1639
|
+
// caller didn't ask for it AND the tap was meaningful.
|
|
1640
|
+
if (!shouldScreenshot && verification?.meaningful !== false)
|
|
1641
|
+
screenshot = undefined;
|
|
1642
|
+
}
|
|
1643
|
+
if (screenshot && app) {
|
|
1644
|
+
app.lastScreenshot = {
|
|
1645
|
+
originalWidth: screenshot.width,
|
|
1646
|
+
originalHeight: screenshot.height,
|
|
1647
|
+
scaleFactor: screenshot.scaleFactor
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
const successResult = formatTapSuccess({
|
|
1651
|
+
method: strat,
|
|
1652
|
+
query,
|
|
1653
|
+
pressed: result.pressed,
|
|
1654
|
+
text: result.text,
|
|
1655
|
+
screen: result.screen,
|
|
1656
|
+
path: result.path,
|
|
1657
|
+
component: result.component,
|
|
1658
|
+
convertedTo: result.convertedTo,
|
|
1659
|
+
platform,
|
|
1660
|
+
device: deviceName,
|
|
1661
|
+
screenshot,
|
|
1662
|
+
verification
|
|
1663
|
+
});
|
|
1664
|
+
// Capture an artifact for "successful but unmeaningful" taps so we can
|
|
1665
|
+
// diagnose taps that landed wrong or hit non-responsive elements.
|
|
1666
|
+
// Uses the same `meaningful` flag the agent sees so dashboard outcomes
|
|
1667
|
+
// stay consistent with what the caller observed.
|
|
1668
|
+
if (verification && !verification.skipped && verification.meaningful === false) {
|
|
1669
|
+
const unmeaningfulSignals = await captureTapArtifact({
|
|
1670
|
+
query,
|
|
1671
|
+
outcome: "unmeaningful",
|
|
1672
|
+
attempted: [...attempted, { strategy: strat, reason: "success" }],
|
|
1673
|
+
platform,
|
|
1674
|
+
iosDriver: platform === "ios" ? (process.env.IOS_DRIVER?.toLowerCase() || "axe") : undefined,
|
|
1675
|
+
deviceName,
|
|
1676
|
+
screenshotMeta: screenshot ? { width: screenshot.width, height: screenshot.height } : undefined,
|
|
1677
|
+
screenshotBuffer: beforeBuffer,
|
|
1678
|
+
afterWithMarker: afterWithMarkerBuffer ?? null,
|
|
1679
|
+
chosenTapPoint: strategyMarker ? { x: strategyMarker.x, y: strategyMarker.y } : null,
|
|
1680
|
+
verification,
|
|
1681
|
+
evidence
|
|
1682
|
+
});
|
|
1683
|
+
attachArtifactSignals(successResult, unmeaningfulSignals);
|
|
1684
|
+
}
|
|
1685
|
+
return successResult;
|
|
1686
|
+
}
|
|
1687
|
+
// Classify the strategy's non-success result. The reason string carries
|
|
1688
|
+
// the diagnostic from the strategy; outcome lets agents branch on category
|
|
1689
|
+
// without parsing prose. "invisible" / "ambiguous" are pinned by substring
|
|
1690
|
+
// matches the fiber strategy emits via pressables.ts.
|
|
1691
|
+
const reasonStr = result.reason || "";
|
|
1692
|
+
let attemptOutcome = "not-found";
|
|
1693
|
+
if (result.ambiguous)
|
|
1694
|
+
attemptOutcome = "ambiguous";
|
|
1695
|
+
else if (reasonStr.indexOf("but none are visible") !== -1)
|
|
1696
|
+
attemptOutcome = "invisible";
|
|
1697
|
+
else if (STRATEGY_TIMEOUT_RE.test(reasonStr))
|
|
1698
|
+
attemptOutcome = "timeout";
|
|
1699
|
+
attempted.push({ strategy: strat, reason: reasonStr, outcome: attemptOutcome });
|
|
1700
|
+
// If fiber found an element with measured coordinates, do a native tap directly
|
|
1701
|
+
if (strat === "fiber" && result.convertedTo && result.pressed) {
|
|
1702
|
+
try {
|
|
1703
|
+
const coords = result.convertedTo;
|
|
1704
|
+
if (platform === "ios") {
|
|
1705
|
+
// react-native-screens modal/sheet presentations cause measureInWindow to
|
|
1706
|
+
// return y relative to the screen's content origin, not the window. If the
|
|
1707
|
+
// measured y falls inside the safe-area band, shift it down by the inset.
|
|
1708
|
+
const safeAreaTop = await getIOSSafeAreaTop(targetUdid);
|
|
1709
|
+
const tapY = (safeAreaTop > 0 && coords.y < safeAreaTop) ? coords.y + safeAreaTop : coords.y;
|
|
1710
|
+
await iosTap(coords.x, tapY, { udid: targetUdid });
|
|
1711
|
+
coords.y = tapY;
|
|
1712
|
+
}
|
|
1713
|
+
else {
|
|
1714
|
+
// Fabric returns dp — androidTap expects pixels
|
|
1715
|
+
// Convert dp to pixels using device density
|
|
1716
|
+
const { androidGetDensity } = await import("../core/android.js");
|
|
1717
|
+
const densityResult = await androidGetDensity();
|
|
1718
|
+
const densityScale = (densityResult.density || 420) / 160;
|
|
1719
|
+
const pxX = Math.round(coords.x * densityScale);
|
|
1720
|
+
const pxY = Math.round(coords.y * densityScale);
|
|
1721
|
+
await androidTap(pxX, pxY);
|
|
1722
|
+
// Report the actual tap coords (pixels) the caller can pass straight
|
|
1723
|
+
// to coordinate tools / verification — not the raw dp from fiber.
|
|
1724
|
+
// Matches every other Android coord report in the tool (OB1, 2026-05-20).
|
|
1725
|
+
coords.x = pxX;
|
|
1726
|
+
coords.y = pxY;
|
|
1727
|
+
coords.unit = "pixels";
|
|
1728
|
+
}
|
|
1729
|
+
// fiber+native uses native tap — always verify
|
|
1730
|
+
let screenshot;
|
|
1731
|
+
let verification;
|
|
1732
|
+
let fnDpr;
|
|
1733
|
+
let fnDensity;
|
|
1734
|
+
if (platform === "ios") {
|
|
1735
|
+
try {
|
|
1736
|
+
const { getDevicePixelRatio } = await import("../core/ios.js");
|
|
1737
|
+
fnDpr = await getDevicePixelRatio(targetUdid);
|
|
1738
|
+
}
|
|
1739
|
+
catch {
|
|
1740
|
+
fnDpr = 3;
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
else {
|
|
1744
|
+
try {
|
|
1745
|
+
const { androidGetDensity } = await import("../core/android.js");
|
|
1746
|
+
const d = await androidGetDensity();
|
|
1747
|
+
fnDensity = (d.density || 420) / 160;
|
|
1748
|
+
}
|
|
1749
|
+
catch {
|
|
1750
|
+
fnDensity = undefined;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
const fiberMarker = computeMarkerPx({
|
|
1754
|
+
strategy: "fiber+native",
|
|
1755
|
+
convertedTo: coords,
|
|
1756
|
+
platform,
|
|
1757
|
+
screenshotScale: beforeScaleFactor || app?.lastScreenshot?.scaleFactor || 1,
|
|
1758
|
+
devicePixelRatio: fnDpr,
|
|
1759
|
+
androidDensityScale: fnDensity
|
|
1760
|
+
});
|
|
1761
|
+
if (options.burst && canVerify && beforeBuffer) {
|
|
1762
|
+
({ screenshot, verification } = await burstCaptureAndVerify({
|
|
1763
|
+
platform,
|
|
1764
|
+
beforeBuffer,
|
|
1765
|
+
udid: targetUdid,
|
|
1766
|
+
beforeScaleFactor,
|
|
1767
|
+
markerPx: fiberMarker
|
|
1768
|
+
}));
|
|
1769
|
+
}
|
|
1770
|
+
else {
|
|
1771
|
+
({ screenshot, verification } = await verifyAndCapture({
|
|
1772
|
+
platform,
|
|
1773
|
+
shouldVerify: canVerify,
|
|
1774
|
+
shouldScreenshot: true,
|
|
1775
|
+
beforeBuffer,
|
|
1776
|
+
udid: targetUdid,
|
|
1777
|
+
beforeScaleFactor,
|
|
1778
|
+
markerPx: fiberMarker
|
|
1779
|
+
}));
|
|
1780
|
+
}
|
|
1781
|
+
if (!shouldScreenshot && verification?.meaningful !== false)
|
|
1782
|
+
screenshot = undefined;
|
|
1783
|
+
if (screenshot && app) {
|
|
1784
|
+
app.lastScreenshot = {
|
|
1785
|
+
originalWidth: screenshot.width,
|
|
1786
|
+
originalHeight: screenshot.height,
|
|
1787
|
+
scaleFactor: screenshot.scaleFactor
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
return formatTapSuccess({
|
|
1791
|
+
method: "fiber+native",
|
|
1792
|
+
query,
|
|
1793
|
+
pressed: result.pressed,
|
|
1794
|
+
text: result.text,
|
|
1795
|
+
path: result.path,
|
|
1796
|
+
component: result.component,
|
|
1797
|
+
convertedTo: coords,
|
|
1798
|
+
platform,
|
|
1799
|
+
device: deviceName,
|
|
1800
|
+
screenshot,
|
|
1801
|
+
verification
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
catch {
|
|
1805
|
+
// Native tap at fiber coordinates failed — continue to next strategy
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
// Ambiguous fiber result — multiple elements matched, no index given.
|
|
1809
|
+
// Return immediately with the full list so the agent can decide.
|
|
1810
|
+
// Do NOT fall through to other strategies (they can't resolve ambiguity).
|
|
1811
|
+
if (result.matches && result.ambiguous) {
|
|
1812
|
+
const { screenshot: matchScreenshot } = shouldScreenshot
|
|
1813
|
+
? await verifyAndCapture({ platform, shouldVerify: false, shouldScreenshot: true, beforeBuffer: null, udid: targetUdid })
|
|
1814
|
+
: { screenshot: undefined };
|
|
1815
|
+
if (matchScreenshot && app) {
|
|
1816
|
+
app.lastScreenshot = {
|
|
1817
|
+
originalWidth: matchScreenshot.width,
|
|
1818
|
+
originalHeight: matchScreenshot.height,
|
|
1819
|
+
scaleFactor: matchScreenshot.scaleFactor
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
return formatTapFailure({
|
|
1823
|
+
query,
|
|
1824
|
+
attempted,
|
|
1825
|
+
error: `Ambiguous: ${result.matches.length} elements match this query. Tap did not execute.`,
|
|
1826
|
+
suggestion: `Specify index= (0–${result.matches.length - 1}) or add text= to narrow down. See matches[] for position and text of each element.`,
|
|
1827
|
+
device: deviceName,
|
|
1828
|
+
matches: result.matches,
|
|
1829
|
+
ambiguous: true,
|
|
1830
|
+
screenshot: matchScreenshot
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
// All strategies failed — check if the tap budget was the cause.
|
|
1835
|
+
// Only outer withTimeout wrapper messages or Skipped entries count; nested
|
|
1836
|
+
// sub-op errors that happen to contain "timed out" do not imply the tap
|
|
1837
|
+
// itself ran out of time.
|
|
1838
|
+
const hitTimeout = isTapTimeout(attempted);
|
|
1839
|
+
const elapsed = TAP_TIMEOUT_MS - remainingMs();
|
|
1840
|
+
// If fiber located the element but visibility filtered it out, prepend a
|
|
1841
|
+
// scroll-or-dismiss hint so the agent doesn't waste a turn assuming the
|
|
1842
|
+
// testID/component is missing.
|
|
1843
|
+
const fiberSawInvisible = attempted.some(a => a.strategy === "fiber" && a.outcome === "invisible");
|
|
1844
|
+
const allTimedOut = attempted.length > 0 && attempted.every(a => a.outcome === "timeout" || a.outcome === "skipped");
|
|
1845
|
+
let suggestion = buildSuggestion(query, strategies, platform);
|
|
1846
|
+
if (fiberSawInvisible) {
|
|
1847
|
+
suggestion = `Element exists in the React tree but is not on screen. Try scrolling it into view with swipe(), dismiss any overlay covering it, or wait for layout to settle. ` + suggestion;
|
|
1848
|
+
}
|
|
1849
|
+
else if (allTimedOut) {
|
|
1850
|
+
// No strategy got to a definitive answer — don't let the agent conclude
|
|
1851
|
+
// the element is missing. Steer toward retry or a different strategy.
|
|
1852
|
+
suggestion = `All strategies timed out — the element's presence is UNKNOWN. Retry the tap (transient slowness is common on dense screens), or try a different strategy explicitly (e.g. strategy='fiber' if accessibility timed out). ` + suggestion;
|
|
1853
|
+
}
|
|
1854
|
+
const { screenshot: failScreenshot } = shouldScreenshot
|
|
1855
|
+
? await verifyAndCapture({ platform, shouldVerify: false, shouldScreenshot: true, beforeBuffer: null, udid: targetUdid })
|
|
1856
|
+
: { screenshot: undefined };
|
|
1857
|
+
if (failScreenshot && app) {
|
|
1858
|
+
app.lastScreenshot = {
|
|
1859
|
+
originalWidth: failScreenshot.width,
|
|
1860
|
+
originalHeight: failScreenshot.height,
|
|
1861
|
+
scaleFactor: failScreenshot.scaleFactor
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
// Pick the right error framing: a definitive "not found" is different from
|
|
1865
|
+
// "all strategies timed out — we don't know whether it exists".
|
|
1866
|
+
let errorOverride;
|
|
1867
|
+
if (hitTimeout) {
|
|
1868
|
+
errorOverride = `Tap timed out after ${elapsed}ms (budget ${TAP_TIMEOUT_MS}ms)`;
|
|
1869
|
+
}
|
|
1870
|
+
else if (allTimedOut) {
|
|
1871
|
+
errorOverride = `All tap strategies timed out before completing — element presence is UNKNOWN`;
|
|
1872
|
+
}
|
|
1873
|
+
else if (fiberSawInvisible) {
|
|
1874
|
+
errorOverride = `Element matches the query but is not visible on screen`;
|
|
1875
|
+
}
|
|
1876
|
+
const failureResult = formatTapFailure({
|
|
1877
|
+
query,
|
|
1878
|
+
attempted,
|
|
1879
|
+
error: errorOverride,
|
|
1880
|
+
suggestion,
|
|
1881
|
+
device: deviceName,
|
|
1882
|
+
screenshot: failScreenshot
|
|
1883
|
+
});
|
|
1884
|
+
const failSignals = await captureTapArtifact({
|
|
1885
|
+
query,
|
|
1886
|
+
outcome: "failure",
|
|
1887
|
+
errorMessage: failureResult.error,
|
|
1888
|
+
attempted,
|
|
1889
|
+
platform,
|
|
1890
|
+
iosDriver: platform === "ios" ? (process.env.IOS_DRIVER?.toLowerCase() || "axe") : undefined,
|
|
1891
|
+
deviceName,
|
|
1892
|
+
screenshotMeta: failScreenshot ? { width: failScreenshot.width, height: failScreenshot.height } : undefined,
|
|
1893
|
+
screenshotBuffer: beforeBuffer,
|
|
1894
|
+
afterWithMarker: null,
|
|
1895
|
+
chosenTapPoint: null,
|
|
1896
|
+
evidence
|
|
1897
|
+
});
|
|
1898
|
+
return attachArtifactSignals(failureResult, failSignals);
|
|
1899
|
+
}
|
|
1900
|
+
// Matches testIDs typical of virtualized list items: trailing `-<digits>` (e.g. `store-item-0`,
|
|
1901
|
+
// `route-item-6`) or containing a UUID v4 fragment (e.g. `group-card-e4566bc6-9164-4fd0-...`).
|
|
1902
|
+
// Used by buildSuggestion() to tell the agent to scroll first when a list-item testID
|
|
1903
|
+
// can't be found in the visible fiber tree.
|
|
1904
|
+
const VIRTUALIZED_TESTID_RE = /(-\d+$)|([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
|
|
1905
|
+
function looksLikeVirtualizedListItem(testID) {
|
|
1906
|
+
return VIRTUALIZED_TESTID_RE.test(testID);
|
|
1907
|
+
}
|
|
1908
|
+
function buildSuggestion(query, triedStrategies, platform) {
|
|
1909
|
+
const suggestions = [];
|
|
1910
|
+
if (!triedStrategies.includes("ocr") && query.text) {
|
|
1911
|
+
suggestions.push("Try strategy='ocr' to find text visually on screen");
|
|
1912
|
+
}
|
|
1913
|
+
if (query.text && query.text.length <= 2) {
|
|
1914
|
+
suggestions.push("Very short text is unreliable for OCR — use testID or coordinates instead");
|
|
1915
|
+
}
|
|
1916
|
+
if (query.text && hasProblematicUnicode(query.text)) {
|
|
1917
|
+
suggestions.push("Emoji text cannot use fiber strategy — use testID or coordinates instead");
|
|
1918
|
+
}
|
|
1919
|
+
if (query.component && triedStrategies.includes("fiber")) {
|
|
1920
|
+
suggestions.push("Component not found or has no onPress handler — use find_components to discover exact component names, or use text/coordinates instead");
|
|
1921
|
+
}
|
|
1922
|
+
if (query.testID && !triedStrategies.includes("ocr")) {
|
|
1923
|
+
suggestions.push("testID not found in fiber/accessibility tree — verify the element is on the current screen with a screenshot");
|
|
1924
|
+
}
|
|
1925
|
+
// List-item testIDs (suffix `-N` or containing a UUID) are typically rendered inside a
|
|
1926
|
+
// virtualized list — items beyond the viewport are unmounted, so the fiber tree genuinely
|
|
1927
|
+
// doesn't contain them. Adding the hint as a separate suggestion (rather than rewriting
|
|
1928
|
+
// the error) keeps the existing message stable for tooling that parses it.
|
|
1929
|
+
if (query.testID && looksLikeVirtualizedListItem(query.testID)) {
|
|
1930
|
+
suggestions.push("testID looks like a virtualized list item — scroll it on-screen first " +
|
|
1931
|
+
`(swipe()${platform === "ios" ? " or ios_button arrow keys" : ""}) before tapping`);
|
|
1932
|
+
}
|
|
1933
|
+
suggestions.push(`Take a screenshot (${platform === "ios" ? "ios_screenshot" : "android_screenshot"}) ` +
|
|
1934
|
+
"to verify the element is visible, then use x/y coordinates");
|
|
1935
|
+
return suggestions.join(". ");
|
|
1936
|
+
}
|
|
1937
|
+
//# sourceMappingURL=tap.js.map
|