diff --git a/.github/actions/preinstall-android-sdk/action.yml b/.github/actions/preinstall-android-sdk/action.yml new file mode 100644 index 00000000000..dd1ca5a5a51 --- /dev/null +++ b/.github/actions/preinstall-android-sdk/action.yml @@ -0,0 +1,28 @@ +name: Pre-install Android SDK packages +description: >- + Retried install of the Android emulator + api-34 system image; clears partial + downloads between attempts so a corrupt archive self-heals. + +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/assert-maestro-shards.sh b/.github/scripts/assert-maestro-shards.sh new file mode 100755 index 00000000000..c8110cd39b9 --- /dev/null +++ b/.github/scripts/assert-maestro-shards.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Emits the shard list the PR matrices fan out over and asserts every flow's +# test- tag falls inside it, so drift fails loud instead of a flow silently +# never running. +SHARD_COUNT=14 +FLOWS_DIR=".maestro/tests" + +declared=() +for n in $(seq 1 "$SHARD_COUNT"); do declared+=("$n"); done + +# Match the same `- test-N` list-item shape run-maestro.sh greps +found="$(grep -rhoE "^[[:space:]]*-[[:space:]]*['\"]?test-[0-9]+" "$FLOWS_DIR" \ + --include='*.yaml' --include='*.yml' \ + | grep -oE '[0-9]+' | sort -n -u)" + +missing=() +for n in "${declared[@]}"; do + printf '%s\n' "$found" | grep -qx "$n" || missing+=("$n") +done + +extra=() +while IFS= read -r n; do + [ -z "$n" ] && continue + if [ "$n" -lt 1 ] || [ "$n" -gt "$SHARD_COUNT" ]; then + extra+=("$n") + fi +done <<< "$found" + +if [ ${#missing[@]} -gt 0 ] || [ ${#extra[@]} -gt 0 ]; then + echo "::error title=Maestro shard drift::Flow test- tags do not match the declared 1..${SHARD_COUNT} shard list." + [ ${#missing[@]} -gt 0 ] && echo " Shards with no flow tagged test-: ${missing[*]}" + [ ${#extra[@]} -gt 0 ] && echo " Flows tagged outside 1..${SHARD_COUNT}: ${extra[*]}" + exit 1 +fi + +json="$(printf '%s,' "${declared[@]}")" +json="[${json%,}]" +echo "Maestro shard coverage OK: test-1..test-${SHARD_COUNT} all present, none out of range." +echo "shards=${json}" +if [ -n "${GITHUB_OUTPUT:-}" ]; then + echo "shards=${json}" >> "$GITHUB_OUTPUT" +fi diff --git a/.github/scripts/run-maestro.sh b/.github/scripts/run-maestro.sh index 1af51d16c8e..bbc833b5834 100755 --- a/.github/scripts/run-maestro.sh +++ b/.github/scripts/run-maestro.sh @@ -5,10 +5,37 @@ 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}" RERUN_REPORT_PREFIX="maestro-rerun" export MAESTRO_DRIVER_STARTUP_TIMEOUT="${MAESTRO_DRIVER_STARTUP_TIMEOUT:-120000}" +# Linux has timeout, macOS has gtimeout (Homebrew coreutils) +TIMEOUT_BIN="$(command -v timeout || command -v gtimeout || true)" + +# Bound each `maestro test` so a wedged CoreSimulator child can't hang the job; +# 124/137 = timed out, annotated as an environment failure. +run_maestro_test() { + local rc=0 + if [ -n "$TIMEOUT_BIN" ]; then + "$TIMEOUT_BIN" -k 30s 35m maestro test "$@" || rc=$? + else + maestro test "$@" || rc=$? + fi + if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then + echo "::error title=Maestro run timed out::A 'maestro test' invocation exceeded 35m and was terminated (likely a wedged CoreSimulator). This is an environment failure, not an app or test regression." + fi + return 0 +} + +# The deeplink-login flow logs a live session token; scrub it before the logs +# upload as a public artifact. perl -i works on both Linux and macOS. +redact_uploaded_logs() { + local logs_dir="$HOME/.maestro/tests" + [ -d "$logs_dir" ] || return 0 + find "$logs_dir" -type f -print0 \ + | xargs -0 perl -pi -e 's/&token=[A-Za-z0-9_-]+/&token=***REDACTED***/g' 2>/dev/null || true +} + if [ "$PLATFORM" = "android" ]; then APP_ID="chat.rocket.android" else @@ -32,8 +59,24 @@ else fi fi +# Probe the E2E server up front so an outage surfaces as one clear annotation +# instead of opaque flow failures. data.js is the source of the server URL. +E2E_SERVER="$(sed -n "s/^[[:space:]]*server:[[:space:]]*['\"]\([^'\"]*\)['\"].*/\1/p" .maestro/scripts/data.js | head -1)" +if [ -z "$E2E_SERVER" ]; then + echo "::error title=E2E server URL not found::Could not scrape 'server' from .maestro/scripts/data.js — its format may have changed. This is a CI config failure, not an app or test regression." + exit 3 +fi +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 +# Redact on every exit path, before the always() upload reads the logs +trap 'rm -f "$MAPFILE"; redact_uploaded_logs' EXIT while IFS= read -r -d '' file; do if grep -qE "^[[:space:]]*-[[:space:]]*['\"]?test-${SHARD}['\"]?([[:space:]]*$|[[:space:]]*,|[[:space:]]*\\])" "$file"; then @@ -69,33 +112,36 @@ 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 + run_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" + else + run_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" + fi +} + +run_main_suite +# No JUnit output = startup failure; go red for a human re-run instead of +# auto-retrying, which would hide real startup breakage. if [ ! -f "$MAIN_REPORT" ]; then - echo "Main report not found" + echo "::error title=Maestro session produced no report::The Maestro run produced no JUnit output (session/driver-startup failure or a timeout — see the annotation above). Re-run the failed job if this looks transient." exit 1 fi @@ -147,21 +193,21 @@ while [ ${#CURRENT_FAILS[@]} -gt 0 ] && [ "$ROUND" -le "$MAX_RERUN_ROUNDS" ]; do RPT="${RERUN_REPORT_PREFIX}-round-${ROUND}.xml" if [ "$PLATFORM" = "android" ]; then - maestro test "${CURRENT_FAILS[@]}" \ + run_maestro_test "${CURRENT_FAILS[@]}" \ -e APP_ID="$APP_ID" \ --exclude-tags=util \ --include-tags="test-${SHARD}" \ --exclude-tags=ios-only \ --format junit \ - --output "$RPT" || true + --output "$RPT" else - maestro test "${CURRENT_FAILS[@]}" \ + run_maestro_test "${CURRENT_FAILS[@]}" \ -e APP_ID="$APP_ID" \ --exclude-tags=util \ --include-tags="test-${SHARD}" \ --exclude-tags=android-only \ --format junit \ - --output "$RPT" || true + --output "$RPT" fi if [ ! -f "$RPT" ]; then @@ -209,4 +255,12 @@ done echo "Retry strategy finished with remaining failures:" printf '%s\n' "${CURRENT_FAILS[@]}" + +# The server can also blip after preflight; scan the local logs and annotate so +# an environment failure doesn't read 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-develop.yml b/.github/workflows/build-develop.yml index 6747598ec17..55c9d7ed55c 100644 --- a/.github/workflows/build-develop.yml +++ b/.github/workflows/build-develop.yml @@ -40,3 +40,71 @@ jobs: secrets: inherit with: trigger: develop + + # Single writer of the AVD + SDK caches: PR-written caches aren't visible to + # other PRs, so develop seeds them and the shards restore read-only. + seed-android-avd: + name: Seed Android AVD Cache + if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }} + runs-on: ubuntu-latest + 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 + system image live outside ~/.android/avd — cache them + # separately so shards don't each re-download ~1 GB. + - 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 + + # Retried install so a corrupt partial download self-heals + - 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" diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index be6d8dd9e84..4ab33b53153 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -52,15 +52,31 @@ jobs: needs: [e2e-hold] secrets: inherit + # Validates flow tags and emits the shard list both matrices fan out over. + e2e-shards: + name: E2E Shard Preflight + if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }} + needs: [e2e-hold] + runs-on: ubuntu-latest + outputs: + shards: ${{ steps.shards.outputs.shards }} + steps: + - name: Checkout Repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Assert Maestro shard coverage + id: shards + run: bash .github/scripts/assert-maestro-shards.sh + 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-shards] secrets: inherit strategy: matrix: - shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] + shard: ${{ fromJSON(needs.e2e-shards.outputs.shards) }} fail-fast: false with: shard: ${{ matrix.shard }} @@ -76,11 +92,11 @@ jobs: name: E2E Run iOS if: ${{ github.repository == 'RocketChat/Rocket.Chat.ReactNative' }} uses: ./.github/workflows/maestro-ios.yml - needs: [e2e-build-ios] + needs: [e2e-build-ios, e2e-shards] secrets: inherit strategy: matrix: - shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] + shard: ${{ fromJSON(needs.e2e-shards.outputs.shards) }} fail-fast: false with: shard: ${{ matrix.shard }} diff --git a/.github/workflows/e2e-build-android.yml b/.github/workflows/e2e-build-android.yml index 91ef6212dd1..b10a1292a7c 100644 --- a/.github/workflows/e2e-build-android.yml +++ b/.github/workflows/e2e-build-android.yml @@ -16,6 +16,10 @@ jobs: android-build: runs-on: ubuntu-latest + env: + # Keep in sync with maestro-android.yml + MAESTRO_VERSION: 2.5.1 + outputs: artifact_name: Android APK @@ -26,6 +30,32 @@ jobs: - name: Checkout and Setup Node uses: ./.github/actions/setup-node + # Warm the Maestro cache the shards restore; this job is its single writer. + - name: Look up Maestro cache + id: maestro-lookup + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.maestro/bin + ~/.maestro/lib + ~/.maestro/deps + key: maestro-${{ runner.os }}-${{ env.MAESTRO_VERSION }} + lookup-only: true + + - name: Install Maestro + if: steps.maestro-lookup.outputs.cache-hit != 'true' + run: curl -fsSL "https://get.maestro.mobile.dev" | bash + + - name: Save Maestro cache + if: steps.maestro-lookup.outputs.cache-hit != 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.maestro/bin + ~/.maestro/lib + ~/.maestro/deps + key: maestro-${{ runner.os }}-${{ env.MAESTRO_VERSION }} + - name: Cache Gradle dependencies uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: diff --git a/.github/workflows/e2e-build-ios.yml b/.github/workflows/e2e-build-ios.yml index 3487a52163f..82c936de132 100644 --- a/.github/workflows/e2e-build-ios.yml +++ b/.github/workflows/e2e-build-ios.yml @@ -24,12 +24,42 @@ jobs: ios-build: runs-on: macos-26 + env: + # Keep in sync with maestro-ios.yml + MAESTRO_VERSION: 2.5.1 + steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up Node.js uses: ./.github/actions/setup-node + + # Warm the Maestro cache the shards restore; this job is its single writer. + - name: Look up Maestro cache + id: maestro-lookup + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.maestro/bin + ~/.maestro/lib + ~/.maestro/deps + key: maestro-${{ runner.os }}-${{ env.MAESTRO_VERSION }} + lookup-only: true + + - name: Install Maestro + if: steps.maestro-lookup.outputs.cache-hit != 'true' + run: curl -fsSL "https://get.maestro.mobile.dev" | bash + + - name: Save Maestro cache + if: steps.maestro-lookup.outputs.cache-hit != 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.maestro/bin + ~/.maestro/lib + ~/.maestro/deps + key: maestro-${{ runner.os }}-${{ env.MAESTRO_VERSION }} - name: Set up Xcode uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 diff --git a/.github/workflows/maestro-android.yml b/.github/workflows/maestro-android.yml index 80028fa74c7..cc4f83b826f 100644 --- a/.github/workflows/maestro-android.yml +++ b/.github/workflows/maestro-android.yml @@ -11,8 +11,9 @@ jobs: android-test: name: 'Android Tests' runs-on: ubuntu-latest + timeout-minutes: 55 env: - MAESTRO_VERSION: 2.2.0 + MAESTRO_VERSION: 2.5.1 ANDROID_AVD_HOME: /home/runner/.android/avd steps: @@ -25,18 +26,45 @@ jobs: distribution: temurin java-version: 17 - - name: Cache Android AVD - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + # Restore-only: the AVD snapshot is seeded on develop (build-develop.yml); + # on a miss the shard cold-boots. Shard 14 builds its own keyboard AVD. + - 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 - - name: Cache Maestro - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + # Restore-only: seeded on develop. Saves each shard re-downloading ~1 GB + # of emulator + system image; on a miss the preinstall step backfills. + - 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 + + # Retried install so a corrupt partial download self-heals instead of + # failing the shard ("Error on ZipFile unknown archive"). + - name: Pre-install Android SDK packages (cache miss only) + if: steps.sdk-cache.outputs.cache-hit != 'true' + uses: ./.github/actions/preinstall-android-sdk + + # Restore-only: written by e2e-build-android.yml. Install dirs only — the + # server-flake log scan must see only this run's logs. + - name: Restore Maestro cache + id: cache-maestro + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: - path: ~/.maestro + path: | + ~/.maestro/bin + ~/.maestro/lib + ~/.maestro/deps key: maestro-${{ runner.os }}-${{ env.MAESTRO_VERSION }} - name: Download APK @@ -50,9 +78,12 @@ jobs: E2E_ACCOUNT: ${{ secrets.E2E_ACCOUNT }} - name: Install Maestro - run: | - curl -Ls "https://get.maestro.mobile.dev" | bash - echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + # The install script always wipes ~/.maestro, so only run it on a cache miss. + 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 +165,14 @@ 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-snapshot-save keeps PRs restore-only; the AVD cache is written only on develop. - name: Start Android Emulator and Run Maestro Tests (default) if: ${{ inputs.shard != '14' }} uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0 @@ -152,11 +184,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 +196,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..bf44985256a 100644 --- a/.github/workflows/maestro-ios.yml +++ b/.github/workflows/maestro-ios.yml @@ -9,14 +9,64 @@ on: jobs: ios-test: - runs-on: macos-14 + runs-on: macos-26 + timeout-minutes: 40 env: - MAESTRO_VERSION: 2.2.0 + MAESTRO_VERSION: 2.5.1 steps: - name: Checkout Repo uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Set up Xcode + # Match the Xcode version e2e-build-ios.yml builds with + 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 + + # Boot async so the simulator boots while the remaining setup steps run; + # "Wait for Simulator Ready" blocks on it later. + - name: Start Simulator Boot + run: | + # Device names rotate with Xcode releases — resolve by family instead of pinning. + if [[ "${{ inputs.shard }}" == "14" ]]; then + # Shard 14 holds the small-screen flows: smallest iPhone, preferring an SE. + SIM_NAME=$(xcrun simctl list devices available --json \ + | jq -r '[.devices | to_entries[] | .value[] | select(.name | test("^iPhone SE"))] | first | .name') + if [[ -z "$SIM_NAME" || "$SIM_NAME" == "null" ]]; then + SIM_NAME=$(xcrun simctl list devices available --json \ + | jq -r '[.devices | to_entries[] | .value[] | select(.name | test("^iPhone [0-9]+( mini)?$"))] | sort_by(.name | capture("(?[0-9]+)").n | tonumber) | first | .name') + fi + SIM_CRITERION="smallest iPhone (small-screen shard)" + else + # Largest "iPhone N Pro" present in the installed runtimes. + 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') + SIM_CRITERION="largest iPhone N Pro" + fi + + if [[ -z "$SIM_NAME" || "$SIM_NAME" == "null" ]]; then + echo "::error::No simulator matched criterion: ${SIM_CRITERION}" + 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: @@ -34,61 +84,42 @@ jobs: with: E2E_ACCOUNT: ${{ secrets.E2E_ACCOUNT }} - - name: Cache Maestro - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + # Restore-only: written by e2e-build-ios.yml. Install dirs only — the + # server-flake log scan must see only this run's logs. + - name: Restore Maestro cache + id: cache-maestro + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: - path: ~/.maestro + path: | + ~/.maestro/bin + ~/.maestro/lib + ~/.maestro/deps 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" + # No idb-companion: Maestro drives iOS via XCUITest (saves ~110s/shard). + - name: Install Maestro + # The install script always wipes ~/.maestro, so only run it on a cache miss. + if: steps.cache-maestro.outputs.cache-hit != 'true' + run: curl -fsSL "https://get.maestro.mobile.dev" | bash - - 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: Add Maestro to PATH + run: echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" - - name: Boot Simulator + - 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 + # UIAnimationDragCoefficient is a duration multiplier (values > 1 SLOW + # animations down); near-zero makes transitions instant — the iOS + # analogue of Android's disable-animations. 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 - - name: Get Booted Simulator UDID - id: booted-sim - run: | - UDID=$(xcrun simctl list devices booted | grep -oE '[A-F0-9-]{36}' | head -n1) - echo "UDID=$UDID" - echo "UDID=$UDID" >> $GITHUB_ENV - - name: Install App run: | xcrun simctl install booted ios-simulator-app @@ -97,17 +128,16 @@ 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 }} + timeout-minutes: 30 + 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-with-deeplink.yaml b/.maestro/helpers/login-with-deeplink.yaml index 3f3e93e3c83..dba619eeea9 100644 --- a/.maestro/helpers/login-with-deeplink.yaml +++ b/.maestro/helpers/login-with-deeplink.yaml @@ -27,7 +27,16 @@ tags: visible: '.*Pixel Launcher.*' commands: - tapOn: 'Close App' -- extendedWaitUntil: - visible: - id: 'rooms-list-view' - timeout: 60000 +- retry: + maxRetries: 3 + commands: + # Cold-boot emulators can show "System UI isn't responding" over the app + - runFlow: + when: + visible: ".*isn't responding.*" + commands: + - tapOn: 'Wait' + - extendedWaitUntil: + visible: + id: 'rooms-list-view' + timeout: 60000 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..dfa7d04b2e3 100644 --- a/.maestro/helpers/open-deeplink.yaml +++ b/.maestro/helpers/open-deeplink.yaml @@ -12,12 +12,7 @@ tags: platform: iOS commands: - 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/delete-server.yaml b/.maestro/tests/assorted/delete-server.yaml index 5728d753b7a..7e63bee1ea9 100644 --- a/.maestro/tests/assorted/delete-server.yaml +++ b/.maestro/tests/assorted/delete-server.yaml @@ -5,7 +5,7 @@ onFlowStart: onFlowComplete: - evalScript: ${output.utils.deleteCreatedUsers()} tags: - - test-6 + - test-7 --- - evalScript: ${output.user = output.utils.createUser()} diff --git a/.maestro/tests/assorted/display-perf.yaml b/.maestro/tests/assorted/display-perf.yaml index 57f8a6c6591..54927e65a37 100644 --- a/.maestro/tests/assorted/display-perf.yaml +++ b/.maestro/tests/assorted/display-perf.yaml @@ -5,7 +5,7 @@ onFlowStart: onFlowComplete: - evalScript: ${output.utils.deleteCreatedUsers()} tags: - - test-6 + - test-8 --- - evalScript: ${output.user = output.utils.createUser()} 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/onboarding/server-history.yaml b/.maestro/tests/onboarding/server-history.yaml index 174708ee248..dce64351414 100644 --- a/.maestro/tests/onboarding/server-history.yaml +++ b/.maestro/tests/onboarding/server-history.yaml @@ -5,7 +5,7 @@ onFlowStart: onFlowEnd: - evalScript: ${output.utils.deleteCreatedUsers()} tags: - - test-3 + - test-1 --- - evalScript: ${output.user = output.utils.createUser()} diff --git a/.maestro/tests/onboarding/workspace/invalid-workspace.yaml b/.maestro/tests/onboarding/workspace/invalid-workspace.yaml index ad0f14623cf..1b23589c4fe 100644 --- a/.maestro/tests/onboarding/workspace/invalid-workspace.yaml +++ b/.maestro/tests/onboarding/workspace/invalid-workspace.yaml @@ -1,7 +1,7 @@ appId: ${APP_ID} name: Invalid Workspace tags: - - test-3 + - test-2 --- - runFlow: ../../../helpers/launch-app.yaml diff --git a/.maestro/tests/onboarding/workspace/valid-workspace.yaml b/.maestro/tests/onboarding/workspace/valid-workspace.yaml index e082dcceecd..594564c66ec 100644 --- a/.maestro/tests/onboarding/workspace/valid-workspace.yaml +++ b/.maestro/tests/onboarding/workspace/valid-workspace.yaml @@ -3,7 +3,7 @@ name: Valid Workspace onFlowStart: - runFlow: '../../../helpers/setup.yaml' tags: - - test-3 + - test-2 --- - runFlow: ../../../helpers/launch-app.yaml 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/message-markdown-click.yaml b/.maestro/tests/room/message-markdown-click.yaml index 9f7db5977ad..caffeae2f2f 100644 --- a/.maestro/tests/room/message-markdown-click.yaml +++ b/.maestro/tests/room/message-markdown-click.yaml @@ -6,7 +6,7 @@ onFlowStart: onFlowComplete: - evalScript: ${output.utils.deleteCreatedUsers()} tags: - - test-12 + - test-9 --- - evalScript: ${output.user = output.utils.createUser()} diff --git a/.maestro/tests/room/room-actions.yaml b/.maestro/tests/room/room-actions.yaml index f4e26cb28d6..52a1d7332a9 100644 --- a/.maestro/tests/room/room-actions.yaml +++ b/.maestro/tests/room/room-actions.yaml @@ -171,8 +171,8 @@ tags: from: id: action-sheet-handle direction: UP -- extendedWaitUntil: - visible: +- scrollUntilVisible: + element: text: 'Unstar' timeout: 60000 - tapOn: diff --git a/.maestro/tests/room/room-info.yaml b/.maestro/tests/room/room-info.yaml index 07a3f6ecebf..7859a2a7fa1 100644 --- a/.maestro/tests/room/room-info.yaml +++ b/.maestro/tests/room/room-info.yaml @@ -5,7 +5,7 @@ onFlowStart: onFlowComplete: - evalScript: ${output.utils.deleteCreatedUsers()} tags: - - test-12 + - test-9 --- - evalScript: ${output.user = output.utils.createUser()} diff --git a/.maestro/tests/room/room.yaml b/.maestro/tests/room/room.yaml index 40732df488b..33273db1ac1 100644 --- a/.maestro/tests/room/room.yaml +++ b/.maestro/tests/room/room.yaml @@ -603,6 +603,10 @@ tags: id: 'message-composer-input' - eraseText - inputText: ${output.quoteMessage} +- extendedWaitUntil: + visible: + text: ${output.quoteMessage} + timeout: 10000 - runFlow: '../../helpers/go-back.yaml' - runFlow: file: '../../helpers/navigate-to-room.yaml' @@ -616,6 +620,10 @@ tags: id: 'message-composer-input' - eraseText - inputText: ${output.quoteMessage} +- extendedWaitUntil: + visible: + text: ${output.quoteMessage} + timeout: 10000 - runFlow: '../../helpers/go-back.yaml' - runFlow: file: '../../helpers/navigate-to-room.yaml' diff --git a/.maestro/tests/room/share-message.yaml b/.maestro/tests/room/share-message.yaml index 6864b3df583..3be6cd5c885 100644 --- a/.maestro/tests/room/share-message.yaml +++ b/.maestro/tests/room/share-message.yaml @@ -3,7 +3,7 @@ name: Share Message onFlowStart: - runFlow: '../../helpers/setup.yaml' tags: - - test-12 + - test-9 --- - evalScript: ${output.user = output.utils.createUser()} diff --git a/.maestro/tests/room/threads.yaml b/.maestro/tests/room/threads.yaml index ebdb7bf7511..20261bdc51c 100644 --- a/.maestro/tests/room/threads.yaml +++ b/.maestro/tests/room/threads.yaml @@ -5,7 +5,7 @@ onFlowStart: onFlowComplete: - evalScript: ${output.utils.deleteCreatedUsers()} tags: - - test-12 + - test-13 --- - evalScript: ${output.user = output.utils.createUser()} @@ -324,9 +324,21 @@ tags: visible: id: 'message-composer-input-thread' timeout: 60000 -- tapOn: - id: 'message-composer-input-thread' -- inputText: 'replied' +- retry: + maxRetries: 2 + commands: + - tapOn: + id: 'message-composer-input-thread' + - eraseText + - inputText: 'replied' + - extendedWaitUntil: + visible: + text: 'replied' + timeout: 10000 +- extendedWaitUntil: + visible: + id: 'message-composer-send' + timeout: 60000 - tapOn: id: 'message-composer-send' - extendedWaitUntil: 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 84f9380703b..3449efafee2 100644 --- a/app/containers/FormContainer.tsx +++ b/app/containers/FormContainer.tsx @@ -1,9 +1,9 @@ import { type ReactElement } 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 922c5956357..48968c76dc2 100644 --- a/app/containers/TextInput/FormTextInput.tsx +++ b/app/containers/TextInput/FormTextInput.tsx @@ -126,6 +126,17 @@ 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. Since `secureTextEntry` is itself + // a credential trigger, masking is necessarily disabled under E2E on iOS — + // only throwaway test users are affected. The eye icon still renders (driven + // by the original prop) but is a no-op while suppression is active. + const suppressIOSCredentialOffer = isIOS && process.env.RUNNING_E2E_TESTS === 'true'; const accessibilityLabelText = useMemo(() => { const baseLabel = `${accessibilityLabel || label || ''}`; const formattedAccessibilityLabel = baseLabel ? `${baseLabel}.` : ''; @@ -174,12 +185,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/lib/services/sdk.test.ts b/app/lib/services/sdk.test.ts new file mode 100644 index 00000000000..f82d0df02dc --- /dev/null +++ b/app/lib/services/sdk.test.ts @@ -0,0 +1,88 @@ +import sdk from './sdk'; + +const mockInnerMethodCall = jest.fn(); +const mockTwoFactor = jest.fn(); + +jest.mock('@rocket.chat/sdk', () => ({ + Rocketchat: jest.fn().mockImplementation(() => ({ + methodCall: (...args: unknown[]) => mockInnerMethodCall(...args) + })), + settings: { customHeaders: {} } +})); + +jest.mock('./twoFactor', () => ({ + twoFactor: (...args: unknown[]) => mockTwoFactor(...args) +})); + +beforeEach(() => { + jest.clearAllMocks(); + sdk.initialize('https://example.com'); +}); + +describe('sdk.methodCall', () => { + it('no 2FA in progress → exact args, no trailing junk', async () => { + mockInnerMethodCall.mockResolvedValue('ok'); + + const result = await sdk.methodCall('loadSurroundingMessages', { messageId: 'x' }, false); + + expect(result).toBe('ok'); + expect(mockInnerMethodCall).toHaveBeenCalledWith('loadSurroundingMessages', { messageId: 'x' }, false); + expect(mockInnerMethodCall.mock.calls[0]).toHaveLength(3); + }); + + it('totp-required → prompt, retry with the code appended', async () => { + const totpObject = { twoFactorCode: 'CODE', twoFactorMethod: 'totp' }; + mockInnerMethodCall.mockRejectedValueOnce({ + error: 'totp-required', + details: { method: 'totp' } + }); + mockInnerMethodCall.mockResolvedValueOnce('ok'); + mockTwoFactor.mockResolvedValue(totpObject); + + const result = await sdk.methodCall('someMethod', 'arg1'); + + expect(result).toBe('ok'); + expect(mockTwoFactor).toHaveBeenCalledTimes(1); + expect(mockTwoFactor).toHaveBeenCalledWith({ method: 'totp', invalid: false }); + // Second call should have the TOTP object appended + expect(mockInnerMethodCall.mock.calls[1]).toEqual(['someMethod', 'arg1', totpObject]); + }); + + it('the code does not leak into the next call', async () => { + const totpObject = { twoFactorCode: 'CODE', twoFactorMethod: 'totp' }; + // First call: rejection that prompts for 2FA + mockInnerMethodCall.mockRejectedValueOnce({ + error: 'totp-required', + details: { method: 'totp' } + }); + // Retry with 2FA code + mockInnerMethodCall.mockResolvedValueOnce('ok'); + // Second unrelated call + mockInnerMethodCall.mockResolvedValueOnce('ok2'); + mockTwoFactor.mockResolvedValue(totpObject); + + // First call with 2FA prompt + await sdk.methodCall('method1', 'a'); + + // Second call should NOT have the code appended + const result = await sdk.methodCall('other/method', 'a'); + + expect(result).toBe('ok2'); + // Third call (the second method call after 2FA) should have exactly 2 args + expect(mockInnerMethodCall.mock.calls[2]).toEqual(['other/method', 'a']); + expect(mockInnerMethodCall.mock.calls[2]).toHaveLength(2); + }); + + it('twoFactor canceled → resolves to {}', async () => { + mockInnerMethodCall.mockRejectedValue({ + error: 'totp-required', + details: { method: 'totp' } + }); + mockTwoFactor.mockRejectedValue(new Error('Canceled')); + + const result = await sdk.methodCall('m'); + + expect(result).toEqual({}); + expect(mockInnerMethodCall).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/lib/services/sdk.ts b/app/lib/services/sdk.ts index 10965b48cf6..d8be776f45a 100644 --- a/app/lib/services/sdk.ts +++ b/app/lib/services/sdk.ts @@ -111,7 +111,10 @@ class Sdk { methodCall(...args: any[]): Promise { return new Promise(async (resolve, reject) => { try { - const result = await this.current.methodCall(...args, this.code || ''); + // Clear the 2FA code after use — a stale trailing arg breaks typed method signatures + const { code } = this; + this.code = null; + const result = await this.current.methodCall(...args, ...(code ? [code] : [])); return resolve(result); } catch (e: any) { if (e.error && (e.error === 'totp-required' || e.error === 'totp-invalid')) { diff --git a/app/views/RoomView/List/hooks/useScroll.test.ts b/app/views/RoomView/List/hooks/useScroll.test.ts new file mode 100644 index 00000000000..46f7f14f324 --- /dev/null +++ b/app/views/RoomView/List/hooks/useScroll.test.ts @@ -0,0 +1,161 @@ +import { act, renderHook } from '@testing-library/react-native'; + +import { useScroll } from './useScroll'; + +const buildRefs = (ids: string[]) => ({ + listRef: { current: { scrollToIndex: jest.fn(), scrollToEnd: jest.fn(), scrollToOffset: jest.fn() } } as any, + messagesIds: { current: ids } as any +}); + +beforeEach(() => { + jest.useFakeTimers(); +}); +afterEach(() => { + jest.useRealTimers(); +}); + +describe('useScroll — jumpToMessage no-progress exit', () => { + it('gives up after 5 no-progress rounds when history never grows', async () => { + const ids: string[] = ['a', 'b', 'c']; + const { listRef, messagesIds } = buildRefs(ids); + const { result } = renderHook(() => useScroll({ listRef, messagesIds })); + + let done = false; + const promise = result.current.jumpToMessage('missing'); + promise.then(() => { + done = true; + }); + + // Round 1: initialises lastLoadedCount (from -1 → 3), noProgressRounds stays 0 + await act(() => jest.advanceTimersByTimeAsync(600)); + expect(done).toBe(false); + + // Rounds 2–5: count stays at 3, noProgressRounds increments each time (4 × 600ms) + await act(() => jest.advanceTimersByTimeAsync(2400)); + + // After round 5 with no progress the loop should have resolved + await act(() => jest.advanceTimersByTimeAsync(0)); + expect(done).toBe(true); + + // scrollToEnd must not have been called more than 6 times total + expect(listRef.current.scrollToEnd.mock.calls.length).toBeLessThanOrEqual(6); + }); + + it('keeps retrying while history grows and resolves when the target id appears', async () => { + const refs = buildRefs(['a', 'b']); + const { listRef, messagesIds } = refs; + const { result } = renderHook(() => useScroll({ listRef, messagesIds })); + + let done = false; + const promise = result.current.jumpToMessage('target'); + promise.then(() => { + done = true; + }); + + // Each round grows the list then advances one 600ms interval; 6 growth + // rounds is past the 5-round no-progress threshold, so surviving them + // proves growth resets the counter. + const growAndAdvance = (id: string) => + act(() => { + messagesIds.current = [...messagesIds.current, id]; + return jest.advanceTimersByTimeAsync(600); + }); + await growAndAdvance('extra-0'); + await growAndAdvance('extra-1'); + await growAndAdvance('extra-2'); + await growAndAdvance('extra-3'); + await growAndAdvance('extra-4'); + await growAndAdvance('extra-5'); + + // Still pending — has not given up because count kept growing + expect(done).toBe(false); + + // Now inject the target id and trigger the found-branch + messagesIds.current = [...messagesIds.current, 'target']; + await act(() => jest.advanceTimersByTimeAsync(600)); + + // scrollToIndex must have been called with the index of 'target' + const { calls } = listRef.current.scrollToIndex.mock; + expect(calls.length).toBeGreaterThan(0); + const [lastCall] = calls[calls.length - 1]; + expect(lastCall.index).toBe(messagesIds.current.indexOf('target')); + }); + + it('cancel still works mid-loop, well before the 5-round threshold', async () => { + const ids: string[] = ['a']; + const { listRef, messagesIds } = buildRefs(ids); + const { result } = renderHook(() => useScroll({ listRef, messagesIds })); + + let done = false; + const promise = result.current.jumpToMessage('missing'); + promise.then(() => { + done = true; + }); + + // 2 rounds of no-progress (well below the threshold of 5) + await act(() => jest.advanceTimersByTimeAsync(600)); + await act(() => jest.advanceTimersByTimeAsync(600)); + expect(done).toBe(false); + + // Cancel the jump + act(() => { + result.current.cancelJumpToMessage(); + }); + + // One more round — the cancel flag causes early resolve + await act(() => jest.advanceTimersByTimeAsync(600)); + await act(() => jest.advanceTimersByTimeAsync(0)); + expect(done).toBe(true); + + // Should have resolved via cancel long before the 5-round threshold + expect(listRef.current.scrollToEnd.mock.calls.length).toBeLessThan(5); + }); + + it('resets counters between jumps so a new jump does not give up immediately', async () => { + const ids: string[] = ['a', 'b', 'c']; + const { listRef, messagesIds } = buildRefs(ids); + const { result } = renderHook(() => useScroll({ listRef, messagesIds })); + + // First jump: exhaust no-progress rounds and give up. + // Round 1 (init) + rounds 2–5 (no-progress) = 1 + 5 × 600ms advances. + let firstDone = false; + const first = result.current.jumpToMessage('missing'); + first.then(() => { + firstDone = true; + }); + + await act(() => jest.advanceTimersByTimeAsync(600)); + await act(() => jest.advanceTimersByTimeAsync(3000)); + await act(() => jest.advanceTimersByTimeAsync(0)); + expect(firstDone).toBe(true); + + // Grow the list and add the target before the second jump + messagesIds.current = [...messagesIds.current, 'present']; + + // Second jump: target is already in the list → should scroll immediately + let secondDone = false; + const second = result.current.jumpToMessage('present'); + second.then(() => { + secondDone = true; + }); + + // Inject viewableItems before any timer fires so the first 300ms visibility + // check sees the message as visible + act(() => { + result.current.viewabilityConfigCallbackPairs.current[0].onViewableItemsChanged?.({ + viewableItems: [{ key: 'present', item: {}, index: messagesIds.current.indexOf('present'), isViewable: true }], + changed: [] + }); + }); + + // Advance past the 300ms scroll-animation wait — viewableItems is already + // populated, so the found-branch resolves immediately + await act(() => jest.advanceTimersByTimeAsync(300)); + await act(() => jest.advanceTimersByTimeAsync(0)); + expect(secondDone).toBe(true); + + // scrollToIndex must have been called for the second jump — counters did not cause early exit + const { calls: indexCalls } = listRef.current.scrollToIndex.mock; + expect(indexCalls.length).toBeGreaterThan(0); + }); +}); diff --git a/app/views/RoomView/List/hooks/useScroll.ts b/app/views/RoomView/List/hooks/useScroll.ts index b83fe5cdb32..8cd8695575a 100644 --- a/app/views/RoomView/List/hooks/useScroll.ts +++ b/app/views/RoomView/List/hooks/useScroll.ts @@ -8,6 +8,8 @@ export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; message const [highlightedMessageId, setHighlightedMessageId] = useState(null); const cancelJump = useRef(false); const jumping = useRef(false); + const lastLoadedCount = useRef(-1); + const noProgressRounds = useRef(0); const viewableItems = useRef(null); const highlightTimeout = useRef | null>(null); @@ -75,7 +77,22 @@ export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; message resetJumpToMessage(); resolve(); } else { - // if message not on state yet, scroll to top, so it triggers onEndReached and try again + // if message not on state yet, check whether history is still loading: + // when the loaded count stops growing across consecutive retries, the + // target can never appear (deleted message or history exhausted) — give + // up instead of looping until the caller's race timeout fires. + const loadedCount = messagesIds.current?.length ?? 0; + if (loadedCount === lastLoadedCount.current) { + noProgressRounds.current += 1; + } else { + noProgressRounds.current = 0; + } + lastLoadedCount.current = loadedCount; + if (noProgressRounds.current >= 5) { + resetJumpToMessage(); + return resolve(); + } + // scroll to top, so it triggers onEndReached and try again listRef.current?.scrollToEnd(); await setTimeout(() => resolve(jumpToMessage(messageId)), 600); } @@ -84,6 +101,8 @@ export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; message const resetJumpToMessage = () => { cancelJump.current = false; jumping.current = false; + lastLoadedCount.current = -1; + noProgressRounds.current = 0; }; const cancelJumpToMessage: IListContainerRef['cancelJumpToMessage'] = () => { diff --git a/app/views/RoomView/index.tsx b/app/views/RoomView/index.tsx index 650752eefec..3105dc61a17 100644 --- a/app/views/RoomView/index.tsx +++ b/app/views/RoomView/index.tsx @@ -1033,7 +1033,7 @@ class RoomView extends 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 - - - - - - + + + + + + + + + ); };