E2e pipeline overhaul#529
Merged
Merged
Conversation
…o+pnpm caching - Add a concurrency group so a new push cancels superseded PR/branch runs - Standardize all Linux jobs on ubuntu-26.04 and macOS jobs on macos-26 - Route the resources job through setup-node-with-cache (adds the pnpm store cache it was missing) - Pin Maestro to cli-2.6.1 (supports assertScreenshot) and fix the cache path ($HOME -> ~, which actions/cache never expanded) so it actually hits
Replace the hand-rolled Mendix runtime setup on the test jobs with Mendix Portable App Distribution (validated against the test project on 11.11.0). - project job: also build `mxbuild --target=portable-app-package` and upload it as the `app-package` artifact (output kept inside the project dir to satisfy mxbuild's path whitelist). MDA still produced for the bundle jobs. - new start-mendix-runtime composite: extract the package, start `bin/start` in the background, and poll a real readiness probe (HTTP) with a bounded timeout, replacing the fake `$?` health check and the nick-fields/retry wrapper. - all 4 test jobs now consume app-package + the composite instead of the MDA. - delete setup-runtime.sh, start-runtime-with-verification.sh and configs/e2e/m2ee-native.yml; drop the runtime-tarball and m2ee-tools caches from setup-tools (the package bundles the runtime, no CDN download / m2ee clone). Needs a CI shakeout run to confirm the backgrounded runtime survives across steps and the emulator/simulator reaches it.
…R namespace
Use ghcr.io/${{ github.repository }}/mxbuild instead of the hardcoded
ghcr.io/mendix/native-widgets/mxbuild. Identical to the previous value on
upstream, but lets a fork build/push/pull the image in its own namespace so
the pipeline is self-sufficient when run from a fork.
The Android app build let gradle download/install the NDK (~1GB) on every run. Install it explicitly via sdkmanager in a dedicated step and cache it by version, so subsequent runs restore from cache instead of re-downloading from Google. The NDK version is a job-level env var that must track native-template/React Native.
Read ndkVersion from the checked-out native-template/android/build.gradle instead of hardcoding it, so the cached/installed NDK always matches the resolved native-template ref (different branches/tags pin different NDK versions). If detection fails, the cache/install steps no-op and gradle resolves the NDK as before.
…r ABI only The CI emulator is x86_64, but the gradle build compiled native C/C++ for all four ABIs (arm64-v8a, armeabi-v7a, x86, x86_64). Pass -PreactNativeArchitectures=x86_64 to build just the one we run on, cutting the native build by ~75%.
setup-tools installed Python only for the old m2ee-based runtime, which the portable app package replaced (it starts via bin/start on the JVM). Remove it to trim test-job setup time.
… device/version prepare_ios.sh hardcoded "iPhone 16" / iOS 18.5, which don't exist on macos-26 (every macOS image bump broke it). Parse `simctl list -j` and pick the available iPhone with the highest (iOS runtime, model) — e.g. iPhone 17 / iOS 26 today. restart_simulator now shuts down whatever is booted rather than a hardcoded name, and the now-unused IOS_DEVICE vars are removed.
Add an android-avd-cache job that boots the emulator once and caches the AVD, then have the per-widget Android test shards restore it and boot from snapshot (force-avd-creation: false, -no-snapshot-save) instead of cold-booting every shard (~1-2 min each). Keeps the matrix parallelism; the APK is still a quick per-shard install. On cache miss shards fall back to a cold boot, so a failure in the cache job doesn't block tests.
…t licenses safely sdkmanager is not on PATH on the runner (it lives under the SDK cmdline-tools), so the install step failed with "command not found". Resolve it from ANDROID_HOME/cmdline-tools, with fallbacks. Also accept licenses in a separate step tolerant of `yes` SIGPIPE under pipefail, then install without piping `yes`.
…oaPods downloads The iOS build forced VALID_ARCHS="i386 x86_64", which is wrong on the arm64 macos-26 runners (i386 is dead, x86_64 mismatches the arm64 simulator) and made xcodebuild compile foreign archs. Drop it and rely on ONLY_ACTIVE_ARCH=YES to build the simulator's native arch (arm64), matching the arm64 iPhone 17 sim. Also cache ~/Library/Caches/CocoaPods so `pod install` is fast even when the Pods/ cache misses.
…est jobs The iOS widget/js test jobs began with "Force cleanup workspace" / "Clean up any Git locks" steps that sudo-pkill git/node/xcodebuild and `rm -rf .git` before checkout. That's cargo-cult from self-hosted runners; GitHub-hosted macOS runners are ephemeral and start clean, so these steps are pointless and risky. Remove them.
…estro analytics - Android test shards now use actions/cache/restore for the AVD snapshot; the android-avd-cache job is the sole writer, so shards no longer each attempt to re-save the same key. - Set MAESTRO_CLI_NO_ANALYTICS to skip Maestro's anonymous analytics network call.
…udor/action-zip - Lower the Android/iOS widget-test matrices from max-parallel 5 to 4 to ease runner load. - Replace the third-party montudor/action-zip Docker action with native `unzip` (apt-installed alongside curl) in the project job — removes a supply-chain dependency. Also drop the blanket `apt upgrade -y` (slow/nondeterministic; we only need curl + unzip).
…PTY flake) The maestro test jobs ran `pnpm install` via setup-node-with-cache, which triggers the root `prepare` lifecycle (building tool packages like piw-utils-internal). That flaked with "ENOTEMPTY: rmdir dist/utils" and failed shards before any test ran — and it was never needed: maestro is a standalone binary, so the test jobs need only Java + Maestro. Removing it fixes the flake class and speeds up every test shard.
…meout Broken widget tests took ~25 min because each failing test ran up to 4 times (1 initial in run_tests + 3 in the rerun loop), and each attempt could stall on a 5-min MAESTRO_DRIVER_STARTUP_TIMEOUT before maestro gave up (S6: retry amplification). - MAX_RETRIES 3 -> 1 (2 attempts total, standard flake absorption) - driver startup timeout 300000 -> 120000ms (maestro default is 15s) - hoist the timeout to a single var instead of duplicating the literal Worst-case broken-build time ~25 min -> ~5 min; healthy tests unchanged.
…Phase 4) Visual regression now runs inside each Maestro flow via the built-in assertScreenshot, so each widget's own shard owns its visual result — no decoupled compare-screenshots gate and no "gh run download || true" gaps that could silently skip a widget. - 52 flows: add assertScreenshot (default 95% threshold, full screen) right after each takeScreenshot, comparing against the committed maestro/images/expected/<platform>/<name>.png baseline. - Delete the compare-screenshots job, maestro/helpers/compare_screenshots.js, and the pixelmatch/pngjs/@actions/core devDeps. - New update_baselines dispatch input -> UPDATE_BASELINES env; the 4 run steps append '|| [ "$UPDATE_BASELINES" = true ]' so a capture run can produce fresh baselines without going red. archive-test-results already uploads *-screenshots-* (if: always()) as the re-baseline source. Maestro assertScreenshot has no region masking (only cropOn + a whole-screen threshold); the old 2% pixel-diff masked the status bar / home indicator. The Android status bar is already normalized in set_status_bar, and the default 5% tolerance covers those small regions. Follow-ups (not in this commit): capture+commit fresh baselines for both platforms via update_baselines=true (iOS mandatory after the device change); refresh pnpm-lock.yaml for the removed deps.
The pixelmatch/pngjs/@actions/core deps are now unused (compare_screenshots.js was deleted in the assertScreenshot migration), but CI installs with pnpm install --frozen-lockfile, so removing them from package.json without regenerating pnpm-lock.yaml breaks the resources job. Keep them for now; drop them with the next lockfile refresh.
Downloads the *-screenshots-* artifacts from a capture run (one triggered
with update_baselines=true) and drops them into maestro/images/expected/
{android,ios}/ keyed by basename, ready to review + commit.
…e jobs (Phase 2 pt2)
The project job now runs 'mxbuild --target=deploy --native-packager' to emit
the Android/iOS JS bundles (deployment/native/bundle/{android,iOS}) in
native-template-ready layout, alongside the existing portable-app-package run.
- Delete the android-bundle/ios-bundle jobs and the create-native-bundle action,
which called mxbuild's internal @react-native-community/cli by hand from
/tmp/mxbuild/modeler/tools/node/... (M5 — undocumented internals, brittle).
- Drop the now-unused 'mda' build + artifact (only create-native-bundle consumed it;
test jobs use the portable app package).
- android-app/ios-app: needs [project, determine-nt-version], gate on project success;
copy steps use 'cp -r bundles/<plat>/*' since the native-packager output is pre-split
(android: assets/ + res/) per the official manual-build doc.
Same mxbuild compile-pass count as before (2), minus two jobs and a second
test-project download. mxbuild command + layout validated in Docker; the CI
app-build integration is validated by the next run.
prepare_android.sh: replace the blind 'sleep 30' with adb wait-for-device + a bounded poll on sys.boot_completed and package-manager readiness, so the shard proceeds the moment the emulator is up and fails fast (300s) if it never boots instead of always paying 30s. prepare_ios.sh: screenshot baselines are device/resolution-specific, so prefer a pinned device (PREFERRED_IOS_DEVICE, default 'iPhone 17 Pro') for reproducible visual regression; if it's not on the runner image, fall back to the newest available iPhone and emit a ::warning:: to regenerate baselines — graceful degradation instead of the old hardcoded hard-fail, without the silent drift of pure newest-detection. Also fixes a latent parse bug: device names contain spaces, so switch the simctl selector to tab-delimited output.
…scoping (S7) The resources job rebuilt every scoped widget in a sequential bash loop with no cross-run caching. Now: - determine-widget-scope.sh emits a full_build flag (true when every widget is built — infra-only/js-only/*-native/schedule; false for a specific subset). - resources job restores a content-hashed dist cache (keyed on widget/JS-action src + package.json + pnpm-lock.yaml). On a full build with an exact hit (e.g. an infra-only PR), the rebuild is skipped entirely. - Cache save is gated to full builds only, so a partial build can't populate the shared cache with an incomplete dist set under a key a later full build hits. - The build itself now runs as one 'pnpm --filter=… --filter=…' invocation (concurrent) instead of one pnpm call per widget. Held: full dorny/paths-filter scope rewrite (R6) remains a separate item.
The capture run's android-app/ios-app failed downloading android-bundle/
ios-bundle: the project job produced no bundle artifacts. Root cause —
ordering: 'deploy --native-packager' wrote deployment/native/bundle/{android,iOS}
first, then 'portable-app-package' ran and cleaned the deployment/ directory,
wiping the bundles before upload (uploads then warned 'no files found' and the
job stayed green, so apps failed later).
Fix: build portable-app-package FIRST (app.zip lives outside deployment/, so the
later deploy clean doesn't touch it), then deploy --native-packager LAST so
nothing cleans deployment/ afterward. Add if-no-files-found: error to the bundle
uploads so a missing/incorrect path fails the project job fast instead of
silently, plus an ls/find of the bundle output for diagnostics.
Re-capture on the pinned iPhone 17 Pro (iOS) and android emulator. Drop 4 orphaned baselines no current flow emits (activity_indicator x2, SafeAreaViewMaps, accordion_on_change). iOS background-image-native / popup-menu-native baselines retained from prior run (their capture jobs timed out); to be re-captured.
…dget-test cap - cores: 2 as an action input. The -cores 4 emulator-option was silently overridden by config.ini hw.cpu.ncore; free runners have 4 vCPU, so leave 2 for the host (adb/Maestro/bundler). - Android widget-test matrix max-parallel 4->5 (GitHub Free 20-total job cap; iOS stays 4 under the macOS-5 concurrent cap). - Widget-test timeout-minutes 60->15 on both platforms — backstop for the fast-fail smoke check so a hung widget can't grind to the old 60-min wall.
…n (S6) - New Smoke.yaml + smoke_check() run once per shard before any flows; a broken build fails in ~1 min instead of grinding every flow x retries. - Precondition.yaml extendedWaitUntil 240s->60s and maxRetries 3->2 (was up to 12 min PER flow — the real amplifier behind 60-min broken shards). - prepare_ios.sh: export PREFERRED_IOS_DEVICE so the piped python3 inherits it (a VAR=x cmd | python prefix only applies to cmd), fixing the always-fallback device selection.
…eanup - Android: add --build-cache to gradlew so RN libs' native compiles are reused via Gradle's local build cache (~/.gradle/caches/build-cache-1, already persisted by setup-java's cache: gradle). - iOS: add COMPILATION_CACHE_ENABLE_CACHING=YES to xcodebuild → Xcode 26 LLVM-CAS compilation cache. It lives under the build/ derivedDataPath, already persisted by the existing 'Cache iOS Build' step, and survives clean (unlike DerivedData product reuse). Requires Xcode 26 (macos-26 ships it). - Quote $GITHUB_OUTPUT / $GITHUB_ENV redirect targets and the mpk-move paths (shellcheck SC2086) across the version/scope/mpk run blocks. Caches are no-ops on miss; a 2-run validation is needed to confirm 2nd-run hits.
Fresh popup_menu_alert.png (android + iOS iPhone 17 Pro) from a scoped update_baselines capture. background-image-native baselines deferred — its capture wedges the app mid-suite (likely a widget issue, under investigation).
…ck retry - widget-test timeout 15->20 (iOS sim/driver/runtime startup overhead was truncating slow-but-healthy shards at 15; fast-fail intent preserved) - aggregate-test-results job merges per-shard screenshots/logs/artifacts into single all-screenshots / all-runtime-logs / all-debug-artifacts downloads - smoke_check() retries once with a driver/sim reset to absorb transient Maestro XCUITest attach flakes without re-opening the runaway-hang window
…dd result summary Phase 5 (D3/M4) — single toolchain: - port determine-nt-version.py -> determine-nt-version.mjs (global fetch + a small dotted-version compare; no external deps), invoked via the runner's preinstalled node - delete the dead setup-python composite action and the inline 'pip install requests packaging' - fail loudly (exit 1) instead of the silent 'nt_branch=master' fallback when the Mendix version matches no range / the releases API call fails Phase 4 cleanup + observability: - remove unused pixelmatch / pngjs / @actions/core deps (dead since the assertScreenshot migration) and refresh pnpm-lock.yaml - aggregate-test-results now writes a consolidated pass/fail table per shard to $GITHUB_STEP_SUMMARY
Record a screen video around every Maestro flow, keep it only when the flow fails (delete on pass) so artifact storage holds just targeted clips of the failing interaction. iOS uses `simctl io booted recordVideo` (SIGINT-finalized, no length cap); Android uses `adb shell screenrecord --time-limit 180` started after `adb root` so adbd restarts don't kill it. Smoke-check attempts are recorded too. Toggle with RECORD_VIDEO=false. archive-test-results uploads `*-videos-*`; aggregate-test-results merges them into a single all-failure-videos artifact. Also fix a latent bug: the four "Archive test results" steps lacked `if: always()`, so a failing shard (run script exits non-zero) SKIPPED the archive step -- screenshots/logs were captured only for green shards, never the failing ones. Add `if: always()` to all four so failure artifacts actually upload.
Split the BUILD scope from the TEST scope in determine-widget-scope.sh. A change confined to a widget's e2e/ folder (Maestro flows + screenshots) changes only the test, not the built artifact, so it no longer triggers a rebuild of that widget -- the widget is still added to the test matrix and runs against the test project's baseline .mpk, exactly like every other not-rebuilt widget in a partial run. The build set can now be empty (e.g. an e2e/infra-only PR), so add a resources-manifest.txt step + include it in both resources upload paths to keep the artifact non-empty -- otherwise the project job's download would fail.
Remove COMPILATION_CACHE_ENABLE_CACHING=YES from the iOS xcodebuild command. It was added speculatively (its own commit noted a 2-run validation was still needed) and never paid off: step timings show the build itself is ~95% of the ios-app job and varies ~9 min run-to-run independent of this flag, while cross-run DerivedData reuse on ephemeral runners is unreliable -- so CAS mostly adds compile-unit hashing/write overhead with no reuse benefit. The DerivedData and Pods caches are kept (their restore/save overhead is ~1 min total).
…o 30m The Android per-flow video recording left the on-device `screenrecord` running: stop_recording only killed the local `adb shell` client, so the recorder kept going until its --time-limit (180s) and `wait` blocked ~2.5 min PER FLOW. That silently added ~15 min to multi-flow shards and pushed them past the 20-min cap (mass "cancelled" widget shards). Fix: `adb shell pkill -INT screenrecord` to make the recorder finalize the mp4 promptly, then a bounded client kill so we never block on the time-limit again. Also raise the android-widget-tests cap 20→30 min as a hard backstop. With the recording bug fixed a healthy shard finishes far under this; hitting 30 now means real app instability, not cap tightness.
…conditionally - Pin the mendix/Native-Mobile-Resources download to a commit SHA (NATIVE_MOBILE_RESOURCES_REF, single source of truth, overridable via a new test_project_ref dispatch input) instead of tracking a moving main; rename the ref-named archive dir back to Native-Mobile-Resources-main so downstream paths are unchanged. The repo publishes no tags, so a SHA is the only stable ref. - Split "Move widgets": the mpk overlay stays guarded, but `mx update-widgets` now runs unconditionally so a js-actions dispatch (or any run that builds no mpks) no longer leaves stale Atlas widget defs for the portable-app build.
setup-tools no longer references mendix_version (the runtime-tarball + m2ee caches that consumed it were removed when the portable app package replaced the hand-rolled runtime). Remove the input declaration and the 4 call-site with: lines.
The scope job checked out with fetch-depth: 2 and passed github.event.before -- which is empty on pull_request events -- so the script fell back to `git diff HEAD~1`, scoping a multi-commit PR off only its last commit (widgets changed in earlier commits were silently dropped from build + test). Check out full history (fetch-depth: 0) and pass the PR base SHA (github.event.pull_request.base.sha); the script now diffs against the merge-base with the base branch, covering every commit in the PR. Kept the per-widget classifier and the dispatch path unchanged -- no new third-party action needed.
- determine-nt-version.mjs (Python toolchain removed) - build-vs-test scope split (e2e-only changes test but don't build) + full_build output - PR scoping now diffs against the base merge-base
…ial license The portable-app runtime runs under a developer/trial license limited to 6 concurrent named users. Every Maestro flow does `launchApp clearState:true`, which clears device state but opens a FRESH server session that lingers for the default 10-min SessionTimeout. Widgets whose flow count (+smoke launch +retries) exceeds 6 therefore wedge mid-suite on the "Maximum number of sessions exceeded" screen — Precondition can't find the Widgets menu and the shard fails. This hit background-image (8 flows), safe-area-view (7), accordion (6), and the js-tests job (mobile-resources 5 + nanoflow 3 = 8); every <=5-flow widget stays green. Fast-fail un-masked this rather than causing it: the old 4x-retry/long-timeout config spread a shard's launches past the 10-min window, so early sessions expired and freed slots before later retries ran (eventual green, just slow). start-mendix-runtime now shortens the server session idle timeout (RUNTIME_PARAMS_SESSIONTIMEOUT=60000, via a tunable session-timeout-ms input) and reaps expired sessions promptly (RUNTIME_PARAMS_CLUSTERMANAGERACTIONINTERVAL =30000), both ridden through the portable app's existing HOCON env overrides. At ~30s/flow this holds peak concurrency to ~2-3, well under 6, with no test changes. Confirmed against run 28168923730's safe-area-view runtime log. Also reword script/action/workflow comments and the scripts README to describe the current design instead of narrating what it replaced.
… bump iOS cap to 30m
NikolaSimsic
approved these changes
Jun 26, 2026
MxKevinBeqo
approved these changes
Jun 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A structural rework of
.github/workflows/NativePipeline.ymland its Maestro testharness. The goal is to make the native end-to-end suite fast, reproducible, and
honest — today it is slow and fails the majority of runs, mostly for infrastructure
reasons that mask the handful of real widget problems underneath.
This document walks reviewers through the most impactful changes, in rough order of
impact, with representative diffs. Per-flow failure-video recording (§9) earns its
keep: it's what turned an opaque "couldn't find element" failure into a visible diagnosis
of the trial-license session cap described in § What this surfaced.
It is no longer treated as optional.
TL;DR for reviewers
mxbuild --native-packagerassertScreenshot)distcache, Gradle/Pods/NDK caches.concurrency.$GITHUB_STEP_SUMMARYpass/fail table.master.Please also read § What this surfaced and
§ Known limitations before reviewing — they explain why some
runs are still red and what is deliberately left for follow-up.
1. Self-contained runtime via Portable App Distribution (PAD)
Before: the test job assembled a runtime by hand — cloning
m2ee-tools,downloading the runtime from a CDN, and using a config file + a health check that
didn't actually confirm the runtime was serving. Fragile and version-drift-prone.
After: the build stage produces a self-contained portable app package
(
mxbuild --target=portable-app-package→app.zip, which bundles the Java runtimeand a
bin/start). The test job just extracts it, starts it, and polls until ittruly serves HTTP 200 before any test runs.
.github/scripts/setup-runtime.sh | deleted .github/scripts/start-runtime-with-verification.sh | deleted configs/e2e/m2ee-native.yml | deleted +.github/actions/start-mendix-runtime/action.yaml | addedNew runtime action (the real readiness gate, abridged):
Built once in the
projectjob and shared as theapp-packageartifact:2. Native bundling via
mxbuild --native-packagerPAD ships the server runtime, not the RN JS bundle. Previously two near-identical
android-bundle/ios-bundlejobs (plus acreate-native-bundlecomposite) producedthose bundles via an internal-tools hack. This replaces them with the supported
mxbuild --target=deploy --native-packagertarget, run in the build stage.3. Visual regression moved into Maestro (
assertScreenshot)Before: flows only did
takeScreenshot; a separatecompare-screenshotsjob laterdiffed PNGs with a custom
pixelmatchscript (maestro/helpers/compare_screenshots.js).A decoupled gate with
gh run download || truegaps and three extra npm deps.After: each flow asserts visually in-line with Maestro's built-in
assertScreenshot(default 95% threshold). The widget's own shard owns its visualresult — no separate job, no extra deps.
- takeScreenshot: path: "maestro/images/actual/${PLATFORM}/animation" +- assertScreenshot: + path: "maestro/images/expected/${PLATFORM}/animation.png"This one-line addition is repeated across 52 flows — that's the bulk of the file count.
4. Fast-fail test policy (the biggest runtime/stability lever)
The old harness amplified every failure: up to 4 attempts per flow (initial + 3
retries) × a 5-minute driver-startup timeout × 240s × 3 precondition waits, all
under a 60-minute job cap. A broken build could grind for ~25–60 minutes before failing.
Changes:
MAX_RETRIES3 → 1, driver-startup timeout 300s → 120s.Preconditionwaits trimmed (240s×3 → 60s×2).timeout-minutes60 → 20 (iOS) / 30 (Android) — backstop only.Smoke.yaml): launch the app and confirm the Widgetsmenu renders once before running any flow. A fundamentally broken build now fails
in ~1 min instead of grinding every flow.
Net effect: worst-case broken shard ~25 min → ~1–5 min, and healthy shards finish well
under the cap.
5. Caching & snapshots
Several independent caches, each keyed on the inputs that actually invalidate it:
android-avd-cachejob is the solewriter; test shards restore read-only (no thundering-herd re-saves).
distcache — on a full build (full_build=true: infra-onlyPRs, JS-action-only changes, or a full dispatch) an exact source-hash hit lets the job
skip the rebuild entirely. On a partial (widget) PR the job does a fast scoped build
of just the changed widgets via
pnpm --filterinstead.e2e/changes — the test scope and the build scope are nowseparate. A change confined to a widget's
e2e/folder (Maestro flows / screenshots)changes only the test, not the built artifact, so it no longer rebuilds the
widget — the widget is still added to the test matrix and runs against the test
project's baseline
.mpk(exactly like every other not-rebuilt widget in a partialrun). Concretely, this very PR (mostly
e2e/*.yaml+ infra) drops from "rebuild all 20widgets" to "rebuild 0". A safety-net manifest keeps the
resourcesartifact non-emptywhen a run builds nothing, so the
projectdownload never fails.restore-keysfallback.6. Reproducibility & robustness
prepare_ios.shprefers a pinned device(
PREFERRED_IOS_DEVICE, defaultiPhone 17 Pro— whatmacos-26ships and baselinesare captured on) for reproducible screenshots; falls back to the newest available
device with a loud
::warning::if absent. Avoids both the old hardcoded hard-failand silent device drift.
sleep 30with a state-based wait(
adb wait-for-device→sys.boot_completed→ package manager ready, 300s cap).mendix/Native-Mobile-Resourcesis downloaded at a pinnedcommit SHA (
NATIVE_MOBILE_RESOURCES_REF, overridable via thetest_project_refdispatch input) instead of a moving
main, so a build is reproducible.merge-base with the PR base branch, so every commit in a multi-commit PR is
considered (it previously diffed only
HEAD~1via an emptygithub.event.before,silently under-scoping). No third-party action added — done in the existing script.
concurrencyblock to cancel superseded runs.7. Observability
aggregate-test-resultsjob merges the dozens of per-shardoutputs into a handful —
all-screenshots,all-runtime-logs,all-debug-artifacts— so reviewers download once instead of per-shard.
$GITHUB_STEP_SUMMARY: the same job writes a consolidated per-shard pass/failtable (read back via
gh api --paginateover the run's jobs) to the run Summary tab.Archive test resultssteps weremissing
if: always(), so a failing shard (run script exits non-zero) skippedarchiving — meaning screenshots/logs were captured only for green shards. Fixed.
8. Tooling cleanup — drop Python, fail loudly
determine-nt-version.py→determine-nt-version.mjs(globalfetch+ a tinyversion comparator, no deps, runs on the runner's preinstalled Node). Deleted the
dead
setup-pythonaction and inlinepip install. Python is now fully gone.nt_branch=master(an unpinnedmoving target) on no-match or a transient API blip. It now exits 1 loudly instead.
9. Per-flow failure videos
BrowserStack-style debugging: record a screen video around every flow, keep it
only if the flow fails, delete it on pass. Each kept clip is a short, targeted
recording of exactly the failing interaction — far more useful than a "couldn't find
element" log line. Toggle off with
RECORD_VIDEO=false.xcrun simctl io booted recordVideo(SIGINT-finalized, no length cap).adb shell screenrecord --time-limit 180(3-min/segment cap). The recorder isstopped with
adb shell pkill -INT screenrecord(stopping only the localadbclientleaves the on-device recorder running to its time-limit — which earlier added
~2.5 min/flow and blew the shard timeout).
Merged into a single
all-failure-videosartifact alongside the others. It'sself-contained (one toggle, no coupling to the rest of the PR) — but on the strength of
the session-cap diagnosis it's a keep, not a cut.
What this surfaced (please read)
Tightening retries/timeouts and adding the smoke check did not introduce new
failures — it un-masked an existing one, and the root cause turned out to be the
Mendix trial license, not the widgets.
The runtime runs under a trial license capped at 6 concurrent named users. Every
Maestro flow does
launchApp clearState: true, which clears device state but starts afresh server-side session that lingers for the default 10-minute
SessionTimeout.A widget's flows therefore accumulate one session each. The residual failures cluster on
exactly the widgets whose flow count pushes a shard past 6 live sessions (smoke launch +
flows + retries):
background-image-nativesafe-area-view-nativeaccordion-nativeThe runtime log is unambiguous: after 6 sessions it logs
Maximum number of sessions exceeded! (You are currently using a trial license), the nextlaunchApplands on theuser-limit screen,
Preconditioncan't find the "Widgets" menu, and the shard"wedges" mid-suite. That is the same symptom previously mis-attributed to image-rendering
OOM / per-widget instability — it isn't widget-specific at all.
Why fast-fail exposed it: the old config's 4× retries and long timeouts stretched a
shard's flows past the 10-minute session window, so early sessions expired and freed
license slots before later retries ran — eventual green, just slow and noisy. Fast-fail
concentrates all 6–9 launches inside the 10-minute window, so every session is alive at
once and the cap trips. The speed-up didn't add instability; it removed the accidental
delay that was hiding a session-cap.
Fix: the
start-mendix-runtimeaction now shortens the server session idle timeout(
RUNTIME_PARAMS_SESSIONTIMEOUT=60000) and reaps expired sessions promptly(
RUNTIME_PARAMS_CLUSTERMANAGERACTIONINTERVAL=30000) via the portable app's HOCON envoverrides. At ~30s/flow, each flow's session expires before the next ones pile up, holding
peak concurrency to ~2–3 — comfortably under 6 — without slowing the suite or touching any
test. (Identified from run 28168923730's
safe-area-viewruntime log; pending a cleanvalidation run.)
Known limitations / deliberately deferred
background-image-nativebaselines couldnot be re-captured on earlier runs because its 8-flow shard tripped the trial-license
session cap (see § What this surfaced) and wedged mid-suite — the
capture run never produced clean screenshots. With the session-timeout fix these should
now be re-capturable; re-baselining is pending a clean validation run.
session-cap fixed this is far less pressing, but a bail-after-N-consecutive-precondition-
failures backstop is still worth adding for genuinely wedged apps.
screenrecordlimit) — long flows truncate;still useful, but not unbounded like iOS.
e2e/-only change no longer rebuilds its widget (see §5), that widget's e2e runsagainst the test project's committed baseline
.mpk. This matches how partial buildsalready treat every non-rebuilt widget, and is correct when the repo's widget source
equals the released baseline; a widget with unreleased source changes on
mainand atest-only PR would be tested against the older released widget. (The same
e2e/exclusion is not applied to JS-action packages — their
js_actions_changedflagdrives both build and test, and they're only two cheap packages.)
4 near-identical test jobs into a reusable workflow; per-PR-branch version overrides;
more aggressive Gradle native-build caching (
.cxx/externalNativeBuild).Validation status
assertScreenshot, fast-fail, caching, combined artifacts, step summary, Node port:exercised on dispatch/PR runs on the fork.