Last active
October 13, 2025 12:21
-
-
Save kibotu/4664b976e43dee875d64b00daf79d1e8 to your computer and use it in GitHub Desktop.
Running maesto ui tests on headless android emulator on mac or linux in parallel.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * runMaestro("signin-flow.yaml", "36", "app/build/outputs/apk/debug/app-debug.apk", "de.example") | |
| */ | |
| def runMaestro(flow, api, apk, bundleIdentifier) { | |
| int rc = sh( | |
| script: "./build/buildscripts/run-headless-maestro-tests.sh ${flow} ${apk} ${api}", | |
| returnStatus: true | |
| ) | |
| // Publish results; this marks the build UNSTABLE if failures are present. | |
| junit allowEmptyResults: true, testResults: 'flow-report.xml' | |
| // Belt & suspenders: if xcodebuild failed but no report got generated, | |
| // force UNSTABLE so the pipeline doesn't look green. | |
| // take screenshot of current screen | |
| if (rc != 0) { | |
| def errorFlow = """ | |
| appId: $bundleIdentifier | |
| --- | |
| - takeScreenshot: error | |
| """.trim() | |
| writeFile file: 'screenshot-flow.yaml', text: errorFlow | |
| def uuid = readFile "current-device.txt" | |
| sh "maestro --device \"$uuid\" test screenshot-flow.yaml" | |
| archiveArtifacts artifacts: 'error.png', fingerprint: true | |
| currentBuild.result = currentBuild.result ?: 'UNSTABLE' | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * runMaestro("signin-flow.yaml", "iPhone 16", "de.example") | |
| */ | |
| def runMaestro(flow, simulator, bundleIdentifier) { | |
| sh "cat $flow" | |
| env.MAESTRO_DRIVER_STARTUP_TIMEOUT = "60000" | |
| env.MAESTRO_CLI_NO_ANALYTICS = "1" | |
| try { | |
| // boot device | |
| sh "xcrun simctl boot \"${simulator}\" && open -a Simulator" | |
| } catch (Exception ignored) { | |
| // ignore: device might already be running | |
| } | |
| // find uuid for our booted simulator | |
| def uuid = sh(script: "xcrun simctl list devices | grep \"(Booted)\" | grep \"${simulator}\" | grep -oE \"[0-9A-Fa-f-]{36}\"", returnStdout: true).trim() | |
| // Resolve Temurin 17 on the macOS node (e.g., /Library/Java/.../temurin-17.jdk/Contents/Home) | |
| def jdkHome = sh(script: "/usr/libexec/java_home -v17", returnStdout: true).trim() | |
| withEnv([ | |
| "JAVA_HOME=${jdkHome}", | |
| // Use PATH+VAR so Jenkins prepends without clobbering PATH | |
| "PATH+JDK=${jdkHome}/bin" | |
| ]) { | |
| // Run tests but don't fail the stage if xcodebuild exits non-zero. | |
| int rc = sh( | |
| script: """ | |
| set -o pipefail | |
| maestro --device \"${uuid}\" test ${flow} --format junit --output flow-report.xml | |
| """.trim(), | |
| returnStatus: true | |
| ) | |
| // Publish results; this marks the build UNSTABLE if failures are present. | |
| junit allowEmptyResults: true, testResults: 'flow-report.xml' | |
| // Belt & suspenders: if xcodebuild failed but no report got generated, | |
| // force UNSTABLE so the pipeline doesn't look green. | |
| // take screenshot of current screen | |
| if (rc != 0) { | |
| def errorFlow = """ | |
| appId: $bundleIdentifier | |
| --- | |
| - takeScreenshot: error | |
| """.trim() | |
| writeFile file: 'screenshot-flow.yaml', text: errorFlow | |
| sh "maestro --device \"$uuid\" test screenshot-flow.yaml" | |
| archiveArtifacts artifacts: 'error.png', fingerprint: true | |
| currentBuild.result = currentBuild.result ?: 'UNSTABLE' | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # ============================================================================== | |
| # Maestro Android Emulator Test Runner | |
| # ============================================================================== | |
| # | |
| # USAGE: | |
| # ./run-headless-maestro-tests.sh <flow-yaml> [apk-path] [android-api-level] | |
| # | |
| # ARGUMENTS: | |
| # flow-yaml (required) Path to Maestro test flow YAML file | |
| # apk-path (optional) Path to APK file to install | |
| # Default: app/build/outputs/apk/release/app-release.apk | |
| # android-api-level (optional) Android API level (e.g., 34, 35, 36) | |
| # Default: 36 (Android 15) | |
| # | |
| # EXAMPLES: | |
| # ./run-headless-maestro-tests.sh signin-flow.yaml | |
| # ./run-headless-maestro-tests.sh tests/login.yaml app/build/outputs/apk/debug/app-debug.apk | |
| # ./run-headless-maestro-tests.sh tests/checkout.yaml app-release.apk 34 | |
| # | |
| # ENVIRONMENT VARIABLES: | |
| # ANDROID_SDK_ROOT Path to Android SDK (default: /opt/android-sdk) | |
| # WORKSPACE Workspace directory for isolation (default: $PWD) | |
| # BOOT_TIMEOUT_SECONDS Emulator boot timeout (default: 420) | |
| # | |
| # ============================================================================== | |
| export LANG=en_US.UTF-8 | |
| export LANGUAGE=en_US.UTF-8 | |
| export LC_ALL=en_US.UTF-8 | |
| set -xeuo pipefail | |
| # --- Parse arguments | |
| if [ $# -lt 1 ]; then | |
| echo "Error: Missing required argument <flow-yaml>" >&2 | |
| echo "Usage: $0 <flow-yaml> [apk-path] [android-api-level]" >&2 | |
| exit 1 | |
| fi | |
| FLOW_PATH="$1" | |
| APK_PATH="${2:-app/build/outputs/apk/release/app-release.apk}" | |
| ANDROID_API_LEVEL="${3:-36}" | |
| # Validate flow file exists | |
| if [ ! -f "$FLOW_PATH" ]; then | |
| echo "Error: Flow file not found: $FLOW_PATH" >&2 | |
| exit 1 | |
| fi | |
| # Validate APK exists | |
| if [ ! -f "$APK_PATH" ]; then | |
| echo "Error: APK file not found: $APK_PATH" >&2 | |
| exit 1 | |
| fi | |
| # Validate API level | |
| if ! [[ "$ANDROID_API_LEVEL" =~ ^[0-9]+$ ]] || [ "$ANDROID_API_LEVEL" -lt 21 ]; then | |
| echo "Error: Invalid Android API level: $ANDROID_API_LEVEL (must be >= 21)" >&2 | |
| exit 1 | |
| fi | |
| echo "=== Maestro Test Configuration ===" | |
| echo "Flow file: $FLOW_PATH" | |
| echo "APK path: $APK_PATH" | |
| echo "API level: $ANDROID_API_LEVEL" | |
| echo "==================================" | |
| set -x | |
| # --- Cleanup function for trap | |
| cleanup() { | |
| local exit_code=$? | |
| echo "" | |
| echo "=== Cleanup: Stopping emulator and removing AVD ===" | |
| # Kill emulator if running | |
| if [ -n "${ADB_SERIAL:-}" ]; then | |
| echo "Killing emulator $ADB_SERIAL..." | |
| adb -s "$ADB_SERIAL" emu kill 2>/dev/null || true | |
| fi | |
| if [ -n "${EMU_PID:-}" ] && kill -0 "$EMU_PID" 2>/dev/null; then | |
| echo "Force killing emulator process $EMU_PID..." | |
| kill -9 "$EMU_PID" 2>/dev/null || true | |
| fi | |
| # Wait a moment for emulator to fully stop | |
| sleep 2 | |
| # Delete AVD | |
| if [ -n "${AVD_NAME:-}" ]; then | |
| echo "Deleting AVD $AVD_NAME..." | |
| avdmanager delete avd -n "$AVD_NAME" 2>/dev/null || true | |
| fi | |
| # Clean up temporary files | |
| if [ -d "./tmp" ]; then | |
| echo "Removing temporary files..." | |
| rm -rf "./tmp" | |
| fi | |
| echo "=== Cleanup complete ===" | |
| exit $exit_code | |
| } | |
| # Set trap to always cleanup on exit (success, error, or interrupt) | |
| trap cleanup EXIT INT TERM | |
| # --- Host detection | |
| OS="$(uname -s)" | |
| case "$OS" in | |
| Darwin) HOST_OS="mac";; | |
| Linux) HOST_OS="linux";; | |
| *) HOST_OS="other";; | |
| esac | |
| export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-/opt/android-sdk}" | |
| export PATH="${ANDROID_SDK_ROOT}/emulator:${ANDROID_SDK_ROOT}/platform-tools:${PATH}" | |
| # --- Per-build isolation | |
| export ANDROID_AVD_HOME="${WORKSPACE:-$PWD}/.android/avd" | |
| export ANDROID_EMULATOR_HOME="${WORKSPACE:-$PWD}/.android" | |
| export ANDROID_PREFS_ROOT="${WORKSPACE:-$PWD}/.android" | |
| rm -rf ".android/" | |
| mkdir -p "$ANDROID_AVD_HOME" | |
| # ---- Choose system image based on API level and host OS | |
| if [[ "$HOST_OS" == "mac" ]]; then | |
| ABI_TYPE="arm64-v8a" | |
| SYSTEM_IMAGE="system-images;android-${ANDROID_API_LEVEL};google_apis_playstore_ps16k;arm64-v8a" | |
| AVD_NAME="api${ANDROID_API_LEVEL}-arm64" | |
| else | |
| ABI_TYPE="x86_64" | |
| SYSTEM_IMAGE="system-images;android-${ANDROID_API_LEVEL};google_apis_playstore_ps16k;x86_64" | |
| AVD_NAME="api${ANDROID_API_LEVEL}-x86_64" | |
| fi | |
| DEVICE_NAME="pixel_6" # small & stable device profile | |
| #yes | sdkmanager --licenses >/dev/null || true | |
| #sdkmanager --install "$SYSTEM_IMAGE" >/dev/null | |
| echo "$SYSTEM_IMAGE installed." | |
| avdmanager delete avd -n "$AVD_NAME" || true | |
| # Create (fresh) AVD | |
| echo "no" | avdmanager create avd -n "$AVD_NAME" -k "$SYSTEM_IMAGE" --device "$DEVICE_NAME" --abi "$ABI_TYPE" | |
| # Defensive: if someone changed the image under the same name, recreate on ABI mismatch | |
| CFG="$ANDROID_AVD_HOME/${AVD_NAME}.avd/config.ini" | |
| if [ -f "$CFG" ] && ! grep -q "^abi.type=${ABI_TYPE}\$" "$CFG"; then | |
| rm -rf "$ANDROID_AVD_HOME/${AVD_NAME}.avd" | |
| avdmanager delete avd -n "$AVD_NAME" || true | |
| echo "no" | avdmanager create avd -n "$AVD_NAME" -k "$SYSTEM_IMAGE" --device "$DEVICE_NAME" --abi "$ABI_TYPE" | |
| fi | |
| # --- Port picking (your cross-platform version) | |
| is_port_free() { | |
| local port="$1" | |
| if command -v ss >/dev/null 2>&1; then | |
| ss -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(:|\\.)${port}\$" && return 1 || return 0 | |
| elif command -v netstat >/dev/null 2>&1; then | |
| netstat -an 2>/dev/null | awk '/LISTEN/ {print $4}' | grep -Eq "(:|\\.)${port}\$" && return 1 || return 0 | |
| elif command -v lsof >/dev/null 2>&1; then | |
| lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 && return 1 || return 0 | |
| elif [[ -n "${BASH_VERSION:-}" ]]; then | |
| (echo >/dev/tcp/127.0.0.1/"$port") >/dev/null 2>&1 && return 1 || return 0 | |
| elif command -v nc >/dev/null 2>&1; then | |
| nc -z 127.0.0.1 "$port" >/dev/null 2>&1 && return 1 || return 0 | |
| else | |
| return 0 | |
| fi | |
| } | |
| pick_ports() { | |
| local candidates=(5560 5566 5572 5578 5584 5590 5596 5602 5608 5614) | |
| local base | |
| for base in "${candidates[@]}"; do | |
| if is_port_free "$base" && is_port_free "$((base+1))"; then | |
| echo "$base" | |
| return 0 | |
| fi | |
| done | |
| echo "No free emulator ports found" >&2 | |
| return 1 | |
| } | |
| CONSOLE_PORT="$(pick_ports)" | |
| ADB_PORT="$((CONSOLE_PORT+1))" | |
| ADB_SERIAL="emulator-${CONSOLE_PORT}" | |
| # --- Acceleration: prefer KVM; fall back if missing | |
| EMU_ACCEL_ARGS="-accel on" | |
| if [ ! -w /dev/kvm ]; then | |
| echo "KVM not available; running without acceleration." | |
| EMU_ACCEL_ARGS="-no-accel" | |
| fi | |
| rm -rf tmp | |
| mkdir "tmp" | |
| EMU_LOG="./tmp/emulator_${CONSOLE_PORT}.log" | |
| echo "Starting emulator ${AVD_NAME} (ABI=${ABI_TYPE}) on :${CONSOLE_PORT}" | |
| adb start-server >/dev/null 2>&1 || true | |
| # --- Start emulator (headless, fast boot) | |
| emulator -avd "$AVD_NAME" \ | |
| -port "$CONSOLE_PORT" \ | |
| -no-window -gpu swiftshader_indirect -no-audio \ | |
| -no-snapshot -wipe-data -no-boot-anim \ | |
| ${EMU_ACCEL_ARGS} -netfast \ | |
| -qt-hide-window -skip-adb-auth \ | |
| -writable-system \ | |
| -camera-back none -camera-front none \ | |
| -cores 2 \ | |
| -memory 2048 \ | |
| >"$EMU_LOG" 2>&1 & | |
| EMU_PID=$! | |
| echo "$ADB_SERIAL" > current-device.txt | |
| # --- Wait for THIS device only (avoid generic wait) | |
| echo "Waiting for device $ADB_SERIAL to appear…" | |
| timeout 90s bash -c ' | |
| until adb devices | awk "NR>1 {print \$1}" | grep -q "^'"$ADB_SERIAL"'$"; do | |
| sleep 2 | |
| done | |
| ' | |
| # Guard: make sure the device is responsive to adb | |
| adb -s "$ADB_SERIAL" wait-for-device | |
| # --- Wait for boot with extended timeout + progress | |
| BOOT_TIMEOUT="${BOOT_TIMEOUT_SECONDS:-420}" # Play it safe on CI | |
| echo "Waiting up to ${BOOT_TIMEOUT}s for sys.boot_completed… (tailing $EMU_LOG if it fails)" | |
| if ! timeout "${BOOT_TIMEOUT}s" bash -c ' | |
| until adb -s '"$ADB_SERIAL"' shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; do | |
| sleep 3 | |
| done | |
| '; then | |
| echo "Emulator did not finish booting in time. Last 100 lines of log:" | |
| tail -n 100 "$EMU_LOG" || true | |
| adb -s "$ADB_SERIAL" emu kill || kill "$EMU_PID" || true | |
| exit 1 | |
| fi | |
| # --- Basic prep | |
| adb -s "$ADB_SERIAL" shell settings put global window_animation_scale 0 || true | |
| adb -s "$ADB_SERIAL" shell settings put global transition_animation_scale 0 || true | |
| adb -s "$ADB_SERIAL" shell settings put global animator_duration_scale 0 || true | |
| adb -s "$ADB_SERIAL" shell input keyevent 82 || true | |
| # --- Install APK | |
| echo "Installing APK: $APK_PATH" | |
| adb devices | |
| ps aux | grep emulator | |
| adb -s "$ADB_SERIAL" install -r "$APK_PATH" | |
| # --- Run Maestro targeting THIS emulator | |
| echo "Running Maestro test flow..." | |
| maestro --device "$ADB_SERIAL" test "$FLOW_PATH" --format junit --output "flow-report.xml" | |
| echo "" | |
| echo "=== Test completed successfully ===" | |
| echo "Report saved to: flow-report.xml" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # runMaestro - Run Maestro flow tests on iOS Simulator | |
| # Usage: ./runMaestro.sh <flow> <simulator> <bundleIdentifier> | |
| # | |
| # Example: ./runMaestro.sh signin-flow.yaml "iPhone 16" de.example | |
| set -e | |
| # Function to run Maestro tests | |
| runMaestro() { | |
| local flow="$1" | |
| local simulator="$2" | |
| local bundleIdentifier="$3" | |
| # Validate arguments | |
| if [[ -z "$flow" || -z "$simulator" || -z "$bundleIdentifier" ]]; then | |
| echo "Error: Missing required arguments" | |
| echo "Usage: $0 <flow> <simulator> <bundleIdentifier>" | |
| exit 1 | |
| fi | |
| # Display flow file contents | |
| cat "$flow" | |
| # Set Maestro environment variables | |
| export MAESTRO_DRIVER_STARTUP_TIMEOUT="60000" | |
| export MAESTRO_CLI_NO_ANALYTICS="1" | |
| # Boot simulator (ignore errors if already running) | |
| echo "Booting simulator: $simulator" | |
| xcrun simctl boot "$simulator" 2>/dev/null || true | |
| open -a Simulator | |
| # Wait a moment for simulator to boot | |
| sleep 2 | |
| # Find UUID for booted simulator | |
| echo "Finding UUID for booted simulator..." | |
| uuid=$(xcrun simctl list devices | grep "(Booted)" | grep "$simulator" | grep -oE "[0-9A-Fa-f-]{36}" | head -n 1) | |
| if [[ -z "$uuid" ]]; then | |
| echo "Error: Could not find booted simulator matching: $simulator" | |
| exit 1 | |
| fi | |
| echo "Found simulator UUID: $uuid" | |
| # Resolve Temurin 17 JDK path | |
| echo "Resolving Java 17 installation..." | |
| jdkHome=$(/usr/libexec/java_home -v17) | |
| if [[ -z "$jdkHome" ]]; then | |
| echo "Error: Java 17 not found" | |
| exit 1 | |
| fi | |
| echo "Using JAVA_HOME: $jdkHome" | |
| # Set Java environment | |
| export JAVA_HOME="$jdkHome" | |
| export PATH="$jdkHome/bin:$PATH" | |
| # Run Maestro tests | |
| echo "Running Maestro tests..." | |
| set +e # Don't exit on error | |
| maestro --device "$uuid" test "$flow" --format junit --output flow-report.xml | |
| rc=$? | |
| set -e | |
| echo "Maestro test exit code: $rc" | |
| # Handle test failures | |
| if [[ $rc -ne 0 ]]; then | |
| echo "Tests failed or encountered errors. Taking screenshot..." | |
| # Create screenshot flow | |
| cat > screenshot-flow.yaml << EOF | |
| appId: $bundleIdentifier | |
| --- | |
| - takeScreenshot: error | |
| EOF | |
| # Take screenshot of current state | |
| maestro --device "$uuid" test screenshot-flow.yaml || true | |
| # Archive screenshot if it exists | |
| if [[ -f "error.png" ]]; then | |
| echo "Screenshot saved: error.png" | |
| fi | |
| echo "Build marked as UNSTABLE due to test failures" | |
| exit 1 | |
| fi | |
| echo "Maestro tests completed successfully" | |
| } | |
| # Main execution | |
| if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then | |
| runMaestro "$1" "$2" "$3" | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment