diff --git a/.github/actions/preinstall-android-sdk/action.yml b/.github/actions/preinstall-android-sdk/action.yml new file mode 100644 index 00000000000..01f873bc877 --- /dev/null +++ b/.github/actions/preinstall-android-sdk/action.yml @@ -0,0 +1,30 @@ +name: Pre-install Android SDK packages +description: >- + Serialized, 3x-retried install of the Android emulator + api-34 system image that + clears partial downloads between attempts, so a truncated/corrupt archive self-heals + instead of failing the job ("Error on ZipFile unknown archive"). Gate the calling step + on a cache miss; on a cache hit the install is a no-op. + +runs: + using: composite + steps: + - name: Install emulator + system image + shell: bash + run: | + set -u + SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}" + SDKMANAGER="$SDK/cmdline-tools/latest/bin/sdkmanager" + IMAGE="system-images;android-34;google_apis;x86_64" + yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true + for attempt in 1 2 3; do + echo "sdkmanager install attempt $attempt" + if "$SDKMANAGER" --install "emulator" "$IMAGE" --channel=0; then + echo "SDK packages installed on attempt $attempt" + exit 0 + fi + echo "Install failed (attempt $attempt); clearing partial downloads and retrying" + rm -rf "$SDK/emulator" "$SDK/system-images/android-34/google_apis/x86_64" + sleep 10 + done + echo "sdkmanager failed to install emulator + system image after 3 attempts" + exit 1 diff --git a/.github/scripts/run-maestro.sh b/.github/scripts/run-maestro.sh index 1af51d16c8e..c14db5f686e 100755 --- a/.github/scripts/run-maestro.sh +++ b/.github/scripts/run-maestro.sh @@ -5,7 +5,10 @@ PLATFORM="${1:-${PLATFORM:-android}}" SHARD="${2:-${SHARD:-default}}" FLOWS_DIR=".maestro/tests" MAIN_REPORT="maestro-report.xml" -MAX_RERUN_ROUNDS="${MAX_RERUN_ROUNDS:-3}" +MAX_RERUN_ROUNDS="${MAX_RERUN_ROUNDS:-2}" +# Whole-suite retries for a session/driver-startup failure (no report produced), +# distinct from MAX_RERUN_ROUNDS which retries individual failed flows. +MAX_STARTUP_RETRIES="${MAX_STARTUP_RETRIES:-1}" RERUN_REPORT_PREFIX="maestro-rerun" export MAESTRO_DRIVER_STARTUP_TIMEOUT="${MAESTRO_DRIVER_STARTUP_TIMEOUT:-120000}" @@ -32,6 +35,23 @@ else fi fi +# E2E server preflight. Every flow is driven by REST calls (login, users.create, …) +# that .maestro/scripts/data-setup.js makes to this server from the runner host. If +# the server is down the flow fails deep inside an evalScript as an opaque +# "[Failed] " with no reason in the job log — you'd have to download the +# Maestro artifact to learn it was an HTTP error. Probe it up front so a server +# outage surfaces as a single red annotation on the job (and, when the whole server +# is down, the same annotation on every shard) instead of mystery flow failures. +E2E_SERVER="$(sed -n "s/^[[:space:]]*server:[[:space:]]*'\([^']*\)'.*/\1/p" .maestro/scripts/data.js | head -1)" +E2E_SERVER="${E2E_SERVER:-https://mobile.qa.rocket.chat}" +echo "Preflight: checking E2E server ${E2E_SERVER} ..." +PREFLIGHT_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 25 --retry 3 --retry-all-errors --retry-delay 5 "${E2E_SERVER}/api/info" || true)" +if [ "$PREFLIGHT_CODE" != "200" ]; then + echo "::error title=E2E server unreachable::${E2E_SERVER}/api/info returned HTTP ${PREFLIGHT_CODE:-000} — the test server is likely down. This is an environment failure, not an app or test regression." + exit 3 +fi +echo "Preflight OK: ${E2E_SERVER}/api/info -> 200" + MAPFILE="$(mktemp)" trap 'rm -f "$MAPFILE"' EXIT @@ -69,35 +89,50 @@ done < "$MAPFILE" echo "Main run will execute:" printf ' %s\n' "${FLOW_FILES[@]}" -if [ "$PLATFORM" = "android" ]; then - adb shell settings put system show_touches 1 || true - adb install -r "app-release.apk" || true - adb shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1 || true - sleep 6 - adb shell am force-stop "$APP_ID" || true - - maestro test "${FLOW_FILES[@]}" \ - -e APP_ID="$APP_ID" \ - --exclude-tags=util \ - --include-tags="test-${SHARD}" \ - --exclude-tags=ios-only \ - --format junit \ - --output "$MAIN_REPORT" || true +run_main_suite() { + rm -f "$MAIN_REPORT" + if [ "$PLATFORM" = "android" ]; then + adb shell settings put system show_touches 1 || true + adb install -r "app-release.apk" || true -else - maestro test "${FLOW_FILES[@]}" \ - -e APP_ID="$APP_ID" \ - --exclude-tags=util \ - --include-tags="test-${SHARD}" \ - --exclude-tags=android-only \ - --format junit \ - --output "$MAIN_REPORT" || true -fi + maestro test "${FLOW_FILES[@]}" \ + -e APP_ID="$APP_ID" \ + --exclude-tags=util \ + --include-tags="test-${SHARD}" \ + --exclude-tags=ios-only \ + --format junit \ + --output "$MAIN_REPORT" || true + else + maestro test "${FLOW_FILES[@]}" \ + -e APP_ID="$APP_ID" \ + --exclude-tags=util \ + --include-tags="test-${SHARD}" \ + --exclude-tags=android-only \ + --format junit \ + --output "$MAIN_REPORT" || true + fi +} -if [ ! -f "$MAIN_REPORT" ]; then - echo "Main report not found" - exit 1 -fi +# A missing main report means the run aborted before producing any JUnit output — +# a session/driver-startup failure (e.g. the iOS XCUITest runner not attaching +# within MAESTRO_DRIVER_STARTUP_TIMEOUT), not a flow failure. That class is +# transient and the per-flow rerun loop below can't see it (it needs a report to +# know which flows to retry), so the whole suite is retried here up to +# MAX_STARTUP_RETRIES times. A report that exists WITH failures is left to the +# rerun loop unchanged. +STARTUP_ATTEMPT=0 +while : ; do + run_main_suite + if [ -f "$MAIN_REPORT" ]; then + break + fi + if [ "$STARTUP_ATTEMPT" -ge "$MAX_STARTUP_RETRIES" ]; then + echo "::error title=Maestro driver/session failed to start::No main report after $((STARTUP_ATTEMPT + 1)) attempt(s) — the Maestro session never produced JUnit output (driver/session-startup failure, e.g. the iOS XCUITest runner not attaching). This is an environment failure, not an app or test regression." + exit 1 + fi + STARTUP_ATTEMPT=$((STARTUP_ATTEMPT + 1)) + echo "Main report not found; session/driver likely failed to start. Retrying whole suite (startup retry ${STARTUP_ATTEMPT}/${MAX_STARTUP_RETRIES})" +done FAILED_NAMES="$(python3 - <" in the +# local Maestro logs. Scan them (already on disk — no artifact download) so an +# environment-caused failure is annotated as such instead of reading like an app bug. +SERVER_ERR="$(grep -rhoE "Non-retryable error [0-9]{3}|Connection refused|Failed to connect|UnknownHostException|ConnectException|Read timed out" "$HOME/.maestro/tests/" 2>/dev/null | sort -u | head -5 | paste -sd '; ' - || true)" +if [ -n "$SERVER_ERR" ]; then + echo "::error title=E2E server error during run::A test-setup REST call to ${E2E_SERVER:-the test server} failed mid-run (${SERVER_ERR}). The shard failure is likely a server/environment flake, not an app or test regression." +fi + exit 1 diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index be6d8dd9e84..0d5c224a3da 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -52,15 +52,93 @@ jobs: needs: [e2e-hold] secrets: inherit + # Pre-populates the AVD snapshot cache before the matrix fans out. Without + # this, all 14 shards check actions/cache at the same instant — nobody has + # written yet, so every shard misses and runs its own snapshot-generation + # step. With this seed job, the shards' cache restore is guaranteed to hit. + e2e-seed-android-avd: + name: E2E Seed Android AVD Cache + if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }} + runs-on: ubuntu-latest + needs: [e2e-hold] + env: + ANDROID_AVD_HOME: /home/runner/.android/avd + steps: + - name: Checkout Repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Java + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + with: + distribution: temurin + java-version: 17 + + - name: Cache Android AVD + id: avd-cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ runner.os }}-api34 + + # The emulator binary and the api-34 system image are NOT in the AVD cache + # above (that only holds the AVD instance under ~/.android/avd). Cache them + # separately here so the shards can restore them instead of each pulling + # ~1 GB from dl.google.com — the concurrent re-downloads are what trigger + # the random "Error on ZipFile unknown archive" failures. This seed job is + # the single writer for this cache; the shards restore it read-only. + - name: Cache Android SDK packages (emulator + system image) + id: sdk-cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + /usr/local/lib/android/sdk/emulator + /usr/local/lib/android/sdk/system-images/android-34/google_apis/x86_64 + key: android-sdk-emu-${{ runner.os }}-api34 + + # Single serialized, retried download of the emulator + system image so a + # corrupt/truncated archive self-heals instead of failing 13 parallel + # shards. Runs before the snapshot step below so android-emulator-runner's + # own sdkmanager install finds the packages already present (a no-op). + - name: Pre-install Android SDK packages (cache miss only) + if: steps.sdk-cache.outputs.cache-hit != 'true' + uses: ./.github/actions/preinstall-android-sdk + + - name: Enable KVM group permissions + if: steps.avd-cache.outputs.cache-hit != 'true' + 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: Generate AVD snapshot (cache miss only) + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0 + timeout-minutes: 45 + with: + api-level: 34 + disk-size: 4096M + arch: x86_64 + target: google_apis + profile: pixel_7_pro + cores: 4 + ram-size: 6144M + force-avd-creation: true + disable-animations: true + emulator-boot-timeout: 900 + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -accel on + script: echo "AVD snapshot generated for cache" + e2e-run-android: name: E2E Run Android if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }} uses: ./.github/workflows/maestro-android.yml - needs: [e2e-build-android] + needs: [e2e-build-android, e2e-seed-android-avd] secrets: inherit strategy: matrix: - shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] + shard: [1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14] fail-fast: false with: shard: ${{ matrix.shard }} @@ -80,7 +158,7 @@ jobs: secrets: inherit strategy: matrix: - shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] + shard: [1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14] fail-fast: false with: shard: ${{ matrix.shard }} diff --git a/.github/workflows/maestro-android.yml b/.github/workflows/maestro-android.yml index 80028fa74c7..36dfc486a1a 100644 --- a/.github/workflows/maestro-android.yml +++ b/.github/workflows/maestro-android.yml @@ -12,7 +12,9 @@ jobs: name: 'Android Tests' runs-on: ubuntu-latest env: - MAESTRO_VERSION: 2.2.0 + # 2.5.0 switched gRPC to dadb direct ADB socket — eliminates flaky TCP forwarding (Broken pipe at install). + # https://github.com/mobile-dev-inc/maestro/releases/tag/cli-2.5.0 + MAESTRO_VERSION: 2.5.1 ANDROID_AVD_HOME: /home/runner/.android/avd steps: @@ -25,15 +27,52 @@ jobs: distribution: temurin java-version: 17 - - name: Cache Android AVD - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + # Restore-only: the seed job (e2e-seed-android-avd, and post-merge the + # build-develop refresh) is the SINGLE writer of this AVD snapshot. Shards + # never save it, so 13 shards × N open PRs no longer each persist a ~3 GB + # AVD under this key — that per-PR duplication was pushing the repo past + # the 10 GB Actions cache cap and LRU-evicting the SDK system-image cache, + # which forced cold-boot re-downloads (the "Error on ZipFile unknown + # archive" shard failures). Shard 14 builds its own keyboard AVD, so skip. + - name: Restore Android AVD + if: ${{ inputs.shard != '14' }} + id: avd-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.android/avd/* ~/.android/adb* key: avd-${{ runner.os }}-api34 + # Restore the emulator + api-34 system image pre-installed by the + # e2e-seed-android-avd seed job. With these present, android-emulator-runner's + # internal sdkmanager install (and shard 14's keyboard-AVD script) become a + # no-op, so the shards no longer each re-download ~1 GB from dl.google.com — + # the source of the random "unknown archive" shard failures. Restore-only: + # the seed job is the single writer, so the shards never race to save it. + # Applies to all shards, including 14 (it installs the same system image). + - name: Restore Android SDK packages (emulator + system image) + id: sdk-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + /usr/local/lib/android/sdk/emulator + /usr/local/lib/android/sdk/system-images/android-34/google_apis/x86_64 + key: android-sdk-emu-${{ runner.os }}-api34 + + # Fix A — safety net for SDK-cache misses (eviction, first run, key bump). + # android-emulator-runner's internal sdkmanager install AND shard 14's + # keyboard-AVD create both download the system image in a SINGLE attempt + # with no retry, so a truncated/corrupt archive fails the shard outright + # ("Error on ZipFile unknown archive"). The composite action does a + # serialized, 3× retried install that clears partial downloads between + # tries. Runs before any emulator boot, so on a cache hit it is a no-op. + - name: Pre-install Android SDK packages (cache miss only) + if: steps.sdk-cache.outputs.cache-hit != 'true' + uses: ./.github/actions/preinstall-android-sdk + - name: Cache Maestro + id: cache-maestro uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.maestro @@ -50,9 +89,14 @@ jobs: E2E_ACCOUNT: ${{ secrets.E2E_ACCOUNT }} - name: Install Maestro - run: | - curl -Ls "https://get.maestro.mobile.dev" | bash - echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + # Gate on cache miss: the install script wipes and re-downloads ~/.maestro + # on every run (no "already installed" check), so without this guard the + # Cache Maestro restore above is overwritten and wasted (~70s/shard). + if: steps.cache-maestro.outputs.cache-hit != 'true' + run: curl -Ls "https://get.maestro.mobile.dev" | bash + + - name: Add Maestro to PATH + run: echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" - name: Enable KVM group permissions run: | @@ -134,13 +178,18 @@ jobs: target: google_apis avd-name: Pixel_API_34_Keyboard cores: 4 - ram-size: 4096M + ram-size: 6144M force-avd-creation: false disable-animations: true emulator-boot-timeout: 900 emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -accel on script: ./.github/scripts/run-maestro.sh android ${{ inputs.shard }} + # No per-shard AVD snapshot generation: e2e-seed-android-avd is a hard + # `needs:` of this workflow and the single writer of the avd--api34 + # cache, so the "Restore Android AVD" step above always hits by the time a + # shard runs. On the rare miss (eviction mid-run) the test step below + # cold-boots and android-emulator-runner creates the AVD on the fly. - name: Start Android Emulator and Run Maestro Tests (default) if: ${{ inputs.shard != '14' }} uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0 @@ -152,11 +201,11 @@ jobs: target: google_apis profile: pixel_7_pro cores: 4 - ram-size: 4096M + ram-size: 6144M force-avd-creation: false disable-animations: true emulator-boot-timeout: 900 - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -accel on + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -accel on script: ./.github/scripts/run-maestro.sh android ${{ inputs.shard }} - name: Android Maestro Logs @@ -164,5 +213,8 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: Android Maestro Logs - Shard ${{ inputs.shard }} - path: ~/.maestro/tests/**/*.png + path: | + ~/.maestro/tests/**/* + maestro-report.xml + maestro-rerun-round-*.xml retention-days: 7 diff --git a/.github/workflows/maestro-ios.yml b/.github/workflows/maestro-ios.yml index 36fdbb24839..337674e3c23 100644 --- a/.github/workflows/maestro-ios.yml +++ b/.github/workflows/maestro-ios.yml @@ -9,14 +9,58 @@ on: jobs: ios-test: - runs-on: macos-14 + runs-on: macos-26 env: - MAESTRO_VERSION: 2.2.0 + # Pinned alongside the Android workflow so both platforms run the same + # Maestro CLI. See cli-2.5.1 release notes for driver improvements. + # https://github.com/mobile-dev-inc/maestro/releases/tag/cli-2.5.1 + MAESTRO_VERSION: 2.5.1 steps: - name: Checkout Repo uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Set up Xcode + # Match e2e-build-ios.yml so the artifact built against the iOS 26 SDK + # is exercised on an iOS 26 simulator runtime (avoids SDK/runtime drift). + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 + with: + xcode-version: '26.2.0' + + - name: Configure Simulator + run: | + defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false + defaults write com.apple.iphonesimulator SlowAnimations -bool false + defaults write com.apple.iphonesimulator ShowDeviceBezels -bool false + defaults write com.apple.iphonesimulator DisableShadows -bool true + defaults write com.apple.iphonesimulator AllowFullscreenMode -bool false + defaults write com.apple.iphonesimulator PasteboardAutomaticSync -bool false + defaults write com.apple.iphonesimulator ShowChrome -bool false + defaults write com.apple.iphonesimulator DeviceFramebufferOnly -bool true + + # Kick off the cold boot asynchronously, as early as possible, so the iOS + # runtime boots in the background while the remaining setup steps (Java, + # app download, E2E account, Maestro cache/install) run. We block on it + # afterwards in "Wait for Simulator Ready". + - name: Start Simulator Boot + run: | + # macos-26 catalogs different iPhone names per Xcode release; pick the + # highest-numbered "iPhone N Pro" present in the installed runtimes + # so we are not pinned to a name that disappears on image rotation. + SIM_NAME=$(xcrun simctl list devices available --json \ + | jq -r '[.devices | to_entries[] | .value[] | select(.name | test("^iPhone [0-9]+ Pro$"))] | sort_by(.name | capture("(?[0-9]+)").n | tonumber) | last | .name') + + if [[ -z "$SIM_NAME" || "$SIM_NAME" == "null" ]]; then + echo "::error::No iPhone N Pro simulator available" + xcrun simctl list devices available + exit 1 + fi + + echo "Starting boot for simulator: $SIM_NAME" + echo "SIM_NAME=$SIM_NAME" >> "$GITHUB_ENV" + + xcrun simctl boot "$SIM_NAME" || true + - name: Setup Java uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 with: @@ -35,49 +79,43 @@ jobs: E2E_ACCOUNT: ${{ secrets.E2E_ACCOUNT }} - name: Cache Maestro + id: cache-maestro uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.maestro key: maestro-${{ runner.os }}-${{ env.MAESTRO_VERSION }} - - name: Install Maestro + idb - run: | - brew tap facebook/fb - brew install facebook/fb/idb-companion - curl -fsSL "https://get.maestro.mobile.dev" | bash - echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" - - - name: Configure Simulator - run: | - defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false - defaults write com.apple.iphonesimulator SlowAnimations -bool false - defaults write com.apple.iphonesimulator ShowDeviceBezels -bool false - defaults write com.apple.iphonesimulator DisableShadows -bool true - defaults write com.apple.iphonesimulator AllowFullscreenMode -bool false - defaults write com.apple.iphonesimulator PasteboardAutomaticSync -bool false - defaults write com.apple.iphonesimulator ShowChrome -bool false - defaults write com.apple.iphonesimulator DeviceFramebufferOnly -bool true - - - name: Boot Simulator + # idb-companion is intentionally not installed: Maestro's iOS driver has + # used XCUITest since 1.18.0 and no longer needs idb to drive the simulator + # (verified on Maestro 2.5.1 + iOS 26 — launch, tap, hierarchy read and + # screenshot all work with idb absent). Dropping the `brew tap facebook/fb` + # + idb install removes ~110s/shard — the tap triggers a full Homebrew + # auto-update. MAESTRO_VERSION is read from the job env to pin the release. + - name: Install Maestro + # The install script wipes and re-downloads ~/.maestro on every run (it + # has no "already installed" check), so gate it on a cache miss — + # otherwise the Cache Maestro restore above is overwritten and wasted. + if: steps.cache-maestro.outputs.cache-hit != 'true' + run: curl -fsSL "https://get.maestro.mobile.dev" | bash + + - name: Add Maestro to PATH + run: echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: Wait for Simulator Ready timeout-minutes: 15 run: | - if [ "${{ inputs.shard }}" = "14" ]; then - SIM_NAME="iPhone SE (3rd generation)" - else - SIM_NAME="iPhone 16 Pro" - fi - - echo "Booting simulator: $SIM_NAME" - - xcrun simctl boot "$SIM_NAME" || true + echo "Waiting for simulator to finish booting: $SIM_NAME" xcrun simctl bootstatus "$SIM_NAME" -b + # Collapse UIKit/CoreAnimation transition durations toward zero so + # React Navigation native-stack transitions finish near-instantly and + # Maestro never waits on (or acts mid-) a transition. Read once at app + # launch. iOS analogue of Android's disable-animations. + # NOTE: this is a duration MULTIPLIER, not an on/off flag — values > 1 + # SLOW animations down. The previous value of 10 made every transition + # 10x slower (the opposite of the "Disabling animations" intent). echo "Disabling animations" - xcrun simctl spawn booted defaults write -g UIAnimationDragCoefficient -float 10 - - echo "Warming SpringBoard" - xcrun simctl launch booted com.apple.springboard - sleep 15 + xcrun simctl spawn booted defaults write -g UIAnimationDragCoefficient -float 0.0001 echo "Booted devices:" xcrun simctl list devices | grep Booted @@ -97,17 +135,19 @@ jobs: run: chmod +x .github/scripts/run-maestro.sh - name: Run Maestro Tests - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2 - with: - timeout_minutes: 30 - max_attempts: 2 - retry_on: timeout - command: ./.github/scripts/run-maestro.sh ios ${{ inputs.shard }} + # Slowest passing shard is ~27 min; with up to 2 internal rerun rounds + # we need headroom above that. nick-fields/retry previously gave 60 min + # (2 × 30); 45 min is a closer single-step equivalent without overshoot. + timeout-minutes: 45 + run: ./.github/scripts/run-maestro.sh ios ${{ inputs.shard }} - name: Upload Maestro Logs if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: iOS Maestro Logs - Shard ${{ inputs.shard }} - path: ~/.maestro/tests/**/*.png + path: | + ~/.maestro/tests/**/* + maestro-report.xml + maestro-rerun-round-*.xml retention-days: 7 diff --git a/.maestro/helpers/login.yaml b/.maestro/helpers/login.yaml index 21951564042..7b0ae9f69c0 100644 --- a/.maestro/helpers/login.yaml +++ b/.maestro/helpers/login.yaml @@ -33,9 +33,3 @@ onFlowStart: visible: id: rooms-list-view timeout: 60000 -- runFlow: - when: - platform: iOS - visible: 'Not Now' - commands: - - tapOn: 'Not Now' diff --git a/.maestro/helpers/open-deeplink.yaml b/.maestro/helpers/open-deeplink.yaml index 8f988e8f1f1..01b366809f5 100644 --- a/.maestro/helpers/open-deeplink.yaml +++ b/.maestro/helpers/open-deeplink.yaml @@ -11,13 +11,12 @@ tags: visible: '.*Open in.*' platform: iOS commands: + # Anchored regex: the dialog title "Open in 'Rocket.Chat Experimental'?" + # also matches `Open` as a substring, so an unanchored selector picks the + # title (cold-launch on iOS 26) instead of the button and leaves the + # dialog up. ^Open$ targets only the button label. - tapOn: - text: Open - index: 0 - optional: true - - tapOn: - text: Open - index: 1 + text: '^Open$' optional: true - runFlow: when: diff --git a/.maestro/helpers/search-room.yaml b/.maestro/helpers/search-room.yaml index 10abc7dba89..6314ba6f9af 100644 --- a/.maestro/helpers/search-room.yaml +++ b/.maestro/helpers/search-room.yaml @@ -3,8 +3,10 @@ name: Search room tags: - 'util' --- -- assertVisible: - id: 'rooms-list-view' +- extendedWaitUntil: + visible: + id: 'rooms-list-view' + timeout: 60000 - waitForAnimationToEnd: timeout: 5000 - tapOn: @@ -16,4 +18,4 @@ tags: - extendedWaitUntil: visible: id: 'rooms-list-view-item-${ROOM}' - timeout: 10000 + timeout: 60000 diff --git a/.maestro/tests/assorted/profile.yaml b/.maestro/tests/assorted/profile.yaml index 42e26a06df7..e80ea3c621e 100644 --- a/.maestro/tests/assorted/profile.yaml +++ b/.maestro/tests/assorted/profile.yaml @@ -81,7 +81,9 @@ tags: id: 'profile-view-submit' direction: UP -# should change name and username +# edit name, username, nickname, bio, and email then submit once. +# users.updateOwnBasicInfo is rate-limited per-user, so we batch all changes +# into a single request instead of three separate submits. - assertVisible: id: 'profile-view-name' - runFlow: @@ -98,15 +100,6 @@ tags: - inputText: ${output.user.username + 'username'} - runFlow: file: '../../helpers/hide-keyboard.yaml' -- scrollUntilVisible: - element: - id: 'profile-view-submit' - timeout: 60000 - centerElement: true -- tapOn: - id: 'profile-view-submit' - -# should change nickname and bio - assertVisible: id: 'profile-view-nickname' - tapOn: @@ -122,15 +115,6 @@ tags: - tapOn: text: '.*Bio.*' index: 0 -- scrollUntilVisible: - element: - id: 'profile-view-submit' - timeout: 60000 - centerElement: true -- tapOn: - id: 'profile-view-submit' - -# should change email - scrollUntilVisible: element: id: 'profile-view-email' @@ -183,11 +167,18 @@ tags: - tapOn: id: 'change-password-view-current-password' - inputText: ${output.user.password} +# Dismiss the keyboard so the lower fields aren't clipped below the fold. With the +# keyboard open, KeyboardView (behavior='padding') shrinks the scroll viewport until +# only the title + current-password fit, pushing new-password out of the hierarchy. +- runFlow: + file: '../../helpers/hide-keyboard.yaml' - assertVisible: id: 'change-password-view-new-password' - tapOn: id: 'change-password-view-new-password' - inputText: ${output.user.password + 'new'} +- runFlow: + file: '../../helpers/hide-keyboard.yaml' - assertVisible: id: 'change-password-view-confirm-new-password' - tapOn: diff --git a/.maestro/tests/e2ee/e2e-encryption.yaml b/.maestro/tests/e2ee/e2e-encryption.yaml index a4852dbb510..b4509f2d165 100644 --- a/.maestro/tests/e2ee/e2e-encryption.yaml +++ b/.maestro/tests/e2ee/e2e-encryption.yaml @@ -5,7 +5,7 @@ onFlowStart: onFlowComplete: - evalScript: ${output.utils.deleteCreatedUsers()} tags: - - test-9 + - test-3 --- - evalScript: ${output.room = 'encrypted' + output.random()} - evalScript: ${output.userA = output.utils.createUser()} diff --git a/.maestro/tests/room/discussion.yaml b/.maestro/tests/room/discussion.yaml index ead9e56417c..3fc7f536218 100644 --- a/.maestro/tests/room/discussion.yaml +++ b/.maestro/tests/room/discussion.yaml @@ -93,7 +93,14 @@ tags: visible: id: 'room-view-title-${output.discussionFromNewMessage}' timeout: 60000 -- tapOn: 'Back' +- runFlow: '../../helpers/hide-keyboard.yaml' +- runFlow: + when: + visible: + id: 'room-view' + commands: + - tapOn: + id: 'header-back' - extendedWaitUntil: visible: id: 'rooms-list-view-item-${output.discussionFromNewMessage}' diff --git a/.maestro/tests/room/room-actions.yaml b/.maestro/tests/room/room-actions.yaml index f4e26cb28d6..9d687d0f1d9 100644 --- a/.maestro/tests/room/room-actions.yaml +++ b/.maestro/tests/room/room-actions.yaml @@ -171,8 +171,12 @@ tags: from: id: action-sheet-handle direction: UP -- extendedWaitUntil: - visible: +# Mirror the Star step above: a single swipe doesn't always fully expand the +# action sheet, so 'Unstar' can sit below the fold. scrollUntilVisible scrolls +# the sheet until it's on screen instead of asserting after one swipe (the +# flaky "Assertion is false: 'Unstar' is visible" shard failure). +- scrollUntilVisible: + element: text: 'Unstar' timeout: 60000 - tapOn: diff --git a/.maestro/tests/teams/create-team.yaml b/.maestro/tests/teams/create-team.yaml index 0c14d9ba0b2..9cc9dc3952a 100644 --- a/.maestro/tests/teams/create-team.yaml +++ b/.maestro/tests/teams/create-team.yaml @@ -41,7 +41,8 @@ tags: id: 'room-view-messages' - assertVisible: id: 'room-view-title-${output.teamname}' -- tapOn: 'private team ${output.teamname} .' +- tapOn: + id: 'room-header' - assertVisible: id: 'room-actions-info' - tapOn: diff --git a/app/containers/FormContainer.tsx b/app/containers/FormContainer.tsx index 7cc800b009b..11c0930ccb5 100644 --- a/app/containers/FormContainer.tsx +++ b/app/containers/FormContainer.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import { ScrollView, type ScrollViewProps, StyleSheet, View } from 'react-native'; +import { type ScrollViewProps, StyleSheet, View } from 'react-native'; +import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; import sharedStyles from '../views/Styles'; import scrollPersistTaps from '../lib/methods/helpers/scrollPersistTaps'; -import KeyboardView from './KeyboardView'; import { useTheme } from '../theme'; import AppVersion from './AppVersion'; import { isTablet } from '../lib/methods/helpers'; @@ -37,18 +37,17 @@ const FormContainer = ({ children, testID, showAppVersion = true, ...props }: IF const { colors } = useTheme(); return ( - - - - {children} - <>{showAppVersion && } - - - + + + {children} + <>{showAppVersion && } + + ); }; diff --git a/app/containers/TextInput/FormTextInput.tsx b/app/containers/TextInput/FormTextInput.tsx index baf374b12d6..f3dafea3aba 100644 --- a/app/containers/TextInput/FormTextInput.tsx +++ b/app/containers/TextInput/FormTextInput.tsx @@ -126,6 +126,15 @@ export const FormTextInput = ({ const [showPassword, setShowPassword] = useState(false); const showClearInput = onClearInput && value && value.length > 0; const inputError = getInputError(error); + // iOS 26 surfaces a system "Save Password?" sheet asynchronously after any + // credential-classified field submit. It overlays the app and blocks + // XCUITest hit-testing, breaking Maestro flows that interact with the + // screen underneath. iOS classifies a field as a credential via any of + // `secureTextEntry`, `textContentType` in {password, newPassword, ...}, + // or `autoComplete` in {password, password-new, ...} — so we must suppress + // all three under RUNNING_E2E_TESTS on iOS. Visual masking and the + // show/hide eye icon stay driven by the original `secureTextEntry` prop. + const suppressIOSCredentialOffer = isIOS && process.env.RUNNING_E2E_TESTS === 'true'; const accessibilityLabelText = useMemo(() => { const baseLabel = `${accessibilityLabel || label || ''}`; const formattedAccessibilityLabel = baseLabel ? `${baseLabel}.` : ''; @@ -174,12 +183,13 @@ export const FormTextInput = ({ autoCorrect={false} autoCapitalize='none' underlineColorAndroid='transparent' - secureTextEntry={secureTextEntry && !showPassword} + secureTextEntry={secureTextEntry && !showPassword && !suppressIOSCredentialOffer} testID={testID} placeholder={placeholder} value={value} placeholderTextColor={colors.fontAnnotation} {...inputProps} + {...(suppressIOSCredentialOffer && { textContentType: 'none', autoComplete: 'off' })} /> {iconLeft ? ( diff --git a/app/containers/message/Components/Attachments/Reply.tsx b/app/containers/message/Components/Attachments/Reply.tsx index 74d47da3ceb..67c14e6b7e8 100644 --- a/app/containers/message/Components/Attachments/Reply.tsx +++ b/app/containers/message/Components/Attachments/Reply.tsx @@ -17,6 +17,7 @@ import MessageContext from '../../Context'; import Touchable from '../../Touchable'; import messageStyles from '../../styles'; import dayjs from '../../../../lib/dayjs'; +import { getAttachmentText } from '../../utils'; const styles = StyleSheet.create({ button: { @@ -123,7 +124,7 @@ const Description = React.memo( 'use memo'; const { user } = useContext(MessageContext); - const text = attachment.text || attachment.title; + const text = getAttachmentText(attachment); if (!text) { return null; diff --git a/app/containers/message/hooks/useMessageAccessibilityLabel.test.ts b/app/containers/message/hooks/useMessageAccessibilityLabel.test.ts index 227fe9d9f72..8fda94253df 100644 --- a/app/containers/message/hooks/useMessageAccessibilityLabel.test.ts +++ b/app/containers/message/hooks/useMessageAccessibilityLabel.test.ts @@ -113,6 +113,36 @@ describe('useMessageAccessibilityLabel', () => { expect(result.current).toBe(`alice ${HOUR} caption. Image description: A wavy pattern`); }); + it('appends the quoted message text to the suffix', () => { + const { result } = renderHook(() => + useMessageAccessibilityLabel( + buildProps({ + msg: 'Go to a thread from another room', + attachments: [ + { + message_link: 'https://example.com/group/jumping?msg=abc', + author_name: 'diego.mello', + text: "Go to jumping-thread's thread" + } + ] + }) + ) + ); + expect(result.current).toBe(`alice ${HOUR} Go to a thread from another room. Quote: Go to jumping-thread's thread`); + }); + + it('announces the quote even when the message has no body of its own', () => { + const { result } = renderHook(() => + useMessageAccessibilityLabel( + buildProps({ + msg: undefined, + attachments: [{ message_link: 'https://example.com/group/jumping?msg=abc', text: 'quoted body' }] + }) + ) + ); + expect(result.current).toBe(`alice ${HOUR}. Quote: quoted body`); + }); + it('does not announce "undefined" for attachment-only messages', () => { const { result } = renderHook(() => useMessageAccessibilityLabel( diff --git a/app/containers/message/hooks/useMessageAccessibilityLabel.ts b/app/containers/message/hooks/useMessageAccessibilityLabel.ts index bec1d4971aa..85f679e4676 100644 --- a/app/containers/message/hooks/useMessageAccessibilityLabel.ts +++ b/app/containers/message/hooks/useMessageAccessibilityLabel.ts @@ -1,6 +1,7 @@ import i18n from '../../../i18n'; import translationLanguages from '../../../lib/constants/translationLanguages'; import { useImageDescriptionLabel } from './useImageDescriptionLabel'; +import { useQuoteDescriptionLabel } from './useQuoteDescriptionLabel'; import { type IMessage, type IMessageTouchable } from '../interfaces'; import { getInfoMessage } from '../utils'; @@ -21,6 +22,7 @@ const stripMentions = (label: string, mentions: IMessage['mentions'] = [], chann export const useMessageAccessibilityLabel = (props: IMessage & IMessageTouchable): string => { const imageDescriptionLabel = useImageDescriptionLabel(props.attachments, props.msg); + const quoteDescriptionLabel = useQuoteDescriptionLabel(props.attachments); const msg = props?.msg || ''; const threadMessageLabel = i18n.t('Thread_message', { msg }); let label = props.isInfo ? msg : `${props.tmid ? threadMessageLabel : msg}`; @@ -47,10 +49,10 @@ export const useMessageAccessibilityLabel = (props: IMessage & IMessageTouchable const translatedLanguage = translationLanguages[props?.autoTranslateLanguage || 'en']; const translated = props.isTranslated ? i18n.t('Message_translated_into_idiom', { idiom: translatedLanguage }) : ''; // For translated messages, the translated body is announced by the inner A11y.Index node, so the outer label - // only carries the metadata (user, hour, translated marker) and the suffix (image description, encryption, read receipt). + // only carries the metadata (user, hour, translated marker) and the suffix (quoted text, image description, encryption, read receipt). const prefix = props.isTranslated ? [user, hour, translated].filter(Boolean).join(' ') : [user, hour, translated, label].filter(Boolean).join(' '); - const suffix = [imageDescriptionLabel, encryptedMessageLabel, readReceipt].filter(Boolean).join(' '); + const suffix = [quoteDescriptionLabel, imageDescriptionLabel, encryptedMessageLabel, readReceipt].filter(Boolean).join(' '); return suffix ? `${prefix}. ${suffix}` : `${prefix}.`; }; diff --git a/app/containers/message/hooks/useQuoteDescriptionLabel.ts b/app/containers/message/hooks/useQuoteDescriptionLabel.ts new file mode 100644 index 00000000000..747e70588b8 --- /dev/null +++ b/app/containers/message/hooks/useQuoteDescriptionLabel.ts @@ -0,0 +1,20 @@ +import i18n from '../../../i18n'; +import { type IAttachment } from '../../../definitions'; +import { getAttachmentText } from '../utils'; + +// A quoted reply renders its text via a nested inside the message's single accessible +// Touchable. On iOS that subtree is merged into the parent accessibility element, so the quoted +// text is never exposed on its own (on Android the raw text node is still enumerated). Fold it into +// the message accessibility label so VoiceOver announces it, mirroring useImageDescriptionLabel. +export const useQuoteDescriptionLabel = (attachments: IAttachment[] | undefined): string => { + const quotedText = attachments + ?.filter(attachment => !!attachment.message_link) + .map(attachment => getAttachmentText(attachment)?.trim()) + .find(text => !!text); + + if (!quotedText) { + return ''; + } + + return `${i18n.t('Quote')}: ${quotedText}`; +}; diff --git a/app/containers/message/utils.ts b/app/containers/message/utils.ts index ae5e778311a..cc893af579c 100644 --- a/app/containers/message/utils.ts +++ b/app/containers/message/utils.ts @@ -221,3 +221,7 @@ export const getPreviewMessageFromAttachment = (attachment: IAttachment, transla } return attachment.description ?? attachment.title; }; + +// The displayed body of a quote/reply attachment: its text, falling back to the title. +// Single source of truth so the rendered quote (Reply) and its accessibility label stay in sync. +export const getAttachmentText = (attachment: IAttachment): string | undefined => attachment.text || attachment.title; diff --git a/app/lib/services/sdk.ts b/app/lib/services/sdk.ts index 10965b48cf6..89ab588160d 100644 --- a/app/lib/services/sdk.ts +++ b/app/lib/services/sdk.ts @@ -111,7 +111,11 @@ class Sdk { methodCall(...args: any[]): Promise { return new Promise(async (resolve, reject) => { try { - const result = await this.current.methodCall(...args, this.code || ''); + // Only append the TOTP code when a 2FA flow is in progress. Appending an empty + // string unconditionally pushes a junk trailing positional arg into every method + // call, which breaks methods whose signature grows a typed trailing param + // (e.g. loadSurroundingMessages' `showThreadMessages: boolean`). + const result = await this.current.methodCall(...args, ...(this.code ? [this.code] : [])); return resolve(result); } catch (e: any) { if (e.error && (e.error === 'totp-required' || e.error === 'totp-invalid')) { diff --git a/app/views/RoomView/index.tsx b/app/views/RoomView/index.tsx index fb2ed19be60..2260c1799d0 100644 --- a/app/views/RoomView/index.tsx +++ b/app/views/RoomView/index.tsx @@ -1033,7 +1033,7 @@ class RoomView extends React.Component { } // Synchronization needed for Fabric to work await new Promise(res => setTimeout(res, 100)); - await Promise.race([this.list.current?.jumpToMessage(message.id), new Promise(res => setTimeout(res, 5000))]); + await Promise.race([this.list.current?.jumpToMessage(message.id), new Promise(res => setTimeout(res, 20000))]); this.cancelJumpToMessage(); } } catch (error: any) { diff --git a/app/views/SidebarView/index.tsx b/app/views/SidebarView/index.tsx index 5a14dcca4cd..d027cfd47b5 100644 --- a/app/views/SidebarView/index.tsx +++ b/app/views/SidebarView/index.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { type DrawerNavigationProp } from '@react-navigation/drawer'; -import { ScrollView } from 'react-native'; +import { ScrollView, View } from 'react-native'; import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps'; import styles from './styles'; @@ -25,13 +25,15 @@ const SidebarView = ({ navigation }: { navigation: DrawerNavigationProp - - - - - - + + + + + + + + + ); };