diff --git a/e2e/README.md b/e2e/README.md index e304979d..e5f932ec 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..3252796f --- /dev/null +++ b/e2e/android/checkout-completion.yaml @@ -0,0 +1,187 @@ +appId: com.shopify.checkout_kit_mobile_buy_integration_sample +name: Checkout completes +tags: + - android + - checkout + +# Override these for store-specific product and shipping-address data. +env: + PRODUCT_INDEX: ${PRODUCT_INDEX || "1"} + COUNTRY: ${COUNTRY || "United States"} + ADDRESS_LINE1: ${ADDRESS_LINE1 || "350 5th Ave"} + CITY: ${CITY || "New York"} + POSTAL_CODE: ${POSTAL_CODE || "10118"} + POSTAL_FIELD: ${POSTAL_FIELD || "ZIP code"} +--- +# Timeout tiers: +# 10000 - in-app interactions (taps, animations) +# 30000 - checkout step transitions (network) +# 60000 - cold starts, first checkout paint, final confirmation + +# 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: 10000 + centerElement: true +- tapOn: + id: product-${PRODUCT_INDEX}-grid-item +- scrollUntilVisible: + element: + id: add-to-cart-button + direction: DOWN + timeout: 30000 + centerElement: true +- tapOn: + id: add-to-cart-button + enabled: true +- waitForAnimationToEnd: + timeout: 10000 +- tapOn: + id: cart-tab +- extendedWaitUntil: + visible: + id: checkout-button + timeout: 30000 +- 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" +- hideKeyboard +- tapOn: + text: "^First name( \\(optional\\))?$" +- inputText: "Maestro" +- hideKeyboard +- tapOn: + text: "^Last name$" +- inputText: "Shopify" +- hideKeyboard + +# Shipping address +- scrollUntilVisible: + element: + text: "Country/Region" + direction: DOWN + timeout: 10000 +- tapOn: + text: "Country/Region" + index: 1 +- waitForAnimationToEnd: + timeout: 3000 +- scrollUntilVisible: + element: + text: "^${COUNTRY}$" + direction: UP + timeout: 10000 + visibilityPercentage: 50 + centerElement: true + optional: true +- runFlow: + when: + notVisible: "^${COUNTRY}$" + commands: + - scrollUntilVisible: + element: + text: "^${COUNTRY}$" + direction: DOWN + timeout: 30000 + visibilityPercentage: 50 + centerElement: true +- tapOn: + text: "^${COUNTRY}$" +- waitForAnimationToEnd: + timeout: 3000 + +- scrollUntilVisible: + element: + text: "Address" + direction: DOWN + timeout: 10000 +- tapOn: + text: "Address" + index: -1 +- eraseText: 80 +- scrollUntilVisible: + element: + text: "^${POSTAL_FIELD}$" + direction: DOWN + timeout: 10000 + centerElement: true +- inputText: "${ADDRESS_LINE1} ${CITY} ${POSTAL_CODE}" +- extendedWaitUntil: + visible: ".*${ADDRESS_LINE1}, ${CITY}.*${POSTAL_CODE}.*${COUNTRY}.*" + timeout: 30000 +- waitForAnimationToEnd: + timeout: 2000 +- tapOn: + text: ".*${ADDRESS_LINE1}, ${CITY}.*${POSTAL_CODE}.*${COUNTRY}.*" + index: 0 + retryTapIfNoChange: true +- waitForAnimationToEnd: + timeout: 5000 +- extendedWaitUntil: + visible: "^${POSTAL_CODE}$" + timeout: 15000 +- hideKeyboard +- waitForAnimationToEnd: + timeout: 5000 + +# Payment +- scrollUntilVisible: + element: + text: "^Card number$" + direction: DOWN + timeout: 30000 + centerElement: true +- tapOn: + text: "^Card number$" +- inputText: "4242424242424242" +- hideKeyboard +- tapOn: "Expiration date (MM / YY)" +- inputText: "1230" +- hideKeyboard +- tapOn: + text: "^Security code$" +- inputText: "123" +- hideKeyboard +- scrollUntilVisible: + element: + text: "^Name on card$" + direction: DOWN + timeout: 30000 + centerElement: true +- scrollUntilVisible: + element: + text: "^Pay now$" + direction: DOWN + timeout: 30000 +- extendedWaitUntil: + visible: "^Pay now$" + timeout: 30000 +- tapOn: + text: "^Pay now$" + enabled: true +- extendedWaitUntil: + visible: ".*(Thank you|[Oo]rder (is )?confirmed).*" + 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 6d002d97..a866e622 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 @@ -54,8 +54,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 @@ -98,7 +101,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) } @@ -139,9 +146,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 6c445671..26e44416 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 @@ -53,6 +53,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 @@ -253,6 +254,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 c7902af6..b86f3114 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 @@ -35,6 +35,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 @@ -62,6 +63,7 @@ fun BottomAppBarWithNavigation( ImageVector.vectorResource(R.drawable.home), stringResource(id = R.string.navigation_home), currentScreen, + "home-tab", ) NavigationItem( navController, @@ -69,6 +71,7 @@ fun BottomAppBarWithNavigation( ImageVector.vectorResource(R.drawable.product), stringResource(id = R.string.navigation_shop), currentScreen, + "products-tab", ) NavigationItem( navController, @@ -76,6 +79,7 @@ fun BottomAppBarWithNavigation( ImageVector.vectorResource(R.drawable.profile), stringResource(id = R.string.navigation_log_in), currentScreen, + "settings-tab", ) } } @@ -88,6 +92,7 @@ fun NavigationItem( icon: ImageVector, label: String, currentScreen: Screen, + testTag: String, ) { val isActiveScreen = currentScreen == screen val color = if (isActiveScreen) { @@ -99,9 +104,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 6de76316..d3d0f8cf 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 @@ -40,6 +40,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 @@ -119,6 +120,7 @@ fun ProductsView( Product( product = product, imageHeight = if (largeScreen) defaultProductImageHeightLg else defaultProductImageHeight, + testTag = "product-${index}-grid-item", onProductClick = { productId -> productsViewModel.productClicked(navController, productId) } @@ -135,10 +137,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 ae70c4b8..b105352b 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 @@ -32,6 +32,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 @@ -50,7 +51,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