From 0846a8d2ee17134e039750593f6e3ac324791895 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Tue, 19 May 2026 19:38:53 -0400 Subject: [PATCH] Add native Android checkout e2e flow --- e2e/README.md | 27 +- e2e/android/checkout-completion.yaml | 288 ++++++++++++++++++ .../CheckoutKitApp.kt | 18 +- .../cart/CartView.kt | 2 + .../navigation/BottomAppBarWithNavigation.kt | 13 +- .../products/ProductsView.kt | 4 + .../products/product/AddToCartButton.kt | 5 +- platforms/android/scripts/e2e_maestro_android | 221 ++++++++++++++ 8 files changed, 569 insertions(+), 9 deletions(-) create mode 100644 e2e/android/checkout-completion.yaml create mode 100755 platforms/android/scripts/e2e_maestro_android diff --git a/e2e/README.md b/e2e/README.md index b93ce9cd..dd82634b 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -52,7 +52,7 @@ terminal. | ------------------ | ------------------------------- | ------------------ | | React Native, iOS | `platforms/react-native/` | `pnpm e2e:ios` | | Swift, iOS | `platforms/swift/` | `./Scripts/e2e_maestro_ios` | -| Android (native) | TBD | TBD | +| Android (native) | `platforms/android/` | `./scripts/e2e_maestro_android` | | RN, Android | `platforms/react-native/` | `pnpm e2e:android` | Maestro itself is a system CLI, not an npm dependency. Install once with: @@ -61,6 +61,31 @@ Maestro itself is a system CLI, not an npm dependency. Install once with: curl -fsSL "https://get.maestro.mobile.dev" | bash ``` +To pin the native Android runner to a specific emulator, set +`MAESTRO_ANDROID_UDID`: + +``` +MAESTRO_ANDROID_UDID=emulator-5556 ./scripts/e2e_maestro_android +``` + +If local Android runs fail before `launchApp` with `deviceInfo`, +`io.grpc.StatusRuntimeException: UNAVAILABLE`, or `tcp:7001 closed`, Maestro +failed to start its on-device Android driver. The native Android runner retries +that failure once with a local fallback that installs and starts the driver +manually, then runs Maestro with `--no-reinstall-driver`. + +The fallback auto-detects the Android device when exactly one device is +connected. If multiple devices are connected, set `MAESTRO_ANDROID_UDID`. +To force the fallback path manually, run: + +``` +MAESTRO_ANDROID_UDID=emulator-5556 MAESTRO_ANDROID_MANUAL_DRIVER=1 ./scripts/e2e_maestro_android +``` + +The fallback auto-detects Homebrew formula installs of Maestro. For other +install layouts, set `MAESTRO_CLIENT_JAR` to the local `maestro-client.jar`. +Set `MAESTRO_ANDROID_AUTO_DRIVER_FALLBACK=0` to disable the automatic retry. + ## Adding a flow 1. Drop a new `.yaml` under the right folder. diff --git a/e2e/android/checkout-completion.yaml b/e2e/android/checkout-completion.yaml new file mode 100644 index 00000000..a75efe8f --- /dev/null +++ b/e2e/android/checkout-completion.yaml @@ -0,0 +1,288 @@ +appId: com.shopify.checkout_kit_mobile_buy_integration_sample +name: Checkout submits and shows result +tags: + - android + - checkout + +env: + PRODUCT_INDEX: "1" + + # CI shipping fixture + COUNTRY_LABEL: "United States" + ADDRESS_LINE1: "700 S Flower St" + ADDRESS_LINE1_VISIBLE_PATTERN: ".*700.*Flower St$" + CITY: "Los Angeles" + STATE_FIELD_LABEL: "State" + STATE_LABEL: "California" + POSTAL_CODE: "90017" + POSTAL_FIELD_LABEL: "ZIP code" + + # CI payment fixture + CARD_NUMBER: "4242424242424242" + CARD_EXPIRY: "1230" + CARD_SECURITY_CODE: "123" + + # Accepted terminal checkout states for this smoke test. + POST_SUBMIT_RESULT_PATTERN: ".*(Thank you|Your order|Order confirmed|confirmation|Shipping not available|There was a problem|declined|couldn.t process).*" +--- +# Timeout tiers: +# 3000 - animation settles +# 5000 - local in-page interactions and optional probes +# 15000 - sample-app checkout transitions +# 60000 - cold starts, first checkout paint, final submit + +# Product and cart +- launchApp: + clearState: true +- extendedWaitUntil: + visible: + id: products-tab + timeout: 60000 +- tapOn: + id: products-tab +- extendedWaitUntil: + visible: + id: product-0-grid-item + timeout: 60000 +- scrollUntilVisible: + element: + id: product-${PRODUCT_INDEX}-grid-item + direction: DOWN + timeout: 5000 + centerElement: true +- tapOn: + id: product-${PRODUCT_INDEX}-grid-item +- scrollUntilVisible: + element: + id: add-to-cart-button + direction: DOWN + timeout: 15000 + centerElement: true +- tapOn: + id: add-to-cart-button + enabled: true +- waitForAnimationToEnd: + timeout: 3000 +- tapOn: + id: cart-tab +- extendedWaitUntil: + visible: + id: checkout-button + timeout: 15000 +- tapOn: + id: checkout-button + enabled: true + +# Contact +- extendedWaitUntil: + visible: + text: "^Email( or mobile phone number)?$" + timeout: 60000 +- tapOn: + text: "^Email( or mobile phone number)?$" +- inputText: "maestro.e2e@shopify.com" +- extendedWaitUntil: + visible: "^maestro.e2e@shopify.com$" + timeout: 5000 +- tapOn: + text: "^First name( \\(optional\\))?$" +- inputText: "Maestro" +- extendedWaitUntil: + visible: "^Maestro$" + timeout: 5000 +- tapOn: + text: "^Last name$" +- inputText: "Shopify" +- extendedWaitUntil: + visible: "^Shopify$" + timeout: 5000 + +# Shipping address +- scrollUntilVisible: + element: + text: "Country/Region" + direction: DOWN + timeout: 5000 +- tapOn: + text: "Country/Region" + index: 1 +- waitForAnimationToEnd: + timeout: 3000 +- scrollUntilVisible: + element: + text: "^${COUNTRY_LABEL}$" + direction: UP + timeout: 5000 + visibilityPercentage: 10 + optional: true +- scrollUntilVisible: + element: + text: "^${COUNTRY_LABEL}$" + direction: DOWN + timeout: 5000 + visibilityPercentage: 10 + optional: true +- tapOn: + text: "^${COUNTRY_LABEL}$" +- waitForAnimationToEnd: + timeout: 3000 + +- scrollUntilVisible: + element: + text: "Address" + direction: DOWN + timeout: 5000 +- tapOn: + text: "Address" + index: -1 +- eraseText: 80 +- inputText: "${ADDRESS_LINE1}" +- runFlow: + when: + visible: "Close suggestions" + commands: + - tapOn: "Close suggestions" + - waitForAnimationToEnd: + timeout: 3000 +- extendedWaitUntil: + visible: + id: billing-address1 + text: "${ADDRESS_LINE1_VISIBLE_PATTERN}" + timeout: 5000 +- scrollUntilVisible: + element: + text: "^City$" + direction: DOWN + timeout: 5000 + centerElement: true +- tapOn: + text: "^City$" + index: -1 +- eraseText: 80 +- inputText: "${CITY}" +- extendedWaitUntil: + visible: "^${CITY}$" + timeout: 5000 +- scrollUntilVisible: + element: + id: Select1 + direction: DOWN + timeout: 5000 + centerElement: true +- runFlow: + when: + notVisible: "^${STATE_LABEL}$" + commands: + - tapOn: + id: Select1 + - waitForAnimationToEnd: + timeout: 3000 + - scrollUntilVisible: + element: + text: "^${STATE_LABEL}$" + direction: UP + timeout: 5000 + visibilityPercentage: 100 + optional: true + - scrollUntilVisible: + element: + text: "^${STATE_LABEL}$" + direction: DOWN + timeout: 5000 + visibilityPercentage: 100 + optional: true + - tapOn: + text: "^${STATE_LABEL}$" + - waitForAnimationToEnd: + timeout: 3000 +- extendedWaitUntil: + notVisible: "Select a state" + timeout: 5000 +- extendedWaitUntil: + visible: "^${STATE_LABEL}$" + timeout: 5000 +- scrollUntilVisible: + element: + text: "^${POSTAL_FIELD_LABEL}$" + direction: DOWN + timeout: 5000 + centerElement: true +- tapOn: + text: "^${POSTAL_FIELD_LABEL}$" + index: -1 +- eraseText: 80 +- inputText: "${POSTAL_CODE}" +- extendedWaitUntil: + visible: "^${POSTAL_CODE}$" + timeout: 5000 +- waitForAnimationToEnd: + timeout: 3000 + +# Payment +- scrollUntilVisible: + element: + text: "^Complete order$" + direction: DOWN + timeout: 5000 + centerElement: true + optional: true +- runFlow: + when: + notVisible: "^Complete order$" + commands: + - scrollUntilVisible: + element: + text: "^Card number$" + direction: UP + timeout: 5000 + centerElement: true + optional: true + - scrollUntilVisible: + element: + text: "^Card number$" + direction: DOWN + timeout: 5000 + centerElement: true + optional: true + - runFlow: + when: + visible: "^Card number$" + commands: + - tapOn: + text: "^Card number$" + - inputText: "${CARD_NUMBER}" + - tapOn: "Expiration date (MM / YY)" + - inputText: "${CARD_EXPIRY}" + - tapOn: + text: "^Security code$" + - inputText: "${CARD_SECURITY_CODE}" + - scrollUntilVisible: + element: + text: "^Name on card$" + direction: DOWN + timeout: 5000 + centerElement: true + - scrollUntilVisible: + element: + text: "^Pay now$" + direction: DOWN + timeout: 5000 + centerElement: true + optional: true + - runFlow: + when: + visible: "^Pay now$" + commands: + - tapOn: + text: "^Pay now$" + enabled: true +- runFlow: + when: + visible: "^Complete order$" + commands: + - tapOn: + text: "^Complete order$" + enabled: true +- extendedWaitUntil: + visible: "${POST_SUBMIT_RESULT_PATTERN}" + timeout: 60000 diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/CheckoutKitApp.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/CheckoutKitApp.kt index 796fc4eb..6a120bb0 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/CheckoutKitApp.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/CheckoutKitApp.kt @@ -32,8 +32,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.unit.dp import androidx.navigation.compose.rememberNavController import com.shopify.checkout_kit_mobile_buy_integration_sample.cart.CartViewModel @@ -76,7 +79,11 @@ fun CheckoutKitAppRoot( CheckoutKitSampleTheme(darkTheme = useDarkTheme) { Surface( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, ) { val navController = rememberNavController() var currentScreen by remember { mutableStateOf(Screen.Product) } @@ -117,9 +124,12 @@ fun CheckoutKitAppRoot( ) }, actions = { - IconButton(onClick = { - navController.navigate(Screen.Cart.route) - }) { + IconButton( + modifier = Modifier.testTag("cart-tab"), + onClick = { + navController.navigate(Screen.Cart.route) + } + ) { BadgedBox(badge = { if (totalQuantity > 0) { Badge( diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartView.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartView.kt index 956258e7..60981467 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartView.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/cart/CartView.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration @@ -226,6 +227,7 @@ private fun CheckoutButton( modifier = modifier ) { Button( + modifier = Modifier.testTag("checkout-button"), shape = RectangleShape, onClick = onClick, ) { diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/navigation/BottomAppBarWithNavigation.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/navigation/BottomAppBarWithNavigation.kt index 0bfcb9a8..a5d56e2d 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/navigation/BottomAppBarWithNavigation.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/common/navigation/BottomAppBarWithNavigation.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.semantics.contentDescription @@ -40,6 +41,7 @@ fun BottomAppBarWithNavigation( ImageVector.vectorResource(R.drawable.home), stringResource(id = R.string.navigation_home), currentScreen, + "home-tab", ) NavigationItem( navController, @@ -47,6 +49,7 @@ fun BottomAppBarWithNavigation( ImageVector.vectorResource(R.drawable.product), stringResource(id = R.string.navigation_shop), currentScreen, + "products-tab", ) NavigationItem( navController, @@ -54,6 +57,7 @@ fun BottomAppBarWithNavigation( ImageVector.vectorResource(R.drawable.profile), stringResource(id = R.string.navigation_log_in), currentScreen, + "settings-tab", ) } } @@ -66,6 +70,7 @@ fun NavigationItem( icon: ImageVector, label: String, currentScreen: Screen, + testTag: String, ) { val isActiveScreen = currentScreen == screen val color = if (isActiveScreen) { @@ -77,9 +82,11 @@ fun NavigationItem( Column { IconButton( onClick = { navController.navigate(screen.route) }, - modifier = Modifier.semantics { - this.contentDescription = "$label icon" - } + modifier = Modifier + .testTag(testTag) + .semantics { + this.contentDescription = "$label icon" + } ) { Icon(imageVector = icon, contentDescription = label, tint = color) } diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/products/ProductsView.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/products/ProductsView.kt index f3927848..30e7704d 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/products/ProductsView.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/products/ProductsView.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -97,6 +98,7 @@ fun ProductsView( Product( product = product, imageHeight = if (largeScreen) defaultProductImageHeightLg else defaultProductImageHeight, + testTag = "product-${index}-grid-item", onProductClick = { productId -> productsViewModel.productClicked(navController, productId) } @@ -113,10 +115,12 @@ fun ProductsView( fun Product( product: Product, imageHeight: Dp, + testTag: String, onProductClick: (id: ID) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier .wrapContentWidth() + .testTag(testTag) .clickable { onProductClick(product.id) }) { diff --git a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/products/product/AddToCartButton.kt b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/products/product/AddToCartButton.kt index a13e914e..00d89b10 100644 --- a/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/products/product/AddToCartButton.kt +++ b/platforms/android/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_kit_mobile_buy_integration_sample/products/product/AddToCartButton.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -28,7 +29,9 @@ fun AddToCartButton( horizontalAlignment = Alignment.CenterHorizontally, ) { TextButton( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag("add-to-cart-button"), enabled = !loading, onClick = onClick, border = BorderStroke(1.dp, MaterialTheme.colorScheme.onBackground), diff --git a/platforms/android/scripts/e2e_maestro_android b/platforms/android/scripts/e2e_maestro_android new file mode 100755 index 00000000..37d09656 --- /dev/null +++ b/platforms/android/scripts/e2e_maestro_android @@ -0,0 +1,221 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +MANUAL_DRIVER="${MAESTRO_ANDROID_MANUAL_DRIVER:-}" +AUTO_DRIVER_FALLBACK="${MAESTRO_ANDROID_AUTO_DRIVER_FALLBACK:-1}" +ANDROID_UDID="${MAESTRO_ANDROID_UDID:-}" +DRIVER_PID="" + +is_truthy() { + case "${1:-}" in + 1 | true | TRUE | yes | YES) + return 0 + ;; + *) + return 1 + ;; + esac +} + +resolve_udid() { + if [[ -n "${ANDROID_UDID}" ]]; then + printf "%s\n" "${ANDROID_UDID}" + return + fi + + local devices + local device_count + + devices="$(adb devices | awk 'NR > 1 && $2 == "device" { print $1 }')" + device_count="$(printf "%s\n" "${devices}" | sed '/^$/d' | wc -l | tr -d ' ')" + + if [[ "${device_count}" != "1" ]]; then + echo "Maestro Android driver fallback requires MAESTRO_ANDROID_UDID when ${device_count} devices are connected." >&2 + exit 1 + fi + + printf "%s\n" "${devices}" +} + +locate_maestro_client_jar() { + if [[ -n "${MAESTRO_CLIENT_JAR:-}" ]]; then + if [[ -f "${MAESTRO_CLIENT_JAR}" ]]; then + printf "%s\n" "${MAESTRO_CLIENT_JAR}" + return + fi + + echo "MAESTRO_CLIENT_JAR does not point to a file: ${MAESTRO_CLIENT_JAR}" >&2 + exit 1 + fi + + if command -v brew >/dev/null 2>&1; then + local brew_prefix + brew_prefix="$(brew --prefix mobile-dev-inc/tap/maestro 2>/dev/null || true)" + + if [[ -n "${brew_prefix}" && -f "${brew_prefix}/libexec/lib/maestro-client.jar" ]]; then + printf "%s\n" "${brew_prefix}/libexec/lib/maestro-client.jar" + return + fi + fi + + local jar + for jar in \ + /opt/homebrew/Cellar/maestro/*/libexec/lib/maestro-client.jar \ + /usr/local/Cellar/maestro/*/libexec/lib/maestro-client.jar; do + if [[ -f "${jar}" ]]; then + printf "%s\n" "${jar}" + return + fi + done + + cat >&2 <<'EOF' +Could not locate maestro-client.jar. + +Set MAESTRO_CLIENT_JAR to the path for your Maestro installation, then retry. +EOF + exit 1 +} + +prepare_driver_apks() { + local maestro_client_jar="$1" + local apk_dir="${MAESTRO_ANDROID_DRIVER_APK_DIR:-${TMPDIR:-/tmp}/maestro-android-driver}" + + if ! command -v jar >/dev/null 2>&1; then + echo "Could not find the jar command needed to extract Maestro Android driver APKs." >&2 + exit 1 + fi + + mkdir -p "${apk_dir}" + + if [[ ! -f "${apk_dir}/maestro-app.apk" || ! -f "${apk_dir}/maestro-server.apk" ]]; then + ( + cd "${apk_dir}" + jar xf "${maestro_client_jar}" maestro-app.apk maestro-server.apk + ) + fi + + if [[ ! -f "${apk_dir}/maestro-app.apk" || ! -f "${apk_dir}/maestro-server.apk" ]]; then + echo "Failed to extract Maestro Android driver APKs from ${maestro_client_jar}." >&2 + exit 1 + fi + + printf "%s\n" "${apk_dir}" +} + +cleanup_manual_driver() { + if [[ -n "${ANDROID_UDID}" ]]; then + adb -s "${ANDROID_UDID}" shell am force-stop dev.mobile.maestro >/dev/null 2>&1 || true + adb -s "${ANDROID_UDID}" forward --remove tcp:7001 >/dev/null 2>&1 || true + fi + + if [[ -n "${DRIVER_PID}" ]]; then + wait "${DRIVER_PID}" >/dev/null 2>&1 || true + fi +} + +start_manual_driver() { + local maestro_client_jar + local apk_dir + local driver_log + + ANDROID_UDID="$(resolve_udid)" + maestro_client_jar="$(locate_maestro_client_jar)" + apk_dir="$(prepare_driver_apks "${maestro_client_jar}")" + driver_log="${MAESTRO_ANDROID_DRIVER_LOG:-${TMPDIR:-/tmp}/maestro-android-driver-${ANDROID_UDID}.log}" + + trap cleanup_manual_driver EXIT + + adb -s "${ANDROID_UDID}" shell am force-stop dev.mobile.maestro >/dev/null 2>&1 || true + adb -s "${ANDROID_UDID}" forward --remove tcp:7001 >/dev/null 2>&1 || true + adb -s "${ANDROID_UDID}" install -r -g "${apk_dir}/maestro-app.apk" + adb -s "${ANDROID_UDID}" install -r -g "${apk_dir}/maestro-server.apk" + adb -s "${ANDROID_UDID}" forward tcp:7001 tcp:7001 >/dev/null + adb -s "${ANDROID_UDID}" shell am instrument -w dev.mobile.maestro.test/androidx.test.runner.AndroidJUnitRunner >"${driver_log}" 2>&1 & + DRIVER_PID="$!" + + sleep 2 +} + +run_maestro() { + local use_existing_driver="${1:-}" + local args=(--platform android) + + if [[ -n "${ANDROID_UDID}" ]]; then + args+=(--udid "${ANDROID_UDID}") + fi + + args+=(test) + + if [[ "${use_existing_driver}" == "use-existing-driver" ]]; then + args+=(--no-reinstall-driver) + fi + + args+=(--config "${REPO_ROOT}/e2e/config.yaml" "${REPO_ROOT}/e2e/android") + + maestro "${args[@]}" +} + +is_driver_bootstrap_failure() { + local output_file="$1" + + grep -Eq \ + 'deviceInfo command|Not able to reach the gRPC server|StatusRuntimeException: UNAVAILABLE|tcp:7001\): closed|driver.*did not start|Failed to start.*driver' \ + "${output_file}" +} + +latest_maestro_log() { + /bin/ls -t "${HOME}/.maestro/tests/"*/maestro.log 2>/dev/null | head -n 1 || true +} + +run_maestro_with_fallback() { + local output_file + local debug_log + local driver_failure="" + local maestro_status + + output_file="$(mktemp "${TMPDIR:-/tmp}/maestro-android-run.XXXXXX.log")" + + set +e + run_maestro 2>&1 | tee "${output_file}" + maestro_status="${PIPESTATUS[0]}" + set -e + + if [[ "${maestro_status}" == "0" ]]; then + rm -f "${output_file}" + return 0 + fi + + debug_log="$(latest_maestro_log)" + + if ! is_truthy "${AUTO_DRIVER_FALLBACK}"; then + rm -f "${output_file}" + return "${maestro_status}" + fi + + if is_driver_bootstrap_failure "${output_file}"; then + driver_failure="1" + elif [[ -n "${debug_log}" && -f "${debug_log}" ]] && is_driver_bootstrap_failure "${debug_log}"; then + driver_failure="1" + fi + + if [[ -z "${driver_failure}" ]]; then + rm -f "${output_file}" + return "${maestro_status}" + fi + + rm -f "${output_file}" + + echo "Maestro Android driver startup failed. Retrying with the local driver fallback..." >&2 + start_manual_driver + run_maestro use-existing-driver +} + +if is_truthy "${MANUAL_DRIVER}"; then + start_manual_driver + run_maestro use-existing-driver +else + run_maestro_with_fallback +fi