mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-06 19:00:14 +00:00
fix(e2e): stabilize Detox and Maestro test suite Restore main features, and harden the E2E harness against flaky UI, network, retry, synchronization, and classification-lock failures. Stabilize channel creation, saved messages, bookmarks, search, deep links, keyboard dismissal, and post verification. Bound network, transport, health-check, synchronization, and lock retries by elapsed time. Fix iOS and Android Maestro/Detox races and flaky interaction flows. Restore Session Attributes and deep-link fixes lost during merge. Improve test isolation, global-setting cleanup, and Jest/TSIO reporting. Enforce Maestro flow-header validation in local and CI checks.
1113 lines
51 KiB
YAML
1113 lines
51 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)
|
|
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
|
|
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
|
|
|
|
# AllowDownloadLogs lives under SupportSettings (not ServiceSettings)
|
|
# in the Mattermost server config — verified during local maestro
|
|
# iteration against SITE_1_URL.
|
|
echo "==> Patching SupportSettings.AllowDownloadLogs=false on ${SITE_1_URL}"
|
|
curl -f -sS --show-error -X PUT \
|
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"SupportSettings":{"AllowDownloadLogs":false}}' \
|
|
"${SITE_1_URL}/api/v4/config/patch" > /dev/null
|
|
|
|
# Always restore the default before exiting this step, regardless
|
|
# of whether the maestro run passes.
|
|
trap 'curl -f -sS --show-error -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"' EXIT
|
|
|
|
~/.maestro/bin/maestro test \
|
|
--device "$SIMULATOR_ID" \
|
|
--format junit \
|
|
--output build/maestro-report-T67856_4.xml \
|
|
--test-output-dir build/maestro-artifacts-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: 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
|
|
|
|
- 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
|
|
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: patch server config to AllowDownloadLogs=false, run
|
|
# the single flow, restore on exit. continue-on-error so the main
|
|
# report stays 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
|
|
|
|
# AllowDownloadLogs lives under SupportSettings (not ServiceSettings)
|
|
# in the Mattermost server config — verified during local maestro
|
|
# iteration against SITE_1_URL.
|
|
echo "==> Patching SupportSettings.AllowDownloadLogs=false on ${SITE_1_URL}"
|
|
curl -f -sS --show-error -X PUT \
|
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"SupportSettings":{"AllowDownloadLogs":false}}' \
|
|
"${SITE_1_URL}/api/v4/config/patch" > /dev/null
|
|
|
|
trap 'curl -f -sS --show-error -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"' EXIT
|
|
|
|
~/.maestro/bin/maestro test \
|
|
--format junit \
|
|
--output build/maestro-report-T67856_4.xml \
|
|
--test-output-dir build/maestro-artifacts-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: 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
|