mattermost-mobile-upstream/.github/workflows/e2e-maestro-template.yml
yasser khan f3af9dbe99
Fix/detox hittest cluster (#10023)
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.
2026-08-27 08:58:03 +05:30

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