mattermost-mobile-upstream/.github/workflows/e2e-maestro-template.yml
yasser khan 72e74b8785
test(e2e): make CMT and PR E2E green (absorbs #10186) (#10184)
test(e2e): stabilize mobile suites and shared-server configuration

- Restore Android character-limit coverage with realistic word-based payloads.
- Use a time-based grace period for classification-lock transport failures,
  resetting it after each successful request.
- Pin the demo plugin to v0.12.1 and replace mismatched installations using
  the multipart upload force flag.
- Recover uncertain channel creation through read-back, retry transient
  membership and profile-update failures, and check setup results.
- Improve sidebar navigation, channel-info recovery, tooltip dismissal,
  archived-channel assertions, and archived-search retries.
- Verify banner attributes before UI assertions and report observed failures
  without attributing unsupported causes.
- Reduce ChannelAttributes flag changes, retry app reloads, strengthen
  feature-state assertions, and remove unsafe full-config updates.
- Check configuration writability and environment overrides before patching;
  verify the app reflects AllowDownloadLogs and fix Maestro text matching.
- Increase the Jest timeout to accommodate existing test waits.
2026-09-22 12:33:09 +05:30

1440 lines
70 KiB
YAML

name: Maestro E2E Tests Template
on:
workflow_call:
inputs:
platform:
description: "Platform to test on: ios or android"
required: false
type: string
default: "ios"
flow-path:
description: "Path to the Maestro flow(s) to run (defaults differ by platform)"
required: false
type: string
default: ""
MM_TEST_SERVER_URL:
description: "The test server URL"
required: false
type: string
MOBILE_VERSION:
description: "The mobile version to test"
required: false
default: ""
type: string
run-type:
description: "Type of run (PR, MAIN, etc.)"
required: false
type: string
default: "PR"
artifact_run_id:
description: "GitHub run ID of the workflow that uploaded ios-build-simulator-* (defaults to this run)"
required: false
type: string
default: ""
record_tests_in_zephyr:
description: "Record Maestro test results in Zephyr Scale (release/main runs)"
required: false
type: boolean
default: false
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. maestro-ios-Server_11.9.0-rc3)"
required: false
type: string
default: ""
secrets:
MM_MOBILE_E2E_ADMIN_EMAIL:
required: true
MM_MOBILE_E2E_ADMIN_USERNAME:
required: true
MM_MOBILE_E2E_ADMIN_PASSWORD:
required: true
MM_MOBILE_E2E_ZEPHYR_API_KEY:
required: false
MM_MOBILE_DETOX_AWS_ACCESS_KEY_ID:
required: false
MM_MOBILE_DETOX_AWS_SECRET_ACCESS_KEY:
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_MOBILE_E2E_WEBHOOK_URL:
required: false
MM_E2E_MASTER_HEALTH_WEBHOOK_URL:
required: false
MM_E2E_RELEASE_WEBHOOK_URL:
required: false
outputs:
STATUS:
value: ${{ jobs.e2e-maestro-ios.outputs.STATUS || jobs.e2e-maestro-android.outputs.STATUS }}
TARGET_URL:
value: ${{ jobs.e2e-maestro-ios.outputs.TARGET_URL || jobs.e2e-maestro-android.outputs.TARGET_URL }}
FAILURES:
value: ${{ jobs.e2e-maestro-ios.outputs.FAILURES || jobs.e2e-maestro-android.outputs.FAILURES }}
PASSES:
value: ${{ jobs.e2e-maestro-ios.outputs.PASSES || jobs.e2e-maestro-android.outputs.PASSES }}
TOTAL:
value: ${{ jobs.e2e-maestro-ios.outputs.TOTAL || jobs.e2e-maestro-android.outputs.TOTAL }}
SKIPPED:
value: ${{ jobs.e2e-maestro-ios.outputs.SKIPPED || jobs.e2e-maestro-android.outputs.SKIPPED }}
PERCENTAGE:
value: ${{ jobs.e2e-maestro-ios.outputs.PERCENTAGE || jobs.e2e-maestro-android.outputs.PERCENTAGE }}
CANCELLED:
value: ${{ jobs.e2e-maestro-ios.outputs.CANCELLED || jobs.e2e-maestro-android.outputs.CANCELLED }}
# Least-privilege token: maestro jobs only check out code and talk to the test
# server / AWS via dedicated secrets — they never use GITHUB_TOKEN for writes.
# actions: read is required to download build artifacts from the parent workflow run.
permissions:
contents: read
actions: read
# test-system-io-report-upload authenticates via the workflow's OIDC token
# and resolves gh_job_id via the GitHub API; reusable workflows cannot
# escalate beyond the caller, so this must be granted at every level.
id-token: write
# Per-job TSIO finalize posts <platform> commit statuses (detox-/maestro-*).
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
env:
ADMIN_EMAIL: ${{ secrets.MM_MOBILE_E2E_ADMIN_EMAIL }}
ADMIN_USERNAME: ${{ secrets.MM_MOBILE_E2E_ADMIN_USERNAME }}
ADMIN_PASSWORD: ${{ secrets.MM_MOBILE_E2E_ADMIN_PASSWORD }}
SITE_1_URL: ${{ inputs.MM_TEST_SERVER_URL }}
TYPE: ${{ inputs.run-type }}
DETOX_AWS_S3_BUCKET: "mattermost-detox-report"
AWS_REGION: "us-east-1"
# iOS .app artifact is uploaded by e2e-detox-pr.yml under ios-build-simulator-<run_id>.
# ios-build-simulator-<run_id>. When this template is nested, that run_id is
# the parent's, not this workflow's.
ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id || github.run_id }}
# Keep Maestro device targets identical to detox/.detoxrc.json and e2e-*-template.yml.
E2E_IOS_DEVICE_NAME: iPhone 17 Pro
E2E_IOS_DEVICE_OS_VERSION: iOS 26.2
E2E_ANDROID_AVD_NAME: detox_pixel_8
E2E_ANDROID_API_LEVEL: "35"
RUNNING_E2E: "true"
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"
BRANCH: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}
COMMIT_HASH: ${{ inputs.MOBILE_VERSION || github.sha }}
jobs:
e2e-maestro-ios:
# TSIO shard label = job name; prefer tsio-shard-name (e.g. maestro-ios-e2e).
name: ${{ inputs.tsio-shard-name != '' && inputs.tsio-shard-name || 'Maestro E2E Tests (iOS)' }}
if: ${{ inputs.platform == 'ios' || inputs.platform == '' }}
runs-on: macos-26
timeout-minutes: 90
outputs:
STATUS: ${{ steps.determine-status.outputs.STATUS }}
TARGET_URL: ${{ steps.set-url.outputs.TARGET_URL }}
FAILURES: ${{ steps.summary.outputs.FAILURES }}
PASSES: ${{ steps.summary.outputs.PASSES }}
TOTAL: ${{ steps.summary.outputs.TOTAL }}
SKIPPED: ${{ steps.summary.outputs.SKIPPED }}
PERCENTAGE: ${{ steps.summary.outputs.PERCENTAGE }}
ERRORS: ${{ steps.summary.outputs.ERRORS }}
CANCELLED: ${{ steps.shard-cancelled.outputs.CANCELLED }}
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: Install Detox Dependencies
run: cd detox && npm ci --prefer-offline --no-audit --no-fund
- name: Cache Maestro
id: maestro-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.maestro
key: maestro-00f516729f57d48f36ded55244cfbe358ada6b00-macos
- name: Install Maestro CLI
if: steps.maestro-cache.outputs.cache-hit != 'true'
run: |
MAESTRO_VERSION=$(jq -r .version detox/maestro/maestro-version.json)
MAESTRO_COMMIT=$(jq -r .commit detox/maestro/maestro-version.json)
echo "Installing Maestro ${MAESTRO_VERSION} (pinned to commit ${MAESTRO_COMMIT})"
export MAESTRO_VERSION
curl -fsSL "https://get.maestro.mobile.dev" | bash
MAESTRO_BIN="$HOME/.maestro/bin/maestro"
if [ ! -f "$MAESTRO_BIN" ]; then
echo "Error: Maestro binary not found at $MAESTRO_BIN"
exit 1
fi
echo "Maestro installed successfully at $MAESTRO_BIN"
"$MAESTRO_BIN" --version
- name: Verify Maestro installation
run: ~/.maestro/bin/maestro --version
- name: Download iOS Simulator Build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ios-build-simulator-${{ env.ARTIFACT_RUN_ID }}
# Reusable workflows have their own github.run_id; when Maestro is
# Reusable workflows have their own github.run_id; artifacts live on the parent e2e-detox-pr.yml run.
run-id: ${{ env.ARTIFACT_RUN_ID }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: mobile-artifacts
- name: Unzip iOS Simulator Build
run: |
unzip -o mobile-artifacts/*.zip -d mobile-artifacts/
rm -f mobile-artifacts/*.zip
- name: Patch iOS app Info.plist for file-sharing flows
run: |
APP_PATH=$(find mobile-artifacts -name "*.app" -maxdepth 2 | head -1)
if [ -z "$APP_PATH" ]; then
echo "Error: Could not find .app bundle in mobile-artifacts/"
ls -la mobile-artifacts/
exit 1
fi
plutil -replace UIFileSharingEnabled -bool true "$APP_PATH/Info.plist" 2>/dev/null || \
plutil -insert UIFileSharingEnabled -bool true "$APP_PATH/Info.plist" 2>/dev/null || true
plutil -replace LSSupportsOpeningDocumentsInPlace -bool true "$APP_PATH/Info.plist" 2>/dev/null || \
plutil -insert LSSupportsOpeningDocumentsInPlace -bool true "$APP_PATH/Info.plist" 2>/dev/null || true
echo "UIFileSharingEnabled patched in $APP_PATH/Info.plist"
- name: Pre-boot iOS Simulator (Detox-compatible)
env:
DEVICE_NAME: ${{ env.E2E_IOS_DEVICE_NAME }}
DEVICE_OS_VERSION: ${{ env.E2E_IOS_DEVICE_OS_VERSION }}
PREBOOT_SKIP_PREWARM: "1"
run: bash detox/scripts/preboot_ios_simulator.sh
# Old inline Maestro boot waited for SpringBoard via listapps before tests.
- name: Wait for Simulator UI readiness
run: |
for i in $(seq 1 30); do
xcrun simctl listapps "$SIMULATOR_ID" &>/dev/null && exit 0
sleep 2
done
echo "Warning: listapps did not succeed within 60s"
- name: Place test fixture file in app Documents (channel_bookmark_file flow)
run: |
FIXTURE="detox/e2e/support/fixtures/image.png"
if [ ! -f "$FIXTURE" ]; then
echo "Fixture file not found at $FIXTURE — skipping document setup"
exit 0
fi
APP_DATA=$(xcrun simctl get_app_container "$SIMULATOR_ID" com.mattermost.rnbeta data 2>/dev/null || true)
if [ -z "$APP_DATA" ]; then
echo "App data container not found — app may not be installed yet; skipping"
exit 0
fi
DOCS_DIR="$APP_DATA/Documents"
mkdir -p "$DOCS_DIR"
cp "$FIXTURE" "$DOCS_DIR/test_bookmark.png"
echo "Copied test fixture to $DOCS_DIR/test_bookmark.png"
- name: Set simulator timezone for timezone flow
run: |
# MM-T1325: set timezone before any flow runs; launchApp:stopApp:true in the flow
# will restart the app so it picks up the new timezone.
# 'xcrun simctl timezone' was removed in Xcode 26; 'defaults write' doesn't affect
# [NSTimeZone localTimeZone] either. 'launchctl setenv TZ' sets it in launchd so
# all new child processes (including the restarted app) inherit the correct timezone.
xcrun simctl spawn "$SIMULATOR_ID" launchctl setenv TZ "America/New_York"
echo "Simulator timezone set to America/New_York"
- name: Seed test data
run: |
cd detox/maestro
npm ci --prefer-offline --no-audit --no-fund
npx tsx fixtures/seed.ts
env:
SITE_1_URL: ${{ env.SITE_1_URL }}
ADMIN_EMAIL: ${{ env.ADMIN_EMAIL }}
ADMIN_USERNAME: ${{ env.ADMIN_USERNAME }}
ADMIN_PASSWORD: ${{ env.ADMIN_PASSWORD }}
- name: Push test fixture into iOS simulator
continue-on-error: true
run: |
set +e
UDID=$(xcrun simctl list devices booted 2>/dev/null | grep -oE '[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}' | head -1)
if [ -z "$UDID" ]; then
echo "Warning: no booted iOS simulator found; skipping fixture push"
exit 0
fi
echo "Resolved booted simulator UDID: $UDID"
LOCAL_STORAGE=$(find "$HOME/Library/Developer/CoreSimulator/Devices/$UDID/data/Containers/Shared/AppGroup" \
-name ".com.apple.mobile_container_manager.metadata.plist" \
-exec grep -l "FileProvider.LocalStorage" {} \; 2>/dev/null | xargs -I{} dirname {} | head -1)
if [ -n "$LOCAL_STORAGE" ]; then
mkdir -p "$LOCAL_STORAGE/File Provider Storage"
cp detox/e2e/support/fixtures/image.png "$LOCAL_STORAGE/File Provider Storage/test_bookmark.png"
echo "Pushed test fixture to $LOCAL_STORAGE/File Provider Storage/test_bookmark.png"
else
echo "Warning: FileProvider.LocalStorage container not found; channel_bookmark_file.yml may fail"
fi
- name: Start iOS Simulator device log capture
if: always()
run: |
mkdir -p build
# Filter sim log to Mattermost/WebRTC processes only.
xcrun simctl spawn "$SIMULATOR_ID" log stream \
--level=debug \
--predicate 'process == "Mattermost" OR process == "MattermostRN" OR senderImagePath CONTAINS "WebRTC" OR senderImagePath CONTAINS "calls-native"' \
> build/sim-device.log 2> build/sim-device.err &
echo $! > build/sim-device.pid
echo "==> Device log capture started (pid $(cat build/sim-device.pid))"
- name: Pre-flight — simulator can reach test server
id: maestro-ios-preflight
env:
MAESTRO_DRIVER_STARTUP_TIMEOUT: "180000"
run: |
set -a
source detox/maestro/.maestro-test-env.sh
set +a
for attempt in 1 2 3; do
if "$HOME/.maestro/bin/maestro" --device "$SIMULATOR_ID" test \
--env "SITE_1_URL=$SITE_1_URL" \
--env "MAESTRO_APP_ID=${MAESTRO_APP_ID:-com.mattermost.rnbeta}" \
detox/maestro/preflight/_connect_check.yml; then
echo "==> Pre-flight connect OK (attempt $attempt)"
exit 0
fi
echo "==> Pre-flight connect failed (attempt $attempt/3)"
done
echo "::error::Simulator could not connect to $SITE_1_URL after 3 attempts. This is an environment failure (TLS/DNS/server), not a test failure — check build/sim-device.log for NSURLErrorDomain -1200 or 'Cancelled during verify block' before touching any flow."
exit 1
- name: Run Maestro Flows (iOS)
# CI 33936010053: this step ran 64m then the runner died (completed_at
# null, logs BlobNotFound). always() TSIO stub/upload never ran, so
# e2e-test/maestro-ios stayed pending. Cap the step so those steps run.
timeout-minutes: 50
continue-on-error: true
env:
FLOW_PATH: ${{ inputs.flow-path }}
E2E_IOS_DEVICE_NAME: ${{ env.E2E_IOS_DEVICE_NAME }}
E2E_IOS_DEVICE_OS_VERSION: ${{ env.E2E_IOS_DEVICE_OS_VERSION }}
MAESTRO_DRIVER_STARTUP_TIMEOUT: "180000"
run: |
if [ -n "${FLOW_PATH:-}" ] && ! printf '%s' "$FLOW_PATH" | grep -Eq '^[A-Za-z0-9_./ -]+$'; then
echo "Invalid flow-path input: $FLOW_PATH"
exit 1
fi
set -a
source detox/maestro/.maestro-test-env.sh
set +a
# MM-T3261 (report_problem_action.yml) requires the link variant: the
# default paid-tier path opens the native mail composer, which cannot
# open on iOS simulators (no Mail accounts) — the app then shows an
# error alert and the report screen never dismisses. Patch it to the
# link variant (report URL opens in the browser) and always restore.
trap 'curl -f -sS --show-error -X PUT \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"SupportSettings\":{\"ReportAProblemType\":\"default\"}}" \
"${SITE_1_URL}/api/v4/config/patch" > /dev/null && \
echo "==> Restored ReportAProblemType=default"' EXIT
echo "==> Patching SupportSettings.ReportAProblemType=link on ${SITE_1_URL}"
curl -f -sS --show-error -X PUT \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"SupportSettings":{"ReportAProblemType":"link"}}' \
"${SITE_1_URL}/api/v4/config/patch" > /dev/null
chmod +x detox/maestro/scripts/run_ci_batches.sh
detox/maestro/scripts/run_ci_batches.sh --platform ios --device "$SIMULATOR_ID"
- name: Stop iOS Simulator device log capture
if: always()
run: |
if [ -f build/sim-device.pid ]; then
pid=$(cat build/sim-device.pid)
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
echo "==> Device log capture stopped (pid $pid)"
fi
# Print log tail for quick failure scans without downloading the artifact.
if [ -f build/sim-device.log ]; then
sz=$(wc -c < build/sim-device.log)
echo "==> build/sim-device.log size=${sz}B"
echo "==> Last 100 lines of sim-device.log:"
tail -100 build/sim-device.log || true
echo "==> Lines matching calls/webrtc/peer/timed-out:"
grep -iE 'calls:|RTCPeer|WebRTC|peer connect|unable to get media|wired ?headset|timed out waiting' build/sim-device.log | head -100 || true
fi
- name: Run MM-T67856_4 with AllowDownloadLogs=false (iOS)
if: always() && hashFiles('detox/maestro/.maestro-test-env.sh') != '' && steps.maestro-ios-preflight.conclusion == 'success'
continue-on-error: true
run: |
set -a
source detox/maestro/.maestro-test-env.sh
set +a
# The flow flips SupportSettings.AllowDownloadLogs=false in its own
# onFlowStart hook and restores true in onFlowComplete
# (detox/maestro/fixtures/set_allow_download_logs.js), so this step
# only runs it; a local `maestro test` of the same file does the same.
# The always() restore step below stays as the safety net for runner
# hard-kills, which never reach onFlowComplete.
# Confirm the client config actually serves false instead of assuming a 200
# means the write took: an installation that supplies this setting through an
# environment variable answers 200 and keeps its own value, and the flow then
# asserts against a toggle the server never turned off. Same treatment
# ReportAProblemType already gets below.
echo "==> Patching SupportSettings.AllowDownloadLogs=false on ${SITE_1_URL}"
bash detox/maestro/scripts/apply_client_config_value.sh \
"${SITE_1_URL}" "$ADMIN_TOKEN" '{"SupportSettings":{"AllowDownloadLogs":false}}' \
AllowDownloadLogs false || {
rc=$?
# 3 = the installation forbids the write, so the flow's pre-condition cannot
# be created here; record a skip rather than a red. Anything else is real.
if [ "$rc" -eq 3 ]; then
bash detox/maestro/scripts/write_maestro_skip_stub.sh \
build/maestro-report-T67856_4.xml \
detox/maestro/flows/account/attach_logs_disabled_when_download_logs_off.yml \
"AllowDownloadLogs=false is not writable on this server (set by environment variable, patch silently ignored); flow cannot run here"
exit 0
fi
bash detox/maestro/scripts/write_maestro_failure_stub.sh \
build/maestro-report-T67856_4.xml \
detox/maestro/flows/account/attach_logs_disabled_when_download_logs_off.yml \
"AllowDownloadLogs=false never reached the client config; the flow's pre-condition was not in force"
exit 1
}
~/.maestro/bin/maestro test \
--device "$SIMULATOR_ID" \
--format junit \
--output build/maestro-report-T67856_4.xml \
--test-output-dir build/maestro-artifacts-T67856_4 \
--debug-output build/maestro-debug-T67856_4 \
--flatten-debug-output \
--env SITE_1_URL="${SITE_1_URL}" \
--env TEST_USER_EMAIL="${TEST_USER_EMAIL}" \
--env TEST_USER_PASSWORD="${TEST_USER_PASSWORD}" \
--env TEST_CHANNEL_NAME="${TEST_CHANNEL_NAME}" \
--env TEST_TEAM_NAME="${TEST_TEAM_NAME}" \
--env ADMIN_TOKEN="${ADMIN_TOKEN}" \
--env MAESTRO_APP_ID="com.mattermost.rnbeta" \
detox/maestro/flows/account/attach_logs_disabled_when_download_logs_off.yml
- name: Run MM-T3261_1 with ReportAProblemType=link (iOS)
# Isolated step: patch ReportAProblemType=link plus the report link, run the
# link-mode flow, restore the default on exit. The flow has no onFlowStart hook
# of its own, so the patch and the trap restore live here. continue-on-error so
# the main batch report stays authoritative.
if: always() && hashFiles('detox/maestro/.maestro-test-env.sh') != '' && steps.maestro-ios-preflight.conclusion == 'success'
continue-on-error: true
run: |
set -a
source detox/maestro/.maestro-test-env.sh
set +a
trap 'curl -f -sS --show-error -X PUT \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"SupportSettings\":{\"ReportAProblemType\":\"default\"}}" \
"${SITE_1_URL}/api/v4/config/patch" > /dev/null && \
echo "==> Restored ReportAProblemType=default"' EXIT
# Patch and then confirm the client config actually serves it. Two config writes
# close together can lose the later one: in run 34344304929 the mail step's trap
# restored 'default' 58 ms before this step patched, the app observed 'default',
# and handleReport() emailed instead of opening the browser.
echo "==> Patching SupportSettings.ReportAProblemType=link on ${SITE_1_URL}"
bash detox/maestro/scripts/apply_client_config_value.sh \
"${SITE_1_URL}" "$ADMIN_TOKEN" '{"SupportSettings":{"ReportAProblemType":"link","ReportAProblemLink":"https://mattermost.com/pl/report_a_problem_licensed"}}' \
ReportAProblemType link || {
rc=$?
# 3 = this installation forbids the write (run 34452126763 proved it is set by an
# environment variable, so config/patch is silently ignored); the flow's
# pre-condition cannot be created here, so record a skip rather than a red.
# Anything else is a real failure and stays red.
if [ "$rc" -eq 3 ]; then
bash detox/maestro/scripts/write_maestro_skip_stub.sh \
build/maestro-report-T3261_1.xml \
detox/maestro/flows/account/report_problem_link_mode.yml \
"ReportAProblemType=link is not writable on this server (set by environment variable, patch silently ignored); flow cannot run here"
exit 0
fi
bash detox/maestro/scripts/write_maestro_failure_stub.sh \
build/maestro-report-T3261_1.xml \
detox/maestro/flows/account/report_problem_link_mode.yml \
"ReportAProblemType=link never reached the client config; the flow's pre-condition was not in force"
exit 1
}
~/.maestro/bin/maestro test \
--device "$SIMULATOR_ID" \
--format junit \
--output build/maestro-report-T3261_1.xml \
--test-output-dir build/maestro-artifacts-T3261_1 \
--debug-output build/maestro-debug-T3261_1 \
--flatten-debug-output \
--env SITE_1_URL="${SITE_1_URL}" \
--env TEST_USER_EMAIL="${TEST_USER_EMAIL}" \
--env TEST_USER_PASSWORD="${TEST_USER_PASSWORD}" \
--env TEST_CHANNEL_NAME="${TEST_CHANNEL_NAME}" \
--env TEST_TEAM_NAME="${TEST_TEAM_NAME}" \
--env ADMIN_TOKEN="${ADMIN_TOKEN}" \
--env MAESTRO_APP_ID="com.mattermost.rnbeta" \
detox/maestro/flows/account/report_problem_link_mode.yml
- name: Restore AllowDownloadLogs baseline (self-healing)
if: always() && hashFiles('detox/maestro/.maestro-test-env.sh') != ''
continue-on-error: true
run: |
set -a
source detox/maestro/.maestro-test-env.sh
set +a
# # Standalone restore independent of the dedicated T67856_4 step:
# its in-step EXIT trap does not fire on runner hard-kills and is
# skipped entirely when the step is skipped, while transient restore
# failures are masked by continue-on-error. Setting true when already
# true is a harmless no-op, which keeps this safe when multiple jobs
# share one server and a concurrent job re-flipped the flag.
echo "==> Restoring SupportSettings.AllowDownloadLogs=true on ${SITE_1_URL}"
curl -f -sS --show-error --connect-timeout 10 --max-time 30 --retry 3 --retry-delay 2 --retry-connrefused -X PUT \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"SupportSettings":{"AllowDownloadLogs":true}}' \
"${SITE_1_URL}/api/v4/config/patch" > /dev/null && \
echo "==> Restored AllowDownloadLogs=true"
- name: Upload Maestro Artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: maestro-ios-results-${{ github.run_id }}
path: build/
- name: Parse Maestro Report
id: summary
if: always()
run: |
cd detox
npm ci --prefer-offline --no-audit --no-fund
node -e "
const path = require('path');
const {parseMaestroReport, generateMaestroHtmlReport, mergeMaestroBatchReportsFromDir, prepareMaestroScreenshotsForTsio} = require('./utils/maestro_report');
const buildDir = path.resolve('..', 'build');
const xmlPath = path.join(buildDir, 'maestro-report.xml');
mergeMaestroBatchReportsFromDir(buildDir, xmlPath);
const summary = parseMaestroReport(xmlPath);
const fs = require('fs');
if (summary) {
const s = summary.stats;
const output = [
'FAILURES=' + s.failures,
'PASSES=' + s.passes,
'TOTAL=' + s.tests,
'ERRORS=' + s.errors,
'SKIPPED=' + s.skipped,
'PERCENTAGE=' + s.passPercent,
];
output.forEach(l => fs.appendFileSync(process.env.GITHUB_OUTPUT, l + '\n'));
// Generate a rich HTML report (stats + per-flow drill-down with
// inline screenshots + log links). Lives next to maestro-artifacts/
// so relative <img src> URLs resolve when viewed from S3.
generateMaestroHtmlReport({
xmlPath: xmlPath,
artifactsDir: path.join(buildDir, 'maestro-artifacts'),
outputPath: path.join(buildDir, 'maestro-report.html'),
platform: 'iOS',
commitSha: process.env.GITHUB_SHA || '',
});
// Per-flow folders so TSIO LinkScreenshots can match full_title.
prepareMaestroScreenshotsForTsio({
xmlPath: xmlPath,
artifactsDir: path.join(buildDir, 'maestro-artifacts'),
outputDir: path.join(buildDir, 'maestro-artifacts-tsio'),
repoRoot: path.resolve('..'),
});
} else {
console.log('No Maestro report found — marking as failure');
['FAILURES=1','PASSES=0','TOTAL=0','ERRORS=1','SKIPPED=0','PERCENTAGE=0','NO_REPORT=true'].forEach(l => fs.appendFileSync(process.env.GITHUB_OUTPUT, l + '\n'));
}
"
- name: Write TSIO failure stub when Maestro report is missing
if: always() && inputs.tsio-config != '' && vars.TSIO_UPLOAD_ENABLED != 'false' && hashFiles('build/maestro-report.xml') == ''
env:
TSIO_SHARD_NAME: ${{ inputs.tsio-shard-name != '' && inputs.tsio-shard-name || 'Maestro E2E Tests (iOS)' }}
run: |
node detox/utils/write-tsio-failure-stub.mjs \
--format junit \
--output build/maestro-report.xml \
--job-name "$TSIO_SHARD_NAME" \
--reason "Maestro job failed or was cancelled before maestro-report.xml 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')"
# TSIO accepts framework=maestro (JUnit XML) since mattermost-test-system-io#76.
- name: Upload report to Test System IO
if: always() && inputs.tsio-config != '' && vars.TSIO_UPLOAD_ENABLED != 'false' && hashFiles('build/maestro-report.xml') != '' && steps.tsio-cfg.outputs.config != ''
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-report-upload@0661accd8cc9ab9dde6ec93f6f5ad40d06deb0d3 # release-0.14.0 / main@0661acc (#93 unique release tags per run)
with:
composite-identity: ${{ toJSON(fromJSON(steps.tsio-cfg.outputs.config).composite_identity) }}
total-reports-expected: ${{ fromJSON(steps.tsio-cfg.outputs.config).total_reports_expected }}
framework: maestro
github-token: ${{ github.token }}
gh-job-name: ${{ inputs.tsio-shard-name != '' && inputs.tsio-shard-name || 'Maestro E2E Tests (iOS)' }}
# Action input is named json-path; Maestro uploads JUnit XML bytes.
json-path: build/maestro-report.xml
screenshots-dir: build/maestro-artifacts-tsio
# 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 }}
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"
if [ "${{ job.status }}" = "cancelled" ]; then UPSTREAM="false"; fi
IDENTITY=$(echo "$TSIO_CONFIG" | jq -c '.composite_identity')
CONTEXT=$(echo "$TSIO_CONFIG" | jq -r '.status_context // "e2e-test/maestro-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" \
--e2e-override="$E2E_OVERRIDE" \
--poll-attempts=24
RC=$?
echo "tsio-report-status exit=$RC upstream_succeeded=$UPSTREAM context=$CONTEXT"
if [ "$RC" -ne 0 ] && [ "$UPSTREAM" = "true" ]; then
echo "::error::TSIO reported test failures for $CONTEXT"
exit 1
fi
exit 0
- name: Save Maestro results to Zephyr Scale
if: always() && inputs.record_tests_in_zephyr
run: npm run e2e:save-maestro-zephyr -- ../build/maestro-report.xml
working-directory: detox
env:
ZEPHYR_ENABLE: "true"
IOS: "true"
BUILD_ID: maestro-ios-${{ github.run_id }}
- name: Upload Maestro Report to S3
if: always()
run: |
cd detox
node -e "
const {saveArtifacts} = require('./utils/artifacts');
const fse = require('fs-extra');
fse.copySync('../build', './artifacts/maestro-ios', {overwrite: true});
saveArtifacts('maestro-ios').then(r => {
if (r && r.success) console.log('Uploaded:', r.reportLink);
}).catch(e => console.log('S3 upload skipped:', e.message));
"
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 }}
DETOX_AWS_S3_BUCKET: ${{ env.DETOX_AWS_S3_BUCKET }}
REPORT_PATH: maestro-ios-${{ github.run_id }}
- name: Set Target URL
id: set-url
if: always()
env:
S3_BUCKET: ${{ env.DETOX_AWS_S3_BUCKET }}
run: |
# Point at the HTML report generated above. It's uploaded inside
# detox/artifacts/maestro-ios/, which lands at the maestro-ios/
# path segment under the S3 prefix.
echo "TARGET_URL=https://${S3_BUCKET:-mattermost-detox-report}.s3.amazonaws.com/maestro-ios-${{ github.run_id }}/maestro-ios/maestro-report.html" >> "$GITHUB_OUTPUT"
- name: Record job cancellation
id: shard-cancelled
if: always()
run: |
if [[ "${{ job.status }}" == "cancelled" ]]; then
echo "CANCELLED=true" >> "$GITHUB_OUTPUT"
else
echo "CANCELLED=false" >> "$GITHUB_OUTPUT"
fi
- name: Determine Status
id: determine-status
if: always()
env:
FAILURES: ${{ steps.summary.outputs.FAILURES }}
ERRORS: ${{ steps.summary.outputs.ERRORS }}
TOTAL: ${{ steps.summary.outputs.TOTAL }}
NO_REPORT: ${{ steps.summary.outputs.NO_REPORT }}
SHARD_CANCELLED: ${{ steps.shard-cancelled.outputs.CANCELLED }}
run: |
if [[ "${SHARD_CANCELLED}" == "true" ]]; then
echo "STATUS=error" >> "$GITHUB_OUTPUT"
elif [[ "${FAILURES:-0}" == "-1" || "${FAILURES:-0}" -gt 0 || "${ERRORS:-0}" -gt 0 || "${TOTAL:-0}" -eq 0 || "${NO_REPORT:-}" == "true" ]]; then
echo "STATUS=failure" >> "$GITHUB_OUTPUT"
else
echo "STATUS=success" >> "$GITHUB_OUTPUT"
fi
- name: Generate Summary
if: always()
run: |
echo "### Maestro iOS E2E Results" >> "$GITHUB_STEP_SUMMARY"
echo "| Tests | Passed :white_check_mark: | Failed :x: | Skipped :fast_forward: |" >> "$GITHUB_STEP_SUMMARY"
echo "|:---:|:---:|:---:|:---:|" >> "$GITHUB_STEP_SUMMARY"
echo "| ${{ steps.summary.outputs.TOTAL }} | ${{ steps.summary.outputs.PASSES }} | ${{ steps.summary.outputs.FAILURES }} | ${{ steps.summary.outputs.SKIPPED }} |" >> "$GITHUB_STEP_SUMMARY"
- name: Fail job on test failures
if: always() && (steps.determine-status.outputs.STATUS == 'failure' || steps.determine-status.outputs.STATUS == 'error')
env:
STATUS: ${{ steps.determine-status.outputs.STATUS }}
FAILURES: ${{ steps.summary.outputs.FAILURES }}
run: |
echo "::error::Maestro reported test failures (STATUS=${STATUS}, FAILURES=${FAILURES})"
exit 1
e2e-maestro-android:
# TSIO shard label = job name; prefer tsio-shard-name (e.g. maestro-android-e2e).
name: ${{ inputs.tsio-shard-name != '' && inputs.tsio-shard-name || 'Maestro E2E Tests (Android)' }}
if: ${{ inputs.platform == 'android' }}
runs-on: ubuntu-latest-8-cores
timeout-minutes: 90
outputs:
STATUS: ${{ steps.determine-status.outputs.STATUS }}
TARGET_URL: ${{ steps.set-url.outputs.TARGET_URL }}
FAILURES: ${{ steps.summary.outputs.FAILURES }}
PASSES: ${{ steps.summary.outputs.PASSES }}
TOTAL: ${{ steps.summary.outputs.TOTAL }}
SKIPPED: ${{ steps.summary.outputs.SKIPPED }}
PERCENTAGE: ${{ steps.summary.outputs.PERCENTAGE }}
CANCELLED: ${{ steps.shard-cancelled.outputs.CANCELLED }}
steps:
- name: Checkout Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.MOBILE_VERSION }}
- name: Install system dependencies
timeout-minutes: 6
run: |
APT_OPTS=(-o Acquire::Retries=3 -o Acquire::http::Timeout=15 -o DPkg::Lock::Timeout=120)
PACKAGES=(libpulse0 xvfb)
sudo apt-get "${APT_OPTS[@]}" install -y --no-install-recommends "${PACKAGES[@]}" || {
sudo apt-get "${APT_OPTS[@]}" update
sudo apt-get "${APT_OPTS[@]}" install -y --no-install-recommends "${PACKAGES[@]}"
}
- name: ci/prepare-node-deps
uses: ./.github/actions/prepare-node-deps
- name: Cache Maestro
id: maestro-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.maestro
key: maestro-00f516729f57d48f36ded55244cfbe358ada6b00-linux
- name: Install Maestro CLI
if: steps.maestro-cache.outputs.cache-hit != 'true'
run: |
MAESTRO_VERSION=$(jq -r .version detox/maestro/maestro-version.json)
MAESTRO_COMMIT=$(jq -r .commit detox/maestro/maestro-version.json)
echo "Installing Maestro ${MAESTRO_VERSION} (pinned to commit ${MAESTRO_COMMIT})"
export MAESTRO_VERSION
curl -fsSL "https://get.maestro.mobile.dev" | bash
MAESTRO_BIN="$HOME/.maestro/bin/maestro"
if [ ! -f "$MAESTRO_BIN" ]; then
echo "Error: Maestro binary not found at $MAESTRO_BIN"
exit 1
fi
echo "Maestro installed successfully at $MAESTRO_BIN"
"$MAESTRO_BIN" --version
- name: Verify Maestro installation
run: ~/.maestro/bin/maestro --version
- name: Download Maestro Android Build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: android-maestro-build-files-${{ env.ARTIFACT_RUN_ID }}
run-id: ${{ env.ARTIFACT_RUN_ID }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: android/app/build/outputs/apk/release
- name: Seed test data
run: |
cd detox/maestro
npm ci --prefer-offline --no-audit --no-fund
npx tsx fixtures/seed.ts
env:
SITE_1_URL: ${{ env.SITE_1_URL }}
ADMIN_EMAIL: ${{ env.ADMIN_EMAIL }}
ADMIN_USERNAME: ${{ env.ADMIN_USERNAME }}
ADMIN_PASSWORD: ${{ env.ADMIN_PASSWORD }}
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Set up Android SDK
run: |
export ANDROID_HOME=/usr/local/lib/android/sdk
export ANDROID_SDK_ROOT=/usr/local/lib/android/sdk
export PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/emulator:$ANDROID_HOME/tools/bin:$ANDROID_HOME/platform-tools:$PATH
echo "ANDROID_HOME=$ANDROID_HOME" >> $GITHUB_ENV
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> $GITHUB_ENV
echo "PATH=$PATH" >> $GITHUB_ENV
- name: Start Xvfb
run: |
Xvfb :99 -screen 0 1920x1080x24 &
echo "DISPLAY=:99" >> $GITHUB_ENV
- name: Accept Android licenses
run: |
mkdir -p ~/.android
yes | sdkmanager --licenses > sdk_licenses_output.txt || true
if grep -q "All SDK package licenses accepted" sdk_licenses_output.txt; then
echo "All licenses accepted successfully."
else
echo "Licenses not fully accepted."
cat sdk_licenses_output.txt
exit 1
fi
# Same SDK cache strategy as e2e-android-template.yml (API 35, workspace cache).
- name: Cache Android SDK components
id: android-sdk-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .android-sdk-cache
key: android-sdk-${{ env.E2E_ANDROID_API_LEVEL }}-google-apis-x86_64-v2
- name: Apply cached Android SDK components
if: steps.android-sdk-cache.outputs.cache-hit == 'true'
run: |
API_LEVEL='${{ env.E2E_ANDROID_API_LEVEL }}'
for component in platform-tools emulator; do
if [ -d ".android-sdk-cache/${component}" ]; then
rm -rf "${ANDROID_HOME:?}/${component}"
cp -a ".android-sdk-cache/${component}" "${ANDROID_HOME}/"
fi
done
if [ -d ".android-sdk-cache/platforms/android-${API_LEVEL}" ]; then
rm -rf "${ANDROID_HOME}/platforms/android-${API_LEVEL}"
mkdir -p "${ANDROID_HOME}/platforms"
cp -a ".android-sdk-cache/platforms/android-${API_LEVEL}" "${ANDROID_HOME}/platforms/"
fi
if [ -d ".android-sdk-cache/system-images/android-${API_LEVEL}" ]; then
rm -rf "${ANDROID_HOME}/system-images/android-${API_LEVEL}"
mkdir -p "${ANDROID_HOME}/system-images"
cp -a ".android-sdk-cache/system-images/android-${API_LEVEL}" "${ANDROID_HOME}/system-images/"
fi
- name: Install Android SDK components
if: steps.android-sdk-cache.outputs.cache-hit != 'true'
env:
JAVA_HOME: ${{ env.JAVA_HOME_17_X64 }}
run: |
API_LEVEL='${{ env.E2E_ANDROID_API_LEVEL }}'
for attempt in 1 2 3; do
echo "SDK install attempt $attempt..."
if yes | sdkmanager --install "platform-tools" "emulator" "platforms;android-${API_LEVEL}" "system-images;android-${API_LEVEL};google_apis;x86_64"; then
echo "SDK install succeeded on attempt $attempt"
break
fi
echo "Attempt $attempt failed, retrying..."
if [ "$attempt" = "3" ]; then
echo "SDK install failed after 3 attempts"
exit 1
fi
sleep 10
done
mkdir -p .android-sdk-cache/platforms .android-sdk-cache/system-images
cp -a "${ANDROID_HOME}/platform-tools" .android-sdk-cache/
cp -a "${ANDROID_HOME}/emulator" .android-sdk-cache/
cp -a "${ANDROID_HOME}/platforms/android-${API_LEVEL}" .android-sdk-cache/platforms/
cp -a "${ANDROID_HOME}/system-images/android-${API_LEVEL}" .android-sdk-cache/system-images/
- name: Cache Android Emulator & AVD
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
detox/.android
detox/${{ env.E2E_ANDROID_AVD_NAME }}_api_${{ env.E2E_ANDROID_API_LEVEL }}
key: android-emulator-${{ runner.os }}-${{ runner.arch }}-${{ env.E2E_ANDROID_AVD_NAME }}-api-${{ env.E2E_ANDROID_API_LEVEL }}-v3
restore-keys: |
android-emulator-${{ runner.os }}-${{ runner.arch }}-${{ env.E2E_ANDROID_AVD_NAME }}-api-${{ env.E2E_ANDROID_API_LEVEL }}-
android-emulator-${{ runner.os }}-${{ runner.arch }}-
# Retry only the boot (infra failures); test failures must not be auto-retried.
- name: Bootstrap Android emulator
env:
BOOTSTRAP_ONLY: "true"
MAESTRO_ANDROID: "true"
JAVA_HOME: ${{ env.JAVA_HOME_17_X64 }}
API_LEVEL: ${{ env.E2E_ANDROID_API_LEVEL }}
AVD_NAME: ${{ env.E2E_ANDROID_AVD_NAME }}
MAX_ATTEMPTS: "2"
ATTEMPT_TIMEOUT_MIN: "15"
shell: bash
run: |
set +e
attempt=1
while (( attempt <= MAX_ATTEMPTS )); do
echo "==> Boot attempt ${attempt}/${MAX_ATTEMPTS} (timeout ${ATTEMPT_TIMEOUT_MIN}m)"
timeout "${ATTEMPT_TIMEOUT_MIN}m" bash -c '
cd detox
chmod +x ./create_android_emulator.sh
CI=true ./create_android_emulator.sh "$API_LEVEL" "$AVD_NAME"
'
rc=$?
if (( rc == 0 )); then
echo "==> Boot succeeded on attempt ${attempt}"
exit 0
fi
echo "==> Boot attempt ${attempt} failed with exit ${rc}"
if (( attempt < MAX_ATTEMPTS )); then
echo "==> Cleaning up before retry"
adb -s emulator-5554 emu kill 2>/dev/null || true
pkill -9 -f qemu-system 2>/dev/null || true
pkill -9 -f emulator 2>/dev/null || true
sleep 5
fi
attempt=$(( attempt + 1 ))
done
echo "==> All ${MAX_ATTEMPTS} boot attempts failed"
exit 1
# The iOS job has captured a device log since it was written; the Android job never has,
# so an Android Maestro failure has no record of what the OS did. That gap is why
# report_problem_link_mode could not be diagnosed across three runs: the command trace
# proves the "Report a problem" tap completed and the app stayed put, but with no logcat
# there is no way to tell whether the browser intent was fired, resolved, or foregrounded.
- name: Start Android logcat capture
run: |
mkdir -p build
adb logcat -c || true
adb logcat -v threadtime > build/android-logcat.log 2> build/android-logcat.err &
echo $! > build/android-logcat.pid
echo "==> logcat capture started (pid $(cat build/android-logcat.pid))"
- name: Run Maestro Flows (Android)
continue-on-error: true
env:
FLOW_PATH: ${{ inputs.flow-path }}
MAESTRO_DRIVER_STARTUP_TIMEOUT: "180000"
run: |
if [ -n "${FLOW_PATH:-}" ] && ! printf '%s' "$FLOW_PATH" | grep -Eq '^[A-Za-z0-9_./ -]+$'; then
echo "Invalid flow-path input: $FLOW_PATH"
exit 1
fi
set -a
source detox/maestro/.maestro-test-env.sh
set +a
# Same link-mode patch as the iOS batch — see the iOS step's comment.
trap 'curl -f -sS --show-error -X PUT \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"SupportSettings\":{\"ReportAProblemType\":\"default\"}}" \
"${SITE_1_URL}/api/v4/config/patch" > /dev/null && \
echo "==> Restored ReportAProblemType=default"' EXIT
echo "==> Patching SupportSettings.ReportAProblemType=link on ${SITE_1_URL}"
curl -f -sS --show-error -X PUT \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"SupportSettings":{"ReportAProblemType":"link"}}' \
"${SITE_1_URL}/api/v4/config/patch" > /dev/null
chmod +x detox/maestro/scripts/run_ci_batches.sh
detox/maestro/scripts/run_ci_batches.sh --platform android
- name: Run MM-T67856_4 with AllowDownloadLogs=false (Android)
# Isolated step: the flow patches AllowDownloadLogs=false in onFlowStart and
# restores it in onFlowComplete, so it must not share a batch with attach_logs.yml.
# continue-on-error keeps the main batch report authoritative.
# Skip when seed never wrote the env file (e.g. inactive Matterwick site).
if: always() && hashFiles('detox/maestro/.maestro-test-env.sh') != ''
continue-on-error: true
run: |
set -a
source detox/maestro/.maestro-test-env.sh
set +a
# The flow flips SupportSettings.AllowDownloadLogs=false in its own
# onFlowStart hook and restores true in onFlowComplete
# (detox/maestro/fixtures/set_allow_download_logs.js), so this step
# only runs it; a local `maestro test` of the same file does the same.
# The always() restore step below stays as the safety net for runner
# hard-kills, which never reach onFlowComplete.
# Confirm the client config actually serves false instead of assuming a 200
# means the write took: an installation that supplies this setting through an
# environment variable answers 200 and keeps its own value, and the flow then
# asserts against a toggle the server never turned off. Same treatment
# ReportAProblemType already gets below.
echo "==> Patching SupportSettings.AllowDownloadLogs=false on ${SITE_1_URL}"
bash detox/maestro/scripts/apply_client_config_value.sh \
"${SITE_1_URL}" "$ADMIN_TOKEN" '{"SupportSettings":{"AllowDownloadLogs":false}}' \
AllowDownloadLogs false || {
rc=$?
# 3 = the installation forbids the write, so the flow's pre-condition cannot
# be created here; record a skip rather than a red. Anything else is real.
if [ "$rc" -eq 3 ]; then
bash detox/maestro/scripts/write_maestro_skip_stub.sh \
build/maestro-report-T67856_4.xml \
detox/maestro/flows/account/attach_logs_disabled_when_download_logs_off.yml \
"AllowDownloadLogs=false is not writable on this server (set by environment variable, patch silently ignored); flow cannot run here"
exit 0
fi
bash detox/maestro/scripts/write_maestro_failure_stub.sh \
build/maestro-report-T67856_4.xml \
detox/maestro/flows/account/attach_logs_disabled_when_download_logs_off.yml \
"AllowDownloadLogs=false never reached the client config; the flow's pre-condition was not in force"
exit 1
}
~/.maestro/bin/maestro test \
--format junit \
--output build/maestro-report-T67856_4.xml \
--test-output-dir build/maestro-artifacts-T67856_4 \
--debug-output build/maestro-debug-T67856_4 \
--flatten-debug-output \
--platform android \
--env SITE_1_URL="${SITE_1_URL}" \
--env TEST_USER_EMAIL="${TEST_USER_EMAIL}" \
--env TEST_USER_PASSWORD="${TEST_USER_PASSWORD}" \
--env TEST_CHANNEL_NAME="${TEST_CHANNEL_NAME}" \
--env TEST_TEAM_NAME="${TEST_TEAM_NAME}" \
--env ADMIN_TOKEN="${ADMIN_TOKEN}" \
--env MAESTRO_APP_ID="com.mattermost.rnbeta" \
detox/maestro/flows/account/attach_logs_disabled_when_download_logs_off.yml
- name: Restore AllowDownloadLogs baseline (self-healing)
if: always() && hashFiles('detox/maestro/.maestro-test-env.sh') != ''
continue-on-error: true
run: |
set -a
source detox/maestro/.maestro-test-env.sh
set +a
# # Android copy of the iOS restore step — see that step for the
# rationale (trap-less hard-kill and skip-path coverage).
echo "==> Restoring SupportSettings.AllowDownloadLogs=true on ${SITE_1_URL}"
curl -f -sS --show-error --connect-timeout 10 --max-time 30 --retry 3 --retry-delay 2 --retry-connrefused -X PUT \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"SupportSettings":{"AllowDownloadLogs":true}}' \
"${SITE_1_URL}/api/v4/config/patch" > /dev/null && \
echo "==> Restored AllowDownloadLogs=true"
- name: Run MM-T3261_2 with ReportAProblemType=email (Android)
# Isolated step: clear Gmail, patch ReportAProblemType=email, run the
# mail-mode flow, restore on exit. Android-only — iOS simulators have no
# Mail app. continue-on-error so the main report stays authoritative.
if: always() && hashFiles('detox/maestro/.maestro-test-env.sh') != ''
continue-on-error: true
run: |
set -a
source detox/maestro/.maestro-test-env.sh
set +a
echo "==> Clearing Gmail so MM-T3261_2 hits first-run onboarding"
adb shell pm clear com.google.android.gm || true
trap 'curl -f -sS --show-error -X PUT \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"SupportSettings\":{\"ReportAProblemType\":\"default\"}}" \
"${SITE_1_URL}/api/v4/config/patch" > /dev/null && \
echo "==> Restored ReportAProblemType=default"' EXIT
# ReportAProblemMail is required: the server rejects ReportAProblemType=email
# without a valid address (model/config.go IsValid — CI 34290629488 got HTTP 400
# here and the flow never ran). On patch failure, record the flow as failed so
# it stays in the merged report instead of vanishing from the count.
# Patch and then confirm the client config actually serves it. Two config writes
# close together can lose the later one: in run 34344304929 the mail step's trap
# restored 'default' 58 ms before this step patched, the app observed 'default',
# and handleReport() emailed instead of opening the browser.
echo "==> Patching SupportSettings.ReportAProblemType=email on ${SITE_1_URL}"
bash detox/maestro/scripts/apply_client_config_value.sh \
"${SITE_1_URL}" "$ADMIN_TOKEN" '{"SupportSettings":{"ReportAProblemType":"email","ReportAProblemMail":"e2e-report-a-problem@example.com"}}' \
ReportAProblemType email || {
rc=$?
# 3 = this installation forbids the write (run 34452126763 proved it is set by an
# environment variable, so config/patch is silently ignored); the flow's
# pre-condition cannot be created here, so record a skip rather than a red.
# Anything else is a real failure and stays red.
if [ "$rc" -eq 3 ]; then
bash detox/maestro/scripts/write_maestro_skip_stub.sh \
build/maestro-report-T3261_2.xml \
detox/maestro/flows/account/report_problem_mail_mode.yml \
"ReportAProblemType=email is not writable on this server (set by environment variable, patch silently ignored); flow cannot run here"
exit 0
fi
bash detox/maestro/scripts/write_maestro_failure_stub.sh \
build/maestro-report-T3261_2.xml \
detox/maestro/flows/account/report_problem_mail_mode.yml \
"ReportAProblemType=email never reached the client config; the flow's pre-condition was not in force"
exit 1
}
~/.maestro/bin/maestro test \
--format junit \
--output build/maestro-report-T3261_2.xml \
--test-output-dir build/maestro-artifacts-T3261_2 \
--debug-output build/maestro-debug-T3261_2 \
--flatten-debug-output \
--platform android \
--env SITE_1_URL="${SITE_1_URL}" \
--env TEST_USER_EMAIL="${TEST_USER_EMAIL}" \
--env TEST_USER_PASSWORD="${TEST_USER_PASSWORD}" \
--env TEST_CHANNEL_NAME="${TEST_CHANNEL_NAME}" \
--env TEST_TEAM_NAME="${TEST_TEAM_NAME}" \
--env ADMIN_TOKEN="${ADMIN_TOKEN}" \
--env MAESTRO_APP_ID="com.mattermost.rnbeta" \
detox/maestro/flows/account/report_problem_mail_mode.yml
- name: Run MM-T3261_1 with ReportAProblemType=link (Android)
# Isolated step: patch ReportAProblemType=link plus the report link, run the
# link-mode flow, restore the default on exit. The flow has no onFlowStart hook
# of its own, so the patch and the trap restore live here. continue-on-error so
# the main batch report stays authoritative.
if: always() && hashFiles('detox/maestro/.maestro-test-env.sh') != ''
continue-on-error: true
run: |
set -a
source detox/maestro/.maestro-test-env.sh
set +a
trap 'curl -f -sS --show-error -X PUT \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"SupportSettings\":{\"ReportAProblemType\":\"default\"}}" \
"${SITE_1_URL}/api/v4/config/patch" > /dev/null && \
echo "==> Restored ReportAProblemType=default"' EXIT
# Patch and then confirm the client config actually serves it. Two config writes
# close together can lose the later one: in run 34344304929 the mail step's trap
# restored 'default' 58 ms before this step patched, the app observed 'default',
# and handleReport() emailed instead of opening the browser.
echo "==> Patching SupportSettings.ReportAProblemType=link on ${SITE_1_URL}"
bash detox/maestro/scripts/apply_client_config_value.sh \
"${SITE_1_URL}" "$ADMIN_TOKEN" '{"SupportSettings":{"ReportAProblemType":"link","ReportAProblemLink":"https://mattermost.com/pl/report_a_problem_licensed"}}' \
ReportAProblemType link || {
rc=$?
# 3 = this installation forbids the write (run 34452126763 proved it is set by an
# environment variable, so config/patch is silently ignored); the flow's
# pre-condition cannot be created here, so record a skip rather than a red.
# Anything else is a real failure and stays red.
if [ "$rc" -eq 3 ]; then
bash detox/maestro/scripts/write_maestro_skip_stub.sh \
build/maestro-report-T3261_1.xml \
detox/maestro/flows/account/report_problem_link_mode.yml \
"ReportAProblemType=link is not writable on this server (set by environment variable, patch silently ignored); flow cannot run here"
exit 0
fi
bash detox/maestro/scripts/write_maestro_failure_stub.sh \
build/maestro-report-T3261_1.xml \
detox/maestro/flows/account/report_problem_link_mode.yml \
"ReportAProblemType=link never reached the client config; the flow's pre-condition was not in force"
exit 1
}
~/.maestro/bin/maestro test \
--format junit \
--output build/maestro-report-T3261_1.xml \
--test-output-dir build/maestro-artifacts-T3261_1 \
--debug-output build/maestro-debug-T3261_1 \
--flatten-debug-output \
--platform android \
--env SITE_1_URL="${SITE_1_URL}" \
--env TEST_USER_EMAIL="${TEST_USER_EMAIL}" \
--env TEST_USER_PASSWORD="${TEST_USER_PASSWORD}" \
--env TEST_CHANNEL_NAME="${TEST_CHANNEL_NAME}" \
--env TEST_TEAM_NAME="${TEST_TEAM_NAME}" \
--env ADMIN_TOKEN="${ADMIN_TOKEN}" \
--env MAESTRO_APP_ID="com.mattermost.rnbeta" \
detox/maestro/flows/account/report_problem_link_mode.yml
- name: Stop Android logcat capture
if: always()
run: |
if [ -f build/android-logcat.pid ]; then
pid=$(cat build/android-logcat.pid)
kill "$pid" 2>/dev/null || true
echo "==> logcat capture stopped (pid $pid)"
fi
if [ -f build/android-logcat.log ]; then
echo "==> logcat size: $(wc -c < build/android-logcat.log) bytes"
fi
- name: Upload Maestro Artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: maestro-android-results-${{ github.run_id }}
path: build/
- name: Parse Maestro Report
id: summary
if: always()
run: |
cd detox
npm ci --prefer-offline --no-audit --no-fund
node -e "
const path = require('path');
const {parseMaestroReport, generateMaestroHtmlReport, mergeMaestroBatchReportsFromDir, prepareMaestroScreenshotsForTsio} = require('./utils/maestro_report');
const buildDir = path.resolve('..', 'build');
const xmlPath = path.join(buildDir, 'maestro-report.xml');
mergeMaestroBatchReportsFromDir(buildDir, xmlPath);
const summary = parseMaestroReport(xmlPath);
const fs = require('fs');
if (summary) {
const s = summary.stats;
const output = [
'FAILURES=' + s.failures,
'PASSES=' + s.passes,
'TOTAL=' + s.tests,
'ERRORS=' + s.errors,
'SKIPPED=' + s.skipped,
'PERCENTAGE=' + s.passPercent,
];
output.forEach(l => fs.appendFileSync(process.env.GITHUB_OUTPUT, l + '\n'));
// Generate a rich HTML report (stats + per-flow drill-down with
// inline screenshots + log links). See maestro iOS notes above.
generateMaestroHtmlReport({
xmlPath: xmlPath,
artifactsDir: path.join(buildDir, 'maestro-artifacts'),
outputPath: path.join(buildDir, 'maestro-report.html'),
platform: 'Android',
commitSha: process.env.GITHUB_SHA || '',
});
// Per-flow folders so TSIO LinkScreenshots can match full_title.
prepareMaestroScreenshotsForTsio({
xmlPath: xmlPath,
artifactsDir: path.join(buildDir, 'maestro-artifacts'),
outputDir: path.join(buildDir, 'maestro-artifacts-tsio'),
repoRoot: path.resolve('..'),
});
} else {
console.log('No Maestro report found — marking as failure');
['FAILURES=1','PASSES=0','TOTAL=0','ERRORS=1','SKIPPED=0','PERCENTAGE=0','NO_REPORT=true'].forEach(l => fs.appendFileSync(process.env.GITHUB_OUTPUT, l + '\n'));
}
"
- name: Write TSIO failure stub when Maestro report is missing
if: always() && inputs.tsio-config != '' && vars.TSIO_UPLOAD_ENABLED != 'false' && hashFiles('build/maestro-report.xml') == ''
env:
TSIO_SHARD_NAME: ${{ inputs.tsio-shard-name != '' && inputs.tsio-shard-name || 'Maestro E2E Tests (Android)' }}
run: |
node detox/utils/write-tsio-failure-stub.mjs \
--format junit \
--output build/maestro-report.xml \
--job-name "$TSIO_SHARD_NAME" \
--reason "Maestro job failed or was cancelled before maestro-report.xml 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')"
# TSIO accepts framework=maestro (JUnit XML) since mattermost-test-system-io#76.
- name: Upload report to Test System IO
if: always() && inputs.tsio-config != '' && vars.TSIO_UPLOAD_ENABLED != 'false' && hashFiles('build/maestro-report.xml') != '' && steps.tsio-cfg.outputs.config != ''
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-report-upload@0661accd8cc9ab9dde6ec93f6f5ad40d06deb0d3 # release-0.14.0 / main@0661acc (#93 unique release tags per run)
with:
composite-identity: ${{ toJSON(fromJSON(steps.tsio-cfg.outputs.config).composite_identity) }}
total-reports-expected: ${{ fromJSON(steps.tsio-cfg.outputs.config).total_reports_expected }}
framework: maestro
github-token: ${{ github.token }}
gh-job-name: ${{ inputs.tsio-shard-name != '' && inputs.tsio-shard-name || 'Maestro E2E Tests (Android)' }}
# Action input is named json-path; Maestro uploads JUnit XML bytes.
json-path: build/maestro-report.xml
screenshots-dir: build/maestro-artifacts-tsio
# 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 }}
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"
if [ "${{ job.status }}" = "cancelled" ]; then UPSTREAM="false"; fi
IDENTITY=$(echo "$TSIO_CONFIG" | jq -c '.composite_identity')
CONTEXT=$(echo "$TSIO_CONFIG" | jq -r '.status_context // "e2e-test/maestro-android"')
TOTAL=$(echo "$TSIO_CONFIG" | jq -r '.total_reports_expected // 1')
node detox/utils/tsio-report-status.js \
--composite-identity="$IDENTITY" \
--total-reports-expected="$TOTAL" \
--github-token="$GITHUB_TOKEN" \
--context="$CONTEXT" \
--upstream-succeeded="$UPSTREAM" \
--e2e-override="$E2E_OVERRIDE" \
--poll-attempts=24
RC=$?
echo "tsio-report-status exit=$RC upstream_succeeded=$UPSTREAM context=$CONTEXT"
if [ "$RC" -ne 0 ] && [ "$UPSTREAM" = "true" ]; then
echo "::error::TSIO reported test failures for $CONTEXT"
exit 1
fi
exit 0
- name: Save Maestro results to Zephyr Scale
if: always() && inputs.record_tests_in_zephyr
run: npm run e2e:save-maestro-zephyr -- ../build/maestro-report.xml
working-directory: detox
env:
ZEPHYR_ENABLE: "true"
IOS: "false"
BUILD_ID: maestro-android-${{ github.run_id }}
- name: Upload Maestro Report to S3
if: always()
run: |
cd detox
node -e "
const {saveArtifacts} = require('./utils/artifacts');
const fse = require('fs-extra');
fse.copySync('../build', './artifacts/maestro-android', {overwrite: true});
saveArtifacts('maestro-android').then(r => {
if (r && r.success) console.log('Uploaded:', r.reportLink);
}).catch(e => console.log('S3 upload skipped:', e.message));
"
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 }}
DETOX_AWS_S3_BUCKET: ${{ env.DETOX_AWS_S3_BUCKET }}
REPORT_PATH: maestro-android-${{ github.run_id }}
- name: Set Target URL
id: set-url
if: always()
env:
S3_BUCKET: ${{ env.DETOX_AWS_S3_BUCKET }}
run: |
# Point at the HTML report generated above.
echo "TARGET_URL=https://${S3_BUCKET:-mattermost-detox-report}.s3.amazonaws.com/maestro-android-${{ github.run_id }}/maestro-android/maestro-report.html" >> "$GITHUB_OUTPUT"
- name: Record shard cancellation
id: shard-cancelled
if: always()
run: |
if [[ "${{ job.status }}" == "cancelled" ]]; then
echo "CANCELLED=true" >> "$GITHUB_OUTPUT"
else
echo "CANCELLED=false" >> "$GITHUB_OUTPUT"
fi
- name: Determine Status
id: determine-status
if: always()
env:
FAILURES: ${{ steps.summary.outputs.FAILURES }}
ERRORS: ${{ steps.summary.outputs.ERRORS }}
TOTAL: ${{ steps.summary.outputs.TOTAL }}
NO_REPORT: ${{ steps.summary.outputs.NO_REPORT }}
SHARD_CANCELLED: ${{ steps.shard-cancelled.outputs.CANCELLED }}
run: |
if [[ "${SHARD_CANCELLED}" == "true" ]]; then
echo "STATUS=error" >> "$GITHUB_OUTPUT"
elif [[ "${FAILURES:-0}" == "-1" || "${FAILURES:-0}" -gt 0 || "${ERRORS:-0}" -gt 0 || "${TOTAL:-0}" -eq 0 || "${NO_REPORT:-}" == "true" ]]; then
echo "STATUS=failure" >> "$GITHUB_OUTPUT"
else
echo "STATUS=success" >> "$GITHUB_OUTPUT"
fi
- name: Generate Summary
if: always()
run: |
echo "### Maestro Android E2E Results" >> "$GITHUB_STEP_SUMMARY"
echo "| Tests | Passed :white_check_mark: | Failed :x: | Skipped :fast_forward: |" >> "$GITHUB_STEP_SUMMARY"
echo "|:---:|:---:|:---:|:---:|" >> "$GITHUB_STEP_SUMMARY"
echo "| ${{ steps.summary.outputs.TOTAL }} | ${{ steps.summary.outputs.PASSES }} | ${{ steps.summary.outputs.FAILURES }} | ${{ steps.summary.outputs.SKIPPED }} |" >> "$GITHUB_STEP_SUMMARY"
- name: Fail job on test failures
if: always() && (steps.determine-status.outputs.STATUS == 'failure' || steps.determine-status.outputs.STATUS == 'error')
env:
STATUS: ${{ steps.determine-status.outputs.STATUS }}
FAILURES: ${{ steps.summary.outputs.FAILURES }}
run: |
echo "::error::Maestro reported test failures (STATUS=${STATUS}, FAILURES=${FAILURES})"
exit 1