mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-09 12:40:12 +00:00
* 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.
1356 lines
65 KiB
YAML
1356 lines
65 KiB
YAML
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
|