mattermost-mobile-upstream/detox/maestro/scripts/run_ci_batches.sh
yasser khan b5fef86993
test(e2e): fix main-branch E2E flakes and make a killed shard report red (#10161)
test(e2e): fix markdown image precondition, shard-completeness gate, and Detox/Maestro flakes
2026-09-17 16:25:02 +05:30

547 lines
21 KiB
Bash
Executable File

#!/usr/bin/env bash
# Run Maestro flows in isolated batches for CI.
#
# PR exclusions: detox/maestro/config/exclude_tags.json (Zephyr tiers: README.md)
#
# One batch failure (e.g. iOS SFSafariViewController wedging the driver) must not
# prevent unrelated flows from running. Each batch is a separate `maestro test`
# invocation; JUnit XML files are merged afterward.
#
# Usage (from repo root):
# source detox/maestro/.maestro-test-env.sh
# detox/maestro/scripts/run_ci_batches.sh --platform ios --device "$SIMULATOR_ID"
# detox/maestro/scripts/run_ci_batches.sh --platform android
#
# Environment:
# MAESTRO_BIN — default ~/.maestro/bin/maestro
# MAESTRO_APP_ID — default com.mattermost.rnbeta
# FLOW_PATH — optional; space-separated flow dirs (default: detox/maestro/flows/* categories)
# SITE_1_URL, TEST_* — passed through to maestro --env
# ADMIN_TOKEN — re-applies the server config baseline at startup and enables
# Calls in the test channel; passed through to maestro --env
# MAESTRO_DRIVER_STARTUP_TIMEOUT — default 180000 (Maestro CI recommendation)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
cd "$REPO_ROOT"
PLATFORM=""
DEVICE_ARGS=()
OUTPUT_DIR="build"
ARTIFACTS_DIR="build/maestro-artifacts"
# NOT maestro-report.xml. The workflow's parse step builds that file itself with
# mergeMaestroBatchReportsFromDir, which globs maestro-batch-*.xml AND
# maestro-report-*.xml so the config-gated flows run from dedicated steps
# (MM-T67856_4, MM-T3261_1, MM-T3261_2) are counted in the gate and in TSIO.
# That helper returns early if its output already exists, so writing
# maestro-report.xml here silently excluded those flows from the pass/fail
# decision. This name is outside the helper's glob, so the batches are merged
# exactly once.
MERGED_XML="$OUTPUT_DIR/maestro-batches-merged.xml"
MAESTRO_BIN="${MAESTRO_BIN:-$HOME/.maestro/bin/maestro}"
MAESTRO_APP_ID="${MAESTRO_APP_ID:-com.mattermost.rnbeta}"
export MAESTRO_DRIVER_STARTUP_TIMEOUT="${MAESTRO_DRIVER_STARTUP_TIMEOUT:-180000}"
usage() {
echo "Usage: $0 --platform ios|android [--device UDID]"
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--platform) PLATFORM="$2"; shift 2 ;;
--device) DEVICE_ARGS=(--device "$2"); shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown arg: $1"; usage ;;
esac
done
[[ -n "$PLATFORM" ]] || usage
[[ -x "$MAESTRO_BIN" ]] || { echo "Maestro not found at $MAESTRO_BIN"; exit 1; }
[[ -n "${SITE_1_URL:-}" ]] || { echo "SITE_1_URL is required for Maestro server connect" >&2; exit 1; }
[[ -n "${TEST_USER_EMAIL:-}" ]] || { echo "TEST_USER_EMAIL is required" >&2; exit 1; }
[[ -n "${TEST_USER_PASSWORD:-}" ]] || { echo "TEST_USER_PASSWORD is required" >&2; exit 1; }
mkdir -p "$OUTPUT_DIR" "$ARTIFACTS_DIR"
# shellcheck source=detox/maestro/lib/timezone_region.sh
source "$REPO_ROOT/detox/maestro/lib/timezone_region.sh"
SIMULATOR_TIMEZONE="${SIMULATOR_TIMEZONE:-America/New_York}"
EXPECTED_TIMEZONE_REGION="${EXPECTED_TIMEZONE_REGION:-$(timezone_region_from_iana "$SIMULATOR_TIMEZONE")}"
EXCLUDE_TAGS_FILE="$REPO_ROOT/detox/maestro/config/exclude_tags.json"
load_exclude_tags() {
# Emit comma-joined exclude tags from default + platform key in exclude_tags.json.
[[ -f "$EXCLUDE_TAGS_FILE" ]] || { echo ""; return 0; }
PLATFORM="$PLATFORM" node -e "
const cfg = require('$EXCLUDE_TAGS_FILE');
const platform = process.env.PLATFORM || '';
const tags = [...(cfg.default || []), ...(cfg[platform] || [])];
process.stdout.write(tags.join(','));
"
}
build_maestro_env_args() {
maestro_env_args=(
--env "SITE_1_URL=${SITE_1_URL:-}"
--env "TEST_USER_EMAIL=${TEST_USER_EMAIL:-}"
--env "TEST_USER_PASSWORD=${TEST_USER_PASSWORD:-}"
--env "TEST_CHANNEL_NAME=${TEST_CHANNEL_NAME:-}"
--env "TEST_CHANNEL_ID=${TEST_CHANNEL_ID:-}"
--env "TEST_TEAM_NAME=${TEST_TEAM_NAME:-}"
--env "ADMIN_TOKEN=${ADMIN_TOKEN:-}"
--env "MAESTRO_APP_ID=${MAESTRO_APP_ID}"
--env "MAESTRO_CI=true"
--env "SIMULATOR_TIMEZONE=${SIMULATOR_TIMEZONE}"
--env "EXPECTED_TIMEZONE_REGION=${EXPECTED_TIMEZONE_REGION}"
--env "SYNC_TOKEN=${SYNC_TOKEN:-}"
)
}
build_maestro_env_args
platform_args=()
[[ "$PLATFORM" == "android" ]] && platform_args=(--platform android)
DEFAULT_FLOW_PATH="detox/maestro/flows/timezone detox/maestro/flows/channels detox/maestro/flows/account detox/maestro/flows/calls"
flow_sort_key() {
case "$1" in
detox/maestro/flows/timezone/*) echo "1:$1" ;;
detox/maestro/flows/channels/*) echo "2:$1" ;;
detox/maestro/flows/account/*) echo "3:$1" ;;
detox/maestro/flows/calls/*) echo "4:$1" ;;
*) echo "9:$1" ;;
esac
}
EXCLUDE_TAGS_STR=""
EXCLUDE_TAGS_STR=$(load_exclude_tags)
should_skip_flow() {
local flow=$1
local base=${flow##*/}
[[ "$flow" == *"/multi_device/"* ]] && return 0
[[ "$base" == _* ]] && return 0
[[ "$base" == *_picker.yml ]] && return 0
[[ "$base" == "attach_logs_disabled_when_download_logs_off.yml" ]] && return 0
[[ "$base" == "file_type_preview.yml" ]] && return 0
[[ "$base" == "start_call.yml" ]] && return 0
# iOS Simulator cannot reliably run CallKit + WebRTC (Maestro CI run 28306039412:
# call_ui_permission, leave_call, mute_unmute fail waiting for id mute-unmute).
# See CallKitProvider.swift — "RTCAudioSession may never activate on CI simulators
# and peer connection times out before CurrentCallBar appears."
# libraries/@mattermost/calls-native/ios/Source/Managers/CallKitProvider.swift
[[ "$PLATFORM" == "ios" && "$flow" == *"/calls/"* ]] && return 0
# Skip flows whose tags appear in the platform-specific exclude list.
if [[ -n "$EXCLUDE_TAGS_STR" && -f "$flow" ]]; then
local flow_tags
flow_tags=$(awk '/^tags:/{found=1; next} found{if (!/^ - /) exit; print $2}' "$flow" 2>/dev/null || true)
local tag
for tag in $flow_tags; do
[[ ",$EXCLUDE_TAGS_STR," == *",$tag,"* ]] && return 0
done
fi
return 1
}
BATCHES=()
flow_path="${FLOW_PATH:-}"
[[ -z "$flow_path" ]] && flow_path="$DEFAULT_FLOW_PATH"
read -r -a flow_entries <<< "$flow_path"
for entry in "${flow_entries[@]}"; do
if [[ -d "$entry" ]]; then
while IFS= read -r flow; do
should_skip_flow "$flow" && continue
BATCHES+=("$(flow_sort_key "$flow")")
done < <(find "$entry" -maxdepth 1 -name '*.yml' | sort)
elif [[ -f "$entry" ]]; then
should_skip_flow "$entry" || BATCHES+=("$(flow_sort_key "$entry")")
else
echo "Flow path not found: $entry" >&2
exit 1
fi
done
# Sort by category order, then strip sort prefix.
if ((${#BATCHES[@]})); then
sorted=()
while IFS= read -r line; do
sorted+=("${line#*:}")
done < <(printf '%s\n' "${BATCHES[@]}" | sort)
BATCHES=("${sorted[@]}")
fi
if ((${#BATCHES[@]} == 0)); then
echo "No Maestro flows to run (FLOW_PATH=$flow_path)" >&2
exit 1
fi
echo "==> Running ${#BATCHES[@]} Maestro flow(s)"
ensure_ios_simulator_healthy() {
[[ "$PLATFORM" != "ios" ]] && return 0
local udid="${DEVICE_ARGS[1]:-}"
[[ -n "$udid" ]] || return 0
if xcrun simctl listapps "$udid" &>/dev/null; then
return 0
fi
echo "==> iOS simulator unhealthy — rebooting $udid"
xcrun simctl shutdown "$udid" 2>/dev/null || true
xcrun simctl boot "$udid" 2>/dev/null || true
set +e
PREBOOT_SKIP_PREWARM="${PREBOOT_SKIP_PREWARM:-1}" \
DEVICE_NAME="${E2E_IOS_DEVICE_NAME:-iPhone 17 Pro}" \
DEVICE_OS_VERSION="${E2E_IOS_DEVICE_OS_VERSION:-iOS 26.2}" \
bash detox/scripts/preboot_ios_simulator.sh
set -e
for _ in $(seq 1 30); do
xcrun simctl listapps "$udid" &>/dev/null && return 0
sleep 2
done
echo "Warning: simulator may still be unhealthy after reboot"
}
ensure_android_app_launchable() {
[[ "$PLATFORM" != "android" ]] && return 0
command -v adb >/dev/null 2>&1 || return 0
echo "==> Preparing Android app $MAESTRO_APP_ID for Maestro launch"
adb shell am force-stop "$MAESTRO_APP_ID" 2>/dev/null || true
adb reverse tcp:8081 tcp:8081 2>/dev/null || true
# Re-assert after adb root (timezone prep) — Google Autofill overlays hide form testIDs.
adb shell settings put secure autofill_service null 2>/dev/null || true
}
grant_ios_calls_permissions() {
[[ "$PLATFORM" != "ios" ]] && return 0
local udid="${DEVICE_ARGS[1]:-}"
[[ -n "$udid" ]] || return 0
echo "==> Re-granting iOS microphone/camera for Calls ($MAESTRO_APP_ID)"
xcrun simctl privacy "$udid" grant microphone "$MAESTRO_APP_ID" 2>/dev/null || true
xcrun simctl privacy "$udid" grant camera "$MAESTRO_APP_ID" 2>/dev/null || true
}
grant_android_calls_permissions() {
[[ "$PLATFORM" != "android" ]] && return 0
command -v adb >/dev/null 2>&1 || return 0
echo "==> Re-granting Android microphone/camera for Calls ($MAESTRO_APP_ID)"
adb shell pm grant "$MAESTRO_APP_ID" android.permission.RECORD_AUDIO 2>/dev/null || true
adb shell pm grant "$MAESTRO_APP_ID" android.permission.CAMERA 2>/dev/null || true
}
# Idempotent and non-destructive: re-asserts only the state a run requires, so a shared
# server or a crashed earlier job cannot leak config into this one. Called once before any
# batch runs. AllowDownloadLogs is intentionally NOT included here: MM-T67856_4 flips it in
# its own onFlowStart/onFlowComplete hooks (fixtures/set_allow_download_logs.js) so that flow
# is self-contained whether it runs in CI or locally.
apply_config_baseline() {
[[ -n "${ADMIN_TOKEN:-}" && -n "${SITE_1_URL:-}" ]] || {
echo "Warning: missing ADMIN_TOKEN/SITE_1_URL; skipping config baseline" >&2
return 0
}
echo "==> Re-applying config baseline on ${SITE_1_URL}"
if ! curl -f -sS --show-error --connect-timeout 10 --max-time 30 --retry 3 --retry-delay 2 --retry-connrefused -X PUT \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"SupportSettings":{"AllowDownloadLogs":true},"ServiceSettings":{"EnableSignInWithEmail":true,"EnableSignInWithUsername":true}}' \
"${SITE_1_URL}/api/v4/config/patch" >/dev/null; then
echo "Warning: could not PUT config baseline on ${SITE_1_URL}" >&2
fi
}
ensure_calls_channel_enabled() {
[[ -n "${TEST_CHANNEL_ID:-}" && -n "${ADMIN_TOKEN:-}" && -n "${SITE_1_URL:-}" ]] || {
echo "Warning: missing TEST_CHANNEL_ID/ADMIN_TOKEN/SITE_1_URL; skipping calls channel enable" >&2
return 0
}
echo "==> Ensuring Calls enabled in channel ${TEST_CHANNEL_ID}"
if ! curl -fsS -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"enabled":true}' \
"${SITE_1_URL}/plugins/com.mattermost.calls/${TEST_CHANNEL_ID}" >/dev/null; then
echo "Warning: could not POST calls enable for channel ${TEST_CHANNEL_ID}" >&2
fi
}
reset_android_app_state() {
[[ "$PLATFORM" != "android" ]] && return 0
command -v adb >/dev/null 2>&1 || return 0
echo "==> Resetting Android cross-app state ($MAESTRO_APP_ID)"
adb shell am force-stop com.android.chrome 2>/dev/null || true
adb shell am force-stop "$MAESTRO_APP_ID" 2>/dev/null || true
adb reverse tcp:8081 tcp:8081 2>/dev/null || true
sleep 2
}
# macOS CI uses bash 3.2; with `set -u`, expanding an empty array via "${arr[@]}"
# throws "unbound variable". Build the maestro argv explicitly instead.
run_maestro_batch() {
build_maestro_env_args
local batch_xml=$1
shift
local -a flows=("$@")
local -a cmd=( "$MAESTRO_BIN" test )
if ((${#DEVICE_ARGS[@]})); then
cmd+=("${DEVICE_ARGS[@]}")
fi
if ((${#platform_args[@]})); then
cmd+=("${platform_args[@]}")
fi
# Keep Maestro's debug output (maestro.log, per-command timings, the failure screenshot
# and hierarchy dump, and the XCUITest runner's console via `simctl launch --console`)
# inside the uploaded build/ tree. With --flatten-debug-output and no --debug-output,
# Maestro 2.6.1 writes all of it to $HOME (TestDebugReporter.getDebugOutputPath), which
# CI never collects. That is why run 34185558418 reported attach_logs_toggle_visible as
# "Unknown error": the runner process (the app's XPC peer) exited mid-flow and the only
# record of why was in /Users/runner. One directory per batch, so the flattened files of
# one batch never overwrite another's.
local debug_dir
debug_dir="$ARTIFACTS_DIR/debug/$(basename "${batch_xml%.xml}")"
mkdir -p "$debug_dir"
cmd+=(
--format junit
--output "$batch_xml"
--test-output-dir "$ARTIFACTS_DIR"
--debug-output "$debug_dir"
--flatten-debug-output
)
local exclude_tags
exclude_tags=$(load_exclude_tags)
if [[ -n "$exclude_tags" ]]; then
cmd+=("--exclude-tags=$exclude_tags")
fi
cmd+=("${maestro_env_args[@]}")
cmd+=("${flows[@]}")
# Tee so the batch output is still in the CI log verbatim while also being greppable for
# driver-startup failures (see the retry in the batch loop). PIPESTATUS keeps maestro's
# exit code rather than tee's.
local batch_log="${batch_xml%.xml}.log"
"${cmd[@]}" 2>&1 | tee "$batch_log"
return "${PIPESTATUS[0]}"
}
# True when the driver died before driving the app: Android then writes JUnit with every
# time="0.0". Any digit 1-9 means non-zero, so "0.0" is zero and "0.5" is not.
driver_startup_failed() {
local batch_log=$1 batch_xml=${2:-}
{ [[ -f "$batch_log" ]] && grep -qE 'IOSDriverTimeoutException|iOS driver not ready in time|StatusRuntimeException: UNAVAILABLE|Command failed \(tcp:' "$batch_log"; } || return 1
[[ -s "$batch_xml" ]] || return 0
! grep -qE '<testcase\b[^>]*\btime="[0-9.]*[1-9]' "$batch_xml"
}
ensure_android_driver_healthy() {
[[ "$PLATFORM" != "android" ]] && return 0
command -v adb >/dev/null 2>&1 || return 0
echo "==> Reconnecting adb after Maestro driver loss"
adb start-server >/dev/null 2>&1 || true
# Never returns when no device comes back, and `|| true` cannot help, so bound it.
timeout 60 adb wait-for-device || echo "==> adb wait-for-device timed out — continuing"
reset_android_app_state
ensure_android_app_launchable
}
# Flow paths come from the filesystem, so `&`, `<` or `"` in a name would otherwise
# emit XML the report parser cannot read.
xml_escape() {
printf '%s' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' -e 's/"/\&quot;/g'
}
# Failures, not skips: a flow that never ran must not shrink the suite and still pass. One
# <testcase> per flow, not per batch, so the report names which flows died and why.
write_driver_failure_junit() {
local batch_xml=$1
shift
local msg
msg="$(xml_escape 'Maestro driver died before the flow started (gRPC UNAVAILABLE / tcp closed / iOS driver timeout) and did not recover after one retry')"
{
printf "<?xml version='1.0' encoding='UTF-8'?>\n<testsuites>\n"
printf ' <testsuite name="maestro-driver-unavailable" tests="%d" failures="%d" errors="0" skipped="0" time="0">\n' "$#" "$#"
local flow base id flow_xml id_xml
for flow in "$@"; do
base="${flow##*/}"; id="${base%.yml}"
flow_xml="$(xml_escape "$flow")"; id_xml="$(xml_escape "$id")"
printf ' <testcase id="%s" name="%s" classname="%s" file="%s" time="0" status="ERROR">\n' "$id_xml" "$id_xml" "$flow_xml" "$flow_xml"
printf ' <failure>%s</failure>\n </testcase>\n' "$msg"
done
printf ' </testsuite>\n</testsuites>\n'
} > "$batch_xml"
}
BATCH_XMLS=()
BATCH_FAILED=0
batch_idx=0
MERGE_DONE=0
merge_batch_reports() {
[[ "$MERGE_DONE" == "1" ]] && return 0
[[ ${#BATCH_XMLS[@]} -eq 0 ]] && return 0
MERGE_DONE=1
echo ""
echo "==> Merging ${#BATCH_XMLS[@]} JUnit reports -> $MERGED_XML"
local xml_json
xml_json=$(printf '"%s",' "${BATCH_XMLS[@]}")
xml_json="[${xml_json%,}]"
node -e "
const {mergeMaestroJunitReports} = require('./detox/utils/maestro_report');
mergeMaestroJunitReports(${xml_json}, '${MERGED_XML}');
"
}
trap merge_batch_reports EXIT
ensure_android_timezone() {
[[ "$PLATFORM" != "android" ]] && return 0
command -v adb >/dev/null 2>&1 || return 0
echo "==> Setting Android emulator timezone to America/New_York (MM-T1325)"
adb root 2>/dev/null || true
adb shell cmd alarm set-timezone "America/New_York" 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 force-stop "$MAESTRO_APP_ID" 2>/dev/null || true
sleep 2
}
# Log CPU/RAM snapshot before each batch (temporary diagnostic for runner sizing).
log_resource_snapshot() {
local label=$1
if [[ "$PLATFORM" == "ios" ]]; then
local load mem swap
load=$(uptime | sed -E 's/.*load averages?: //')
mem=$(vm_stat 2>/dev/null | awk '
/page size of/ {ps=$8}
/Pages free/ {free=$3+0}
/Pages active/ {active=$3+0}
/Pages inactive/ {inactive=$3+0}
/Pages wired down/ {wired=$4+0}
/Pageouts/ {pageouts=$2+0}
/Swapins/ {swapins=$2+0}
/Swapouts/ {swapouts=$2+0}
END {
gb=1024*1024*1024
printf "free=%.1fGB active=%.1fGB wired=%.1fGB pageouts=%d swapins=%d swapouts=%d",
free*ps/gb, active*ps/gb, wired*ps/gb, pageouts, swapins, swapouts
}')
swap=$(sysctl -n vm.swapusage 2>/dev/null | tr -s ' ')
echo "[RES ${label}] load=${load} ${mem} ${swap}"
elif [[ "$PLATFORM" == "android" ]]; then
local load mem
load=$(uptime | sed -E 's/.*load average: //')
mem=$(free -h 2>/dev/null | awk '/^Mem:/ {printf "total=%s used=%s free=%s avail=%s", $2, $3, $4, $7} /^Swap:/ {printf " swap_used=%s", $3}')
echo "[RES ${label}] load=${load} ${mem}"
fi
}
apply_config_baseline
for batch_paths in "${BATCHES[@]}"; do
batch_idx=$((batch_idx + 1))
read -r -a path_arr <<< "$batch_paths"
batch_xml="$OUTPUT_DIR/maestro-batch-${batch_idx}.xml"
BATCH_XMLS+=("$batch_xml")
echo ""
echo "==> Maestro batch $batch_idx/${#BATCHES[@]}: ${path_arr[*]}"
log_resource_snapshot "batch-${batch_idx}-pre"
[[ "$batch_paths" == *"/timezone/"* ]] && ensure_android_timezone
ensure_ios_simulator_healthy
if [[ "$batch_paths" == *"/calls/"* ]]; then
ensure_calls_channel_enabled
grant_ios_calls_permissions
grant_android_calls_permissions
fi
[[ "$PLATFORM" == "android" ]] && reset_android_app_state
ensure_android_app_launchable
set +e
batch_start_epoch=$(date +%s)
run_maestro_batch "$batch_xml" "${path_arr[@]}"
rc=$?
batch_end_epoch=$(date +%s)
echo "[BATCH-TIME] batch ${batch_idx} wall=$((batch_end_epoch - batch_start_epoch))s exit=${rc}"
log_resource_snapshot "batch-${batch_idx}-post"
# Retry once when the driver never drove the app. A flow that ran and failed writes
# real elapsed times and is reported as-is.
if [[ $rc -ne 0 ]] && driver_startup_failed "${batch_xml%.xml}.log" "$batch_xml"; then
echo "==> Batch ${batch_idx}: Maestro driver never drove the app. Recovering and retrying this batch once."
if [[ "$PLATFORM" == "ios" ]]; then
ensure_ios_simulator_healthy
else
ensure_android_driver_healthy
fi
rm -f "$batch_xml"
batch_start_epoch=$(date +%s)
run_maestro_batch "$batch_xml" "${path_arr[@]}"
rc=$?
batch_end_epoch=$(date +%s)
echo "[BATCH-TIME] batch ${batch_idx} retry wall=$((batch_end_epoch - batch_start_epoch))s exit=${rc}"
if [[ $rc -ne 0 ]] && driver_startup_failed "${batch_xml%.xml}.log" "$batch_xml"; then
echo "==> Batch ${batch_idx}: driver still unavailable — recording ${#path_arr[@]} flow(s) as failed"
write_driver_failure_junit "$batch_xml" "${path_arr[@]}"
# Recover the device for the next batch, but leave rc non-zero so the batch stays red.
[[ "$PLATFORM" == "ios" ]] && ensure_ios_simulator_healthy
[[ "$PLATFORM" == "android" ]] && ensure_android_driver_healthy
fi
fi
if [[ $rc -ne 0 ]]; then
echo "==> Batch $batch_idx failed (exit $rc) — continuing with remaining batches"
BATCH_FAILED=1
if [[ ! -s "$batch_xml" ]]; then
flow_label="${path_arr[0]:-unknown_flow}"
flow_base="${flow_label##*/}"
flow_id="${flow_base%.yml}"
flow_label_xml="$(xml_escape "$flow_label")"
flow_id_xml="$(xml_escape "$flow_id")"
cat > "$batch_xml" <<EOF
<?xml version='1.0' encoding='UTF-8'?>
<testsuites>
<testsuite name="${flow_label_xml}" tests="1" failures="1" errors="0" skipped="0" time="0">
<testcase id="${flow_id_xml}" name="${flow_id_xml}" classname="${flow_label_xml}" file="${flow_label_xml}" time="0" status="ERROR">
<failure>Maestro batch ${batch_idx} exited ${rc} without writing JUnit XML (${flow_label_xml})</failure>
</testcase>
</testsuite>
</testsuites>
EOF
echo "==> Wrote synthetic failure JUnit for missing ${batch_xml}"
fi
if [[ "$PLATFORM" == "ios" ]]; then
ensure_ios_simulator_healthy
fi
fi
# Browser hand-off flows can wedge the driver; recover before the next batch.
if [[ "$batch_paths" == *help_url* ]]; then
[[ "$PLATFORM" == "ios" ]] && ensure_ios_simulator_healthy
[[ "$PLATFORM" == "android" ]] && ensure_android_app_launchable
fi
done
merge_batch_reports
if [[ $BATCH_FAILED -ne 0 ]]; then
echo "==> One or more Maestro batches failed"
exit 1
fi
echo "==> All Maestro batches passed"