Closes manual release-test gaps for mobile (Detox + Maestro), automating new test cases and hardening flaky/CI-only failures uncovered along the way. ### New coverage automated - **MM-T416** offline/online post-retry (Android via airplane mode; iOS ultimately marked unsupported — see below) - **MM-T1194 / MM-T1193** custom ToS accept/decline survives relaunch - **MM-T3251** status change via account UI (cross-user visibility) - **MM-T510** channel-wide/keyword mention delivery — later expanded to a full 5-channel mention matrix with exact server-side counts - **MM-T3261 (_1/_2)** Report a Problem external hand-off (browser link mode, email mode) - **MM-T2940** iOS managed-config (MDM) pre-fill/autoconnect - **MM-T3271/3272** opening the app from a push notification (synthetic notification payloads, background/closed states) - Un-skipped several tests that turned out to already pass (stale skips) ### Root-caused failing tests - **Android SQLite corruption**: traced to native Kotlin DB connections not opening in WAL mode while JSI did (post #10109) — journal-mode mismatch corrupted the store on push-triggered writes. Fixed properly with a **WAL patch** (#10117) rather than the original destructive "wipe app database" workaround, which was reverted. - **Fabric crash** (`addViewAt`/reparent) on `SlideUpPanelItem` taps — root-caused to view flattening racing sheet mounts; fixed with `collapsable={false}` (went through a revert/re-revert once CI reproduced the crash without it). - **Search race**: stale in-flight search results could overwrite a cleared search box — fixed with request-id tagging. - **iOS offline detection**: multiple iterations (AAAA sampling → Cloudflare-prefix check → final decision) landed on refusing iOS offline simulation outright, since all E2E servers are Cloudflare-fronted/anycast and the address space can't be enumerated/blocked. - **MM-T3261 config patch** intermittently silently ignored by the server — root-caused to `ReportAProblemType` being environment-managed on PR Spinwicks (config PATCH returns 200 but is silently dropped); now detected and reported as a skip with proven cause, not a failure. - **MM-T416 failed-post timing**: multiple rounds of tightening the wait bound to match the client's actual retry/backoff/timeout ceiling (ended at ~150s, derived from OkHttp/retry-interceptor math, not guessed). - **MM-T5603 upload wait**: raised to match the client's real 180s upload timeout. - **Classification banner suite**: fixed to verify config state directly instead of trusting a PATCH reply that the cloud sometimes drops. ### CI/reporting infra fixes - Dedicated Maestro steps (MM-T3261, MM-T67856_4) weren't feeding into the merged JUnit report and couldn't gate pass/fail — fixed report merging/naming so they now count. - Android Maestro logcat capture added (previously iOS-only diagnostics). - Jest retry logic no longer discards passing results from a shard killed by timeout. - Sharding raised 24→28. - Removed dead Detox Pilot/Playbooks scaffolding (unused, uncovered by CI). ### Cleanup - Comment/docs pass stripping narrative session-archaeology from long explanatory comments, keeping only load-bearing facts and CI references.
17 KiB
Maestro E2E — Authoring Guidelines
Single source of truth for engineers, QA, and AI tools writing Maestro flows in this repository. For setup and CI wiring see README.md.
1. Scope: when Maestro vs Detox
| Use Maestro | Use Detox |
|---|---|
| Cross-app flows (share extension, Safari/Chrome hand-off) | In-app navigation and form interaction |
| OS permission dialogs (mic, camera, photos) | API-driven setup and DB assertions |
| Multi-device orchestration | Fast, JS-bridge-heavy regression |
| External browser / mail composer verification | Component-level unit tests (Jest) |
Do not justify a Maestro flow by referencing Detox limitations in flow comments — state the user-visible behavior under test.
2. Flow file header contract
Every file under detox/maestro/flows/**/*.yml must start with this block before tags: / appId::
# MM-TXXXX: <one-line behavior summary>
#
# PRE-CONDITIONS:
# - <server config, seed script, or device state required>
#
# REQUIRED ENV VARS:
# - SITE_1_URL, TEST_USER_EMAIL, TEST_USER_PASSWORD, TEST_CHANNEL_NAME
# - (list only vars this flow reads)
#
# ASSERTIONS:
# - <observable outcome 1>
# - <observable outcome 2>
#
# testIDs:
# - settings.report_problem.option
# - report_problem.screen
tags:
- MM-TXXXX
appId: ${MAESTRO_APP_ID}
---
Header rules
| Field | Required | Content |
|---|---|---|
| Ticket line | Yes | # MM-TXXXX: <behavior> — what the user sees, not how the app implements it |
| PRE-CONDITIONS | Yes | Server settings, seed steps, permissions, fixtures |
| REQUIRED ENV VARS | Yes | Only variables referenced in the flow |
| ASSERTIONS | Yes | Pass/fail outcomes in product language |
| testIDs | Yes | Every id: selector used in the flow (grep app/ for source of truth) |
tags: |
Yes | At least the Zephyr ticket id |
appId |
Yes | ${MAESTRO_APP_ID} |
Exempt from this contract (enforced by scripts/validate-flow-headers.sh, which scans only flows/** and skips _-prefixed basenames):
subflows/**— reusable fragments, not test cases._*.ymlanywhere — helper fragments invoked by another flow.preflight/**— environment gates run by CI before the reported suite (e.g.preflight/_connect_check.ymlverifies the device can reachSITE_1_URL). These have no Zephyr id because they assert no product behavior; a failure means the environment is broken, not that a test failed.
Exempt files still need appId: ${MAESTRO_APP_ID} and a comment block explaining what they do and why they exist.
Body comment markers — # # steps, # * assertions
Below the ---, every comment block that introduces one or more commands must start with a marker:
| Marker | Means | Use for |
|---|---|---|
# # |
Test step / action | tapOn, inputText, launchApp, runFlow, swipe, pressKey, runScript, waits that exist only to reach the next step |
# * |
Assertion / expected result | assertVisible, assertNotVisible, and extendedWaitUntil used to verify an outcome |
This mirrors the Detox convention (// # / // *, see detox/CLAUDE.md) so both suites map onto the same test management system.
# GOOD
# # Open Settings and reveal Report a Problem.
# Report a Problem can sit below the fold; also wait for config sync on first login.
- tapOn:
id: "account.settings.option"
# * Verify Settings is visible again with Report a Problem listed
- extendedWaitUntil:
visible:
id: "settings.report_problem.option"
timeout: 10000
# BAD — no marker; a "why" comment is not a substitute for one
# Report a Problem can sit below the fold.
- tapOn:
id: "account.settings.option"
# BAD — decorative section banners
# --- Open channel info ---
# ── Path 1: Leave from the full call screen ──
Rules:
- Only the first line of a block carries the marker. Continuation lines stay plain
#, same as Detox. - The marker goes at the block's own indentation, including inside nested
commands:lists. - A comment block that introduces no command (a trailing note at end of file) needs no marker.
- Applies to
flows/**,subflows/**,preflight/**, and_-prefixed fragments — the header contract exemptions do not exempt a file from this. - Enforced by
scripts/validate-flow-headers.sh(npm run validate-headers), which runs in CI as the Validate Maestro flow headers job.
Comment style — DO / DON'T
# GOOD — behavior and expected outcome
# MM-T67856: Tapping Report a Problem leaves Settings and opens the report form.
# PRE-CONDITIONS:
# - Logged-in user on an unlicensed (free-edition) server
# ASSERTIONS:
# - Settings is visible again after dismissing the report form
# BAD — implementation / Detox references (do not write these)
# On free edition, report_problem.tsx sets skipReportAProblemScreen = ...
# The original Detox spec at detox/e2e/... is describe.skip(...)
# WatermelonDB observables for AllowDownloadLogs may lag on first login
Inline step comments should explain why a wait or branch exists in test terms, not cite React components or Detox patterns:
# GOOD
# iOS may show an in-app report screen or an external browser sheet — handle both.
# BAD
# SFSafariViewController hosts the form when isFreeEdition is true in report_problem.tsx
3. Maestro platform rules
These align with Maestro docs and lessons from Mattermost mobile CI.
Selectors
- Always
id:(testID). Neverpoint:except to dismiss system overlays with no accessibility node. - Exception: Apple system apps (Photos, Safari share sheet) have no testIDs — share-extension coordinate-tap utils live on
rf-split/maestro-ios-deferred-flows(not in default CI onrf-split/maestro). - If an element lacks a testID, add one in
app/— do not work around with coordinates. text:is allowed in exactly two cases (both already relied on by merged flows; preferid:everywhere else):- iOS
FloatingTextInputfields. The component passes its testID to the container, not to the innerTextInput, so on iOS neither the field nor its value is queryable byid:— only the rendered label is. Hencetext: "Enter Server URL"/text: "Display Name"insubflows/server/on iOS only. Android exposes these fields normally and must useid:(server_form.server_url.input,server_form.server_display_name.input). CI 33915931136: AndroidtapOn: text: Display Namefailed 10/10 flows. The inline validation error is addressable:<testID>.error(seeapp/components/floating_input/floating_input_container.tsx). - System (SpringBoard / Android framework) alert buttons —
Allow,Okay,Not now,No thanks. These are OS-owned views with no app testID and cannot be given one. Keep themoptional: true; do not substitutepoint:, which is more brittle than the label.
- iOS
- Verify hierarchy before authoring:
maestro --device <id> hierarchy text:is a full-string regex, so a prefix or fragment matches nothing — silently underoptional: true.text: "mattermost.com"does not match the title "Mattermost | Collaboration Platform…". Match the whole string or use a regex that spans it, and beware labels assembled from server config (the login placeholder gains an "AD/LDAP Username" segment only when LDAP is enabled).
testIDs follow component.subcomponent.element:
- tapOn:
id: "channel.post_draft.send_action.send.button"
- tapOn:
id: "tab_bar.home.tab"
- tapOn:
id: "login_form.username.input"
- tapOn:
id: "channel_header.channel_quick_actions.button"
- tapOn:
id: "share_extension.channel.option"
Waits
- Prefer
extendedWaitUntilon a specific element overwaitForAnimationToEnd. - Use
waitForAnimationToEndonly when no stable element exists yet (e.g. immediately after login tap).
Platform branches
- runFlow:
when:
platform: iOS
commands:
- ...
Keep shared steps outside runFlow blocks.
Environment variables
Never hardcode server URLs, credentials, or channel names:
| Variable | Description |
|---|---|
${MAESTRO_APP_ID} |
App bundle ID (e.g. com.mattermost.rnbeta) |
${SITE_1_URL} |
Primary test server URL |
${TEST_USER_EMAIL} |
Test user email |
${TEST_USER_PASSWORD} |
Test user password |
${TEST_CHANNEL_NAME} |
Test channel name (set by seed script) |
${TEST_TEAM_NAME} |
Test team name (set by seed script) |
App launch
Use runFlow: ../../subflows/auth/login.yml — do not add raw launchApp when login already handles stale sessions, call screens, and permission dialogs. Follow with navigate_to_channel.yml when needed.
Toggles
Parent toggle rows are not tappable. Use the Switch child testID:
- tapOn:
id: "report_problem.enable_log_attachments.toggled.false.button"
optional: true
Format: <parent_id>.toggled.{true|false}.button where the boolean is the current state.
Server preferences survive clearState: true — the next flow cannot assume default toggle state. Use optional: true on toggle taps or drive to a definitive end state.
After mutating a toggle:
- Assert the new toggle visual state.
waitForAnimationToEnd: {timeout: 3000}(async server save).- Then any
stopApp: truerelaunch.
Modal exit on iOS
Use launchApp: {stopApp: true, clearState: false} — not back — to leave modal-presented screens.
optional: true
Allowed only on toggle taps (idempotent state) and genuinely intermittent system UI (permission prompts, Save Password, onboarding). Do not use on ordinary taps to silence failures.
- tapOn:
text: "Allow"
optional: true
- tapOn:
text: "Not Now"
optional: true
Bottom sheets (iOS)
@gorhom/bottom-sheet content is invisible to XCUITest unless accessibilityViewIsModal={true} on the content container (already set in app/screens/bottom_sheet/index.tsx). Callers must pass explicit testID on inner OptionItem components.
Screenshots
takeScreenshot: <kebab-case-name>at key transitions and at flow end.- Names describe state, not implementation:
report-problem-dismissed, notafter-breadcrumb-tap.
Sub-flows
runFlow: ../../subflows/auth/login.ymlfirst when auth is required.runFlow: ../../subflows/navigation/navigate_to_channel.ymlbefore channel actions.- Seed via shell (
npx tsx detox/maestro/fixtures/seed.ts) — not inside the YAML.
Server config beyond defaults
- Tag the flow uniquely (e.g.
MM-T67856_4) and add the tag toconfig/exclude_tags.jsonso the default batch does not run it with the setting flipped. - Flip the setting inside the flow, not in the workflow: an
onFlowStarthook that runs arunScriptfixture (Maestro's built-inhttpclient;--envvalues are globals) and anonFlowCompletehook that restores it.fixtures/set_allow_download_logs.jsis the reference. This keeps a localmaestro testof the file identical to CI. - Give it a dedicated run step in
e2e-maestro-template.ymlplus a standalonealways()restore step. The hook covers every normal exit, including failures; the workflow step covers a runner hard-kill, which never reachesonFlowCompleteand would leave the setting flipped for every later job on a shared server.
AllowDownloadLogs is under SupportSettings, not ServiceSettings:
curl -sS -X PUT \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"SupportSettings":{"AllowDownloadLogs":false}}' \
"$SITE_1_URL/api/v4/config/patch"
Cross-platform notes
- iOS share extension (deferred branch
rf-split/maestro-ios-deferred-flows): Maestro must be attached to the host app (Safari/Photos) before the share sheet opens; share target label is Mattermost, not Mattermost Beta. - Android Chrome: Fresh emulators show first-run / ads interstitials — use
subflows/browser/dismiss_chrome_interstitial.ymlor CI Chrome pre-warm (seecreate_android_emulator.sh). - Android port forwarding:
adb reverse tcp:8065 tcp:8065when using localhost URLs. - iOS timezone:
xcrun simctl spawn $SIMULATOR_ID launchctl setenv TZ "America/New_York"(Xcode 26 removedsimctl timezone). - Android timezone:
adb shell setprop persist.sys.timezone "America/New_York".
4. Server provisioning & fixtures
Maestro flows run against Matterwick cloud servers (SITE_1_URL, SITE_2_URL, SITE_3_URL). CI provisions via detox/provision_server.js + detox/maestro/fixtures/seed.ts.
| Setting | Set by | Why |
|---|---|---|
| Trial Enterprise license | detox/provision_server.js |
Calls + Custom Profile Attributes |
Calls plugin + DefaultEnabled |
detox/provision_server.js |
Calls flows |
| Per-channel calls enable | detox/maestro/fixtures/seed.ts |
Cloud servers may not auto-enable per channel |
EnableChannelBookmarks |
detox/provision_server.js |
Bookmark flows |
ExperimentalViewArchivedChannels |
detox/provision_server.js |
Archive flows |
RateLimitSettings.Enable=false |
detox/provision_server.js |
Prevents 429s on burst traffic |
AllowDownloadLogs=false (MM-T67856_4 only) |
Dedicated CI step | Isolated attach-logs variant |
iOS file bookmark fixture: document picker reads from FileProvider.LocalStorage — CI copies test_bookmark.png there in e2e-maestro-template.yml.
Android file bookmark fixture: adb push to /sdcard/Download/test_bookmark.png + MEDIA_SCANNER_SCAN_FILE broadcast in create_android_emulator.sh.
Calls flows: after login, WebSocket-driven config can take 10–30s — use waitForAnimationToEnd: {timeout: 25000} at the top of calls flows (see call_ui_permission.yml).
Local parity: README.md § Official local workflow and ./scripts/setup_local.sh.
5. CLI version pin
Single source of truth: maestro-version.json
- Local install: read version from
maestro-version.json, thencurl -fsSL "https://get.maestro.mobile.dev" | bash - CI reads
maestro-version.jsonand caches by commit SHA. - Bump only after running the full suite on both platforms; update version and commit together.
6. CI layout
| Workflow | Role |
|---|---|
e2e-detox-pr.yml |
Matterwick entry point: builds, provisioning, header validation, dispatches Maestro |
e2e-maestro-pr.yml |
Maestro status contexts, orchestration (reusable) |
e2e-maestro-template.yml |
Simulator/emulator, seed, batch test runner, report |
Maestro runs via detox/maestro/scripts/run_ci_batches.sh (one flow per batch). JUnit reports are merged before HTML generation.
Device targets (same as Detox):
- iOS: iPhone 17 Pro, iOS 26.2 —
detox/scripts/preboot_ios_simulator.sh - Android:
detox_pixel_8_api_35—detox/create_android_emulator.shwithMAESTRO_ANDROID=true
See README.md § CI Integration for the full diagram.
7. Pre-PR checklist
Before opening a PR, verify every changed flow:
- Header has ticket, PRE-CONDITIONS, REQUIRED ENV VARS, ASSERTIONS, testIDs
- No Detox references,
app/paths, or React implementation details in comments - Every body comment block introducing a command starts with
# #(step) or# *(assertion) - All interactions use
id:selectors verified viagrep -rn 'testID=' app/ tags:includes the Zephyr id- Login sub-flow present when auth is required
- Toggle taps use
.toggled.{true|false}.buttonpattern - iOS modal exits use
launchApp:{stopApp:true,clearState:false} - No
optional: trueon non-toggle, non-system-dialog taps - Flow ends with
takeScreenshot - Non-default server config uses exclude-tag + dedicated CI step
npm run validate-headerspasses locally
cd detox/maestro && npm run validate-headers
CI runs the same script in e2e-detox-pr.yml (validate-maestro-flow-headers) before Maestro tests.
Forbidden without artifact evidence:
- Adding
optional: trueto a non-toggle tap to silence a failure. - Increasing a timeout because "it's flaky" — find the real reason.
- Platform-only
runFlow.whenbranches instead of fixing the actual issue.
8. Debugging a failing flow
- Check
~/.maestro/tests/<latest>/for screenshots andview-hierarchy.xml. - Classify: testID drift (grep
app/), navigation timeout (inspect hierarchy), or server state (curl ${SITE_1_URL}/api/v4/config). - Re-run with the same env as CI:
source detox/maestro/.maestro-test-env.sh
~/.maestro/bin/maestro test detox/maestro/flows/<flow>.yml
For CI parity, use detox/maestro/scripts/run_ci_batches.sh (batch mode) with the CI-built .app/APK — not isolated single-flow runs only.
Use the Maestro CLI for final verification (matches CI). Use maestro hierarchy / MCP inspect tools during authoring iteration.
9. Example — canonical header
See flows/account/attach_logs.yml for the reference implementation.