Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions .github/actions/archive-test-results/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,24 @@ inputs:
required: false
default: ${{ github.workspace }}

# Every step below is `if: ${{ !cancelled() }}` rather than `if: always()`. Both still run
# when the test step failed — which is the case we need these artifacts for — but always()
# is exempt from cancellation, so cancelling a run left each shard uploading five artifact
# sets before it could die, which is why "Cancel workflow" appeared to do nothing.
runs:
using: composite
steps:
- name: Archive runtime logs
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7
if: always()
if: ${{ !cancelled() }}
with:
name: ${{ inputs.platform }}-runtime-logs-${{ inputs.test-type }}
path: log/*.log
if-no-files-found: ignore

- name: Archive test screenshots
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7
if: always()
if: ${{ !cancelled() }}
with:
name: ${{ inputs.platform }}-screenshots-${{ inputs.test-type }}
path: ${{ inputs.workspace-path }}/maestro/images/actual/${{ inputs.platform }}/**/*.png
Expand All @@ -38,7 +42,7 @@ runs:
# resolves the diff name against the baseline's own relative path. That nested location
# was never matched by the screenshots glob above, so diffs were silently lost. Gather
# any *_diff.png from wherever it landed into one flat dir for upload.
if: always()
if: ${{ !cancelled() }}
shell: bash
run: |
dest="${{ inputs.workspace-path }}/maestro/images/diff/${{ inputs.platform }}"
Expand All @@ -49,15 +53,15 @@ runs:

- name: Archive screenshot diffs
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7
if: always()
if: ${{ !cancelled() }}
with:
name: ${{ inputs.platform }}-diffs-${{ inputs.test-type }}
path: ${{ inputs.workspace-path }}/maestro/images/diff/${{ inputs.platform }}/*.png
if-no-files-found: ignore

- name: Archive artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7
if: always()
if: ${{ !cancelled() }}
with:
name: ${{ inputs.platform }}-artifacts-${{ inputs.test-type }}
path: packages/pluggableWidgets/**/artifacts/
Expand All @@ -68,7 +72,7 @@ runs:
# empty and the upload is a no-op (if-no-files-found: ignore).
- name: Archive failure videos
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f #v7
if: always()
if: ${{ !cancelled() }}
with:
name: ${{ inputs.platform }}-videos-${{ inputs.test-type }}
path: maestro/videos/*.mp4
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/Build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ on:
branches:
- main
- 'mx/**'

# Supersede in-flight runs: a new push to the same PR/branch cancels the previous run
# instead of queueing beside it (this repo shares GitHub Free's 20-concurrent-job cap with
# NativePipeline, so stale runs directly delay the ones that matter). Only PR runs are
# cancelled — a push run on main/mx-* is the recorded result for that commit, so let it finish.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
test:
name: "Build (${{ matrix.os }})"
Expand Down
50 changes: 30 additions & 20 deletions .github/workflows/NativePipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ env:
# Pin the test project (mendix/Native-Mobile-Resources) to a SHA for reproducibility instead
# of tracking a moving `main`. Single source of truth — bump deliberately. The repo publishes
# no tags, so a commit SHA is the only stable ref. Override per run via the dispatch input.
NATIVE_MOBILE_RESOURCES_REF: ${{ github.event.inputs.test_project_ref || 'a915484ef2cfed403cfbdfa531638df3c46c9d00' }}
NATIVE_MOBILE_RESOURCES_REF: ${{ github.event.inputs.test_project_ref || '05255f5ad19de7b109f6a68088e4fb198447ed00' }}
permissions:
packages: write
jobs:
Expand Down Expand Up @@ -418,7 +418,7 @@ jobs:
runs-on: ubuntu-26.04
# Skip this job if we're using artifacts from a specific run; the native bundles now come
# from the project job (deploy + native-packager), so gate on project success.
if: ${{ github.event.inputs.LOCAL_TEST_ARTIFACT_RUN_ID == '' && always() && (needs.project.result == 'success') }}
if: ${{ github.event.inputs.LOCAL_TEST_ARTIFACT_RUN_ID == '' && !cancelled() && (needs.project.result == 'success') }}
steps:
- name: Debug branch value
run: echo "Using branch ${{ needs.determine-nt-version.outputs.nt_branch }}"
Expand Down Expand Up @@ -535,7 +535,7 @@ jobs:
runs-on: macos-26
# Skip this job if we're using artifacts from a specific run; the native bundles now come
# from the project job (deploy + native-packager), so gate on project success.
if: ${{ github.event.inputs.LOCAL_TEST_ARTIFACT_RUN_ID == '' && always() && (needs.project.result == 'success') }}
if: ${{ github.event.inputs.LOCAL_TEST_ARTIFACT_RUN_ID == '' && !cancelled() && (needs.project.result == 'success') }}
steps:
- name: "Check out Native Template for Native Components Test Project"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down Expand Up @@ -647,7 +647,7 @@ jobs:
android-widget-tests:
needs: [scope, mendix-version, project, android-app, android-avd-cache]
# Run if widgets need testing (widgets_to_test is not empty) and project succeeds
if: ${{ needs.scope.outputs.widgets_to_test != '[]' && always() && needs.project.result == 'success' && (needs.android-app.result == 'success' || needs.android-app.result == 'skipped') }}
if: ${{ needs.scope.outputs.widgets_to_test != '[]' && !cancelled() && needs.project.result == 'success' && (needs.android-app.result == 'success' || needs.android-app.result == 'skipped') }}
runs-on: ubuntu-26.04
# 30 min hard cap per widget shard. A healthy widget's flows run in ~30s each; the cap is a
# backstop for genuinely wedged shards. Bumped 20→30 after a run where shards were cancelled
Expand Down Expand Up @@ -731,10 +731,12 @@ jobs:
bash maestro/run_maestro_widget_tests.sh android "${{ matrix.widget }}" || [ "$UPDATE_BASELINES" = "true" ]

- name: Archive test results
# always(): the test step exits non-zero on a failing shard, which would otherwise
# !cancelled(): the test step exits non-zero on a failing shard, which would otherwise
# SKIP this step — i.e. we'd capture screenshots/logs/videos for green shards but NOT
# for the failing ones we actually need to debug. Run regardless of test outcome.
if: ${{ always() }}
# for the failing ones we actually need to debug. This covers pass AND fail, but
# unlike always() it is NOT exempt from cancellation, so a cancelled run stops here
# instead of first uploading five artifact sets per shard.
if: ${{ !cancelled() }}
uses: ./.github/actions/archive-test-results
with:
platform: android
Expand All @@ -743,15 +745,15 @@ jobs:
ios-widget-tests:
needs: [scope, mendix-version, project, ios-app]
# Run if widgets need testing (widgets_to_test is not empty) and project succeeds
if: ${{ needs.scope.outputs.widgets_to_test != '[]' && always() && needs.project.result == 'success' && (needs.ios-app.result == 'success' || needs.ios-app.result == 'skipped') }}
if: ${{ needs.scope.outputs.widgets_to_test != '[]' && !cancelled() && needs.project.result == 'success' && (needs.ios-app.result == 'success' || needs.ios-app.result == 'skipped') }}
runs-on: macos-26
# 30 min per widget shard, matching android-widget-tests. iOS needs the budget more than
# Android: sim cold-boot + Maestro driver startup + runtime-ready wait eat several minutes
# before any flow runs, so on a free macOS runner slow-but-healthy shards were getting
# truncated — at 20 min several shards (accordion, background-image, bottom-sheet,
# color-picker, safe-area-view) were cancelled mid-flow. If a shard still hits 30 the cause
# is real app instability, not the cap.
timeout-minutes: 35
timeout-minutes: 30
strategy:
max-parallel: 4
matrix:
Expand Down Expand Up @@ -803,8 +805,10 @@ jobs:
bash maestro/run_maestro_widget_tests.sh ios "${{ matrix.widget }}" || [ "$UPDATE_BASELINES" = "true" ]

- name: Archive test results
# always(): capture failure artifacts (the failing shard's test step exits non-zero).
if: ${{ always() }}
# !cancelled(): capture failure artifacts (the failing shard's test step exits
# non-zero), but do NOT hold a cancelled run open uploading artifacts nobody wants
# -- always() is exempt from cancellation, !cancelled() is not.
if: ${{ !cancelled() }}
uses: ./.github/actions/archive-test-results
with:
platform: ios
Expand All @@ -813,7 +817,7 @@ jobs:
android-js-tests:
needs: [scope, mendix-version, project, android-app, android-avd-cache]
# Run if JS actions changed and project succeeds and either android-app succeeds OR we're using custom artifacts (android-app was skipped)
if: ${{ needs.scope.outputs.js_actions_changed == 'true' && always() && needs.project.result == 'success' && (needs.android-app.result == 'success' || needs.android-app.result == 'skipped') }}
if: ${{ needs.scope.outputs.js_actions_changed == 'true' && !cancelled() && needs.project.result == 'success' && (needs.android-app.result == 'success' || needs.android-app.result == 'skipped') }}
runs-on: ubuntu-26.04
timeout-minutes: 90
steps:
Expand Down Expand Up @@ -883,8 +887,10 @@ jobs:
bash maestro/run_maestro_jsactions_tests.sh android || [ "$UPDATE_BASELINES" = "true" ]

- name: Archive test results
# always(): capture failure artifacts (the failing shard's test step exits non-zero).
if: ${{ always() }}
# !cancelled(): capture failure artifacts (the failing shard's test step exits
# non-zero), but do NOT hold a cancelled run open uploading artifacts nobody wants
# -- always() is exempt from cancellation, !cancelled() is not.
if: ${{ !cancelled() }}
uses: ./.github/actions/archive-test-results
with:
platform: android
Expand All @@ -893,7 +899,7 @@ jobs:
ios-js-tests:
needs: [scope, mendix-version, project, ios-app]
# Run if JS actions changed and project succeeds and either ios-app succeeds OR we're using custom artifacts (ios-app was skipped)
if: ${{ needs.scope.outputs.js_actions_changed == 'true' && always() && needs.project.result == 'success' && (needs.ios-app.result == 'success' || needs.ios-app.result == 'skipped') }}
if: ${{ needs.scope.outputs.js_actions_changed == 'true' && !cancelled() && needs.project.result == 'success' && (needs.ios-app.result == 'success' || needs.ios-app.result == 'skipped') }}
runs-on: macos-26
timeout-minutes: 90
steps:
Expand Down Expand Up @@ -941,8 +947,10 @@ jobs:
bash maestro/run_maestro_jsactions_tests.sh ios || [ "$UPDATE_BASELINES" = "true" ]

- name: Archive test results
# always(): capture failure artifacts (the failing shard's test step exits non-zero).
if: ${{ always() }}
# !cancelled(): capture failure artifacts (the failing shard's test step exits
# non-zero), but do NOT hold a cancelled run open uploading artifacts nobody wants
# -- always() is exempt from cancellation, !cancelled() is not.
if: ${{ !cancelled() }}
uses: ./.github/actions/archive-test-results
with:
platform: ios
Expand All @@ -954,9 +962,11 @@ jobs:
# sources after a successful merge, leaving just the combined artifacts.
aggregate-test-results:
needs: [android-widget-tests, ios-widget-tests, android-js-tests, ios-js-tests]
# Run even when shards fail/cancel — the failing shards are exactly the ones whose
# screenshots/logs we want to inspect.
if: ${{ always() }}
# Run even when shards fail or time out — those are exactly the ones whose
# screenshots/logs we want to inspect. !cancelled() rather than always() so an
# explicitly cancelled run isn't held open merging artifacts nobody asked for
# (a shard that hits timeout-minutes does not make cancelled() true).
if: ${{ !cancelled() }}
runs-on: ubuntu-26.04
# The workflow-level block grants only packages:write, so every other scope defaults to
# none. This job needs contents:read (to check out the triage script) and actions:read
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/Release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ on:
pull_request:
branches: [main, 'mx/11.12.x']

# Supersede in-flight runs: a new push to the same PR/branch cancels the previous run
# instead of queueing beside it (this repo shares GitHub Free's 20-concurrent-job cap with
# NativePipeline, so stale runs directly delay the ones that matter). Only PR runs are
# cancelled — a push run on main/mx-* is the recorded result for that commit, so let it finish.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
test:
name: "Release (${{ matrix.os }})"
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/ShaCheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ on:
paths:
- ".github/workflows/*.yml"

# Supersede in-flight runs: a new push to the same PR/branch cancels the previous run
# instead of queueing beside it (this repo shares GitHub Free's 20-concurrent-job cap with
# NativePipeline, so stale runs directly delay the ones that matter). Only PR runs are
# cancelled — a push run on main/mx-* is the recorded result for that commit, so let it finish.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
harden_security:
name: Check SHA in GH Actions
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/UnitTests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ on:
pull_request:
branches: [main, 'mx/11.12.x']

# Supersede in-flight runs: a new push to the same PR/branch cancels the previous run
# instead of queueing beside it (this repo shares GitHub Free's 20-concurrent-job cap with
# NativePipeline, so stale runs directly delay the ones that matter). Only PR runs are
# cancelled — a push run on main/mx-* is the recorded result for that commit, so let it finish.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
test:
name: "Unit tests"
Expand Down
57 changes: 53 additions & 4 deletions maestro/helpers/helpers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,62 @@ stop_recording() {
REC_FILE=""
}

# Function to clean up stale XCTest processes (iOS only)
cleanup_xctest_processes() {
if [ "$PLATFORM" != "ios" ]; then
return 0
fi
echo "🧹 Cleaning up stale XCTest processes..."
# Kill any leftover XCTest runner processes that might be blocking driver initialization
pkill -9 -f "XCTRunner" 2>/dev/null || true
pkill -9 -f "xctest" 2>/dev/null || true
pkill -9 -f "maestro.*driver" 2>/dev/null || true
sleep 2
}

# Function to restart the iOS simulator
#
# This is the recovery path taken when the Maestro/XCTest driver wedges ("iOS driver not
# ready in time"), so it has to ACTUALLY restart the device. It previously did not: the
# shutdown result was discarded with `|| true`, and the following prepare_ios.sh reported
#
# Monitoring boot status for iPhone 17 Pro (...).
# Device already booted, nothing to do.
#
# i.e. the device never went down and the shard only got an app reinstall on top of the
# same wedged runtime. So we now verify the device reaches Shutdown and say so loudly if
# it doesn't, rather than silently continuing with a half-reset simulator.
restart_simulator() {
echo "🔄 Restarting iOS Simulator..."
# Shut down whatever is booted; the device is auto-selected in prepare_ios.sh,
# so we don't depend on a hardcoded device name here.
xcrun simctl shutdown all || true
sleep 10
# Kill the XCTest runner FIRST: while it holds the device, shutdown can fail.
cleanup_xctest_processes

# Target the device prepare_ios.sh selected when we know it; `all` otherwise (first
# call in a shard, where SIMULATOR_DEVICE_ID isn't exported into this shell yet).
local target="${SIMULATOR_DEVICE_ID:-all}"
echo "Shutting down simulator ($target)..."
if ! xcrun simctl shutdown "$target" 2>&1; then
# Already-shutdown is a benign error, so don't fail here — the poll below is what
# decides whether the device is actually down.
echo "shutdown reported an error; verifying device state anyway"
fi

# `simctl shutdown <udid>` is synchronous (measured ~3s), but poll rather than trust
# it: this replaces a blind `sleep 10` that padded every retry whether or not it was
# needed. Bounded so a stuck device surfaces as a warning instead of hanging.
local deadline=$((SECONDS + 30))
while [ "$SECONDS" -lt "$deadline" ]; do
if [ -n "${SIMULATOR_DEVICE_ID:-}" ]; then
xcrun simctl list devices | grep -q "$SIMULATOR_DEVICE_ID.*Shutdown" && break
else
xcrun simctl list devices | grep -q "(Booted)" || break
fi
sleep 1
done
if [ "$SECONDS" -ge "$deadline" ]; then
echo "::warning::Simulator did not reach Shutdown within 30s; the driver may still be wedged after this restart"
fi

bash ./maestro/helpers/prepare_ios.sh
}

Expand Down
29 changes: 29 additions & 0 deletions maestro/helpers/prepare_ios.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,34 @@ elif best:
xcrun simctl bootstatus "$DEVICE_ID" -b || echo "Simulator booted"
}

# Cut UIKit animation time, mirroring `disable-animations: true` on the Android emulator
# (NativePipeline.yml) — iOS previously had no equivalent, so every navigation push/modal
# played at full length on a 3-vCPU runner while Android's ran instantly.
#
# Scope: this shortens SYSTEM transitions (nav pushes, modal presents, alerts). React
# Native's `Animated` does NOT read this flag automatically, so widget-internal animations
# (carousel, animation-native) are unaffected — this is not a substitute for the per-flow
# waits those need.
#
# Requires a BOOTED device (`simctl spawn`), so call after start_simulator. The value is
# written to the device's persistent domain and survives shutdown/boot, so a
# restart_simulator in helpers.sh keeps it.
reduce_motion() {
echo "Reducing motion on iOS Simulator..."
if [ -z "$SIMULATOR_DEVICE_ID" ]; then
echo "Error: SIMULATOR_DEVICE_ID not set"
return 1
fi
# Best-effort: a future runtime that drops/renames these keys should not fail the shard,
# since tests still pass with animations on (just slower).
xcrun simctl spawn "$SIMULATOR_DEVICE_ID" defaults write com.apple.Accessibility ReduceMotionEnabled -bool true \
|| echo "::warning::Could not set ReduceMotionEnabled; animations stay at full length"
# Replaces the sliding push/pop transition with a cross-fade — the slide is what Maestro
# most often races against when asserting right after a navigation tap.
xcrun simctl spawn "$SIMULATOR_DEVICE_ID" defaults write com.apple.Accessibility ReduceMotionReduceSlideTransitionsEnabled -bool true \
|| echo "::warning::Could not set ReduceMotionReduceSlideTransitionsEnabled"
}

set_status_bar() {
echo "Setting status bar on iOS Simulator..."
if [ -z "$SIMULATOR_DEVICE_ID" ]; then
Expand Down Expand Up @@ -92,6 +120,7 @@ verify_installed_app() {
}

start_simulator
reduce_motion
set_status_bar
install_ios_app
verify_installed_app
Binary file modified maestro/images/expected/android/bg_image_dynamic_svg.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file removed maestro/maestro/images/actual/ios/bar_chart.png
Binary file not shown.
Binary file removed maestro/maestro/images/actual/ios/clipboard.png
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
10 changes: 10 additions & 0 deletions maestro/run_maestro_jsactions_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ run_jsactions_tests() {
fi
}

# Clean up any stale XCTest processes before starting (iOS only)
if [ "$PLATFORM" == "ios" ]; then
cleanup_xctest_processes
fi

# Fast-fail smoke check before running jsActions tests (same as widget tests)
if ! smoke_check; then
exit 1
fi

# Run jsActions tests
run_jsactions_tests
if [ $? -ne 0 ]; then
Expand Down
Loading
Loading