mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-04 09:58:57 +00:00
test(e2e): stabilize Detox and Maestro suites and harden CI - Re-enable stabilized cases and document remaining iOS skips. - Improve selectors, navigation recovery, state assertions, and retry limits. - Harden API retries, fixture setup, and server configuration cleanup. - Increase Detox shards to 24 and improve reporting, artifact uploads, and timeout handling. - Add testIDs and unit-test fixes; revert unproven app behavior changes.
430 lines
17 KiB
Bash
Executable File
430 lines
17 KiB
Bash
Executable File
#!/bin/bash
|
|
# Reference: https://wix.github.io/Detox/docs/guide/android-dev-env
|
|
|
|
set -ex
|
|
set -o pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
|
|
SDK_VERSION=${1:-35} # First argument is SDK version
|
|
AVD_BASE_NAME=${2:-"detox_pixel_8"} # Second argument is AVD base name (no api suffix — added below)
|
|
AVD_NAME="${AVD_BASE_NAME}_api_${SDK_VERSION}"
|
|
TEST_FILES=("${@:3}") # Capture all remaining arguments as Detox test files
|
|
EMULATOR_RAM_MB=${MM_ANDROID_EMULATOR_RAM_MB:-3072}
|
|
|
|
setup_avd_home() {
|
|
# ${CI:-} so `set -u` cannot abort the TESTS_ONLY retry path on a local run.
|
|
if [[ "${CI:-}" == "true" ]]; then
|
|
export ANDROID_AVD_HOME=$(pwd)/.android/avd
|
|
mkdir -p "$ANDROID_AVD_HOME"
|
|
fi
|
|
}
|
|
|
|
get_cpu_architecture() {
|
|
if [[ $(uname -p) == 'arm' ]]; then
|
|
echo "arm64-v8a arm64"
|
|
else
|
|
echo "x86_64 x86_64"
|
|
fi
|
|
}
|
|
|
|
create_avd() {
|
|
local cpu_arch_family cpu_arch
|
|
read cpu_arch_family cpu_arch < <(get_cpu_architecture)
|
|
|
|
avdmanager create avd -n "$AVD_NAME" -k "system-images;android-${SDK_VERSION};google_apis;${cpu_arch_family}" -p "$AVD_NAME" -d 'pixel_4_xl'
|
|
|
|
cp -r android_emulator/ "$AVD_NAME/"
|
|
sed -i -e "s|AvdId = change_avd_id|AvdId = ${AVD_NAME}|g" "$AVD_NAME/config.ini"
|
|
sed -i -e "s|avd.ini.displayname = change_avd_displayname|avd.ini.displayname = Detox Pixel 8 API ${SDK_VERSION}|g" "$AVD_NAME/config.ini"
|
|
sed -i -e "s|abi.type = change_type|abi.type = ${cpu_arch_family}|g" "$AVD_NAME/config.ini"
|
|
sed -i -e "s|hw.cpu.arch = change_cpu_arch|hw.cpu.arch = ${cpu_arch}|g" "$AVD_NAME/config.ini"
|
|
sed -i -e "s|image.sysdir.1 = change_to_image_sysdir/|image.sysdir.1 = system-images/android-${SDK_VERSION}/google_apis/${cpu_arch_family}/|g" "$AVD_NAME/config.ini"
|
|
sed -i -e "s|hw.ramSize = .*|hw.ramSize = ${EMULATOR_RAM_MB}|g" "$AVD_NAME/config.ini"
|
|
|
|
echo "Android virtual device successfully created: ${AVD_NAME}"
|
|
}
|
|
|
|
update_existing_avd_memory() {
|
|
local config_path="${AVD_NAME}/config.ini"
|
|
if [[ -f "$config_path" ]]; then
|
|
sed -i -e "s|hw.ramSize = .*|hw.ramSize = ${EMULATOR_RAM_MB}|g" "$config_path"
|
|
fi
|
|
}
|
|
|
|
prepare_avd_for_boot() {
|
|
# Cached AVDs may include snapshot/userdata state that causes
|
|
# "snapshot operation is pending and timeout has expired" on boot.
|
|
rm -rf "${AVD_NAME}/snapshots" 2>/dev/null || true
|
|
rm -f "${AVD_NAME}"/userdata*.img* "${AVD_NAME}"/cache.img* 2>/dev/null || true
|
|
|
|
if [[ "$CI" == "true" ]]; then
|
|
# Restored AVD caches can retain lock files from a crashed prior boot, which
|
|
# makes the emulator exit with "Running multiple emulators with the same AVD".
|
|
rm -f "${AVD_NAME}"/*.lock "${AVD_NAME}"/multiinstance.lock 2>/dev/null || true
|
|
# grep exits 1 when no emulators are listed; pipefail would abort bootstrap.
|
|
while read -r serial; do
|
|
[[ -z "$serial" ]] && continue
|
|
adb -s "$serial" emu kill 2>/dev/null || true
|
|
done < <( (adb devices 2>/dev/null | grep -E '^emulator-' | cut -f1) || true)
|
|
sleep 2
|
|
fi
|
|
}
|
|
|
|
start_adb_server() {
|
|
echo "Restarting ADB server..."
|
|
adb kill-server
|
|
adb start-server
|
|
}
|
|
|
|
start_emulator() {
|
|
echo "Starting the emulator..."
|
|
local emulator_opts="-avd $AVD_NAME -no-snapshot -no-snapshot-load -no-snapshot-save -no-boot-anim -no-audio -gpu off -no-window"
|
|
|
|
if [[ "$CI" == "true" || "$(uname -s)" == "Linux" ]]; then
|
|
emulator $emulator_opts -gpu swiftshader_indirect -accel on -qemu -m "$EMULATOR_RAM_MB" &
|
|
else
|
|
emulator $emulator_opts -gpu guest -verbose -qemu -vnc :0
|
|
fi
|
|
}
|
|
|
|
emulator_process_running() {
|
|
pgrep -f "emulator.*${AVD_NAME}" >/dev/null 2>&1 || pgrep -f 'qemu-system-x86_64' >/dev/null 2>&1
|
|
}
|
|
|
|
wait_for_emulator() {
|
|
if [[ "$CI" != "true" ]]; then return; fi
|
|
|
|
echo "Waiting for emulator to boot..."
|
|
local device_timeout=120
|
|
local device_elapsed=0
|
|
until adb devices | grep -qE '^emulator-[0-9]+\s+device'; do
|
|
if [[ $device_elapsed -ge $device_timeout ]]; then
|
|
echo "Emulator device did not appear within ${device_timeout}s"
|
|
adb devices -l || true
|
|
exit 1
|
|
fi
|
|
if ! emulator_process_running; then
|
|
echo "Emulator process exited before device appeared (check snapshot/cache state)"
|
|
exit 1
|
|
fi
|
|
sleep 5
|
|
device_elapsed=$((device_elapsed + 5))
|
|
done
|
|
|
|
local boot_timeout=300 # 5 minutes max
|
|
local boot_elapsed=0
|
|
until [[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; do
|
|
if [[ $boot_elapsed -ge $boot_timeout ]]; then
|
|
echo "Emulator failed to boot within 5 minutes"
|
|
exit 1
|
|
fi
|
|
if ! emulator_process_running; then
|
|
echo "Emulator process exited during boot"
|
|
exit 1
|
|
fi
|
|
echo "Waiting for emulator to fully boot..."
|
|
sleep 10
|
|
boot_elapsed=$((boot_elapsed + 10))
|
|
done
|
|
echo "Emulator is fully booted."
|
|
|
|
# API 35 emulators take longer to stabilize the instrumentation layer after
|
|
# sys.boot_completed — 15s was insufficient, causing the first Detox app
|
|
# launch to fail with "No activities in stage RESUMED".
|
|
sleep 30
|
|
adb shell pm list packages > /dev/null 2>&1
|
|
echo "Emulator is fully ready."
|
|
}
|
|
|
|
resolve_app_apk() {
|
|
if [[ "${MAESTRO_ANDROID:-}" == "true" ]]; then
|
|
local release_apk="../android/app/build/outputs/apk/release/app-release.apk"
|
|
if [[ -f "$release_apk" ]]; then
|
|
echo "$release_apk"
|
|
return 0
|
|
fi
|
|
echo "Maestro release APK not found at $release_apk" >&2
|
|
ls -la ../android/app/build/outputs/apk/release/ 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
echo "../android/app/build/outputs/apk/debug/app-debug.apk"
|
|
}
|
|
|
|
install_app() {
|
|
local app_apk
|
|
app_apk=$(resolve_app_apk)
|
|
echo "Installing the app from $app_apk..."
|
|
adb install -r "$app_apk"
|
|
if [[ "${MAESTRO_ANDROID:-}" != "true" && "${BOOTSTRAP_ONLY:-}" != "true" ]]; then
|
|
# Install the test/instrumentation APK — required by Detox (reinstallApp: false assumes
|
|
# both the main APK and the test APK are already present on the device).
|
|
adb install -r ../android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
|
|
fi
|
|
adb shell pm list packages | grep "com.mattermost.rnbeta" && echo "App is installed." || echo "App is not installed."
|
|
}
|
|
|
|
grant_android_runtime_permissions() {
|
|
local bundle_id="com.mattermost.rnbeta"
|
|
adb shell pm grant "$bundle_id" android.permission.POST_NOTIFICATIONS 2>/dev/null || true
|
|
adb shell pm grant "$bundle_id" android.permission.RECORD_AUDIO 2>/dev/null || true
|
|
adb shell pm grant "$bundle_id" android.permission.CAMERA 2>/dev/null || true
|
|
adb shell settings put secure show_ime_with_hard_keyboard 0 2>/dev/null || true
|
|
adb shell settings put secure spell_checker_enabled 0 2>/dev/null || true
|
|
adb shell settings put secure auto_text_enabled 0 2>/dev/null || true
|
|
# google_apis images ship Google Autofill; overlays on the server form hide
|
|
# FloatingTextInput testIDs from Maestro (same class of issue as iOS autofill).
|
|
adb shell settings put secure autofill_service null 2>/dev/null || true
|
|
}
|
|
|
|
configure_emulator_for_tests() {
|
|
adb shell settings put global window_animation_scale 0
|
|
adb shell settings put global transition_animation_scale 0
|
|
adb shell settings put global animator_duration_scale 0
|
|
adb shell input keyevent 82 2>/dev/null || true
|
|
adb root 2>/dev/null || true
|
|
adb shell setprop persist.sys.timezone "America/New_York" 2>/dev/null || true
|
|
adb shell setprop sys.timezone "America/New_York" 2>/dev/null || true
|
|
adb shell am broadcast -a android.intent.action.TIMEZONE_CHANGED \
|
|
--ez bypassUserRestrictions true 2>/dev/null || true
|
|
keep_display_awake
|
|
configure_chrome_for_ci
|
|
}
|
|
|
|
# Espresso resolves its root view by asking for the window that currently has
|
|
# focus. If the display sleeps or the keyguard comes back, every window reports
|
|
# has-window-focus=false and each launch fails with "Waited for the root of the
|
|
# view hierarchy to have window focus and not request layout for 10 seconds"
|
|
# before the app is ever given a chance to render.
|
|
keep_display_awake() {
|
|
adb shell svc power stayon true 2>/dev/null || true
|
|
adb shell settings put system screen_off_timeout 2147483647 2>/dev/null || true
|
|
adb shell input keyevent KEYCODE_WAKEUP 2>/dev/null || true
|
|
adb shell wm dismiss-keyguard 2>/dev/null || true
|
|
adb shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true
|
|
}
|
|
|
|
configure_chrome_for_ci() {
|
|
adb shell 'mkdir -p /data/local/tmp' 2>/dev/null || true
|
|
adb shell 'echo "chrome --disable-fre --no-first-run --no-default-browser-check" > /data/local/tmp/chrome-command-line' 2>/dev/null || true
|
|
# Chrome only reads /data/local/tmp/chrome-command-line at process start, so it
|
|
# has to be started once for the flags to stick.
|
|
adb shell am start -n com.android.chrome/com.google.android.apps.chrome.Main 2>/dev/null || true
|
|
sleep 3
|
|
# A single BACK is not enough to get rid of Chrome: on swiftshader it is often
|
|
# still on its splash 3s in, so the keyevent lands on a window that ignores it
|
|
# and Chrome stays foregrounded holding window focus. Force-stop it instead and
|
|
# go back to the launcher, so the next window to take focus is the app Detox
|
|
# launches.
|
|
adb shell am force-stop com.android.chrome 2>/dev/null || true
|
|
adb shell input keyevent KEYCODE_HOME 2>/dev/null || true
|
|
}
|
|
|
|
# Fail fast and loudly here rather than 10s at a time inside every beforeAll.
|
|
wait_for_focused_window() {
|
|
local timeout=60 elapsed=0 focus=""
|
|
while (( elapsed < timeout )); do
|
|
focus=$(adb shell dumpsys window 2>/dev/null | tr -d '\r' | grep -m1 -E 'mCurrentFocus=' || true)
|
|
if [[ -n "$focus" && "$focus" != *"mCurrentFocus=null"* ]]; then
|
|
echo "Emulator has a focused window: ${focus}"
|
|
return 0
|
|
fi
|
|
echo "No focused window yet (${elapsed}s): ${focus:-<none>}"
|
|
keep_display_awake
|
|
sleep 5
|
|
elapsed=$(( elapsed + 5 ))
|
|
done
|
|
echo "ERROR: no focused window after ${timeout}s — Espresso root matching is expected to fail"
|
|
adb shell dumpsys window 2>/dev/null | tr -d '\r' | grep -E 'mCurrentFocus|mFocusedApp' || true
|
|
return 1
|
|
}
|
|
|
|
push_e2e_fixtures() {
|
|
local fixture="${SCRIPT_DIR}/e2e/support/fixtures/image.png"
|
|
if [[ -f "$fixture" ]]; then
|
|
adb push "$fixture" /sdcard/Download/test_bookmark.png
|
|
echo "Pushed test fixture to /sdcard/Download/test_bookmark.png"
|
|
adb shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE \
|
|
-d file:///sdcard/Download/test_bookmark.png 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
start_server() {
|
|
echo "Starting the server..."
|
|
cd "$REPO_ROOT"
|
|
RUNNING_E2E=true npm run start &
|
|
local timeout=120 interval=5 elapsed=0
|
|
|
|
until nc -z localhost 8081; do
|
|
if [[ $elapsed -ge $timeout ]]; then
|
|
echo "Server did not start within 3 minutes."
|
|
exit 1
|
|
fi
|
|
echo "Waiting for server to be ready..."
|
|
sleep $interval
|
|
elapsed=$((elapsed + interval))
|
|
done
|
|
echo "Server is ready."
|
|
|
|
echo "Pre-warming Metro Android bundle before Detox launchApp..."
|
|
local bundle_url="http://127.0.0.1:8081/index.bundle?platform=android&dev=true&minify=false"
|
|
local metro_status_url="http://127.0.0.1:8081/status"
|
|
local status_timeout=120 status_elapsed=0 status_interval=2
|
|
|
|
# Wait for Metro HTTP — does not trigger a bundle build.
|
|
until curl -sf "$metro_status_url" 2>/dev/null | grep -q "packager-status:running"; do
|
|
if [[ $status_elapsed -ge $status_timeout ]]; then
|
|
echo "ERROR: Metro packager did not report running within ${status_timeout}s"
|
|
exit 1
|
|
fi
|
|
echo "Waiting for Metro packager status... (${status_elapsed}s)"
|
|
sleep $status_interval
|
|
status_elapsed=$((status_elapsed + status_interval))
|
|
done
|
|
|
|
# One bundle request only. Polling the bundle URL in a loop starts a fresh Metro
|
|
# build on every curl (CI log: 94% → 0% repeatedly) and never finishes.
|
|
echo "Compiling Android bundle (single request, up to 300s)..."
|
|
local bundle_timeout=300
|
|
local bundle_file
|
|
bundle_file=$(mktemp)
|
|
if ! curl -sf --max-time "$bundle_timeout" "$bundle_url" -o "$bundle_file"; then
|
|
echo "ERROR: failed to download Metro Android bundle within ${bundle_timeout}s"
|
|
rm -f "$bundle_file"
|
|
exit 1
|
|
fi
|
|
|
|
local bundle_bytes
|
|
bundle_bytes=$(wc -c < "$bundle_file" | tr -d ' ')
|
|
if [[ "$bundle_bytes" -lt 500000 ]]; then
|
|
echo "ERROR: Metro Android bundle too small (${bundle_bytes} bytes)"
|
|
head -c 500 "$bundle_file" || true
|
|
rm -f "$bundle_file"
|
|
exit 1
|
|
fi
|
|
if ! grep -q '__d' "$bundle_file"; then
|
|
echo "ERROR: Metro Android bundle missing __d module marker"
|
|
rm -f "$bundle_file"
|
|
exit 1
|
|
fi
|
|
rm -f "$bundle_file"
|
|
echo "Metro Android bundle is ready (${bundle_bytes} bytes)."
|
|
}
|
|
|
|
setup_adb_reverse() {
|
|
echo "Setting up ADB reverse port forwarding..."
|
|
# adb root (in configure_emulator_for_tests) restarts adbd and drops prior reverse mappings.
|
|
adb reverse --remove-all 2>/dev/null || true
|
|
adb reverse tcp:8081 tcp:8081
|
|
if ! adb reverse --list | grep -q 'tcp:8081'; then
|
|
echo "ERROR: adb reverse tcp:8081 is not active after setup"
|
|
adb reverse --list || true
|
|
exit 1
|
|
fi
|
|
echo "adb reverse verified: $(adb reverse --list)"
|
|
}
|
|
|
|
run_detox_tests() {
|
|
echo "Running Detox tests... $@"
|
|
|
|
cd "$SCRIPT_DIR"
|
|
AVD_NAME="$AVD_NAME" npm run detox:config-gen
|
|
mkdir -p artifacts
|
|
npm run e2e:android-test -- "$@" -- --json --outputFile=artifacts/jest-results.json
|
|
}
|
|
|
|
emulator_is_ready() {
|
|
adb devices 2>/dev/null | grep -qE '^emulator-[0-9]+\s+device' || return 1
|
|
[[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]] || return 1
|
|
adb shell pm list packages 2>/dev/null | grep -q 'com.mattermost.rnbeta' || return 1
|
|
}
|
|
|
|
reset_app_for_retry() {
|
|
local bundle_id="com.mattermost.rnbeta"
|
|
echo "Resetting app state for failed-spec retry..."
|
|
adb shell input keyevent KEYCODE_HOME 2>/dev/null || true
|
|
adb shell am force-stop "$bundle_id" 2>/dev/null || true
|
|
adb shell pm clear "$bundle_id" 2>/dev/null || true
|
|
if ! adb shell pm list packages 2>/dev/null | grep -q "$bundle_id"; then
|
|
echo "App missing after pm clear — reinstalling"
|
|
install_app
|
|
fi
|
|
}
|
|
|
|
run_tests_only() {
|
|
# The AVD lives under ANDROID_AVD_HOME (detox/.android/avd), not the default
|
|
# ~/.android/avd. main() only exports it on the cold-boot path, so the retry
|
|
# used to reach Detox with it unset: AVDValidator ran `emulator -list-avds`
|
|
setup_avd_home
|
|
|
|
if ! emulator_is_ready; then
|
|
echo "TESTS_ONLY: emulator is not ready"
|
|
exit 2
|
|
fi
|
|
|
|
# Exit 2 is the caller's "retry the cold-boot way" signal. Falling back beats
|
|
# failing the shard outright if the AVD is somehow still not visible.
|
|
if ! emulator -list-avds | grep -q "$AVD_NAME"; then
|
|
echo "TESTS_ONLY: '${AVD_NAME}' not listed under ANDROID_AVD_HOME=${ANDROID_AVD_HOME:-<unset>} — falling back to cold boot"
|
|
exit 2
|
|
fi
|
|
reset_app_for_retry
|
|
grant_android_runtime_permissions
|
|
configure_emulator_for_tests
|
|
setup_adb_reverse
|
|
if ! nc -z localhost 8081 2>/dev/null; then
|
|
echo "Metro is down — restarting"
|
|
start_server
|
|
fi
|
|
push_e2e_fixtures
|
|
run_detox_tests "$@"
|
|
}
|
|
|
|
main() {
|
|
cd "$SCRIPT_DIR"
|
|
|
|
if [[ "${TESTS_ONLY:-}" == "true" ]]; then
|
|
run_tests_only "${TEST_FILES[@]}"
|
|
return
|
|
fi
|
|
|
|
setup_avd_home
|
|
|
|
if ! emulator -list-avds | grep -q "$AVD_NAME"; then
|
|
create_avd
|
|
else
|
|
echo "'${AVD_NAME}' Android virtual device already exists."
|
|
update_existing_avd_memory
|
|
fi
|
|
|
|
prepare_avd_for_boot
|
|
start_adb_server
|
|
start_emulator
|
|
wait_for_emulator
|
|
|
|
if [[ "$CI" == "true" ]]; then
|
|
install_app
|
|
# Maestro uses a release APK with an embedded bundle (mirrors iOS simulator builds).
|
|
if [[ "${MAESTRO_ANDROID:-}" != "true" ]]; then
|
|
start_server
|
|
fi
|
|
grant_android_runtime_permissions
|
|
configure_emulator_for_tests
|
|
if [[ "${MAESTRO_ANDROID:-}" != "true" ]]; then
|
|
setup_adb_reverse
|
|
fi
|
|
push_e2e_fixtures
|
|
wait_for_focused_window
|
|
fi
|
|
|
|
if [[ "${BOOTSTRAP_ONLY:-}" == "true" ]]; then
|
|
echo "Bootstrap complete (BOOTSTRAP_ONLY=true) — skipping Detox tests"
|
|
exit 0
|
|
fi
|
|
|
|
run_detox_tests "${TEST_FILES[@]}"
|
|
}
|
|
|
|
main
|