diff --git a/.github/actions/generate-specs/split-tests.js b/.github/actions/generate-specs/split-tests.js index cf221c3359..3f54476d83 100644 --- a/.github/actions/generate-specs/split-tests.js +++ b/.github/actions/generate-specs/split-tests.js @@ -39,6 +39,11 @@ class Specs { const stats = fs.statSync(filePath); if (stats.isDirectory()) { + // iPad tests are iOS-only; exclude from Android and other non-iPad runs. + // They run in their own isolated job with search_path pointing directly at ipad/. + if (file === 'ipad') { + return; + } walkSync(filePath); } else if (fileRegex.test(filePath)) { const relativeFilePath = filePath.replace(dirPath + '/', ''); diff --git a/.github/actions/prepare-ios-build/action.yaml b/.github/actions/prepare-ios-build/action.yaml index e1cb57287c..72d5949cff 100644 --- a/.github/actions/prepare-ios-build/action.yaml +++ b/.github/actions/prepare-ios-build/action.yaml @@ -45,9 +45,9 @@ runs: path: | ios/Pods libraries/@mattermost/intune/ios/Frameworks - key: ${{ runner.os }}-pods-v4-intune-${{ inputs.intune-enabled }}-${{ steps.intune-hash.outputs.hash }}-${{ hashFiles('ios/Podfile.lock') }}-${{ github.ref_name }} + key: ${{ runner.os }}-pods-v4-intune-${{ inputs.intune-enabled }}-${{ steps.intune-hash.outputs.hash }}-${{ hashFiles('ios/Podfile.lock') }} restore-keys: | - ${{ runner.os }}-pods-v4-intune-${{ inputs.intune-enabled }}-${{ steps.intune-hash.outputs.hash }}-${{ hashFiles('ios/Podfile.lock') }}- + ${{ runner.os }}-pods-v4-intune-${{ inputs.intune-enabled }}-${{ steps.intune-hash.outputs.hash }}- - name: ci/install-pods-dependencies shell: bash diff --git a/.github/actions/prepare-node-deps/action.yaml b/.github/actions/prepare-node-deps/action.yaml index 708c569b6f..d698928ee4 100644 --- a/.github/actions/prepare-node-deps/action.yaml +++ b/.github/actions/prepare-node-deps/action.yaml @@ -21,14 +21,6 @@ runs: node node_modules/\@sentry/cli/scripts/install.js echo "::endgroup::" - - name: Cache Node.js modules - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - name: ci/patch-npm-dependencies shell: bash run: | diff --git a/.github/actions/test/action.yaml b/.github/actions/test/action.yaml index db6b246723..9de64cd41a 100644 --- a/.github/actions/test/action.yaml +++ b/.github/actions/test/action.yaml @@ -11,7 +11,19 @@ runs: shell: bash run: | echo "::group::check-styles" - npm run check + npm run lint & + LINT_PID=$! + npm run tsc & + TSC_PID=$! + set +e + wait "$LINT_PID" + LINT_EXIT=$? + wait "$TSC_PID" + TSC_EXIT=$? + set -e + if [ $LINT_EXIT -ne 0 ] || [ $TSC_EXIT -ne 0 ]; then + exit 1 + fi echo "::endgroup::" - name: ci/run-tests diff --git a/.github/workflows/compatibility-matrix-testing.yml b/.github/workflows/compatibility-matrix-testing.yml index e365caf44f..fbaa5c217b 100644 --- a/.github/workflows/compatibility-matrix-testing.yml +++ b/.github/workflows/compatibility-matrix-testing.yml @@ -99,6 +99,42 @@ jobs: name: ios-build-simulator-${{ github.run_id }} path: Mattermost-simulator-x86_64.app.zip + build-android-apk: + runs-on: ubuntu-latest-8-cores + needs: + - update-initial-status + env: + ORG_GRADLE_PROJECT_jvmargs: -Xmx8g + steps: + - name: Prune Docker to free up space + run: docker system prune -af + + - name: Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ inputs.MOBILE_VERSION }} + + - name: Prepare Android Build + uses: ./.github/actions/prepare-android-build + env: + STORE_FILE: ${{ secrets.MM_MOBILE_STORE_FILE }} + STORE_ALIAS: ${{ secrets.MM_MOBILE_STORE_ALIAS }} + STORE_PASSWORD: ${{ secrets.MM_MOBILE_STORE_PASSWORD }} + MATTERMOST_BUILD_GH_TOKEN: ${{ secrets.MATTERMOST_BUILD_GH_TOKEN }} + + - name: Detox build + run: | + cd detox + npm ci --prefer-offline --no-audit --no-fund + npm install -g detox-cli + npm run e2e:android-build + + - name: Upload Android Build + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: android-build-files-${{ github.run_id }} + path: "android/app/build/**/*" + detox-e2e: name: mobile-cmt-${{ matrix.server.version }} uses: ./.github/workflows/e2e-ios-template.yml @@ -113,12 +149,80 @@ jobs: MM_TEST_SERVER_URL: ${{ matrix.server.url }} MOBILE_VERSION: ${{ inputs.MOBILE_VERSION }} + detox-ipad-e2e: + name: mobile-cmt-ipad-${{ matrix.server.version }} + uses: ./.github/workflows/e2e-ios-template.yml + needs: + - build-ios-simulator + strategy: + fail-fast: false + matrix: ${{ fromJson(inputs.CMT_MATRIX) }} + secrets: inherit + with: + run-type: "RELEASE" + MM_TEST_SERVER_URL: ${{ matrix.server.url }} + MOBILE_VERSION: ${{ inputs.MOBILE_VERSION }} + ios_device_name: "iPad Pro 13-inch (M5)" + ios_device_os_name: "iOS 26.2" + search_path: "detox/e2e/test/products/channels/ipad" + parallelism: "1" + detox_config: "ios.ipad.debug" + + maestro-ios-e2e: + name: mobile-cmt-maestro-ios-${{ matrix.server.version }} + uses: ./.github/workflows/e2e-maestro-template.yml + needs: + - build-ios-simulator + strategy: + fail-fast: false + matrix: ${{ fromJson(inputs.CMT_MATRIX) }} + secrets: inherit + with: + platform: ios + run-type: "RELEASE" + MM_TEST_SERVER_URL: ${{ matrix.server.url }} + MOBILE_VERSION: ${{ inputs.MOBILE_VERSION }} + + detox-android-e2e: + name: mobile-cmt-android-${{ matrix.server.version }} + uses: ./.github/workflows/e2e-android-template.yml + needs: + - build-android-apk + strategy: + fail-fast: false + matrix: ${{ fromJson(inputs.CMT_MATRIX) }} + secrets: inherit + with: + run-android-tests: true + run-type: "RELEASE" + MM_TEST_SERVER_URL: ${{ matrix.server.url }} + MOBILE_VERSION: ${{ inputs.MOBILE_VERSION }} + + maestro-android-e2e: + name: mobile-cmt-maestro-android-${{ matrix.server.version }} + uses: ./.github/workflows/e2e-maestro-template.yml + needs: + - build-android-apk + strategy: + fail-fast: false + matrix: ${{ fromJson(inputs.CMT_MATRIX) }} + secrets: inherit + with: + platform: android + run-type: "RELEASE" + MM_TEST_SERVER_URL: ${{ matrix.server.url }} + MOBILE_VERSION: ${{ inputs.MOBILE_VERSION }} + update-final-status: runs-on: ubuntu-22.04 if: always() needs: - calculate-commit-hash - detox-e2e + - detox-android-e2e + - detox-ipad-e2e + - maestro-ios-e2e + - maestro-android-e2e steps: - uses: mattermost/actions/delivery/update-commit-status@main env: diff --git a/.github/workflows/e2e-android-template.yml b/.github/workflows/e2e-android-template.yml index 464081e071..89ac597a4e 100644 --- a/.github/workflows/e2e-android-template.yml +++ b/.github/workflows/e2e-android-template.yml @@ -26,7 +26,7 @@ on: MOBILE_VERSION: description: "The mobile version to test" required: false - default: ${{ github.head_ref || github.ref }} + default: "" type: string run-android-tests: description: "Run Android tests" @@ -51,16 +51,26 @@ on: required: false type: boolean default: false + search_path: + description: "Path to search for test specs (use for smoke test subset)" + required: false + type: string + default: "detox/e2e/test" + parallelism: + description: "Number of parallel test shards" + required: false + type: string + default: "20" android_avd_name: description: "Android Emulator name" required: false type: string - default: "detox_pixel_4_xl" + default: "detox_pixel_8" android_api_level: description: "Android API level" required: false type: string - default: "34" + default: "35" outputs: STATUS: value: ${{ jobs.generate-report.outputs.STATUS }} @@ -68,12 +78,22 @@ on: value: ${{ jobs.generate-report.outputs.TARGET_URL }} FAILURES: value: ${{ jobs.generate-report.outputs.FAILURES }} + PASSES: + value: ${{ jobs.generate-report.outputs.PASSES }} + TOTAL: + value: ${{ jobs.generate-report.outputs.TOTAL }} + SKIPPED: + value: ${{ jobs.generate-report.outputs.SKIPPED }} + ERRORS: + value: ${{ jobs.generate-report.outputs.ERRORS }} + PERCENTAGE: + value: ${{ jobs.generate-report.outputs.PERCENTAGE }} env: AWS_REGION: "us-east-1" ADMIN_EMAIL: ${{ secrets.MM_MOBILE_E2E_ADMIN_EMAIL }} - ADMIN_USERNAME: ${{ secrets.MM_MOBILE_E2E_ADMIN_USERNAME }} - ADMIN_PASSWORD: ${{ secrets.MM_MOBILE_E2E_ADMIN_PASSWORD }} + ADMIN_USERNAME: ${{ inputs.MM_TEST_USER_NAME || secrets.MM_MOBILE_E2E_ADMIN_USERNAME }} + ADMIN_PASSWORD: ${{ inputs.MM_TEST_PASSWORD || secrets.MM_MOBILE_E2E_ADMIN_PASSWORD }} BRANCH: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }} COMMIT_HASH: ${{ github.sha }} DEVICE_NAME: ${{ inputs.android_avd_name }} # This is needed to split tests as same code is used in iOS job @@ -129,8 +149,8 @@ jobs: id: generate-specs uses: ./.github/actions/generate-specs with: - parallelism: 10 - search_path: detox/e2e/test + parallelism: ${{ inputs.parallelism }} + search_path: ${{ inputs.search_path }} device_name: ${{ env.AVD_NAME }} device_os_version: ${{ env.SDK_VERSION }} @@ -165,13 +185,15 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Prepare Android Build - uses: ./.github/actions/prepare-android-build - env: - STORE_FILE: "${{ secrets.MM_MOBILE_STORE_FILE }}" - STORE_ALIAS: "${{ secrets.MM_MOBILE_STORE_ALIAS }}" - STORE_PASSWORD: "${{ secrets.MM_MOBILE_STORE_PASSWORD }}" - MATTERMOST_BUILD_GH_TOKEN: "${{ secrets.MATTERMOST_BUILD_GH_TOKEN }}" + - name: ci/prepare-node-deps + uses: ./.github/actions/prepare-node-deps + + - name: Cache detox node_modules + uses: actions/cache@v4 + with: + path: detox/node_modules + key: ${{ runner.os }}-detox-deps-${{ hashFiles('detox/package-lock.json') }} + restore-keys: ${{ runner.os }}-detox-deps- - name: Install Detox Dependencies run: | @@ -184,9 +206,6 @@ jobs: RUNNING_E2E=true EOF - - name: Create destination path - run: mkdir -p android/app/build - - name: Download APK artifact uses: actions/download-artifact@v4 with: @@ -222,7 +241,19 @@ jobs: - name: Install Android SDK components run: | - yes | sdkmanager --install "platform-tools" "emulator" "platforms;android-34" "system-images;android-34;default;x86_64" "system-images;android-34;google_apis;x86_64" + for attempt in 1 2 3; do + echo "SDK install attempt $attempt..." + if yes | sdkmanager --install "platform-tools" "emulator" "platforms;android-35" "system-images;android-35;google_apis;x86_64"; then + echo "SDK install succeeded on attempt $attempt" + break + fi + echo "Attempt $attempt failed, retrying..." + if [ "$attempt" = "3" ]; then + echo "SDK install failed after 3 attempts" + exit 1 + fi + sleep 10 + done env: JAVA_HOME: ${{ env.JAVA_HOME_17_X64 }} @@ -233,7 +264,7 @@ jobs: /usr/local/lib/android/sdk/system-images /usr/local/lib/android/sdk/emulator ~/.android/avd - key: android-emulator-${{ runner.os }}-${{ runner.arch }}-api-34 + key: android-emulator-${{ runner.os }}-${{ runner.arch }}-api-35 restore-keys: | android-emulator-${{ runner.os }}-${{ runner.arch }}- @@ -242,6 +273,10 @@ jobs: cd detox chmod +x ./create_android_emulator.sh CI=true ./create_android_emulator.sh ${{ env.SDK_VERSION }} ${{ env.AVD_NAME }} ${{ matrix.specs }} + env: + SITE_1_URL: ${{ env.SITE_1_URL }} + SITE_2_URL: ${{ env.SITE_2_URL }} + SITE_3_URL: ${{ env.SITE_3_URL }} continue-on-error: true # We want to run all the tests - name: Upload Android Test Report @@ -252,6 +287,7 @@ jobs: path: detox/artifacts/ generate-report: + if: always() runs-on: ubuntu-22.04 needs: - generate-specs @@ -260,6 +296,11 @@ jobs: TARGET_URL: ${{ steps.set-url.outputs.TARGET_URL }} STATUS: ${{ steps.determine-status.outputs.STATUS }} FAILURES: ${{ steps.summary.outputs.FAILURES }} + PASSES: ${{ steps.summary.outputs.PASSES }} + TOTAL: ${{ steps.summary.outputs.TOTAL }} + SKIPPED: ${{ steps.summary.outputs.SKIPPED }} + ERRORS: ${{ steps.summary.outputs.ERRORS }} + PERCENTAGE: ${{ steps.summary.outputs.PERCENTAGE }} steps: - name: Checkout Repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -300,12 +341,22 @@ jobs: - name: Calculate failures id: summary run: | - echo "FAILURES=$(cat detox/artifacts/summary.json | jq .stats.failures)" >> ${GITHUB_OUTPUT} - echo "PASSES=$(cat detox/artifacts/summary.json | jq .stats.passes)" >> ${GITHUB_OUTPUT} - echo "SKIPPED=$(cat detox/artifacts/summary.json | jq .stats.skipped)" >> ${GITHUB_OUTPUT} - echo "TOTAL=$(cat detox/artifacts/summary.json | jq .stats.tests)" >> ${GITHUB_OUTPUT} - echo "ERRORS=$(cat detox/artifacts/summary.json | jq .stats.errors)" >> ${GITHUB_OUTPUT} - echo "PERCENTAGE=$(cat detox/artifacts/summary.json | jq .stats.passPercent)" >> ${GITHUB_OUTPUT} + if [ -f detox/artifacts/summary.json ]; then + echo "FAILURES=$(cat detox/artifacts/summary.json | jq .stats.failures)" >> ${GITHUB_OUTPUT} + echo "PASSES=$(cat detox/artifacts/summary.json | jq .stats.passes)" >> ${GITHUB_OUTPUT} + echo "SKIPPED=$(cat detox/artifacts/summary.json | jq .stats.skipped)" >> ${GITHUB_OUTPUT} + echo "TOTAL=$(cat detox/artifacts/summary.json | jq .stats.tests)" >> ${GITHUB_OUTPUT} + echo "ERRORS=$(cat detox/artifacts/summary.json | jq .stats.errors)" >> ${GITHUB_OUTPUT} + echo "PERCENTAGE=$(cat detox/artifacts/summary.json | jq .stats.passPercent)" >> ${GITHUB_OUTPUT} + else + echo "⚠️ No summary.json found — all test machines likely failed before producing results" + echo "FAILURES=-1" >> ${GITHUB_OUTPUT} + echo "PASSES=0" >> ${GITHUB_OUTPUT} + echo "SKIPPED=0" >> ${GITHUB_OUTPUT} + echo "TOTAL=0" >> ${GITHUB_OUTPUT} + echo "ERRORS=0" >> ${GITHUB_OUTPUT} + echo "PERCENTAGE=0" >> ${GITHUB_OUTPUT} + fi - name: Set Target URL id: set-url @@ -314,51 +365,31 @@ jobs: - name: Determine Status id: determine-status + env: + FAILURES: ${{ steps.summary.outputs.FAILURES }} + TESTCASE_FAILURE_FATAL: ${{ inputs.testcase_failure_fatal }} run: | - if [[ ${{ steps.summary.outputs.failures }} -gt 0 && "${{ inputs.testcase_failure_fatal }}" == "true" ]]; then + if [[ "$FAILURES" == "-1" ]]; then + echo "STATUS=failure" >> ${GITHUB_OUTPUT} + elif [[ "$FAILURES" -gt 0 && "$TESTCASE_FAILURE_FATAL" == "true" ]]; then echo "STATUS=failure" >> ${GITHUB_OUTPUT} else echo "STATUS=success" >> ${GITHUB_OUTPUT} fi - name: Generate Summary - run: | - echo "| Tests | Passed :white_check_mark: | Failed :x: | Skipped :fast_forward: | Errors :warning: | " >> ${GITHUB_STEP_SUMMARY} - echo "|:---:|:---:|:---:|:---:|:---:|" >> ${GITHUB_STEP_SUMMARY} - echo "| ${{ steps.summary.outputs.TOTAL }} | ${{ steps.summary.outputs.PASSES }} | ${{ steps.summary.outputs.FAILURES }} | ${{ steps.summary.outputs.SKIPPED }} | ${{ steps.summary.outputs.ERRORS }} |" >> ${GITHUB_STEP_SUMMARY} - echo "" >> ${GITHUB_STEP_SUMMARY} - echo "You can check the full report [here](${{ steps.set-url.outputs.TARGET_URL }})" >> ${GITHUB_STEP_SUMMARY} - echo "There was **${{ steps.summary.outputs.PERCENTAGE }}%** success rate." >> ${GITHUB_STEP_SUMMARY} - - - name: Comment report on the PR - if: ${{ github.event_name == 'pull_request' }} - uses: actions/github-script@v7 - with: - script: | - const prNumber = context.payload.pull_request.number; - - const commentBody = `**Android E2E Test Report**: ${process.env.MOBILE_SHA} | ${process.env.PERCENTAGE}% (${process.env.PASSES}/${process.env.TOTAL}) | [full report](${process.env.TARGET_URL}) - | Tests | Passed ✅ | Failed ❌ | Skipped ⏭️ | Errors ⚠️ | - |:---:|:---:|:---:|:---:|:---:| - | ${process.env.TOTAL} | ${process.env.PASSES} | ${process.env.FAILURES} | ${process.env.SKIPPED} | ${process.env.ERRORS} | - `; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: commentBody, - }); env: - STATUS: ${{ steps.determine-status.outputs.STATUS }} - FAILURES: ${{ steps.summary.outputs.FAILURES }} + TOTAL: ${{ steps.summary.outputs.TOTAL }} PASSES: ${{ steps.summary.outputs.PASSES }} + FAILURES: ${{ steps.summary.outputs.FAILURES }} SKIPPED: ${{ steps.summary.outputs.SKIPPED }} - TOTAL: ${{ steps.summary.outputs.TOTAL }} ERRORS: ${{ steps.summary.outputs.ERRORS }} PERCENTAGE: ${{ steps.summary.outputs.PERCENTAGE }} - BUILD_ID: ${{ needs.generate-specs.outputs.build_id }} - RUN_TYPE: ${{ inputs.run-type }} - MOBILE_REF: ${{ needs.generate-specs.outputs.mobile_ref }} - MOBILE_SHA: ${{ needs.generate-specs.outputs.mobile_sha }} TARGET_URL: ${{ steps.set-url.outputs.TARGET_URL }} + run: | + echo "| Tests | Passed :white_check_mark: | Failed :x: | Skipped :fast_forward: | Errors :warning: | " >> ${GITHUB_STEP_SUMMARY} + echo "|:---:|:---:|:---:|:---:|:---:|" >> ${GITHUB_STEP_SUMMARY} + echo "| ${TOTAL} | ${PASSES} | ${FAILURES} | ${SKIPPED} | ${ERRORS} |" >> ${GITHUB_STEP_SUMMARY} + echo "" >> ${GITHUB_STEP_SUMMARY} + echo "You can check the full report [here](${TARGET_URL})" >> ${GITHUB_STEP_SUMMARY} + echo "There was **${PERCENTAGE}%** success rate." >> ${GITHUB_STEP_SUMMARY} diff --git a/.github/workflows/e2e-cancel-on-label-removal.yml b/.github/workflows/e2e-cancel-on-label-removal.yml new file mode 100644 index 0000000000..e6923c51a7 --- /dev/null +++ b/.github/workflows/e2e-cancel-on-label-removal.yml @@ -0,0 +1,71 @@ +# Cancel in-progress E2E workflow runs when the E2E/Run label is removed from a PR. +# When the label is removed, Matterwick tears down the provisioned test servers, +# so there is no point in letting the E2E jobs continue — they will fail mid-way. +name: Cancel E2E on label removal + +on: + pull_request: + types: + - unlabeled + +jobs: + cancel-e2e-runs: + if: >- + github.event.label.name == 'E2E/Run' + || github.event.label.name == 'E2E/Run-iOS' + || github.event.label.name == 'E2E/Run-Android' + runs-on: ubuntu-22.04 + permissions: + actions: write + steps: + - name: Cancel in-progress E2E workflow runs + uses: actions/github-script@e7aeb8c663f696059ebb5f9ab1425ed2ef511bdb # v7.0.1 + with: + script: | + const prNumber = context.payload.pull_request.number; + const headBranch = context.payload.pull_request.head.ref; + const owner = context.repo.owner; + const repo = context.repo.repo; + const removedLabel = context.payload.label.name; + + core.info(`Label "${removedLabel}" removed from PR #${prNumber} (branch: ${headBranch})`); + + // Find the E2E workflow by name + const workflows = await github.rest.actions.listRepoWorkflows({ owner, repo }); + const e2eWorkflow = workflows.data.workflows.find(w => w.name === 'E2E'); + if (!e2eWorkflow) { + core.info('No E2E workflow found in repository'); + return; + } + + core.info(`Found E2E workflow (id: ${e2eWorkflow.id})`); + + // Find in-progress or queued runs on the same branch + const statuses = ['in_progress', 'queued', 'waiting']; + let cancelled = 0; + + for (const status of statuses) { + const runs = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: e2eWorkflow.id, + status, + branch: headBranch, + }); + + for (const run of runs.data.workflow_runs) { + core.info(`Cancelling run ${run.id} (status: ${run.status}, branch: ${run.head_branch})`); + try { + await github.rest.actions.cancelWorkflowRun({ + owner, + repo, + run_id: run.id, + }); + cancelled++; + } catch (err) { + core.warning(`Failed to cancel run ${run.id}: ${err.message}`); + } + } + } + + core.info(`Cancelled ${cancelled} E2E workflow run(s) for PR #${prNumber}`); diff --git a/.github/workflows/e2e-detox-pr.yml b/.github/workflows/e2e-detox-pr.yml index b6d5028204..8e09ba3fcc 100644 --- a/.github/workflows/e2e-detox-pr.yml +++ b/.github/workflows/e2e-detox-pr.yml @@ -49,7 +49,8 @@ concurrency: cancel-in-progress: true env: - INTUNE_ENABLED: 'true' + INTUNE_ENABLED: 'false' + DETOX_AWS_S3_BUCKET: "mattermost-detox-report" jobs: update-initial-status-ios: @@ -80,6 +81,20 @@ jobs: description: Detox Android tests for mattermost mobile app have started ... status: pending + update-initial-status-ipad: + if: inputs.PLATFORM == 'ios' || inputs.PLATFORM == 'both' + runs-on: ubuntu-22.04 + steps: + - uses: mattermost/actions/delivery/update-commit-status@main + env: + GITHUB_TOKEN: ${{ github.token }} + with: + repository_full_name: ${{ github.repository }} + commit_sha: ${{ inputs.MOBILE_VERSION }} + context: e2e/detox-ipad-tests + description: Detox iPad tests for mattermost mobile app have started ... + status: pending + build-ios-simulator: if: inputs.PLATFORM == 'ios' || inputs.PLATFORM == 'both' runs-on: macos-26 @@ -142,7 +157,6 @@ jobs: cd detox npm ci --prefer-offline --no-audit --no-fund npm install -g detox-cli - npm run e2e:android-inject-settings npm run e2e:android-build - name: Upload Android Build @@ -151,11 +165,74 @@ jobs: name: android-build-files-${{ github.run_id }} path: "android/app/build/**/*" + provision-servers: + name: Provision test servers + runs-on: ubuntu-22.04 + steps: + - name: Validate server URLs + run: | + validate_url() { + local url="$1" label="$2" + if [ -z "$url" ]; then return 0; fi + + # Must be HTTPS + if [[ ! "$url" =~ ^https:// ]]; then + echo "::error::$label must use HTTPS: $url" + exit 1 + fi + + # Extract hostname and validate against allowed domains + hostname=$(echo "$url" | sed -E 's|^https://([^/:]+).*|\1|' | tr '[:upper:]' '[:lower:]') + + # Block private/internal IPs + if echo "$hostname" | grep -qE '^(127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|0\.|localhost)'; then + echo "::error::$label points to a private/internal address: $hostname" + exit 1 + fi + + # Allowlist: only Mattermost-operated domains + if ! echo "$hostname" | grep -qE '\.(cloud\.mattermost\.com|test\.mattermost\.cloud|mattermost\.com|mattermost\.cloud)$'; then + echo "::error::$label domain not in allowlist: $hostname" + exit 1 + fi + + echo "✓ $label validated: $hostname" + } + + validate_url "$SITE_1_URL" "SITE_1_URL" + validate_url "$SITE_2_URL" "SITE_2_URL" + validate_url "$SITE_3_URL" "SITE_3_URL" + env: + SITE_1_URL: ${{ inputs.SITE_1_URL }} + SITE_2_URL: ${{ inputs.SITE_2_URL }} + SITE_3_URL: ${{ inputs.SITE_3_URL }} + + - name: Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ inputs.MOBILE_VERSION }} + + - name: Provision servers (license + plugins) + run: | + for url in $SITE_1_URL $SITE_2_URL $SITE_3_URL; do + if [ -n "$url" ]; then + echo "--- Provisioning $url ---" + node detox/provision_server.js "$url" || echo "Warning: provisioning $url failed (non-fatal)" + fi + done + env: + SITE_1_URL: ${{ inputs.SITE_1_URL }} + SITE_2_URL: ${{ inputs.SITE_2_URL }} + SITE_3_URL: ${{ inputs.SITE_3_URL }} + ADMIN_USERNAME: ${{ secrets.MM_MOBILE_E2E_ADMIN_USERNAME }} + ADMIN_PASSWORD: ${{ secrets.MM_MOBILE_E2E_ADMIN_PASSWORD }} + run-ios-tests: if: inputs.PLATFORM == 'ios' || inputs.PLATFORM == 'both' name: iOS needs: - build-ios-simulator + - provision-servers uses: ./.github/workflows/e2e-ios-template.yml with: run-type: ${{ inputs.run_type || 'PR' }} @@ -171,6 +248,7 @@ jobs: name: Android needs: - build-android-apk + - provision-servers uses: ./.github/workflows/e2e-android-template.yml with: run-android-tests: true @@ -195,8 +273,8 @@ jobs: repository_full_name: ${{ github.repository }} commit_sha: ${{ inputs.MOBILE_VERSION }} context: e2e/detox-ios-tests - description: Completed with ${{ needs.run-ios-tests.outputs.FAILURES }} failures - status: ${{ needs.run-ios-tests.outputs.STATUS }} + description: ${{ needs.run-ios-tests.outputs.FAILURES == '-1' && 'All test machines failed before producing results' || format('Completed with {0} failures', needs.run-ios-tests.outputs.FAILURES || '?') }} + status: ${{ needs.run-ios-tests.outputs.STATUS || 'failure' }} target_url: ${{ needs.run-ios-tests.outputs.TARGET_URL }} update-final-status-android: @@ -212,25 +290,86 @@ jobs: repository_full_name: ${{ github.repository }} commit_sha: ${{ inputs.MOBILE_VERSION }} context: e2e/detox-android-tests - description: Completed with ${{ needs.run-android-tests.outputs.FAILURES }} failures - status: ${{ needs.run-android-tests.outputs.STATUS }} + description: ${{ needs.run-android-tests.outputs.FAILURES == '-1' && 'All test machines failed before producing results' || format('Completed with {0} failures', needs.run-android-tests.outputs.FAILURES || '?') }} + status: ${{ needs.run-android-tests.outputs.STATUS || 'failure' }} target_url: ${{ needs.run-android-tests.outputs.TARGET_URL }} + run-ios-ipad-tests: + if: inputs.PLATFORM == 'ios' || inputs.PLATFORM == 'both' + name: iOS iPad + needs: + - build-ios-simulator + - update-initial-status-ipad + - provision-servers + uses: ./.github/workflows/e2e-ios-template.yml + with: + run-type: ${{ inputs.run_type || 'PR' }} + MOBILE_VERSION: ${{ inputs.MOBILE_VERSION }} + MM_TEST_SERVER_URL: ${{ inputs.SITE_2_URL }} + ios_device_name: "iPad Pro 13-inch (M5)" + ios_device_os_name: "iOS 26.2" + search_path: "detox/e2e/test/products/channels/ipad" + parallelism: "4" + detox_config: "ios.ipad.debug" + secrets: inherit + + update-final-status-ipad: + runs-on: ubuntu-22.04 + if: always() && (inputs.PLATFORM == 'ios' || inputs.PLATFORM == 'both') + needs: + - run-ios-ipad-tests + steps: + - uses: mattermost/actions/delivery/update-commit-status@main + env: + GITHUB_TOKEN: ${{ github.token }} + with: + repository_full_name: ${{ github.repository }} + commit_sha: ${{ inputs.MOBILE_VERSION }} + context: e2e/detox-ipad-tests + description: ${{ needs.run-ios-ipad-tests.outputs.FAILURES == '-1' && 'All test machines failed before producing results' || format('Completed with {0} failures', needs.run-ios-ipad-tests.outputs.FAILURES || '?') }} + status: ${{ needs.run-ios-ipad-tests.outputs.STATUS || 'failure' }} + target_url: ${{ needs.run-ios-ipad-tests.outputs.TARGET_URL }} + e2e-remove-matterwick-label: name: Remove Matterwick E2E label from PR runs-on: ubuntu-22.04 needs: - - run-ios-tests - - run-android-tests - if: always() && inputs.pr_number != '' + - update-final-status-ios + - update-final-status-android + - update-final-status-ipad + # IMPORTANT: !cancelled() prevents label removal when the workflow is cancelled + # (e.g. by concurrency cancel-in-progress). Without this, Matterwick sees the + # label removed and deletes the test servers while the replacement run is still + # using them. + if: always() && !cancelled() steps: - name: e2e/remove-label-from-pr uses: actions/github-script@e7aeb8c663f696059ebb5f9ab1425ed2ef511bdb # v7.0.1 continue-on-error: true # Label might have been removed manually with: script: | - const prNumber = parseInt(process.env.PR_NUMBER); - if (!prNumber) return; + // Use pr_number input if provided, otherwise look up from branch + let prNumber = parseInt(process.env.PR_NUMBER); + if (!prNumber) { + const branch = process.env.BRANCH; + if (branch) { + const {data: prs} = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + head: `${context.repo.owner}:${branch}`, + state: 'open', + per_page: 1, + }); + if (prs.length > 0) { + prNumber = prs[0].number; + } + } + } + if (!prNumber) { + console.log('No PR found — skipping label removal'); + return; + } + console.log(`Removing E2E labels from PR #${prNumber}`); const labels = ['E2E/Run', 'E2E/Run-iOS', 'E2E/Run-Android']; for (const label of labels) { try { @@ -246,3 +385,4 @@ jobs: } env: PR_NUMBER: ${{ inputs.pr_number }} + BRANCH: ${{ github.ref_name }} diff --git a/.github/workflows/e2e-detox-release.yml b/.github/workflows/e2e-detox-release.yml index 9d533ed2c4..a55f10724f 100644 --- a/.github/workflows/e2e-detox-release.yml +++ b/.github/workflows/e2e-detox-release.yml @@ -13,7 +13,7 @@ on: type: string env: - INTUNE_ENABLED: 'true' + INTUNE_ENABLED: 'false' jobs: update-initial-status-ios: @@ -142,6 +142,43 @@ jobs: MOBILE_VERSION: ${{ github.ref }} secrets: inherit + run-ios-ipad-tests-on-release: + name: iPad Tests on Release + needs: + - build-ios-simulator + uses: ./.github/workflows/e2e-ios-template.yml + with: + run-type: "RELEASE" + MOBILE_VERSION: ${{ github.ref }} + ios_device_name: "iPad Pro 13-inch (M5)" + ios_device_os_name: "iOS 26.2" + search_path: "detox/e2e/test/products/channels/ipad" + parallelism: "1" + detox_config: "ios.ipad.debug" + secrets: inherit + + run-maestro-ios-on-release: + name: Maestro iOS on Release + needs: + - build-ios-simulator + uses: ./.github/workflows/e2e-maestro-template.yml + with: + platform: ios + run-type: "RELEASE" + MOBILE_VERSION: ${{ github.ref }} + secrets: inherit + + run-maestro-android-on-release: + name: Maestro Android on Release + needs: + - build-android-apk + uses: ./.github/workflows/e2e-maestro-template.yml + with: + platform: android + run-type: "RELEASE" + MOBILE_VERSION: ${{ github.ref }} + secrets: inherit + update-final-status-ios: runs-on: ubuntu-22.04 needs: diff --git a/.github/workflows/e2e-detox-scheduled.yml b/.github/workflows/e2e-detox-scheduled.yml index 8a9bacdee2..592d905914 100644 --- a/.github/workflows/e2e-detox-scheduled.yml +++ b/.github/workflows/e2e-detox-scheduled.yml @@ -25,7 +25,7 @@ on: type: string env: - INTUNE_ENABLED: 'true' + INTUNE_ENABLED: 'false' jobs: update-initial-status-ios: @@ -193,3 +193,44 @@ jobs: description: Completed with ${{ needs.run-android-tests-on-main-scheduled.outputs.FAILURES }} failures status: ${{ needs.run-android-tests-on-main-scheduled.outputs.STATUS }} target_url: ${{ needs.run-android-tests-on-main-scheduled.outputs.TARGET_URL }} + + run-ios-ipad-tests-scheduled: + name: iOS iPad Tests (Scheduled) + uses: ./.github/workflows/e2e-ios-template.yml + needs: + - build-ios-simulator + with: + run-type: "MAIN" + record_tests_in_zephyr: 'true' + MOBILE_VERSION: ${{ inputs.MOBILE_VERSION || github.ref }} + MM_TEST_SERVER_URL: ${{ inputs.MM_TEST_SERVER_URL }} + ios_device_name: "iPad Pro 13-inch (M5)" + ios_device_os_name: "iOS 26.2" + search_path: "detox/e2e/test/products/channels/ipad" + parallelism: "1" + detox_config: "ios.ipad.debug" + secrets: inherit + + run-maestro-ios: + name: Maestro iOS Tests (Scheduled) + uses: ./.github/workflows/e2e-maestro-template.yml + needs: + - build-ios-simulator + with: + platform: ios + run-type: "MAIN" + MOBILE_VERSION: ${{ inputs.MOBILE_VERSION || github.ref }} + MM_TEST_SERVER_URL: ${{ inputs.MM_TEST_SERVER_URL_3 }} + secrets: inherit + + run-maestro-android: + name: Maestro Android Tests (Scheduled) + uses: ./.github/workflows/e2e-maestro-template.yml + needs: + - build-android-apk + with: + platform: android + run-type: "MAIN" + MOBILE_VERSION: ${{ inputs.MOBILE_VERSION || github.ref }} + MM_TEST_SERVER_URL: ${{ inputs.MM_TEST_SERVER_URL_3 }} + secrets: inherit diff --git a/.github/workflows/e2e-ios-template.yml b/.github/workflows/e2e-ios-template.yml index 8562c2774f..3bf0006ce3 100644 --- a/.github/workflows/e2e-ios-template.yml +++ b/.github/workflows/e2e-ios-template.yml @@ -26,7 +26,7 @@ on: MOBILE_VERSION: description: "The mobile version to test" required: false - default: ${{ github.head_ref || github.ref }} + default: "" type: string run-type: type: string @@ -57,6 +57,21 @@ on: required: false type: boolean default: false + search_path: + description: "Path to search for test specs (use for smoke test subset)" + required: false + type: string + default: "detox/e2e/test" + parallelism: + description: "Number of parallel test shards" + required: false + type: string + default: "20" + detox_config: + description: "Detox configuration name to use (e.g. ios.sim.debug, ios.ipad.debug)" + required: false + type: string + default: "ios.sim.debug" outputs: STATUS: value: ${{ jobs.generate-report.outputs.STATUS }} @@ -64,12 +79,22 @@ on: value: ${{ jobs.generate-report.outputs.TARGET_URL }} FAILURES: value: ${{ jobs.generate-report.outputs.FAILURES }} + PASSES: + value: ${{ jobs.generate-report.outputs.PASSES }} + TOTAL: + value: ${{ jobs.generate-report.outputs.TOTAL }} + SKIPPED: + value: ${{ jobs.generate-report.outputs.SKIPPED }} + ERRORS: + value: ${{ jobs.generate-report.outputs.ERRORS }} + PERCENTAGE: + value: ${{ jobs.generate-report.outputs.PERCENTAGE }} env: AWS_REGION: "us-east-1" ADMIN_EMAIL: ${{ secrets.MM_MOBILE_E2E_ADMIN_EMAIL }} - ADMIN_USERNAME: ${{ secrets.MM_MOBILE_E2E_ADMIN_USERNAME }} - ADMIN_PASSWORD: ${{ secrets.MM_MOBILE_E2E_ADMIN_PASSWORD }} + ADMIN_USERNAME: ${{ inputs.MM_TEST_USER_NAME || secrets.MM_MOBILE_E2E_ADMIN_USERNAME }} + ADMIN_PASSWORD: ${{ inputs.MM_TEST_PASSWORD || secrets.MM_MOBILE_E2E_ADMIN_PASSWORD }} BRANCH: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }} COMMIT_HASH: ${{ github.sha }} DEVICE_NAME: ${{ inputs.ios_device_name }} @@ -123,8 +148,8 @@ jobs: id: generate-specs uses: ./.github/actions/generate-specs with: - parallelism: 10 - search_path: detox/e2e/test + parallelism: ${{ inputs.parallelism }} + search_path: ${{ inputs.search_path }} device_name: ${{ env.DEVICE_NAME }} device_os_version: ${{ env.DEVICE_OS_VERSION }} @@ -149,10 +174,22 @@ jobs: - name: ci/prepare-node-deps uses: ./.github/actions/prepare-node-deps + - name: Cache Homebrew packages + uses: actions/cache@v4 + with: + path: | + ~/Library/Caches/Homebrew + /opt/homebrew/Cellar/applesimutils + key: ${{ runner.os }}-${{ runner.arch }}-brew-applesimutils-v1 + restore-keys: ${{ runner.os }}-${{ runner.arch }}-brew-applesimutils- + - name: Install Homebrew Dependencies run: | brew tap wix/brew brew install applesimutils + # Re-link after potential cache restore: cache restores Cellar files but not + # the /opt/homebrew/bin/ symlinks, so brew install is a no-op and skips linking. + brew link --overwrite applesimutils - name: Download iOS Simulator Build uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4 @@ -178,9 +215,31 @@ jobs: upload_speed: "3300" latency: "500" + - name: Cache detox node_modules + uses: actions/cache@v4 + with: + path: detox/node_modules + key: ${{ runner.os }}-detox-deps-${{ hashFiles('detox/package-lock.json') }} + restore-keys: ${{ runner.os }}-detox-deps- + - name: Install Detox Dependencies run: cd detox && npm i + - name: Get Xcode Version + id: xcode-version + run: echo "version=$(xcodebuild -version | head -1 | sed 's/Xcode //')" >> "$GITHUB_OUTPUT" + + - name: Cache Detox Framework + id: detox-framework-cache + uses: actions/cache@v4 + with: + path: ~/Library/Detox/ios + key: ${{ runner.os }}-${{ runner.arch }}-detox-framework-${{ hashFiles('detox/package-lock.json') }}-xcode-${{ steps.xcode-version.outputs.version }} + + - name: Build Detox Framework Cache + if: steps.detox-framework-cache.outputs.cache-hit != 'true' + run: cd detox && npx detox build-framework-cache + - name: Pre-boot iOS Simulator run: | set -e @@ -249,11 +308,136 @@ jobs: # Wait for boot to complete xcrun simctl bootstatus "$SIMULATOR_ID" + # Suppress the "Save Password?" credential-save prompt on iOS simulators. + # + # Architecture (iOS 26.x / iOS 18+): + # The "Save Password?" modal is shown by SharedWebCredentialViewService, + # a UIProcess launched on-demand by SpringBoard — NOT a persistent launchd + # service. There is no pre-existing launchd service to kill before login. + # (com.apple.PasswordsCredentialProvider was removed in iOS 18+.) + # + # Strategy — two layers: + # 1. Best-effort defaults writes: seed the Passwords.app and springboard + # defaults domains so that if/when the app reads them it sees AutoFill=NO. + # Keys are silently ignored if the domain or key name doesn't match the + # running iOS version, so this is best-effort only. + # 2. Programmatic dismissal (primary): dismissSystemDialogIfVisible() in + # the test login flow taps "Not Now" when the dialog appears, then does + # a sendToHome+launchApp cycle to clear the _alertControllerDimming + # overlay — this is the only reliable cross-version approach. + echo "Seeding Passwords.app defaults to suppress credential-save prompt..." + xcrun simctl spawn "${SIMULATOR_ID}" defaults write com.apple.Passwords AutoFill -bool NO 2>/dev/null || true + xcrun simctl spawn "${SIMULATOR_ID}" defaults write com.apple.Passwords AutoSave -bool NO 2>/dev/null || true + xcrun simctl spawn "${SIMULATOR_ID}" defaults write com.apple.Passwords credentialSaveNotificationsEnabled -bool NO 2>/dev/null || true + xcrun simctl spawn "${SIMULATOR_ID}" defaults write com.apple.springboard AutoFillPasswords -bool NO 2>/dev/null || true + echo "Passwords defaults seeded (keys are best-effort; dialog is also handled programmatically)" + + # Diagnostic: list running password-related launchd services for CI log visibility. + # Note: com.apple.PasswordsCredentialProvider does NOT exist on iOS 26.x — + # the dialog is shown by the on-demand SharedWebCredentialViewService UIProcess. + echo "Password-related launchd services (diagnostic only):" + xcrun simctl spawn "${SIMULATOR_ID}" launchctl list 2>/dev/null | grep -i "password\|credential\|SharedWebCred" || echo " (none found)" + + # Install the app now while the simulator is fully booted. + # reinstallApp: false in .detoxrc.json means Detox never installs the app, + # and launchApp({newInstance: true}) only launches — it does not install. + # simctl install here is the only place the app binary enters the simulator. + APP_PATH=$(ls -d mobile-artifacts/*.app 2>/dev/null | head -1) + if [ -z "$APP_PATH" ]; then + echo "❌ Error: No .app bundle found in mobile-artifacts/" + ls -la mobile-artifacts/ + exit 1 + fi + echo "Installing $APP_PATH in simulator $SIMULATOR_ID..." + xcrun simctl install "$SIMULATOR_ID" "$APP_PATH" + echo "✅ App installed: $APP_PATH" + + # Pre-grant app permissions so Detox never calls `simctl privacy` at runtime. + # device.launchApp({permissions}) runs one `simctl privacy` command per key + # synchronously before simctl launch. On 3-core macOS-15 runners with iOS 26.x, + # this takes 6–9s per permission × 4 keys = 24–36s of overhead on EVERY test + # file's beforeAll. Under VM neighbour CPU contention this can reach 40–50s, + # pushing the total launch budget past the 90s cap and causing ~9/20 shards to + # time out. Permissions granted here persist through clearIOSAppData() (which only + # clears the data container, not privacy settings), so they never need re-applying. + # + # NOTE: Only `grant notifications` is applied here. The `deny camera/medialibrary/ + # photos` commands were removed because on iOS 26.x: + # - `deny medialibrary` returns "Unknown action 'deny'" (unsupported action) + # - `deny camera` throws NSPOSIXErrorDomain code=1 on freshly installed apps + # Both errors corrupt the simulator's TCC database, preventing Detox's WebSocket + # from ever completing its handshake — causing ALL tests to fail with + # "[launch attempt 2] timed out after 90s". + # The app's Info.plist does not request camera/photos/medialibrary access at + # startup, so no denial is needed to suppress permission dialogs. + echo "Pre-granting app permissions..." + xcrun simctl privacy "$SIMULATOR_ID" grant notifications com.mattermost.rnbeta || true + echo "✅ Permissions pre-granted" + + # Pre-warm: launch the app once to create its data container and establish a + # clean SpringBoard registration. A fresh simctl install creates a + # "pending-install" record in launchd; without a launch→kill cycle, the first + # Detox launchApp call hangs waiting for that record to settle. + # The app will crash immediately without Metro — that is expected and fine; + # launchd creates the data container before the JS engine starts. + # + # Both simctl launch and simctl terminate can hang on iOS 26.x — the same bug + # we work around in setup.ts. Protect each command: + # - simctl launch: backgrounded with a hard sleep+kill timeout. + # - kill: use simctl spawn launchctl to kill inside the simulator's launchd + # namespace instead of simctl terminate. launchd then sees the SIGCHLD, + # cleans up the service record, and leaves no stale state. + echo "Pre-warming app to create data container..." + + # Pre-warm function: launch app briefly to create its data container. + # iPad simulators on iOS 26.x can take 10-15s to fully register the app. + prewarm_app() { + local attempt=$1 + local sleep_time=$2 + echo "Pre-warm attempt $attempt (waiting ${sleep_time}s)..." + xcrun simctl launch "$SIMULATOR_ID" com.mattermost.rnbeta 2>/dev/null & + local LAUNCH_PID=$! + sleep "$sleep_time" + kill "$LAUNCH_PID" 2>/dev/null || true + wait "$LAUNCH_PID" 2>/dev/null || true + + # Kill via launchd (reliable — simctl terminate can hang on iOS 26.x) + local APP_PID + APP_PID=$(xcrun simctl spawn "$SIMULATOR_ID" launchctl list 2>/dev/null | \ + grep "com.mattermost.rnbeta" | awk '{print $1}' | grep -E '^[0-9]+$' || true) + if [ -n "$APP_PID" ]; then + xcrun simctl spawn "$SIMULATOR_ID" kill -9 "$APP_PID" 2>/dev/null || true + sleep 1 + echo " Killed app via launchd (PID $APP_PID)" + fi + + # Verify data container was created + if xcrun simctl get_app_container "$SIMULATOR_ID" com.mattermost.rnbeta data 2>/dev/null; then + echo " ✅ Data container verified" + return 0 + else + echo " ⚠️ Data container not found" + return 1 + fi + } + + # First attempt with 15s wait + if ! prewarm_app 1 15; then + echo "Retrying pre-warm with longer timeout..." + # Second attempt with 25s wait + if ! prewarm_app 2 25; then + echo "⚠️ Pre-warm failed after 2 attempts — tests may fail at launch" + fi + fi + echo "✅ App pre-warmed" + # Open Simulator.app in background to speed up UI rendering open -a Simulator --args -CurrentDeviceUDID "$SIMULATOR_ID" - # Give Simulator.app time to fully initialize UI (iOS 26.2 needs more time) - sleep 5 + # Give Simulator.app time to fully initialize UI — iOS 26.x simulators need + # at least 10s after open before the first launchApp() reliably completes + # within the 90s Detox cap. 5s caused ~9/20 shards to hit launch timeouts. + sleep 10 echo "SIMULATOR_ID=$SIMULATOR_ID" >> $GITHUB_ENV @@ -281,11 +465,33 @@ jobs: sleep 3 xcrun simctl boot "$SIMULATOR_ID" xcrun simctl bootstatus "$SIMULATOR_ID" + + # Re-open Simulator.app after reboot + open -a Simulator --args -CurrentDeviceUDID "$SIMULATOR_ID" + sleep 5 fi + # Verify the app is still installed (simctl install can silently fail) + if ! xcrun simctl get_app_container "$SIMULATOR_ID" com.mattermost.rnbeta 2>/dev/null; then + echo "⚠️ App not found in simulator — reinstalling..." + APP_PATH=$(ls -d mobile-artifacts/*.app 2>/dev/null | head -1) + if [ -n "$APP_PATH" ]; then + xcrun simctl install "$SIMULATOR_ID" "$APP_PATH" + echo "✅ App reinstalled" + else + echo "❌ No .app bundle found for reinstall" + exit 1 + fi + fi + + # Reduce CPU competition: disable Spotlight indexing and other macOS services + # that consume CPU on CI runners (3-core M1 with 7 GB RAM) + sudo mdutil -a -i off 2>/dev/null || true + echo "✅ Simulator is healthy and ready" - name: Start Metro Bundler and Run Detox E2E Tests + timeout-minutes: 60 continue-on-error: true # We want to run all the tests run: | # Start Metro bundler in background @@ -327,37 +533,59 @@ jobs: tail -30 metro.log else echo "Metro is serving on http://localhost:8081" - # Give Metro extra time to fully initialize for iOS 26.2 (React Native bridge needs more time) + # Give Metro extra time to fully initialize for iOS 26.2 (React Native bridge needs more time). + # On 3-core macOS-15 runners, Metro can take 15+ seconds to finish loading all + # transforms after reporting "ready". Without this, the first app launch may + # see a red screen / stale bundle. echo "Allowing Metro to fully stabilize..." - sleep 8 + sleep 15 fi cd detox npm run detox:config-gen - npm run e2e:ios-test -- ${{ matrix.specs }} + # Use read -ra to safely word-split space-separated spec paths without + # glob expansion or shell injection from the matrix value. + read -ra SPECS <<< "$MATRIX_SPECS" + if [ "$DETOX_CONFIG" = "ios.ipad.debug" ]; then + npm run e2e:ios-ipad-test -- "${SPECS[@]}" + else + npm run e2e:ios-test -- "${SPECS[@]}" + fi env: + DETOX_CONFIG: ${{ inputs.detox_config }} + MATRIX_SPECS: ${{ matrix.specs }} DETOX_DISABLE_HIERARCHY_DUMP: "YES" DETOX_DISABLE_SCREENSHOT_TRACKING: "YES" - DETOX_LOGLEVEL: "debug" + DETOX_LOGLEVEL: "info" DETOX_DEVICE_TYPE: ${{ env.DEVICE_NAME }} DETOX_OS_VERSION: ${{ env.DEVICE_OS_VERSION }} LOW_BANDWIDTH_MODE: ${{ inputs.low_bandwidth_mode }} + SITE_1_URL: ${{ env.SITE_1_URL }} + SITE_2_URL: ${{ env.SITE_2_URL }} + SITE_3_URL: ${{ env.SITE_3_URL }} - name: Cleanup Simulator State if: always() + timeout-minutes: 2 run: | echo "Cleaning up simulator state after tests..." - # Stop Metro bundler if still running - if [ ! -z "$METRO_PID" ]; then - echo "Stopping Metro bundler (PID: $METRO_PID)..." - kill -9 "$METRO_PID" 2>/dev/null || true + # Kill the entire Metro process tree, not just the top-level PID. + # `kill -9 $PID` only kills the npm wrapper; the actual Metro Node.js + # workers and watchman stay alive. GitHub Actions waits ~10 min for + # orphaned processes to die, causing the cleanup step to hang. + if [ -n "$METRO_PID" ]; then + echo "Stopping Metro bundler (PID: $METRO_PID) and child processes..." + kill -9 -"$METRO_PID" 2>/dev/null || true + pkill -9 -f "react-native.*start" 2>/dev/null || true + pkill -9 -f "metro" 2>/dev/null || true + sleep 1 fi - # Terminate app gracefully - if [ ! -z "$SIMULATOR_ID" ]; then + # Terminate app with a timeout — simctl terminate can hang on iOS 26.x + if [ -n "$SIMULATOR_ID" ]; then echo "Terminating app on simulator..." - xcrun simctl terminate "$SIMULATOR_ID" com.mattermost.rnbeta 2>/dev/null || true + timeout 10 xcrun simctl terminate "$SIMULATOR_ID" com.mattermost.rnbeta 2>/dev/null || true sleep 2 fi @@ -391,6 +619,7 @@ jobs: path: detox/artifacts/ generate-report: + if: always() runs-on: ubuntu-22.04 needs: - generate-specs @@ -399,6 +628,11 @@ jobs: TARGET_URL: ${{ steps.set-url.outputs.TARGET_URL }} STATUS: ${{ steps.determine-status.outputs.STATUS }} FAILURES: ${{ steps.summary.outputs.FAILURES }} + PASSES: ${{ steps.summary.outputs.PASSES }} + TOTAL: ${{ steps.summary.outputs.TOTAL }} + SKIPPED: ${{ steps.summary.outputs.SKIPPED }} + ERRORS: ${{ steps.summary.outputs.ERRORS }} + PERCENTAGE: ${{ steps.summary.outputs.PERCENTAGE }} steps: - name: Checkout Repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -438,12 +672,22 @@ jobs: - name: Calculate failures id: summary run: | - echo "FAILURES=$(cat detox/artifacts/summary.json | jq .stats.failures)" >> ${GITHUB_OUTPUT} - echo "PASSES=$(cat detox/artifacts/summary.json | jq .stats.passes)" >> ${GITHUB_OUTPUT} - echo "SKIPPED=$(cat detox/artifacts/summary.json | jq .stats.skipped)" >> ${GITHUB_OUTPUT} - echo "TOTAL=$(cat detox/artifacts/summary.json | jq .stats.tests)" >> ${GITHUB_OUTPUT} - echo "ERRORS=$(cat detox/artifacts/summary.json | jq .stats.errors)" >> ${GITHUB_OUTPUT} - echo "PERCENTAGE=$(cat detox/artifacts/summary.json | jq .stats.passPercent)" >> ${GITHUB_OUTPUT} + if [ -f detox/artifacts/summary.json ]; then + echo "FAILURES=$(cat detox/artifacts/summary.json | jq .stats.failures)" >> ${GITHUB_OUTPUT} + echo "PASSES=$(cat detox/artifacts/summary.json | jq .stats.passes)" >> ${GITHUB_OUTPUT} + echo "SKIPPED=$(cat detox/artifacts/summary.json | jq .stats.skipped)" >> ${GITHUB_OUTPUT} + echo "TOTAL=$(cat detox/artifacts/summary.json | jq .stats.tests)" >> ${GITHUB_OUTPUT} + echo "ERRORS=$(cat detox/artifacts/summary.json | jq .stats.errors)" >> ${GITHUB_OUTPUT} + echo "PERCENTAGE=$(cat detox/artifacts/summary.json | jq .stats.passPercent)" >> ${GITHUB_OUTPUT} + else + echo "⚠️ No summary.json found — all test machines likely failed before producing results" + echo "FAILURES=-1" >> ${GITHUB_OUTPUT} + echo "PASSES=0" >> ${GITHUB_OUTPUT} + echo "SKIPPED=0" >> ${GITHUB_OUTPUT} + echo "TOTAL=0" >> ${GITHUB_OUTPUT} + echo "ERRORS=0" >> ${GITHUB_OUTPUT} + echo "PERCENTAGE=0" >> ${GITHUB_OUTPUT} + fi - name: Set Target URL id: set-url @@ -452,51 +696,31 @@ jobs: - name: Determine Status id: determine-status + env: + FAILURES: ${{ steps.summary.outputs.FAILURES }} + TESTCASE_FAILURE_FATAL: ${{ inputs.testcase_failure_fatal }} run: | - if [[ ${{ steps.summary.outputs.failures }} -gt 0 && "${{ inputs.testcase_failure_fatal }}" == "true" ]]; then + if [[ "$FAILURES" == "-1" ]]; then + echo "STATUS=failure" >> ${GITHUB_OUTPUT} + elif [[ "$FAILURES" -gt 0 && "$TESTCASE_FAILURE_FATAL" == "true" ]]; then echo "STATUS=failure" >> ${GITHUB_OUTPUT} else echo "STATUS=success" >> ${GITHUB_OUTPUT} fi - name: Generate Summary - run: | - echo "| Tests | Passed :white_check_mark: | Failed :x: | Skipped :fast_forward: | Errors :warning: | " >> ${GITHUB_STEP_SUMMARY} - echo "|:---:|:---:|:---:|:---:|:---:|" >> ${GITHUB_STEP_SUMMARY} - echo "| ${{ steps.summary.outputs.TOTAL }} | ${{ steps.summary.outputs.PASSES }} | ${{ steps.summary.outputs.FAILURES }} | ${{ steps.summary.outputs.SKIPPED }} | ${{ steps.summary.outputs.ERRORS }} |" >> ${GITHUB_STEP_SUMMARY} - echo "" >> ${GITHUB_STEP_SUMMARY} - echo "You can check the full report [here](${{ steps.set-url.outputs.TARGET_URL }})" >> ${GITHUB_STEP_SUMMARY} - echo "There was **${{ steps.summary.outputs.PERCENTAGE }}%** success rate." >> ${GITHUB_STEP_SUMMARY} - - - name: Comment report on the PR - if: ${{ github.event_name == 'pull_request' }} - uses: actions/github-script@v7 - with: - script: | - const prNumber = context.payload.pull_request.number; - - const commentBody = `**iOS E2E Test Report**: ${process.env.MOBILE_SHA} | ${process.env.PERCENTAGE}% (${process.env.PASSES}/${process.env.TOTAL}) | [full report](${process.env.TARGET_URL}) - | Tests | Passed ✅ | Failed ❌ | Skipped ⏭️ | Errors ⚠️ | - |:---:|:---:|:---:|:---:|:---:| - | ${process.env.TOTAL} | ${process.env.PASSES} | ${process.env.FAILURES} | ${process.env.SKIPPED} | ${process.env.ERRORS} | - `; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: commentBody, - }); env: - STATUS: ${{ steps.determine-status.outputs.STATUS }} - FAILURES: ${{ steps.summary.outputs.FAILURES }} + TOTAL: ${{ steps.summary.outputs.TOTAL }} PASSES: ${{ steps.summary.outputs.PASSES }} + FAILURES: ${{ steps.summary.outputs.FAILURES }} SKIPPED: ${{ steps.summary.outputs.SKIPPED }} - TOTAL: ${{ steps.summary.outputs.TOTAL }} ERRORS: ${{ steps.summary.outputs.ERRORS }} PERCENTAGE: ${{ steps.summary.outputs.PERCENTAGE }} - BUILD_ID: ${{ needs.generate-specs.outputs.build_id }} - RUN_TYPE: ${{ inputs.run-type }} - MOBILE_REF: ${{ needs.generate-specs.outputs.mobile_ref }} - MOBILE_SHA: ${{ needs.generate-specs.outputs.mobile_sha }} TARGET_URL: ${{ steps.set-url.outputs.TARGET_URL }} + run: | + echo "| Tests | Passed :white_check_mark: | Failed :x: | Skipped :fast_forward: | Errors :warning: | " >> ${GITHUB_STEP_SUMMARY} + echo "|:---:|:---:|:---:|:---:|:---:|" >> ${GITHUB_STEP_SUMMARY} + echo "| ${TOTAL} | ${PASSES} | ${FAILURES} | ${SKIPPED} | ${ERRORS} |" >> ${GITHUB_STEP_SUMMARY} + echo "" >> ${GITHUB_STEP_SUMMARY} + echo "You can check the full report [here](${TARGET_URL})" >> ${GITHUB_STEP_SUMMARY} + echo "There was **${PERCENTAGE}%** success rate." >> ${GITHUB_STEP_SUMMARY} diff --git a/.github/workflows/e2e-maestro-template.yml b/.github/workflows/e2e-maestro-template.yml new file mode 100644 index 0000000000..a280ea53ec --- /dev/null +++ b/.github/workflows/e2e-maestro-template.yml @@ -0,0 +1,594 @@ +name: Maestro E2E Tests Template + +on: + workflow_call: + inputs: + platform: + description: "Platform to test on: ios or android" + required: false + type: string + default: "ios" + flow-path: + description: "Path to the Maestro flow(s) to run (defaults differ by platform)" + required: false + type: string + default: "" + MM_TEST_SERVER_URL: + description: "The test server URL" + required: false + type: string + MOBILE_VERSION: + description: "The mobile version to test" + required: false + default: "" + type: string + run-type: + description: "Type of run (PR, MAIN, etc.)" + required: false + type: string + default: "PR" + outputs: + STATUS: + value: ${{ jobs.e2e-maestro-ios.outputs.STATUS || jobs.e2e-maestro-android.outputs.STATUS }} + TARGET_URL: + value: ${{ jobs.e2e-maestro-ios.outputs.TARGET_URL || jobs.e2e-maestro-android.outputs.TARGET_URL }} + FAILURES: + value: ${{ jobs.e2e-maestro-ios.outputs.FAILURES || jobs.e2e-maestro-android.outputs.FAILURES }} + PASSES: + value: ${{ jobs.e2e-maestro-ios.outputs.PASSES || jobs.e2e-maestro-android.outputs.PASSES }} + TOTAL: + value: ${{ jobs.e2e-maestro-ios.outputs.TOTAL || jobs.e2e-maestro-android.outputs.TOTAL }} + PERCENTAGE: + value: ${{ jobs.e2e-maestro-ios.outputs.PERCENTAGE || jobs.e2e-maestro-android.outputs.PERCENTAGE }} +env: + ADMIN_EMAIL: ${{ secrets.MM_MOBILE_E2E_ADMIN_EMAIL }} + ADMIN_USERNAME: ${{ secrets.MM_MOBILE_E2E_ADMIN_USERNAME }} + ADMIN_PASSWORD: ${{ secrets.MM_MOBILE_E2E_ADMIN_PASSWORD }} + SITE_1_URL: ${{ inputs.MM_TEST_SERVER_URL }} + TYPE: ${{ inputs.run-type }} + DETOX_AWS_S3_BUCKET: "mattermost-detox-report" + +jobs: + e2e-maestro-ios: + name: Maestro E2E Tests (iOS) + if: ${{ inputs.platform == 'ios' || inputs.platform == '' }} + runs-on: macos-15 + timeout-minutes: 60 + outputs: + STATUS: ${{ steps.determine-status.outputs.STATUS }} + TARGET_URL: ${{ steps.set-url.outputs.TARGET_URL }} + FAILURES: ${{ steps.summary.outputs.FAILURES }} + PASSES: ${{ steps.summary.outputs.PASSES }} + TOTAL: ${{ steps.summary.outputs.TOTAL }} + PERCENTAGE: ${{ steps.summary.outputs.PERCENTAGE }} + steps: + - name: Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ inputs.MOBILE_VERSION }} + + - name: ci/prepare-node-deps + uses: ./.github/actions/prepare-node-deps + + - name: Cache Maestro + id: maestro-cache + uses: actions/cache@v4 + with: + path: ~/.maestro + key: maestro-2.3.0 + + - name: Install Maestro CLI + if: steps.maestro-cache.outputs.cache-hit != 'true' + run: | + curl -fsSL "https://get.maestro.mobile.dev" | bash -s -- --version 2.3.0 + MAESTRO_BIN="$HOME/.maestro/bin/maestro" + if [ ! -f "$MAESTRO_BIN" ]; then + echo "Error: Maestro binary not found at $MAESTRO_BIN" + exit 1 + fi + echo "Maestro installed successfully at $MAESTRO_BIN" + "$MAESTRO_BIN" --version + + - name: Verify Maestro installation + run: ~/.maestro/bin/maestro --version + + - name: Download iOS Simulator Build + uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4 + with: + name: ios-build-simulator-${{ github.run_id }} + path: mobile-artifacts + + - name: Unzip iOS Simulator Build + run: | + unzip -o mobile-artifacts/*.zip -d mobile-artifacts/ + rm -f mobile-artifacts/*.zip + + - name: Pre-boot iOS Simulator + run: | + set -e + DEVICE_NAME="iPhone 17 Pro" + DEVICE_OS_VERSION="iOS 26.2" + + echo "Looking for simulator: $DEVICE_NAME with $DEVICE_OS_VERSION" + + SIMULATOR_ID=$(xcrun simctl list devices | grep "$DEVICE_NAME" | grep "$DEVICE_OS_VERSION" | head -1 | grep -oE '([0-9A-F-]{36})' || true) + + if [ -z "$SIMULATOR_ID" ]; then + echo "Simulator not found. Creating simulator $DEVICE_NAME with $DEVICE_OS_VERSION" + + DEVICE_TYPE=$(xcrun simctl list devicetypes | grep "$DEVICE_NAME (" | head -1 | awk -F'[()]' '{print $(NF-1)}') + if [ -z "$DEVICE_TYPE" ]; then + echo "Error: Could not find device type for $DEVICE_NAME" + exit 1 + fi + echo "Found device type: $DEVICE_TYPE" + + RUNTIME=$(xcrun simctl list runtimes | grep "$DEVICE_OS_VERSION" | head -1 | sed 's/.* - \(.*\)/\1/') + if [ -z "$RUNTIME" ]; then + echo "Error: Could not find runtime for $DEVICE_OS_VERSION" + exit 1 + fi + echo "Found runtime: $RUNTIME" + + SIMULATOR_ID=$(xcrun simctl create "CI-$DEVICE_NAME" "$DEVICE_TYPE" "$RUNTIME") + echo "Created simulator with ID: $SIMULATOR_ID" + else + echo "Found existing simulator: $SIMULATOR_ID" + fi + + xcrun simctl boot "$SIMULATOR_ID" + xcrun simctl bootstatus "$SIMULATOR_ID" + + # Disable password autofill to prevent "Save Password?" dialogs blocking tests + echo "Shutting down to apply password autofill restrictions..." + xcrun simctl shutdown "$SIMULATOR_ID" + sleep 3 + + SETTINGS_DIR="$HOME/Library/Developer/CoreSimulator/Devices/$SIMULATOR_ID/data/Containers/Shared/SystemGroup/systemgroup.com.apple.configurationprofiles/Library/ConfigurationProfiles" + mkdir -p "$SETTINGS_DIR" + node detox/utils/disable_ios_autofill.js --simulator-id "$SIMULATOR_ID" + if [ $? -ne 0 ]; then + echo "⚠️ Failed to disable password autofill" + exit 1 + fi + + echo "Booting simulator with password autofill disabled..." + xcrun simctl boot "$SIMULATOR_ID" + xcrun simctl bootstatus "$SIMULATOR_ID" + + open -a Simulator --args -CurrentDeviceUDID "$SIMULATOR_ID" + sleep 5 + + echo "SIMULATOR_ID=$SIMULATOR_ID" >> $GITHUB_ENV + + - name: Install app on iOS Simulator + run: | + APP_PATH=$(find mobile-artifacts -name "*.app" -maxdepth 2 | head -1) + if [ -z "$APP_PATH" ]; then + echo "Error: Could not find .app bundle in mobile-artifacts/" + ls -la mobile-artifacts/ + exit 1 + fi + echo "Installing app from: $APP_PATH" + + # Patch UIFileSharingEnabled BEFORE install so iOS registers file sharing + # at install time. Patching post-install is cached by iOS and ignored until + # the next simulator reboot, making the Documents folder invisible in Files app. + plutil -replace UIFileSharingEnabled -bool true "$APP_PATH/Info.plist" 2>/dev/null || \ + plutil -insert UIFileSharingEnabled -bool true "$APP_PATH/Info.plist" 2>/dev/null || true + plutil -replace LSSupportsOpeningDocumentsInPlace -bool true "$APP_PATH/Info.plist" 2>/dev/null || \ + plutil -insert LSSupportsOpeningDocumentsInPlace -bool true "$APP_PATH/Info.plist" 2>/dev/null || true + echo "UIFileSharingEnabled patched in Info.plist" + + xcrun simctl install "$SIMULATOR_ID" "$APP_PATH" + + # Grant all privacy permissions (camera, photos, microphone, notifications, etc.) + xcrun simctl privacy "$SIMULATOR_ID" grant all com.mattermost.rnbeta + echo "All privacy permissions granted" + + - name: Place test fixture file in app Documents (channel_bookmark_file flow) + run: | + FIXTURE="detox/e2e/support/fixtures/image.png" + if [ ! -f "$FIXTURE" ]; then + echo "Fixture file not found at $FIXTURE — skipping document setup" + exit 0 + fi + APP_DATA=$(xcrun simctl get_app_container "$SIMULATOR_ID" com.mattermost.rnbeta data 2>/dev/null || true) + if [ -z "$APP_DATA" ]; then + echo "App data container not found — app may not be installed yet; skipping" + exit 0 + fi + DOCS_DIR="$APP_DATA/Documents" + mkdir -p "$DOCS_DIR" + cp "$FIXTURE" "$DOCS_DIR/test_bookmark.png" + echo "Copied test fixture to $DOCS_DIR/test_bookmark.png" + + - name: Set simulator timezone for timezone flow + run: | + # MM-T1325: set timezone before any flow runs; launchApp:stopApp:true in the flow + # will restart the app so it picks up the new timezone. + # 'xcrun simctl timezone' was removed in Xcode 26; 'defaults write' doesn't affect + # [NSTimeZone localTimeZone] either. 'launchctl setenv TZ' sets it in launchd so + # all new child processes (including the restarted app) inherit the correct timezone. + xcrun simctl spawn "$SIMULATOR_ID" launchctl setenv TZ "America/New_York" + echo "Simulator timezone set to America/New_York" + + - name: Seed test data + run: | + cd maestro + npm install + npx tsx fixtures/seed.ts + env: + SITE_1_URL: ${{ env.SITE_1_URL }} + ADMIN_EMAIL: ${{ env.ADMIN_EMAIL }} + ADMIN_USERNAME: ${{ env.ADMIN_USERNAME }} + ADMIN_PASSWORD: ${{ env.ADMIN_PASSWORD }} + + - name: Resolve flow path + id: resolve-flow-path + run: | + if [ -n "${{ inputs.flow-path }}" ]; then + echo "flow_path=${{ inputs.flow-path }}" >> $GITHUB_OUTPUT + else + # iOS: run all single-device flows (skip multi_device only) + echo "flow_path=maestro/flows/account maestro/flows/calls maestro/flows/channels maestro/flows/share_extension maestro/flows/timezone" >> $GITHUB_OUTPUT + fi + + - name: Run Maestro Flows (iOS) + run: | + set -a + source maestro/.maestro-test-env.sh + set +a + + mkdir -p build + FLOW_PATH="${{ steps.resolve-flow-path.outputs.flow_path }}" + + ~/.maestro/bin/maestro test \ + --format junit \ + --output build/maestro-report.xml \ + --test-output-dir build/maestro-artifacts \ + --flatten-debug-output \ + --env SITE_1_URL="${SITE_1_URL}" \ + --env TEST_USER_EMAIL="${TEST_USER_EMAIL}" \ + --env TEST_USER_PASSWORD="${TEST_USER_PASSWORD}" \ + --env TEST_CHANNEL_NAME="${TEST_CHANNEL_NAME}" \ + --env TEST_TEAM_NAME="${TEST_TEAM_NAME}" \ + --env ADMIN_TOKEN="${ADMIN_TOKEN}" \ + --env MAESTRO_APP_ID="com.mattermost.rnbeta" \ + $FLOW_PATH + + - name: Upload Maestro Artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: maestro-ios-results-${{ github.run_id }} + path: build/ + + - name: Parse Maestro Report + id: summary + if: always() + run: | + cd detox + npm ci --prefer-offline --no-audit --no-fund + node -e " + const {parseMaestroReport} = require('./utils/maestro_report'); + const summary = parseMaestroReport('../build/maestro-report.xml'); + const fs = require('fs'); + if (summary) { + const s = summary.stats; + const output = [ + 'FAILURES=' + s.failures, + 'PASSES=' + s.passes, + 'TOTAL=' + s.tests, + 'ERRORS=' + s.errors, + 'SKIPPED=' + s.skipped, + 'PERCENTAGE=' + s.passPercent, + ]; + output.forEach(l => fs.appendFileSync(process.env.GITHUB_OUTPUT, l + '\n')); + } else { + console.log('No Maestro report found — marking as failure'); + ['FAILURES=1','PASSES=0','TOTAL=0','ERRORS=1','SKIPPED=0','PERCENTAGE=0','NO_REPORT=true'].forEach(l => fs.appendFileSync(process.env.GITHUB_OUTPUT, l + '\n')); + } + " + + - name: Upload Maestro Report to S3 + if: always() + run: | + cd detox + node -e " + const {saveArtifacts} = require('./utils/artifacts'); + const fse = require('fs-extra'); + fse.copySync('../build', './artifacts/maestro-ios', {overwrite: true}); + saveArtifacts('maestro-ios').then(r => { + if (r && r.success) console.log('Uploaded:', r.reportLink); + }).catch(e => console.log('S3 upload skipped:', e.message)); + " + env: + DETOX_AWS_ACCESS_KEY_ID: ${{ secrets.MM_MOBILE_DETOX_AWS_ACCESS_KEY_ID }} + DETOX_AWS_SECRET_ACCESS_KEY: ${{ secrets.MM_MOBILE_DETOX_AWS_SECRET_ACCESS_KEY }} + DETOX_AWS_S3_BUCKET: ${{ env.DETOX_AWS_S3_BUCKET }} + REPORT_PATH: maestro-ios-${{ github.run_id }} + + - name: Set Target URL + id: set-url + if: always() + env: + S3_BUCKET: ${{ env.DETOX_AWS_S3_BUCKET }} + run: | + echo "TARGET_URL=https://${S3_BUCKET:-mattermost-detox-report}.s3.amazonaws.com/maestro-ios-${{ github.run_id }}/maestro-ios/maestro-report.xml" >> "$GITHUB_OUTPUT" + + - name: Determine Status + id: determine-status + if: always() + run: | + FAILURES="${{ steps.summary.outputs.FAILURES }}" + if [[ "${FAILURES:-0}" -gt 0 ]]; then + echo "STATUS=failure" >> "$GITHUB_OUTPUT" + else + echo "STATUS=success" >> "$GITHUB_OUTPUT" + fi + + - name: Generate Summary + if: always() + run: | + echo "### Maestro iOS E2E Results" >> "$GITHUB_STEP_SUMMARY" + echo "| Tests | Passed :white_check_mark: | Failed :x: | Skipped :fast_forward: |" >> "$GITHUB_STEP_SUMMARY" + echo "|:---:|:---:|:---:|:---:|" >> "$GITHUB_STEP_SUMMARY" + echo "| ${{ steps.summary.outputs.TOTAL }} | ${{ steps.summary.outputs.PASSES }} | ${{ steps.summary.outputs.FAILURES }} | ${{ steps.summary.outputs.SKIPPED }} |" >> "$GITHUB_STEP_SUMMARY" + + e2e-maestro-android: + name: Maestro E2E Tests (Android) + if: ${{ inputs.platform == 'android' }} + runs-on: ubuntu-latest-8-cores + timeout-minutes: 60 + outputs: + STATUS: ${{ steps.determine-status.outputs.STATUS }} + TARGET_URL: ${{ steps.set-url.outputs.TARGET_URL }} + FAILURES: ${{ steps.summary.outputs.FAILURES }} + PASSES: ${{ steps.summary.outputs.PASSES }} + TOTAL: ${{ steps.summary.outputs.TOTAL }} + PERCENTAGE: ${{ steps.summary.outputs.PERCENTAGE }} + steps: + - name: Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ inputs.MOBILE_VERSION }} + + - name: ci/prepare-node-deps + uses: ./.github/actions/prepare-node-deps + + - name: Cache Maestro + id: maestro-cache + uses: actions/cache@v4 + with: + path: ~/.maestro + key: maestro-2.3.0-linux + + - name: Install Maestro CLI + if: steps.maestro-cache.outputs.cache-hit != 'true' + run: | + curl -fsSL "https://get.maestro.mobile.dev" | bash -s -- --version 2.3.0 + MAESTRO_BIN="$HOME/.maestro/bin/maestro" + if [ ! -f "$MAESTRO_BIN" ]; then + echo "Error: Maestro binary not found at $MAESTRO_BIN" + exit 1 + fi + echo "Maestro installed successfully at $MAESTRO_BIN" + "$MAESTRO_BIN" --version + + - name: Verify Maestro installation + run: ~/.maestro/bin/maestro --version + + - name: Download Android APK Build + uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4 + with: + name: android-build-files-${{ github.run_id }} + path: mobile-artifacts/apk + + - name: Seed test data + run: | + cd maestro + npm install + npx tsx fixtures/seed.ts + env: + SITE_1_URL: ${{ env.SITE_1_URL }} + ADMIN_EMAIL: ${{ env.ADMIN_EMAIL }} + ADMIN_USERNAME: ${{ env.ADMIN_USERNAME }} + ADMIN_PASSWORD: ${{ env.ADMIN_PASSWORD }} + + - name: Resolve flow path + id: resolve-flow-path + run: | + if [ -n "${{ inputs.flow-path }}" ]; then + echo "flow_path=${{ inputs.flow-path }}" >> $GITHUB_OUTPUT + else + # Android: skip share_extension (cross-app), multi_device, + # and channels/channel_bookmark_file (iOS document picker navigation) + echo "flow_path=maestro/flows/account maestro/flows/calls maestro/flows/timezone" >> $GITHUB_OUTPUT + fi + + - name: Set up emulator, install app, and run Maestro flows (Android) + env: + JAVA_HOME: ${{ env.JAVA_HOME_17_X64 }} + run: | + set -e + + # --- 1. Enable KVM --- + 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 + + # --- 2. Set up Android SDK --- + export ANDROID_HOME=/usr/local/lib/android/sdk + export PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/emulator:$ANDROID_HOME/tools/bin:$ANDROID_HOME/platform-tools:$PATH + + # --- 3. Install SDK components --- + for attempt in 1 2 3; do + echo "SDK install attempt $attempt..." + yes | sdkmanager --install "platform-tools" "emulator" "platforms;android-34" "system-images;android-34;google_apis;x86_64" && break + echo "Attempt $attempt failed, retrying..." + sleep 10 + done + + # --- 4. Create AVD (set ANDROID_AVD_HOME so emulator can find it) --- + export ANDROID_AVD_HOME=$(pwd)/.android/avd + mkdir -p "$ANDROID_AVD_HOME" + echo "ANDROID_AVD_HOME=$ANDROID_AVD_HOME" + + echo "no" | avdmanager create avd -n maestro_avd \ + -k "system-images;android-34;google_apis;x86_64" \ + -d "pixel_4_xl" --force + + # --- 5. Start emulator --- + echo "Starting emulator..." + nohup emulator -avd maestro_avd \ + -no-snapshot-save -no-window -gpu swiftshader_indirect \ + -noaudio -no-boot-anim -camera-back none \ + -accel on -qemu -m 8192 & + + # --- 6. Wait for boot (5 min timeout, matching Detox pattern) --- + adb wait-for-device + boot_timeout=300 + boot_elapsed=0 + until [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; do + if [ $boot_elapsed -ge $boot_timeout ]; then + echo "Emulator failed to boot within 5 minutes" + exit 1 + fi + echo "Waiting for emulator to fully boot... (${boot_elapsed}s)" + sleep 10 + boot_elapsed=$((boot_elapsed + 10)) + done + echo "Emulator is fully booted." + sleep 15 + adb shell pm list packages > /dev/null 2>&1 + echo "Emulator is fully ready." + + # Disable animations + adb shell settings put global window_animation_scale 0 + adb shell settings put global transition_animation_scale 0 + adb shell settings put global animator_duration_scale 0 + adb shell input keyevent 82 + + # --- 7. Install APK --- + APK_PATH=$(find mobile-artifacts/apk -name "*.apk" | head -1) + if [ -z "$APK_PATH" ]; then + echo "Error: Could not find APK in mobile-artifacts/apk/" + ls -la mobile-artifacts/apk/ || true + exit 1 + fi + echo "Installing APK from: $APK_PATH" + adb install -r "$APK_PATH" + + # Set timezone for MM-T1325 timezone flow + adb root + adb shell setprop persist.sys.timezone "America/New_York" + + # Push test fixture file for bookmark flow + FIXTURE="detox/e2e/support/fixtures/image.png" + if [ -f "$FIXTURE" ]; then + adb push "$FIXTURE" /sdcard/Download/test_bookmark.png + echo "Pushed test fixture to /sdcard/Download/test_bookmark.png" + fi + + # --- 8. Run Maestro flows --- + set -a + source maestro/.maestro-test-env.sh + set +a + + FLOW_PATH="${{ steps.resolve-flow-path.outputs.flow_path }}" + + mkdir -p build + ~/.maestro/bin/maestro test \ + --format junit \ + --output build/maestro-report.xml \ + --test-output-dir build/maestro-artifacts \ + --flatten-debug-output \ + --platform android \ + --env SITE_1_URL="${SITE_1_URL}" \ + --env TEST_USER_EMAIL="${TEST_USER_EMAIL}" \ + --env TEST_USER_PASSWORD="${TEST_USER_PASSWORD}" \ + --env TEST_CHANNEL_NAME="${TEST_CHANNEL_NAME}" \ + --env TEST_TEAM_NAME="${TEST_TEAM_NAME}" \ + --env ADMIN_TOKEN="${ADMIN_TOKEN}" \ + --env MAESTRO_APP_ID="com.mattermost.rnbeta" \ + $FLOW_PATH + + - name: Upload Maestro Artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: maestro-android-results-${{ github.run_id }} + path: build/ + + - name: Parse Maestro Report + id: summary + if: always() + run: | + cd detox + npm ci --prefer-offline --no-audit --no-fund + node -e " + const {parseMaestroReport} = require('./utils/maestro_report'); + const summary = parseMaestroReport('../build/maestro-report.xml'); + const fs = require('fs'); + if (summary) { + const s = summary.stats; + const output = [ + 'FAILURES=' + s.failures, + 'PASSES=' + s.passes, + 'TOTAL=' + s.tests, + 'ERRORS=' + s.errors, + 'SKIPPED=' + s.skipped, + 'PERCENTAGE=' + s.passPercent, + ]; + output.forEach(l => fs.appendFileSync(process.env.GITHUB_OUTPUT, l + '\n')); + } else { + console.log('No Maestro report found — marking as failure'); + ['FAILURES=1','PASSES=0','TOTAL=0','ERRORS=1','SKIPPED=0','PERCENTAGE=0','NO_REPORT=true'].forEach(l => fs.appendFileSync(process.env.GITHUB_OUTPUT, l + '\n')); + } + " + + - name: Upload Maestro Report to S3 + if: always() + run: | + cd detox + node -e " + const {saveArtifacts} = require('./utils/artifacts'); + const fse = require('fs-extra'); + fse.copySync('../build', './artifacts/maestro-android', {overwrite: true}); + saveArtifacts('maestro-android').then(r => { + if (r && r.success) console.log('Uploaded:', r.reportLink); + }).catch(e => console.log('S3 upload skipped:', e.message)); + " + env: + DETOX_AWS_ACCESS_KEY_ID: ${{ secrets.MM_MOBILE_DETOX_AWS_ACCESS_KEY_ID }} + DETOX_AWS_SECRET_ACCESS_KEY: ${{ secrets.MM_MOBILE_DETOX_AWS_SECRET_ACCESS_KEY }} + DETOX_AWS_S3_BUCKET: ${{ env.DETOX_AWS_S3_BUCKET }} + REPORT_PATH: maestro-android-${{ github.run_id }} + + - name: Set Target URL + id: set-url + if: always() + env: + S3_BUCKET: ${{ env.DETOX_AWS_S3_BUCKET }} + run: | + echo "TARGET_URL=https://${S3_BUCKET:-mattermost-detox-report}.s3.amazonaws.com/maestro-android-${{ github.run_id }}/maestro-android/maestro-report.xml" >> "$GITHUB_OUTPUT" + + - name: Determine Status + id: determine-status + if: always() + run: | + FAILURES="${{ steps.summary.outputs.FAILURES }}" + if [[ "${FAILURES:-0}" -gt 0 ]]; then + echo "STATUS=failure" >> "$GITHUB_OUTPUT" + else + echo "STATUS=success" >> "$GITHUB_OUTPUT" + fi + + - name: Generate Summary + if: always() + run: | + echo "### Maestro Android E2E Results" >> "$GITHUB_STEP_SUMMARY" + echo "| Tests | Passed :white_check_mark: | Failed :x: | Skipped :fast_forward: |" >> "$GITHUB_STEP_SUMMARY" + echo "|:---:|:---:|:---:|:---:|" >> "$GITHUB_STEP_SUMMARY" + echo "| ${{ steps.summary.outputs.TOTAL }} | ${{ steps.summary.outputs.PASSES }} | ${{ steps.summary.outputs.FAILURES }} | ${{ steps.summary.outputs.SKIPPED }} |" >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 119e957ab2..3d1baf1591 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,24 @@ env.d.ts .tsbuildinfo.precommit */**/compass-icons.ttf +mobile-artifacts/* +# Local MCP and IDE config +.mcp.json +.cursor/ + +# Maestro local env (contains test server credentials) +.maestro-test-env.sh +maestro/.maestro-test-env.sh + +# Debug screenshots captured during local Maestro runs +/*.png + +# Maestro TypeScript build output +maestro/dist/ + +# Detox test output directory +detox/e2e/e2e/ # OSX # .DS_Store @@ -133,5 +150,7 @@ libraries/@mattermost/intune/* node_modules/@mattermost/intune # Claude Code .claude/settings.local.json +.worktrees +.pi/ .codex/ .cursor/ diff --git a/android/gradle.properties b/android/gradle.properties index 6a8f9d53cb..c79c39260c 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -15,7 +15,8 @@ org.gradle.jvmargs=-Xmx4096m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true +org.gradle.parallel=true +org.gradle.caching=true #android.enableAapt2=false #android.useDeprecatedNdk=true diff --git a/app/actions/local/thread.test.ts b/app/actions/local/thread.test.ts index dc6ef21730..7c64288ef3 100644 --- a/app/actions/local/thread.test.ts +++ b/app/actions/local/thread.test.ts @@ -20,6 +20,7 @@ import { } from './thread'; import type ServerDataOperator from '@database/operator/server_data_operator'; +import type ThreadModel from '@typings/database/models/servers/thread'; const serverUrl = 'baseHandler.test.com'; let operator: ServerDataOperator; @@ -232,6 +233,56 @@ describe('createThreadFromNewPost', () => { expect(models?.length).toBe(2); // thread, thread participant }); + it('should set is_following=true locally when the current user posts a reply (existing thread)', async () => { + await operator.handleUsers({users: [user], prepareRecordsOnly: false}); + await operator.handleThreads({threads: [{...threads[0], is_following: false}], prepareRecordsOnly: false, teamId: team.id}); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); + const post = TestHelper.fakePost({channel_id: channelId, user_id: user.id, id: 'postid', create_at: 1, root_id: rootPost.id}); + + const {error} = await createThreadFromNewPost(serverUrl, post, false); + const savedThread = await operator.database.get('Thread').find(rootPost.id); + + expect(error).toBeUndefined(); + expect(savedThread.isFollowing).toBe(true); + }); + + it('should set is_following=true when thread record does not exist yet (only root post in DB)', async () => { + // Only store the root post — no Thread record — to exercise the else-branch in createThreadFromNewPost. + await operator.handleUsers({users: [user], prepareRecordsOnly: false}); + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [rootPost.id], + posts: [rootPost], + prepareRecordsOnly: false, + }); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); + const post = TestHelper.fakePost({channel_id: channelId, user_id: user.id, id: 'postid3', create_at: 3, root_id: rootPost.id, reply_count: 1}); + + const {error} = await createThreadFromNewPost(serverUrl, post, false); + const savedThread = await operator.database.get('Thread').find(rootPost.id); + + expect(error).toBeUndefined(); + expect(savedThread.isFollowing).toBe(true); + }); + + it('should update reply_count when creating thread from new reply post', async () => { + await operator.handleUsers({users: [user], prepareRecordsOnly: false}); + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [rootPost.id], + posts: [rootPost], + prepareRecordsOnly: false, + }); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); + const post = TestHelper.fakePost({channel_id: channelId, user_id: user.id, id: 'postid2', create_at: 2, root_id: rootPost.id, reply_count: 1}); + + const {error} = await createThreadFromNewPost(serverUrl, post, false); + const savedThread = await operator.database.get('Thread').find(rootPost.id); + + expect(error).toBeUndefined(); + expect(savedThread.replyCount).toBe(1); + }); + it('base case - no root post', async () => { await operator.handleUsers({users: [user2], prepareRecordsOnly: false}); const post = TestHelper.fakePost({channel_id: channelId, user_id: user2.id, id: 'postid', create_at: 1}); diff --git a/app/actions/local/thread.ts b/app/actions/local/thread.ts index 2213495e5e..061456d4ce 100644 --- a/app/actions/local/thread.ts +++ b/app/actions/local/thread.ts @@ -167,13 +167,30 @@ export const switchToThread = async (serverUrl: string, rootId: string, isFromNo // 2. Else add the post as a thread export async function createThreadFromNewPost(serverUrl: string, post: Post, prepareRecordsOnly = false) { try { - const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const models: Model[] = []; if (post.root_id) { - // Update the thread data: `reply_count` - const {model: threadModel} = await updateThread(serverUrl, post.root_id, {reply_count: post.reply_count}, true); - if (threadModel) { - models.push(threadModel); + const existingThread = await getThreadById(database, post.root_id); + + if (existingThread) { + // Update the thread data: `reply_count` and auto-follow (replying auto-follows the thread) + const {model: threadModel} = await updateThread(serverUrl, post.root_id, { + reply_count: post.reply_count, + is_following: true, + }, true); + if (threadModel) { + models.push(threadModel); + } + } else { + const rootPost = await getPostById(database, post.root_id); + if (rootPost) { + const threadModels = await prepareThreadsFromReceivedPosts(operator, [{ + ...await rootPost.toApi(), + reply_count: post.reply_count, + is_following: true, + }], false); + models.push(...threadModels); + } } // Add user as a participant to the thread @@ -218,13 +235,17 @@ export async function processReceivedThreads(serverUrl: string, threads: Thread[ // Extract posts & users from the received threads for (let i = 0; i < threads.length; i++) { const {participants, post} = threads[i]; - posts.push(post); - participants.forEach((participant) => { - if (currentUserId !== participant.id) { - users.push(participant); - } - }); - threadsToHandle.push({...threads[i], lastFetchedAt: post.create_at}); + if (post) { + posts.push(post); + } + if (participants) { + participants.forEach((participant) => { + if (currentUserId !== participant.id) { + users.push(participant); + } + }); + } + threadsToHandle.push({...threads[i], lastFetchedAt: post?.create_at ?? 0}); } const postModels = await operator.handlePosts({ diff --git a/app/actions/remote/preference.test.ts b/app/actions/remote/preference.test.ts index 70b9162722..0182fb2365 100644 --- a/app/actions/remote/preference.test.ts +++ b/app/actions/remote/preference.test.ts @@ -1,13 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -/* eslint-disable max-lines */ - import {Preferences} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; import {querySavedPostsPreferences, queryPreferencesByCategoryAndName} from '@queries/servers/preference'; +import EphemeralStore from '@store/ephemeral_store'; import TestHelper from '@test/test_helper'; import { @@ -47,6 +46,7 @@ const throwFunc = () => { }; jest.mock('@queries/servers/preference'); +jest.mock('@store/ephemeral_store'); const mockClient = { getMyPreferences: jest.fn(() => [preference1]), @@ -55,7 +55,6 @@ const mockClient = { }; beforeAll(() => { - // eslint-disable-next-line // @ts-ignore NetworkManager.getClient = () => mockClient; }); @@ -115,6 +114,7 @@ describe('preferences', () => { expect(result.preferences).toBeDefined(); expect(result.preferences?.length).toBe(1); expect(result.preferences?.[0].category).toBe(Preferences.CATEGORIES.SAVED_POST); + expect(EphemeralStore.clearRecentlyUnsavedSavedPost).toHaveBeenCalledWith(serverUrl, post1.id); }); it('savePreference - handle error', async () => { @@ -155,9 +155,29 @@ describe('preferences', () => { expect(result).toBeDefined(); expect(result.error).toBeUndefined(); expect(result.preference).toBeDefined(); + expect(EphemeralStore.addRecentlyUnsavedSavedPost).toHaveBeenCalledWith(serverUrl, post1.id); expect(prefModel.destroyPermanently).toHaveBeenCalledTimes(1); }); + it('deleteSavedPost - does not mark ephemeral store when API call fails', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false}); + + const prefModel = { + user_id: user1.id, + name: post1.id, + category: Preferences.CATEGORIES.SAVED_POST, + value: 'true', + destroyPermanently: jest.fn(), + } as unknown as PreferenceModel; + (querySavedPostsPreferences as jest.Mock).mockReturnValueOnce({fetch: jest.fn(() => [prefModel])}); + mockClient.deletePreferences.mockImplementationOnce(jest.fn(throwFunc)); + + const result = await deleteSavedPost(serverUrl, post1.id); + expect(result.error).toBeDefined(); + expect(EphemeralStore.addRecentlyUnsavedSavedPost).not.toHaveBeenCalled(); + expect(prefModel.destroyPermanently).not.toHaveBeenCalled(); + }); + it('openChannelIfNeeded - handle not found database', async () => { const result = await openChannelIfNeeded('foo', '') as {error: unknown}; expect(result).toBeDefined(); diff --git a/app/actions/remote/preference.ts b/app/actions/remote/preference.ts index 2793f5e698..7ba1671a97 100644 --- a/app/actions/remote/preference.ts +++ b/app/actions/remote/preference.ts @@ -90,15 +90,19 @@ export const savePreference = async (serverUrl: string, preferences: PreferenceT return {preferences: []}; } + preferences.forEach((preference) => { + if (preference.category === Preferences.CATEGORIES.SAVED_POST && preference.value === 'true') { + EphemeralStore.clearRecentlyUnsavedSavedPost(serverUrl, preference.name); + } + }); + const client = NetworkManager.getClient(serverUrl); const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const userId = await getCurrentUserId(database); const chunkSize = 100; const chunks = chunk(preferences, chunkSize); - chunks.forEach((c: PreferenceType[]) => { - client.savePreferences(userId, c, groupLabel); - }); + await Promise.all(chunks.map((c: PreferenceType[]) => client.savePreferences(userId, c, groupLabel))); const preferenceModels = await operator.handlePreferences({ preferences, prepareRecordsOnly, @@ -127,7 +131,8 @@ export const deleteSavedPost = async (serverUrl: string, postId: string) => { }; if (postPreferenceRecord) { - client.deletePreferences(userId, [pref]); + await client.deletePreferences(userId, [pref]); + EphemeralStore.addRecentlyUnsavedSavedPost(serverUrl, postId); await postPreferenceRecord.destroyPermanently(); } diff --git a/app/actions/remote/search.ts b/app/actions/remote/search.ts index 4628f890d7..d053a78640 100644 --- a/app/actions/remote/search.ts +++ b/app/actions/remote/search.ts @@ -7,7 +7,7 @@ import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; import {prepareMissingChannelsForAllTeams} from '@queries/servers/channel'; -import {getConfigValue, getCurrentTeamId} from '@queries/servers/system'; +import {getConfigBooleanValue, getCurrentTeamId} from '@queries/servers/system'; import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread'; import {getCurrentUser} from '@queries/servers/user'; import {getFullErrorMessage} from '@utils/errors'; @@ -58,14 +58,14 @@ export const searchPosts = async (serverUrl: string, teamId: string, params: Pos try { const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const client = NetworkManager.getClient(serverUrl); - const viewArchivedChannels = await getConfigValue(database, 'ExperimentalViewArchivedChannels'); + const viewArchivedChannels = await getConfigBooleanValue(database, 'ExperimentalViewArchivedChannels', true); const user = await getCurrentUser(database); const timezoneOffset = getUtcOffsetForTimeZone(getUserTimezone(user)) * 60; let postsArray: Post[] = []; const data = await client.searchPostsWithParams(teamId, { ...params, - include_deleted_channels: Boolean(viewArchivedChannels), + include_deleted_channels: viewArchivedChannels, time_zone_offset: timezoneOffset, }); diff --git a/app/actions/remote/thread.test.ts b/app/actions/remote/thread.test.ts index d0288e2d55..2afcfe74aa 100644 --- a/app/actions/remote/thread.test.ts +++ b/app/actions/remote/thread.test.ts @@ -264,6 +264,53 @@ describe('update threads', () => { expect(result.error).toBeUndefined(); }); + it('markThreadAsUnread - uses unread_replies and unread_mentions from API response', async () => { + (mockClient.markThreadAsUnread as jest.Mock).mockResolvedValueOnce({ + unread_replies: 5, + unread_mentions: 2, + }); + + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + await operator.handleThreads({threads, prepareRecordsOnly: false, teamId: team.id}); + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post1.id], + posts: [post1], + prepareRecordsOnly: false, + }); + + const result = await markThreadAsUnread(serverUrl, '', thread1.id, post1.id); + expect(result.error).toBeUndefined(); + + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const {getThreadById} = require('@queries/servers/thread'); + const savedThread = await getThreadById(database, thread1.id); + expect(savedThread?.unreadReplies).toBe(5); + expect(savedThread?.unreadMentions).toBe(2); + }); + + it('markThreadAsUnread - falls back to defaults when API response has no unread counts', async () => { + (mockClient.markThreadAsUnread as jest.Mock).mockResolvedValueOnce(undefined); + + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + await operator.handleThreads({threads, prepareRecordsOnly: false, teamId: team.id}); + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post1.id], + posts: [post1], + prepareRecordsOnly: false, + }); + + const result = await markThreadAsUnread(serverUrl, '', thread1.id, post1.id); + expect(result.error).toBeUndefined(); + + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const {getThreadById} = require('@queries/servers/thread'); + const savedThread = await getThreadById(database, thread1.id); + expect(savedThread?.unreadReplies).toBe(1); + expect(savedThread?.unreadMentions).toBe(0); + }); + it('markThreadAsUnread - handle error', async () => { const result = await markThreadAsUnread('foo', '', '', ''); expect(result).toBeDefined(); diff --git a/app/actions/remote/thread.ts b/app/actions/remote/thread.ts index 3c7a0e1b30..e1e8546adb 100644 --- a/app/actions/remote/thread.ts +++ b/app/actions/remote/thread.ts @@ -162,12 +162,15 @@ export const markThreadAsUnread = async (serverUrl: string, teamId: string, thre const data = await client.markThreadAsUnread('me', threadTeamId, threadId, postId); - // Update locally + // Update locally using response data so unread_replies is set immediately + // (without waiting for the THREAD_READ_CHANGED WS event) const post = await getPostById(database, postId); if (post) { await updateThread(serverUrl, threadId, { last_viewed_at: post.createAt - 1, viewed_at: post.createAt - 1, + unread_replies: data?.unread_replies ?? 1, + unread_mentions: data?.unread_mentions ?? 0, }); } diff --git a/app/actions/websocket/channel.ts b/app/actions/websocket/channel.ts index 8f01dfaa00..1059ab57b8 100644 --- a/app/actions/websocket/channel.ts +++ b/app/actions/websocket/channel.ts @@ -432,7 +432,7 @@ export async function handleChannelDeletedEvent(serverUrl: string, msg: WebSocke const currentChannel = await getCurrentChannel(database); const config = await getConfig(database); - if (config?.ExperimentalViewArchivedChannels !== 'true') { + if (config?.ExperimentalViewArchivedChannels === 'false') { if (currentChannel && currentChannel.id === channelId) { await handleKickFromChannel(serverUrl, channelId, Events.CHANNEL_ARCHIVED); } diff --git a/app/actions/websocket/preferences.test.ts b/app/actions/websocket/preferences.test.ts index f45d1d0812..9ea0c217ff 100644 --- a/app/actions/websocket/preferences.test.ts +++ b/app/actions/websocket/preferences.test.ts @@ -34,6 +34,7 @@ describe('WebSocket Preferences Actions', () => { jest.spyOn(operator, 'handlePreferences').mockResolvedValue([]); jest.mocked(EphemeralStore.isEnablingCRT).mockReturnValue(false); + jest.mocked(EphemeralStore.isRecentlyUnsavedSavedPost).mockReturnValue(false); }); afterEach(async () => { @@ -155,6 +156,21 @@ describe('WebSocket Preferences Actions', () => { expect(fetchPostById).toHaveBeenCalledWith(serverUrl, 'post1', false); }); + it('should ignore stale saved post preferences after a local unsave', async () => { + const msg = { + data: { + preferences: JSON.stringify(mockPreferences), + }, + } as WebSocketMessage; + + jest.mocked(EphemeralStore.isRecentlyUnsavedSavedPost).mockReturnValue(true); + + await handlePreferencesChangedEvent(serverUrl, msg); + + expect(operator.handlePreferences).not.toHaveBeenCalled(); + expect(fetchPostById).not.toHaveBeenCalled(); + }); + it('should handle name format changes in bulk', async () => { const msg = { data: { diff --git a/app/actions/websocket/preferences.ts b/app/actions/websocket/preferences.ts index e19991e3a5..de5fd92f81 100644 --- a/app/actions/websocket/preferences.ts +++ b/app/actions/websocket/preferences.ts @@ -10,6 +10,16 @@ import {getPostById} from '@queries/servers/post'; import {deletePreferences, differsFromLocalNameFormat, getHasCRTChanged} from '@queries/servers/preference'; import EphemeralStore from '@store/ephemeral_store'; +function filterStaleSavedPostPreferences(serverUrl: string, preferences: PreferenceType[]) { + return preferences.filter((preference) => { + if (preference.category !== Preferences.CATEGORIES.SAVED_POST || preference.value !== 'true') { + return true; + } + + return !EphemeralStore.isRecentlyUnsavedSavedPost(serverUrl, preference.name); + }); +} + export async function handlePreferenceChangedEvent(serverUrl: string, msg: WebSocketMessage): Promise { if (EphemeralStore.isEnablingCRT()) { return; @@ -18,14 +28,21 @@ export async function handlePreferenceChangedEvent(serverUrl: string, msg: WebSo try { const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const preference: PreferenceType = JSON.parse(msg.data.preference); - handleSavePostAdded(serverUrl, [preference]); + const preferences = filterStaleSavedPostPreferences(serverUrl, [preference]); - const hasDiffNameFormatPref = await differsFromLocalNameFormat(database, [preference]); - const crtToggled = await getHasCRTChanged(database, [preference]); + // Empty only when the single preference was a stale SAVED_POST re-save — safe to skip. + if (!preferences.length) { + return; + } + + handleSavePostAdded(serverUrl, preferences); + + const hasDiffNameFormatPref = await differsFromLocalNameFormat(database, preferences); + const crtToggled = await getHasCRTChanged(database, preferences); await operator.handlePreferences({ prepareRecordsOnly: false, - preferences: [preference], + preferences, }); if (hasDiffNameFormatPref) { @@ -47,7 +64,15 @@ export async function handlePreferencesChangedEvent(serverUrl: string, msg: WebS try { const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); - const preferences: PreferenceType[] = JSON.parse(msg.data.preferences); + const preferences: PreferenceType[] = filterStaleSavedPostPreferences(serverUrl, JSON.parse(msg.data.preferences)); + + // filterStaleSavedPostPreferences only removes SAVED_POST entries with value='true' that + // are in the recently-unsaved set; all other preference categories pass through unchanged. + // An empty result here means the entire batch was stale SAVED_POST re-saves — safe to skip. + if (!preferences.length) { + return; + } + handleSavePostAdded(serverUrl, preferences); const hasDiffNameFormatPref = await differsFromLocalNameFormat(database, preferences); diff --git a/app/components/channel_bookmarks/add_bookmark.tsx b/app/components/channel_bookmarks/add_bookmark.tsx index e5c0a29f42..04c7a4c823 100644 --- a/app/components/channel_bookmarks/add_bookmark.tsx +++ b/app/components/channel_bookmarks/add_bookmark.tsx @@ -143,6 +143,7 @@ const AddBookmark = ({bookmarksCount, channelId, currentUserId, canUploadFiles, backgroundStyle={showLarge ? styles.largeButton : styles.smallButton} onPress={onPress} hitSlop={hitSlop} + testID='channel_info.add_bookmark.button' text={showLarge ? formatMessage({id: 'channel_info.add_bookmark', defaultMessage: 'Add a bookmark'}) : ''} textStyle={showLarge ? styles.largeButtonText : styles.smallButtonText} theme={theme} diff --git a/app/components/channel_bookmarks/bookmark_type.tsx b/app/components/channel_bookmarks/bookmark_type.tsx index 07d7da8b6c..39e71f0638 100644 --- a/app/components/channel_bookmarks/bookmark_type.tsx +++ b/app/components/channel_bookmarks/bookmark_type.tsx @@ -53,6 +53,7 @@ const BookmarkType = ({channelId, type, ownerId}: Props) => { action={onPress} label={label} icon={icon} + testID={`channel_bookmark.type.${type}`} type='default' /> ); diff --git a/app/components/channel_bookmarks/channel_bookmark/bookmark_details.tsx b/app/components/channel_bookmarks/channel_bookmark/bookmark_details.tsx index 7f490d691e..572318121c 100644 --- a/app/components/channel_bookmarks/channel_bookmark/bookmark_details.tsx +++ b/app/components/channel_bookmarks/channel_bookmark/bookmark_details.tsx @@ -67,7 +67,12 @@ const BookmarkDetails = ({bookmark, children, file}: Props) => { /> {children} - {bookmark.displayName} + + {bookmark.displayName} + ); }; diff --git a/app/components/channel_bookmarks/channel_bookmark/channel_bookmark.tsx b/app/components/channel_bookmarks/channel_bookmark/channel_bookmark.tsx index 077e48d4a5..67c05ad459 100644 --- a/app/components/channel_bookmarks/channel_bookmark/channel_bookmark.tsx +++ b/app/components/channel_bookmarks/channel_bookmark/channel_bookmark.tsx @@ -126,7 +126,10 @@ const ChannelBookmark = ({ } return ( - +