mattermost-mobile-upstream/.github/workflows/e2e-android-template.yml
yasser khan 75a3cf2bf5
Some checks are pending
ci / lint-typecheck (push) Waiting to run
ci / detox-lint-typecheck (push) Waiting to run
ci / test (push) Waiting to run
E2E: apply one server baseline at provisioning (#10258)
* MM-71189 Preserve feature flags in Detox config patches

Co-authored-by: mattermost-code <matty-code@mattermost.com>

* Guard getFeatureFlags against empty config responses

Return an error instead of treating a missing config body as an empty
flag set, so a dropped GET does not patch only the requested flags.

Co-authored-by: mattermost-code <matty-code@mattermost.com>

* Retrigger CI after Detox iOS and iPad E2E flakes

Co-authored-by: mattermost-code <matty-code@mattermost.com>

* test(e2e): apply one declared server baseline at provisioning

Provisioning drives each test server to one declared baseline: the config keys E2E
needs and the full FeatureFlags map, written read-modify-write so no flag is reset.
Env-pinned keys are reported, not patched, and leftover E2E policies, remote clusters
and property fields are removed. Provisioning also disables stray plugins once.

* test(e2e): assert the server baseline instead of patching config in specs

These specs patched server-wide settings (CRT, archived channels, auto-replies, ...) and
restored them while other shards depended on them. They now assert the provisioned
baseline instead.

* test(e2e): split CI servers into pool and mutation roles; remove the locks

Each platform's server A becomes a read-only pool for 27 shards (and iPad and Maestro);
server B (and on iOS part of SITE_3) runs one serial mutation shard with the specs in
detox/e2e/server_mutation_specs.json. The classification, server and SITE_3 locks go.

- split-tests.js places mutation specs by the manifest and packs the pool by duration;
  a spec that needs B never falls back to the shared SITE_3.
- A pool request guard, an ESLint rule and a manifest checker keep server-wide writes
  off the pool. Mutation shards re-apply the baseline before each spec file.
- Mutating tests move to their own spec files; ABAC specs use channel-scoped
  permission policies; MFA and Terms of Service run on the mutation server.
- Pool shards no longer disable provisioned plugins, so the Agents specs run.

* fix(e2e): fail a spec when the baseline cannot be applied locally

* fix(e2e): keep transport retries for feature-flag and baseline reads

Restore the retry in getFeatureFlags, which apiEnableFeatureFlags calls directly, and
retry the baseline assertion's GET requests on transport failures.

* test(e2e): cover retries of dropped config and baseline reads

* test(e2e): apply one declared server baseline at provisioning

Provisioning drives each test server to one declared baseline: the config keys E2E
needs and the full FeatureFlags map, written read-modify-write so no flag is reset.
Env-pinned keys are reported, not patched, and leftover E2E policies, remote clusters
and property fields are removed. Provisioning also disables stray plugins once.

* test(e2e): assert the server baseline instead of patching config in specs

These specs patched server-wide settings (CRT, archived channels, auto-replies, ...) and
restored them while other shards depended on them. They now assert the provisioned
baseline instead.

* fix(e2e): fail a spec when the baseline cannot be applied locally

* fix(e2e): keep transport retries for feature-flag and baseline reads

Restore the retry in getFeatureFlags, which apiEnableFeatureFlags calls directly, and
retry the baseline assertion's GET requests on transport failures.

* test(e2e): cover retries of dropped config and baseline reads

* E2E: stop burn-on-read spec turning other feature flags off

Its partial FeatureFlags patch reset every other flag on the shared server,
which turned ChannelAttributes off under channel_attributes on another shard.
Enable only the missing flags, as redacted_files and upload_permission do,
and do the same for the dialog spec. Also tap the attach button the app
actually renders in MM-T339_1.

* E2E: route flag-off spec to B on iOS; tap the real attach button in the max-size spec

* test(e2e): apply one declared server baseline at provisioning

Provisioning drives each test server to one declared baseline: the config keys E2E
needs and the full FeatureFlags map, written read-modify-write so no flag is reset.
Env-pinned keys are reported, not patched, and leftover E2E policies, remote clusters
and property fields are removed. Provisioning also disables stray plugins once.

* test(e2e): assert the server baseline instead of patching config in specs

These specs patched server-wide settings (CRT, archived channels, auto-replies, ...) and
restored them while other shards depended on them. They now assert the provisioned
baseline instead.

* fix(e2e): fail a spec when the baseline cannot be applied locally

* fix(e2e): keep transport retries for feature-flag and baseline reads

Restore the retry in getFeatureFlags, which apiEnableFeatureFlags calls directly, and
retry the baseline assertion's GET requests on transport failures.

* test(e2e): cover retries of dropped config and baseline reads

* E2E: stop burn-on-read spec turning other feature flags off

Its partial FeatureFlags patch reset every other flag on the shared server,
which turned ChannelAttributes off under channel_attributes on another shard.
Enable only the missing flags, as redacted_files and upload_permission do,
and do the same for the dialog spec. Also tap the attach button the app
actually renders in MM-T339_1.

* E2E: wait for the results service before retrying a shard upload

upload-artifact does not retry network errors (ENOTFOUND, ETIMEDOUT), and the
retry fired ~30 ms after the first failure, inside the same DNS outage. A shard
whose specs all passed was then reported as "spec did not run". Each retry now
waits (bounded, 120 s) for the results host to answer, with a second retry.

* E2E: run the specs a killed iOS Jest never started; record why it died

When Jest is killed between spec files (exit code = null) it writes no report, and the
specs after that point never run. Run only those once in a fresh Jest and merge the
results; specs that ran keep their own result, so a spec that crashes Jest stays red.
Also sample memory and collect jetsam/crash reports so the next kill names its cause.

* E2E: keep the demo plugin off the shared servers; 32 shards

The demo plugin posts into every team on every server event and inside every config
save, and on our servers each of those posts fails. Only the two dialog specs use it,
so install it only on mutation server B, disable it everywhere else (failing
provisioning if it stays on), and route both dialog specs to B. Drop the
DialogOnlyMode setting, which v0.12.1 does not have. Raise Detox shards 28 -> 32.

* E2E: give the demo plugin a settings entry on the servers that install it

Demo plugin v0.12.1's ConfigurationWillBeSaved hands the whole config back when it
has no settings entry, and that round trip resets settings it does not know, or that
are false, to their defaults. With DialogOnlyMode gone from the baseline, fresh B
servers had no entry, so ChannelAttributesRequired=true and EnableMobileUpload=false
were reverted inside the save. An empty entry keeps the hook out of the save.

---------

Co-authored-by: mattermost-code <matty-code@mattermost.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: yasserfaraazkhan <yasser.khan@mattermost.com>
2026-10-21 23:36:31 +05:30

867 lines
38 KiB
YAML

name: Detox Android E2E Tests Template
on:
workflow_call:
secrets:
MM_MOBILE_E2E_ADMIN_EMAIL:
required: false
MM_MOBILE_E2E_ADMIN_USERNAME:
required: false
MM_MOBILE_E2E_ADMIN_PASSWORD:
required: false
MM_MOBILE_E2E_ZEPHYR_API_KEY:
required: false
MM_MOBILE_E2E_TEST_CYCLE_LINK_PREFIX:
required: false
MM_MOBILE_E2E_WEBHOOK_URL:
required: false
# Channel-notify routing for the per-job TSIO finalize step. Callers that use
# an explicit `secrets:` block must forward these or notify silently no-ops.
MM_E2E_MASTER_HEALTH_WEBHOOK_URL:
required: false
MM_E2E_RELEASE_WEBHOOK_URL:
required: false
MM_MOBILE_DETOX_AWS_ACCESS_KEY_ID:
required: false
MM_MOBILE_DETOX_AWS_SECRET_ACCESS_KEY:
required: false
# Webhook sidecar. Unset means mm_blocks specs fall back to trycloudflare.
MM_MOBILE_E2E_WEBHOOK_PUBLIC_BASE_URL:
required: false
MM_MOBILE_E2E_CLOUDFLARED_TUNNEL_TOKEN:
required: false
inputs:
MM_TEST_SERVER_URL:
description: "The test server URL (SITE_1)"
required: false
type: string
MM_TEST_SERVER_URL_2:
description: "The second test server URL (SITE_2); optional, no automatic fallback"
required: false
type: string
MM_TEST_SERVER_URL_3:
description: "The third test server URL (SITE_3); optional, no automatic fallback"
required: false
type: string
MM_OTHER_PLATFORM_POOL_URL:
description: "The other platform's SITE_1 server, used as this platform's pool SITE_2; empty falls back to SITE_1"
required: false
type: string
default: ""
MM_TEST_USER_NAME:
description: "The admin username of the test instance"
required: false
type: string
MM_TEST_PASSWORD:
description: "The admin password of the test instance"
required: false
type: string
MOBILE_VERSION:
description: "The mobile version to test"
required: false
default: ""
type: string
run-android-tests:
description: "Run Android tests"
required: true
type: boolean
run-type:
type: string
required: false
default: "PR"
record_tests_in_zephyr:
description: "Record test results in Zephyr, typically for release and main runs"
required: false
type: boolean
default: false
low_bandwidth_mode:
description: "Enable low bandwidth mode"
required: false
type: boolean
default: false
search_path:
description: "Path to search for test specs (use for smoke test subset)"
required: false
type: string
default: "detox/e2e/test"
parallelism:
description: "Number of parallel test shards"
required: false
type: string
default: "32"
android_avd_name:
description: "Android Emulator name"
required: false
type: string
default: "detox_pixel_8"
android_api_level:
description: "Android API level"
required: false
type: string
default: "35"
artifact_run_id:
description: "Run ID of the workflow that uploaded android-build-files-* (defaults to caller run)"
required: false
type: string
default: ""
tsio-config:
description: >
TSIO reporting config: {"composite_identity":{...},"total_reports_expected":N}.
Empty disables TSIO upload for this platform.
required: false
type: string
default: ""
tsio-shard-name:
description: "Display name for this shard in TSIO (e.g. detox-android-Server_11.9.0-rc3)"
required: false
type: string
default: ""
durations_key:
description: >
Spec-duration manifest key (e.g. detox-android). Enables duration-aware
shard packing. Empty keeps the even alphabetical split.
required: false
type: string
default: ""
outputs:
STATUS:
value: ${{ jobs.generate-report.outputs.STATUS }}
TARGET_URL:
value: ${{ jobs.generate-report.outputs.TARGET_URL }}
FAILURES:
value: ${{ jobs.generate-report.outputs.FAILURES }}
PASSES:
value: ${{ jobs.generate-report.outputs.PASSES }}
TOTAL:
value: ${{ jobs.generate-report.outputs.TOTAL }}
SKIPPED:
value: ${{ jobs.generate-report.outputs.SKIPPED }}
ERRORS:
value: ${{ jobs.generate-report.outputs.ERRORS }}
PERCENTAGE:
value: ${{ jobs.generate-report.outputs.PERCENTAGE }}
env:
AWS_REGION: "us-east-1"
ADMIN_EMAIL: ${{ secrets.MM_MOBILE_E2E_ADMIN_EMAIL }}
ADMIN_USERNAME: ${{ inputs.MM_TEST_USER_NAME || secrets.MM_MOBILE_E2E_ADMIN_USERNAME }}
ADMIN_PASSWORD: ${{ inputs.MM_TEST_PASSWORD || secrets.MM_MOBILE_E2E_ADMIN_PASSWORD }}
BRANCH: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}
COMMIT_HASH: ${{ github.sha }}
DEVICE_NAME: ${{ inputs.android_avd_name }} # This is needed to split tests as same code is used in iOS job
DEVICE_OS_VERSION: ${{ inputs.android_api_level }}
DETOX_AWS_S3_BUCKET: "mattermost-detox-report"
HEADLESS: "true"
TYPE: ${{ inputs.run-type }}
PULL_REQUEST: "https://github.com/mattermost/mattermost-mobile/pull/${{ github.event.number }}"
SITE_1_URL: ${{ inputs.MM_TEST_SERVER_URL }}
SITE_2_URL: ${{ inputs.MM_TEST_SERVER_URL_2 }}
SITE_3_URL: ${{ inputs.MM_TEST_SERVER_URL_3 }}
ZEPHYR_ENABLE: ${{ inputs.record_tests_in_zephyr }}
JIRA_PROJECT_KEY: "MM"
ZEPHYR_API_KEY: ${{ secrets.MM_MOBILE_E2E_ZEPHYR_API_KEY }}
ZEPHYR_FOLDER_ID: "3233873"
TEST_CYCLE_LINK_PREFIX: ${{ secrets.MM_MOBILE_E2E_TEST_CYCLE_LINK_PREFIX }}
WEBHOOK_URL: ${{ secrets.MM_MOBILE_E2E_WEBHOOK_URL }}
FAILURE_MESSAGE: "Something has failed"
IOS: "false"
RUNNING_E2E: "true"
AVD_NAME: ${{ inputs.android_avd_name }}
SDK_VERSION: ${{ inputs.android_api_level }}
ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }}
jobs:
generate-specs:
runs-on: ubuntu-24.04
outputs:
specs: ${{ steps.generate-specs.outputs.specs }}
shard_count: ${{ steps.generate-specs.outputs.shard_count }}
build_id: ${{ steps.resolve-device.outputs.BUILD_ID }}
mobile_sha: ${{ steps.resolve-device.outputs.MOBILE_SHA }}
mobile_ref: ${{ steps.resolve-device.outputs.MOBILE_REF }}
workflow_hash: ${{ steps.resolve-device.outputs.WORKFLOW_HASH }}
steps:
- name: Checkout Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.MOBILE_VERSION }}
- name: Set Build ID
id: resolve-device
run: |
BUILD_ID="${{ github.run_id }}-${{ env.AVD_NAME }}-${{ env.SDK_VERSION}}"
WORKFLOW_HASH=$(tr -dc a-z0-9 </dev/urandom | head -c 10)
## We need that hash to separate the artifacts
echo "WORKFLOW_HASH=${WORKFLOW_HASH}" >> ${GITHUB_OUTPUT}
echo "BUILD_ID=$(echo ${BUILD_ID} | sed 's/ /_/g')" >> ${GITHUB_OUTPUT}
echo "MOBILE_SHA=$(git rev-parse HEAD)" >> ${GITHUB_OUTPUT}
echo "MOBILE_REF=$(git rev-parse --abbrev-ref HEAD)" >> ${GITHUB_OUTPUT}
# Duration history from the last main-push run; absent → even split.
# continue-on-error: this job gates the entire test matrix, and shard packing
# is an optimization — a missing manifest or an AWS hiccup must not stop the
# run, it must fall back to the even split.
- name: Restore spec-duration manifest
if: inputs.durations_key != ''
continue-on-error: true
uses: ./.github/actions/s3-spec-durations
env:
DETOX_AWS_ACCESS_KEY_ID: ${{ secrets.MM_MOBILE_DETOX_AWS_ACCESS_KEY_ID }}
DETOX_AWS_SECRET_ACCESS_KEY: ${{ secrets.MM_MOBILE_DETOX_AWS_SECRET_ACCESS_KEY }}
with:
key: ${{ inputs.durations_key }}
bucket: ${{ env.DETOX_AWS_S3_BUCKET }}
mode: restore
# Server B gets a mutation shard when it is distinct from A. Android has no
# site3 mutation target; its specs whose iosTarget is site3 run on B.
- name: Resolve mutation server targets
id: mutation-targets
env:
SERVER_A: ${{ inputs.MM_TEST_SERVER_URL }}
SERVER_B: ${{ inputs.MM_TEST_SERVER_URL_2 }}
run: |
# No targets without a distinct second server; split-tests then fails only if a mutation spec is selected.
TARGETS=""
if [ -n "$SERVER_B" ] && [ "$SERVER_B" != "$SERVER_A" ]; then
TARGETS="B"
fi
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
echo "Mutation targets: ${TARGETS:-none}"
- name: Generate Test Specs
id: generate-specs
uses: ./.github/actions/generate-specs
with:
parallelism: ${{ inputs.parallelism }}
search_path: ${{ inputs.search_path }}
device_name: ${{ env.AVD_NAME }}
device_os_version: ${{ env.SDK_VERSION }}
durations_file: ${{ inputs.durations_key != '' && 'detox/artifacts/spec-durations.json' || '' }}
platform: android
mutation_targets: ${{ steps.mutation-targets.outputs.targets }}
e2e-android:
name: ${{ matrix.role == 'mutation' && 'mutation-' || '' }}machine-${{ matrix.runId }}-api-${{ matrix.deviceOsVersion }}
runs-on: ubuntu-latest-8-cores
continue-on-error: true
timeout-minutes: 150
env:
ANDROID_HOME: /usr/local/lib/android/sdk
ANDROID_SDK_ROOT: /usr/local/lib/android/sdk
needs:
- generate-specs
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.generate-specs.outputs.specs) }}
steps:
- name: Checkout Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.MOBILE_VERSION }}
# Pool shards pair server A with the other platform's A; mutation shards own server B alone.
# SITE_3 is also every pool shard's read-only third server, so site3 mutations must not touch login.
- name: Assign test sites by shard role
env:
ROLE: ${{ matrix.role }}
TARGET: ${{ matrix.target }}
SERVER_A: ${{ inputs.MM_TEST_SERVER_URL }}
SERVER_B: ${{ inputs.MM_TEST_SERVER_URL_2 }}
SERVER_SHARED: ${{ inputs.MM_TEST_SERVER_URL_3 }}
OTHER_PLATFORM_POOL: ${{ inputs.MM_OTHER_PLATFORM_POOL_URL }}
run: |
case "$ROLE" in
pool)
SITE_1_URL="$SERVER_A"; SITE_1_LABEL="server-a"
if [ -n "$OTHER_PLATFORM_POOL" ]; then
SITE_2_URL="$OTHER_PLATFORM_POOL"; SITE_2_LABEL="other-platform-a"
else
# Never SITE_3: on iOS it is also a mutation target.
echo "::warning::MM_OTHER_PLATFORM_POOL_URL is empty; pool SITE_2 falls back to this shard's own server A"
SITE_2_URL="$SERVER_A"; SITE_2_LABEL="server-a"
fi
SITE_3_URL="$SERVER_SHARED"; SITE_3_LABEL="site3"
GUARD="enforce"
;;
mutation)
case "$TARGET" in
B) SITE_1_URL="$SERVER_B"; SITE_1_LABEL="server-b" ;;
site3) SITE_1_URL="$SERVER_SHARED"; SITE_1_LABEL="site3" ;;
A) echo "::error::No isolated server for mutation specs (MM_TEST_SERVER_URL_2 missing or equal to MM_TEST_SERVER_URL); they would change state on the pool server"; exit 1 ;;
*) echo "::error::Unknown mutation target '$TARGET'"; exit 1 ;;
esac
SITE_2_URL="$SERVER_A"; SITE_2_LABEL="server-a"
SITE_3_URL=""; SITE_3_LABEL="unset"
GUARD="off"
;;
*)
echo "::error::Unknown shard role '$ROLE'"; exit 1 ;;
esac
if [ -z "$SITE_1_URL" ]; then
echo "::error::SITE_1 is empty for role=$ROLE target=$TARGET"; exit 1
fi
{
echo "SITE_1_URL=$SITE_1_URL"
echo "SITE_2_URL=$SITE_2_URL"
echo "SITE_3_URL=$SITE_3_URL"
echo "E2E_SERVER_ROLE=$ROLE"
echo "E2E_POOL_GUARD=$GUARD"
} >> "$GITHUB_ENV"
echo "role=$ROLE target=$TARGET: SITE_1=$SITE_1_LABEL SITE_2=$SITE_2_LABEL SITE_3=$SITE_3_LABEL pool_guard=$GUARD"
# Only libpulse0 is a real dependency here — the Android emulator needs it for
# audio. xvfb is already in the runner image (apt reports "already the newest
# version"), and it is listed only so a future image change fails loudly.
- name: Install Dependencies
timeout-minutes: 6
run: |
APT_OPTS=(-o Acquire::Retries=3 -o Acquire::http::Timeout=15 -o DPkg::Lock::Timeout=120)
PACKAGES=(libpulse0 xvfb)
sudo apt-get "${APT_OPTS[@]}" install -y "${PACKAGES[@]}" || {
sudo apt-get "${APT_OPTS[@]}" update
sudo apt-get "${APT_OPTS[@]}" install -y "${PACKAGES[@]}"
}
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: ci/prepare-node-deps
uses: ./.github/actions/prepare-node-deps
- name: Cache detox node_modules
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: detox/node_modules
key: ${{ runner.os }}-detox-deps-${{ hashFiles('detox/package-lock.json') }}
restore-keys: ${{ runner.os }}-detox-deps-
- name: Install Detox Dependencies
run: |
cd detox
npm install
- name: Set .env with RUNNING_E2E=true
run: |
cat > .env <<EOF
RUNNING_E2E=true
EOF
# Must precede the download: download-artifact consumes ARTIFACT_RUN_ID and
# fails with an opaque API error when it is empty.
- name: Verify artifact_run_id was passed by the parent workflow
env:
ARTIFACT_RUN_ID: ${{ env.ARTIFACT_RUN_ID }}
run: |
if [ -z "${ARTIFACT_RUN_ID}" ]; then
echo "❌ ARTIFACT_RUN_ID is empty — parent workflow must pass artifact_run_id from build-android-apk"
exit 1
fi
- name: Download Detox Android build from parent workflow
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: android-build-files-${{ env.ARTIFACT_RUN_ID }}
repository: ${{ github.repository }}
run-id: ${{ env.ARTIFACT_RUN_ID }}
github-token: ${{ github.token }}
path: android/app/build
- name: Verify Detox Android app from parent build
run: |
APP_APK="android/app/build/outputs/apk/debug/app-debug.apk"
TEST_APK="android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk"
if [ ! -f "$APP_APK" ]; then
echo "❌ Missing ${APP_APK} from android-build-files-${ARTIFACT_RUN_ID}"
find android/app/build -name '*.apk' 2>/dev/null || true
exit 1
fi
if [ ! -f "$TEST_APK" ]; then
echo "❌ Missing ${TEST_APK} from android-build-files-${ARTIFACT_RUN_ID}"
find android/app/build -name '*.apk' 2>/dev/null || true
exit 1
fi
echo "✅ Detox will use Android APKs from parent workflow run ${ARTIFACT_RUN_ID}:"
ls -lh "$APP_APK" "$TEST_APK"
env:
ARTIFACT_RUN_ID: ${{ env.ARTIFACT_RUN_ID }}
- name: Set up Android SDK
run: |
export ANDROID_HOME=/usr/local/lib/android/sdk
export PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/emulator:$ANDROID_HOME/tools/bin:$ANDROID_HOME/platform-tools:$PATH
echo "ANDROID_HOME=$ANDROID_HOME" >> $GITHUB_ENV
echo "PATH=$PATH" >> $GITHUB_ENV
- name: Start Xvfb
run: |
Xvfb :99 -screen 0 1920x1080x24 &
export DISPLAY=:99
echo "DISPLAY=:99" >> $GITHUB_ENV
- name: Accept Android licenses
run: |
mkdir -p ~/.android
yes | sdkmanager --licenses > sdk_licenses_output.txt || true
# Check if "All SDK package licenses accepted" appears in output
if grep -q "All SDK package licenses accepted" sdk_licenses_output.txt; then
echo "✅ All licenses accepted successfully."
else
echo "❌ Licenses not fully accepted."
cat sdk_licenses_output.txt
exit 1
fi
# Cache SDK under the workspace (not /usr/local) — tar restore into system
# SDK dirs often fails with exit code 2 when paths already exist on the image.
- name: Cache Android SDK components
id: android-sdk-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .android-sdk-cache
key: android-sdk-${{ inputs.android_api_level }}-google-apis-x86_64-v2
- name: Apply cached Android SDK components
if: steps.android-sdk-cache.outputs.cache-hit == 'true'
env:
API_LEVEL: ${{ inputs.android_api_level }}
run: |
for component in platform-tools emulator; do
if [ -d ".android-sdk-cache/${component}" ]; then
rm -rf "${ANDROID_HOME:?}/${component}"
cp -a ".android-sdk-cache/${component}" "${ANDROID_HOME}/"
fi
done
if [ -d ".android-sdk-cache/platforms/android-${API_LEVEL}" ]; then
rm -rf "${ANDROID_HOME}/platforms/android-${API_LEVEL}"
mkdir -p "${ANDROID_HOME}/platforms"
cp -a ".android-sdk-cache/platforms/android-${API_LEVEL}" "${ANDROID_HOME}/platforms/"
fi
if [ -d ".android-sdk-cache/system-images/android-${API_LEVEL}" ]; then
rm -rf "${ANDROID_HOME}/system-images/android-${API_LEVEL}"
mkdir -p "${ANDROID_HOME}/system-images"
cp -a ".android-sdk-cache/system-images/android-${API_LEVEL}" "${ANDROID_HOME}/system-images/"
fi
- name: Install Android SDK components
if: steps.android-sdk-cache.outputs.cache-hit != 'true'
env:
API_LEVEL: ${{ inputs.android_api_level }}
JAVA_HOME: ${{ env.JAVA_HOME_17_X64 }}
run: |
if ! [[ "$API_LEVEL" =~ ^[0-9]+$ ]]; then
echo "Invalid android_api_level: $API_LEVEL"
exit 1
fi
for attempt in 1 2 3; do
echo "SDK install attempt $attempt..."
if yes | sdkmanager --install "platform-tools" "emulator" "platforms;android-${API_LEVEL}" "system-images;android-${API_LEVEL};google_apis;x86_64"; then
echo "SDK install succeeded on attempt $attempt"
break
fi
echo "Attempt $attempt failed, retrying..."
if [ "$attempt" = "3" ]; then
echo "SDK install failed after 3 attempts"
exit 1
fi
sleep 10
done
mkdir -p .android-sdk-cache/platforms .android-sdk-cache/system-images
cp -a "${ANDROID_HOME}/platform-tools" .android-sdk-cache/
cp -a "${ANDROID_HOME}/emulator" .android-sdk-cache/
cp -a "${ANDROID_HOME}/platforms/android-${API_LEVEL}" .android-sdk-cache/platforms/
cp -a "${ANDROID_HOME}/system-images/android-${API_LEVEL}" .android-sdk-cache/system-images/
# CI AVDs live under detox/.android/avd (see create_android_emulator.sh), not ~/.android/avd.
- name: Cache Android Emulator & AVD
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
detox/.android
detox/${{ env.AVD_NAME }}_api_${{ inputs.android_api_level }}
key: android-emulator-${{ runner.os }}-${{ runner.arch }}-${{ env.AVD_NAME }}-api-${{ inputs.android_api_level }}-v3
restore-keys: |
android-emulator-${{ runner.os }}-${{ runner.arch }}-${{ env.AVD_NAME }}-api-${{ inputs.android_api_level }}-
android-emulator-${{ runner.os }}-${{ runner.arch }}-
- name: Start mm_blocks webhook sidecar
if: contains(matrix.specs, 'mm_blocks_')
continue-on-error: true
env:
SITE_1_URL: ${{ env.SITE_1_URL }}
WEBHOOK_PUBLIC_BASE_URL: ${{ secrets.MM_MOBILE_E2E_WEBHOOK_PUBLIC_BASE_URL }}
CLOUDFLARED_TUNNEL_TOKEN: ${{ secrets.MM_MOBILE_E2E_CLOUDFLARED_TUNNEL_TOKEN }}
run: bash detox/scripts/start_webhook_sidecar.sh
- name: Create and run Android Emulator
env:
MATRIX_SPECS: ${{ matrix.specs }}
SITE_1_URL: ${{ env.SITE_1_URL }}
SITE_2_URL: ${{ env.SITE_2_URL }}
SITE_3_URL: ${{ env.SITE_3_URL }}
MAX_ATTEMPTS: "2"
ATTEMPT_TIMEOUT_MIN: "60"
shell: bash
# Attempt 2 retries failed/unrun files only. Cold-boots only if the emulator is dead.
run: |
chmod +x detox/scripts/run_android_ci_shard.sh detox/create_android_emulator.sh
# Split on whitespace into an array, then expand quoted: an unquoted
# $MATRIX_SPECS is also subject to pathname expansion, so a spec path
# containing a glob character would silently become a different argument
# list (or vanish). Matches the iOS template's read -ra handling.
read -ra specs <<< "$MATRIX_SPECS"
detox/scripts/run_android_ci_shard.sh "$SDK_VERSION" "$AVD_NAME" "${specs[@]}"
continue-on-error: true # We want to run all the tests
# Second attempt for the same reason as the iOS template: losing this upload to a
# transient ETIMEDOUT reports the shard as having produced no results at all.
- name: Upload Android Test Report
id: upload-android-report
if: always() && hashFiles('detox/artifacts/**') != ''
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: android-results-${{ needs.generate-specs.outputs.workflow_hash }}-${{ matrix.runId }}
path: detox/artifacts/
# Same two-retry sequence as the iOS template (see there).
- name: Await results service
if: |
!cancelled() &&
steps.upload-android-report.outcome == 'failure'
uses: ./.github/actions/await-results-service
- name: Upload Android Test Report (retry 1)
id: upload-android-report-retry
if: |
!cancelled() &&
steps.upload-android-report.outcome == 'failure'
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: android-results-${{ needs.generate-specs.outputs.workflow_hash }}-${{ matrix.runId }}
path: detox/artifacts/
- name: Await results service (retry 2)
if: |
!cancelled() &&
steps.upload-android-report.outcome == 'failure' &&
steps.upload-android-report-retry.outcome == 'failure'
uses: ./.github/actions/await-results-service
- name: Upload Android Test Report (retry 2)
if: |
!cancelled() &&
steps.upload-android-report.outcome == 'failure' &&
steps.upload-android-report-retry.outcome == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: android-results-${{ needs.generate-specs.outputs.workflow_hash }}-${{ matrix.runId }}
path: detox/artifacts/
overwrite: true
generate-report:
# TSIO displays gh-job-name as the shard label — keep it aligned with tsio-shard-name.
name: ${{ inputs.tsio-shard-name != '' && inputs.tsio-shard-name || 'generate-report' }}
if: always()
runs-on: ubuntu-24.04
needs:
- generate-specs
- e2e-android
permissions:
contents: read
# test-system-io-report-upload authenticates via the workflow's OIDC
# token and resolves gh_job_id via the GitHub API.
id-token: write
actions: read
# Per-job TSIO finalize posts detox-android (or CMT <shard>).
statuses: write
# Finalize re-reads the PR labels so a result landing after E2E/Override was
# applied reports success instead of re-blocking the merge.
issues: read
outputs:
TARGET_URL: ${{ steps.set-url.outputs.TARGET_URL }}
STATUS: ${{ steps.determine-status.outputs.STATUS }}
FAILURES: ${{ steps.summary.outputs.FAILURES }}
PASSES: ${{ steps.summary.outputs.PASSES }}
TOTAL: ${{ steps.summary.outputs.TOTAL }}
SKIPPED: ${{ steps.summary.outputs.SKIPPED }}
ERRORS: ${{ steps.summary.outputs.ERRORS }}
PERCENTAGE: ${{ steps.summary.outputs.PERCENTAGE }}
steps:
- name: Checkout Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.MOBILE_VERSION }}
- name: ci/prepare-node-deps
uses: ./.github/actions/prepare-node-deps
- name: Download Android Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: detox/artifacts/
pattern: android-results-${{ needs.generate-specs.outputs.workflow_hash }}-*
continue-on-error: true
- name: Generate Report Path
id: s3
env:
BUILD_ID: ${{ needs.generate-specs.outputs.build_id }}
MOBILE_SHA: ${{ needs.generate-specs.outputs.mobile_sha }}
MOBILE_REF: ${{ needs.generate-specs.outputs.mobile_ref }}
run: |
path="${BUILD_ID}-${MOBILE_SHA}-${MOBILE_REF}"
echo "path=$(echo "${path}" | sed 's/\./-/g')" >> "$GITHUB_OUTPUT"
# ── Spec durations for the next run's shard packing ────────────────────
# Must run before e2e:save-report, which deletes the android-results-* dirs
# the per-shard jest-results.json live in. Only main-push publishes (a PR
# must not steer how other branches shard); PR runs still compute it so the
# step is exercised on every PR that touches this workflow.
# Re-fetch first: this is a different runner from generate-specs, so without
# it the EWMA would have no history and collapse to "last run only".
- name: Restore previous spec-duration manifest
if: always() && inputs.durations_key != ''
continue-on-error: true
uses: ./.github/actions/s3-spec-durations
env:
DETOX_AWS_ACCESS_KEY_ID: ${{ secrets.MM_MOBILE_DETOX_AWS_ACCESS_KEY_ID }}
DETOX_AWS_SECRET_ACCESS_KEY: ${{ secrets.MM_MOBILE_DETOX_AWS_SECRET_ACCESS_KEY }}
with:
key: ${{ inputs.durations_key }}
bucket: ${{ env.DETOX_AWS_S3_BUCKET }}
mode: restore
- name: Build spec-duration manifest
if: always() && inputs.durations_key != ''
continue-on-error: true
env:
DURATIONS_KEY: ${{ inputs.durations_key }}
run: |
node detox/utils/spec-durations.js \
--dir detox/artifacts \
--previous detox/artifacts/spec-durations.json \
--output detox/artifacts/spec-durations.next.json \
--key "$DURATIONS_KEY" \
--repo-root "$GITHUB_WORKSPACE"
- name: Publish spec-duration manifest
if: always() && inputs.durations_key != '' && (inputs.run-type == 'MAIN' || inputs.run-type == 'MASTER') && hashFiles('detox/artifacts/spec-durations.next.json') != ''
continue-on-error: true
uses: ./.github/actions/s3-spec-durations
env:
DETOX_AWS_ACCESS_KEY_ID: ${{ secrets.MM_MOBILE_DETOX_AWS_ACCESS_KEY_ID }}
DETOX_AWS_SECRET_ACCESS_KEY: ${{ secrets.MM_MOBILE_DETOX_AWS_SECRET_ACCESS_KEY }}
with:
key: ${{ inputs.durations_key }}
bucket: ${{ env.DETOX_AWS_S3_BUCKET }}
manifest-path: detox/artifacts/spec-durations.next.json
mode: save
- name: Merge per-shard Jest JSON for TSIO
if: always() && inputs.tsio-config != '' && vars.TSIO_UPLOAD_ENABLED != 'false'
id: tsio-merge
env:
# Built from repo filenames, so it must reach the shell as data, never spliced
# into the script text: a spec named x'$(...)'.e2e.ts would otherwise execute.
EXPECTED_SPECS: ${{ needs.generate-specs.outputs.specs }}
run: |
set +e
# TSIO extractDetox expects testFilePath + nested testResults (not Jest CLI
# name + assertionResults). Convert while merging shard JSON files.
if ! node detox/utils/merge-jest-results-for-tsio.js \
--dir detox/artifacts \
--repo-root "${GITHUB_WORKSPACE}" \
--expected-specs "$EXPECTED_SPECS" \
--output detox/artifacts/jest-results-merged.json; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "No jest-results.json found or merge failed — skipping TSIO upload."
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Write TSIO failure stub when Detox JSON is missing
if: always() && inputs.tsio-config != '' && vars.TSIO_UPLOAD_ENABLED != 'false' && steps.tsio-merge.outputs.skip == 'true'
env:
TSIO_SHARD_NAME: ${{ inputs.tsio-shard-name != '' && inputs.tsio-shard-name || 'generate-report' }}
run: |
node detox/utils/write-tsio-failure-stub.mjs \
--format jest \
--output detox/artifacts/jest-results-merged.json \
--job-name "$TSIO_SHARD_NAME" \
--reason "Detox job failed or was cancelled before jest-results.json was written"
- name: Resolve per-job TSIO config
if: always() && inputs.tsio-config != '' && vars.TSIO_UPLOAD_ENABLED != 'false'
id: tsio-cfg
env:
TSIO_CONFIG: ${{ inputs.tsio-config }}
TSIO_SHARD_NAME: ${{ inputs.tsio-shard-name }}
run: |
CFG=$(bash detox/utils/resolve-tsio-job-config.sh)
{
echo "config<<EOF"
echo "$CFG"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "Resolved TSIO job: $(echo "$CFG" | jq -r '.composite_identity.name') context=$(echo "$CFG" | jq -r '.status_context')"
- name: Upload report to Test System IO
if: always() && inputs.tsio-config != '' && vars.TSIO_UPLOAD_ENABLED != 'false' && hashFiles('detox/artifacts/jest-results-merged.json') != '' && steps.tsio-cfg.outputs.config != ''
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-report-upload@0661accd8cc9ab9dde6ec93f6f5ad40d06deb0d3 # release-0.14.0 / main@0661acc (#93 unique release tags per run)
with:
composite-identity: ${{ toJSON(fromJSON(steps.tsio-cfg.outputs.config).composite_identity) }}
total-reports-expected: ${{ fromJSON(steps.tsio-cfg.outputs.config).total_reports_expected }}
framework: detox
github-token: ${{ github.token }}
gh-job-name: ${{ inputs.tsio-shard-name != '' && inputs.tsio-shard-name || 'generate-report' }}
json-path: detox/artifacts/jest-results-merged.json
screenshots-dir: detox/artifacts/
# Resolved here rather than inside tsio-report-status.js so the GitHub API
# call stays in the Actions layer and the reporter acts on its inputs.
- name: Check E2E/Override on the PR
id: e2e-override
if: always() && inputs.tsio-config != '' && vars.TSIO_UPLOAD_ENABLED != 'false' && steps.tsio-cfg.outputs.config != ''
uses: ./.github/actions/e2e-override-label
with:
pr_number: ${{ fromJSON(steps.tsio-cfg.outputs.config).composite_identity.gh_pr_number }}
- name: Post per-job TSIO commit status
if: always() && inputs.tsio-config != '' && vars.TSIO_UPLOAD_ENABLED != 'false' && steps.tsio-cfg.outputs.config != ''
env:
GITHUB_TOKEN: ${{ github.token }}
TSIO_CONFIG: ${{ steps.tsio-cfg.outputs.config }}
E2E_OVERRIDE: ${{ steps.e2e-override.outputs.override }}
SPECS_RESULT: ${{ needs.generate-specs.result }}
E2E_RESULT: ${{ needs.e2e-android.result }}
JOB_STATUS: ${{ job.status }}
MATTERMOST_E2E_WEBHOOK_URL: ${{ secrets.MM_MOBILE_E2E_WEBHOOK_URL }}
MATTERMOST_MASTER_HEALTH_WEBHOOK_URL: ${{ secrets.MM_E2E_MASTER_HEALTH_WEBHOOK_URL }}
MATTERMOST_CMT_WEBHOOK_URL: ${{ secrets.MM_E2E_RELEASE_WEBHOOK_URL }}
run: |
set +e
UPSTREAM="true"
# This job runs with always(), so job.status stays success when an upstream
# job is cancelled — read the needs results to detect that.
if [ "$SPECS_RESULT" = "cancelled" ] ||
[ "$E2E_RESULT" = "cancelled" ] ||
[ "$JOB_STATUS" = "cancelled" ]; then
UPSTREAM="false"
fi
IDENTITY=$(echo "$TSIO_CONFIG" | jq -c '.composite_identity')
CONTEXT=$(echo "$TSIO_CONFIG" | jq -r '.status_context // "e2e-test/detox-android"')
TOTAL=$(echo "$TSIO_CONFIG" | jq -r '.total_reports_expected // 1')
node detox/utils/tsio-report-status.js \
--composite-identity="$IDENTITY" \
--total-reports-expected="$TOTAL" \
--github-token="$GITHUB_TOKEN" \
--context="$CONTEXT" \
--upstream-succeeded="$UPSTREAM" \
--e2e-override="$E2E_OVERRIDE" \
--poll-attempts=24
RC=$?
echo "tsio-report-status exit=$RC upstream_succeeded=$UPSTREAM context=$CONTEXT"
if [ "$RC" -ne 0 ] && [ "$UPSTREAM" = "true" ]; then
echo "::error::TSIO reported test failures for $CONTEXT"
exit 1
fi
exit 0
- name: Save report Detox Dependencies
id: report-link
if: always()
continue-on-error: true
run: |
cd detox
npm ci
npm run e2e:save-report
env:
DETOX_AWS_ACCESS_KEY_ID: ${{ secrets.MM_MOBILE_DETOX_AWS_ACCESS_KEY_ID }}
DETOX_AWS_SECRET_ACCESS_KEY: ${{ secrets.MM_MOBILE_DETOX_AWS_SECRET_ACCESS_KEY }}
BUILD_ID: ${{ needs.generate-specs.outputs.build_id }}
REPORT_PATH: ${{ steps.s3.outputs.path }}
## These are needed for the MM Webhook report
COMMIT_HASH: ${{ needs.generate-specs.outputs.mobile_sha }}
BRANCH: ${{ needs.generate-specs.outputs.mobile_ref }}
- name: Calculate failures
id: summary
if: always()
run: |
if [ -f detox/artifacts/summary.json ]; then
echo "FAILURES=$(cat detox/artifacts/summary.json | jq .stats.failures)" >> ${GITHUB_OUTPUT}
echo "PASSES=$(cat detox/artifacts/summary.json | jq .stats.passes)" >> ${GITHUB_OUTPUT}
echo "SKIPPED=$(cat detox/artifacts/summary.json | jq .stats.skipped)" >> ${GITHUB_OUTPUT}
echo "TOTAL=$(cat detox/artifacts/summary.json | jq .stats.tests)" >> ${GITHUB_OUTPUT}
echo "ERRORS=$(cat detox/artifacts/summary.json | jq .stats.errors)" >> ${GITHUB_OUTPUT}
echo "PERCENTAGE=$(cat detox/artifacts/summary.json | jq .stats.passPercent)" >> ${GITHUB_OUTPUT}
else
echo "⚠️ No summary.json found — all test machines likely failed before producing results"
echo "FAILURES=-1" >> ${GITHUB_OUTPUT}
echo "PASSES=0" >> ${GITHUB_OUTPUT}
echo "SKIPPED=0" >> ${GITHUB_OUTPUT}
echo "TOTAL=0" >> ${GITHUB_OUTPUT}
echo "ERRORS=0" >> ${GITHUB_OUTPUT}
echo "PERCENTAGE=0" >> ${GITHUB_OUTPUT}
fi
- name: Set Target URL
id: set-url
if: always()
env:
S3_BUCKET: ${{ env.DETOX_AWS_S3_BUCKET }}
REPORT_PATH: ${{ steps.s3.outputs.path }}
run: |
echo "TARGET_URL=https://${S3_BUCKET}.s3.amazonaws.com/${REPORT_PATH}/jest-stare/android-report.html" >> "$GITHUB_OUTPUT"
- name: Determine Status
id: determine-status
if: always()
env:
FAILURES: ${{ steps.summary.outputs.FAILURES }}
ERRORS: ${{ steps.summary.outputs.ERRORS }}
TOTAL: ${{ steps.summary.outputs.TOTAL }}
run: |
# Fail on missing/empty reports (infra died before tests) and on real failures.
if [[ "$FAILURES" == "-1" || "$FAILURES" -gt 0 || "${ERRORS:-0}" -gt 0 || "${TOTAL:-0}" -eq 0 ]]; then
echo "STATUS=failure" >> ${GITHUB_OUTPUT}
else
echo "STATUS=success" >> ${GITHUB_OUTPUT}
fi
- name: Generate Summary
if: always()
env:
TOTAL: ${{ steps.summary.outputs.TOTAL }}
PASSES: ${{ steps.summary.outputs.PASSES }}
FAILURES: ${{ steps.summary.outputs.FAILURES }}
SKIPPED: ${{ steps.summary.outputs.SKIPPED }}
ERRORS: ${{ steps.summary.outputs.ERRORS }}
PERCENTAGE: ${{ steps.summary.outputs.PERCENTAGE }}
TARGET_URL: ${{ steps.set-url.outputs.TARGET_URL }}
run: |
echo "| Tests | Passed :white_check_mark: | Failed :x: | Skipped :fast_forward: | Errors :warning: | " >> ${GITHUB_STEP_SUMMARY}
echo "|:---:|:---:|:---:|:---:|:---:|" >> ${GITHUB_STEP_SUMMARY}
echo "| ${TOTAL} | ${PASSES} | ${FAILURES} | ${SKIPPED} | ${ERRORS} |" >> ${GITHUB_STEP_SUMMARY}
echo "" >> ${GITHUB_STEP_SUMMARY}
echo "You can check the full report [here](${TARGET_URL})" >> ${GITHUB_STEP_SUMMARY}
echo "There was **${PERCENTAGE}%** success rate." >> ${GITHUB_STEP_SUMMARY}
- name: Fail job on test failures
if: always() && steps.determine-status.outputs.STATUS == 'failure'
env:
FAILURES: ${{ steps.summary.outputs.FAILURES }}
TOTAL: ${{ steps.summary.outputs.TOTAL }}
ERRORS: ${{ steps.summary.outputs.ERRORS }}
run: |
echo "::error::Detox reported test failures (FAILURES=${FAILURES}, TOTAL=${TOTAL}, ERRORS=${ERRORS})"
exit 1