mattermost-mobile-upstream/.github/workflows/e2e-ios-template.yml
yasser khan cf509f26c1
Some checks are pending
ci / test (push) Waiting to run
ci / lint-typecheck (push) Waiting to run
ci / detox-lint-typecheck (push) Waiting to run
ci(e2e): enforce triage verdicts on PR and main E2E lanes by default (#10303)
* ci(e2e): enforce triage verdicts on PR and main E2E lanes by default

A lane red only on failures that are not the PR's now turns green, as on
mattermost and desktop. The E2E_TRIAGE_MODE variable can still set
report-only. The templates' triage announcement follows the same default.

* ci(e2e): don't announce triage on a PR that triage runs report-only for

With the E2E/Triage-report-only label, or labels that cannot be read,
triage runs report-only and never settles the e2e-test/triage check,
so the pending announcement stayed forever. e2e-override-label now
reports that label, and the templates announce only without it.

* ci(e2e): settle a triage check report-only leaves pending; retry status posts on 5xx

- Announce triage only when the report-only label check says 'false'.
- When triage does not enforce (the report-only label added after the
  announcement, unreadable labels, or a triage that stopped early), the
  e2e-triage action settles this run's own pending e2e-test/triage check
  from the lanes' current states.
- createCommitStatus tries a GitHub 5xx or a dropped connection again
  (3 attempts); a 4xx still fails at once. A 500 on the iPad lane of this
  PR failed a job whose tests passed.

* ci(e2e): retry a 5xx whose body cannot be read; keep test wait records per test

* ci(e2e): bound the 5xx body read in status posts; fix test lint

* ci(e2e): settle the triage check in a final job, from tested code

The settle step inside the triage composite never ran when triage was
skipped (a re-run that turned every lane green), and wrote without a
retry. A final job now settles this run's own e2e-test/triage check once
every lane is final: pending, or red while every lane is green. A lane
that is pending or never posted counts as not final, never green. The
decision lives in detox/utils/tsio-triage-settle.js with unit tests, and
the write goes through createCommitStatus, which now also retries a 429.

* ci(e2e): triage a lane that ended abandoned, not only failed

Run 41818921294: five Android shards never got a runner, so the lane's
workflow ended "abandoned" and triage, which ran only on "failure",
was skipped. The lane stayed red with no verdict and no re-run. Triage
now runs on any lane result but success or skipped.

* ci(e2e): retry the settle job's status reads; share the GitHub retry

- githubRequestWithRetry (tsio-report-status.js) holds the retry policy
  (5xx, 429, dropped connection; a stalled body is bounded); createCommitStatus
  and the settle job's status reads both use it, so a transient error on the
  read no longer leaves e2e-test/triage pending.
- The settle script imports TRIAGE_CHECK_CONTEXT instead of redefining it.
- The tsio-triage gate's comment says where 'abandoned' was seen.

* fix(e2e): bound status-body reads and abort unused response bodies in githubRequestWithRetry

* fix(e2e): scope settle lane statuses to runId and bound status-body reads

* ci(e2e): check out the settle script from the dispatch ref, not MOBILE_VERSION

The settle job checked out its script at the commit under test. A PR commit that
predates detox/utils/tsio-triage-settle.js has no script, so the node step fails
and this run's e2e-test/triage check is never settled; and the job holds a token
that writes statuses, so it should not run the branch's code. It now checks out
the dispatch ref, as the triage action does; --sha still names the tested commit.

* fix(e2e): keep ABAC grant tests inside the 300s test budget

#10301 made MM-68219_5 and MM-70693_2 wait up to four minutes, after the grant
check, for the server to deny the user again. Login, channel open and the grant
wait already take 136-210s, so whenever the deny was slow the test hit Jest's
300s timeout: MM-68219_5 timed out on both attempts on main (fca832c) and on
this PR, and MM-70693_2 on main.

The restore only matters to an in-place retry (iOS CI); Android re-runs the
spec file with new users. The test now restores the attribute without waiting,
and checks the server-side deny at its start instead: a first attempt already
has it, so that returns on the first poll; a retry waits up to two minutes for
the previous attempt's restore to land.
2026-11-05 14:35:43 +05:30

1356 lines
65 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

name: Detox iOS 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-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
ios_device_name:
description: "iPhone simulator name"
required: false
type: string
default: "iPhone 17 Pro"
ios_device_os_name:
description: "iPhone simulator OS version"
required: false
type: string
default: "iOS 26.2"
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"
detox_config:
description: "Detox configuration name to use (e.g. ios.sim.debug, ios.ipad.debug)"
required: false
type: string
default: "ios.sim.debug"
artifact_run_id:
description: "Run ID of the workflow that uploaded ios-build-simulator-* (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-ios-Server_11.9.0-rc3)"
required: false
type: string
default: ""
spec_files:
description: >
Space-separated spec files to run instead of every spec under search_path:
a re-run of failed specs. Pair it with an empty tsio-config, run-type NONE and
results-artifact, so the re-run reports only to the channel message.
required: false
type: string
default: ""
results-artifact:
description: "Artifact name for this run's merged per-test results (a re-run of failed specs); empty uploads none"
required: false
type: string
default: ""
durations_key:
description: >
Spec-duration manifest key (e.g. detox-ios, detox-ipad). 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.ios_device_name }}
DEVICE_OS_VERSION: ${{ inputs.ios_device_os_name }}
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 }}
# save_report's own channel post, only for a run that does not report to TSIO: a TSIO run is posted once, for the
# whole run, by the tsio-channel-notify rollup.
WEBHOOK_URL: ${{ (inputs.tsio-config == '' || vars.TSIO_UPLOAD_ENABLED == 'false') && secrets.MM_MOBILE_E2E_WEBHOOK_URL || '' }}
FAILURE_MESSAGE: "Something has failed"
IOS: "true"
RUNNING_E2E: "true"
ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id || github.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.DEVICE_NAME }}-${{ env.DEVICE_OS_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; the iOS-only site3
# target needs SITE_3 to be distinct from both A and 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 }}
SERVER_SHARED: ${{ inputs.MM_TEST_SERVER_URL_3 }}
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"
if [ -n "$SERVER_SHARED" ] && [ "$SERVER_SHARED" != "$SERVER_A" ] && [ "$SERVER_SHARED" != "$SERVER_B" ]; then
TARGETS="B,site3"
fi
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 }}
spec_files: ${{ inputs.spec_files }}
device_name: ${{ env.DEVICE_NAME }}
device_os_version: ${{ env.DEVICE_OS_VERSION }}
durations_file: ${{ inputs.durations_key != '' && 'detox/artifacts/spec-durations.json' || '' }}
platform: ios
mutation_targets: ${{ steps.mutation-targets.outputs.targets }}
e2e-ios:
name: ${{ matrix.role == 'mutation' && 'mutation-' || '' }}machine-${{ matrix.runId }}-os-${{ matrix.deviceOsVersion }}
# Temporarily reverted to macos-15 due to Metro stabilization issues on macos-26.
# macos-26 ships the iOS 26.2 runtime pre-installed, avoiding a ~10min
# LaunchServicesMigrator + runtime install on every shard, but has timing issues.
# TODO: Investigate macos-26 Metro/Detox compatibility and re-enable if resolved.
runs-on: macos-15
continue-on-error: true
timeout-minutes: ${{ inputs.low_bandwidth_mode && 240 || 180 }}
env:
IOS: true
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"
- name: ci/prepare-node-deps
uses: ./.github/actions/prepare-node-deps
# Cache Homebrew packages including applesimutils
- name: Cache Homebrew packages
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/Library/Caches/Homebrew
/opt/homebrew/Cellar/applesimutils
key: ${{ runner.os }}-${{ runner.arch }}-brew-applesimutils-v1
restore-keys: ${{ runner.os }}-${{ runner.arch }}-brew-applesimutils-
- name: Install Homebrew Dependencies
run: |
brew tap wix/brew
# Homebrew 5.2+/6.0 requires explicit trust before loading third-party formulae.
brew trust wix/brew
# Cache restores Cellar without /opt/homebrew/bin symlinks. Prefer link-only when
# already installed to avoid the noisy "installed, just not linked" brew message.
if brew list --formula wix/brew/applesimutils &>/dev/null || brew list --formula applesimutils &>/dev/null; then
brew link --overwrite --force wix/brew/applesimutils 2>/dev/null \
|| brew link --overwrite --force applesimutils
else
brew install applesimutils
brew link --overwrite --force wix/brew/applesimutils 2>/dev/null \
|| brew link --overwrite --force applesimutils
fi
command -v applesimutils
- name: Download iOS Simulator Build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ios-build-simulator-${{ env.ARTIFACT_RUN_ID }}
run-id: ${{ env.ARTIFACT_RUN_ID }}
github-token: ${{ github.token }}
path: mobile-artifacts
- name: Unzip iOS Simulator Build
run: |
unzip -o mobile-artifacts/*.zip -d mobile-artifacts/
# delete zip file
rm -f mobile-artifacts/*.zip
- name: Prepare Low Bandwidth Environment
id: prepare-low-bandwidth
uses: ./.github/actions/prepare-low-bandwidth
if: ${{ inputs.low_bandwidth_mode }}
with:
test_server_url: ${{ env.SITE_1_URL }}
device_name: ${{ env.DEVICE_NAME }}
# all these value should be configurable
download_speed: "3300"
upload_speed: "3300"
latency: "500"
- 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 i
- name: Get Xcode Version
id: xcode-version
run: echo "version=$(xcodebuild -version | head -1 | sed 's/Xcode //')" >> "$GITHUB_OUTPUT"
- name: Cache Detox Framework
id: detox-framework-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/Library/Detox/ios
key: ${{ runner.os }}-${{ runner.arch }}-detox-framework-${{ hashFiles('detox/package-lock.json') }}-xcode-${{ steps.xcode-version.outputs.version }}
- name: Build Detox Framework Cache
if: steps.detox-framework-cache.outputs.cache-hit != 'true'
run: cd detox && npx detox build-framework-cache
- name: Pre-boot iOS Simulator
run: |
set -e
echo "Looking for simulator: ${{ env.DEVICE_NAME }} with ${{ env.DEVICE_OS_VERSION }}"
# Find or create the simulator
SIMULATOR_ID=$(xcrun simctl list devices | grep "${{ env.DEVICE_NAME }}" | grep "${{ env.DEVICE_OS_VERSION }}" | head -1 | grep -oE '([0-9A-F-]{36})' || true)
if [ -z "$SIMULATOR_ID" ]; then
echo "Simulator not found. Creating simulator ${{ env.DEVICE_NAME }} with ${{ env.DEVICE_OS_VERSION }}"
# Get device type (use exact match with parentheses to avoid partial matches)
DEVICE_TYPE=$(xcrun simctl list devicetypes | grep "${{ env.DEVICE_NAME }} (" | head -1 | awk -F'[()]' '{print $(NF-1)}')
if [ -z "$DEVICE_TYPE" ]; then
echo "Error: Could not find device type for ${{ env.DEVICE_NAME }}"
exit 1
fi
echo "Found device type: $DEVICE_TYPE"
# Get runtime (extract the identifier after the last " - ")
RUNTIME=$(xcrun simctl list runtimes | grep "${{ env.DEVICE_OS_VERSION }}" | head -1 | sed 's/.* - \(.*\)/\1/')
if [ -z "$RUNTIME" ]; then
echo "Error: Could not find runtime for ${{ env.DEVICE_OS_VERSION }}"
exit 1
fi
echo "Found runtime: $RUNTIME"
# Create simulator
SIMULATOR_ID=$(xcrun simctl create "CI-${{ env.DEVICE_NAME }}" "$DEVICE_TYPE" "$RUNTIME")
echo "Created simulator with ID: $SIMULATOR_ID"
else
echo "Found existing simulator: $SIMULATOR_ID"
fi
# Boot simulator first to initialize directory structure
echo "Initializing simulator directories (first boot)..."
xcrun simctl boot "$SIMULATOR_ID"
xcrun simctl bootstatus "$SIMULATOR_ID"
# Shut down to apply password autofill restrictions
echo "Shutting down to apply password autofill restrictions..."
xcrun simctl shutdown "$SIMULATOR_ID"
for i in 1 2 3 4 5 6; do
state=$(xcrun simctl list devices | grep "$SIMULATOR_ID" | sed -E 's/.*\(([^)]+)\)$/\1/')
if [ "$state" = "Shutdown" ]; then break; fi
sleep 0.5
done
echo "Disabling password autofill (simulator is shut down)"
# Ensure the directory exists (should now exist after first boot)
SETTINGS_DIR="$HOME/Library/Developer/CoreSimulator/Devices/$SIMULATOR_ID/data/Containers/Shared/SystemGroup/systemgroup.com.apple.configurationprofiles/Library/ConfigurationProfiles"
mkdir -p "$SETTINGS_DIR"
# Use the JavaScript utility to disable password autofill
cd detox
node utils/disable_ios_autofill.js --simulator-id "$SIMULATOR_ID"
if [ $? -ne 0 ]; then
echo "⚠️ Failed to disable password autofill"
exit 1
fi
cd ..
echo "Booting simulator with password autofill disabled..."
xcrun simctl boot "$SIMULATOR_ID"
# Wait for boot to complete
xcrun simctl bootstatus "$SIMULATOR_ID"
# Suppress the "Save Password?" credential-save prompt on iOS simulators.
#
# Architecture (iOS 26.x / iOS 18+):
# The "Save Password?" modal is shown by SharedWebCredentialViewService,
# a UIProcess launched on-demand by SpringBoard — NOT a persistent launchd
# service. There is no pre-existing launchd service to kill before login.
# (com.apple.PasswordsCredentialProvider was removed in iOS 18+.)
#
# Strategy — two layers:
# 1. Best-effort defaults writes: seed the Passwords.app and springboard
# defaults domains so that if/when the app reads them it sees AutoFill=NO.
# Keys are silently ignored if the domain or key name doesn't match the
# running iOS version, so this is best-effort only.
# 2. Programmatic dismissal (primary): dismissSystemDialogIfVisible() in
# the test login flow taps "Not Now" when the dialog appears, then does
# a sendToHome+launchApp cycle to clear the _alertControllerDimming
# overlay — this is the only reliable cross-version approach.
echo "Seeding Passwords.app defaults to suppress credential-save prompt..."
xcrun simctl spawn "${SIMULATOR_ID}" defaults write com.apple.Passwords AutoFill -bool NO 2>/dev/null || true
xcrun simctl spawn "${SIMULATOR_ID}" defaults write com.apple.Passwords AutoSave -bool NO 2>/dev/null || true
xcrun simctl spawn "${SIMULATOR_ID}" defaults write com.apple.Passwords credentialSaveNotificationsEnabled -bool NO 2>/dev/null || true
xcrun simctl spawn "${SIMULATOR_ID}" defaults write com.apple.springboard AutoFillPasswords -bool NO 2>/dev/null || true
echo "Passwords defaults seeded (keys are best-effort; dialog is also handled programmatically)"
# Diagnostic: list running password-related launchd services for CI log visibility.
# Note: com.apple.PasswordsCredentialProvider does NOT exist on iOS 26.x —
# the dialog is shown by the on-demand SharedWebCredentialViewService UIProcess.
echo "Password-related launchd services (diagnostic only):"
xcrun simctl spawn "${SIMULATOR_ID}" launchctl list 2>/dev/null | grep -i "password\|credential\|SharedWebCred" || echo " (none found)"
# Install the app now while the simulator is fully booted.
# reinstallApp: false in .detoxrc.json means Detox never installs the app,
# and launchApp({newInstance: true}) only launches — it does not install.
# simctl install here is the only place the app binary enters the simulator.
APP_PATH=$(ls -d mobile-artifacts/*.app 2>/dev/null | head -1)
if [ -z "$APP_PATH" ]; then
echo "❌ Error: No .app bundle found in mobile-artifacts/"
ls -la mobile-artifacts/
exit 1
fi
echo "Installing $APP_PATH in simulator $SIMULATOR_ID..."
xcrun simctl install "$SIMULATOR_ID" "$APP_PATH"
echo "✅ App installed: $APP_PATH"
# Pre-grant app permissions so Detox never calls `simctl privacy` at runtime.
# device.launchApp({permissions}) runs one `simctl privacy` command per key
# synchronously before simctl launch. On 3-core macOS-15 runners with iOS 26.x,
# this takes 6–9s per permission × 4 keys = 24–36s of overhead on EVERY test
# file's beforeAll. Under VM neighbour CPU contention this can reach 40–50s,
# pushing the total launch budget past the 90s cap and causing ~9/20 shards to
# time out. Permissions granted here persist through clearIOSAppData() (which only
# clears the data container, not privacy settings), so they never need re-applying.
#
# NOTE: Only `grant notifications` is applied here. The `deny camera/medialibrary/
# photos` commands were removed because on iOS 26.x:
# - `deny medialibrary` returns "Unknown action 'deny'" (unsupported action)
# - `deny camera` throws NSPOSIXErrorDomain code=1 on freshly installed apps
# Both errors corrupt the simulator's TCC database, preventing Detox's WebSocket
# from ever completing its handshake — causing ALL tests to fail with
# "[launch attempt 2] timed out after 90s".
# The app's Info.plist does not request camera/photos/medialibrary access at
# startup, so no denial is needed to suppress permission dialogs.
echo "Pre-granting app permissions..."
xcrun simctl privacy "$SIMULATOR_ID" grant notifications com.mattermost.rnbeta || true
echo "✅ Permissions pre-granted"
# Pre-warm: launch the app once to create its data container and establish a
# clean SpringBoard registration. A fresh simctl install creates a
# "pending-install" record in launchd; without a launch→kill cycle, the first
# Detox launchApp call hangs waiting for that record to settle.
# The app will crash immediately without Metro — that is expected and fine;
# launchd creates the data container before the JS engine starts.
#
# Both simctl launch and simctl terminate can hang on iOS 26.x — the same bug
# we work around in setup.ts. Protect each command:
# - simctl launch: backgrounded with a hard sleep+kill timeout.
# - kill: use simctl spawn launchctl to kill inside the simulator's launchd
# namespace instead of simctl terminate. launchd then sees the SIGCHLD,
# cleans up the service record, and leaves no stale state.
echo "Pre-warming app to create data container..."
# Pre-warm function: launch app briefly to create its data container.
# iPad simulators on iOS 26.x can take 10-15s to fully register the app.
prewarm_app() {
local attempt=$1
local sleep_time=$2
echo "Pre-warm attempt $attempt (waiting ${sleep_time}s)..."
xcrun simctl launch "$SIMULATOR_ID" com.mattermost.rnbeta 2>/dev/null &
local LAUNCH_PID=$!
sleep "$sleep_time"
kill "$LAUNCH_PID" 2>/dev/null || true
wait "$LAUNCH_PID" 2>/dev/null || true
# Kill via launchd (reliable — simctl terminate can hang on iOS 26.x)
local APP_PID
APP_PID=$(xcrun simctl spawn "$SIMULATOR_ID" launchctl list 2>/dev/null | \
grep "com.mattermost.rnbeta" | awk '{print $1}' | grep -E '^[0-9]+$' || true)
if [ -n "$APP_PID" ]; then
xcrun simctl spawn "$SIMULATOR_ID" kill -9 "$APP_PID" 2>/dev/null || true
for i in 1 2 3 4; do
if ! xcrun simctl spawn "$SIMULATOR_ID" launchctl list 2>/dev/null | grep -q "com.mattermost.rnbeta"; then
break
fi
sleep 0.25
done
echo " Killed app via launchd (PID $APP_PID)"
fi
# Verify data container was created
if xcrun simctl get_app_container "$SIMULATOR_ID" com.mattermost.rnbeta data 2>/dev/null; then
echo " ✅ Data container verified"
return 0
else
echo " ⚠️ Data container not found"
return 1
fi
}
# First attempt with 15s wait
if ! prewarm_app 1 15; then
echo "Retrying pre-warm with longer timeout..."
# Second attempt with 25s wait
if ! prewarm_app 2 25; then
echo "⚠️ Pre-warm failed after 2 attempts — tests may fail at launch"
fi
fi
echo "✅ App pre-warmed"
# Open Simulator.app in background to speed up UI rendering
open -a Simulator --args -CurrentDeviceUDID "$SIMULATOR_ID"
# Poll bootstatus instead of a blind 10s sleep. Cap at 10s so we never
# exceed the previous worst case. 5s was empirically too short (~9/20
# shards timed out at the Detox 90s launchApp cap), so the minimum wait
# is 8s before exiting the loop.
for i in 1 2 3 4 5 6 7 8 9 10; do
if xcrun simctl bootstatus "$SIMULATOR_ID" -b 2>/dev/null && [ "$i" -ge 8 ]; then
break
fi
sleep 1
done
echo "SIMULATOR_ID=$SIMULATOR_ID" >> $GITHUB_ENV
- name: Start Proxy
if: ${{ inputs.low_bandwidth_mode }}
id: start-proxy
uses: ./.github/actions/start-proxy
with:
test_server_url: ${{ env.SITE_1_URL }}
- name: Set .env with RUNNING_E2E=true
run: |
echo "RUNNING_E2E=true" > .env
- name: Verify Simulator Health
run: |
echo "Verifying simulator health before running tests..."
# Check if simulator is still responsive
if ! xcrun simctl bootstatus "$SIMULATOR_ID" -b 2>/dev/null; then
echo "⚠️ Simulator not responsive, attempting recovery..."
# Try to shutdown and reboot
xcrun simctl shutdown "$SIMULATOR_ID" || true
sleep 3
xcrun simctl boot "$SIMULATOR_ID"
xcrun simctl bootstatus "$SIMULATOR_ID"
# Re-open Simulator.app after reboot
open -a Simulator --args -CurrentDeviceUDID "$SIMULATOR_ID"
sleep 5
fi
# Verify the app is still installed (simctl install can silently fail)
if ! xcrun simctl get_app_container "$SIMULATOR_ID" com.mattermost.rnbeta 2>/dev/null; then
echo "⚠️ App not found in simulator — reinstalling..."
APP_PATH=$(ls -d mobile-artifacts/*.app 2>/dev/null | head -1)
if [ -n "$APP_PATH" ]; then
xcrun simctl install "$SIMULATOR_ID" "$APP_PATH"
echo "✅ App reinstalled"
else
echo "❌ No .app bundle found for reinstall"
exit 1
fi
fi
# Reduce CPU competition: disable Spotlight indexing and other macOS services
# that consume CPU on CI runners (3-core M1 with 7 GB RAM)
sudo mdutil -a -i off 2>/dev/null || true
echo "✅ Simulator is healthy and ready"
- name: Start mm_blocks webhook sidecar
if: contains(matrix.specs, 'mm_blocks_')
continue-on-error: true
env:
SITE_1_URL: ${{ env.SITE_1_URL }}
# Prefer a stable public origin over trycloudflare (optional repo secrets).
WEBHOOK_PUBLIC_BASE_URL: ${{ secrets.MM_MOBILE_E2E_WEBHOOK_PUBLIC_BASE_URL }}
CLOUDFLARED_TUNNEL_TOKEN: ${{ secrets.MM_MOBILE_E2E_CLOUDFLARED_TUNNEL_TOKEN }}
# mm_blocks specs are sharded alone (split-tests.js). This step must not hang:
# the script bounds all curls and exits 0 on tunnel failure so Jest still runs.
run: bash detox/scripts/start_webhook_sidecar.sh
- name: Start Metro Bundler and Run Detox E2E Tests
# Bumped from 60 to 180 min: with ~6 specs per shard averaging 5-15 min each,
# the 60min step timeout was being hit silently — GitHub kills the step but
# `continue-on-error: true` masks it, and the tests that didn't run get
# dropped from the result count. iOS run 26368981355 had 524 tests vs
# Android's 537 (same codebase, same 124 spec files) — the 13-test gap was
# tail tests on shards that timed out at this step. 180 matches the job
# timeout above so the step uses the full available budget.
timeout-minutes: 180
continue-on-error: true # We want to run all the tests
run: |
# Start Metro bundler in background
npm run start > metro.log 2>&1 &
METRO_PID=$!
echo "Metro PID: $METRO_PID"
echo "METRO_PID=$METRO_PID" >> $GITHUB_ENV
# Wait for Metro to be ready with health check endpoint polling
timeout=120
elapsed=0
echo "Waiting for Metro bundler to be ready..."
while [ $elapsed -lt $timeout ]; do
# Try health check endpoint first (more reliable)
if curl -s http://localhost:8081/status 2>/dev/null | grep -q "packager-status:running"; then
echo "✅ Metro bundler is ready after ${elapsed}s (verified via health check)"
break
# Fallback to log checking
elif grep -q "Fast - Scalable - Integrated" metro.log 2>/dev/null; then
echo "✅ Metro bundler is ready after ${elapsed}s (verified via logs)"
break
fi
# Show progress every 10 seconds
if [ $((elapsed % 10)) -eq 0 ] && [ $elapsed -gt 0 ]; then
echo "Still waiting for Metro... (${elapsed}s elapsed)"
fi
sleep 2
elapsed=$((elapsed + 2))
done
if [ $elapsed -ge $timeout ]; then
echo "⚠️ Warning: Metro bundler may not be fully ready after ${timeout}s, proceeding anyway"
echo "Metro process status:"
ps aux | grep -E "metro|node.*start" | grep -v grep || echo "Metro process not found"
echo ""
echo "Last 30 lines of metro.log:"
tail -30 metro.log
else
echo "Metro is serving on http://localhost:8081"
# Give Metro time to fully initialize for iOS 26.2 (React Native bridge needs stabilization).
# 20s should be sufficient for macos-15 to ensure full Metro compilation.
# Without adequate wait, the app may fail with "No script URL provided" on launch.
echo "Allowing Metro to fully stabilize (20s on macos-15)..."
sleep 20
# Verify Metro is actually serving the bundle (not just the process is running).
# This catches cases where Metro reports ready but hasn't finished compilation.
echo "Verifying Metro can serve bundles..."
if curl -s http://localhost:8081/index.ios.bundle 2>/dev/null | grep -q "__d("; then
echo "✅ Metro is serving bundles successfully"
else
echo "⚠️ Warning: Could not verify Metro is serving bundles, proceeding anyway"
ps aux | grep -E "metro|node.*start" | grep -v grep || echo "Metro process not found"
fi
fi
cd detox
npm run detox:config-gen
# Ensure the Jest --json output directory exists before the run.
mkdir -p artifacts
# Use read -ra to safely word-split space-separated spec paths without
# glob expansion or shell injection from the matrix value.
read -ra SPECS <<< "$MATRIX_SPECS"
set +e
# `--` separates Detox args from Jest args; --json --outputFile emits
# Jest's native JSON report (consumed by the TSIO detox parser) into
# the existing detox/artifacts/ upload. The output is also kept for the
# triage evidence step: on a Jest timeout it is the only record of what
# the app was still waiting on.
run_specs() {
if [ "$DETOX_CONFIG" = "ios.ipad.debug" ]; then
npm run e2e:ios-ipad-test -- "$@" -- --json --outputFile=artifacts/jest-results.json
else
npm run e2e:ios-test -- "$@" -- --json --outputFile=artifacts/jest-results.json
fi
}
# Diagnostics only (no behavior change): record what happens to the Jest process when it
# dies with a signal (detox reports exit code = null). Output goes under artifacts/diag,
# which the iOS report upload already ships.
DIAG_DIR=artifacts/diag
mkdir -p "$DIAG_DIR"
touch "$DIAG_DIR/step-marker"
(
while :; do
echo "$(date -u +%H:%M:%S) $(memory_pressure -Q 2>/dev/null | tail -1)"
ps -axo pid=,ppid=,rss=,etime=,comm= | grep -E 'jest|node|launchd_sim|Simulator|SpringBoard|applesimutils|simctl' || true
sleep 2
done
) >> "$DIAG_DIR/sampler.log" 2>&1 &
SAMPLER_PID=$!
trap 'kill "$SAMPLER_PID" 2>/dev/null || true' EXIT
run_specs "${SPECS[@]}" 2>&1 | tee "$RUNNER_TEMP/detox-run.log"
DETOX_EXIT_CODE=${PIPESTATUS[0]}
RETRIED=0
echo "$DETOX_EXIT_CODE" > "$DIAG_DIR/detox-exit.txt"
log show --last 60m --style compact --predicate '(eventMessage CONTAINS[c] "memorystatus") OR (eventMessage CONTAINS[c] "jetsam") OR (process == "kernel" AND eventMessage CONTAINS[c] "kill")' > "$DIAG_DIR/unified-log.txt" 2>&1 || true
find "$HOME/Library/Logs/DiagnosticReports" /Library/Logs/DiagnosticReports -type f -newer "$DIAG_DIR/step-marker" -exec cp {} "$DIAG_DIR/" \; 2>/dev/null || true
ls -la "$DIAG_DIR" > "$DIAG_DIR/listing.txt" 2>&1 || true
if [ ! -f artifacts/jest-results.json ]; then
if [ -f artifacts/jest-stare/ios-data.json ]; then
echo "⚠️ Detox left no jest-results.json — promoting jest-stare/ios-data.json"
cp artifacts/jest-stare/ios-data.json artifacts/jest-results.json
# Jest was killed between spec files, so the files after that point never started.
# Run only those once in a fresh Jest; files that already ran keep their own result.
UNRUN_SPECS=()
while IFS= read -r spec; do
[ -n "$spec" ] && UNRUN_SPECS+=("$spec")
done < <(node utils/failed-jest-specs.js --unrun-only --results artifacts/jest-results.json --shard-specs "$MATRIX_SPECS")
if [ "${#UNRUN_SPECS[@]}" -gt 0 ]; then
echo "::warning::Jest died before finishing the shard (exit ${DETOX_EXIT_CODE}); running the ${#UNRUN_SPECS[@]} spec(s) it never started: ${UNRUN_SPECS[*]}"
mv artifacts/jest-results.json artifacts/jest-results-attempt1.json
run_specs "${UNRUN_SPECS[@]}" 2>&1 | tee -a "$RUNNER_TEMP/detox-run.log"
UNRUN_EXIT_CODE=${PIPESTATUS[0]}
if [ ! -f artifacts/jest-results.json ] && [ -f artifacts/jest-stare/ios-data.json ]; then
cp artifacts/jest-stare/ios-data.json artifacts/jest-results.json
fi
node utils/failed-jest-specs.js \
--attempt1 artifacts/jest-results-attempt1.json \
--attempt2 artifacts/jest-results.json \
--output artifacts/jest-results.json
MERGE_EXIT_CODE=$?
if [ "$UNRUN_EXIT_CODE" -eq 0 ] && [ "$MERGE_EXIT_CODE" -eq 0 ] &&
node -e 'process.exit(require("./artifacts/jest-results.json").success ? 0 : 1)'; then
DETOX_EXIT_CODE=0
fi
fi
else
echo "⚠️ Detox left no artifacts/jest-results.json (exit ${DETOX_EXIT_CODE}) — writing shard stub"
node utils/write-tsio-failure-stub.mjs \
--format jest \
--output artifacts/jest-results.json \
--job-name "machine-${{ matrix.runId }}" \
--reason "Detox exited ${DETOX_EXIT_CODE} before writing jest-results.json (specs: ${MATRIX_SPECS})"
fi
fi
# Re-run each failed spec file once, as Android's shard script does.
# jest.retryTimes retries a failed test but never a failed beforeAll, so
# a file whose setup failed gets no second chance without this. The
# attempts are merged so a spec that passes now is recorded as flaky,
# its first failure kept as a retry, not as a failure. More than
# MAX_RETRY_SPECS failed files is usually an environment-wide problem a
# re-run won't fix, so those are left for triage.
if [ "$DETOX_EXIT_CODE" -ne 0 ] && [ -f artifacts/jest-results.json ]; then
RETRY_SPECS=()
while IFS= read -r spec; do
[ -n "$spec" ] && RETRY_SPECS+=("$spec")
done < <(node utils/failed-jest-specs.js --results artifacts/jest-results.json --shard-specs "${SPECS[*]}")
if [ "${#RETRY_SPECS[@]}" -gt 0 ] && [ "${#RETRY_SPECS[@]}" -le "$MAX_RETRY_SPECS" ]; then
echo "Re-running ${#RETRY_SPECS[@]} failed spec file(s) once: ${RETRY_SPECS[*]}"
mv artifacts/jest-results.json artifacts/jest-results-attempt1.json
RETRIED=1
run_specs "${RETRY_SPECS[@]}" 2>&1 | tee -a "$RUNNER_TEMP/detox-run.log"
DETOX_EXIT_CODE=${PIPESTATUS[0]}
if [ -f artifacts/jest-results.json ]; then
node utils/failed-jest-specs.js --attempt1 artifacts/jest-results-attempt1.json --attempt2 artifacts/jest-results.json --output artifacts/jest-results.json
else
echo "⚠️ The re-run left no jest-results.json — keeping the first attempt's results"
mv artifacts/jest-results-attempt1.json artifacts/jest-results.json
DETOX_EXIT_CODE=1
fi
elif [ "${#RETRY_SPECS[@]}" -gt "$MAX_RETRY_SPECS" ]; then
echo "${#RETRY_SPECS[@]} spec files failed (more than $MAX_RETRY_SPECS); not re-running them"
fi
fi
# The runner can lose its route for ~2 s (every host flow, DNS included, -1009). Re-run once
# only the files whose every failure shows that in its own device.log; the rest stay red.
# Skipped when the failed files were already re-run above, so no file runs a third time.
if [ "$DETOX_EXIT_CODE" -ne 0 ] && [ "$RETRIED" -eq 0 ] && [ -f artifacts/jest-results.json ]; then
OUTAGE_SPECS=()
while IFS= read -r spec; do
[ -n "$spec" ] && OUTAGE_SPECS+=("$spec")
done < <(node utils/network-outage-specs.js --results artifacts/jest-results.json --artifacts artifacts)
if [ "${#OUTAGE_SPECS[@]}" -gt 0 ]; then
echo "::warning::Runner lost its network route during ${#OUTAGE_SPECS[@]} spec(s); re-running only those: ${OUTAGE_SPECS[*]}"
mv artifacts/jest-results.json artifacts/jest-results-attempt1.json
run_specs "${OUTAGE_SPECS[@]}" 2>&1 | tee -a "$RUNNER_TEMP/detox-run.log"
RERUN_EXIT_CODE=${PIPESTATUS[0]}
node utils/failed-jest-specs.js \
--attempt1 artifacts/jest-results-attempt1.json \
--attempt2 artifacts/jest-results.json \
--output artifacts/jest-results.json
MERGE_EXIT_CODE=$?
if [ "$RERUN_EXIT_CODE" -eq 0 ] && [ "$MERGE_EXIT_CODE" -eq 0 ] &&
node -e 'process.exit(require("./artifacts/jest-results.json").success ? 0 : 1)'; then
DETOX_EXIT_CODE=0
fi
fi
fi
exit "${DETOX_EXIT_CODE:-1}"
env:
DETOX_CONFIG: ${{ inputs.detox_config }}
MATRIX_SPECS: ${{ matrix.specs }}
MAX_RETRY_SPECS: "5"
DETOX_DISABLE_HIERARCHY_DUMP: "YES"
DETOX_DISABLE_SCREENSHOT_TRACKING: "YES"
DETOX_LOGLEVEL: "info"
DETOX_DEVICE_TYPE: ${{ env.DEVICE_NAME }}
DETOX_OS_VERSION: ${{ env.DEVICE_OS_VERSION }}
LOW_BANDWIDTH_MODE: ${{ inputs.low_bandwidth_mode }}
SITE_1_URL: ${{ env.SITE_1_URL }}
SITE_2_URL: ${{ env.SITE_2_URL }}
SITE_3_URL: ${{ env.SITE_3_URL }}
# For the E2E triage judge: for each failed test, the screenshot at the
# failure (e2e/timeout_evidence.js's on a timeout, Detox's otherwise), the
# requests Detox reported the app was still waiting on, and the test's own
# server calls that failed.
- name: Collect triage evidence for timed-out tests
if: always()
continue-on-error: true
working-directory: detox
run: node utils/triage-evidence.js --results artifacts/jest-results.json --log "$RUNNER_TEMP/detox-run.log" --dir artifacts/triage-evidence --artifacts artifacts --out "artifacts/triage-evidence/evidence-${{ matrix.runId }}.json"
- name: Upload triage evidence
if: always() && inputs.tsio-shard-name != '' && hashFiles('detox/artifacts/triage-evidence/*.json') != ''
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: triage-evidence-${{ inputs.tsio-shard-name }}-${{ needs.generate-specs.outputs.workflow_hash }}-${{ matrix.runId }}
path: detox/artifacts/triage-evidence/
- name: Cleanup Simulator State
if: always()
timeout-minutes: 2
run: |
echo "Cleaning up simulator state after tests..."
# Kill the entire Metro process tree, not just the top-level PID.
# `kill -9 $PID` only kills the npm wrapper; the actual Metro Node.js
# workers and watchman stay alive. GitHub Actions waits ~10 min for
# orphaned processes to die, causing the cleanup step to hang.
if [ -n "$METRO_PID" ]; then
echo "Stopping Metro bundler (PID: $METRO_PID) and child processes..."
kill -9 -"$METRO_PID" 2>/dev/null || true
pkill -9 -f "react-native.*start" 2>/dev/null || true
pkill -9 -f "metro" 2>/dev/null || true
sleep 1
fi
# Terminate app with a timeout — simctl terminate can hang on iOS 26.x
if [ -n "$SIMULATOR_ID" ]; then
echo "Terminating app on simulator..."
timeout 10 xcrun simctl terminate "$SIMULATOR_ID" com.mattermost.rnbeta 2>/dev/null || true
sleep 2
fi
echo "✅ Simulator cleanup complete"
- name: reset network settings
if: ${{ inputs.low_bandwidth_mode || failure() }}
run: |
networksetup -setwebproxystate Ethernet "off"
networksetup -setsecurewebproxystate Ethernet "off"
if (sudo pfctl -q -sa | grep 'Status: Enabled') then sudo pfctl -d; fi
if (command -v pm2 &> /dev/null) then pm2 stop mitmdump; fi
sleep 5;
- name: Upload mitmdump Flow Output
if: ${{ inputs.low_bandwidth_mode }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ios-mitmdump-flow-output-${{ needs.generate-specs.outputs.workflow_hash }}-${{ matrix.runId }}
path: |
/Users/runner/work/mattermost-mobile/mattermost-mobile/flow-output.csv
/Users/runner/work/mattermost-mobile/mattermost-mobile/mitmdump.log
# A failed upload here loses the shard's jest-results.json, so generate-report counts
# the shard as "uploaded no jest-results.json" and the whole job goes red even though
# the tests ran and reported. Seen as `Failed to CreateArtifact: Unable to make request:
# ETIMEDOUT` on machine-19. The payload is large on a failing shard (screenshots + device
# logs), which is exactly when the upload is slowest and the results matter most, so give
# it a second attempt rather than losing the run's results to one timed-out request.
- name: Upload iOS Test Report
id: upload-ios-report
if: always()
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ios-results-${{ needs.generate-specs.outputs.workflow_hash }}-${{ matrix.runId }}
path: detox/artifacts/
# upload-artifact fails fast on ENOTFOUND/ETIMEDOUT (no internal retry), so each retry
# waits for the results service to answer before it runs.
- name: Await results service
if: |
!cancelled() &&
steps.upload-ios-report.outcome == 'failure'
uses: ./.github/actions/await-results-service
- name: Upload iOS Test Report (retry 1)
id: upload-ios-report-retry
if: |
!cancelled() &&
steps.upload-ios-report.outcome == 'failure'
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ios-results-${{ needs.generate-specs.outputs.workflow_hash }}-${{ matrix.runId }}
path: detox/artifacts/
- name: Await results service (retry 2)
if: |
!cancelled() &&
steps.upload-ios-report.outcome == 'failure' &&
steps.upload-ios-report-retry.outcome == 'failure'
uses: ./.github/actions/await-results-service
# Last attempt: overwrite clears an artifact a timed-out attempt may have left behind.
- name: Upload iOS Test Report (retry 2)
if: |
!cancelled() &&
steps.upload-ios-report.outcome == 'failure' &&
steps.upload-ios-report-retry.outcome == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ios-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-ios
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-ios|ipad (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 iOS Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: detox/artifacts/
pattern: ios-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 }}
# A re-run of failed specs runs in the same workflow run: its own report, not over the run's.
RERUN: ${{ inputs.results-artifact != '' && '-rerun' || '' }}
run: |
path="${BUILD_ID}-${MOBILE_SHA}-${MOBILE_REF}${RERUN}"
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 ios-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 the published manifest here: this is a different runner from
# generate-specs, so without it the EWMA would have no history to smooth
# against and the manifest would 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"
# spec-durations.js merges the restored manifest into the new one, so the
# freshly built manifest already carries every valid entry of the previously
# published one — there is nothing to fall back to. (Entries the restored
# manifest had that were malformed or non-positive are dropped on purpose.)
# An empty/zero manifest is rejected by
# the action's `validate` guard rather than published (publishing one would
# poison duration packing for every branch until main rewrote it).
- 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
# Merge before report generation so TSIO and summary.json use the same results.
- name: Merge per-shard Jest JSON
if: always()
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."
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
# A re-run of failed specs hands its per-test results to the channel message.
- name: Upload re-run results
if: always() && inputs.results-artifact != '' && steps.tsio-merge.outputs.skip == 'false'
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ inputs.results-artifact }}
path: detox/artifacts/jest-results-merged.json
retention-days: 1
- 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@3212731263d782a3993dbdf4c8c54f143e69a7b2 # release-0.16.0 / main@3212731
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 }}
# The runs e2e-detox-pr.yml triages (its tsio-triage job), so a red lane
# says triage is coming instead of looking final.
ANNOUNCE_TRIAGE: ${{ (inputs.run-type == 'PR' || inputs.run-type == 'MAIN' || inputs.run-type == 'MASTER') && (vars.E2E_TRIAGE_MODE || 'enforce') == 'enforce' && steps.e2e-override.outputs.triage_report_only == 'false' }}
SPECS_RESULT: ${{ needs.generate-specs.result }}
E2E_RESULT: ${{ needs.e2e-ios.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"
CANCELLED="false"
# 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"
CANCELLED="true"
fi
IDENTITY=$(echo "$TSIO_CONFIG" | jq -c '.composite_identity')
CONTEXT=$(echo "$TSIO_CONFIG" | jq -r '.status_context // "e2e-test/detox-ios"')
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" \
--cancelled="$CANCELLED" \
--e2e-override="$E2E_OVERRIDE" \
--announce-triage="$ANNOUNCE_TRIAGE" \
--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/ios-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