mattermost-mobile-upstream/detox/create_android_emulator.sh
yasser khan 0f9beb0f95
ci(e2e): per-job TSIO report groups, commit statuses, and webhook sidecar (#9969)
* ci(e2e): per-job TSIO report groups and commit statuses

E2E jobs previously shared a single `e2e/mobile` commit status backed by one
TSIO report group, so concurrent platform legs raced to finalize the group and
clobbered each other's results. Give each Detox/Maestro job its own report
group (`mobile-<flow>-<job>`, total_reports_expected=1) and its own commit
status context (`detox-ios`, `detox-android`, `detox-ipad`, `maestro-ios`,
`maestro-android`); CMT reports per shard plus an umbrella context.

Channel notifications move to a single rollup step so N platform legs no
longer spam the webhook, and the five-server topology is dispatched with
explicit per-platform inputs that still resolve to the existing SITE_*_URL
contract consumed by the test config.

Also adds the webhook sidecar launcher, Android emulator CI prep, and TSIO
screenshot path linking that keeps folder basenames matching Jest full names.

The new detox type-check job surfaced pre-existing broken import paths in
maestro/fixtures/calls_seed.ts, which pointed at detox/detox/e2e; corrected so
the guard passes on its own first run.

Note: the CMT matrix now reads per-platform server URLs (`ios_site_1_url`,
`android_site_1_url`, `site_3_url`) instead of a single `url`, which needs the
matching Matterwick dispatch change to land alongside this.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci(e2e): aggregate CMT channel rollup from TSIO and wire notify secrets

The CMT umbrella posted a placeholder summary built from CI job outcomes
(passed:0, failed:0|1, reports:[]), so the release channel message carried no
per-leg detail. It now runs the same tsio-channel-notify-rollup.js path as
PR/main: cmt-identity emits a per-shard TSIO config map and the rollup polls
every shard group, so counts and per-leg rows come from TSIO. The rollup job
also needs id-token: write to mint its OIDC token.

Webhook secrets used by the per-job TSIO finalize steps were referenced but
never declared in the templates' workflow_call.secrets, so GitHub resolved them
to empty strings and channel notify silently no-opped. Declare them in the
iOS/Android/Maestro templates and forward them from e2e-detox.yml and the CMT
callers. The Maestro PR path worked only because its callers use
secrets: inherit.

Also from review:
- split-tests.js emitted two matrix jobs at parallelism=1 when mm_blocks specs
  were present; merge into one shard instead of exceeding the requested count.
- Move the Android ARTIFACT_RUN_ID emptiness check ahead of download-artifact,
  which consumes it and otherwise fails with an opaque API error.
- resolve-tsio-job-config.sh now reuses webhookBucketForReportName instead of
  re-deriving the bucket prefix with a looser startsWith check.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci(e2e): add local preflight for CI PRs

The webhook-secret and OIDC-permission gaps in this branch were invisible
locally and in a green CI run — the affected steps exit 0 and just skip the
notify or commit status. Add a script engineers can run before opening a PR that
touches .github/ or detox/utils:

  cd detox && npm run preflight:ci        # :ci-fast skips lint + tsc

It runs actionlint (which flags secrets not declared in a callee's
workflow_call.secrets), scans for exit-code capture broken by a trailing
backslash, asserts every job running a TSIO OIDC script declares
id-token: write, then runs the detox/utils tests and npm run check. actionlint
and the CodeRabbit CLI are optional and skipped with an install hint when absent.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci(e2e): use mktemp for preflight logs

Fixed /tmp log paths can be pre-created as symlinks, so the preflight
script would truncate whatever they point at (CWE-377). Create both logs
with mktemp and drop them on exit.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci(e2e): address review feedback on TSIO statuses and sidecar

A cancelled E2E run reached no conclusion, so posting `success` on its
commit statuses let a PR merge on a check that never ran. Cancelled runs
now post `pending`; `success` is reserved for the one deliberate bypass,
the E2E/Override label.

Commit-status contexts move into the `e2e-test/*` namespace to match the
mattermost monorepo, with the prefix owned by build-tsio-job-config.js and
mirrored in the cancel action's context list. Legacy unprefixed and `e2e/*`
contexts are dropped rather than carried forward.

The `preflight:ci` script and its OIDC-permission checker duplicated what
actionlint, the unit tests and `npm run check` already do, so they are
removed and detox/CLAUDE.md documents the underlying failure modes and the
commands to catch them instead.

cloudflared and the TSIO CLI are pinned to known-good releases rather than
`latest`, and detox/README.md now documents the webhook sidecar: when a
tunnel is needed, the WEBHOOK_PUBLIC_BASE_URL / CLOUDFLARED_TUNNEL_TOKEN
precedence, and why quick tunnels report callbacks as unreachable.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci(e2e): detect upstream cancellation and surface status reset failures

generate-report runs with always(), so job.status stays "success" when an
upstream job is cancelled and TSIO was published as a real failure. Read the
generate-specs/e2e-* needs results so a cancelled run is reported as such.

In cancel-e2e-runs, only reset the E2E commit statuses for runs that were
actually stopped — a run we failed to cancel keeps publishing its own results.
Aggregate createCommitStatus failures across every context and fail the action
afterwards instead of silently warning, so a stale check cannot go unnoticed.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:45:06 +05:30

330 lines
12 KiB
Bash
Executable File

#!/bin/bash
# Reference: https://wix.github.io/Detox/docs/guide/android-dev-env
set -ex
set -o pipefail
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() {
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
configure_chrome_for_ci
}
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
adb shell am start -n com.android.chrome/com.google.android.apps.chrome.Main 2>/dev/null || true
sleep 3
adb shell input keyevent 4 2>/dev/null || true
}
push_e2e_fixtures() {
local fixture="../detox/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 ..
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 detox
AVD_NAME="$AVD_NAME" npm run detox:config-gen
mkdir -p artifacts
npm run e2e:android-test -- "$@" -- --json --outputFile=artifacts/jest-results.json
}
main() {
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
fi
if [[ "${BOOTSTRAP_ONLY:-}" == "true" ]]; then
echo "Bootstrap complete (BOOTSTRAP_ONLY=true) — skipping Detox tests"
exit 0
fi
run_detox_tests "${TEST_FILES[@]}"
}
main