Skip to content

Instantly share code, notes, and snippets.

@kibotu
Last active October 13, 2025 12:21
Show Gist options
  • Select an option

  • Save kibotu/4664b976e43dee875d64b00daf79d1e8 to your computer and use it in GitHub Desktop.

Select an option

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.
/**
* 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'
}
}
/**
* 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'
}
}
}
#!/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"
#!/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