diff --git a/.github/actions/setup-ios-runtime/action.yml b/.github/actions/setup-ios-runtime/action.yml new file mode 100644 index 0000000000..b116785d20 --- /dev/null +++ b/.github/actions/setup-ios-runtime/action.yml @@ -0,0 +1,48 @@ +name: Setup iOS Runtime +description: Download and install a requested iOS simulator runtime, and create a simulator on it +inputs: + version: + description: iOS runtime version (e.g. 17.5) + required: true + device: + description: Simulator device type, also the simulator's name (e.g. iPhone 15 Pro) + required: true +runs: + using: composite + steps: + - name: Install ipsw + shell: bash + run: | + IPSW_VERSION=3.1.592 + FILE="ipsw_${IPSW_VERSION}_macOS_universal.tar.gz" + if ! command -v ipsw >/dev/null; then + wget -q "https://github.com/blacktop/ipsw/releases/download/v${IPSW_VERSION}/${FILE}" + tar -xzf "$FILE" + chmod +x ipsw + sudo mv ipsw /usr/local/bin/ + rm -f "$FILE" + fi + + # Starts from no simulators, as the Flutter nightly does. + - name: Remove the preinstalled simulators + shell: bash + run: sudo rm -rf ~/Library/Developer/CoreSimulator/* || true + + - name: Install iOS runtime + shell: bash + working-directory: examples/SampleApp + run: bundle exec fastlane install_runtime ios:${{ inputs.version }} + + - name: Cleanup iOS runtime install artifacts + shell: bash + run: | + sudo rm -rf examples/SampleApp/*.dmg || true + xcrun simctl list runtimes + + # Named after the device type, so the job's `Boot simulator` step finds it. + - name: Create the iOS simulator + shell: bash + run: | + ios_version_dash=$(echo "${{ inputs.version }}" | tr '.' '-') + xcrun simctl create "${{ inputs.device }}" "${{ inputs.device }}" "com.apple.CoreSimulator.SimRuntime.iOS-${ios_version_dash}" + xcrun simctl list devices "${{ inputs.version }}" diff --git a/.github/workflows/e2e-test-cron.yml b/.github/workflows/e2e-test-cron.yml new file mode 100644 index 0000000000..8af4ead87a --- /dev/null +++ b/.github/workflows/e2e-test-cron.yml @@ -0,0 +1,455 @@ +name: E2E Tests Nightly + +on: + schedule: + - cron: "0 2 * * *" + workflow_dispatch: + inputs: + ios: + description: "Run iOS e2e tests" + type: boolean + default: true + android: + description: "Run Android e2e tests" + type: boolean + default: true + mock_server_branch: + description: "stream-chat-test-mock-server branch" + type: string + default: "main" + +permissions: + contents: read + # Downloads the previous map, whose coverage failed tests keep. + actions: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + MOCK_SERVER_BRANCH: ${{ inputs.mock_server_branch || 'main' }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }} + # node_modules of the e2e app and the workspaces it depends on (nmHoistingLimits: workspaces + # keeps them apart); `yarn workspaces focus` installs nothing else. + NODE_MODULES_PATHS: | + node_modules + configs/*/node_modules + package/node_modules + package/native-package/node_modules + examples/E2ETestApp/node_modules + .yarn/install-state.gz + +jobs: + ios: + name: Test iOS ${{ matrix.ios }} + if: github.event_name == 'schedule' || inputs.ios + runs-on: macos-15 + timeout-minutes: 360 + strategy: + fail-fast: false + matrix: + include: + - ios: "26.2" + device: "iPhone 17 Pro" + setup_runtime: false + # The oldest runtime the Flutter nightly covers; the runner image doesn't ship it. + - ios: "17.5" + device: "iPhone 15 Pro" + setup_runtime: true + env: + DETOX_IOS_DEVICE: ${{ matrix.device }} + IOS_VERSION: ${{ matrix.ios }} + # Pinned to match the simulator runtimes this job selects. + XCODE_VERSION: "26.2" + APP_PATH: examples/E2ETestApp/ios/build/detox/Build/Products/Release-iphonesimulator/E2ETestApp.app + MOCK_SERVER_BUNDLE_PATH: ${{ github.workspace }}/../mock-server-gems + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24.x + cache: "yarn" + cache-dependency-path: "yarn.lock" + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.XCODE_VERSION }} + - uses: ./.github/actions/ruby-cache + - uses: ./.github/actions/setup-ios-runtime + if: matrix.setup_runtime + with: + version: ${{ matrix.ios }} + device: ${{ matrix.device }} + # A fresh simulator takes minutes to boot on CI; boot it now so that overlaps with setup. + - name: Boot simulator + run: | + runtime="com.apple.CoreSimulator.SimRuntime.iOS-${IOS_VERSION//./-}" + udid=$(xcrun simctl list devices available -j | + jq -r --arg rt "$runtime" --arg name "$DETOX_IOS_DEVICE" '.devices[$rt][]? | select(.name == $name) | .udid' | head -1) + if [ -z "$udid" ]; then + # Detox would pick a simulator of the same type on another iOS version. + if [ "${{ matrix.setup_runtime }}" = "true" ]; then + echo "::error::No $DETOX_IOS_DEVICE on iOS $IOS_VERSION" + exit 1 + fi + echo "::warning::No $DETOX_IOS_DEVICE on iOS $IOS_VERSION; Detox will pick and boot a simulator itself" + exit 0 + fi + echo "DETOX_IOS_UDID=$udid" >> "$GITHUB_ENV" + nohup xcrun simctl boot "$udid" > /dev/null 2>&1 & + + - name: Restore node_modules + id: node-modules + uses: actions/cache/restore@v5 + with: + path: ${{ env.NODE_MODULES_PATHS }} + key: ${{ runner.os }}-e2e-node-modules-${{ hashFiles('yarn.lock') }} + - name: Install dependencies + run: yarn workspaces focus e2etestapp + - name: Save node_modules + if: steps.node-modules.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ${{ env.NODE_MODULES_PATHS }} + key: ${{ steps.node-modules.outputs.cache-primary-key }} + + # --- App binary: rebuilt only when the app or SDK sources change, not for test-only changes --- + - name: Restore app + id: app + uses: actions/cache/restore@v5 + with: + path: ${{ env.APP_PATH }} + key: e2e-ios-app-xcode${{ env.XCODE_VERSION }}-${{ hashFiles('yarn.lock', 'package/package.json', 'package/src/**', 'package/shared-native/**', 'package/native-package/package.json', 'package/native-package/src/**', 'package/native-package/ios/**', 'package/native-package/*.podspec', 'examples/SampleApp/Gemfile.lock', 'examples/E2ETestApp/*.js', '!examples/E2ETestApp/.detoxrc.js', 'examples/E2ETestApp/*.json', 'examples/E2ETestApp/*.tsx', 'examples/E2ETestApp/src/**', 'examples/E2ETestApp/ios/**', '!examples/E2ETestApp/ios/build/**', '!examples/E2ETestApp/ios/Pods/**') }} + - name: Set up ccache + if: steps.app.outputs.cache-hit != 'true' + uses: hendrikmuhs/ccache-action@v1.2 + with: + key: e2e-ios-xcode${{ env.XCODE_VERSION }} + max-size: 1500M + - name: Point the RN ccache wrappers at ccache + if: steps.app.outputs.cache-hit != 'true' + # RN's ccache-clang.sh runs `$CCACHE_BINARY clang`, but Xcode doesn't export that build + # setting to the compiler, so without this every compile silently skips ccache. + run: echo "CCACHE_BINARY=$(which ccache)" >> "$GITHUB_ENV" + - name: Cache pods + if: steps.app.outputs.cache-hit != 'true' + uses: actions/cache@v5 + with: + path: examples/E2ETestApp/ios/Pods + key: ${{ runner.os }}-e2e-pods-${{ hashFiles('examples/E2ETestApp/ios/Podfile.lock') }} + restore-keys: | + ${{ runner.os }}-e2e-pods- + - name: Install pods + if: steps.app.outputs.cache-hit != 'true' + working-directory: examples/SampleApp + run: USE_CCACHE=1 bundle exec fastlane e2e_pod_install + - name: Build app + if: steps.app.outputs.cache-hit != 'true' + working-directory: examples/E2ETestApp + run: yarn e2e:build -c ios.sim.release + - name: Save app + if: steps.app.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ${{ env.APP_PATH }} + key: ${{ steps.app.outputs.cache-primary-key }} + + - name: Install applesimutils + # Homebrew refuses formulae from non-official taps until they are trusted. + run: brew tap wix/brew && brew trust --formula wix/brew/applesimutils && brew install wix/brew/applesimutils + - name: Restore Detox framework + id: detox-framework + uses: actions/cache/restore@v5 + with: + path: ~/Library/Detox + key: ${{ runner.os }}-detox-framework-xcode${{ env.XCODE_VERSION }}-${{ hashFiles('yarn.lock') }} + # Yarn's `enableScripts: false` skips Detox's postinstall, which would build this. + - name: Build Detox framework + if: steps.detox-framework.outputs.cache-hit != 'true' + working-directory: examples/E2ETestApp + run: yarn detox build-framework-cache + - name: Save Detox framework + if: steps.detox-framework.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ~/Library/Detox + key: ${{ steps.detox-framework.outputs.cache-primary-key }} + - name: Resolve mock server revision + id: mock-server + run: echo "sha=$(git ls-remote https://github.com/GetStream/stream-chat-test-mock-server "refs/heads/$MOCK_SERVER_BRANCH" | cut -f1)" >> "$GITHUB_OUTPUT" + - name: Cache mock server gems + uses: actions/cache@v5 + with: + path: ${{ env.MOCK_SERVER_BUNDLE_PATH }} + key: ${{ runner.os }}-mock-server-gems-${{ steps.mock-server.outputs.sha }} + restore-keys: | + ${{ runner.os }}-mock-server-gems- + + - name: Wait for the simulator + if: env.DETOX_IOS_UDID != '' + timeout-minutes: 15 + # `-b` boots it if the background boot never started; it errors (405) when the device is + # already booted, and then the plain call returns at once. + run: xcrun simctl bootstatus "$DETOX_IOS_UDID" -b || xcrun simctl bootstatus "$DETOX_IOS_UDID" + - name: Launch Allure TestOps + if: env.ALLURE_TOKEN != '' + working-directory: examples/SampleApp + run: bundle exec fastlane allure_launch cron:true + continue-on-error: true + # A macos-15 runner is about 4x slower than an M-series Mac: one pass takes well over an hour, + # and the retries come on top (the Flutter nightly allows 360 minutes too). Below the job + # timeout, so an overrunning suite fails instead of cancelling the job, and the Allure upload, + # the logs and the Slack report still run. + - name: Run e2e tests + timeout-minutes: 300 + working-directory: examples/SampleApp + run: bundle exec fastlane test_e2e platform:ios skip_build:true mock_server_branch:"$MOCK_SERVER_BRANCH" + - name: Upload Allure results + if: env.LAUNCH_ID != '' && (success() || failure()) + working-directory: examples/SampleApp + run: bundle exec fastlane allure_upload launch_id:"$LAUNCH_ID" + - name: Allure TestOps Launch Removal + if: env.LAUNCH_ID != '' && cancelled() + working-directory: examples/SampleApp + run: bundle exec fastlane allure_launch_removal launch_id:"$LAUNCH_ID" + - uses: actions/upload-artifact@v6 + if: failure() + with: + name: e2e-ios-nightly-logs-${{ matrix.ios }} + path: | + examples/E2ETestApp/stream-chat-test-mock-server/logs + examples/E2ETestApp/artifacts + + # Built once per flavor and shared by the test jobs, as on pull requests. + android-build: + name: Build Android (${{ matrix.coverage && 'coverage' || 'plain' }}) + if: github.event_name == 'schedule' || inputs.android + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + # The coverage runs need a bundle instrumented with E2E_COVERAGE; the others test the plain app. + coverage: [true, false] + env: + APK_PATHS: | + examples/E2ETestApp/android/app/build/outputs/apk/release/app-release.apk + examples/E2ETestApp/android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk + # The emulator is x86_64: skip compiling native code for the other ABIs. + ORG_GRADLE_PROJECT_reactNativeArchitectures: x86_64 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24.x + cache: "yarn" + cache-dependency-path: "yarn.lock" + - uses: actions/setup-java@v5 + with: + distribution: "zulu" + java-version: "21" + - uses: gradle/actions/setup-gradle@v4 + + - name: Restore node_modules + id: node-modules + uses: actions/cache/restore@v5 + with: + path: ${{ env.NODE_MODULES_PATHS }} + key: ${{ runner.os }}-e2e-node-modules-${{ hashFiles('yarn.lock') }} + - name: Install dependencies + run: yarn workspaces focus e2etestapp + - name: Save node_modules + if: steps.node-modules.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ${{ env.NODE_MODULES_PATHS }} + key: ${{ steps.node-modules.outputs.cache-primary-key }} + + # The nightly build is never cached, so every Gradle plugin and dependency is downloaded; a + # transient repository outage (plugin portal, 2026-11-10) must not cost the whole night. + - name: Build app + working-directory: examples/E2ETestApp + run: yarn e2e:build -c android.att.release || { sleep 60; yarn e2e:build -c android.att.release; } + env: + E2E_COVERAGE: ${{ matrix.coverage && '1' || '0' }} + - uses: actions/upload-artifact@v6 + with: + name: e2e-android-apks-${{ matrix.coverage && 'coverage' || 'plain' }} + path: ${{ env.APK_PATHS }} + retention-days: 1 + + android: + name: Test Android API ${{ matrix.api-level }}${{ matrix.coverage && format(' coverage ({0})', matrix.batch) || '' }} + needs: android-build + runs-on: ubuntu-latest + timeout-minutes: 180 + strategy: + fail-fast: false + matrix: + include: + # Coverage runs every test in its own app process for the map that selects e2e tests on + # pull requests. That is too slow for one runner, so its tests are split across batches. + # coverage_map's batch_count must match. + - { api-level: 36, coverage: true, batch: 0, batch_count: 4 } + - { api-level: 36, coverage: true, batch: 1, batch_count: 4 } + - { api-level: 36, coverage: true, batch: 2, batch_count: 4 } + - { api-level: 36, coverage: true, batch: 3, batch_count: 4 } + - { api-level: 34, batch: 0, batch_count: 1 } + - { api-level: 31, batch: 0, batch_count: 1 } + - { api-level: 28, batch: 0, batch_count: 1 } + env: + # swangle_indirect, not swiftshader_indirect: with the latter the emulator exits silently mid-run + # (RN-513). + ANDROID_EMULATOR_OPTIONS: -no-snapshot-save -no-window -no-audio -no-boot-anim -gpu swangle_indirect -camera-back none -camera-front none + DETOX_ADB_NAME: "emulator-5554" + MOCK_SERVER_BUNDLE_PATH: ${{ github.workspace }}/../mock-server-gems + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24.x + cache: "yarn" + cache-dependency-path: "yarn.lock" + - uses: actions/setup-java@v5 + with: + distribution: "zulu" + java-version: "21" + - uses: ./.github/actions/ruby-cache + + - name: Restore node_modules + uses: actions/cache/restore@v5 + with: + path: ${{ env.NODE_MODULES_PATHS }} + key: ${{ runner.os }}-e2e-node-modules-${{ hashFiles('yarn.lock') }} + - name: Install dependencies + run: yarn workspaces focus e2etestapp + - uses: actions/download-artifact@v7 + with: + name: e2e-android-apks-${{ matrix.coverage && 'coverage' || 'plain' }} + path: examples/E2ETestApp/android/app/build/outputs/apk + + # --- Emulator: same setup as the native Android SDK's e2e jobs (AOSP image, no Play services) --- + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Resolve mock server revision + id: mock-server + run: echo "sha=$(git ls-remote https://github.com/GetStream/stream-chat-test-mock-server "refs/heads/$MOCK_SERVER_BRANCH" | cut -f1)" >> "$GITHUB_OUTPUT" + - name: Cache mock server gems + uses: actions/cache@v5 + with: + path: ${{ env.MOCK_SERVER_BUNDLE_PATH }} + key: ${{ runner.os }}-mock-server-gems-${{ steps.mock-server.outputs.sha }} + restore-keys: | + ${{ runner.os }}-mock-server-gems- + + - name: Launch Allure TestOps + if: env.ALLURE_TOKEN != '' + working-directory: examples/SampleApp + run: bundle exec fastlane allure_launch cron:true + continue-on-error: true + - name: Run e2e tests (Android emulator) + id: tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + profile: pixel + emulator-options: ${{ env.ANDROID_EMULATOR_OPTIONS }} + disable-animations: true + working-directory: examples/SampleApp + script: bundle exec fastlane test_e2e platform:android device:att skip_build:true mock_server_branch:"$MOCK_SERVER_BRANCH" coverage:${{ matrix.coverage == true }} batch:${{ matrix.batch }} batch_count:${{ matrix.batch_count }} + + # Also when tests failed: those keep their coverage from the previous map. Not when they never + # ran: a batch without its failed-tests file keeps the whole previous map. + - name: List the failed tests + id: failed-tests + if: ${{ !cancelled() && matrix.coverage && (steps.tests.outcome == 'success' || steps.tests.outcome == 'failure') }} + working-directory: examples/SampleApp + run: bundle exec fastlane e2e_failed_tests batch:${{ matrix.batch }} + # A tarball, as upload-artifact rejects some characters a test name (the folder name) may hold. + - name: Pack the coverage + if: ${{ !cancelled() && steps.failed-tests.outcome == 'success' }} + working-directory: examples/E2ETestApp + run: tar -czf e2e-coverage-batch-${{ matrix.batch }}.tgz e2e-coverage + - uses: actions/upload-artifact@v6 + if: ${{ !cancelled() && steps.failed-tests.outcome == 'success' }} + with: + name: e2e-coverage-batch-${{ matrix.batch }} + path: examples/E2ETestApp/e2e-coverage-batch-${{ matrix.batch }}.tgz + retention-days: 3 + + - name: Upload Allure results + if: env.LAUNCH_ID != '' && (success() || failure()) + working-directory: examples/SampleApp + run: bundle exec fastlane allure_upload launch_id:"$LAUNCH_ID" + - name: Allure TestOps Launch Removal + if: env.LAUNCH_ID != '' && cancelled() + working-directory: examples/SampleApp + run: bundle exec fastlane allure_launch_removal launch_id:"$LAUNCH_ID" + # The emulator has vanished mid-run without any output; this shows whether the kernel killed it. + - name: Diagnose emulator + if: failure() + run: | + sudo dmesg -T | grep -i -E 'out of memory|oom-kill|killed process' || echo "No OOM kills in dmesg" + free -m + df -h + ls -la /tmp/android-runner 2>/dev/null || true + - uses: actions/upload-artifact@v6 + if: failure() + with: + name: e2e-nightly-android-${{ matrix.api-level }}-batch-${{ matrix.batch }} + path: | + examples/E2ETestApp/stream-chat-test-mock-server/logs + examples/E2ETestApp/artifacts + /tmp/android-runner + + coverage_map: + name: Build e2e coverage map + needs: android + # Failed tests keep their previous coverage; only a batch that never finished blocks the map. + if: ${{ !cancelled() && (github.event_name == 'schedule' || inputs.android) }} + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/ruby-cache + - uses: actions/download-artifact@v7 + with: + pattern: e2e-coverage-batch-* + path: examples/E2ETestApp/batches + merge-multiple: true + - name: Merge the batches + working-directory: examples/E2ETestApp + run: | + shopt -s nullglob + for batch in batches/*.tgz; do tar -xzf "$batch"; done + - name: Build the coverage map + working-directory: examples/SampleApp + run: bundle exec fastlane build_e2e_coverage_map batch_count:4 + - uses: actions/upload-artifact@v6 + with: + name: e2e-coverage-map + path: examples/E2ETestApp/e2e_coverage_map.json + retention-days: 14 + + slack: + name: Slack Report + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [ios, android-build, android, coverage_map] + # always(): a job that hit its timeout is `cancelled`, which failure() alone doesn't see. + if: always() && github.event_name == 'schedule' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) + steps: + - uses: 8398a7/action-slack@v3 + with: + status: cancelled + text: 'You shall not pass!' + job_name: '${{ github.workflow }}: ${{ github.job }}' + fields: repo,commit,author,workflow + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_NIGHTLY_CHECKS }} diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index e71792358f..4a7af02577 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -22,6 +22,8 @@ permissions: contents: read pull-requests: read checks: read + # Downloads the nightly e2e coverage map (impact analysis). + actions: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -30,6 +32,7 @@ concurrency: env: MOCK_SERVER_BRANCH: ${{ inputs.mock_server_branch || 'main' }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }} GITHUB_PR_NUM: ${{ github.event.pull_request.number }} GITHUB_EVENT_ACTION: ${{ github.event.action }} GITHUB_EVENT_BEFORE: ${{ github.event.before }} @@ -51,13 +54,22 @@ jobs: timeout-minutes: 10 outputs: required: ${{ steps.check.outputs.required }} + e2e_batches: ${{ steps.check.outputs.e2e_batches }} steps: - uses: actions/checkout@v6 + with: + # The pull request's merge commit and its base, which the impact analysis diffs. + fetch-depth: 2 - uses: ./.github/actions/ruby-cache - name: Check if e2e tests are required id: check working-directory: examples/SampleApp run: bundle exec fastlane e2e_check_required + - uses: actions/upload-artifact@v6 + if: steps.check.outputs.required == 'true' + with: + name: e2e-selection + path: examples/E2ETestApp/e2e_selection.json ios: needs: check @@ -94,14 +106,12 @@ jobs: nohup xcrun simctl boot "$udid" > /dev/null 2>&1 & - uses: ./.github/actions/ruby-cache - # --- JS dependencies --- - name: Restore node_modules id: node-modules uses: actions/cache/restore@v5 with: path: ${{ env.NODE_MODULES_PATHS }} key: ${{ runner.os }}-e2e-node-modules-${{ hashFiles('yarn.lock') }} - # Only the e2e app's dependency tree, not every example app. - name: Install dependencies run: yarn workspaces focus e2etestapp - name: Save node_modules @@ -152,7 +162,6 @@ jobs: path: ${{ env.APP_PATH }} key: ${{ steps.app.outputs.cache-primary-key }} - # --- Test tooling --- - name: Install applesimutils # Homebrew refuses formulae from non-official taps until they are trusted. run: brew tap wix/brew && brew trust --formula wix/brew/applesimutils && brew install wix/brew/applesimutils @@ -190,9 +199,29 @@ jobs: # `-b` boots it if the background boot never started; it errors (405) when the device is # already booted, and then the plain call returns at once. run: xcrun simctl bootstatus "$DETOX_IOS_UDID" -b || xcrun simctl bootstatus "$DETOX_IOS_UDID" + - name: Launch Allure TestOps + if: env.ALLURE_TOKEN != '' + working-directory: examples/SampleApp + run: bundle exec fastlane allure_launch + continue-on-error: true + env: + # Names the launch after the pull request and tags it with its labels. + GITHUB_EVENT: ${{ toJson(github.event) }} + - uses: actions/download-artifact@v7 + with: + name: e2e-selection + path: examples/E2ETestApp - name: Run e2e tests working-directory: examples/SampleApp - run: bundle exec fastlane test_e2e platform:ios skip_build:true mock_server_branch:"$MOCK_SERVER_BRANCH" + run: bundle exec fastlane test_e2e platform:ios skip_build:true mock_server_branch:"$MOCK_SERVER_BRANCH" selection:examples/E2ETestApp/e2e_selection.json + - name: Upload Allure results + if: env.LAUNCH_ID != '' && (success() || failure()) + working-directory: examples/SampleApp + run: bundle exec fastlane allure_upload launch_id:"$LAUNCH_ID" + - name: Allure TestOps Launch Removal + if: env.LAUNCH_ID != '' && cancelled() + working-directory: examples/SampleApp + run: bundle exec fastlane allure_launch_removal launch_id:"$LAUNCH_ID" - uses: actions/upload-artifact@v6 if: failure() with: @@ -201,20 +230,16 @@ jobs: examples/E2ETestApp/stream-chat-test-mock-server/logs examples/E2ETestApp/artifacts - android: + android-build: + name: Build Android needs: check if: needs.check.outputs.required == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android) runs-on: ubuntu-latest timeout-minutes: 60 env: - # Same as the native Android SDK e2e jobs, which run stably on these runners - ANDROID_API_LEVEL: "36" - ANDROID_EMULATOR_OPTIONS: -no-snapshot-save -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect -camera-back none -camera-front none - DETOX_ADB_NAME: "emulator-5554" APK_PATHS: | examples/E2ETestApp/android/app/build/outputs/apk/release/app-release.apk examples/E2ETestApp/android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk - MOCK_SERVER_BUNDLE_PATH: ${{ github.workspace }}/../mock-server-gems # The emulator is x86_64: skip compiling native code for the other ABIs. ORG_GRADLE_PROJECT_reactNativeArchitectures: x86_64 steps: @@ -235,14 +260,12 @@ jobs: cache-read-only: false - uses: ./.github/actions/ruby-cache - # --- JS dependencies --- - name: Restore node_modules id: node-modules uses: actions/cache/restore@v5 with: path: ${{ env.NODE_MODULES_PATHS }} key: ${{ runner.os }}-e2e-node-modules-${{ hashFiles('yarn.lock') }} - # Only the e2e app's dependency tree, not every example app. - name: Install dependencies run: yarn workspaces focus e2etestapp - name: Save node_modules @@ -259,31 +282,95 @@ jobs: with: path: ${{ env.APK_PATHS }} key: e2e-android-apk-${{ hashFiles('yarn.lock', 'package/package.json', 'package/src/**', 'package/shared-native/**', 'package/native-package/package.json', 'package/native-package/src/**', 'package/native-package/android/**', '!package/native-package/android/build/**', 'examples/E2ETestApp/*.js', '!examples/E2ETestApp/.detoxrc.js', 'examples/E2ETestApp/*.json', 'examples/E2ETestApp/*.tsx', 'examples/E2ETestApp/src/**', 'examples/E2ETestApp/android/**', '!examples/E2ETestApp/android/**/build/**', '!examples/E2ETestApp/android/.gradle/**') }} - # Built before the emulator boots so the emulator doesn't idle through the Gradle build. - name: Build app if: steps.apk.outputs.cache-hit != 'true' working-directory: examples/E2ETestApp - run: yarn e2e:build -c android.att.release + # Retried once: a cold build downloads every Gradle plugin, and the repositories flake. + run: yarn e2e:build -c android.att.release || { sleep 60; yarn e2e:build -c android.att.release; } - name: Save APKs if: steps.apk.outputs.cache-hit != 'true' uses: actions/cache/save@v5 with: path: ${{ env.APK_PATHS }} key: ${{ steps.apk.outputs.cache-primary-key }} - # Frees the daemons' memory for the emulator, which otherwise can get killed mid-run. - - name: Stop Gradle daemons - if: steps.apk.outputs.cache-hit != 'true' - working-directory: examples/E2ETestApp/android - run: ./gradlew --stop + - uses: actions/upload-artifact@v6 + with: + name: e2e-android-apks + path: ${{ env.APK_PATHS }} + retention-days: 1 + + android-allure-launch: + name: Launch Allure TestOps (Android) + needs: android-build + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + launch_id: ${{ steps.launch.outputs.launch_id }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + - uses: ./.github/actions/ruby-cache + - name: Launch Allure TestOps + id: launch + if: env.ALLURE_TOKEN != '' + working-directory: examples/SampleApp + run: | + bundle exec fastlane allure_launch + echo "launch_id=$(grep '^LAUNCH_ID=' "$GITHUB_ENV" | tail -1 | cut -d= -f2)" >> "$GITHUB_OUTPUT" + continue-on-error: true + env: + # Names the launch after the pull request and tags it with its labels. + GITHUB_EVENT: ${{ toJson(github.event) }} - # --- Emulator: same setup as the native Android SDK's e2e jobs (AOSP image, no Play services) --- + android: + name: Test Android + needs: [check, android-build, android-allure-launch] + if: ${{ !cancelled() && needs.android-build.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + matrix: + batch: ${{ fromJson(needs.check.outputs.e2e_batches) }} + fail-fast: false + env: + # Same as the native Android SDK e2e jobs + ANDROID_API_LEVEL: "36" + # swangle_indirect, not swiftshader_indirect: with the latter the emulator exits silently mid-run + # (RN-513, also outside GiphyTests). + ANDROID_EMULATOR_OPTIONS: -no-snapshot-save -no-window -no-audio -no-boot-anim -gpu swangle_indirect -camera-back none -camera-front none + DETOX_ADB_NAME: "emulator-5554" + MOCK_SERVER_BUNDLE_PATH: ${{ github.workspace }}/../mock-server-gems + LAUNCH_ID: ${{ needs.android-allure-launch.outputs.launch_id }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24.x + cache: "yarn" + cache-dependency-path: "yarn.lock" + - uses: actions/setup-java@v5 + with: + distribution: "zulu" + java-version: "21" + - uses: ./.github/actions/ruby-cache + - name: Restore node_modules + uses: actions/cache/restore@v5 + with: + path: ${{ env.NODE_MODULES_PATHS }} + key: ${{ runner.os }}-e2e-node-modules-${{ hashFiles('yarn.lock') }} + - name: Install dependencies + run: yarn workspaces focus e2etestapp + - uses: actions/download-artifact@v7 + with: + name: e2e-android-apks + path: examples/E2ETestApp/android/app/build/outputs/apk - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - # --- Test tooling --- - name: Resolve mock server revision id: mock-server run: echo "sha=$(git ls-remote https://github.com/GetStream/stream-chat-test-mock-server "refs/heads/$MOCK_SERVER_BRANCH" | cut -f1)" >> "$GITHUB_OUTPUT" @@ -295,6 +382,10 @@ jobs: restore-keys: | ${{ runner.os }}-mock-server-gems- + - uses: actions/download-artifact@v7 + with: + name: e2e-selection + path: examples/E2ETestApp - name: Run e2e tests (Android emulator) uses: reactivecircus/android-emulator-runner@v2 with: @@ -304,7 +395,18 @@ jobs: emulator-options: ${{ env.ANDROID_EMULATOR_OPTIONS }} disable-animations: true working-directory: examples/SampleApp - script: bundle exec fastlane test_e2e platform:android device:att skip_build:true mock_server_branch:"$MOCK_SERVER_BRANCH" + # Logcat is written on the runner, so it survives the emulator dying mid-run. + script: | + nohup "$ANDROID_HOME/platform-tools/adb" -s emulator-5554 logcat -b all -v threadtime > "$RUNNER_TEMP/logcat.txt" 2>&1 < /dev/null & + bundle exec fastlane test_e2e platform:android device:att skip_build:true mock_server_branch:"$MOCK_SERVER_BRANCH" selection:examples/E2ETestApp/e2e_selection.json batch:${{ matrix.batch }} batch_count:${{ strategy.job-total }} + - name: Upload Allure results + if: env.LAUNCH_ID != '' && (success() || failure()) + working-directory: examples/SampleApp + run: bundle exec fastlane allure_upload launch_id:"$LAUNCH_ID" + - name: Allure TestOps Launch Removal + if: env.LAUNCH_ID != '' && cancelled() + working-directory: examples/SampleApp + run: bundle exec fastlane allure_launch_removal launch_id:"$LAUNCH_ID" # The emulator has vanished mid-run without any output; this shows whether the kernel killed it. - name: Diagnose emulator if: failure() @@ -316,11 +418,12 @@ jobs: - uses: actions/upload-artifact@v6 if: failure() with: - name: e2e-android-logs + name: e2e-android-logs-${{ matrix.batch }} path: | examples/E2ETestApp/stream-chat-test-mock-server/logs examples/E2ETestApp/artifacts /tmp/android-runner + ${{ runner.temp }}/logcat.txt result: name: Test E2E Result @@ -328,6 +431,8 @@ jobs: if: always() needs: - check + - android-build + - android-allure-launch - android steps: - name: Check E2E results diff --git a/.github/workflows/sample-distribution.yml b/.github/workflows/sample-distribution.yml index a3ad523287..ce7f788c0d 100644 --- a/.github/workflows/sample-distribution.yml +++ b/.github/workflows/sample-distribution.yml @@ -77,6 +77,9 @@ jobs: build_and_deploy_android_firebase: name: Build SampleApp Android and Deploy-${{ github.ref_name == 'develop' || github.ref_name == vars.RC_BRANCH }} runs-on: public + env: + LANG: C.UTF-8 + LC_ALL: C.UTF-8 strategy: matrix: node-version: [24.x] diff --git a/.gitignore b/.gitignore index badc31e28f..547fbb2d5a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,11 +14,17 @@ node_modules NEXT_RELEASE_CHANGELOG.md artifacts +allure-results +examples/E2ETestApp/allurectl e2e/.env *.log .DS_STORE *.db .idea +# Eclipse / Buildship metadata (VS Code Java, Android Studio imports) +.settings/ +.project +.classpath # Testing examples/SampleApp/fastlane/fastlane.log diff --git a/.prettierignore b/.prettierignore index 25acaf5f04..796a04e4c9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -27,6 +27,11 @@ yarn.lock # Detox e2e: mock server checkout cloned by `fastlane start_mock_server`, test artifacts examples/E2ETestApp/stream-chat-test-mock-server/ examples/E2ETestApp/artifacts/ +examples/E2ETestApp/allure-results/ +examples/E2ETestApp/e2e-coverage/ +examples/E2ETestApp/e2e_coverage_map.json +examples/E2ETestApp/e2e_selection.json +examples/E2ETestApp/e2e_batch_selection.json # Generated SDK assets package/src/components/docs/ diff --git a/bin/install_ios_runtime.sh b/bin/install_ios_runtime.sh new file mode 100755 index 0000000000..e3701d3cef --- /dev/null +++ b/bin/install_ios_runtime.sh @@ -0,0 +1,57 @@ +#!/bin/bash -e + +log() { echo "๐Ÿ‘‰ ${1}" >&2; } +die() { log "${1}"; exit 1; } +[ $# -eq 1 ] || die "usage: $0 path/to/runtime.dmg" + +dmg=$1 +mountpoint=$(mktemp -d) +staging=$(mktemp -d) + +cleanup() { + if [ -d "$staging" ]; then + set +e + log "Removing $staging..." + rm -r "$staging" || true + log "Unmounting $mountpoint..." + hdiutil detach "$mountpoint" >&2 || true + fi + + if [ -d "$mountpoint" ]; then + log "Removing $mountpoint..." + rmdir "$mountpoint" || true + fi +} +trap cleanup EXIT + +log "Mounting $dmg on $mountpoint..." +hdiutil attach "$dmg" -mountpoint "$mountpoint" >&2 + +if ! ls "$mountpoint"/*.pkg >/dev/null 2>&1; then + log "Detected a modern volume runtime; installing with simctl..." + xcrun simctl runtime add "$1" + exit 0 +fi + +log "Detected packaged runtime." + +bundle=$(echo "$mountpoint"/*.pkg) +basename=$(basename "$bundle") +sdkname=${basename%.*} +log "Found package $bundle (sdk $sdkname)." + +log "Expanding package $bundle to $staging/expanded..." +pkgutil --expand "$bundle" "$staging/expanded" + +dest=/Library/Developer/CoreSimulator/Profiles/Runtimes/$sdkname.simruntime +log "Rewriting package install location to $dest..." +sed -I '' "s| void }) => ( ); -const ChannelScreen = ({ channel, onBack }: { channel: ChannelType; onBack: () => void }) => { - const { top } = useSafeAreaInsets(); - +/** Pops the screen on the Android back button, like a navigation stack. */ +const useBackHandler = (onBack: (() => void) | undefined) => { useEffect(() => { + if (!onBack) { + return; + } const subscription = BackHandler.addEventListener('hardwareBackPress', () => { onBack(); return true; }); return () => subscription.remove(); }, [onBack]); +}; + +const ScreenHeader = ({ onBack, title }: { onBack: () => void; title: string }) => { + const { top } = useSafeAreaInsets(); + return ( + + + โ€น Back + + + {title} + + + ); +}; + +const ChannelScreen = ({ + channel, + covered, + onBack, + onThreadSelect, +}: { + channel: ChannelType; + /** A thread is stacked over the channel: it stays mounted but is hidden, like a native stack. */ + covered: boolean; + onBack: () => void; + onThreadSelect: (thread: ThreadType, targetedMessageId?: string) => void; +}) => { + useBackHandler(covered ? undefined : onBack); + // "View" on a thread reply sent to the channel, and a quote of one, open the thread at that reply. + const onAlsoSentToChannelHeaderPress = useCallback( + ({ targetedMessageId, thread }: AlsoSentToChannelHeaderPressPayload) => { + if (thread) { + onThreadSelect(thread, targetedMessageId); + } + }, + [onThreadSelect], + ); + + // RN-506: a message sent from a jumped-to page sometimes leaves the list there. The paginator state + // at the own send's echo ends up in the logcat of a failing test. + useEffect(() => { + const { unsubscribe } = channel.on('message.new', (event) => { + if (event.user?.id !== channel.getClient().userID) { + return; + } + const paginator = channel.messagePaginator; + const items = paginator.items ?? []; + console.info( + '[RN-506] own message.new', + JSON.stringify({ + first: items[0]?.text, + hasMoreHead: paginator.hasMoreHead, + hasMoreTail: paginator.hasMoreTail, + isActiveIntervalAtHead: paginator.isActiveIntervalAtHead, + items: items.length, + last: items[items.length - 1]?.text, + }), + ); + }); + return unsubscribe; + }, [channel]); + + // The SDK leaves hard delete out of the menu on purpose (RN-504); apps add it themselves, as here. + const messageActions = useCallback( + (params: MessageActionsParams) => { + const actions = defaultMessageActions(params); + if (!params.isMyMessage || !params.ownCapabilities.deleteOwnMessage) { + return actions; + } + const hardDelete = () => + Alert.alert('Hard delete message', 'The message will be removed for everyone.', [ + { style: 'cancel', text: 'Cancel' }, + { + onPress: () => + channel.messageOperations + .delete({ localMessage: params.message, options: { hard: true } }) + .catch((error: unknown) => console.warn('Hard delete failed:', error)), + style: 'destructive', + text: 'Delete', + }, + ]); + return [ + ...actions, + { + action: () => { + params.dismissOverlay(); + hardDelete(); + }, + actionType: 'hardDeleteMessage', + title: 'Hard Delete Message', + type: 'destructive' as const, + }, + ]; + }, + [channel], + ); // Laid out like the SampleApp's channel screen: the header lives inside , which sizes // itself to the screen and handles the bottom inset and keyboard for the composer. + return ( + + + + onThreadSelect(selected)} /> + + + + ); +}; + +const ThreadScreen = ({ + onBack, + targetedMessageId, + thread, +}: { + onBack: () => void; + targetedMessageId?: string; + thread: ThreadType; +}) => { + useBackHandler(onBack); + + // Before the list picks its initial anchor (a passive effect), like the SampleApp's thread screen. + useLayoutEffect(() => { + if (targetedMessageId) { + thread.messagePaginator + .jumpToMessage(targetedMessageId, { + focusReason: 'jump-to-message', + focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION, + }) + .catch((error: unknown) => console.warn('Jump to reply failed:', error)); + } + }, [thread, targetedMessageId]); + return ( - - - - โ€น Back - - - {channel.id} - - + + - + + + ); +}; + +const ChannelDetailsScreen = ({ + channel, + onBack, +}: { + channel: ChannelType; + onBack: () => void; +}) => { + useBackHandler(onBack); + return ( + + + + ); }; +type Tab = 'channels' | 'threads'; + +const TabBar = ({ onSelect, tab }: { onSelect: (tab: Tab) => void; tab: Tab }) => ( + + {(['channels', 'threads'] as const).map((item) => ( + onSelect(item)} + style={styles.tab} + testID={`tab-${item}`} + > + + {item === 'channels' ? 'Channels' : 'Threads'} + + + ))} + +); + const ChatScreens = ({ client }: { client: StreamChat }) => { const [channel, setChannel] = useState(); + const [thread, setThread] = useState<{ targetedMessageId?: string; thread: ThreadType }>(); + const openThread = useCallback( + (nextThread: ThreadType, targetedMessageId?: string) => + setThread({ targetedMessageId, thread: nextThread }), + [], + ); // The app owns the channel list's paginator, for as long as the connected client lives. const channelListPaginator = useMemo( () => @@ -102,15 +297,71 @@ const ChatScreens = ({ client }: { client: StreamChat }) => { }; }, [channelListPaginator, client]); + const [detailsChannel, setDetailsChannel] = useState(); + const [tab, setTab] = useState('channels'); const closeChannel = useCallback(() => setChannel(undefined), []); + const closeDetails = useCallback(() => setDetailsChannel(undefined), []); + const getChannelActionItems = useCallback( + ({ context: { channel: actionChannel }, defaultItems }) => { + const viewInfo: ChannelActionItem = { + action: () => setDetailsChannel(actionChannel), + Icon: () => null, + id: 'info', + label: 'View Info', + placement: 'sheet', + type: 'standard', + }; + return [viewInfo, ...defaultItems]; + }, + [], + ); + const onThreadListSelect = useCallback( + (selected: ThreadType) => { + setChannel(selected.channel); + openThread(selected); + }, + [openThread], + ); + const closeThread = useCallback(() => setThread(undefined), []); return ( - + // Offline support is on, as in the native test apps: sends and reactions made offline are + // queued and synced on reconnect. + {/* The list stays mounted under the channel, as in a navigation stack. */} - + + + + {tab === 'threads' ? ( + + + + ) : null} + - {channel ? : null} + {detailsChannel ? ( + + ) : null} + {channel ? ( + + ) : null} + {channel && thread ? ( + + ) : null} ); }; @@ -122,6 +373,10 @@ const App = () => { const chatClient = StreamChat.getInstance(apiKey, { client: { axiosRequestConfig: { timeout: 6000 }, baseURL: mockServerUrl }, }); + // Every test launches the app anew but keeps its data: start from an empty offline database so + // nothing a previous test stored is hydrated into this one. + await SqliteClient.initializeDatabase(); + await SqliteClient.resetDB(); await chatClient.connectUser(appUser, appUserToken); setClient(chatClient); }, []); @@ -130,7 +385,13 @@ const App = () => { - {client ? : } + {isJwtTest ? ( + + ) : client ? ( + + ) : ( + + )} @@ -148,6 +409,11 @@ const styles = StyleSheet.create({ paddingVertical: 12, }, headerTitle: { flex: 1, fontSize: 17, fontWeight: '600' }, + hidden: { display: 'none' }, + tab: { alignItems: 'center', flex: 1, paddingVertical: 12 }, + tabBar: { borderTopColor: '#E5E5E5', borderTopWidth: 1, flexDirection: 'row' }, + tabTitle: { color: '#7A7A7A', fontSize: 15 }, + tabTitleSelected: { color: '#005FFF', fontWeight: '600' }, hint: { padding: 16 }, title: { fontSize: 22, fontWeight: '700', padding: 16 }, userName: { fontSize: 17 }, diff --git a/examples/E2ETestApp/android/app/build.gradle b/examples/E2ETestApp/android/app/build.gradle index 879f64489c..9977792823 100644 --- a/examples/E2ETestApp/android/app/build.gradle +++ b/examples/E2ETestApp/android/app/build.gradle @@ -50,6 +50,12 @@ react { // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" // hermesFlags = ["-O", "-output-source-map"] + // E2E_COVERAGE=1 instruments the JS (babel.config.js). Passing it as a bundle argument keeps + // Gradle from reusing an uninstrumented bundle from its build cache, and the other way round. + if (System.getenv("E2E_COVERAGE") == "1") { + extraPackagerArgs = ["--reset-cache"] + } + /* Autolinking */ autolinkLibrariesWithApp() } diff --git a/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml b/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml index d5ac9c6e7c..dcd82215a6 100644 --- a/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml +++ b/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + + userRobot.assertMessage('Hi').assertMe ## The app -`examples/E2ETestApp` exists only for this suite, like the native SDKs' `StreamChatUITestsApp`. It has the SDK and its -required peer dependencies, and nothing else: no Firebase, maps, video or navigation library. That keeps native builds -short (a clean Release build is ~2 min on an M-series Mac, versus ~15 min for the SampleApp on Android). It has three -screens: a user selector, the channel list, and a channel stacked on top of the list (`App.tsx`), laid out like the -SampleApp's, so the same SDK paths run. - -`setUpStreamTestCase()` starts a mock server per test and relaunches the app with the launch argument +`examples/E2ETestApp` exists only for this suite, like the native SDKs' `StreamChatUITestsApp`. It has the SDK, its +required peer dependencies, and the optional peers the tested features need (the pickers that show the composer's attach +button, clipboard, video), and nothing else: no Firebase, maps or navigation library. That keeps native builds short (a +clean Release build is ~2 min on an M-series Mac, versus ~15 min for the SampleApp on Android). Its screens +(`App.tsx`), laid out like the SampleApp's so the same SDK paths run: a user selector; the root screen with a Channels / +Threads tab bar (`ChannelList`, `ThreadList`); a channel stacked over it; a thread stacked over the channel (the covered +channel stays mounted but hidden, like a native stack); and the SDK's channel details, opened from "View Info" in the +channel actions sheet. The `JWT_TEST` launch argument opens a JWT test screen instead (`src/JwtScreen.tsx`, used by +`AuthTests`). + +`setUpStreamTestCase()` starts a mock server per test (`/start/?api_version=v2×tamps=ns`: stream-chat-js v10 +calls the v2 API and reads every timestamp as unix nanoseconds, so the mock must send them that way) and relaunches the +app with the launch argument `MOCK_SERVER_URL` (`localhost` on iOS, `10.0.2.2` on the Android emulator), read in `src/config.ts` via -`react-native-launch-arguments`. The app only lists the mock user (`luke_skywalker`), never persists a login and runs -without offline storage, so every test starts clean. +`react-native-launch-arguments`. The app only lists the mock user (`luke_skywalker`) and never persists a login. Offline +support is on (`@op-engineering/op-sqlite`, ``), as in the native test apps, so offline sends +and reactions are queued; the login empties the database first, so every test starts clean. ## Running @@ -46,7 +53,8 @@ bundle exec fastlane e2e_pod_install # once, and after nativ bundle exec fastlane test_e2e platform:ios bundle exec fastlane test_e2e platform:android # AVD from DETOX_AVD_NAME (default Pixel_API_37) # Options: configuration:debug, skip_build:true, local_server:, -# mock_server_branch:, test:e2e/tests/MessageListTests.e2e.ts +# mock_server_branch:, test:e2e/tests/MessageListTests.e2e.ts, +# selection:, coverage:true (see Impact analysis) # Or by hand (yarn commands in examples/E2ETestApp) bundle exec fastlane start_mock_server # driver on MOCK_DRIVER_PORT (default 4569) @@ -61,13 +69,85 @@ narrow it further. Release configurations bundle the JS, so no Metro is needed. Debug configurations need `yarn start`. Android builds need JDK 17 or 21 (`JAVA_HOME`). +## Allure TestOps + +Every test links to a case in Allure TestOps project 3 (`stream-chat-react-native`, https://streamio.testops.cloud) with +`allureId()` from `support/step.ts`; `step()` reports each GIVEN / WHEN / THEN as an Allure step. +`jest-allure2-reporter` writes `allure-results/`, and the lanes in `examples/SampleApp/fastlane/Allurefile` (imported by +the Fastfile, as on iOS) talk to TestOps: `allure_launch` / `allure_upload` / `allure_launch_removal` create the launch +and upload the results on CI, and `allure_testcase` creates a case and prints its id. They need `ALLURE_TOKEN` (a +repository secret on CI). A new test gets a new case: create it in project 3 (`allure_testcase`, or +`POST /api/rs/testcase` with `name`, `fullName: "."`, `automated: true`) rather than reusing a native SDK's +ID; the native projects are separate. + +## Ported tests + +The suites port the Android compose and iOS UIKit suites (the union of both, one test per scenario), keeping the native +test names and expectations. Where the SDK disagrees with the native expectation, the test is skipped with +`it.skip` and a comment linking the Linear ticket. Not ported, because the RN SDK has no counterpart: the Android-only +grouped channel lists, channel/message/member search, reminders, push notifications (app-level in RN), Android's +`BackendTests` (real backend), hard delete from the message menu, iOS read counts and the iOS scroll performance tests. +Tests that cut the network run only on Android (`itOnAndroid`): the iOS simulator shares the Mac's connection. +Tests that take the Android CI emulator down run only on iOS (`itOnIOS`), with a comment linking the ticket. + +## Impact analysis + +Like the native SDKs, a pull request runs only the tests its diff can reach, read from a per-test coverage map that a +nightly run rebuilds. Other events (dispatch, the nightly) run everything. + +- **Coverage.** `E2E_COVERAGE=1` at bundle time (`babel.config.js`; the Android build passes `--reset-cache` so Gradle + never reuses the other kind of bundle) adds `e2e/coverage/babelPlugin.js`. It marks a file in + `globalThis.__e2eCoverage` when any of its functions runs. Metro loads nearly every module at startup, so a module + body counts only when it has no functions or does something at the top level (a call, an `if`); `X.displayName = โ€ฆ` + doesn't count. It is not istanbul: react-native-worklets would capture istanbul's module-scoped counters into every + worklet, which then throws on the UI runtime; `globalThis` is a worklet global. The instrumented code + (`e2e/coverage/sources.js`) is `package/src`, `package/native-package/src` and the app, without tests and mock + builders. +- **Collection.** With `E2E_COVERAGE_DIR` set (`test_e2e coverage:true`), `StreamTestCase` starts a collector on + `E2E_COVERAGE_PORT` (4570) and passes the app `E2E_COVERAGE_URL`. After each test it sends the app home, the app posts + its marks on going to the background (`src/e2eCoverage.ts`), and `e2e/support/Coverage.ts` writes + `e2e-coverage///lcov.info`. Every tracked source file is listed, also those never loaded: type-only + modules leave the bundle, so a change to one selects no tests instead of all of them. +- **Map.** `fastlane build_e2e_coverage_map` turns those into `e2e_coverage_map.json` (`build_test_coverage_map` from + `fastlane-plugin-stream_actions`). Test ids are `/`, e.g. `MessageListTests.e2e/test_x`. A test + whose last Allure result failed keeps its coverage from the previous map, or leaves the map until it passes. +- **Selection.** `fastlane select_e2e_tests` (`select_impacted_tests`) writes `e2e_selection.json`. A changed source + selects the tests that ran it; one that โ‰ฅ90% of the tests run selects one test per file. A changed test file selects + its own tests. The harness (`e2e/` outside `tests/`), the app's native and build setup, the SDK's native code, + dependencies, the lanes and the e2e workflows run everything, as does a missing or week-old map. Tests that aren't in + the map yet always run. `E2E_SELECTION=` makes `e2e/jest.config.js` run only the selected tests. + +Locally (the map comes from a coverage run, or from the `e2e-coverage-map` artifact of the nightly): + +```bash +bundle exec fastlane test_e2e platform:android coverage:true # builds instrumented, collects per test +bundle exec fastlane build_e2e_coverage_map +bundle exec fastlane select_e2e_tests coverage_map:examples/E2ETestApp/e2e_coverage_map.json changed_files:, +bundle exec fastlane test_e2e platform:android skip_build:true selection:examples/E2ETestApp/e2e_selection.json +``` + +For a debug build, start Metro with `E2E_COVERAGE=1 yarn start --reset-cache`; switching back needs `--reset-cache` +too. A coverage build left in `android/app/build` or `ios/build` stays instrumented until rebuilt. + ## CI `.github/workflows/e2e-test.yml` runs on pull requests and on demand (`workflow_dispatch`, with iOS/Android toggles and a mock server branch). Like the iOS SDK, a cheap `Check E2E Required` job first runs `fastlane e2e_check_required` -(`is_check_required` over the `e2e` sources in the Fastfile's `impact_analysis`). The simulator/emulator jobs run -only when the PR touches those sources, or when the last change to them hasn't passed yet. `Test E2E Result` is the -job to require in branch protection: it passes when the e2e jobs pass or are skipped as not required. +(`is_check_required` over the `e2e` sources in the Fastfile's `impact_analysis`, then `select_e2e_tests`; it checks +out 2 commits to diff the merge commit against its base). The simulator/emulator jobs run only when the PR touches +those sources and the selection isn't empty, or when the last change to them hasn't passed yet; they run the +`e2e-selection` artifact's tests. `Test E2E Result` is the job to require in branch protection: it passes when the e2e +jobs pass or are skipped as not required. + +Android splits the tests like the native Android SDK: `Build Android` builds (or restores) the APKs once and uploads +them, `Launch Allure TestOps (Android)` creates one launch for all batches, and `Test Android` runs a matrix of up to 8 +batches (`e2e_batches` from `e2e_check_required`: one per selected test when fewer than 8 are selected). Each batch +runs `test_e2e batch: batch_count:`, every `total`-th test of the selection, round-robin. + +`.github/workflows/e2e-test-cron.yml` runs nightly on `develop` (and on demand, with iOS/Android toggles), reporting to +Allure as cron launches. iOS runs every test on the PR job's simulator setup, the only regular iOS run, since pull +requests run iOS on dispatch only. Android runs every test with an instrumented build, then `build_e2e_coverage_map`, +and uploads the `e2e-coverage-map` artifact (14 days) that pull requests download. Until its first run on `develop`, every pull request runs everything ("no coverage map"). iOS runs on `macos-15` (Xcode 26.2, iPhone 17 Pro, `ios.sim.release`). Android runs on `ubuntu-latest` with KVM and an API 36 x86_64 emulator (`android.att.release`). Both call `fastlane test_e2e skip_build:true`, which on CI adds @@ -117,3 +197,13 @@ the base branch, so each PR fills its own: its first run builds cold, and later - Detox iOS never reports avatars as visible (their remote image renders transparent until loaded), so avatar asserts check existence. - A ported test keeps the native expectation. If the SDK disagrees, fix the SDK or skip the test against a ticket. +- Debug builds (Metro) are the fast local loop: the SDK is served from `package/src`, so testID changes need no rebuild. + `index.js` turns LogBox off, because its warning toast covers the composer. Detox re-shells the jest command, so a + `-t` filter with `|` needs inner quotes: `-t '"test_a|test_b"'`. +- The message menu renders through a portal and its items match twice: tap them with `{ index: 0 }`. +- On Android, a swipe on the inverted `message-flat-list` lands as a horizontal gesture; robots swipe + `message-flat-list-wrapper` (`MessageList.scrollTarget`). +- `replaceText` doesn't fire the composer's suggestion triggers, so a lone `/` or `@` is typed as a key press. +- Don't call a robot chain method inside `perform()`: it waits on the queue it is running in. Use the plain helpers + (`typeIntoComposer`, `editMessageNow`). +- Index 0 of a selector isn't the newest message; `indexFromBottom()` sorts matches by position. diff --git a/examples/E2ETestApp/e2e/coverage/babelPlugin.js b/examples/E2ETestApp/e2e/coverage/babelPlugin.js new file mode 100644 index 0000000000..d0ebb79cd8 --- /dev/null +++ b/examples/E2ETestApp/e2e/coverage/babelPlugin.js @@ -0,0 +1,85 @@ +/** + * Per-test coverage for the e2e impact analysis (`e2e/README.md`), at file level: which source files + * ran any of their functions while a test ran. Enabled with `E2E_COVERAGE=1` at bundle time. + * + * Not istanbul: its counters are module-scoped functions, and react-native-worklets captures them + * into every worklet's closure, which then throws on the UI runtime. These markers only touch + * `globalThis`, which worklets treat as a global, so a worklet runs them harmlessly on its own + * runtime (its hits are not counted; the JS-thread code around it is). + * + * Every instrumented module registers itself in `globalThis.__e2eCoverage` (0) when it loads, and + * every function body marks it (1). Metro loads nearly the whole SDK at startup, so the module body + * says nothing about a test; only function calls do. A module without functions, or with top-level + * statements that do something (calls, conditionals; not property assignments), counts as run once + * it loads. + */ +const { sourceId } = require('./sources'); + +module.exports = ({ types: t }) => { + // (globalThis.__e2eCoverage || (globalThis.__e2eCoverage = {})) + const coverage = () => { + const member = () => t.memberExpression(t.identifier('globalThis'), t.identifier('__e2eCoverage')); + return t.logicalExpression( + '||', + member(), + t.assignmentExpression('=', member(), t.objectExpression([])), + ); + }; + const entry = (id) => t.memberExpression(coverage(), t.stringLiteral(id), true); + // (...)[''] = 1; + const mark = (id) => + t.expressionStatement(t.assignmentExpression('=', entry(id), t.numericLiteral(1))); + // '' in (...) || ((...)[''] = 0); Keeps a mark set before the module body ran (a cycle). + const register = (id) => + t.expressionStatement( + t.logicalExpression( + '||', + t.binaryExpression('in', t.stringLiteral(id), coverage()), + t.assignmentExpression('=', entry(id), t.numericLiteral(0)), + ), + ); + + return { + name: 'e2e-coverage', + visitor: { + Program: { + // On enter, before other plugins (worklets, TypeScript, module transforms) change the file. + enter(programPath, state) { + const id = sourceId(state.filename || state.file.opts.filename); + if (!id) { + return; + } + let functions = 0; + programPath.traverse({ + Function(fnPath) { + if (!fnPath.node.body) { + return; + } + functions += 1; + fnPath.ensureBlock(); + fnPath.get('body').unshiftContainer('body', mark(id)); + }, + }); + // Top-level side effects (`registerNativeHandlers(...)`, `if (...) ...`) run in every test + // that loads the module, so changing them matters to those tests too. `X.displayName = ...` + // doesn't count. + const sideEffects = programPath + .get('body') + .some( + (statement) => + statement.isIfStatement() || + (statement.isExpressionStatement() && + !( + statement.get('expression').isAssignmentExpression() && + statement.get('expression.left').isMemberExpression() + )), + ); + programPath.unshiftContainer( + 'body', + functions > 0 && !sideEffects ? register(id) : mark(id), + ); + }, + }, + }, + }; +}; diff --git a/examples/E2ETestApp/e2e/coverage/sources.d.ts b/examples/E2ETestApp/e2e/coverage/sources.d.ts new file mode 100644 index 0000000000..96ee62c1a8 --- /dev/null +++ b/examples/E2ETestApp/e2e/coverage/sources.d.ts @@ -0,0 +1,3 @@ +export const repoRoot: string; +export const isSource: (id: string) => boolean; +export const sourceId: (filename: string | undefined) => string | undefined; diff --git a/examples/E2ETestApp/e2e/coverage/sources.js b/examples/E2ETestApp/e2e/coverage/sources.js new file mode 100644 index 0000000000..9c5d599cdb --- /dev/null +++ b/examples/E2ETestApp/e2e/coverage/sources.js @@ -0,0 +1,43 @@ +/** + * The code the e2e impact analysis maps (e2e/README.md): shared by the babel plugin that instruments + * it and the collector that lists it. Matches `source_paths` in the Fastfile's `select_e2e_tests`. + */ +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.resolve(__dirname, '../../../..'); + +const included = ['package/src/', 'package/native-package/src/', 'examples/E2ETestApp/']; +const excluded = [ + /\/node_modules\//, + /\/__tests__\//, + /^package\/src\/mock-builders\//, + /^examples\/E2ETestApp\/(e2e|android|ios|stream-chat-test-mock-server)\//, + /^examples\/E2ETestApp\/(\.detoxrc|[^/]+\.config)\.js$/, + /\.test\.(js|jsx|ts|tsx)$/, + /\.d\.ts$/, +]; + +const isSource = (id) => + /\.(js|jsx|ts|tsx)$/.test(id) && + included.some((prefix) => id.startsWith(prefix)) && + !excluded.some((re) => re.test(id)); + +const realpath = (file) => { + try { + return fs.realpathSync(file); + } catch { + return file; + } +}; + +/** The repo-relative path of a file to instrument, or undefined when it isn't mapped. */ +const sourceId = (filename) => { + if (!filename) { + return undefined; + } + const id = path.relative(repoRoot, realpath(filename)).split(path.sep).join('/'); + return isSource(id) ? id : undefined; +}; + +module.exports = { isSource, repoRoot, sourceId }; diff --git a/examples/E2ETestApp/e2e/jest.config.js b/examples/E2ETestApp/e2e/jest.config.js index 5456675188..dc3f9d8a76 100644 --- a/examples/E2ETestApp/e2e/jest.config.js +++ b/examples/E2ETestApp/e2e/jest.config.js @@ -1,13 +1,48 @@ +const { readFileSync } = require('fs'); + +/** + * E2E_SELECTION: the `select_e2e_tests` output (impact analysis, e2e/README.md), whose `tests` are + * `/` ids, e.g. `MessageListTests.e2e/test_x`. Runs only those, unless `run_all`. + */ +const selection = () => { + if (!process.env.E2E_SELECTION) { + return {}; + } + const { run_all: runAll, tests } = JSON.parse(readFileSync(process.env.E2E_SELECTION, 'utf8')); + if (runAll) { + return {}; + } + const ids = tests.map((id) => id.split('/')); + const files = [...new Set(ids.map(([file]) => file))]; + const names = [...new Set(ids.map(([, name]) => name))]; + return { + testMatch: files.map((file) => `/e2e/tests/${file}.ts`), + // Full names are ` `; test names are unique words, so match the last one. + testNamePattern: ` (${names.join('|')})$`, + }; +}; + /** @type {import('jest').Config} */ module.exports = { rootDir: '..', testMatch: ['/e2e/tests/**/*.e2e.ts'], + ...selection(), testTimeout: 180000, maxWorkers: 1, globalSetup: 'detox/runners/jest/globalSetup', globalTeardown: 'detox/runners/jest/globalTeardown', - reporters: ['detox/runners/jest/reporter'], + reporters: [ + 'detox/runners/jest/reporter', + // Kept across Detox's `--retries` runs; the `test_e2e` lane clears it before the first one. + ['jest-allure2-reporter', { resultsDir: 'allure-results', overwrite: false }], + ], testEnvironment: 'detox/runners/jest/testEnvironment', + testEnvironmentOptions: { + eventListeners: [ + 'jest-metadata/environment-listener', + 'jest-allure2-reporter/environment-listener', + ], + }, verbose: true, transform: { '\\.ts$': [ diff --git a/examples/E2ETestApp/e2e/mock-server/MockServer.ts b/examples/E2ETestApp/e2e/mock-server/MockServer.ts index 1d273d12cf..57230a8192 100644 --- a/examples/E2ETestApp/e2e/mock-server/MockServer.ts +++ b/examples/E2ETestApp/e2e/mock-server/MockServer.ts @@ -30,7 +30,7 @@ export class MockServer { /** Never retried: every call to the driver's `/start` spawns a new server on a new port. */ async start(testName: string): Promise { const response = await this.execute( - `${MockServer.driverUrl}/start/${encodeURIComponent(testName)}`, + `${MockServer.driverUrl}/start/${encodeURIComponent(testName)}?api_version=v2×tamps=ns`, ); this.port = (await response.text()).trim(); await this.waitUntilReady(); diff --git a/examples/E2ETestApp/e2e/pages/ChannelListPage.ts b/examples/E2ETestApp/e2e/pages/ChannelListPage.ts index 4cc136b9b0..7029fcad73 100644 --- a/examples/E2ETestApp/e2e/pages/ChannelListPage.ts +++ b/examples/E2ETestApp/e2e/pages/ChannelListPage.ts @@ -1,4 +1,41 @@ export const ChannelListPage = { + Tabs: { + get channels() { + return by.id('tab-channels'); + }, + get threads() { + return by.id('tab-threads'); + }, + }, + + ThreadList: { + get screen() { + return by.id('thread-list-screen'); + }, + get threads() { + return by.id('thread-list-item'); + }, + }, + + /** The channel actions bottom sheet, opened with the "more" swipe action. */ + ChannelActions: { + item(label: string) { + return by.text(label); + }, + }, + + ChannelDetails: { + get pinnedMessagesOption() { + return by.id('channel-details-pinned-messages'); + }, + get pinnedMessageList() { + return by.id('pinned-message-list'); + }, + pinnedMessage(text: string) { + return by.text(text).withAncestor(by.id('pinned-message-list')); + }, + }, + ChannelList: { get channelList() { return by.id('channel-list-view'); @@ -24,8 +61,27 @@ export const ChannelListPage = { get messageSender() { return by.id('channel-preview-sender'); }, + deliveryStatus(status: 'sending' | 'sent' | 'delivered' | 'read') { + return by.id(`channel-preview-status-${status}`); + }, + get anyDeliveryStatus() { + return by.id(/^channel-preview-status-/); + }, + get failedMessage() { + return by.text('Message failed to send').withAncestor(by.id('channel-preview-button')); + }, + get timestamp() { + return by.id('channel-preview-timestamp'); + }, + /** The swipe actions behind a channel row: `openSheet` ("more") and `mute`. */ + swipeAction(id: string) { + return by.id(`swipe-action-${id}`); + }, + get mutedIcon() { + return by.id('channel-preview-muted-icon'); + }, get unreadCount() { - return by.id('unread-count').withAncestor(by.id('channel-preview-button')); + return by.id('channel-preview-unread-count'); }, }, }, diff --git a/examples/E2ETestApp/e2e/pages/MessageListPage.ts b/examples/E2ETestApp/e2e/pages/MessageListPage.ts index 4559f48590..3b9d84e650 100644 --- a/examples/E2ETestApp/e2e/pages/MessageListPage.ts +++ b/examples/E2ETestApp/e2e/pages/MessageListPage.ts @@ -18,27 +18,158 @@ export const MessageListPage = { get sendButton() { return by.id('send-button'); }, + get editPreview() { + return by.id('reply-composer-preview'); + }, get attachmentsButton() { return by.id('attach-button'); }, get cooldownIndicator() { return by.id('cooldown-timer'); }, + /** The mentions and commands suggestions both render in this list above the composer. */ + get suggestionList() { + return by.id('auto-complete-suggestion-list'); + }, + get userSuggestion() { + return by.id('mentions-item-name'); + }, + userSuggestionWithName(name: string) { + return by.id('mentions-item-name').and(by.text(name)); + }, + get commandSuggestion() { + return by.id('commands-item-title'); + }, + commandSuggestionWithName(name: string) { + return by.id('commands-item-title').and(by.text(name)); + }, + get commandsHeader() { + return by.id('commands-header-title'); + }, + get linkPreview() { + return by.id('composer-link-preview'); + }, + get mediaAttachmentPreview() { + return by.id('image-attachment-upload-preview'); + }, + get fileAttachmentPreview() { + return by.id('file-attachment-upload-preview'); + }, + get linkPreviewCancelButton() { + return by.id('composer-link-preview-dismiss'); + }, + /** "Also send to channel", shown in a thread's composer. */ + get alsoSendToChannelCheckbox() { + return by.id('show-thread-message-in-channel-button'); + }, + }, + + AttachmentPicker: { + /** Gallery images not picked yet (labelled while the app's accessibility layer is on). */ + get images() { + return by.label('Select image'); + }, + }, + + CreatePoll: { + /** + * The text fields of the create poll sheet: the question first, then one per option (a new empty + * one follows each filled option). Their placeholders are hints on Android, which `by.text` + * doesn't match. + */ + get fields() { + return device.getPlatform() === 'android' + ? by.type('com.facebook.react.views.textinput.ReactEditText') + : by.type('RCTUITextField'); + }, + get questionLabel() { + return by.text('Questions'); + }, + get multipleVotesSwitch() { + return device.getPlatform() === 'android' + ? by.type('com.facebook.react.views.switchview.ReactSwitch') + : by.type('UISwitch'); + }, + get createButton() { + return by.label('Create poll'); + }, + }, + + ContextMenu: { + /** An action of the message menu, by its `actionType` (`deleteMessage`, `threadReply`, โ€ฆ). */ + action(actionType: string) { + return by.id(`message-action-${actionType}`); + }, + get backdrop() { + return by.id('message-overlay-backdrop'); + }, + reaction(type: string) { + return by.id(`reaction-button-${type}`); + }, + get showMoreReactions() { + return by.id('more-reactions-button'); + }, + emoji(emoji: string) { + return by.text(emoji); + }, + }, + + Alert: { + /** + * Matches twice from iOS 26 (a wrapper and the action view, with the same frame): use index 0. + */ + button(text: string) { + return device.getPlatform() === 'android' + ? by.text(text).and(by.type('android.widget.Button')) + : by.label(text).and(by.traits(['button'])); + }, + }, + + ThreadPage: { + /** "N Replies" between the thread's parent message and its replies. */ + get repliesCountLabel() { + return by.id('thread-footer-component'); + }, }, MessageList: { get messageList() { return by.id(/^message-(flat|flash)-list$/); }, + /** + * What swipes target: the inverted FlatList's own transform throws off Detox's gesture + * coordinates on Android (a vertical swipe lands as a horizontal one). + */ + get scrollTarget() { + return by.id('message-flat-list-wrapper'); + }, get messages() { return messageCell(); }, + /** + * Every rendered message bubble, deleted ones included. Carries the message text as its label, + * so it is also how the robots find "the n-th message from the bottom". + */ + get messageContents() { + return inMessageList(by.id(/^message-(content-wrapper|deleted)$/)); + }, get dateSeparator() { return by.id('date-separator'); }, get typingIndicator() { return by.id('typing-indicator'); }, + /** The "N new messages" separator above the first unread message. */ + get unreadSeparator() { + return by.id('inline-unread-indicator'); + }, + /** The "N unread" banner at the top that jumps to the first unread message. */ + get scrollToFirstUnreadButton() { + return by.id('unread-messages-notification-button'); + }, + get scrollToFirstUnreadDismissButton() { + return by.id('unread-messages-notification-dismiss-button'); + }, get scrollToBottomButton() { return by.id('scroll-to-bottom-button'); }, @@ -53,7 +184,7 @@ export const MessageListPage = { return by.text(text).withAncestor(by.id('message-content-wrapper')); }, get timestamp() { - return by.id('message-status-time'); + return inMessageList(by.id('message-status-time')); }, get deliveryStatusIsRead() { return inMessageList(by.id('message-status-read')); @@ -71,15 +202,103 @@ export const MessageListPage = { return inMessageList(by.id('message-error')); }, get deletedMessage() { - return by.id('message-deleted'); + return inMessageList(by.id('message-deleted')); + }, + get editedLabel() { + return inMessageList(by.text('Edited')); }, get threadRepliesLabel() { - return by.id('message-replies'); + return inMessageList(by.id('message-replies')); + }, + /** The header of a thread reply that was also sent to the channel, in the channel. */ + get repliedToThreadLabel() { + return inMessageList(by.text('Replied to a thread')); + }, + /** The header of a thread reply that was also sent to the channel, in the thread. */ + get alsoSentInChannelLabel() { + return inMessageList(by.text('Also sent in channel')); + }, + + pinnedLabel(name: string) { + return inMessageList(by.text(`Pinned by ${name}`)); + }, + Poll: { + question(text: string) { + return inMessageList(by.text(text)); + }, + get singleVoteSubtitle() { + return inMessageList(by.text('Select one')); + }, + get closedSubtitle() { + return inMessageList(by.text('Poll has ended')); + }, + option(text: string) { + return inMessageList(by.text(text)); + }, + /** The vote button of `option` (labelled with its text); `checked` once the user voted. */ + voteButton(option: string, checked?: boolean) { + const id = + checked === undefined + ? /^poll-vote-button(-checked)?$/ + : checked + ? 'poll-vote-button-checked' + : 'poll-vote-button'; + return by.id(id).and(by.label(option)); + }, + voteCount(count: number) { + return inMessageList(by.label(count === 1 ? '1 vote' : `${count} votes`)); + }, + get viewResultsButton() { + return by.text('View Results'); + }, + get endPollButton() { + return by.text('End Vote'); + }, + get resultsTitle() { + return by.text('Poll Results'); + }, + }, + get quotedMessage() { + return inMessageList(by.id('quoted-message')); + }, + quotedMessageWithText(text: string) { + return by.text(text).withAncestor(by.id('quoted-message')); + }, + get linkPreview() { + return inMessageList(by.id('card-attachment')); + }, + get galleryItem() { + return inMessageList(by.id(/^gallery-(row|column)-\d+-item-\d+$/)); + }, + get gallery() { + return inMessageList(by.id('gallery-container')); + }, + get file() { + return inMessageList(by.id('file-attachment')); + }, + /** A sent giphy, or the ephemeral preview (with Send / Shuffle / Cancel) before it is sent. */ + get giphy() { + return inMessageList(by.id(/^giphy-(action-)?attachment$/)); + }, + GiphyButtons: { + get send() { + return by.id('send-action-button'); + }, + get shuffle() { + return by.id('shuffle-action-button'); + }, + get cancel() { + return by.id('cancel-action-button'); + }, }, Reactions: { + /** The reactions under a message (clustered: one row for all types). */ get reactions() { - return by.id('reaction-list-item'); + return inMessageList(by.id(/^reaction-list-item-/)); + }, + reaction(type: string) { + return inMessageList(by.id(`reaction-list-item-${type}`)); }, }, }, diff --git a/examples/E2ETestApp/e2e/robots/BackendRobot.ts b/examples/E2ETestApp/e2e/robots/BackendRobot.ts index c62e3eea5c..7c5616f768 100644 --- a/examples/E2ETestApp/e2e/robots/BackendRobot.ts +++ b/examples/E2ETestApp/e2e/robots/BackendRobot.ts @@ -44,6 +44,11 @@ export class BackendRobot extends Robot { return this.post('freeze_messages'); } + /** The mock answers new messages after `seconds`, so they stay in the sending state meanwhile. */ + delayNewMessages(seconds = 5): this { + return this.post(`delay_messages?delay=${seconds}`); + } + setCooldown(enabled: boolean, duration: number): this { return this.post(`config/cooldown?enabled=${enabled}&duration=${duration}`); } @@ -85,6 +90,10 @@ export class BackendRobot extends Robot { ); } + muteChannel(): this { + return this.post('mute_channel'); + } + revokeToken(duration = 5): this { return this.post(`jwt/revoke_token?duration=${duration}`); } diff --git a/examples/E2ETestApp/e2e/robots/ParticipantRobot.ts b/examples/E2ETestApp/e2e/robots/ParticipantRobot.ts index 0615c8a469..0109361460 100644 --- a/examples/E2ETestApp/e2e/robots/ParticipantRobot.ts +++ b/examples/E2ETestApp/e2e/robots/ParticipantRobot.ts @@ -48,6 +48,14 @@ export class ParticipantRobot extends Robot { ); } + sendSystemMessage(text: string): this { + return this.post('participant/message?system=true', text); + } + + sendMessageInChannelNamed(text: string, channelName: string): this { + return this.post(`participant/message?channel_name=${encodeURIComponent(channelName)}`, text); + } + /** Sends `count` messages named `${text}-1` through `${text}-${count}`, 300ms apart. */ sendMultipleMessages(text: string, count: number): this { for (let index = 1; index <= count; index++) { @@ -67,6 +75,13 @@ export class ParticipantRobot extends Robot { return this.post('participant/message?action=edit', text); } + editMessageInThread(text: string, alsoSendInChannel = false): this { + return this.post( + `participant/message?action=edit&thread=true&thread_and_channel=${alsoSendInChannel}`, + text, + ); + } + deleteMessage(hard = false): this { return this.post(`participant/message?action=delete&hard_delete=${hard}`); } diff --git a/examples/E2ETestApp/e2e/robots/UserRobot.ts b/examples/E2ETestApp/e2e/robots/UserRobot.ts index f8e99e6bee..3f1a115e4c 100644 --- a/examples/E2ETestApp/e2e/robots/UserRobot.ts +++ b/examples/E2ETestApp/e2e/robots/UserRobot.ts @@ -1,17 +1,90 @@ +import { ParticipantRobot } from './ParticipantRobot'; + import { Robot } from './Robot'; +import { ReactionType } from '../mock-server/DataTypes'; import { ChannelListPage } from '../pages/ChannelListPage'; import { LoginPage } from '../pages/LoginPage'; import { MessageListPage } from '../pages/MessageListPage'; +import { adb } from '../support/AndroidSystemDialogs'; import { + allAttributes, + frameOf, + indexFromBottom, + isAnyDisplayed, + isDisplayed, + retryWhileNotHittable, + sleep, + waitForExistence, waitToAppear, waitToAppearAndClick, waitToAppearAndLongPress, waitToDisappear, + waitToSettleAndClick, + waitUntil, } from '../support/Element'; const { ChannelList } = ChannelListPage; -const { Composer, Header, MessageList } = MessageListPage; +const { Alert, Composer, ContextMenu, Header, MessageList } = MessageListPage; + +/** + * One step of a scroll that looks for a message. iOS runs without Detox's idle sync, so the list + * still coasts when the next check runs and can fling past the message (to the oldest page): it + * takes shorter swipes there and lets the list come to rest before the check. + */ +const stepScroll = async (direction: 'up' | 'down') => { + const ios = device.getPlatform() === 'ios'; + const list = await waitToAppear(MessageList.scrollTarget, { index: 0 }); + // A list still sliding in (a thread opening) is not hittable yet. + await retryWhileNotHittable(() => list.swipe(direction, 'slow', ios ? 0.3 : 0.5)); + await sleep(ios ? 1000 : 300); +}; + +/** + * Taps an item of the message menu (an action, a reaction). iOS runs without Detox's idle sync, so + * a tap can land while the menu is still animating in: Detox reports it done, but nothing fires. + * Lets the animation settle first. Menu items match twice (the overlay portal), hence index 0. + */ +const tapInContextMenu = async (selector: Detox.NativeMatcher) => { + await waitToSettleAndClick(selector, { index: 0 }); + if (device.getPlatform() === 'ios') { + // The menu animates out after an action, and its closing layer swallows the next tap (a back + // button, a reaction): wait until it is gone. Resolves anyway when an action keeps it open. + // An existence check: the backdrop is transparent, so it never counts as visible. + await waitForExistence(ContextMenu.backdrop, { shouldExist: false, timeout: 5000 }); + await sleep(500); + } +}; + +/** + * Long-presses a message until its menu opens. iOS runs without Detox's idle sync, so a long press + * that lands while the list is still settling after a scroll is cancelled by the movement, and the + * menu never opens. `target` is re-resolved on each attempt, since the frames may have moved. + */ +const longPressUntilMenuOpens = async ( + target: () => Promise<{ index: number; selector: Detox.NativeMatcher }>, + attempts = 3, +) => { + for (let attempt = 1; ; attempt++) { + const { index, selector } = await target(); + await waitToAppearAndLongPress(selector, { index }); + if ( + device.getPlatform() !== 'ios' || + attempt >= attempts || + (await waitForExistence(ContextMenu.backdrop, { timeout: 3000 })) + ) { + return; + } + } +}; + +const extendedPickerEmoji: Record = { + [ReactionType.LIKE]: '๐Ÿ‘', + [ReactionType.LOL]: '๐Ÿ˜‚', + [ReactionType.LOVE]: 'โค๏ธ', + [ReactionType.SAD]: '๐Ÿ˜ข', + [ReactionType.WOW]: '๐Ÿ˜ฎ', +}; /** Drives the app as the logged-in user. Asserts live in the `UserRobot*Asserts` extensions. */ export class UserRobot extends Robot { @@ -39,26 +112,477 @@ export class UserRobot extends Robot { ).waitForMessageListToLoad(); } - openContextMenu(messageText: string): this { - return this.perform(() => waitToAppearAndLongPress(MessageList.Message.text(messageText))); + /** Long-presses the `messageCellIndex`-th message counted from the newest (0). */ + openContextMenu(messageCellIndex = 0): this { + return this.perform(async () => { + await longPressUntilMenuOpens(async () => ({ + index: await indexFromBottom(MessageList.messageContents, messageCellIndex), + selector: MessageList.messageContents, + })); + }); } /** - * iOS types key by key, like XCUITest. Android sets the text, like the native suite's - * `typeText` (UiAutomator `setText`): typing through the soft keyboard leaves the text in the - * IME's composing state, which the composer doesn't pick up, so the send button never shows. + * Long-presses the newest message reading `messageText`. A quoted reply repeats the original's + * text inside its bubble, so texts lying inside a quote don't count. */ - typeText(text: string): this { + openContextMenuOnMessage(messageText: string): this { + return this.perform(async () => { + const selector = MessageList.Message.text(messageText); + const quotes = (await allAttributes(MessageList.Message.quotedMessage)).map((q) => q.frame); + const inQuote = (frame?: { height: number; width: number; x: number; y: number }) => + !!frame && + quotes.some( + (box) => + !!box && + frame.x >= box.x && + frame.y >= box.y && + frame.y + frame.height <= box.y + box.height + 1, + ); + await longPressUntilMenuOpens(async () => { + await waitToAppear(selector, { index: 0 }); + const candidates = (await allAttributes(selector)) + .map((attributes, index) => ({ frame: attributes.frame, index })) + .filter(({ frame }) => !inQuote(frame)) + .sort((a, b) => (b.frame?.y ?? 0) - (a.frame?.y ?? 0)); + return { index: candidates[0]?.index ?? 0, selector }; + }); + }); + } + + tapOnContextMenuAction(actionType: string): this { + return this.perform(() => tapInContextMenu(ContextMenu.action(actionType))); + } + + closeContextMenu(): this { return this.perform(async () => { - const inputField = await waitToAppear(Composer.inputField); if (device.getPlatform() === 'android') { - await inputField.replaceText(text); + await device.pressBack(); + await waitToDisappear(ContextMenu.backdrop); + return; + } + // The backdrop is transparent: Detox iOS 17 never counts it as visible, so this waits for it + // to exist, then taps a point of it away from the menu (top left, below the header). A tap + // that lands while the menu still animates in is dropped, so it taps again until the backdrop + // is gone: until then its closing layer swallows the next tap or passes for a reopened menu. + await waitForExistence(ContextMenu.backdrop); + await sleep(1000); + for (let attempt = 1; attempt <= 3; attempt++) { + // A retry may find the backdrop already gone between the check and the tap. + await element(ContextMenu.backdrop) + .atIndex(0) + .tap({ x: 20, y: 150 }) + .catch((error) => { + if (attempt === 1) throw error; + }); + // waitForExistence returns whether the backdrop still exists. + if ( + !(await waitForExistence(ContextMenu.backdrop, { shouldExist: false, timeout: 5000 })) + ) { + break; + } + } + await sleep(500); + }); + } + + /** Swipes the `messageCellIndex`-th message from the newest to the right, which quotes it. */ + swipeMessage(messageCellIndex = 0): this { + return this.perform(async () => { + const index = await indexFromBottom(MessageList.messageContents, messageCellIndex); + const message = await waitToAppear(MessageList.messageContents, { index }); + await message.swipe('right', 'slow', 0.5); + }); + } + + selectReplyFromContextMenu(messageCellIndex = 0): this { + return this.openContextMenu(messageCellIndex).tapOnContextMenuAction('quotedReply'); + } + + quoteMessage(text: string, messageCellIndex = 0): this { + return this.selectReplyFromContextMenu(messageCellIndex).sendMessage(text); + } + + quoteMessageWithText(text: string, quotedMessageText: string): this { + return this.openContextMenuOnMessage(quotedMessageText) + .tapOnContextMenuAction('quotedReply') + .sendMessage(text); + } + + deleteMessageWithText(text: string): this { + return this.openContextMenuOnMessage(text) + .tapOnContextMenuAction('deleteMessage') + .tapOnAlertButton('Delete'); + } + + /** + * Taps the newest quote, which jumps to the quoted message. A tap that lands while the list is + * still moving can be lost, so it taps again while the quote stays on screen. + */ + tapOnQuotedMessage(): this { + return this.perform(async () => { + for (let attempt = 0; attempt < 3; attempt++) { + const index = await indexFromBottom(MessageList.Message.quotedMessage, 0); + await waitToAppearAndClick(MessageList.Message.quotedMessage, { index }); + await waitToDisappear(MessageList.Message.quotedMessage, { timeout: 3000 }); + if (!(await isDisplayed(MessageList.Message.quotedMessage))) { + return; + } + } + }); + } + + /** Taps "View" in the "Replied to a thread" header of the newest such message. */ + tapOnSentToChannelHeaderLink(): this { + return this.perform(async () => { + const link = by.id('sent-to-channel-header-view-button'); + await waitToAppearAndClick(link, { index: await indexFromBottom(link, 0) }); + }).perform(() => waitToAppear(Composer.alsoSendToChannelCheckbox)); + } + + /** + * Runs `/giphy` from the composer, by typing it or through the command suggestions, and sends the + * picked gif unless `send` is off (the ephemeral preview stays then). + */ + uploadGiphy({ + send = true, + useComposerCommand = false, + }: { send?: boolean; useComposerCommand?: boolean } = {}): this { + if (useComposerCommand) { + // Typed as a key press: on Android `replaceText` would also drop the command the suggestion + // picked, and send a plain "G". + this.openComposerCommands() + .tapOnGiphyCommandSuggestion() + .perform(async () => (await waitToAppear(Composer.inputField)).typeText('G')) + .tapOnSendButton(); + } else { + this.sendMessage('/giphy G'); + } + return send ? this.tapOnSendGiphyButton() : this; + } + + tapOnSendGiphyButton(): this { + return this.perform(() => waitToAppearAndClick(MessageList.Message.GiphyButtons.send)); + } + + tapOnShuffleGiphyButton(): this { + return this.perform(() => waitToAppearAndClick(MessageList.Message.GiphyButtons.shuffle)); + } + + tapOnCancelGiphyButton(): this { + return this.perform(() => waitToAppearAndClick(MessageList.Message.GiphyButtons.cancel)); + } + + flagMessage(text: string): this { + return this.openContextMenuOnMessage(text).tapOnContextMenuAction('flagMessage'); + } + + confirmFlagMessage(): this { + return this.tapOnAlertButton('Flag'); + } + + /** Mute and unmute are the same menu action; its title says which one it does. */ + muteMessageAuthor(text: string): this { + return this.openContextMenuOnMessage(text).tapOnContextMenuAction('muteUser'); + } + + unmuteMessageAuthor(text: string): this { + return this.muteMessageAuthor(text); + } + + blockMessageAuthor(text: string): this { + return this.openContextMenuOnMessage(text) + .tapOnContextMenuAction('blockUser') + .tapOnAlertButton('Block'); + } + + /** Unblocking asks for no confirmation. */ + unblockMessageAuthor(text: string): this { + return this.openContextMenuOnMessage(text).tapOnContextMenuAction('blockUser'); + } + + copyMessage(messageCellIndex = 0): this { + return this.openContextMenu(messageCellIndex).tapOnContextMenuAction('copyMessage'); + } + + /** Pastes the clipboard into the composer: the test runner can't read the device clipboard. */ + pasteIntoComposer(): this { + return this.perform(async () => { + if (device.getPlatform() === 'android') { + // Android shows a clipboard preview chip over the bottom of the screen after a copy; a tap + // on the composer while it is up lands on it (and opens Quick Share). + await sleep(6000); + } + await waitToAppearAndClick(Composer.inputField); + if (device.getPlatform() === 'android') { + adb('shell', 'input', 'keyevent', 'KEYCODE_PASTE'); } else { - await inputField.typeText(text); + // The composer moves up with the keyboard: long-press it once it has settled. + await sleep(1000); + await waitToAppearAndLongPress(Composer.inputField); + await waitToSettleAndClick(by.label('Paste'), { index: 0 }); } }); } + tapOnLinkPreviewCancelButton(): this { + return this.perform(() => waitToAppearAndClick(Composer.linkPreviewCancelButton)); + } + + openAttachmentsMenu(): this { + return this.perform(() => waitToAppearAndClick(Composer.attachmentsButton)); + } + + /** Picks `count` images from the attachment picker's gallery (see `seedImages`). */ + attachImages(count = 1): this { + return this.openAttachmentsMenu().perform(async () => { + // A picked image is relabelled "Deselect image", so the next one is always the first match. + for (let i = 0; i < count; i++) { + // The picker slides up first. + await waitToSettleAndClick(MessageListPage.AttachmentPicker.images, { index: 0 }); + } + }); + } + + createPoll(question: string, options: string[], { multipleVotes = false } = {}): this { + const { CreatePoll } = MessageListPage; + return ( + this.openAttachmentsMenu() + // The attachment picker slides up. + .perform(() => waitToSettleAndClick(by.id('create-poll-touchable'))) + .perform(async () => { + await (await waitToAppearAndClick(CreatePoll.fields, { index: 0 })).replaceText(question); + for (const [index, option] of options.entries()) { + await ( + await waitToAppearAndClick(CreatePoll.fields, { index: index + 1 }) + ).replaceText(option); + } + if (multipleVotes) { + // The keyboard covers the switches below the options. + if (device.getPlatform() === 'android') { + // A tap outside the fields hides the keyboard (the sheet's ScrollView doesn't persist + // taps). Back would close the sheet when a CI emulator has shown no keyboard. + await element(CreatePoll.questionLabel).tap(); + } else { + await element(CreatePoll.fields).atIndex(0).tapReturnKey(); + } + // The sheet moves down as the keyboard hides: a tap during that is dropped on iOS. + await waitToSettleAndClick(CreatePoll.multipleVotesSwitch, { index: 0 }); + } + // iOS reports the label on both the gesture-handler view and its inner button. + await waitToAppearAndClick(CreatePoll.createButton, { index: 0 }); + }) + ); + } + + castPollVote(option: string): this { + return this.perform(() => + waitToAppearAndClick(MessageList.Message.Poll.voteButton(option), { index: 0 }), + ); + } + + /** A tap on an option the user already voted for removes the vote. */ + removePollVote(option: string): this { + return this.castPollVote(option); + } + + openPollResults(): this { + return this.perform(() => waitToAppearAndClick(MessageList.Message.Poll.viewResultsButton)); + } + + endPoll(): this { + return this.perform(() => waitToAppearAndClick(MessageList.Message.Poll.endPollButton)).perform( + async () => { + // Confirmation, where the SDK asks for one. + const confirm = MessageListPage.Alert.button('End'); + if (await isDisplayed(confirm, 0)) { + await waitToAppearAndClick(confirm, { index: 0 }); + } + }, + ); + } + + pinMessage(messageCellIndex = 0): this { + return this.openContextMenu(messageCellIndex).tapOnContextMenuAction('pinMessage'); + } + + unpinMessage(messageCellIndex = 0): this { + return this.openContextMenu(messageCellIndex).tapOnContextMenuAction('unpinMessage'); + } + + markMessageAsUnread(text: string): this { + return this.openContextMenuOnMessage(text).tapOnContextMenuAction('markUnread'); + } + + tapOnScrollToFirstUnreadButton(): this { + return this.perform(() => waitToAppearAndClick(MessageList.scrollToFirstUnreadButton)); + } + + dismissUnreadIndicator(): this { + return this.perform(() => waitToAppearAndClick(MessageList.scrollToFirstUnreadDismissButton)); + } + + openThreadList(): this { + return this.perform(() => waitToAppearAndClick(ChannelListPage.Tabs.threads)); + } + + openChannelListTab(): this { + return this.perform(() => waitToAppearAndClick(ChannelListPage.Tabs.channels)); + } + + openThreadFromThreadList(index = 0): this { + return this.perform(() => + waitToAppearAndClick(ChannelListPage.ThreadList.threads, { index }), + ).perform(() => waitToAppear(Composer.alsoSendToChannelCheckbox)); + } + + swipeChannel(channelCellIndex = 0): this { + return this.perform(async () => { + const channel = await waitToAppear(ChannelList.channels, { index: channelCellIndex }); + await channel.swipe('left', 'slow', 0.5); + }); + } + + tapOnMoreSwipeAction(): this { + return this.perform(() => + waitToSettleAndClick(ChannelList.Channel.swipeAction('openSheet'), { index: 0 }), + ); + } + + tapOnMuteSwipeAction(): this { + return this.perform(() => + waitToSettleAndClick(ChannelList.Channel.swipeAction('mute'), { index: 0 }), + ); + } + + tapOnUnmuteSwipeAction(): this { + return this.tapOnMuteSwipeAction(); + } + + /** + * Opens the channel actions sheet from the "more" swipe action. The native robots long press the + * channel, which RN doesn't support yet (https://linear.app/stream/issue/RN-499): see + * `longPressChannel`. + */ + openChannelMenu(channelCellIndex = 0): this { + return this.swipeChannel(channelCellIndex).tapOnMoreSwipeAction(); + } + + /** The native `openChannelMenu`: a long press on the channel. */ + longPressChannel(channelCellIndex = 0): this { + return this.perform(() => + waitToAppearAndLongPress(ChannelList.channels, { index: channelCellIndex }), + ); + } + + /** + * The sheet sometimes opens with no content at all and stays empty + * (https://linear.app/stream/issue/RN-489): such a run fails here. + */ + tapOnChannelAction(label: string): this { + return this.perform(() => + waitToSettleAndClick(ChannelListPage.ChannelActions.item(label), { + index: 0, + timeout: 30000, + }), + ); + } + + tapOnLeaveGroup(): this { + return this.tapOnChannelAction('Leave Group'); + } + + tapOnDeleteGroup(): this { + return this.tapOnChannelAction('Delete Group'); + } + + confirmChannelAction(action: 'Leave' | 'Delete'): this { + return this.tapOnAlertButton(action); + } + + tapOnViewChannelInfo(): this { + return this.tapOnChannelAction('View Info'); + } + + tapOnPinnedMessagesOption(): this { + return this.perform(() => + waitToAppearAndClick(ChannelListPage.ChannelDetails.pinnedMessagesOption), + ); + } + + addReaction(type: string, messageCellIndex = 0): this { + return this.openContextMenu(messageCellIndex).perform(() => + tapInContextMenu(ContextMenu.reaction(type)), + ); + } + + /** Removes the user's reaction: picking the same type in the menu toggles it off. */ + deleteReaction(type: string, messageCellIndex = 0): this { + return this.addReaction(type, messageCellIndex); + } + + /** + * Picks the reaction `type` in the extended reactions picker; picking one already used removes + * it, as in the native robots. + */ + toggleReactionUsingExtendedPicker(type: ReactionType, messageCellIndex = 0): this { + const emoji = extendedPickerEmoji[type]; + return ( + this.openContextMenu(messageCellIndex) + .perform(() => tapInContextMenu(ContextMenu.showMoreReactions)) + // The sheet renders every emoji up front, which takes a few seconds on an emulator. + .perform(() => waitToAppearAndClick(ContextMenu.emoji(emoji), { index: 0, timeout: 30000 })) + ); + } + + tapOnMessageReaction(type: string): this { + return this.perform(() => + // The reaction pill animates in when the reaction lands. + waitToSettleAndClick(MessageList.Message.Reactions.reaction(type), { index: 0 }), + ); + } + + tapOnAlertButton(text: string): this { + return this.perform(() => waitToAppearAndClick(Alert.button(text), { index: 0 })); + } + + /** + * Deletes the `messageCellIndex`-th message from the newest. `hard` picks the E2E app's own hard + * delete action: the SDK leaves it out of the menu on purpose (RN-504). + */ + deleteMessage(messageCellIndex = 0, hard = false): this { + return this.openContextMenu(messageCellIndex) + .tapOnContextMenuAction(hard ? 'hardDeleteMessage' : 'deleteMessage') + .tapOnAlertButton('Delete'); + } + + editMessage(newText: string, messageCellIndex = 0): this { + return this.perform(() => editMessageNow(newText, messageCellIndex)); + } + + /** Opens the thread of the `messageCellIndex`-th message, from its menu or its replies label. */ + openThread(messageCellIndex = 0, usingContextMenu = true): this { + const opened = usingContextMenu + ? this.openContextMenu(messageCellIndex).tapOnContextMenuAction('threadReply') + : this.perform(async () => { + const index = await indexFromBottom(MessageList.Message.threadRepliesLabel, 0); + // The label re-renders as the reply count changes; a tap during that is dropped on iOS. + await waitToSettleAndClick(MessageList.Message.threadRepliesLabel, { index }); + }); + return opened.perform(() => waitToAppear(Composer.alsoSendToChannelCheckbox)); + } + + /** + * iOS types key by key, like XCUITest. Android sets the text, like the native suite's + * `typeText` (UiAutomator `setText`): typing through the soft keyboard leaves the text in the + * IME's composing state, which the composer doesn't pick up, so the send button never shows. + * There `replaceText` always replaces the whole input, so Android appends to what is already + * typed unless `replace` is set; iOS clears the input first when it is. + */ + typeText(text: string, { replace = false }: { replace?: boolean } = {}): this { + return this.perform(() => typeIntoComposer(text, replace)); + } + clearComposer(): this { return this.perform(async () => { const inputField = await waitToAppear(Composer.inputField); @@ -66,16 +590,38 @@ export class UserRobot extends Robot { }); } + /** + * Waits for the send button to be enabled first: without Espresso's synchronization (iOS, and + * suites that turn it off) a tap right after typing lands before the composer has the text. + */ tapOnSendButton(): this { - return this.perform(() => waitToAppearAndClick(Composer.sendButton)); + return this.perform(async () => { + await waitToAppear(Composer.sendButton); + await waitUntil( + async () => (await allAttributes(Composer.sendButton))[0]?.enabled !== false, + 5000, + ); + await waitToAppearAndClick(Composer.sendButton); + }); } sendMessage(text: string): this { return this.typeText(text).tapOnSendButton(); } + sendMessageInThread(text: string, alsoSendInChannel = false): this { + if (alsoSendInChannel) { + this.perform(() => waitToAppearAndClick(Composer.alsoSendToChannelCheckbox)); + } + return this.sendMessage(text); + } + tapOnBackButton(): this { - return this.perform(() => waitToAppearAndClick(Header.backButton)); + return this.perform(async () => { + // The thread stacks over the channel, each with its own header: the top one is the last. + const backButtons = await indexFromBottom(Header.backButton, 0).catch(() => 0); + await waitToSettleAndClick(Header.backButton, { index: backButtons }); + }); } /** Android hardware back; iOS has none, so it taps the header's back button there. */ @@ -99,6 +645,43 @@ export class UserRobot extends Robot { .waitForChannelListToLoad(); } + moveToChannelListFromThread(): this { + return this.tapOnBackButton() + .perform(() => waitToDisappear(Composer.alsoSendToChannelCheckbox)) + .moveToChannelListFromMessageList(); + } + + tapOnMessageList(): this { + return this.perform(async () => { + // The upper part: the suggestions and the keyboard cover the lower one. + const list = await waitToAppear(MessageList.scrollTarget, { index: 0 }); + const frame = await frameOf(MessageList.scrollTarget); + await list.tap({ x: frame.width / 2, y: frame.height / 4 }); + }); + } + + openComposerCommands(): this { + return this.typeText('/'); + } + + tapOnGiphyCommandSuggestion(): this { + return this.perform(() => waitToAppearAndClick(Composer.commandSuggestionWithName('Giphy'))); + } + + mentionParticipant({ + send = true, + useSuggestions = true, + }: { send?: boolean; useSuggestions?: boolean } = {}): this { + if (useSuggestions) { + this.typeText('@').perform(() => + waitToAppearAndClick(Composer.userSuggestionWithName(ParticipantRobot.userName)), + ); + } else { + this.typeText(`@${ParticipantRobot.userName}`); + } + return send ? this.tapOnSendButton() : this; + } + scrollChannelListDown(times = 3): this { return this.swipe(ChannelList.channelList, 'up', times); } @@ -108,12 +691,36 @@ export class UserRobot extends Robot { } scrollMessageListDown(times = 3): this { - // The message list is inverted: newer messages are at the bottom, reached by swiping down. - return this.swipe(MessageList.messageList, 'down', times); + // The message list is inverted: newer messages are at the bottom, reached by swiping up. + return this.swipe(MessageList.scrollTarget, 'up', times); } scrollMessageListUp(times = 3): this { - return this.swipe(MessageList.messageList, 'up', times); + return this.swipe(MessageList.scrollTarget, 'down', times); + } + + scrollMessageListDownToMessage(text: string, maxScrolls = 10): this { + return this.perform(async () => { + for ( + let i = 0; + i < maxScrolls && !(await isAnyDisplayed(MessageList.Message.text(text))); + i++ + ) { + await stepScroll('up'); + } + }); + } + + scrollMessageListUpToMessage(text: string, maxScrolls = 10): this { + return this.perform(async () => { + for ( + let i = 0; + i < maxScrolls && !(await isAnyDisplayed(MessageList.Message.text(text))); + i++ + ) { + await stepScroll('down'); + } + }); } backgroundApp(): this { @@ -126,10 +733,57 @@ export class UserRobot extends Robot { private swipe(list: Detox.NativeMatcher, direction: Detox.Direction, times: number): this { return this.perform(async () => { - const target = await waitToAppear(list); + const target = await waitToAppear(list, { index: 0 }); for (let i = 0; i < times; i++) { await target.swipe(direction, 'fast', 0.6); } }); } } + +/** {@link UserRobot.typeText} outside a robot chain, for asserts that type as part of a check. */ +export const typeIntoComposer = async (text: string, replace = false) => { + const inputField = await waitToAppear(Composer.inputField); + if (device.getPlatform() === 'android') { + const current = replace ? '' : await currentComposerText(); + if (isTrigger(text)) { + // A trigger has to arrive as a key press: `replaceText` sets the text without the selection + // change the composer's suggestions listen for. A single symbol is never left composing. + if (replace) { + await inputField.clearText(); + } + await inputField.typeText(text); + } else { + await inputField.replaceText(`${current}${text}`); + } + } else { + if (replace) { + await inputField.clearText(); + } + await inputField.typeText(text); + } +}; + +/** Edits the `messageCellIndex`-th message from the newest, outside a robot chain. */ +export const editMessageNow = async (newText: string, messageCellIndex = 0) => { + await longPressUntilMenuOpens(async () => ({ + index: await indexFromBottom(MessageList.messageContents, messageCellIndex), + selector: MessageList.messageContents, + })); + await tapInContextMenu(ContextMenu.action('editMessage')); + // Typing before the composer enters edit mode sends the text as a new message. + await waitToAppear(Composer.editPreview); + await typeIntoComposer(newText, true); + await waitToAppearAndClick(Composer.sendButton); +}; + +const isTrigger = (text: string) => text === '/' || text === '@'; + +const currentComposerText = async () => { + try { + const attributes = (await element(Composer.inputField).getAttributes()) as { text?: string }; + return attributes.text ?? ''; + } catch { + return ''; + } +}; diff --git a/examples/E2ETestApp/e2e/robots/UserRobotChannelListAsserts.ts b/examples/E2ETestApp/e2e/robots/UserRobotChannelListAsserts.ts index 068ed8c6e6..d2aa3323a0 100644 --- a/examples/E2ETestApp/e2e/robots/UserRobotChannelListAsserts.ts +++ b/examples/E2ETestApp/e2e/robots/UserRobotChannelListAsserts.ts @@ -2,9 +2,18 @@ import { expect as jestExpect } from '@jest/globals'; import { ParticipantRobot } from './ParticipantRobot'; import { UserRobot } from './UserRobot'; +import { assertVisibility } from './UserRobotMessageListAsserts'; +import { MessageDeliveryStatus } from '../mock-server/DataTypes'; import { ChannelListPage } from '../pages/ChannelListPage'; -import { waitForCount, waitForExistence, waitForText, waitToDisappear } from '../support/Element'; +import { + isDisplayed, + waitForCount, + waitForExistence, + waitForText, + waitToAppear, + waitToDisappear, +} from '../support/Element'; const { ChannelList } = ChannelListPage; const { Channel } = ChannelList; @@ -21,6 +30,22 @@ declare module './UserRobot' { * `undefined` only the text. */ assertMessageInChannelPreview(text: string, fromCurrentUser?: boolean): this; + /** `NIL`: no status icon at all, as for a participant's last message. */ + assertMessageDeliveryStatusInPreview(status: MessageDeliveryStatus): this; + assertFailedMessageDeliveryStatusInPreview(): this; + assertMessagePreviewTimestamp(isDisplayed?: boolean): this; + assertChannelWithName(name: string, isDisplayed?: boolean): this; + assertChannelIsMuted(isMuted: boolean): this; + assertDraftInChannelPreview(text: string): this; + assertChannelActionsSheetForGroupChannel(): this; + assertGroupChannelInfoScreen(): this; + assertPinnedMessagesScreen(): this; + assertMessageInPinnedMessages(text: string): this; + assertPinnedMessagesAreEmpty(): this; + assertThreadListIsEmpty(): this; + assertThreadInThreadList(parentMessageText: string, replies: number): this; + /** Scrolls down from the newest generated channel ("1") until the oldest ("N") shows. */ + assertChannelListPagination(channelsCount: number): this; } } @@ -51,6 +76,10 @@ UserRobot.prototype.assertChannelName = function (name, channelCellIndex = 0) { UserRobot.prototype.assertChannelUnreadCount = function (count) { return this.perform(async () => { + if (count === 0) { + await assertVisibility(Channel.unreadCount, false); + return; + } jestExpect(await waitForText(Channel.unreadCount, `${count}`)).toBe(`${count}`); }); }; @@ -70,3 +99,105 @@ UserRobot.prototype.assertMessageInChannelPreview = function (text, fromCurrentU } }); }; + +UserRobot.prototype.assertMessageDeliveryStatusInPreview = function (status) { + return this.perform(async () => { + switch (status) { + case MessageDeliveryStatus.PENDING: + return assertVisibility(Channel.deliveryStatus('sending'), true); + case MessageDeliveryStatus.SENT: + return assertVisibility(Channel.deliveryStatus('sent'), true); + case MessageDeliveryStatus.DELIVERED: + return assertVisibility(Channel.deliveryStatus('delivered'), true, 30000); + case MessageDeliveryStatus.READ: + return assertVisibility(Channel.deliveryStatus('read'), true, 30000); + case MessageDeliveryStatus.NIL: + for (const icon of ['sending', 'sent', 'delivered', 'read'] as const) { + await assertVisibility(Channel.deliveryStatus(icon), false); + } + return; + default: + throw new Error(`Unsupported delivery status in preview: ${status}`); + } + }); +}; + +UserRobot.prototype.assertFailedMessageDeliveryStatusInPreview = function () { + return this.perform(() => assertVisibility(Channel.failedMessage, true)); +}; + +UserRobot.prototype.assertMessagePreviewTimestamp = function (shown = true) { + return this.perform(() => assertVisibility(Channel.timestamp, shown)); +}; + +UserRobot.prototype.assertChannelListPagination = function (channelsCount) { + // Generated channels are named "1" (newest) to "N" (oldest): the last one only shows once every + // page is loaded. + const last = Channel.nameWithText(`${channelsCount}`); + return this.perform(async () => { + for (let i = 0; i < 20 && !(await isDisplayed(last)); i++) { + await (await waitToAppear(ChannelList.channelList)).swipe('up', 'slow', 0.6); + } + await waitToAppear(last); + }); +}; + +UserRobot.prototype.assertChannelWithName = function (name, shown = true) { + return this.perform(() => assertVisibility(Channel.nameWithText(name), shown)); +}; + +UserRobot.prototype.assertChannelIsMuted = function (muted) { + return this.perform(() => assertVisibility(Channel.mutedIcon, muted)); +}; + +UserRobot.prototype.assertChannelActionsSheetForGroupChannel = function () { + return this.perform(async () => { + for (const label of ['View Info', 'Leave Group', 'Delete Group']) { + await waitToAppear(ChannelListPage.ChannelActions.item(label), { index: 0, timeout: 30000 }); + } + }); +}; + +UserRobot.prototype.assertGroupChannelInfoScreen = function () { + return this.perform(() => waitToAppear(ChannelListPage.ChannelDetails.pinnedMessagesOption)); +}; + +UserRobot.prototype.assertPinnedMessagesScreen = function () { + return this.perform(() => waitToAppear(ChannelListPage.ChannelDetails.pinnedMessageList)); +}; + +UserRobot.prototype.assertMessageInPinnedMessages = function (text) { + return this.perform(() => + waitToAppear(ChannelListPage.ChannelDetails.pinnedMessage(text), { index: 0 }), + ); +}; + +UserRobot.prototype.assertPinnedMessagesAreEmpty = function () { + return this.perform(async () => { + jestExpect(await waitForCount(by.id(/^pinned-message-item-/), 0)).toBe(0); + }); +}; + +UserRobot.prototype.assertThreadListIsEmpty = function () { + return this.perform(async () => { + await waitToAppear(ChannelListPage.ThreadList.screen); + jestExpect(await waitForCount(ChannelListPage.ThreadList.threads, 0)).toBe(0); + }); +}; + +UserRobot.prototype.assertThreadInThreadList = function (parentMessageText, replies) { + const repliesLabel = replies === 1 ? '1 Reply' : `${replies} Replies`; + return this.perform(async () => { + const thread = ChannelListPage.ThreadList.threads; + await waitToAppear(by.text(parentMessageText).withAncestor(thread), { index: 0 }); + await waitToAppear(by.text(repliesLabel).withAncestor(thread), { index: 0 }); + }); +}; + +UserRobot.prototype.assertDraftInChannelPreview = function (text) { + return this.perform(async () => { + const preview = by.id('channel-preview-button'); + await waitToAppear(by.text('Draft:').withAncestor(preview), { index: 0 }); + await waitToAppear(by.text(text).withAncestor(preview), { index: 0 }); + }); +}; diff --git a/examples/E2ETestApp/e2e/robots/UserRobotJwtAsserts.ts b/examples/E2ETestApp/e2e/robots/UserRobotJwtAsserts.ts new file mode 100644 index 0000000000..e97f1e8e41 --- /dev/null +++ b/examples/E2ETestApp/e2e/robots/UserRobotJwtAsserts.ts @@ -0,0 +1,37 @@ +import { UserRobot } from './UserRobot'; + +import { sleep, waitToAppear, waitToAppearAndClick } from '../support/Element'; + +declare module './UserRobot' { + interface UserRobot { + tapOnConnectionButton(): this; + /** The client is connected (after a token refresh, when one was needed). */ + assertConnectionStatus(): this; + /** The connection dropped once the token expired, as Android's `statusOffline` assert. */ + assertTokenHasExpired(): this; + /** The mock server's valid token expires 5s after it is issued. */ + waitForTokenToExpire(): this; + } +} + +const jwtTimeout = 15000; + +UserRobot.prototype.tapOnConnectionButton = function () { + return this.perform(() => waitToAppearAndClick(by.id('jwt-connection-button'))); +}; + +UserRobot.prototype.assertConnectionStatus = function () { + return this.perform(() => + waitToAppear(by.id('jwt-connection-status-connected'), { timeout: jwtTimeout }), + ); +}; + +UserRobot.prototype.assertTokenHasExpired = function () { + return this.perform(() => + waitToAppear(by.id('jwt-has-been-disconnected-true'), { timeout: jwtTimeout }), + ); +}; + +UserRobot.prototype.waitForTokenToExpire = function () { + return this.perform(() => sleep(5000)); +}; diff --git a/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts b/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts index 7596ce79c8..278e33824c 100644 --- a/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts +++ b/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts @@ -1,19 +1,28 @@ import { expect as jestExpect } from '@jest/globals'; -import { UserRobot } from './UserRobot'; +import { ParticipantRobot } from './ParticipantRobot'; +import { editMessageNow, typeIntoComposer, UserRobot } from './UserRobot'; import { MessageDeliveryStatus } from '../mock-server/DataTypes'; import { MessageListPage } from '../pages/MessageListPage'; import { + allAttributes, defaultTimeout, + frameOf, + indexFromBottom, + isAnyDisplayed, isDisplayed, + sleep, + textOf, waitForCount, waitForExistence, + waitForText, waitToAppear, waitToDisappear, + waitUntil, } from '../support/Element'; -const { MessageList } = MessageListPage; +const { Composer, ContextMenu, MessageList } = MessageListPage; const { Message } = MessageList; declare module './UserRobot' { @@ -24,6 +33,74 @@ declare module './UserRobot' { assertMessageFailedIcon(isDisplayed: boolean): this; assertTypingIndicator(isDisplayed: boolean): this; assertScrollToBottomButton(isDisplayed: boolean): this; + assertEditedMessage(text: string): this; + /** `hard`: the message is gone, with no "Message deleted" placeholder. */ + assertDeletedMessage(text?: string, hard?: boolean): this; + assertMessageTimestamps(count: number): this; + assertMessageCount(count: number): this; + assertMessageSizeChangesAfterEditing(linesCountShouldBeIncreased: boolean): this; + /** `isChangeable`: the composer grows with 3 lines; otherwise it stops growing past 6. */ + assertComposerSize(isChangeable: boolean): this; + assertComposerAttachmentsButton(isDisplayed?: boolean): this; + assertComposerCommandsMenu(isDisplayed: boolean): this; + assertComposerMentionsMenu(isDisplayed: boolean): this; + assertMentionWasApplied(): this; + assertComposerText(text: string): this; + assertThreadIsOpen(): this; + assertThreadMessage(text: string): this; + assertThreadReplyLabelOnParentMessage(replies?: number): this; + assertAlsoInTheChannelLabelInChannel(): this; + assertAlsoInTheChannelLabelInThread(): this; + /** + * Scrolls up until the oldest of `messagesCount` seeded messages shows. The mock seeds message + * texts with their 1-based index, so the oldest one reads "1". + */ + assertMessageListPagination(messagesCount: number): this; + /** Opens the message menu of the newest message and checks the action is not offered. */ + assertContextMenuActionNotAvailable(actionType: string, messageCellIndex?: number): this; + /** + * The quoted reply `text` with its `quote`; with `isDisplayed: false`, that the quote (and the + * reply, when `text` is given) is not on screen. + */ + assertQuotedMessage(options: { isDisplayed?: boolean; quote?: string; text?: string }): this; + assertFile(isDisplayed: boolean, count?: number): this; + assertGiphyImage(isDisplayed?: boolean): this; + assertGiphyButtons(areDisplayed?: boolean): this; + assertSystemMessage(text: string, isDisplayed?: boolean): this; + assertInvalidCommandMessage(command: string, isDisplayed?: boolean): this; + assertThreadReplyLabel(replies: number, inThread?: boolean): this; + assertMessages(text: string, count: number): this; + /** `0`: the scroll to bottom button shows no unread badge. */ + assertScrollToBottomButtonUnreadCount(count: number): this; + assertReaction(type: string, isDisplayed: boolean): this; + /** The user's name in the reactions overlay that opens on a tap on a reaction. */ + assertReactionAuthor(name: string): this; + assertFlagMessageDialog(isDisplayed: boolean): this; + /** Opens the message menu and checks it offers to unmute (`true`) or mute the author. */ + assertMuteMessageAuthorOption(messageText: string, isAuthorMuted: boolean): this; + assertBlockMessageAuthorOption(messageText: string, isAuthorBlocked: boolean): this; + assertCooldownIsShown(): this; + assertCooldownIsNotShown(): this; + /** The input and the attachments button are disabled, as Android's slow mode locks them. */ + assertComposerIsDisabledInSlowMode(): this; + /** `pinnedBy`: the name in the label; the current user's label ("Pinned by You") by default. */ + assertMessagePinnedLabel(pinnedBy?: string, isDisplayed?: boolean): this; + assertUnreadSeparator(unreadCount: number, isDisplayed?: boolean): this; + assertLinkPreviewInComposer(isDisplayed: boolean): this; + assertMediaAttachmentInPreview(isDisplayed: boolean, count?: number): this; + assertImage(isDisplayed: boolean, count?: number): this; + assertPollMessage(question: string, multipleVotes?: boolean): this; + /** `isChecked`: also waits for the option's vote state, which a vote round-trip changes. */ + assertPollOption(option: string, isChecked?: boolean): this; + /** How many options show `count` votes. */ + assertPollVoteCounts(count: number, options: number): this; + assertPollResults(voterName: string): this; + assertPollClosed(): this; + assertVideo(isDisplayed: boolean): this; + assertLinkPreviewInMessageList(isDisplayed: boolean): this; + /** A message whose text contains `text` (a linkified text is split into several nodes). */ + assertMessageContaining(text: string): this; + assertScrollToFirstUnreadButton(unreadCount?: number, isDisplayed?: boolean): this; } } @@ -33,7 +110,17 @@ export const assertVisibility = async ( timeout = defaultTimeout, ) => { if (shouldBeDisplayed) { - await waitToAppear(selector, { index: 0, timeout }); + // iOS: any match on screen, not just the first, which can be an off-screen list cell there. + if ( + device.getPlatform() !== 'ios' || + !(await waitUntil(() => isAnyDisplayed(selector), timeout)) + ) { + // Android, or the iOS wait failed: fails with Detox's own description of the selector. + await waitToAppear(selector, { + index: 0, + timeout: device.getPlatform() === 'ios' ? 1000 : timeout, + }); + } } else { await waitToDisappear(selector, { index: 0, timeout }); jestExpect(await isDisplayed(selector, 0)).toBe(false); @@ -51,7 +138,12 @@ UserRobot.prototype.assertMessage = function (text, shouldBeDisplayed = true) { return this.perform(async () => { await assertVisibility(Message.text(text), shouldBeDisplayed); if (shouldBeDisplayed) { - jestExpect(await isDisplayed(Message.timestamp, 0)).toBe(true); + // Any timestamp on screen: the first match can be one scrolled out of view. Waited for: when + // messages arrive back to back, the group's footer moves to the newest one after its text. + const shown = await waitUntil(async () => + (await allAttributes(Message.timestamp)).some((timestamp) => timestamp.visible), + ); + jestExpect(shown).toBe(true); } }); }; @@ -79,7 +171,7 @@ UserRobot.prototype.assertMessageDeliveryStatus = function (status, count) { case MessageDeliveryStatus.SENT: return assertDeliveryStatusCount(Message.deliveryStatusIsSent, count); case MessageDeliveryStatus.FAILED: - return assertDeliveryStatusCount(Message.deliveryStatusIsFailed, count); + return assertFailedIcon(true, count); case MessageDeliveryStatus.NIL: await assertVisibility(Message.deliveryStatusIsRead, false); await assertVisibility(Message.deliveryStatusIsDelivered, false); @@ -92,8 +184,24 @@ UserRobot.prototype.assertMessageDeliveryStatus = function (status, count) { }); }; +/** + * The error badge sits in a container narrower than itself, which clips it below Detox's 75% + * visibility threshold, so its presence is what is checked. + */ +const assertFailedIcon = async (shouldBeDisplayed: boolean, expected?: number) => { + const exists = await waitForExistence(Message.deliveryStatusIsFailed, { + index: 0, + shouldExist: shouldBeDisplayed, + timeout: 30000, + }); + jestExpect(exists).toBe(shouldBeDisplayed); + if (shouldBeDisplayed && expected !== undefined) { + jestExpect(await waitForCount(Message.deliveryStatusIsFailed, expected)).toBe(expected); + } +}; + UserRobot.prototype.assertMessageFailedIcon = function (shouldBeDisplayed) { - return this.perform(() => assertVisibility(Message.deliveryStatusIsFailed, shouldBeDisplayed)); + return this.perform(() => assertFailedIcon(shouldBeDisplayed)); }; UserRobot.prototype.assertTypingIndicator = function (shouldBeDisplayed) { @@ -103,3 +211,494 @@ UserRobot.prototype.assertTypingIndicator = function (shouldBeDisplayed) { UserRobot.prototype.assertScrollToBottomButton = function (shouldBeDisplayed) { return this.perform(() => assertVisibility(MessageList.scrollToBottomButton, shouldBeDisplayed)); }; + +UserRobot.prototype.assertEditedMessage = function (text) { + return this.assertMessage(text).perform(() => waitToAppear(Message.editedLabel)); +}; + +UserRobot.prototype.assertDeletedMessage = function (text, hard = false) { + this.perform(async () => { + if (hard) { + await assertVisibility(Message.deletedMessage, false); + } else { + // An existence check: Fabric mounts the testID'd container without its content on iOS, so + // Detox iOS (pixel-based visibility) never reports it visible. + jestExpect(await waitForExistence(Message.deletedMessage, { index: 0 })).toBe(true); + jestExpect(await isDisplayed(Message.timestamp, 0)).toBe(true); + } + }); + return text === undefined ? this : this.assertMessage(text, false); +}; + +UserRobot.prototype.assertMessageTimestamps = function (expected) { + return this.perform(async () => { + jestExpect(await waitForCount(Message.timestamp, expected)).toBe(expected); + }); +}; + +UserRobot.prototype.assertMessageCount = function (expected) { + return this.perform(async () => { + jestExpect(await waitForCount(MessageList.messageContents, expected)).toBe(expected); + }); +}; + +const newestMessageHeight = async () => { + const index = await indexFromBottom(MessageList.messageContents, 0); + return (await frameOf(MessageList.messageContents, index)).height; +}; + +UserRobot.prototype.assertMessageSizeChangesAfterEditing = function (increased) { + let initialHeight = 0; + let newText = ''; + return this.perform(async () => { + initialHeight = await newestMessageHeight(); + // Android rolls the bubble's text up into the container; iOS gives the container no label, so + // the text comes from the newest bubble's paragraph there. + const source = + device.getPlatform() === 'ios' + ? by.type('RCTParagraphComponentView').withAncestor(by.id('message-content-wrapper')) + : MessageList.messageContents; + const index = await indexFromBottom(source, 0); + const messageText = await textOf(source, index); + newText = increased ? `ok\n${messageText}\nnew line` : 'new line'; + }) + .perform(async () => { + await editMessageNow(newText); + await waitToAppear(Message.text(newText)); + }) + .perform(async () => { + const changed = await waitUntil(async () => { + const height = await newestMessageHeight(); + return increased ? height > initialHeight : height < initialHeight; + }); + jestExpect(changed).toBe(true); + }); +}; + +UserRobot.prototype.assertComposerSize = function (isChangeable) { + return this.perform(async () => { + const initialHeight = (await frameOf(Composer.inputField)).height; + if (isChangeable) { + await typeIntoComposer('1\n2\n3'); + await sleep(500); + jestExpect((await frameOf(Composer.inputField)).height).not.toBe(initialHeight); + } else { + const text = '1\n2\n3\n4\n5\n6'; + await typeIntoComposer(text, true); + await sleep(500); + const grownHeight = (await frameOf(Composer.inputField)).height; + await typeIntoComposer(`${text}\n7`, true); + await sleep(500); + jestExpect((await frameOf(Composer.inputField)).height).toBe(grownHeight); + } + }); +}; + +UserRobot.prototype.assertComposerAttachmentsButton = function (shouldBeDisplayed = true) { + return this.perform(() => assertVisibility(Composer.attachmentsButton, shouldBeDisplayed)); +}; + +UserRobot.prototype.assertComposerCommandsMenu = function (shouldBeDisplayed) { + return this.perform(async () => { + await assertVisibility(Composer.commandSuggestion, shouldBeDisplayed); + if (shouldBeDisplayed) { + jestExpect(await isDisplayed(Composer.commandsHeader)).toBe(true); + } + }); +}; + +UserRobot.prototype.assertComposerMentionsMenu = function (shouldBeDisplayed) { + return this.perform(() => assertVisibility(Composer.userSuggestion, shouldBeDisplayed)); +}; + +UserRobot.prototype.assertMentionWasApplied = function () { + return this.assertComposerText(`@${ParticipantRobot.userName} `); +}; + +UserRobot.prototype.assertComposerText = function (text) { + return this.perform(async () => { + jestExpect(await waitForText(Composer.inputField, text)).toBe(text); + }); +}; + +UserRobot.prototype.assertThreadIsOpen = function () { + return this.perform(() => waitToAppear(Composer.alsoSendToChannelCheckbox)); +}; + +UserRobot.prototype.assertThreadMessage = function (text) { + return this.assertThreadIsOpen().assertMessage(text); +}; + +UserRobot.prototype.assertThreadReplyLabelOnParentMessage = function (replies = 1) { + const expected = replies === 1 ? '1 Reply' : `${replies} Replies`; + return this.perform(async () => { + jestExpect(await waitForText(Message.threadRepliesLabel, expected)).toBe(expected); + }); +}; + +UserRobot.prototype.assertAlsoInTheChannelLabelInChannel = function () { + return this.perform(() => waitToAppear(Message.repliedToThreadLabel, { index: 0 })); +}; + +UserRobot.prototype.assertAlsoInTheChannelLabelInThread = function () { + return this.perform(() => waitToAppear(Message.alsoSentInChannelLabel, { index: 0 })); +}; + +UserRobot.prototype.assertMessageListPagination = function (messagesCount) { + const oldest = Message.text('1'); + return this.perform(async () => { + // Waited for: right after a thread opens its replies are still loading. + await waitToAppear(Message.text(`${messagesCount}`), { index: 0 }); + for (let i = 0; i < 30 && !(await isAnyDisplayed(oldest)); i++) { + await (await waitToAppear(MessageList.scrollTarget, { index: 0 })).swipe('down', 'fast', 0.8); + } + await waitToAppear(oldest, { index: 0 }); + }); +}; + +UserRobot.prototype.assertContextMenuActionNotAvailable = function ( + actionType, + messageCellIndex = 0, +) { + return this.openContextMenu(messageCellIndex) + .perform(async () => { + // Another action proves the menu is open before the absence is checked: every message menu + // offers copy or, for a failed message, resend. + const opened = await waitUntil( + async () => + (await isDisplayed(ContextMenu.action('copyMessage'), 0)) || + (await isDisplayed(ContextMenu.action('retry'), 0)), + ); + jestExpect(opened).toBe(true); + jestExpect(await isDisplayed(ContextMenu.action(actionType))).toBe(false); + }) + .closeContextMenu(); +}; + +/** + * Whether a quote reading `quote` is on screen. The quote is one accessible element (it has its own + * label), so Detox doesn't match its texts as descendants: a text inside its frame counts instead. + */ +export const isQuoteDisplayed = async (quote?: string) => { + // Detox iOS also reports the list's off-screen cells (Android's UiAutomator doesn't), so only the + // quotes on screen count. + const quotes = (await allAttributes(Message.quotedMessage)) + .filter((attributes) => attributes.visible !== false) + .map((attributes) => attributes.frame); + if (quote === undefined) { + return quotes.length > 0; + } + const texts = await allAttributes(by.text(quote)); + return texts.some(({ frame }) => + quotes.some( + (box) => + !!frame && + !!box && + frame.x >= box.x && + frame.y >= box.y && + frame.x + frame.width <= box.x + box.width + 1 && + frame.y + frame.height <= box.y + box.height + 1, + ), + ); +}; + +UserRobot.prototype.assertQuotedMessage = function ({ isDisplayed: shown = true, quote, text }) { + return this.perform(async () => { + const reached = await waitUntil(async () => (await isQuoteDisplayed(quote)) === shown); + jestExpect(reached).toBe(true); + if (text !== undefined) { + await assertVisibility(Message.text(text), shown); + } + }); +}; + +UserRobot.prototype.assertFile = function (shown, expected = 1) { + return this.perform(async () => { + await assertVisibility(Message.file, shown); + if (shown) { + jestExpect(await waitForCount(Message.file, expected)).toBe(expected); + } + }); +}; + +UserRobot.prototype.assertGiphyImage = function (shown = true) { + return this.perform(() => assertVisibility(Message.giphy, shown)); +}; + +UserRobot.prototype.assertGiphyButtons = function (shown = true) { + return this.perform(async () => { + await assertVisibility(Message.GiphyButtons.send, shown); + await assertVisibility(Message.GiphyButtons.shuffle, shown); + await assertVisibility(Message.GiphyButtons.cancel, shown); + }); +}; + +UserRobot.prototype.assertSystemMessage = function (text, shown = true) { + return this.perform(() => assertVisibility(inList(by.text(text)), shown)); +}; + +UserRobot.prototype.assertInvalidCommandMessage = function (command, shown = true) { + return this.assertSystemMessage( + `Sorry, command ${command} doesn't exist. Try posting your message without the starting /`, + shown, + ); +}; + +UserRobot.prototype.assertThreadReplyLabel = function (replies, inThread = false) { + const expected = replies === 1 ? '1 Reply' : `${replies} Replies`; + return this.perform(async () => { + if (inThread) { + // The label sits in a labelled container, which hides it from descendant matching. The + // container itself is checked for existence: Fabric mounts it without its content on iOS, so + // Detox iOS never reports it visible. The label's own visibility is asserted below. + jestExpect(await waitForExistence(MessageListPage.ThreadPage.repliesCountLabel)).toBe(true); + await waitToAppear(inList(by.text(expected)), { index: 0 }); + return; + } + jestExpect(await waitForText(Message.threadRepliesLabel, expected)).toBe(expected); + }); +}; + +UserRobot.prototype.assertMessages = function (text, expected) { + return this.perform(async () => { + if (device.getPlatform() !== 'ios') { + jestExpect(await waitForCount(Message.text(text), expected)).toBe(expected); + return; + } + // Only the ones on screen: iOS 17 still reports the hidden channel under an open thread. + let actual = 0; + await waitUntil(async () => { + actual = (await allAttributes(Message.text(text))).filter((match) => match.visible).length; + return actual === expected; + }); + jestExpect(actual).toBe(expected); + }); +}; + +const inList = (matcher: Detox.NativeMatcher) => + matcher.withAncestor(by.id(/^message-(flat|flash)-list$/)); + +UserRobot.prototype.assertScrollToBottomButtonUnreadCount = function (expected) { + const badge = by.id('unread-count').withAncestor(by.id('scroll-to-bottom-button-unread-count')); + return this.perform(async () => { + if (expected > 0) { + jestExpect(await waitForText(badge, `${expected}`)).toBe(`${expected}`); + } else { + await assertVisibility(badge, false); + } + }); +}; + +UserRobot.prototype.assertReaction = function (type, shown) { + return this.perform(() => assertVisibility(Message.Reactions.reaction(type), shown)); +}; + +UserRobot.prototype.assertReactionAuthor = function (name) { + // Scoped to the reactions sheet: the author's name also shows under the message behind it. + return this.perform(() => + waitToAppear(by.text(name).withAncestor(by.label('reaction-flat-list')), { index: 0 }), + ); +}; + +UserRobot.prototype.assertFlagMessageDialog = function (shown) { + return this.perform(() => + assertVisibility( + by.text( + 'Do you want to send a copy of this message to a moderator for further investigation?', + ), + shown, + ), + ); +}; + +/** Opens the message menu on `messageText` and waits for the action titled `expected`. */ +const assertMenuActionTitle = function ( + robot: UserRobot, + messageText: string, + actionType: string, + expected: string, + unexpected: string, +) { + return robot + .openContextMenuOnMessage(messageText) + .perform(async () => { + const title = by.text(expected).withAncestor(ContextMenu.action(actionType)); + // The open menu rebuilds its actions when the own user's state lands. + await waitToAppear(title, { index: 0, timeout: 15000 }); + jestExpect( + await isDisplayed(by.text(unexpected).withAncestor(ContextMenu.action(actionType))), + ).toBe(false); + }) + .closeContextMenu(); +}; + +UserRobot.prototype.assertMuteMessageAuthorOption = function (messageText, muted) { + return assertMenuActionTitle( + this, + messageText, + 'muteUser', + muted ? 'Unmute User' : 'Mute User', + muted ? 'Mute User' : 'Unmute User', + ); +}; + +UserRobot.prototype.assertBlockMessageAuthorOption = function (messageText, blocked) { + return assertMenuActionTitle( + this, + messageText, + 'blockUser', + blocked ? 'Unblock User' : 'Block User', + blocked ? 'Block User' : 'Unblock User', + ); +}; + +UserRobot.prototype.assertCooldownIsShown = function () { + return this.perform(async () => { + await waitToAppear(Composer.cooldownIndicator); + jestExpect(await isDisplayed(Composer.sendButton)).toBe(false); + }); +}; + +UserRobot.prototype.assertCooldownIsNotShown = function () { + return this.perform(() => assertVisibility(Composer.cooldownIndicator, false)); +}; + +UserRobot.prototype.assertComposerIsDisabledInSlowMode = function () { + return this.perform(async () => { + await waitToAppear(Composer.inputField); + jestExpect((await allAttributes(Composer.inputField))[0]?.enabled).toBe(false); + jestExpect((await allAttributes(Composer.attachmentsButton))[0]?.enabled).toBe(false); + }); +}; + +UserRobot.prototype.assertMessagePinnedLabel = function (pinnedBy = 'You', shown = true) { + return this.perform(() => assertVisibility(Message.pinnedLabel(pinnedBy), shown)); +}; + +UserRobot.prototype.assertUnreadSeparator = function (unreadCount, shown = true) { + // Android's `stream_compose_message_list_unread_separator`. + const expected = unreadCount === 1 ? '1 unread message' : `${unreadCount} unread messages`; + return this.perform(async () => { + if (shown) { + // The separator is one labelled element, so its text isn't matched as its descendant. + await waitToAppear(MessageList.unreadSeparator, { index: 0 }); + await waitToAppear(inList(by.text(expected)), { index: 0 }); + } else { + await assertVisibility(MessageList.unreadSeparator, false); + } + }); +}; + +UserRobot.prototype.assertScrollToFirstUnreadButton = function (unreadCount, shown = true) { + return this.perform(async () => { + await assertVisibility(MessageList.scrollToFirstUnreadButton, shown); + if (shown && unreadCount !== undefined) { + const label = by + .text(`${unreadCount} unread`) + .withAncestor(MessageList.scrollToFirstUnreadButton); + await waitToAppear(label, { index: 0 }); + } + }); +}; + +UserRobot.prototype.assertLinkPreviewInComposer = function (shown) { + return this.perform(() => assertVisibility(Composer.linkPreview, shown)); +}; + +UserRobot.prototype.assertLinkPreviewInMessageList = function (shown) { + return this.perform(() => + assertVisibility(Message.linkPreview, shown, shown ? 30000 : defaultTimeout), + ); +}; + +UserRobot.prototype.assertMessageContaining = function (text) { + return this.perform(async () => { + const contents = MessageList.messageContents; + // Android rolls the bubble's texts up into the container's description; iOS gives the + // container no label, but Fabric flattens the text and its link spans into one paragraph. + const paragraphs = Message.text(text); + const found = await waitUntil(async () => + device.getPlatform() === 'ios' + ? (await allAttributes(paragraphs)).length > 0 + : (await allAttributes(contents)).some((content) => + (content.label ?? content.text ?? '').includes(text), + ), + ); + jestExpect(found).toBe(true); + }); +}; + +UserRobot.prototype.assertMediaAttachmentInPreview = function (shown, expected = 1) { + return this.perform(async () => { + if (!shown) { + return assertVisibility(Composer.mediaAttachmentPreview, false); + } + // Fabric mounts the preview container without its content on iOS, so Detox iOS never reports + // it visible. + jestExpect(await waitForExistence(Composer.mediaAttachmentPreview, { index: 0 })).toBe(true); + jestExpect(await waitForCount(Composer.mediaAttachmentPreview, expected)).toBe(expected); + }); +}; + +UserRobot.prototype.assertImage = function (shown, expected = 1) { + return this.perform(async () => { + if (!shown) { + return assertVisibility(Message.galleryItem, false); + } + await waitForExistence(Message.galleryItem, { index: 0, timeout: 30000 }); + jestExpect(await waitForCount(Message.galleryItem, expected)).toBe(expected); + }); +}; + +UserRobot.prototype.assertVideo = function (shown) { + return this.perform(async () => { + if (!shown) { + return assertVisibility(Message.gallery, false); + } + // Like `assertImage`: Fabric mounts the gallery container without its content on iOS, so + // Detox iOS never reports it visible. + jestExpect(await waitForExistence(Message.gallery, { index: 0, timeout: 30000 })).toBe(true); + }); +}; + +UserRobot.prototype.assertPollMessage = function (question, multipleVotes = false) { + return this.perform(async () => { + await waitToAppear(Message.Poll.question(question), { index: 0 }); + await waitToAppear( + multipleVotes ? inList(by.text('Select one or more')) : Message.Poll.singleVoteSubtitle, + { index: 0 }, + ); + }); +}; + +UserRobot.prototype.assertPollOption = function (option, isChecked) { + return this.perform(async () => { + await waitToAppear(Message.Poll.option(option), { index: 0 }); + if (isChecked !== undefined) { + await waitForExistence(Message.Poll.voteButton(option, isChecked), { index: 0 }); + jestExpect( + await waitForExistence(Message.Poll.voteButton(option, isChecked), { index: 0 }), + ).toBe(true); + } + }); +}; + +UserRobot.prototype.assertPollVoteCounts = function (votes, options) { + return this.perform(async () => { + jestExpect(await waitForCount(Message.Poll.voteCount(votes), options)).toBe(options); + }); +}; + +UserRobot.prototype.assertPollResults = function (voterName) { + return this.perform(async () => { + await waitToAppear(Message.Poll.resultsTitle, { index: 0 }); + await waitToAppear(by.text(voterName), { index: 0 }); + }); +}; + +UserRobot.prototype.assertPollClosed = function () { + return this.perform(async () => { + await waitToAppear(Message.Poll.closedSubtitle, { index: 0 }); + await assertVisibility(Message.Poll.endPollButton, false); + }); +}; diff --git a/examples/E2ETestApp/e2e/robots/index.ts b/examples/E2ETestApp/e2e/robots/index.ts index bc92c7f124..a140df10e2 100644 --- a/examples/E2ETestApp/e2e/robots/index.ts +++ b/examples/E2ETestApp/e2e/robots/index.ts @@ -1,6 +1,7 @@ // The assert files extend UserRobot's prototype, so they must be loaded with it. import './UserRobotMessageListAsserts'; import './UserRobotChannelListAsserts'; +import './UserRobotJwtAsserts'; export { BackendRobot } from './BackendRobot'; export { ParticipantRobot } from './ParticipantRobot'; diff --git a/examples/E2ETestApp/e2e/support/AndroidSystemDialogs.ts b/examples/E2ETestApp/e2e/support/AndroidSystemDialogs.ts index a7d2c61dcd..45e7ad82aa 100644 --- a/examples/E2ETestApp/e2e/support/AndroidSystemDialogs.ts +++ b/examples/E2ETestApp/e2e/support/AndroidSystemDialogs.ts @@ -9,7 +9,7 @@ const { execFileSync } = require('child_process'); const sdkRoot = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT; const adbPath = sdkRoot ? `${sdkRoot}/platform-tools/adb` : 'adb'; -const adb = (...args: string[]) => { +export const adb = (...args: string[]) => { const serial = process.env.DETOX_ADB_NAME; const deviceArgs = serial && !serial.includes('*') ? ['-s', serial] : []; return execFileSync(adbPath, [...deviceArgs, ...args], { encoding: 'utf8' }); diff --git a/examples/E2ETestApp/e2e/support/Coverage.ts b/examples/E2ETestApp/e2e/support/Coverage.ts new file mode 100644 index 0000000000..c3496490e7 --- /dev/null +++ b/examples/E2ETestApp/e2e/support/Coverage.ts @@ -0,0 +1,109 @@ +import { execFileSync } from 'child_process'; +import { mkdirSync, writeFileSync } from 'fs'; +import { createServer, IncomingMessage, Server } from 'http'; +import { basename, join } from 'path'; + +import { isSource, repoRoot } from '../coverage/sources'; + +/** + * Collects per-test coverage for the e2e impact analysis when `E2E_COVERAGE_DIR` is set (the + * `test_e2e coverage:true` lane, on an `E2E_COVERAGE=1` build). The app posts the files it ran to this + * server whenever it goes to the background (`src/e2eCoverage.ts`); after each test the suite sends + * it home and writes `///lcov.info`, which `build_test_coverage_map` reads. + * + * Every tracked source file is listed, also those the app never loaded: type-only modules leave the + * bundle, so a change to one can't affect a test and selects none, instead of all of them. + */ +const coverageDir = process.env.E2E_COVERAGE_DIR; +const port = Number(process.env.E2E_COVERAGE_PORT ?? 4570); +const reportTimeout = 10000; + +type Coverage = Record; + +export class CoverageCollector { + private server?: Server; + private latest?: Coverage; + private waiting?: (coverage: Coverage) => void; + private sources: string[] = []; + + static readonly enabled = !!coverageDir; + + /** The collector as the app sees it: the Android emulator reaches the host via 10.0.2.2. */ + get appUrl(): string { + const host = device.getPlatform() === 'android' ? '10.0.2.2' : 'localhost'; + return `http://${host}:${port}/coverage`; + } + + start(): Promise { + this.sources = execFileSync('git', ['-C', repoRoot, 'ls-files'], { encoding: 'utf8' }) + .split('\n') + .filter(isSource); + this.server = createServer((request, response) => { + readBody(request) + .then((body) => { + this.latest = JSON.parse(body) as Coverage; + this.waiting?.(this.latest); + response.writeHead(204).end(); + }) + .catch(() => response.writeHead(400).end()); + }); + return new Promise((resolve, reject) => { + this.server!.once('error', reject); + this.server!.listen(port, resolve); + }); + } + + stop(): Promise { + return new Promise((resolve) => (this.server ? this.server.close(() => resolve()) : resolve())); + } + + reset() { + this.latest = undefined; + } + + /** + * Sends the app home and writes the report it posts. Falls back to the last report of this test + * (a test can end in the background already), and writes nothing when there is none: the test then + * stays out of the map and runs on every pull request until a coverage run collects it. + */ + async collect(testPath: string, testName: string) { + const report = new Promise((resolve) => { + const timer = setTimeout(() => resolve(this.latest), reportTimeout); + this.waiting = (coverage) => { + clearTimeout(timer); + resolve(coverage); + }; + }); + try { + await device.sendToHome(); + } catch (error) { + console.warn(`Could not send the app home to collect the coverage of ${testName}`, error); + } + const coverage = await report; + this.waiting = undefined; + if (!coverage) { + console.warn(`No coverage report for ${testName}`); + return; + } + const dir = join(coverageDir!, basename(testPath, '.ts'), testName); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'lcov.info'), lcov({ ...zero(this.sources), ...coverage })); + } +} + +const readBody = (request: IncomingMessage) => + new Promise((resolve, reject) => { + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk: string) => (body += chunk)); + request.on('end', () => resolve(body)); + request.on('error', reject); + }); + +const zero = (files: string[]): Coverage => Object.fromEntries(files.map((file) => [file, 0])); + +/** File level only: one line per file, hit when the file ran any code. */ +const lcov = (coverage: Coverage) => + Object.entries(coverage) + .map(([file, hit]) => `SF:${file}\nDA:1,${hit > 0 ? 1 : 0}\nend_of_record\n`) + .join(''); diff --git a/examples/E2ETestApp/e2e/support/Device.ts b/examples/E2ETestApp/e2e/support/Device.ts new file mode 100644 index 0000000000..44d3b6afdf --- /dev/null +++ b/examples/E2ETestApp/e2e/support/Device.ts @@ -0,0 +1,37 @@ +import { adb } from './AndroidSystemDialogs'; + +/** + * Device-level controls, like `device.*` in the Android suite's uiautomator helpers. The internet + * connection can only be cut on Android (`svc`); the iOS simulator shares the Mac's network. + */ +export const disableInternetConnection = (): Promise => { + assertAndroid('disableInternetConnection'); + adb('shell', 'svc', 'wifi', 'disable'); + adb('shell', 'svc', 'data', 'disable'); + return Promise.resolve(); +}; + +export const enableInternetConnection = (): Promise => { + assertAndroid('enableInternetConnection'); + adb('shell', 'svc', 'wifi', 'enable'); + adb('shell', 'svc', 'data', 'enable'); + return Promise.resolve(); +}; + +/** + * Detox waits for in-flight requests before every action, so a delayed send would be done before + * the test could see it pending. Stops it from waiting on message sends until the app relaunches. + */ +export const ignoreMessageSendsForSync = () => device.setURLBlacklist(['.*/message(\\?.*)?$']); + +export const goToBackground = () => device.sendToHome(); + +export const goToForeground = () => device.launchApp({ newInstance: false }); + +export const isIOS = () => device.getPlatform() === 'ios'; + +const assertAndroid = (name: string) => { + if (device.getPlatform() !== 'android') { + throw new Error(`${name} is only supported on Android`); + } +}; diff --git a/examples/E2ETestApp/e2e/support/Element.ts b/examples/E2ETestApp/e2e/support/Element.ts index edefaf1414..cbcc549653 100644 --- a/examples/E2ETestApp/e2e/support/Element.ts +++ b/examples/E2ETestApp/e2e/support/Element.ts @@ -30,7 +30,10 @@ export const waitToAppear = async ( * Retries `action` while Detox reports the element as not hittable (e.g. covered by the keyboard * while it animates in). iOS runs without Detox's idle synchronization, which used to absorb that. */ -const retryWhileNotHittable = async (action: () => Promise, timeout = defaultTimeout) => { +export const retryWhileNotHittable = async ( + action: () => Promise, + timeout = defaultTimeout, +) => { const deadline = Date.now() + timeout; for (;;) { try { @@ -50,6 +53,19 @@ export const waitToAppearAndClick = async (selector: Selector, options?: WaitOpt return target; }; +/** + * Taps an element of something that animates in: a menu, a bottom sheet, a swipe action. iOS runs + * without Detox's idle sync, so a tap that lands during the animation is reported done but never + * fires. On iOS this lets the animation settle first; Android's Espresso sync already waits. + */ +export const waitToSettleAndClick = async (selector: Selector, options?: WaitOptions) => { + if (device.getPlatform() === 'ios') { + await waitToAppear(selector, options); + await sleep(1000); + } + return waitToAppearAndClick(selector, options); +}; + export const waitToAppearAndLongPress = async (selector: Selector, options?: WaitOptions) => { const target = await waitToAppear(selector, options); await retryWhileNotHittable(() => target.longPress(), options?.timeout); @@ -103,6 +119,17 @@ export const isDisplayed = async (selector: Selector, index?: number) => { } }; +/** + * Whether any element matching `selector` is on screen. Unlike `isDisplayed` without an index, it + * copes with several matches: Detox iOS also reports the list's off-screen cells (a message text and + * the same text inside a quote further down), where `isDisplayed` throws "multiple elements" and so + * answers `false` even with the text in view. + */ +export const isAnyDisplayed = async (selector: Selector) => + device.getPlatform() === 'ios' + ? (await allAttributes(selector)).some((attributes) => attributes.visible) + : isDisplayed(selector); + /** Polls until `predicate` holds, then returns `true`; returns `false` once `timeout` elapses. */ export const waitUntil = async ( predicate: () => Promise, @@ -162,3 +189,58 @@ export const textOf = async (selector: Selector, index = 0) => { return ''; } }; + +type ElementAttributes = { + enabled?: boolean; + visible?: boolean; + frame?: { height: number; width: number; x: number; y: number }; + label?: string; + text?: string; +}; + +/** Attributes of every element matching `selector`, in Detox's match order; `[]` when none match. */ +export const allAttributes = async (selector: Selector): Promise => { + try { + const attributes = (await element(selector).getAttributes()) as + | ElementAttributes + | { elements: ElementAttributes[] }; + return 'elements' in attributes ? attributes.elements : [attributes]; + } catch { + return []; + } +}; + +/** + * The Detox index of the `fromBottom`-th match counted from the bottom of the screen (0 is the + * lowest), like Android's `waitToAppearBottomUp`. Message lists render the newest message at the + * bottom, but the platforms disagree on the order in which they report matches. + */ +export const indexFromBottom = async ( + selector: Selector, + fromBottom = 0, + timeout = defaultTimeout, +) => { + let matches: ElementAttributes[] = []; + await waitUntil(async () => { + matches = await allAttributes(selector); + return matches.length > 0; + }, timeout); + if (matches.length === 0) { + throw new Error(`No element matches ${JSON.stringify(selector)}`); + } + const order = matches + .map((match, index) => ({ index, y: match.frame?.y ?? 0 })) + .sort((a, b) => b.y - a.y); + return order[Math.min(fromBottom, order.length - 1)].index; +}; + +export const frameOf = async (selector: Selector, index = 0) => { + const matches = await allAttributes(selector); + const frame = matches[index]?.frame; + if (!frame) { + throw new Error(`No frame for ${JSON.stringify(selector)} at index ${index}`); + } + return frame; +}; + +export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/examples/E2ETestApp/e2e/support/IOSSimulator.ts b/examples/E2ETestApp/e2e/support/IOSSimulator.ts new file mode 100644 index 0000000000..4b2cb8e702 --- /dev/null +++ b/examples/E2ETestApp/e2e/support/IOSSimulator.ts @@ -0,0 +1,62 @@ +// Node built-ins, typed locally: the e2e project's types are limited to `detox/globals`. +declare const require: (module: 'child_process') => { + execFileSync: (file: string, args: string[], options: { encoding: 'utf8' }) => string; +}; + +const { execFileSync } = require('child_process'); + +const bundleId = 'io.getstream.reactnative.e2etestapp'; + +/** + * Grants the app photo library and camera access on the iOS simulator up front. + * + * Without it, the first test that opens the attachment picker (attachments, polls) raises the + * system "Access Your Photo Library" prompt. A system alert outlives the app, so every later + * `launchApp` sits behind it with the main queue busy until the hook times out: one prompt fails + * every remaining test. + * + * - Not `launchApp({ permissions })`: Detox then runs `applesimutils --restartSB` on every launch, + * restarting SpringBoard (minutes per test on CI). This runs it once per suite, without the + * restart. + * - Not `simctl privacy grant photos`: it writes an `auth_version` 1 row, which iOS 18+ ignores for + * Photos (it wants version 2), so the app still prompts. applesimutils writes version 2. + * - tccd keeps answering from its cache (the reinstall left "not determined"), so it is stopped; + * launchd starts a fresh one that reads the grants. + */ +export const grantIOSPermissions = () => { + if (device.getPlatform() !== 'ios') { + return; + } + const run = (file: string, args: string[]) => execFileSync(file, args, { encoding: 'utf8' }); + run('applesimutils', [ + '--byId', + device.id, + '--bundle', + bundleId, + '--setPermissions', + 'photos=YES, camera=YES', + ]); + run('xcrun', ['simctl', 'spawn', device.id, 'launchctl', 'stop', 'com.apple.tccd']); +}; + +/** + * The major iOS version of the simulator under test, from its runtime + * (`com.apple.CoreSimulator.SimRuntime.iOS-17-5`); `undefined` off iOS or when it can't be found. + */ +export const iosMajorVersion = (): number | undefined => { + if (device.getPlatform() !== 'ios') { + return undefined; + } + try { + const { devices } = JSON.parse( + execFileSync('xcrun', ['simctl', 'list', 'devices', '-j'], { encoding: 'utf8' }), + ) as { devices: Record }; + const runtime = Object.keys(devices).find((key) => + devices[key].some((simulator) => simulator.udid === device.id), + ); + const major = runtime?.match(/iOS-(\d+)-/)?.[1]; + return major === undefined ? undefined : Number(major); + } catch { + return undefined; + } +}; diff --git a/examples/E2ETestApp/e2e/support/MediaLibrary.ts b/examples/E2ETestApp/e2e/support/MediaLibrary.ts new file mode 100644 index 0000000000..ede60852ca --- /dev/null +++ b/examples/E2ETestApp/e2e/support/MediaLibrary.ts @@ -0,0 +1,42 @@ +import { adb } from './AndroidSystemDialogs'; + +// Node built-ins, typed locally: the e2e project's types are limited to `detox/globals`. +declare const require: (module: 'fs' | 'os' | 'path') => { + join: (...parts: string[]) => string; + tmpdir: () => string; + writeFileSync: (file: string, data: Uint8Array) => void; +}; +declare const Buffer: { from: (data: string, encoding: 'base64') => Uint8Array }; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +/** A 1x1 PNG: enough for the gallery to list it and the composer to attach it. */ +const PIXEL_PNG = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg=='; + +/** + * Puts `count` images into the Android media library, so the attachment picker's gallery shows + * them. The test runner can't add to the iOS simulator's photo library from the host. + */ +export const seedImages = (count = 2) => { + if (device.getPlatform() !== 'android') { + throw new Error('seedImages is only supported on Android'); + } + for (let i = 1; i <= count; i++) { + const name = `e2e-image-${i}.png`; + const local = path.join(os.tmpdir(), name); + fs.writeFileSync(local, Buffer.from(PIXEL_PNG, 'base64')); + const remote = `/sdcard/Pictures/${name}`; + adb('push', local, remote); + adb( + 'shell', + 'am', + 'broadcast', + '-a', + 'android.intent.action.MEDIA_SCANNER_SCAN_FILE', + '-d', + `file://${remote}`, + ); + } +}; diff --git a/examples/E2ETestApp/e2e/support/StreamTestCase.ts b/examples/E2ETestApp/e2e/support/StreamTestCase.ts index 4a4ddb1b9a..1b32d810b6 100644 --- a/examples/E2ETestApp/e2e/support/StreamTestCase.ts +++ b/examples/E2ETestApp/e2e/support/StreamTestCase.ts @@ -1,6 +1,17 @@ -import { afterEach, beforeEach, expect as jestExpect } from '@jest/globals'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect as jestExpect, + it, +} from '@jest/globals'; import { dismissAnrDialog } from './AndroidSystemDialogs'; +import { CoverageCollector } from './Coverage'; +import { enableInternetConnection } from './Device'; +import { iosMajorVersion } from './IOSSimulator'; +import { grantIOSPermissions } from './IOSSimulator'; import { MockServer } from '../mock-server/MockServer'; import { BackendRobot, ParticipantRobot, UserRobot } from '../robots'; @@ -29,10 +40,34 @@ const currentTestName = () => .pop()! .replace(/[^\w-]/g, '_'); -export const setUpStreamTestCase = (): StreamTestCase => { +export type StreamTestCaseOptions = { + /** Launches the JWT test screen (`src/JwtScreen.tsx`) instead of the chat. */ + jwt?: boolean; + /** + * Turns Espresso's idle synchronization off on Android too (iOS never has it). For suites where + * the app keeps a short timer running, e.g. the slow mode countdown or a toast: Detox counts it as + * busy and holds every action until it ends. + */ + synchronization?: boolean; +}; + +export const setUpStreamTestCase = ({ + jwt = false, + synchronization = true, +}: StreamTestCaseOptions = {}): StreamTestCase => { const testCase = new StreamTestCase(); + const coverage = CoverageCollector.enabled ? new CoverageCollector() : undefined; + + // After Detox installed the app for this suite: a reinstall resets its privacy settings. + beforeAll(() => grantIOSPermissions()); + + if (coverage) { + beforeAll(() => coverage.start()); + afterAll(() => coverage.stop()); + } beforeEach(async () => { + coverage?.reset(); await testCase.mockServer.start(currentTestName()); if (device.getPlatform() === 'android') { dismissAnrDialog(); @@ -40,11 +75,13 @@ export const setUpStreamTestCase = (): StreamTestCase => { await device.launchApp({ launchArgs: { MOCK_SERVER_URL: testCase.mockServer.appUrl, + ...(jwt ? { JWT_TEST: true } : {}), + ...(coverage ? { E2E_COVERAGE_URL: coverage.appUrl } : {}), // iOS: Detox's idle tracking can wedge on RN's UIManager shadow queue (its pending-work // counter underflows to 2^64-1), leaving the app "busy" until the test times out. Every // robot action and assert waits explicitly, like the native XCUITest suites, so it runs // without that synchronization. Android's Espresso synchronization stays on. - detoxEnableSynchronization: device.getPlatform() === 'ios' ? 0 : 1, + detoxEnableSynchronization: device.getPlatform() === 'ios' || !synchronization ? 0 : 1, }, newInstance: true, // No `permissions`: the app asks for none, and on iOS Detox applies each one with @@ -55,7 +92,41 @@ export const setUpStreamTestCase = (): StreamTestCase => { afterEach(async () => { await testCase.mockServer.stop(); + if (device.getPlatform() === 'android') { + // A test that failed while offline must not leave the next one without a connection. + await enableInternetConnection(); + } + // After the connection is back, so the app can reach the collector. + await coverage?.collect(jestExpect.getState().testPath!, currentTestName()); }); return testCase; }; + +/** + * `it` on Android, `it.skip` on iOS: for tests that cut the device's internet connection, which only + * Android can do (`support/Device.ts`). The iOS suites toggle the SDK's connection from a debug + * switch in their test apps instead. + */ +export const itOnAndroid: typeof it = ((name: string, fn: () => Promise, timeout?: number) => + device.getPlatform() === 'android' + ? it(name, fn, timeout) + : it.skip(name, fn, timeout)) as typeof it; + +/** + * `it` on iOS, `it.skip` on Android: for tests that take the Android CI emulator down with them + * (RN-513). Name the ticket in a comment above each one. + */ +export const itOnIOS: typeof it = ((name: string, fn: () => Promise, timeout?: number) => + device.getPlatform() === 'ios' ? it(name, fn, timeout) : it.skip(name, fn, timeout)) as typeof it; + +/** + * `it`, except `it.skip` on iOS below 18: for tests that hit an SDK bug only older iOS versions + * show. Name the ticket in a comment above each one. Runs when the version can't be read. + */ +export const itExceptOnOldIOS: typeof it = (( + name: string, + fn: () => Promise, + timeout?: number, +) => + (iosMajorVersion() ?? 18) < 18 ? it.skip(name, fn, timeout) : it(name, fn, timeout)) as typeof it; diff --git a/examples/E2ETestApp/e2e/support/step.ts b/examples/E2ETestApp/e2e/support/step.ts index 6ff6d18688..8e79b8ddcb 100644 --- a/examples/E2ETestApp/e2e/support/step.ts +++ b/examples/E2ETestApp/e2e/support/step.ts @@ -1,14 +1,20 @@ +import { allure } from 'jest-allure2-reporter/api'; + /** - * A GIVEN / WHEN / THEN step, like `step("...") { }` on Android and `GIVEN("...") { }` on iOS. - * A failure is reported with the step it happened in. + * A GIVEN / WHEN / THEN step, like `step("...") { }` on Android and `GIVEN("...") { }` on iOS, + * reported as an Allure step. A failure is reported with the step it happened in. */ -export const step = async (name: string, body: () => PromiseLike) => { - try { - await body(); - } catch (error) { - if (error instanceof Error) { - error.message = `[${name}] ${error.message}`; +export const step = (name: string, body: () => PromiseLike) => + allure.step(name, async () => { + try { + await body(); + } catch (error) { + if (error instanceof Error) { + error.message = `[${name}] ${error.message}`; + } + throw error; } - throw error; - } -}; + }); + +/** Links the running test to its case in Allure TestOps (project 3, `stream-chat-react-native`). */ +export const allureId = (id: number) => allure.label('AS_ID', String(id)); diff --git a/examples/E2ETestApp/e2e/tests/AttachmentsTests.e2e.ts b/examples/E2ETestApp/e2e/tests/AttachmentsTests.e2e.ts new file mode 100644 index 0000000000..24338c5a4c --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/AttachmentsTests.e2e.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, it } from '@jest/globals'; + +import { AttachmentType } from '../mock-server/DataTypes'; +import { disableInternetConnection, enableInternetConnection } from '../support/Device'; +import { seedImages } from '../support/MediaLibrary'; +import { allureId, step } from '../support/step'; +import { itOnAndroid, setUpStreamTestCase } from '../support/StreamTestCase'; + +describe('AttachmentsTests', () => { + const { participantRobot, userRobot } = setUpStreamTestCase(); + + beforeEach(() => { + if (device.getPlatform() === 'android') { + seedImages(2); + } + }); + + it('test_uploadImage', async () => { + allureId(13035); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN user attaches an image', () => userRobot.attachImages(1)); + await step('THEN image is displayed in preview', () => + userRobot.assertMediaAttachmentInPreview(true), + ); + await step('WHEN user sends an image', () => userRobot.tapOnSendButton()); + await step('THEN user can see uploaded image', () => userRobot.assertImage(true)); + }); + + it('test_uploadMultipleImages', async () => { + allureId(13036); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN user attaches multiple images', () => userRobot.attachImages(2)); + await step('THEN images are displayed in preview', () => + userRobot.assertMediaAttachmentInPreview(true, 2), + ); + await step('WHEN user sends the images', () => userRobot.tapOnSendButton()); + await step('THEN user can see uploaded images', () => userRobot.assertImage(true, 2)); + }); + + it('test_deleteImage', async () => { + allureId(13037); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN user sends an image', () => + userRobot.attachImages(1).tapOnSendButton().assertImage(true), + ); + await step('AND user deletes the image', () => userRobot.deleteMessage()); + await step('THEN user can see deleted message', () => + userRobot.assertImage(false).assertDeletedMessage(), + ); + }); + + itOnAndroid('test_imageUploadRecovers_whenUserComesBackOnline', async () => { + allureId(13038); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user goes offline', () => disableInternetConnection()); + await step('WHEN user sends an image while offline', () => + userRobot.attachImages(1).tapOnSendButton(), + ); + await step('AND user comes back online', () => enableInternetConnection()); + await step('THEN the image is uploaded', () => userRobot.assertImage(true)); + }); + + it('test_participantUploadsImage', async () => { + allureId(13039); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN participant uploads an image', () => + participantRobot.uploadAttachment(AttachmentType.IMAGE), + ); + await step('THEN user can see uploaded image', () => userRobot.assertImage(true)); + }); + + it('test_participantUploadsMultipleImages', async () => { + allureId(13040); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN participant uploads multiple images', () => + participantRobot.uploadAttachment(AttachmentType.IMAGE, 2), + ); + await step('THEN user can see uploaded images', () => userRobot.assertImage(true, 2)); + }); + + it('test_participantUploadsVideo', async () => { + allureId(13041); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN participant uploads a video', () => + participantRobot.uploadAttachment(AttachmentType.VIDEO), + ); + await step('THEN user can see uploaded video', () => userRobot.assertVideo(true)); + }); + + it('test_participantUploadsFile', async () => { + allureId(13042); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN participant uploads a file', () => + participantRobot.uploadAttachment(AttachmentType.FILE), + ); + await step('THEN user can see uploaded file', () => userRobot.assertFile(true)); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/AuthTests.e2e.ts b/examples/E2ETestApp/e2e/tests/AuthTests.e2e.ts new file mode 100644 index 0000000000..19abb8294e --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/AuthTests.e2e.ts @@ -0,0 +1,128 @@ +import { describe, it } from '@jest/globals'; + +import { + disableInternetConnection, + enableInternetConnection, + goToBackground, + goToForeground, +} from '../support/Device'; +import { allureId, step } from '../support/step'; +import { setUpStreamTestCase } from '../support/StreamTestCase'; + +/** Token refresh flows, on the JWT test screen (a token provider backed by the mock's `/jwt/get`). */ +describe('AuthTests', () => { + const { backendRobot, userRobot } = setUpStreamTestCase({ jwt: true }); + + const userLogsIn = () => + step('GIVEN user logs in', () => userRobot.tapOnConnectionButton().assertConnectionStatus()); + + // https://linear.app/stream/issue/RN-483 + it.skip('test_tokenInvalidatesBeforeUserLogsIn', async () => { + allureId(13043); + await step('GIVEN token is invalid', () => backendRobot.invalidateToken()); + await step('WHEN user tries to log in', () => userRobot.tapOnConnectionButton()); + await step('THEN app requests a token refresh', () => userRobot.assertConnectionStatus()); + }); + + // https://linear.app/stream/issue/RN-483 + it.skip('test_tokenInvalidatesAfterUserLogsIn', async () => { + allureId(13044); + await userLogsIn(); + await step('WHEN token invalidates', () => backendRobot.invalidateToken(10)); + await step('THEN app requests a token refresh', () => + userRobot.assertTokenHasExpired().assertConnectionStatus(), + ); + }); + + // https://linear.app/stream/issue/RN-483 + it.skip('test_tokenDateInvalidatesBeforeUserLogsIn', async () => { + allureId(13045); + await step('GIVEN token date is invalid', () => backendRobot.invalidateTokenDate()); + await step('WHEN user tries to log in', () => userRobot.tapOnConnectionButton()); + await step('THEN app requests a token refresh', () => userRobot.assertConnectionStatus()); + }); + + // https://linear.app/stream/issue/RN-483 + it.skip('test_tokenDateInvalidatesAfterUserLogsIn', async () => { + allureId(13046); + await userLogsIn(); + await step('WHEN token date invalidates', () => backendRobot.invalidateTokenDate(10)); + await step('THEN app requests a token refresh', () => + userRobot.assertTokenHasExpired().assertConnectionStatus(), + ); + }); + + // https://linear.app/stream/issue/RN-483 + it.skip('test_tokenSignatureInvalidatesBeforeUserLogsIn', async () => { + allureId(13047); + await step('GIVEN token signature is invalid', () => backendRobot.invalidateTokenSignature()); + await step('WHEN user tries to log in', () => userRobot.tapOnConnectionButton()); + await step('THEN app requests a token refresh', () => userRobot.assertConnectionStatus()); + }); + + // https://linear.app/stream/issue/RN-483 + it.skip('test_tokenSignatureInvalidatesAfterUserLogsIn', async () => { + allureId(13048); + await userLogsIn(); + await step('WHEN token signature invalidates', () => backendRobot.invalidateTokenSignature(10)); + await step('THEN app requests a token refresh', () => + userRobot.assertTokenHasExpired().assertConnectionStatus(), + ); + }); + + it('test_tokenExpiresBeforeUserLogsIn', async () => { + allureId(13049); + await step('GIVEN token expires', () => backendRobot.revokeToken()); + await step('WHEN user tries to log in', () => userRobot.tapOnConnectionButton()); + await step('THEN app requests a token refresh', () => userRobot.assertConnectionStatus()); + }); + + // https://linear.app/stream/issue/RN-483 + it.skip('test_tokenExpiresAfterUserLoggedIn', async () => { + allureId(13050); + await userLogsIn(); + // The mock's valid token expires on its own 5s after it is issued. + await step('THEN app requests a token refresh after the token expires', () => + userRobot.assertTokenHasExpired().assertConnectionStatus(), + ); + }); + + // https://linear.app/stream/issue/RN-483 + it.skip('test_tokenExpiresWhenUserIsInBackground', async () => { + allureId(13051); + await userLogsIn(); + await step('AND user goes to background', () => goToBackground()); + await step('AND token expires', () => userRobot.waitForTokenToExpire()); + await step('WHEN user comes back to foreground', () => goToForeground()); + await step('THEN app requests a token refresh', () => + userRobot.assertTokenHasExpired().assertConnectionStatus(), + ); + }); + + // https://linear.app/stream/issue/RN-483 + it.skip('test_tokenExpiresWhileUserIsOffline', async () => { + allureId(13052); + await userLogsIn(); + await step('AND user goes offline', () => disableInternetConnection()); + await step('WHEN token expires', () => userRobot.waitForTokenToExpire()); + await step('AND user comes back online', () => enableInternetConnection()); + await step('THEN app requests a token refresh', () => + userRobot.assertTokenHasExpired().assertConnectionStatus(), + ); + }); + + // https://linear.app/stream/issue/RN-483 + it.skip('test_tokenGenerationFails', async () => { + allureId(13053); + await userLogsIn(); + await step('AND JWT generation breaks on server side', () => + backendRobot.breakTokenGeneration(), + ); + await step('WHEN app tries to request a token refresh', () => + userRobot.assertTokenHasExpired(), + ); + await step('THEN app requests a token refresh again once generation recovers', () => + userRobot.assertConnectionStatus(), + ); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/ChannelActionsTests.e2e.ts b/examples/E2ETestApp/e2e/tests/ChannelActionsTests.e2e.ts new file mode 100644 index 0000000000..66b4b3540d --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/ChannelActionsTests.e2e.ts @@ -0,0 +1,91 @@ +import { describe, it } from '@jest/globals'; + +import { allureId, step } from '../support/step'; +import { setUpStreamTestCase } from '../support/StreamTestCase'; + +/** + * The channel actions of the channel list. The tests after the first two open the sheet from the + * "more" swipe action, as RN has no long-press menu yet (https://linear.app/stream/issue/RN-499). + */ +describe('ChannelActionsTests', () => { + const { userRobot } = setUpStreamTestCase(); + + const login = () => + step('GIVEN user logs in', () => userRobot.login().waitForChannelListToLoad()); + + // https://linear.app/stream/issue/RN-499 + it.skip('test_channelActionsSheetIsShown_whenUserLongPressesTheChannel', async () => { + allureId(13321); + await login(); + await step('WHEN user long presses the channel', () => userRobot.longPressChannel()); + await step('THEN the channel actions sheet is shown', () => + userRobot.assertChannelActionsSheetForGroupChannel(), + ); + }); + + it('test_channelActionsSheetIsShown_whenUserSwipesTheChannel', async () => { + allureId(13054); + await login(); + await step('WHEN user swipes the channel and taps on the more action', () => + userRobot.swipeChannel().tapOnMoreSwipeAction(), + ); + await step('THEN the channel actions sheet is shown', () => + userRobot.assertChannelActionsSheetForGroupChannel(), + ); + }); + + it('test_userOpensChannelInfoFromTheChannelActionsSheet', async () => { + allureId(13055); + await login(); + await step('AND user opens the channel actions sheet', () => userRobot.openChannelMenu()); + await step('WHEN user taps on the view info option', () => userRobot.tapOnViewChannelInfo()); + await step('THEN the group channel info screen is shown', () => + userRobot.assertGroupChannelInfoScreen(), + ); + }); + + it('test_userLeavesGroupChannel', async () => { + allureId(13056); + await login(); + await step('AND user opens the channel actions sheet', () => userRobot.openChannelMenu()); + await step('WHEN user leaves the group', () => + userRobot.tapOnLeaveGroup().confirmChannelAction('Leave'), + ); + await step('THEN the channel is gone from the channel list', () => + userRobot.assertChannelListIsEmpty(), + ); + }); + + it('test_userDeletesGroupChannel', async () => { + allureId(13057); + await login(); + await step('AND user opens the channel actions sheet', () => userRobot.openChannelMenu()); + await step('WHEN user deletes the group', () => + userRobot.tapOnDeleteGroup().confirmChannelAction('Delete'), + ); + await step('THEN the channel is gone from the channel list', () => + userRobot.assertChannelListIsEmpty(), + ); + }); + + it('test_userMutesChannelFromTheSwipeAction', async () => { + allureId(13058); + await login(); + await step('WHEN user swipes the channel and taps on the mute action', () => + userRobot.swipeChannel().tapOnMuteSwipeAction(), + ); + await step('THEN the channel shows the muted icon', () => userRobot.assertChannelIsMuted(true)); + }); + + it('test_userUnmutesChannelFromTheSwipeAction', async () => { + allureId(13059); + await login(); + await step('AND user mutes the channel from the swipe action', () => + userRobot.swipeChannel().tapOnMuteSwipeAction().assertChannelIsMuted(true), + ); + await step('WHEN user swipes the channel and taps on the unmute action', () => + userRobot.swipeChannel().tapOnUnmuteSwipeAction(), + ); + await step('THEN the channel shows no muted icon', () => userRobot.assertChannelIsMuted(false)); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/ChannelListTests.e2e.ts b/examples/E2ETestApp/e2e/tests/ChannelListTests.e2e.ts index 9a742a8e80..8adcd9865c 100644 --- a/examples/E2ETestApp/e2e/tests/ChannelListTests.e2e.ts +++ b/examples/E2ETestApp/e2e/tests/ChannelListTests.e2e.ts @@ -1,13 +1,19 @@ import { describe, it } from '@jest/globals'; -import { step } from '../support/step'; -import { setUpStreamTestCase } from '../support/StreamTestCase'; +import { MessageDeliveryStatus } from '../mock-server/DataTypes'; +import { disableInternetConnection, enableInternetConnection } from '../support/Device'; +import { sleep } from '../support/Element'; +import { allureId, step } from '../support/step'; +import { itOnAndroid, setUpStreamTestCase } from '../support/StreamTestCase'; describe('ChannelListTests', () => { const { backendRobot, participantRobot, userRobot } = setUpStreamTestCase(); const sampleText = 'Test'; + const noMessages = 'No messages yet'; + const deletedMessage = 'Message deleted'; it('test_channelListShowsGeneratedChannels', async () => { + allureId(13060); await step('GIVEN backend has 3 channels', () => backendRobot.generateChannels({ channelsCount: 3, channelNames: ['Alpha', 'Beta', 'Gamma'] }), ); @@ -17,18 +23,35 @@ describe('ChannelListTests', () => { ); }); + it('test_paginationOnChannelList', async () => { + allureId(13061); + const channelsCount = 30; + await step('WHEN user opens the channel list', async () => { + await backendRobot.generateChannels({ channelsCount }); + await userRobot.login().waitForChannelListToLoad(); + }); + await step('THEN user makes sure that all channels are loaded', () => + userRobot.assertChannelListPagination(channelsCount), + ); + }); + it('test_channelPreviewUpdates_whenParticipantSendsMessage', async () => { + allureId(13062); await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); await step('WHEN participant sends a message', () => participantRobot.sendMessage(sampleText)); await step('AND user goes back to the channel list', () => userRobot.assertMessage(sampleText).moveToChannelListFromMessageList(), ); await step('THEN user observes the new message in preview', () => - userRobot.assertMessageInChannelPreview(sampleText, false).assertChannelAvatar(), + userRobot + .assertMessageInChannelPreview(sampleText, false) + .assertMessageDeliveryStatusInPreview(MessageDeliveryStatus.NIL) + .assertChannelAvatar(), ); }); it('test_channelPreviewUpdates_whenUserSendsMessage', async () => { + allureId(13063); await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); await step('WHEN user sends a message', () => userRobot.sendMessage(sampleText).assertMessage(sampleText), @@ -37,7 +60,319 @@ describe('ChannelListTests', () => { userRobot.moveToChannelListFromMessageList(), ); await step('THEN user observes the new message in preview', () => - userRobot.assertMessageInChannelPreview(sampleText, true), + userRobot + .assertMessageInChannelPreview(sampleText, true) + .assertMessageDeliveryStatusInPreview(MessageDeliveryStatus.SENT) + .assertChannelAvatar(), + ); + await step('WHEN participant reads the message', () => participantRobot.readMessage()); + await step('THEN user observes the read status in preview', () => + userRobot.assertMessageDeliveryStatusInPreview(MessageDeliveryStatus.READ), + ); + }); + + itOnAndroid( + 'test_channelPreviewUpdates_whenUserIsOfflineAndParticipantSendsMessage', + async () => { + allureId(13064); + const delay = 3; + await step('GIVEN user opens a channel list', () => + userRobot.login().waitForChannelListToLoad(), + ); + await step('AND user goes offline while participant sends a message', async () => { + // Sent with a delay, because the mock server can't be reached while offline. + await participantRobot.sendMessage(sampleText, delay); + await disableInternetConnection(); + await sleep((delay + 1) * 1000); + }); + await step('AND user goes back online', () => enableInternetConnection()); + await step('THEN user observes the new message in preview', () => + userRobot.assertMessageInChannelPreview(sampleText, false), + ); + }, + ); + + itOnAndroid('test_participantMessageShownInChannelPreview_whenReturningFromOffline', async () => { + allureId(13065); + await step('GIVEN user opens the channel and goes back', () => + userRobot.login().openChannel().moveToChannelListFromMessageList(), + ); + await step('AND user becomes offline while participant sends a new message', async () => { + await participantRobot.sendMessage(sampleText, 3); + await disableInternetConnection(); + await sleep(4000); + }); + await step('AND user becomes online', () => enableInternetConnection()); + await step("THEN list shows a preview of participant's message", () => + userRobot.assertMessageInChannelPreview(sampleText, false), + ); + }); + + itOnAndroid( + 'test_channelListIsReordered_whenParticipantSendsMessageInOldestChannelWhileUserIsOffline', + async () => { + allureId(13066); + const oldestChannel = '3'; + await step('GIVEN user opens channel list', async () => { + await backendRobot.generateChannels({ channelsCount: 3 }); + await userRobot.login().assertChannelName('1', 0).assertChannelName(oldestChannel, 2); + }); + await step('AND user goes offline', () => disableInternetConnection()); + await step('WHEN participant sends a new message in the oldest channel', async () => { + await participantRobot.sendMessageInChannelNamed(sampleText, oldestChannel); + await sleep(2000); + }); + await step('AND user comes back online', () => enableInternetConnection()); + await step('THEN channel list reorders accordingly', () => + userRobot + .assertChannelName(oldestChannel, 0) + .assertMessageInChannelPreview(sampleText, false), + ); + }, + ); + + // https://linear.app/stream/issue/RN-479 + it.skip('test_errorMessageIsNotShownInChannelPreview', async () => { + allureId(13067); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends a message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + await step('WHEN user sends a message with invalid command', () => + userRobot.sendMessage('/test').assertInvalidCommandMessage('test'), + ); + await step('AND user goes back to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('THEN the error message is not shown in preview', () => + userRobot.assertMessageInChannelPreview(sampleText, false).assertMessagePreviewTimestamp(), + ); + }); + + // https://linear.app/stream/issue/RN-479 + it.skip('test_errorMessageIsNotShownInChannelPreview_whenErrorMessageIsReceived', async () => { + allureId(13068); + const message = 'message'; + const invalidCommand = 'invalid command'; + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user sends a message with invalid command', () => + userRobot + .sendMessage(message) + .assertMessage(message) + .sendMessage(`/${invalidCommand}`) + .assertInvalidCommandMessage(invalidCommand), + ); + await step('WHEN user goes back to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('THEN the error message is not shown in preview', () => + userRobot.assertMessageInChannelPreview(message, true), + ); + await step('AND last message timestamp is shown', () => + userRobot.assertMessagePreviewTimestamp(true), + ); + }); + + it('test_channelPreviewShowsNoMessages_whenChannelIsEmpty', async () => { + allureId(13069); + await step('WHEN user opens channel list', () => userRobot.login().waitForChannelListToLoad()); + await step('THEN the channel preview shows the empty state', () => + userRobot.assertMessageInChannelPreview(noMessages), + ); + await step('AND the message timestamp is hidden', () => + userRobot.assertMessagePreviewTimestamp(false), + ); + }); + + // https://linear.app/stream/issue/RN-494 + it.skip('test_channelPreviewShowsMessageDeleted_whenTheOnlyMessageInChannelIsDeleted', async () => { + allureId(13070); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends a message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + await step('AND participant deletes the message', async () => { + await participantRobot.deleteMessage(); + await userRobot.assertDeletedMessage(sampleText); + }); + await step('WHEN user goes back to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('THEN the channel preview shows the deleted message placeholder', () => + userRobot.assertMessageInChannelPreview(deletedMessage, false), + ); + await step('AND the message timestamp is shown', () => + userRobot.assertMessagePreviewTimestamp(true), + ); + }); + + // https://linear.app/stream/issue/RN-494 + it.skip('test_channelPreviewShowsMessageDeleted_whenLastMessageIsDeleted', async () => { + allureId(13071); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends 2 messages', async () => { + await participantRobot.sendMessage('Old').sendMessage('New'); + await userRobot.assertMessage('New'); + }); + await step('AND participant deletes the last message', async () => { + await participantRobot.deleteMessage(); + await userRobot.assertDeletedMessage('New'); + }); + await step('WHEN user goes back to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('THEN the channel preview shows the deleted message placeholder', () => + userRobot.assertMessageInChannelPreview(deletedMessage, false), + ); + await step('AND the message timestamp is shown', () => + userRobot.assertMessagePreviewTimestamp(true), + ); + }); + + // https://linear.app/stream/issue/RN-494 + it.skip('test_channelPreviewShowsDeletedMessage', async () => { + allureId(13072); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user sends 2 messages', () => + userRobot + .sendMessage('Previous message') + .assertMessage('Previous message') + .sendMessage('Last message') + .assertMessage('Last message'), + ); + await step('AND user deletes the last message', () => + userRobot.deleteMessage().assertDeletedMessage('Last message'), + ); + await step('WHEN user goes back to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('THEN the channel preview shows deleted message', () => + userRobot.assertMessageInChannelPreview(deletedMessage, true), + ); + }); + + it('test_channelPreviewIsNotUpdated_whenThreadReplyIsSent', async () => { + allureId(13073); + const channelMessage = 'Channel message'; + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends a message', async () => { + await participantRobot.sendMessage(channelMessage); + await userRobot.assertMessage(channelMessage); + }); + await step('AND participant adds thread reply to this message', async () => { + await participantRobot.sendMessageInThread('Thread reply'); + await userRobot.assertThreadReplyLabelOnParentMessage(); + }); + await step('WHEN user goes back to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('THEN the channel preview shows the channel message preview', () => + userRobot.assertMessageInChannelPreview(channelMessage, false), + ); + await step('AND the message timestamp is shown', () => + userRobot.assertMessagePreviewTimestamp(true), + ); + }); + + it('test_channelPreviewIsUpdated_whenThreadReplyIsSentAlsoInTheChannel', async () => { + allureId(13074); + const threadReply = 'Thread reply'; + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user sends a message', () => + userRobot.sendMessage('Channel message').assertMessage('Channel message'), + ); + await step('AND user adds thread reply to this message also in the channel', () => + userRobot.openThread().sendMessageInThread(threadReply, true).assertMessage(threadReply), + ); + await step('WHEN user goes back to the channel list', () => + userRobot.moveToChannelListFromThread(), + ); + await step('THEN the channel preview shows the thread message preview', () => + userRobot.assertMessageInChannelPreview(threadReply, true), + ); + await step('AND the message timestamp is shown', () => + userRobot.assertMessagePreviewTimestamp(true), + ); + }); + + it('test_channelPreviewIsUpdated_whenPreviewMessageIsEdited', async () => { + allureId(13075); + const editedMessage = 'edited message'; + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends a message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + await step('WHEN participant edits the message', async () => { + await participantRobot.editMessage(editedMessage); + await userRobot.assertMessage(editedMessage); + }); + await step('AND user goes back to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('THEN the channel preview shows edited message', () => + userRobot.assertMessageInChannelPreview(editedMessage, false), + ); + await step('AND the message timestamp is shown', () => + userRobot.assertMessagePreviewTimestamp(true), + ); + }); + + // https://linear.app/stream/issue/RN-495 + it.skip('test_messageList_and_channelPreview_AreUpdatedWhenChannelTruncatedWithMessage', async () => { + allureId(13076); + const message = 'Channel truncated'; + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 42 }); + await userRobot.login().openChannel().assertMessage('42'); + }); + await step('WHEN the channel is truncated with a system message', () => + backendRobot.truncateChannel(true), + ); + await step('THEN user observes only the system message', () => + userRobot + .assertSystemMessage(message) + .assertMessageCount(0) + .assertScrollToBottomButton(false) + .assertScrollToBottomButtonUnreadCount(0), + ); + await step('WHEN user goes back to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('THEN the channel preview shows the system message', () => + userRobot.assertMessageInChannelPreview(message, true), + ); + await step('AND the message timestamp is shown', () => + userRobot.assertMessagePreviewTimestamp(true), + ); + }); + + it('test_messageList_and_channelPreview_AreUpdatedWhenChannelTruncatedWithoutMessage', async () => { + allureId(13077); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 42 }); + await userRobot.login().openChannel().assertMessage('42'); + }); + await step('WHEN the channel is truncated without a system message', () => + backendRobot.truncateChannel(false), + ); + await step('THEN user observes an empty message list', () => + userRobot + .assertMessageCount(0) + .assertSystemMessage('Channel truncated', false) + .assertScrollToBottomButton(false) + .assertScrollToBottomButtonUnreadCount(0), + ); + await step('WHEN user goes back to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('THEN the channel preview is empty', () => + userRobot.assertMessageInChannelPreview(noMessages), + ); + await step('AND the message timestamp is hidden', () => + userRobot.assertMessagePreviewTimestamp(false), ); }); }); diff --git a/examples/E2ETestApp/e2e/tests/DraftMessagesTests.e2e.ts b/examples/E2ETestApp/e2e/tests/DraftMessagesTests.e2e.ts new file mode 100644 index 0000000000..e6913674e2 --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/DraftMessagesTests.e2e.ts @@ -0,0 +1,85 @@ +import { describe, it } from '@jest/globals'; + +import { disableInternetConnection } from '../support/Device'; +import { allureId, step } from '../support/step'; +import { itOnAndroid, setUpStreamTestCase } from '../support/StreamTestCase'; + +describe('DraftMessagesTests', () => { + const { backendRobot, userRobot } = setUpStreamTestCase(); + const firstMessage = 'message'; + const draftMessage = 'alright'; + + it('test_updateChannelDraftMessage', async () => { + allureId(13078); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user sends a message', () => + userRobot.sendMessage(firstMessage).assertMessage(firstMessage), + ); + await step('WHEN user inputs some text in the composer', () => + userRobot.typeText(draftMessage), + ); + await step('AND user leaves the channel', () => userRobot.moveToChannelListFromMessageList()); + await step('THEN the draft message is in preview', () => + userRobot.assertDraftInChannelPreview(draftMessage), + ); + await step('WHEN user comes back to the channel', () => userRobot.openChannel()); + await step('THEN the draft message is in the composer', () => + userRobot.assertComposerText(draftMessage), + ); + await step('WHEN user deletes the draft message', () => userRobot.clearComposer()); + await step('AND user leaves the channel', () => userRobot.moveToChannelListFromMessageList()); + await step('THEN there is no draft message in preview', () => + userRobot.assertMessageInChannelPreview(firstMessage, true), + ); + await step('WHEN user comes back to the channel', () => userRobot.openChannel()); + await step('THEN there is no draft message in the composer', () => + userRobot.assertComposerText(''), + ); + }); + + it('test_updateThreadDraftMessage', async () => { + allureId(13079); + await step('GIVEN user opens a thread', async () => { + await backendRobot.generateChannels({ + channelsCount: 1, + messagesCount: 1, + repliesCount: 1, + repliesText: firstMessage, + }); + await userRobot.login().openChannel().openThread(0, false); + }); + await step('WHEN user inputs some text in the composer', () => + userRobot.typeText(draftMessage), + ); + await step('AND user leaves the thread and comes back to it', () => + userRobot.tapOnBackButton().openThread(0, false), + ); + await step('THEN the draft message is in the composer', () => + userRobot.assertComposerText(draftMessage), + ); + await step('WHEN user deletes the draft message', () => userRobot.clearComposer()); + await step('AND user leaves the thread and comes back to it', () => + userRobot.tapOnBackButton().openThread(0, false), + ); + await step('THEN there is no draft message in the composer', () => + userRobot.assertComposerText(''), + ); + }); + + itOnAndroid('test_updateDraftMessageBeingOffline', async () => { + allureId(13080); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user becomes offline', () => disableInternetConnection()); + await step('WHEN user inputs some text in the composer', () => + userRobot.typeText(draftMessage), + ); + await step('AND user leaves the channel', () => userRobot.moveToChannelListFromMessageList()); + await step('THEN the draft message is in preview', () => + userRobot.assertDraftInChannelPreview(draftMessage), + ); + await step('WHEN user comes back to the channel', () => userRobot.openChannel()); + await step('THEN the draft message is in the composer', () => + userRobot.assertComposerText(draftMessage), + ); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/GiphyTests.e2e.ts b/examples/E2ETestApp/e2e/tests/GiphyTests.e2e.ts new file mode 100644 index 0000000000..4a6eaec2b6 --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/GiphyTests.e2e.ts @@ -0,0 +1,157 @@ +import { describe, it } from '@jest/globals'; + +import { MessageDeliveryStatus } from '../mock-server/DataTypes'; +import { allureId, step } from '../support/step'; +import { itOnIOS, setUpStreamTestCase } from '../support/StreamTestCase'; + +describe('GiphyTests', () => { + const { backendRobot, participantRobot, userRobot } = setUpStreamTestCase(); + + // https://linear.app/stream/issue/RN-513 + itOnIOS('test_userObservesAnimatedGiphy_whenUserAddsGiphyMessage', async () => { + allureId(13081); + await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); + await step('WHEN user sends a giphy using giphy command', () => userRobot.uploadGiphy()); + await step('THEN user observes the animated gif', () => + userRobot.assertGiphyImage().assertGiphyButtons(false), + ); + }); + + // https://linear.app/stream/issue/RN-513 + itOnIOS('test_userObservesAnimatedGiphy_whenParticipantAddsGiphyMessage', async () => { + allureId(13082); + await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); + await step('WHEN participant sends a giphy', () => participantRobot.uploadGiphy()); + await step('THEN user observes the animated gif', () => userRobot.assertGiphyImage()); + }); + + // https://linear.app/stream/issue/RN-513 + itOnIOS('test_userObservesAnimatedGiphy_whenUserAddsGiphyMessageInThread', async () => { + allureId(13083); + await step('GIVEN user opens a channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('WHEN user runs a giphy command in thread', () => + userRobot.openThread().uploadGiphy(), + ); + await step('THEN user observes the animated gif in thread', () => userRobot.assertGiphyImage()); + }); + + // https://linear.app/stream/issue/RN-513 + itOnIOS('test_userObservesAnimatedGiphy_whenParticipantAddsGiphyMessageInThread', async () => { + allureId(13084); + await step('GIVEN user opens a channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('WHEN participant sends a giphy', async () => { + await participantRobot.uploadGiphyInThread(); + await userRobot.assertThreadReplyLabelOnParentMessage(); + }); + await step('THEN user observes the animated gif in thread', () => + userRobot.openThread(0, false).assertGiphyImage(), + ); + }); + + // https://linear.app/stream/issue/RN-513 + itOnIOS('test_userObservesAnimatedGiphy_afterAddingGiphyThroughComposerMenu', async () => { + allureId(13085); + await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); + await step('WHEN user sends a giphy using the command suggestions', () => + userRobot.uploadGiphy({ useComposerCommand: true }), + ); + await step('THEN user observes the animated gif', () => userRobot.assertGiphyImage()); + }); + + // https://linear.app/stream/issue/RN-513 + itOnIOS('test_userObservesAnimatedGiphy_whenUserAddsGiphyMessage_AfterShuffling', async () => { + allureId(13086); + await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); + await step('WHEN user shuffles a giphy', () => + userRobot.uploadGiphy({ send: false }).tapOnShuffleGiphyButton(), + ); + await step('THEN the giphy is shuffled but not sent', () => + userRobot.assertGiphyImage(true).assertGiphyButtons(true), + ); + await step('WHEN user sends a giphy', () => userRobot.tapOnSendGiphyButton()); + await step('THEN user observes the animated gif', () => + userRobot.assertGiphyImage(true).assertGiphyButtons(false), + ); + }); + + // https://linear.app/stream/issue/RN-513 + itOnIOS('test_messageIsNotSent_whenUserCancelsEphemeralMessage', async () => { + allureId(13087); + await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); + await step('WHEN user cancels a giphy', () => + userRobot.uploadGiphy({ send: false }).assertGiphyButtons(true).tapOnCancelGiphyButton(), + ); + await step('THEN user does not observe the animated gif', () => + userRobot.assertGiphyImage(false).assertGiphyButtons(false), + ); + }); + + // https://linear.app/stream/issue/RN-496 + it.skip('test_messageIsNotSent_whenUserSendsInvalidCommand', async () => { + allureId(13088); + const invalidCommand = 'invalid command'; + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('WHEN user sends a message with invalid command', () => + userRobot.sendMessage(`/${invalidCommand}`), + ); + await step('THEN user observes error message', () => + userRobot.assertInvalidCommandMessage(invalidCommand), + ); + await step('AND the previous message has timestamp and delivery status shown', () => + userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.SENT).assertMessageTimestamps(1), + ); + }); + + // https://linear.app/stream/issue/RN-479 + it.skip('test_channelListNotModified_whenEphemeralMessageShown', async () => { + allureId(13089); + await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); + await step('WHEN user runs a giphy command', () => + userRobot.uploadGiphy({ send: false }).assertGiphyButtons(true), + ); + await step('AND user goes back to channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('THEN message is not added to the channel list', () => + userRobot.assertMessageInChannelPreview('No messages yet'), + ); + }); + + // https://linear.app/stream/issue/RN-513 + itOnIOS('test_deliveryStatusHidden_whenEphemeralMessageShown', async () => { + allureId(13090); + await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); + await step('WHEN user runs a giphy command', () => + userRobot.uploadGiphy({ send: false }).assertGiphyButtons(true), + ); + await step('THEN delivery status is hidden for ephemeral messages', () => + userRobot + .assertMessageDeliveryStatus(MessageDeliveryStatus.NIL) + .assertSystemMessage('Only visible to you'), + ); + }); + + // https://linear.app/stream/issue/RN-513 + itOnIOS('test_deliveryStatusHidden_whenEphemeralMessageShownInThread', async () => { + allureId(13091); + await step('GIVEN user opens a channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('WHEN user runs a giphy command in thread', () => + userRobot.openThread().uploadGiphy({ send: false }).assertGiphyButtons(true), + ); + await step('THEN delivery status is hidden for ephemeral messages', () => + userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.NIL), + ); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/HyperLinksTests.e2e.ts b/examples/E2ETestApp/e2e/tests/HyperLinksTests.e2e.ts new file mode 100644 index 0000000000..09a499ffcc --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/HyperLinksTests.e2e.ts @@ -0,0 +1,88 @@ +import { describe, it } from '@jest/globals'; + +import { allureId, step } from '../support/step'; +import { setUpStreamTestCase } from '../support/StreamTestCase'; + +describe('HyperLinksTests', () => { + const { participantRobot, userRobot } = setUpStreamTestCase(); + const giphyLink = 'Look at https://giphy.com/gifs/test-gw3IWyGkC0rsazTi'; + const unsplashLink = 'Look at https://unsplash.com/photos/1_2d3MRbI9c'; + const youtubeLink = 'Look at https://youtube.com/watch?v=xOX7MsrbaPY'; + + const userSendsLink = async (name: string, link: string, withPreview: boolean) => { + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step(`WHEN user types a ${name} url`, () => userRobot.typeText(link)); + await step('THEN user observes a link preview', () => + userRobot.assertLinkPreviewInComposer(true), + ); + if (!withPreview) { + await step('WHEN user cancels the link preview', () => + userRobot.tapOnLinkPreviewCancelButton(), + ); + await step('THEN link preview disappears', () => + userRobot.assertLinkPreviewInComposer(false), + ); + } + await step('WHEN user taps on the send button', () => userRobot.tapOnSendButton()); + await step( + `THEN user observes a message ${withPreview ? 'with' : 'without'} link preview`, + () => userRobot.assertMessageContaining(link).assertLinkPreviewInMessageList(withPreview), + ); + }; + + const participantSendsLink = async (name: string, link: string) => { + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step(`WHEN participant sends a ${name} url`, () => participantRobot.sendMessage(link)); + await step('THEN user observes a message with link preview', () => + userRobot.assertMessageContaining(link).assertLinkPreviewInMessageList(true), + ); + }; + + it('test_unsplashLinkPreview', async () => { + allureId(13092); + await userSendsLink('unsplash', unsplashLink, true); + }); + + // https://linear.app/stream/issue/RN-482 + it.skip('test_unsplashLinkWithoutPreview', async () => { + allureId(13093); + await userSendsLink('unsplash', unsplashLink, false); + }); + + it('test_youtubeLinkPreview', async () => { + allureId(13094); + await userSendsLink('youtube', youtubeLink, true); + }); + + // https://linear.app/stream/issue/RN-482 + it.skip('test_youtubeLinkWithoutPreview', async () => { + allureId(13095); + await userSendsLink('youtube', youtubeLink, false); + }); + + it('test_giphyLinkPreview', async () => { + allureId(13096); + await userSendsLink('giphy', giphyLink, true); + }); + + // https://linear.app/stream/issue/RN-482 + it.skip('test_giphyLinkWithoutPreview', async () => { + allureId(13097); + await userSendsLink('giphy', giphyLink, false); + }); + + it('test_participantSendsLinkToUnsplash', async () => { + allureId(13098); + await participantSendsLink('unsplash', unsplashLink); + }); + + it('test_participantSendsLinkToYoutube', async () => { + allureId(13099); + await participantSendsLink('youtube', youtubeLink); + }); + + it('test_participantSendsLinkToGiphy', async () => { + allureId(13100); + await participantSendsLink('giphy', giphyLink); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/MessageActionsTests.e2e.ts b/examples/E2ETestApp/e2e/tests/MessageActionsTests.e2e.ts new file mode 100644 index 0000000000..7cf38622e2 --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/MessageActionsTests.e2e.ts @@ -0,0 +1,23 @@ +import { describe, it } from '@jest/globals'; + +import { allureId, step } from '../support/step'; +import { setUpStreamTestCase } from '../support/StreamTestCase'; + +describe('MessageActionsTests', () => { + const { participantRobot, userRobot } = setUpStreamTestCase({ synchronization: false }); + const sampleText = 'Test'; + + it('test_userCopiesMessage', async () => { + allureId(13101); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN participant sends the message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + await step('AND user copies the message', () => userRobot.copyMessage()); + // The test runner can't read the device clipboard, so the copy is checked by pasting it. + await step('THEN the message text is in the clipboard', () => + userRobot.pasteIntoComposer().assertComposerText(sampleText), + ); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/MessageDeliveryStatusTests.e2e.ts b/examples/E2ETestApp/e2e/tests/MessageDeliveryStatusTests.e2e.ts new file mode 100644 index 0000000000..19a9c2c73e --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/MessageDeliveryStatusTests.e2e.ts @@ -0,0 +1,691 @@ +import { describe, it } from '@jest/globals'; + +import { MessageDeliveryStatus } from '../mock-server/DataTypes'; +import { ParticipantRobot } from '../robots'; +import { disableInternetConnection, ignoreMessageSendsForSync } from '../support/Device'; +import { allureId, step } from '../support/step'; +import { itOnAndroid, setUpStreamTestCase } from '../support/StreamTestCase'; + +const { DELIVERED, FAILED, NIL, PENDING, READ, SENT } = MessageDeliveryStatus; + +describe('MessageDeliveryStatusTests', () => { + const { backendRobot, participantRobot, userRobot } = setUpStreamTestCase(); + const sampleText = 'Test'; + + // MARK: Message list + + it('test_singleCheckmarkShown_whenMessageIsSent', async () => { + allureId(13102); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN user sends a new message', () => userRobot.sendMessage(sampleText)); + await step('THEN user spots single checkmark below the message', () => + userRobot.assertMessageDeliveryStatus(SENT), + ); + }); + + it('test_deliveryStatusShowsClocks_whenMessageIsInPendingState', async () => { + allureId(13103); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN user sends a new message that is delayed', async () => { + await ignoreMessageSendsForSync(); + await backendRobot.delayNewMessages(); + await userRobot.sendMessage('pending message'); + }); + await step('THEN message delivery status shows clocks', () => + userRobot.assertMessageDeliveryStatus(PENDING), + ); + }); + + it('test_errorIndicatorShown_whenMessageFailedToBeSent', async () => { + allureId(13104); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN user sends a new message that is gonna fail', async () => { + await backendRobot.failNewMessages(); + await userRobot.sendMessage('failed message'); + }); + await step('THEN error indicator is shown for the message', () => + userRobot.assertMessageDeliveryStatus(FAILED), + ); + }); + + itOnAndroid( + 'test_errorIndicatorShown_whenMessageFailedToBeSent_andCantBePreviewedInThread', + async () => { + allureId(13105); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user becomes offline', () => disableInternetConnection()); + await step('WHEN user sends a new message', () => userRobot.sendMessage('failed message')); + await step('THEN error indicator is shown for the message', () => + userRobot.assertMessageFailedIcon(true), + ); + await step('AND delivery status is not shown', () => + userRobot.assertMessageDeliveryStatus(NIL), + ); + await step("AND user can't preview this message in thread", () => + userRobot.assertContextMenuActionNotAvailable('threadReply'), + ); + }, + ); + + it('test_doubleCheckmarkShown_whenMessageReadByParticipant', async () => { + allureId(13106); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user successfully sends a new message', () => + userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(SENT), + ); + await step("WHEN participant reads the user's message", () => participantRobot.readMessage()); + await step('THEN user spots double checkmark below the message', () => + userRobot.assertMessageDeliveryStatus(READ), + ); + }); + + it('test_deliveredCheckmarkTurnsRead_whenParticipantScrollsChannelToBottom', async () => { + allureId(13107); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user successfully sends a new message', () => + userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(SENT), + ); + await step( + 'AND participant has the channel scrolled up, so the message is delivered but not read', + () => participantRobot.markMessagesDelivered(), + ); + await step('AND message delivery status shows a grey double checkmark', () => + userRobot.assertMessageDeliveryStatus(DELIVERED), + ); + await step('WHEN participant scrolls the channel to bottom', () => + participantRobot.readMessage(), + ); + await step('THEN message delivery status shows a blue double checkmark', () => + userRobot.assertMessageDeliveryStatus(READ), + ); + }); + + it('test_deliveryStatusShownForTheLastMessageInGroup', async () => { + allureId(13108); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user successfully sends a new message', () => + userRobot.sendMessage(sampleText), + ); + await step('AND delivery status shows single checkmark', () => + userRobot.assertMessageDeliveryStatus(SENT, 1), + ); + await step('WHEN user sends another message', () => userRobot.sendMessage(sampleText)); + await step('THEN delivery status for the previous message is hidden', () => + userRobot.assertMessageDeliveryStatus(SENT, 1), + ); + }); + + // https://linear.app/stream/issue/RN-492 + it.skip('test_deliveryStatusHidden_whenMessageIsDeleted', async () => { + allureId(13109); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user successfully sends a new message', () => + userRobot.sendMessage(sampleText), + ); + await step('AND delivery status shows single checkmark', () => + userRobot.assertMessageDeliveryStatus(SENT), + ); + await step('WHEN user removes the message', () => userRobot.deleteMessage()); + await step('THEN delivery status is hidden', () => + userRobot.assertDeletedMessage().assertMessageDeliveryStatus(NIL), + ); + }); + + it('test_deliveryStatusShownForPreviousMessage_whenErrorMessageShown', async () => { + allureId(13110); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('AND user successfully sends a new message', () => + userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(SENT), + ); + await step('WHEN user sends message that is gonna fail', async () => { + await backendRobot.failNewMessages(); + await userRobot.sendMessage('failed message'); + }); + await step('THEN delivery status is shown for the previous message', () => + userRobot.assertMessageDeliveryStatus(FAILED, 1).assertMessageDeliveryStatus(SENT, 1), + ); + }); + + it('test_deliveryStatusShownForPreviousMessage_whenSystemMessageShown', async () => { + allureId(13111); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND successfully sends a new message', () => + userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(SENT), + ); + await step('WHEN user sends message with invalid command', () => + userRobot.sendMessage('/command'), + ); + await step('THEN delivery status is shown for first message', () => + userRobot.assertMessageDeliveryStatus(SENT, 1), + ); + }); + + // MARK: Thread + + // https://linear.app/stream/issue/RN-493 + it.skip('test_singleCheckmarkShown_whenMessageIsSent_andPreviewedInThread', async () => { + allureId(13112); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user successfully sends a new message', () => + userRobot.sendMessage('message').assertMessageDeliveryStatus(SENT), + ); + await step('WHEN user previews thread for the message', () => userRobot.openThread()); + await step('THEN user spots single checkmark below the message', () => + userRobot.assertMessageDeliveryStatus(SENT), + ); + await step('WHEN participant reads the message', () => participantRobot.readMessage()); + await step('THEN user spots double checkmark below the message', () => + userRobot.assertMessageDeliveryStatus(READ), + ); + }); + + it('test_singleCheckmarkShown_whenThreadReplyIsSent', async () => { + allureId(13113); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN participant sends a new message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + await step('AND user replies to the message in thread', () => + userRobot.openThread().sendMessage('thread reply'), + ); + await step('THEN user spots single checkmark below the thread reply', () => + userRobot.assertMessageDeliveryStatus(SENT), + ); + }); + + it('test_errorIndicatorShown_whenThreadReplyFailedToBeSent', async () => { + allureId(13114); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('WHEN user sends a message in thread that is gonna fail', async () => { + await userRobot.openThread(); + await backendRobot.failNewMessages(); + await userRobot.sendMessage(sampleText); + }); + await step('THEN error indicator is shown for the thread reply', () => + userRobot.assertMessageDeliveryStatus(FAILED), + ); + }); + + it('test_doubleCheckmarkShown_whenThreadReplyReadByParticipant', async () => { + allureId(13115); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('WHEN user replies to message in thread', () => + userRobot.openThread().sendMessage(sampleText), + ); + await step('AND the thread reply is delivered', () => + userRobot.assertMessageDeliveryStatus(SENT), + ); + await step("AND participant reads the user's thread reply", () => + participantRobot.readMessage(), + ); + await step('THEN user spots double checkmark below the message', () => + userRobot.assertMessageDeliveryStatus(READ), + ); + }); + + it('test_doubleCheckmarkShown_whenChannelThreadReplyReadByParticipant', async () => { + allureId(13116); + const threadReply = 'thread reply'; + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends a new message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + await step('AND user successfully sends a new thread reply also in the channel', () => + userRobot.openThread().sendMessageInThread(threadReply, true).assertMessage(threadReply), + ); + await step('AND thread reply delivery status shows a single checkmark', () => + userRobot.assertMessageDeliveryStatus(SENT), + ); + await step( + 'WHEN participant has the channel scrolled to bottom and reads the thread reply', + () => participantRobot.readMessage(), + ); + await step('THEN thread reply delivery status shows a double checkmark', () => + userRobot.assertMessageDeliveryStatus(READ), + ); + await step('AND thread reply delivery status shows a double checkmark in the channel', () => + userRobot.tapOnBackButton().assertMessageDeliveryStatus(READ), + ); + }); + + // https://linear.app/stream/issue/RN-493 + it.skip('test_deliveryStatusShownForTheLastThreadReplyInGroup', async () => { + allureId(13117); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('WHEN user replies to message in thread', () => + userRobot.openThread().sendMessage(sampleText), + ); + await step('AND delivery status shows single checkmark', () => + userRobot.assertMessageDeliveryStatus(SENT, 2), + ); + await step('AND user sends another message', () => userRobot.sendMessage(sampleText)); + await step('THEN delivery status for the previous message is hidden', () => + userRobot.assertMessageDeliveryStatus(SENT, 2), + ); + }); + + // https://linear.app/stream/issue/RN-492 + // https://linear.app/stream/issue/RN-493 + it.skip('test_deliveryStatusHidden_whenThreadReplyIsDeleted', async () => { + allureId(13118); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('AND user successfully sends a new message in thread', () => + userRobot.openThread().sendMessage(sampleText), + ); + await step('AND delivery status shows single checkmark', () => + userRobot.assertMessageDeliveryStatus(SENT, 2), + ); + await step('WHEN user removes the message', () => userRobot.deleteMessage()); + await step('THEN delivery status is hidden', () => + userRobot.assertDeletedMessage().assertMessageDeliveryStatus(SENT, 1), + ); + }); + + // MARK: Channel preview + + it('test_deliveryStatusClocksShownInPreview_whenTheLastMessageIsInPendingState', async () => { + allureId(13119); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN user sends a new message that is delayed', async () => { + await ignoreMessageSendsForSync(); + await backendRobot.delayNewMessages(); + await userRobot.sendMessage(sampleText); + }); + await step('AND user returns to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('THEN last message delivery status in the channel preview shows clocks', () => + userRobot.assertMessageDeliveryStatusInPreview(PENDING), + ); + }); + + it('test_singleCheckmarkShownInPreview_whenTheLastMessageIsSent', async () => { + allureId(13120); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user sends a new message', () => + userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(SENT), + ); + await step('WHEN user returns to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step( + 'THEN last message delivery status in the channel preview shows single checkmark', + () => userRobot.assertMessageDeliveryStatusInPreview(SENT), + ); + }); + + it('test_errorIndicatorShownInPreview_whenMessageFailedToBeSent', async () => { + allureId(13121); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN user sends a new message that is gonna fail', async () => { + await backendRobot.failNewMessages(); + await userRobot.sendMessage(sampleText).assertMessageFailedIcon(true); + }); + await step('AND user returns to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('THEN last message delivery status in the channel preview shows failed icon', () => + userRobot.assertFailedMessageDeliveryStatusInPreview(), + ); + }); + + it('test_doubleCheckmarkShownInPreview_whenMessageReadByParticipant', async () => { + allureId(13122); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user sends a new message', () => userRobot.sendMessage(sampleText)); + await step('AND the message is delivered', () => userRobot.assertMessageDeliveryStatus(SENT)); + await step('WHEN user returns to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('AND participant reads the message', () => participantRobot.readMessage()); + await step( + 'THEN last message delivery status in the channel preview shows double checkmark', + () => userRobot.assertMessageDeliveryStatusInPreview(READ), + ); + }); + + it('test_deliveryStatusHiddenInPreview_whenMessageIsSentByParticipant', async () => { + allureId(13123); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN participant sends a new message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + await step('AND user returns to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('THEN delivery status is hidden', () => + userRobot + .assertMessageInChannelPreview(sampleText, false) + .assertMessageDeliveryStatusInPreview(NIL), + ); + }); + + it('test_noCheckmarkShownForMessageInPreview_whenThreadReplyIsSent', async () => { + allureId(13124); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends a new message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + await step('AND user replies to the message in thread', () => + userRobot.openThread().sendMessage('thread reply').assertMessage('thread reply'), + ); + await step('WHEN user returns to the channel list', () => + userRobot.moveToChannelListFromThread(), + ); + await step('THEN delivery status is hidden', () => + userRobot + .assertMessageInChannelPreview(sampleText, false) + .assertMessageDeliveryStatusInPreview(NIL), + ); + }); + + it('test_singleCheckmarkShownForMessageInPreview_whenThreadReplyFailedToBeSent', async () => { + allureId(13125); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user sends a new message', () => + userRobot.sendMessage('1').assertMessageDeliveryStatus(SENT), + ); + await step('AND user sends a new message in thread that is gonna freeze', async () => { + await userRobot.openThread(); + await backendRobot.freezeNewMessages(); + await userRobot.sendMessage('2'); + }); + await step('WHEN user returns to the channel list', () => + userRobot.moveToChannelListFromThread(), + ); + await step('THEN delivery status shows a single checkmark', () => + userRobot.assertMessageDeliveryStatusInPreview(SENT), + ); + }); + + it('test_noCheckmarkShownForMessageInPreview_whenThreadReplyReadByParticipant', async () => { + allureId(13126); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends a new message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + await step('AND user replies to this message in thread', () => + userRobot.openThread().sendMessage('thread reply').assertMessageDeliveryStatus(SENT), + ); + await step("AND participant reads the user's thread reply", () => + participantRobot.readMessage(), + ); + await step('WHEN user returns to the channel list', () => + userRobot.moveToChannelListFromThread(), + ); + await step('THEN delivery status is hidden', () => + userRobot + .assertMessageInChannelPreview(sampleText, false) + .assertMessageDeliveryStatusInPreview(NIL), + ); + }); + + it('test_noCheckmarkShownForMessageInPreview_whenThreadReplyIsSentByParticipant', async () => { + allureId(13127); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends a new message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + await step('AND participant replies to message in thread', () => + participantRobot.sendMessageInThread('thread reply'), + ); + await step('WHEN user returns to the channel list', () => + userRobot.assertThreadReplyLabelOnParentMessage().moveToChannelListFromMessageList(), + ); + await step('THEN delivery status is hidden', () => + userRobot + .assertMessageInChannelPreview(sampleText, false) + .assertMessageDeliveryStatusInPreview(NIL), + ); + }); + + // MARK: Members + + // https://linear.app/stream/issue/RN-480 + it.skip('test_readByDecremented_whenParticipantIsRemoved', async () => { + allureId(13128); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user sends a new message', () => userRobot.sendMessage(sampleText)); + await step('AND the message is delivered', () => userRobot.assertMessageDeliveryStatus(SENT)); + await step('AND participant reads the message', async () => { + await participantRobot.readMessage(); + await userRobot.assertMessageDeliveryStatus(READ); + }); + await step('WHEN participant is removed from the channel', () => + backendRobot.removeMember(ParticipantRobot.userId), + ); + await step('THEN user observes a single checkmark below the message', () => + userRobot.assertMessageDeliveryStatus(SENT), + ); + }); + + // https://linear.app/stream/issue/RN-480 + it.skip('test_readByDecrementedInThreadReply_whenParticipantIsRemoved', async () => { + allureId(13129); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('AND user replies to message in thread', () => + userRobot.openThread().sendMessage(sampleText), + ); + await step('AND the thread reply is delivered', () => + userRobot.assertMessageDeliveryStatus(SENT), + ); + await step('AND participant reads the thread reply', async () => { + await participantRobot.readMessage(); + await userRobot.assertMessageDeliveryStatus(READ); + }); + await step('WHEN participant is removed from the channel', () => + backendRobot.removeMember(ParticipantRobot.userId), + ); + await step('THEN user observes a single checkmark below the thread reply', () => + userRobot.assertMessageDeliveryStatus(SENT), + ); + }); + + it('test_noDoubleCheckmarkShown_whenNewParticipantAdded', async () => { + allureId(13130); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user successfully sends new message', () => + userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(SENT), + ); + await step('AND message is read by the participant', async () => { + await participantRobot.readMessage(); + await userRobot.assertMessageDeliveryStatus(READ); + }); + await step('WHEN new participant is added to the channel', () => + backendRobot.addMember('leia_organa'), + ); + await step('THEN user spots double checkmark below the message', () => + userRobot.assertMessageDeliveryStatus(READ), + ); + }); + + it('test_noDoubleCheckmarkShownInThreadReply_whenNewParticipantAdded', async () => { + allureId(13131); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends a new message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + await step('AND user replies to message in thread', () => + userRobot.openThread().sendMessage('thread reply').assertMessageDeliveryStatus(SENT), + ); + await step('WHEN new participant is added to the channel', () => + backendRobot.addMember('leia_organa'), + ); + await step('THEN user spots single checkmark below the thread reply', () => + userRobot.assertMessageDeliveryStatus(SENT), + ); + }); + + // MARK: Read events disabled + + // https://linear.app/stream/issue/RN-481 + it.skip('test_deliveryStatusHidden_whenMessageIsSentAndReadEventsIsDisabled', async () => { + allureId(13132); + await step('GIVEN read events are disabled and user opens the channel', async () => { + await backendRobot.setReadEvents(false); + await userRobot.login().openChannel(); + }); + await step('WHEN user sends a new message', () => + userRobot.sendMessage(sampleText).assertMessage(sampleText), + ); + await step('THEN delivery status is hidden', () => userRobot.assertMessageDeliveryStatus(NIL)); + }); + + it('test_deliveryStatusShowsClocks_whenMessageIsInPendingStateAndReadEventsIsDisabled', async () => { + allureId(13133); + await step('GIVEN read events are disabled and user opens the channel', async () => { + await backendRobot.setReadEvents(false); + await userRobot.login().openChannel(); + }); + await step('WHEN user sends a new message that is delayed', async () => { + await ignoreMessageSendsForSync(); + await backendRobot.delayNewMessages(); + await userRobot.sendMessage('pending message'); + }); + await step('THEN message delivery status shows clocks', () => + userRobot.assertMessageDeliveryStatus(PENDING), + ); + }); + + it('test_errorIndicatorShown_whenMessageFailedToBeSentAndReadEventsIsDisabled', async () => { + allureId(13134); + await step('GIVEN read events are disabled and user opens the channel', async () => { + await backendRobot.setReadEvents(false); + await userRobot.login().openChannel(); + }); + await step('WHEN user sends a new message that is gonna fail', async () => { + await backendRobot.failNewMessages(); + await userRobot.sendMessage('failed message'); + }); + await step('THEN error indicator is shown for the message', () => + userRobot.assertMessageDeliveryStatus(FAILED), + ); + }); + + // https://linear.app/stream/issue/RN-481 + it.skip('test_deliveryStatusHidden_whenMessageReadByParticipantAndReadEventsIsDisabled', async () => { + allureId(13135); + await step('GIVEN read events are disabled and user opens the channel', async () => { + await backendRobot.setReadEvents(false); + await userRobot.login().openChannel(); + }); + await step('AND user sends a new message', () => + userRobot.sendMessage(sampleText).assertMessage(sampleText), + ); + await step('WHEN participant reads the message', () => participantRobot.readMessage()); + await step('THEN delivery status is hidden', () => userRobot.assertMessageDeliveryStatus(NIL)); + }); + + // https://linear.app/stream/issue/RN-481 + it.skip('test_deliveryStatusHidden_whenNewParticipantAddedAndReadEventsIsDisabled', async () => { + allureId(13136); + await step('GIVEN read events are disabled and user opens the channel', async () => { + await backendRobot.setReadEvents(false); + await userRobot.login().openChannel(); + }); + await step('AND user sends a new message', () => + userRobot.sendMessage(sampleText).assertMessage(sampleText), + ); + await step('AND participant reads the message', () => participantRobot.readMessage()); + await step('WHEN a new participant is added to the channel', () => + backendRobot.addMember('leia_organa'), + ); + await step('THEN delivery status is hidden', () => userRobot.assertMessageDeliveryStatus(NIL)); + }); + + // https://linear.app/stream/issue/RN-481 + it.skip('test_deliveryStatusHidden_whenParticipantIsRemovedAndReadEventsIsDisabled', async () => { + allureId(13137); + await step('GIVEN read events are disabled and user opens the channel', async () => { + await backendRobot.setReadEvents(false); + await userRobot.login().openChannel(); + }); + await step('AND user sends a new message', () => + userRobot.sendMessage(sampleText).assertMessage(sampleText), + ); + await step('AND participant reads the message', () => participantRobot.readMessage()); + await step('WHEN participant is removed from the channel', () => + backendRobot.removeMember(ParticipantRobot.userId), + ); + await step('THEN delivery status is hidden', () => userRobot.assertMessageDeliveryStatus(NIL)); + }); + + // https://linear.app/stream/issue/RN-481 + it.skip('test_deliveryStatusHiddenForMessagesInGroup_whenReadEventsIsDisabled', async () => { + allureId(13138); + await step('GIVEN read events are disabled and user opens the channel', async () => { + await backendRobot.setReadEvents(false); + await userRobot.login().openChannel(); + }); + await step('AND user sends a new message', () => + userRobot.sendMessage(sampleText).assertMessage(sampleText), + ); + await step('WHEN user sends another message', () => + userRobot.sendMessage('second message').assertMessage('second message'), + ); + await step('THEN delivery status is hidden for all messages', () => + userRobot.assertMessageDeliveryStatus(NIL), + ); + }); + + // https://linear.app/stream/issue/RN-481 + it.skip('test_deliveryStatusHidden_whenMessageIsDeletedAndReadEventsIsDisabled', async () => { + allureId(13139); + await step('GIVEN read events are disabled and user opens the channel', async () => { + await backendRobot.setReadEvents(false); + await userRobot.login().openChannel(); + }); + await step('AND user sends a new message', () => + userRobot.sendMessage(sampleText).assertMessage(sampleText), + ); + await step('AND delivery status is hidden', () => userRobot.assertMessageDeliveryStatus(NIL)); + await step('WHEN user deletes the message', () => userRobot.deleteMessage()); + await step('THEN delivery status stays hidden', () => + userRobot.assertDeletedMessage().assertMessageDeliveryStatus(NIL), + ); + }); + + it('test_deliveryStatusHiddenInPreview_whenMessageIsSentAndReadEventsIsDisabled', async () => { + allureId(13140); + await step('GIVEN read events are disabled and user opens the channel', async () => { + await backendRobot.setReadEvents(false); + await userRobot.login().openChannel(); + }); + await step('AND user sends a new message', () => + userRobot.sendMessage(sampleText).assertMessage(sampleText), + ); + await step('WHEN user returns to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('THEN delivery status is hidden in the channel preview', () => + userRobot + .assertMessageInChannelPreview(sampleText, true) + .assertMessageDeliveryStatusInPreview(NIL), + ); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/MessageListTests.e2e.ts b/examples/E2ETestApp/e2e/tests/MessageListTests.e2e.ts index 265ed0d70d..f0e22e39b8 100644 --- a/examples/E2ETestApp/e2e/tests/MessageListTests.e2e.ts +++ b/examples/E2ETestApp/e2e/tests/MessageListTests.e2e.ts @@ -1,14 +1,32 @@ import { describe, it } from '@jest/globals'; -import { MessageDeliveryStatus } from '../mock-server/DataTypes'; -import { step } from '../support/step'; -import { setUpStreamTestCase } from '../support/StreamTestCase'; +import { forbiddenWord, MessageDeliveryStatus } from '../mock-server/DataTypes'; +import { + disableInternetConnection, + enableInternetConnection, + goToBackground, + goToForeground, +} from '../support/Device'; +import { allureId, step } from '../support/step'; +import { itExceptOnOldIOS, itOnAndroid, setUpStreamTestCase } from '../support/StreamTestCase'; describe('MessageListTests', () => { const { backendRobot, participantRobot, userRobot } = setUpStreamTestCase(); const sampleText = 'Test'; + // MARK: Message sending + + it('test_messageListUpdates_whenParticipantSendsMessage', async () => { + allureId(13141); + await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); + await step('WHEN participant sends a message', () => participantRobot.sendMessage(sampleText)); + await step('THEN user receives a message', () => + userRobot.assertMessage(sampleText).assertMessageAuthor(false), + ); + }); + it('test_messageListUpdates_whenUserSendsMessage', async () => { + allureId(13142); await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); await step('WHEN user sends a message', () => userRobot.sendMessage(sampleText)); await step('THEN message list updates', () => @@ -19,15 +37,225 @@ describe('MessageListTests', () => { ); }); - it('test_messageListUpdates_whenParticipantSendsMessage', async () => { - await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); - await step('WHEN participant sends a message', () => participantRobot.sendMessage(sampleText)); - await step('THEN user receives a message', () => - userRobot.assertMessage(sampleText).assertMessageAuthor(false), + it('test_userSendsMessageWithOneEmoji', async () => { + allureId(13143); + const message = '๐Ÿค–'; + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step(`WHEN user sends the emoji: ${message}`, () => userRobot.sendMessage(message)); + await step('THEN the message is delivered', () => + userRobot.assertMessage(message).assertMessageDeliveryStatus(MessageDeliveryStatus.SENT), + ); + }); + + it('test_userSendsMessageWithMultipleEmojis', async () => { + allureId(13144); + const message = '๐Ÿค–๐Ÿ”ฅโœ…'; + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step(`WHEN user sends a message with multiple emojis: ${message}`, () => + userRobot.sendMessage(message), + ); + await step('THEN the message is delivered', () => + userRobot.assertMessage(message).assertMessageDeliveryStatus(MessageDeliveryStatus.SENT), + ); + }); + + // MARK: Message editing + + it('test_userEditsMessage', async () => { + allureId(13145); + const editedMessage = 'hello'; + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN user sends the message', () => + userRobot.sendMessage(sampleText).assertMessage(sampleText), + ); + await step('AND user edits the message', () => userRobot.editMessage(editedMessage)); + await step('THEN the message is edited', () => + userRobot + .assertEditedMessage(editedMessage) + .assertMessageDeliveryStatus(MessageDeliveryStatus.SENT), + ); + }); + + it('test_participantEditsMessage', async () => { + allureId(13146); + const editedMessage = 'hello'; + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN participant sends the message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + await step('AND participant edits the message', () => + participantRobot.editMessage(editedMessage), + ); + await step('THEN the message is edited', () => userRobot.assertEditedMessage(editedMessage)); + }); + + it('test_userEditsThreadReply', async () => { + allureId(13147); + const message = 'message'; + const threadReply = 'thread reply'; + const editedThreadReply = 'edited thread reply'; + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends a message', async () => { + await participantRobot.sendMessage(message); + await userRobot.assertMessage(message); + }); + await step('AND user replies to the message in thread', () => + userRobot.openThread().sendMessageInThread(threadReply).assertMessage(threadReply), + ); + await step('WHEN user edits the thread reply', () => userRobot.editMessage(editedThreadReply)); + await step('THEN the thread reply is edited and marked as edited', () => + userRobot.assertThreadIsOpen().assertEditedMessage(editedThreadReply), + ); + }); + + it('test_participantEditsThreadReply', async () => { + allureId(13148); + const message = 'message'; + const threadReply = 'thread reply'; + const editedThreadReply = 'edited thread reply'; + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user sends a message', () => + userRobot.sendMessage(message).assertMessage(message), + ); + await step('AND participant replies to the message in thread', () => + participantRobot.sendMessageInThread(threadReply), + ); + await step('AND user opens the thread', () => + userRobot.openThread(0, false).assertThreadMessage(threadReply), + ); + await step('WHEN participant edits the thread reply', () => + participantRobot.editMessageInThread(editedThreadReply), + ); + await step('THEN the thread reply is edited and marked as edited', () => + userRobot.assertEditedMessage(editedThreadReply), + ); + }); + + it('test_threadReplyIsEditedEverywhere_whenParticipantEditsThreadReplySentAlsoToChannel', async () => { + allureId(13149); + const message = 'message'; + const threadReply = 'thread reply'; + const editedThreadReply = 'edited thread reply'; + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user sends a message', () => + userRobot.sendMessage(message).assertMessage(message), + ); + await step( + 'AND participant replies to the message in thread and also sends it to the channel', + async () => { + await participantRobot.sendMessageInThread(threadReply, true); + await userRobot.assertMessage(threadReply); + }, + ); + await step('WHEN participant edits the thread reply', () => + participantRobot.editMessageInThread(editedThreadReply, true), + ); + await step('THEN the thread reply is edited in the channel', () => + userRobot.assertEditedMessage(editedThreadReply), + ); + await step('AND the thread reply is edited in the thread', () => + userRobot + .openThread(1, false) + .assertThreadMessage(editedThreadReply) + .assertEditedMessage(editedThreadReply), + ); + }); + + // MARK: Message size + + it('test_messageIncreases_whenUserEditsMessageWithOneLineText', async () => { + allureId(13150); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step(`AND user sends a one line message: ${sampleText}`, () => + userRobot.sendMessage(sampleText).assertMessage(sampleText), + ); + await step('THEN user verifies that message cell increases after editing', () => + userRobot.assertMessageSizeChangesAfterEditing(true), + ); + }); + + it('test_messageDecreases_whenUserEditsMessage', async () => { + allureId(13151); + const message = 'test\nmessage'; + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step(`AND user sends a two line message: ${message}`, () => + userRobot.sendMessage(message).assertMessage(message), + ); + await step('THEN user verifies that message cell decreases after editing', () => + userRobot.assertMessageSizeChangesAfterEditing(false), + ); + }); + + // MARK: Composer + + it('test_composerSizeChange', async () => { + allureId(13152); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('THEN user verifies that composer size changes', () => + userRobot.assertComposerSize(true), + ); + }); + + it('test_composerSizeDoesNotChange', async () => { + allureId(13153); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('THEN user verifies that composer does not grow more than 5 lines', () => + userRobot.assertComposerSize(false), + ); + }); + + it('test_addingCommandHidesAttachmentsButton', async () => { + allureId(13154); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user observes the attachments button', () => + userRobot.assertComposerAttachmentsButton(true), + ); + await step('WHEN user selects the giphy command from the suggestions', () => + userRobot + .openComposerCommands() + .assertComposerCommandsMenu(true) + .tapOnGiphyCommandSuggestion(), + ); + await step('THEN the attachments button disappears', () => + userRobot.assertComposerAttachmentsButton(false), + ); + }); + + // https://linear.app/stream/issue/RN-497 + it.skip('test_addingCommandHidesLeftButtons', async () => { + allureId(13319); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step("WHEN user types '/'", () => userRobot.openComposerCommands()); + await step('THEN composer left buttons disappear', () => + userRobot.assertComposerAttachmentsButton(false), + ); + await step("WHEN user removes '/'", () => userRobot.clearComposer()); + await step('THEN composer left buttons appear', () => + userRobot.assertComposerAttachmentsButton(true), + ); + }); + + // https://linear.app/stream/issue/RN-497 + it.skip('test_commandsPopupDisappear_whenUserTapsOnMessageList', async () => { + allureId(13155); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 30 }); + await userRobot.login().openChannel(); + }); + await step('AND user opens command suggestions', () => + userRobot.openComposerCommands().assertComposerCommandsMenu(true), + ); + await step('WHEN user taps on message list', () => userRobot.tapOnMessageList()); + await step('THEN command suggestions disappear', () => + userRobot.assertComposerCommandsMenu(false), ); }); + // MARK: Typing indicator + it('test_typingIndicator', async () => { + allureId(13156); await step('GIVEN user opens the channel', async () => { await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); await userRobot.login().openChannel(); @@ -41,4 +269,544 @@ describe('MessageListTests', () => { userRobot.assertTypingIndicator(false), ); }); + + it('test_threadTypingIndicatorHidden_whenParticipantStopsTyping', async () => { + allureId(13157); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('AND user opens the thread', () => userRobot.openThread()); + await step('WHEN participant starts typing in thread', () => + participantRobot.startTypingInThread(), + ); + await step('THEN user observes typing indicator is shown', () => + userRobot.assertTypingIndicator(true), + ); + await step('WHEN participant stops typing in thread', () => + participantRobot.stopTypingInThread(), + ); + await step('THEN user observes typing indicator has disappeared', () => + userRobot.assertTypingIndicator(false), + ); + }); + + // MARK: Offline mode + + itOnAndroid('test_offlineMessageInTheMessageList', async () => { + allureId(13158); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user goes to background', () => goToBackground()); + await step("AND user's WebSocket connection is closed", () => + backendRobot.waitForWebSocketDisconnection(), + ); + await step('WHEN participant sends a new message', () => + participantRobot.sendMessage(sampleText), + ); + await step('AND user becomes offline', () => disableInternetConnection()); + await step('AND user comes back to foreground', () => goToForeground()); + await step('THEN user does not observe a new message from participant', () => + userRobot.assertMessage(sampleText, false), + ); + await step('AND user becomes online', () => enableInternetConnection()); + await step('THEN user observes a new message from participant', () => + userRobot.assertMessage(sampleText), + ); + }); + + // Follows iOS: RN marks a message sent offline as failed until the queue sends it (RN-500). + // Android shows it as pending instead. + itOnAndroid('test_userAddsMessageWhileOffline', async () => { + allureId(13159); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user becomes offline', () => disableInternetConnection()); + await step('WHEN user sends a new message', () => userRobot.sendMessage(sampleText)); + await step('THEN error indicator is shown for the message', () => + userRobot.assertMessageFailedIcon(true), + ); + await step('WHEN user becomes online', () => enableInternetConnection()); + await step('THEN new message is delivered', () => + userRobot + .assertMessageDeliveryStatus(MessageDeliveryStatus.SENT, 1) + .assertMessageFailedIcon(false), + ); + }); + + it('test_offlineRecoveryWithinSession', async () => { + allureId(13160); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user goes to the background', () => goToBackground()); + await step("AND user's WebSocket connection is closed", () => + backendRobot.waitForWebSocketDisconnection(), + ); + await step('WHEN participant sends a new message', () => + participantRobot.sendMessage(sampleText), + ); + await step('AND user comes back to the foreground', () => goToForeground()); + await step('THEN new message is delivered', () => + userRobot.assertMessage(sampleText).assertMessageAuthor(false), + ); + }); + + // MARK: Scrolling + + it('test_messageListScrollsDown_whenMessageListIsScrolledUp_andUserSendsNewMessage', async () => { + allureId(13161); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 30 }); + await userRobot.login().openChannel(); + }); + await step('WHEN user scrolls up', () => userRobot.scrollMessageListUp()); + await step('AND user sends a new message', () => userRobot.sendMessage(sampleText)); + await step('THEN message list is scrolled down', () => + userRobot.assertScrollToBottomButton(false).assertMessage(sampleText), + ); + }); + + it('test_messageListScrollsDown_whenMessageListIsScrolledDown_andUserReceivesNewMessage', async () => { + allureId(13162); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 30 }); + await userRobot.login().openChannel(); + }); + await step('WHEN participant sends a message', () => participantRobot.sendMessage(sampleText)); + await step('THEN message list is scrolled down', () => + userRobot.assertScrollToBottomButton(false).assertMessage(sampleText), + ); + }); + + it('test_messageListDoesNotScrollDown_whenMessageListIsScrolledUp_andUserReceivesNewMessage', async () => { + allureId(13163); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 30 }); + await userRobot.login().openChannel(); + }); + await step('WHEN user scrolls up', () => userRobot.scrollMessageListUp()); + await step('AND participant sends a message', () => participantRobot.sendMessage(sampleText)); + await step('THEN message list is not scrolled down', () => + userRobot.assertMessage(sampleText, false).assertScrollToBottomButton(true), + ); + await step('WHEN user taps on scroll to bottom button', () => + userRobot.tapOnScrollToBottomButton(), + ); + await step('THEN message list is scrolled down', () => + userRobot.assertMessage(sampleText).assertScrollToBottomButton(false), + ); + }); + + it('test_messageListScrollsDown_whenUserTapsOnScrollToBottomButton', async () => { + allureId(13164); + const newMessage = 'New message'; + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 30 }); + await userRobot.login().openChannel(); + }); + await step('AND user sends a new message', () => + userRobot.sendMessage(newMessage).assertMessage(newMessage), + ); + await step('WHEN user scrolls up', () => userRobot.scrollMessageListUp()); + await step('AND user taps on the scroll to bottom button', () => + userRobot.tapOnScrollToBottomButton(), + ); + await step('THEN message list is scrolled down', () => + userRobot.assertMessage(newMessage).assertScrollToBottomButton(false), + ); + }); + + // https://linear.app/stream/issue/RN-521 (iOS 17 only) + itExceptOnOldIOS('test_reloadsSkippedMessages_whenScrolledToTheBottom', async () => { + allureId(13165); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 30 }); + await userRobot.login().openChannel(); + }); + await step('AND user scrolls up', () => userRobot.scrollMessageListUp()); + await step('AND participant sends some messages', () => + participantRobot.sendMultipleMessages('Some message', 16), + ); + await step('WHEN user scrolls to the bottom', () => userRobot.tapOnScrollToBottomButton()); + await step('THEN skipped messages are reloaded', () => + userRobot.assertMessage('Some message-16').assertScrollToBottomButton(false), + ); + }); + + it('test_paginationOnMessageList', async () => { + allureId(13166); + const messagesCount = 60; + await step('WHEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount }); + await userRobot.login().openChannel(); + }); + await step('THEN user makes sure that chat history is loaded', () => + userRobot.assertMessageListPagination(messagesCount), + ); + }); + + it('test_paginationOnThread', async () => { + allureId(13167); + const repliesCount = 60; + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1, repliesCount }); + await userRobot.login().openChannel(); + }); + await step('WHEN user opens the thread', () => userRobot.openThread(0, false)); + await step('THEN user makes sure that thread history is loaded', () => + userRobot.assertMessageListPagination(repliesCount), + ); + }); + + // MARK: Mentions + + it('test_mentionsView', async () => { + allureId(13168); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step("WHEN user types '@'", () => userRobot.typeText('@')); + await step('THEN composer mention view appears', () => + userRobot.assertComposerMentionsMenu(true), + ); + await step("WHEN user removes '@'", () => userRobot.clearComposer()); + await step('THEN composer mention view disappears', () => + userRobot.assertComposerMentionsMenu(false), + ); + }); + + it('test_userFillsTheComposerMentioningParticipantThroughMentionsView', async () => { + allureId(13169); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step("WHEN user taps on participant's name", () => + userRobot.mentionParticipant({ send: false }), + ); + await step("THEN composer fills in participant's name", () => + userRobot.assertMentionWasApplied(), + ); + }); + + // MARK: Thread replies + + it('test_threadReplyAppearsInThread_whenParticipantAddsThreadReply', async () => { + allureId(13170); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('WHEN participant adds a thread reply', () => + participantRobot.sendMessageInThread(sampleText, false), + ); + await step('AND user enters thread', () => + userRobot.assertThreadReplyLabelOnParentMessage().openThread(0, false), + ); + await step('THEN user observes the thread reply in thread', () => + userRobot.assertThreadMessage(sampleText), + ); + }); + + it('test_threadReplyAppearsInChannelAndThread_whenParticipantAddsThreadReplySentAlsoToChannel', async () => { + allureId(13171); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('WHEN participant adds a thread reply and also sends it to the channel', () => + participantRobot.sendMessageInThread(sampleText, true), + ); + await step('THEN user observes the thread reply in channel', () => + userRobot.assertMessage(sampleText).assertAlsoInTheChannelLabelInChannel(), + ); + await step('WHEN user enters thread', () => userRobot.openThread(0, false)); + await step('THEN user observes the thread reply in thread', () => + userRobot.assertThreadMessage(sampleText).assertAlsoInTheChannelLabelInThread(), + ); + }); + + it('test_threadReplyAppearsInChannelAndThread_whenUserAddsThreadReplySentAlsoToChannel', async () => { + allureId(13172); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('WHEN user adds a thread reply and sends it also to the main channel', () => + userRobot.openThread().sendMessageInThread(sampleText, true), + ); + await step('THEN user observes the thread reply in thread', () => + userRobot.assertThreadMessage(sampleText).assertAlsoInTheChannelLabelInThread(), + ); + await step('AND user observes the thread reply in channel', () => + userRobot.tapOnBackButton().assertMessage(sampleText).assertAlsoInTheChannelLabelInChannel(), + ); + }); + + // MARK: Message deleting + + it('test_userDeletesMessage', async () => { + allureId(13173); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN user sends the message', () => + userRobot.sendMessage(sampleText).assertMessage(sampleText), + ); + await step('AND user deletes the message', () => userRobot.deleteMessage()); + await step('THEN the message is deleted', () => + userRobot.assertDeletedMessage(sampleText).assertMessageAuthor(true), + ); + }); + + it('test_userHardDeletesMessage', async () => { + allureId(13320); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN user sends the message', () => + userRobot.sendMessage(sampleText).assertMessage(sampleText), + ); + await step('AND user hard-deletes the message', () => userRobot.deleteMessage(0, true)); + await step('THEN the message is hard-deleted', () => + userRobot.assertDeletedMessage(sampleText, true), + ); + }); + + it('test_participantDeletesMessage', async () => { + allureId(13174); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN participant sends the message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + await step('AND participant deletes the message', () => participantRobot.deleteMessage()); + await step('THEN the message is deleted', () => + userRobot.assertDeletedMessage(sampleText).assertMessageAuthor(false), + ); + }); + + it('test_participantHardDeletesMessage', async () => { + allureId(13175); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN participant sends the message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + await step('AND participant hard-deletes the message', () => + participantRobot.deleteMessage(true), + ); + await step('THEN the message is hard-deleted', () => + userRobot.assertDeletedMessage(sampleText, true), + ); + }); + + // https://linear.app/stream/issue/RN-478 + it.skip('test_threadReplyIsRemovedEverywhere_whenParticipantRemovesItFromChannel', async () => { + allureId(13176); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step( + 'AND participant adds a thread reply and sends it also to main channel', + async () => { + await participantRobot.sendMessageInThread(sampleText, true); + await userRobot.assertMessage(sampleText); + }, + ); + await step('WHEN participant removes the thread reply', () => participantRobot.deleteMessage()); + await step('THEN user observes the thread reply removed in channel', () => + userRobot.assertDeletedMessage(sampleText).assertAlsoInTheChannelLabelInChannel(), + ); + await step('AND user observes the thread reply removed in thread', () => + userRobot + .openThread(0, false) + .assertDeletedMessage(sampleText) + .assertAlsoInTheChannelLabelInThread(), + ); + }); + + // https://linear.app/stream/issue/RN-478 + it.skip('test_threadReplyIsRemovedEverywhere_whenUserRemovesItFromChannel', async () => { + allureId(13177); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('AND user adds a thread reply and sends it also to main channel', () => + userRobot.openThread().sendMessageInThread(sampleText, true).assertMessage(sampleText), + ); + await step('WHEN user removes thread reply from channel', () => + userRobot.tapOnBackButton().assertMessage(sampleText).deleteMessage(), + ); + await step('THEN user observes the thread reply removed in channel', () => + userRobot.assertDeletedMessage(sampleText).assertAlsoInTheChannelLabelInChannel(), + ); + await step('AND user observes the thread reply removed in thread', () => + userRobot + .openThread(0, false) + .assertDeletedMessage(sampleText) + .assertAlsoInTheChannelLabelInThread(), + ); + }); + + it('test_threadReplyIsRemovedEverywhere_whenParticipantRemovesItFromThread', async () => { + allureId(13178); + const message = 'message'; + const threadReply = 'thread reply'; + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user sends a message', () => + userRobot.sendMessage(message).assertMessage(message), + ); + await step( + "WHEN participant adds a thread reply to user's message and sends it also to main channel", + () => participantRobot.sendMessageInThread(threadReply, true), + ); + await step('AND user opens the thread', () => + userRobot.openThread(0, false).assertThreadMessage(threadReply), + ); + await step('AND participant removes the thread reply from thread', () => + participantRobot.deleteMessage(), + ); + await step('THEN the message is deleted from the thread', () => + userRobot.assertDeletedMessage(threadReply), + ); + await step('AND the message is deleted from the channel', () => + userRobot.tapOnBackButton().assertDeletedMessage(threadReply), + ); + }); + + it('test_participantRemovesThreadReply', async () => { + allureId(13179); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('AND participant adds a thread reply', () => + participantRobot.sendMessageInThread(sampleText, false), + ); + await step('WHEN participant removes the thread reply', () => participantRobot.deleteMessage()); + await step('THEN user observes the thread reply removed in thread', () => + userRobot + .assertThreadReplyLabelOnParentMessage() + .openThread(0, false) + .assertDeletedMessage(sampleText), + ); + }); + + // https://linear.app/stream/issue/RN-478 + it.skip('test_threadReplyIsRemovedEverywhere_whenUserRemovesItFromThread', async () => { + allureId(13180); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('AND user adds a thread reply and sends it also to main channel', () => + userRobot.openThread().sendMessageInThread(sampleText, true).assertMessage(sampleText), + ); + await step('WHEN user removes thread reply from thread', () => userRobot.deleteMessage()); + await step('THEN user observes the thread reply removed in thread', () => + userRobot.assertDeletedMessage(sampleText).assertAlsoInTheChannelLabelInThread(), + ); + await step('AND user observes the thread reply removed in channel', () => + userRobot + .tapOnBackButton() + .assertDeletedMessage(sampleText) + .assertAlsoInTheChannelLabelInChannel(), + ); + }); + + it('test_userRemovesThreadReply', async () => { + allureId(13181); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('AND user adds a thread reply', () => + userRobot.openThread().sendMessageInThread(sampleText, false).assertMessage(sampleText), + ); + await step('WHEN user removes the thread reply', () => userRobot.deleteMessage()); + await step('THEN user observes the thread reply removed in thread', () => + userRobot.assertDeletedMessage(sampleText), + ); + await step('AND user observes a thread reply count button in channel', () => + userRobot.tapOnBackButton().assertThreadReplyLabelOnParentMessage(), + ); + }); + + // https://linear.app/stream/issue/RN-478 + it.skip('test_threadIsNotLocked_afterParentMessageDeletedByUser', async () => { + allureId(13182); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('AND participant adds a message in thread', () => + participantRobot.sendMessageInThread(sampleText), + ); + await step('WHEN user deletes a parent message', () => + userRobot.assertThreadReplyLabelOnParentMessage().deleteMessage(), + ); + await step('THEN thread is not locked', () => + userRobot.openThread(0, false).assertMessage(sampleText), + ); + }); + + // https://linear.app/stream/issue/RN-478 + it.skip('test_threadIsNotLocked_afterParentMessageDeletedByParticipant', async () => { + allureId(13183); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends a message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + await step('AND user sends a message in thread', () => + userRobot.openThread().sendMessage(sampleText).assertMessage(sampleText).tapOnBackButton(), + ); + await step('WHEN participant deletes a parent message', () => participantRobot.deleteMessage()); + await step('THEN thread is not locked', () => + userRobot.openThread(0, false).assertMessage(sampleText), + ); + }); + + // MARK: Message grouping + + // https://linear.app/stream/issue/RN-496 + it.skip('test_messageEndsGroup_whenFollowedByErrorMessage', async () => { + allureId(13184); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user sends the 1st message', () => + userRobot.sendMessage(sampleText).assertMessage(sampleText), + ); + await step('AND the timestamp is shown under the 1st message', () => + userRobot.assertMessageTimestamps(1), + ); + await step('WHEN user sends a message that does not pass moderation', () => + userRobot + .sendMessage(forbiddenWord) + .assertMessage('Message was blocked by moderation policies'), + ); + await step('THEN messages are not grouped, 1st message shows the timestamp', () => + userRobot.assertMessage(sampleText).assertMessageTimestamps(1), + ); + }); + + it('test_messageRendersTimestampAgain_whenMessageLastInGroupIsHardDeleted', async () => { + allureId(13185); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant inserts 3 group messages', async () => { + await participantRobot.sendMessage('1').sendMessage('2').sendMessage('3'); + await userRobot.assertMessage('3').assertMessageTimestamps(1); + }); + await step('WHEN participant hard deletes last message', () => + participantRobot.deleteMessage(true), + ); + await step('THEN previous message should re-render timestamp', () => + userRobot.assertMessage('3', false).assertMessageTimestamps(1), + ); + }); + + it('test_messageRendersTimestampAgain_whenMessageLastInGroupIsSoftDeleted', async () => { + allureId(13186); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant inserts 3 group messages', async () => { + await participantRobot.sendMessage('1').sendMessage('2').sendMessage('3'); + await userRobot.assertMessage('3').assertMessageTimestamps(1); + }); + await step('WHEN participant soft deletes last message', () => + participantRobot.deleteMessage(false), + ); + await step('THEN previous message should re-render timestamp', () => + userRobot.assertDeletedMessage('3').assertMessageTimestamps(1), + ); + }); }); diff --git a/examples/E2ETestApp/e2e/tests/ModerationTests.e2e.ts b/examples/E2ETestApp/e2e/tests/ModerationTests.e2e.ts new file mode 100644 index 0000000000..29706790d9 --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/ModerationTests.e2e.ts @@ -0,0 +1,168 @@ +import { describe, it } from '@jest/globals'; + +import { ParticipantRobot } from '../robots'; +import { allureId, step } from '../support/step'; +import { setUpStreamTestCase } from '../support/StreamTestCase'; + +/** + * The moderation options the message menu offers for another user's message: flagging it, and + * muting or blocking its author. + */ +describe('ModerationTests', () => { + const { backendRobot, participantRobot, userRobot } = setUpStreamTestCase(); + const sampleText = 'Test'; + // Seeded messages alternate authors starting with the user, so "2" is the participant's. + const participantMessageText = '2'; + const groupChannelName = '1'; + + const participantSendsMessage = async () => { + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends the message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + }; + + it('test_userFlagsMessage', async () => { + allureId(13187); + await participantSendsMessage(); + await step('WHEN user taps on the flag option of the message', () => + userRobot.flagMessage(sampleText), + ); + await step('THEN the flag confirmation is shown', () => + userRobot.assertFlagMessageDialog(true), + ); + await step('WHEN user confirms flagging the message', () => userRobot.confirmFlagMessage()); + await step('THEN the confirmation is dismissed and the message stays in the list', () => + userRobot.assertFlagMessageDialog(false).assertMessage(sampleText), + ); + }); + + it('test_userMutesMessageAuthor', async () => { + allureId(13188); + await participantSendsMessage(); + await step('WHEN user mutes the message author', () => userRobot.muteMessageAuthor(sampleText)); + await step('THEN the message menu offers to unmute the author', () => + userRobot.assertMuteMessageAuthorOption(sampleText, true), + ); + }); + + it('test_userUnmutesMessageAuthor', async () => { + allureId(13189); + await participantSendsMessage(); + await step('AND user mutes the message author', () => + userRobot.muteMessageAuthor(sampleText).assertMuteMessageAuthorOption(sampleText, true), + ); + await step('WHEN user unmutes the message author', () => + userRobot.unmuteMessageAuthor(sampleText), + ); + await step('THEN the message menu offers to mute the author again', () => + userRobot.assertMuteMessageAuthorOption(sampleText, false), + ); + }); + + it('test_mutedAuthorStaysMuted_whenUserReopensChannel', async () => { + allureId(13190); + await participantSendsMessage(); + await step('AND user mutes the message author', () => + userRobot.muteMessageAuthor(sampleText).assertMuteMessageAuthorOption(sampleText, true), + ); + await step('WHEN user reopens the channel', () => + userRobot.moveToChannelListFromMessageList().openChannel(), + ); + await step('THEN the author is still muted', () => + userRobot.assertMuteMessageAuthorOption(sampleText, true), + ); + }); + + it('test_userBlocksMessageAuthor', async () => { + allureId(13191); + await participantSendsMessage(); + await step('WHEN user blocks the message author', () => + userRobot.blockMessageAuthor(sampleText), + ); + await step('THEN the message menu offers to unblock the author', () => + userRobot.assertBlockMessageAuthorOption(sampleText, true), + ); + }); + + it('test_userUnblocksMessageAuthor', async () => { + allureId(13192); + await participantSendsMessage(); + await step('AND user blocks the message author', () => + userRobot.blockMessageAuthor(sampleText).assertBlockMessageAuthorOption(sampleText, true), + ); + await step('WHEN user unblocks the message author', () => + userRobot.unblockMessageAuthor(sampleText), + ); + await step('THEN the message menu offers to block the author again', () => + userRobot.assertBlockMessageAuthorOption(sampleText, false), + ); + }); + + it('test_blockedAuthorStaysBlocked_whenUserReopensChannel', async () => { + allureId(13193); + await participantSendsMessage(); + await step('AND user blocks the message author', () => + userRobot.blockMessageAuthor(sampleText).assertBlockMessageAuthorOption(sampleText, true), + ); + await step('WHEN user reopens the channel', () => + userRobot.moveToChannelListFromMessageList().openChannel(), + ); + await step('THEN the author is still blocked', () => + userRobot.assertBlockMessageAuthorOption(sampleText, true), + ); + }); + + it('test_userBlocksUserInDirectMessageChannel', async () => { + allureId(13194); + await step('GIVEN a direct message channel with the participant exists', () => + backendRobot.generateChannels({ + channelsCount: 1, + messagesCount: 2, + withDirectMessageChannel: true, + }), + ); + await step('AND user opens the direct message channel', () => + userRobot.login().openChannelWithName(ParticipantRobot.userName), + ); + await step('WHEN user blocks the participant', () => + userRobot.blockMessageAuthor(participantMessageText), + ); + await step('THEN the message menu offers to unblock the participant', () => + userRobot.assertBlockMessageAuthorOption(participantMessageText, true), + ); + }); + + it('test_directMessageChannelDisappears_whenUserBlocksParticipant', async () => { + allureId(13195); + await step('GIVEN a direct message channel with the participant exists', () => + backendRobot.generateChannels({ + channelsCount: 1, + messagesCount: 2, + withDirectMessageChannel: true, + }), + ); + await step('AND user sees it in the channel list', () => + userRobot.login().assertChannelCount(2).assertChannelWithName(ParticipantRobot.userName), + ); + await step('WHEN user blocks the participant in the direct message channel', () => + userRobot + .openChannelWithName(ParticipantRobot.userName) + .blockMessageAuthor(participantMessageText) + .moveToChannelListFromMessageList(), + ); + await step('THEN the direct message channel disappears from the channel list', () => + userRobot.assertChannelCount(1).assertChannelWithName(ParticipantRobot.userName, false), + ); + await step('WHEN user unblocks the participant from the group channel', () => + userRobot + .openChannelWithName(groupChannelName) + .unblockMessageAuthor(participantMessageText) + .moveToChannelListFromMessageList(), + ); + await step('THEN the direct message channel is shown in the channel list again', () => + userRobot.assertChannelCount(2).assertChannelWithName(ParticipantRobot.userName), + ); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/PinnedMessagesTests.e2e.ts b/examples/E2ETestApp/e2e/tests/PinnedMessagesTests.e2e.ts new file mode 100644 index 0000000000..bab4364c96 --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/PinnedMessagesTests.e2e.ts @@ -0,0 +1,103 @@ +import { describe, it } from '@jest/globals'; + +import { MessageDeliveryStatus } from '../mock-server/DataTypes'; +import { ParticipantRobot } from '../robots'; +import { allureId, step } from '../support/step'; +import { setUpStreamTestCase } from '../support/StreamTestCase'; + +describe('PinnedMessagesTests', () => { + const { participantRobot, userRobot } = setUpStreamTestCase(); + const sampleText = 'Test'; + + const participantSendsMessage = async () => { + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends the message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + }; + + const userSendsMessage = async () => { + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user sends the message', () => + userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(MessageDeliveryStatus.SENT), + ); + }; + + const openPinnedMessages = () => + userRobot + .moveToChannelListFromMessageList() + .openChannelMenu() + .tapOnViewChannelInfo() + .tapOnPinnedMessagesOption(); + + it('test_userPinsMessage', async () => { + allureId(13196); + await participantSendsMessage(); + await step('WHEN user pins the message', () => userRobot.pinMessage()); + await step('THEN the message shows the pinned by you label', () => + userRobot.assertMessagePinnedLabel(), + ); + }); + + it('test_userUnpinsMessage', async () => { + allureId(13197); + await participantSendsMessage(); + await step('AND user pins the message', () => + userRobot.pinMessage().assertMessagePinnedLabel(), + ); + await step('WHEN user unpins the message', () => userRobot.unpinMessage()); + await step('THEN the message shows no pinned label', () => + userRobot.assertMessagePinnedLabel(undefined, false), + ); + }); + + it('test_participantPinsMessage', async () => { + allureId(13198); + await userSendsMessage(); + await step('WHEN participant pins the message', () => participantRobot.pinMessage()); + await step('THEN the message shows the pinned by participant label', () => + userRobot.assertMessagePinnedLabel(ParticipantRobot.userName), + ); + }); + + it('test_participantUnpinsMessage', async () => { + allureId(13199); + await userSendsMessage(); + await step('AND participant pins the message', async () => { + await participantRobot.pinMessage(); + await userRobot.assertMessagePinnedLabel(ParticipantRobot.userName); + }); + await step('WHEN participant unpins the message', () => participantRobot.unpinMessage()); + await step('THEN the message shows no pinned label', () => + userRobot.assertMessagePinnedLabel(ParticipantRobot.userName, false), + ); + }); + + it('test_pinnedMessageIsShownOnThePinnedMessagesScreen', async () => { + allureId(13200); + await participantSendsMessage(); + await step('AND user pins the message', () => + userRobot.pinMessage().assertMessagePinnedLabel(), + ); + await step('WHEN user opens the pinned messages screen', () => openPinnedMessages()); + await step('THEN the pinned message is shown', () => + userRobot.assertPinnedMessagesScreen().assertMessageInPinnedMessages(sampleText), + ); + }); + + it('test_unpinnedMessageIsNotShownOnThePinnedMessagesScreen', async () => { + allureId(13201); + await participantSendsMessage(); + await step('AND user pins the message', () => + userRobot.pinMessage().assertMessagePinnedLabel(), + ); + await step('WHEN user unpins the message', () => + userRobot.unpinMessage().assertMessagePinnedLabel(undefined, false), + ); + await step('AND user opens the pinned messages screen', () => openPinnedMessages()); + await step('THEN no pinned message is shown', () => + userRobot.assertPinnedMessagesScreen().assertPinnedMessagesAreEmpty(), + ); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/PollsTests.e2e.ts b/examples/E2ETestApp/e2e/tests/PollsTests.e2e.ts new file mode 100644 index 0000000000..b1839b3d7a --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/PollsTests.e2e.ts @@ -0,0 +1,107 @@ +import { describe, it } from '@jest/globals'; + +import { MessageDeliveryStatus } from '../mock-server/DataTypes'; +import { ParticipantRobot } from '../robots'; +import { allureId, step } from '../support/step'; +import { setUpStreamTestCase } from '../support/StreamTestCase'; + +describe('PollsTests', () => { + const { participantRobot, userRobot } = setUpStreamTestCase(); + const question = 'Best color?'; + const firstOption = 'Red'; + const secondOption = 'Blue'; + const options = [firstOption, secondOption]; + + const userCreatesPoll = (multipleVotes = false) => + userRobot + .login() + .openChannel() + .createPoll(question, options, { multipleVotes }) + .assertPollMessage(question, multipleVotes); + + it('test_pollMessageIsShown_whenUserCreatesPoll', async () => { + allureId(13202); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN user creates a poll from the attachment picker', () => + userRobot.createPoll(question, options), + ); + await step('THEN the poll message shows the question and the options', () => + userRobot + .assertPollMessage(question) + .assertPollOption(firstOption) + .assertPollOption(secondOption), + ); + }); + + it('test_optionIsChecked_whenUserVotesInPoll', async () => { + allureId(13203); + await step('GIVEN user creates a poll', () => userCreatesPoll()); + await step('WHEN user votes for an option', () => userRobot.castPollVote(firstOption)); + await step('THEN the option is checked', () => userRobot.assertPollOption(firstOption, true)); + }); + + it('test_optionIsUnchecked_whenUserRemovesPollVote', async () => { + allureId(13204); + await step('GIVEN user creates a poll and votes', () => + userCreatesPoll().castPollVote(firstOption).assertPollOption(firstOption, true), + ); + await step('WHEN user taps on the voted option again', () => + userRobot.removePollVote(firstOption), + ); + await step('THEN the option is unchecked', () => + userRobot.assertPollOption(firstOption, false), + ); + }); + + it('test_voteMovesToAnotherOption_whenUserVotesForAnotherOptionInSingleAnswerPoll', async () => { + allureId(13205); + await step('GIVEN user creates a single answer poll and votes', () => + userCreatesPoll().castPollVote(firstOption).assertPollOption(firstOption, true), + ); + await step('WHEN user votes for another option', () => userRobot.castPollVote(secondOption)); + await step('THEN the vote moves to the other option', () => + userRobot + .assertPollOption(secondOption, true) + .assertPollOption(firstOption, false) + .assertPollVoteCounts(1, 1) + .assertPollVoteCounts(0, 1), + ); + }); + + it('test_userVotesForSeveralOptions_whenPollAllowsMultipleVotes', async () => { + allureId(13206); + await step('GIVEN user creates a poll that allows multiple votes', () => userCreatesPoll(true)); + await step('WHEN user votes for both options', () => + userRobot + .castPollVote(firstOption) + .assertPollOption(firstOption, true) + .castPollVote(secondOption), + ); + await step('THEN both options are checked', () => + userRobot.assertPollOption(secondOption, true).assertPollOption(firstOption, true), + ); + }); + + it('test_participantVoteIsShownInPollResults_whenParticipantVotesInPoll', async () => { + allureId(13207); + await step('GIVEN user creates a poll', () => userCreatesPoll()); + await step('AND participant votes for an option', async () => { + // A vote arriving before the poll message is delivered is rejected by the server. + await userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.SENT); + await participantRobot.castPollVote(firstOption); + }); + await step('WHEN user opens the poll results', () => + userRobot.assertPollVoteCounts(1, 1).openPollResults(), + ); + await step('THEN the participant vote is shown', () => + userRobot.assertPollResults(ParticipantRobot.userName), + ); + }); + + it('test_pollIsClosed_whenUserEndsPoll', async () => { + allureId(13208); + await step('GIVEN user creates a poll', () => userCreatesPoll()); + await step('WHEN user ends the poll', () => userRobot.endPoll()); + await step('THEN the poll is shown as ended', () => userRobot.assertPollClosed()); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/QuotedReplyTests.e2e.ts b/examples/E2ETestApp/e2e/tests/QuotedReplyTests.e2e.ts new file mode 100644 index 0000000000..e608aba30b --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/QuotedReplyTests.e2e.ts @@ -0,0 +1,913 @@ +import { describe, it } from '@jest/globals'; + +import { AttachmentType } from '../mock-server/DataTypes'; +import { allureId, step } from '../support/step'; +import { setUpStreamTestCase } from '../support/StreamTestCase'; + +describe('QuotedReplyTests', () => { + const { backendRobot, participantRobot, userRobot } = setUpStreamTestCase(); + const sampleText = 'Test message'; + const quoteReply = 'Alright'; + const messagesCount = 30; + const firstMessage = '1'; + const deletedText = 'Message deleted'; + const invalidCommand = 'invalid command'; + + /** A channel with one message (`sampleText` unless given) and `repliesCount` thread replies. */ + const openChannelWithThread = async (repliesCount: number, messagesText = sampleText) => { + await backendRobot.generateChannels({ + channelsCount: 1, + messagesCount: 1, + messagesText, + repliesCount, + }); + await userRobot.login().openChannel().assertMessage(messagesText); + }; + + // MARK: Channel + + it('test_whenSwipingMessage_thenMessageIsQuotedReply', async () => { + allureId(13209); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ + channelsCount: 1, + messagesCount: 1, + messagesText: sampleText, + }); + await userRobot.login().openChannel().assertMessage(sampleText); + }); + await step('WHEN user swipes a message', () => userRobot.swipeMessage()); + await step('AND user sends a message', () => userRobot.sendMessage(quoteReply)); + await step('THEN a quote reply is sent', () => + userRobot.assertQuotedMessage({ quote: sampleText, text: quoteReply }), + ); + }); + + it('test_quotedReplyInList_whenUserAddsQuotedReply', async () => { + allureId(13210); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends a message', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }); + await step("WHEN user adds a quoted reply to participant's message", () => + userRobot.quoteMessage(quoteReply), + ); + await step('THEN user observes the quote reply in message list', () => + userRobot + .assertQuotedMessage({ quote: sampleText, text: quoteReply }) + .assertScrollToBottomButton(false), + ); + }); + + it('test_quotedReplyInList_whenParticipantAddsQuotedReply_Message', async () => { + allureId(13211); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step("WHEN participant adds a quoted reply to user's message", () => + participantRobot.quoteMessage(quoteReply), + ); + await step('THEN user observes the quote reply in message list', () => + userRobot + .assertQuotedMessage({ quote: '1', text: quoteReply }) + .assertScrollToBottomButton(false), + ); + }); + + it('test_quotedReplyNotInList_whenUserAddsQuotedReply', async () => { + allureId(13212); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount }); + await userRobot.login().openChannel(); + }); + await step('WHEN user adds a quoted reply to message', () => + userRobot + .scrollMessageListUpToMessage(firstMessage, 15) + .quoteMessageWithText(quoteReply, firstMessage), + ); + await step('THEN user observes the quote reply in message list', () => + userRobot + .assertQuotedMessage({ quote: firstMessage, text: quoteReply }) + .assertScrollToBottomButton(false), + ); + await step('WHEN user taps on a quoted message', () => userRobot.tapOnQuotedMessage()); + await step('THEN user is scrolled up to the quote', () => + userRobot + .assertQuotedMessage({ isDisplayed: false, quote: firstMessage, text: quoteReply }) + .assertScrollToBottomButton(true), + ); + }); + + it('test_quotedReplyNotInList_whenParticipantAddsQuotedReply_Message', async () => { + allureId(13213); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount }); + await userRobot.login().openChannel(); + }); + await step("WHEN participants adds a quoted reply to user's message", () => + participantRobot.quoteMessage(quoteReply, false), + ); + await step('THEN user observes the quote reply in message list', () => + userRobot + .assertQuotedMessage({ quote: firstMessage, text: quoteReply }) + .assertScrollToBottomButton(false), + ); + await step('WHEN user taps on a quoted message', () => userRobot.tapOnQuotedMessage()); + await step('THEN user is scrolled up to the quote', () => + userRobot + .assertQuotedMessage({ isDisplayed: false, quote: firstMessage, text: quoteReply }) + .assertScrollToBottomButton(true), + ); + }); + + it('test_quotedReplyNotInList_whenParticipantAddsQuotedReply_File', async () => { + allureId(13214); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount }); + await userRobot.login().openChannel(); + }); + await step("WHEN participants adds a quoted reply to user's message with a file", () => + participantRobot.quoteMessageWithAttachment(AttachmentType.FILE, 1, false), + ); + await step('THEN user observes the quote reply in message list', () => + userRobot + .assertFile(true) + .assertQuotedMessage({ quote: firstMessage }) + .assertScrollToBottomButton(false), + ); + await step('WHEN user taps on a quoted message', () => userRobot.tapOnQuotedMessage()); + await step('THEN user is scrolled up to the quote', () => + userRobot + .assertQuotedMessage({ isDisplayed: false, quote: firstMessage }) + .assertScrollToBottomButton(true), + ); + }); + + it('test_quotedReplyNotInList_whenParticipantAddsQuotedReply_Giphy', async () => { + allureId(13215); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount }); + await userRobot.login().openChannel(); + }); + await step("WHEN participants adds a quoted reply to user's message with a giphy", () => + participantRobot.quoteMessageWithGiphy(false), + ); + await step('THEN user observes the quote reply in message list', () => + userRobot + .assertGiphyImage(true) + .assertQuotedMessage({ quote: firstMessage }) + .assertScrollToBottomButton(false), + ); + await step('WHEN user taps on a quoted message', () => userRobot.tapOnQuotedMessage()); + await step('THEN user is scrolled up to the quote', () => + userRobot + .assertGiphyImage(false) + .assertQuotedMessage({ isDisplayed: false, quote: firstMessage }) + .assertScrollToBottomButton(true), + ); + }); + + it('test_quotedReplyIsDeletedByParticipant_deletedMessageIsShown', async () => { + allureId(13216); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('AND participant adds a quoted reply', async () => { + await participantRobot.quoteMessage(quoteReply); + await userRobot.assertQuotedMessage({ quote: '1', text: quoteReply }); + }); + await step('WHEN participant deletes a quoted message', () => participantRobot.deleteMessage()); + await step('THEN user observes Message deleted', () => + userRobot + .assertDeletedMessage(quoteReply) + .assertQuotedMessage({ isDisplayed: false, text: quoteReply }), + ); + }); + + it('test_originalQuoteIsDeletedByParticipant_deletedMessageIsShown', async () => { + allureId(13217); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends a message', async () => { + await participantRobot.sendMessage('1'); + await userRobot.assertMessage('1'); + }); + await step('AND user adds a quoted reply', () => + userRobot.quoteMessage(quoteReply).assertQuotedMessage({ quote: '1', text: quoteReply }), + ); + await step('WHEN participant deletes an original message', () => + participantRobot.deleteMessage(), + ); + await step('THEN deleted message is shown', () => userRobot.assertDeletedMessage('1')); + await step('AND deleted message is shown in quoted reply bubble', () => + userRobot.assertQuotedMessage({ quote: deletedText, text: quoteReply }), + ); + }); + + it('test_quotedReplyIsDeletedByUser_deletedMessageIsShown', async () => { + allureId(13218); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('AND user adds a quoted reply', () => + userRobot.quoteMessage(quoteReply).assertQuotedMessage({ quote: '1', text: quoteReply }), + ); + await step('WHEN user deletes a quoted message', () => + userRobot.deleteMessageWithText(quoteReply), + ); + await step('THEN user observes Message deleted', () => + userRobot + .assertDeletedMessage(quoteReply) + .assertQuotedMessage({ isDisplayed: false, text: quoteReply }), + ); + }); + + it('test_originalQuoteIsDeletedByUser_deletedMessageIsShown', async () => { + allureId(13219); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ + channelsCount: 1, + messagesCount: 1, + messagesText: sampleText, + }); + await userRobot.login().openChannel(); + }); + await step('AND user adds a quoted reply', () => + userRobot + .quoteMessage(quoteReply) + .assertQuotedMessage({ quote: sampleText, text: quoteReply }), + ); + await step('WHEN user deletes an original message', () => + userRobot.deleteMessageWithText(sampleText), + ); + await step('THEN deleted message is shown', () => userRobot.assertDeletedMessage(sampleText)); + await step('AND deleted message is shown in quoted reply bubble', () => + userRobot.assertQuotedMessage({ quote: deletedText, text: quoteReply }), + ); + }); + + // https://linear.app/stream/issue/RN-496 + it.skip('test_userAddsQuotedReplyWithInvalidCommand', async () => { + allureId(13220); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount }); + await userRobot.login().openChannel(); + }); + await step('WHEN user quotes a message with invalid command', () => + userRobot.quoteMessage(`/${invalidCommand}`), + ); + await step('THEN user observes invalid command alert', () => + userRobot + .assertInvalidCommandMessage(invalidCommand) + .assertQuotedMessage({ isDisplayed: false, text: quoteReply }), + ); + }); + + // https://linear.app/stream/issue/RN-496 + it.skip('test_unreadCount_whenUserSendsInvalidCommand_and_jumpingOnQuotedMessage', async () => { + allureId(13221); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount }); + await userRobot.login().openChannel(); + }); + await step('WHEN user adds a quoted reply to participant message', () => + userRobot + .scrollMessageListUpToMessage(firstMessage, 15) + .quoteMessageWithText(quoteReply, firstMessage) + .assertQuotedMessage({ quote: firstMessage, text: quoteReply }), + ); + await step('AND user quotes a message with invalid command', () => + userRobot.quoteMessageWithText(`/${invalidCommand}`, quoteReply), + ); + await step('THEN user observes invalid command alert', () => + userRobot.assertInvalidCommandMessage(invalidCommand), + ); + await step('WHEN user taps on a quoted message', () => userRobot.tapOnQuotedMessage()); + await step('THEN user is scrolled up to the quoted message', () => + userRobot.assertScrollToBottomButton(true).assertScrollToBottomButtonUnreadCount(0), + ); + }); + + // MARK: Jumping + + // Fails on some runs: the list stays on the jumped page (https://linear.app/stream/issue/RN-506). + it('test_messageListScrollsToLatest_whenUserSendsMessageAfterJumpingToQuotedMessage', async () => { + allureId(13222); + const quotedText = '30'; + const newMessage = 'New message after jump'; + await step('GIVEN user opens a channel with 60 messages', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 60 }); + await userRobot.login().openChannel(); + }); + await step('AND user quotes a mid-page message', () => + userRobot + .scrollMessageListUpToMessage(quotedText, 15) + .quoteMessageWithText(quoteReply, quotedText) + .assertQuotedMessage({ quote: quotedText, text: quoteReply }), + ); + await step('AND user closes and reopens the channel', () => + userRobot.moveToChannelListFromMessageList().openChannel(), + ); + await step('WHEN user taps on the quoted message', () => + userRobot.tapOnQuotedMessage().assertScrollToBottomButton(true), + ); + await step('AND user sends a new message', () => userRobot.sendMessage(newMessage)); + await step('THEN message list shows the latest message', () => + userRobot.assertMessage(newMessage).assertScrollToBottomButton(false), + ); + }); + + it('test_messageListScrollsToLatest_whenUserTapsScrollToBottomAfterJumpingToQuotedMessage', async () => { + allureId(13223); + const quotedText = '30'; + await step('GIVEN user opens a channel with 60 messages', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 60 }); + await userRobot.login().openChannel(); + }); + await step('AND user quotes a mid-page message', () => + userRobot + .scrollMessageListUpToMessage(quotedText, 15) + .quoteMessageWithText(quoteReply, quotedText) + .assertQuotedMessage({ quote: quotedText, text: quoteReply }), + ); + await step('AND user closes and reopens the channel', () => + userRobot.moveToChannelListFromMessageList().openChannel(), + ); + await step('WHEN user taps on the quoted message', () => + userRobot.tapOnQuotedMessage().assertScrollToBottomButton(true), + ); + await step('AND user taps on the scroll to bottom button', () => + userRobot.tapOnScrollToBottomButton(), + ); + await step('THEN message list shows the latest message', () => + userRobot.assertMessage(quoteReply).assertScrollToBottomButton(false), + ); + }); + + it('test_channelPreviewDoesNotChange_whenUserLeavesChannelAfterJumpingToMidPage', async () => { + allureId(13224); + const quotedText = '30'; + await step('GIVEN user opens a channel with 60 messages', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 60 }); + await userRobot.login().openChannel(); + }); + await step('AND user quotes a mid-page message', () => + userRobot + .scrollMessageListUpToMessage(quotedText, 15) + .quoteMessageWithText(quoteReply, quotedText) + .assertQuotedMessage({ quote: quotedText, text: quoteReply }), + ); + await step('AND user reopens the channel', () => + userRobot + .moveToChannelListFromMessageList() + .assertMessageInChannelPreview(quoteReply, true) + .openChannel(), + ); + await step('AND user jumps to a mid-page', () => + userRobot.tapOnQuotedMessage().assertScrollToBottomButton(true).assertMessage(quotedText), + ); + await step('WHEN user leaves the channel', () => userRobot.moveToChannelListFromMessageList()); + await step('THEN the preview message does not change', () => + userRobot.assertMessageInChannelPreview(quoteReply, true), + ); + }); + + it('test_quotedMessageIsPresent_afterReenteringChannelAndLoadingNewMessages', async () => { + allureId(13225); + const quotedText = '30'; + await step('GIVEN user opens a channel with 60 messages', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 60 }); + await userRobot.login().openChannel(); + }); + await step('AND user quotes a message that is on an old page', () => + userRobot + .scrollMessageListUpToMessage(quotedText, 15) + .quoteMessageWithText(quoteReply, quotedText) + .assertQuotedMessage({ quote: quotedText, text: quoteReply }), + ); + await step('AND user leaves the channel and re-enters', () => + userRobot.moveToChannelListFromMessageList().openChannel(), + ); + await step('WHEN user jumps to the quoted message', () => + userRobot.tapOnQuotedMessage().assertScrollToBottomButton(true).assertMessage(quotedText), + ); + await step('AND user loads all the newest messages until reaching the bottom', () => + userRobot.scrollMessageListDownToMessage(quoteReply, 30).assertMessage(quoteReply), + ); + await step('AND user scrolls back to the quoted message', () => + userRobot.scrollMessageListUpToMessage(quotedText, 30), + ); + await step('THEN the quoted message is still there', () => userRobot.assertMessage(quotedText)); + }); + + // MARK: Thread + + // https://linear.app/stream/issue/RN-490 + it.skip('test_whenUserAddsQuotedReply_InThread', async () => { + allureId(13226); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ + channelsCount: 1, + messagesCount: 1, + repliesCount: 1, + repliesText: sampleText, + }); + await userRobot.login().openChannel(); + }); + await step('WHEN user adds a quoted reply to message in thread', () => + userRobot + .openThread(0, false) + .assertMessage(sampleText) + .quoteMessageWithText(quoteReply, sampleText), + ); + await step('THEN user observes the quote reply in thread', () => + userRobot + .assertQuotedMessage({ quote: sampleText, text: quoteReply }) + .assertScrollToBottomButton(false), + ); + }); + + it('test_whenParticipantAddsQuotedReply_InThread', async () => { + allureId(13227); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('AND user sends a message in thread', () => + userRobot.openThread().sendMessage(sampleText).assertMessage(sampleText), + ); + await step("WHEN participant adds a quoted reply to user's message in thread", () => + participantRobot.quoteMessageInThread(quoteReply), + ); + await step('THEN user observes the quote reply in thread', () => + userRobot + .assertQuotedMessage({ quote: sampleText, text: quoteReply }) + .assertScrollToBottomButton(false), + ); + }); + + // https://linear.app/stream/issue/RN-490 + it.skip('test_quotedReplyInList_whenUserAddsQuotedReply_InThread', async () => { + allureId(13228); + const repliesCount = 20; + await step('GIVEN user opens the channel', () => openChannelWithThread(repliesCount)); + await step('WHEN user adds a quoted reply to participant message in thread', () => + userRobot + .openThread(0, false) + .scrollMessageListUpToMessage(firstMessage, 10) + .quoteMessageWithText(quoteReply, firstMessage), + ); + await step('THEN user observes the quote reply in thread', () => + userRobot + .assertQuotedMessage({ quote: firstMessage, text: quoteReply }) + .assertScrollToBottomButton(false), + ); + await step('WHEN user taps on a quoted message', () => userRobot.tapOnQuotedMessage()); + await step('THEN user is scrolled up to the quoted message', () => + userRobot.assertMessage(firstMessage).assertScrollToBottomButton(true), + ); + }); + + it('test_quotedReplyInList_whenParticipantAddsQuotedReply_Message_InThread', async () => { + allureId(13229); + const repliesCount = 25; + await step('GIVEN user opens the channel', () => openChannelWithThread(repliesCount)); + await step('WHEN participant adds a quoted reply', () => + participantRobot.quoteMessageInThread(quoteReply, false, false), + ); + await step('THEN user observes the quote reply in thread', () => + userRobot + .openThread(0, false) + .assertQuotedMessage({ quote: firstMessage, text: quoteReply }) + .assertScrollToBottomButton(false), + ); + await step('WHEN user taps on a quoted message', () => userRobot.tapOnQuotedMessage()); + await step('THEN user is scrolled up to the quoted message', () => + userRobot.assertMessage(firstMessage).assertScrollToBottomButton(true), + ); + }); + + // https://linear.app/stream/issue/RN-490 + it.skip('test_quotedReplyNotInList_whenUserAddsQuotedReply_InThread', async () => { + allureId(13230); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ + channelsCount: 1, + messagesCount: 1, + repliesCount: messagesCount, + }); + await userRobot.login().openChannel().waitForMessageListToLoad(); + }); + await step('WHEN user adds a quoted reply to message in thread', () => + userRobot + .openThread(0, false) + .scrollMessageListUpToMessage(firstMessage, 15) + .quoteMessageWithText(quoteReply, firstMessage), + ); + await step('THEN user observes the quote reply in thread', () => + userRobot + .assertQuotedMessage({ quote: firstMessage, text: quoteReply }) + .assertScrollToBottomButton(false), + ); + await step('WHEN user taps on a quoted message', () => userRobot.tapOnQuotedMessage()); + await step('THEN user is scrolled up to the quote', () => + userRobot + .assertQuotedMessage({ isDisplayed: false, quote: firstMessage, text: quoteReply }) + .assertScrollToBottomButton(true), + ); + }); + + it('test_quotedReplyNotInList_whenParticipantAddsQuotedReply_Message_InThread', async () => { + allureId(13231); + await step('GIVEN user opens the channel', () => openChannelWithThread(messagesCount)); + await step("WHEN participants adds a quoted reply to user's message", () => + participantRobot.quoteMessageInThread(quoteReply, false, false), + ); + await step('THEN user observes the quote reply in thread', () => + userRobot + .openThread(0, false) + .assertQuotedMessage({ quote: firstMessage, text: quoteReply }) + .assertScrollToBottomButton(false), + ); + await step('WHEN user taps on a quoted message', () => userRobot.tapOnQuotedMessage()); + await step('THEN user is scrolled up to the quote', () => + userRobot + .assertQuotedMessage({ isDisplayed: false, quote: firstMessage, text: quoteReply }) + .assertScrollToBottomButton(true), + ); + }); + + /** A 90-reply thread whose first reply the participant quotes; the user jumps to the quote. */ + const jumpToTheFirstReplyInLongThread = async () => { + await step('GIVEN a thread with more replies than two pages', () => openChannelWithThread(90)); + await step('AND participant adds a quoted reply to the first reply in thread', () => + participantRobot.quoteMessageInThread(quoteReply, false, false), + ); + await step('AND user opens the thread and taps on the quoted message', () => + userRobot.openThread(0, false).tapOnQuotedMessage().assertMessage(firstMessage), + ); + }; + + // https://linear.app/stream/issue/RN-510 + it.skip('test_threadShowsOnlyThePageAroundTheQuote_whenUserJumpsToQuotedReplyNotInList', async () => { + allureId(13232); + const newReply = 'New reply'; + const nextReply = 'Next reply'; + await jumpToTheFirstReplyInLongThread(); + await step('THEN the replies after the quote follow without a gap', () => + userRobot.scrollMessageListDownToMessage('32', 15).assertMessage('32'), + ); + await step( + 'WHEN participant sends a new reply and user taps on the scroll to bottom button', + async () => { + await participantRobot.sendMessageInThread(newReply); + await userRobot.tapOnScrollToBottomButton(); + }, + ); + await step('THEN user observes the new reply', () => userRobot.assertMessage(newReply)); + await step('WHEN participant sends another reply', () => + participantRobot.sendMessageInThread(nextReply), + ); + await step('THEN user observes the other reply at the bottom', () => + userRobot.assertMessage(nextReply), + ); + }); + + // https://linear.app/stream/issue/RN-488 + it.skip('test_ownReplyIsShownWithTheNewestReplies_whenUserRepliesFromJumpedToPageInThread', async () => { + allureId(13233); + const ownReply = 'Own reply'; + await jumpToTheFirstReplyInLongThread(); + await step('WHEN user sends a reply', () => userRobot.sendMessageInThread(ownReply)); + await step('THEN user observes the reply right after the newest replies', () => + userRobot.assertMessage(ownReply).assertMessage('90'), + ); + }); + + // https://linear.app/stream/issue/RN-510 + it.skip('test_replySentWhileOnJumpedToPage_isShown_whenUserScrollsDownToNewestReplies', async () => { + allureId(13234); + const newReply = 'New reply'; + await jumpToTheFirstReplyInLongThread(); + await step('WHEN participant sends a reply', () => + participantRobot.sendMessageInThread(newReply), + ); + await step('AND user scrolls down to the newest replies', () => + userRobot.scrollMessageListDownToMessage(newReply, 30), + ); + await step('THEN user observes the reply after the newest replies', () => + userRobot.assertMessage(newReply).assertMessage(quoteReply), + ); + }); + + it('test_quotedReplyNotInList_whenParticipantAddsQuotedReply_File_InThread', async () => { + allureId(13235); + await step('GIVEN user opens the channel', () => openChannelWithThread(messagesCount)); + await step("WHEN participants adds a quoted reply to user's message with a file", () => + participantRobot.quoteMessageWithAttachmentInThread(AttachmentType.FILE, 1, false, false), + ); + await step('THEN user observes the quote reply in thread', () => + userRobot + .openThread(0, false) + .assertFile(true) + .assertQuotedMessage({ quote: firstMessage }) + .assertScrollToBottomButton(false), + ); + await step('WHEN user taps on a quoted message', () => userRobot.tapOnQuotedMessage()); + await step('THEN user is scrolled up to the quote', () => + userRobot + .assertQuotedMessage({ isDisplayed: false, quote: firstMessage }) + .assertScrollToBottomButton(true), + ); + }); + + it('test_quotedReplyNotInList_whenParticipantAddsQuotedReply_Giphy_InThread', async () => { + allureId(13236); + await step('GIVEN user opens the channel', () => openChannelWithThread(messagesCount)); + await step("WHEN participants adds a quoted reply to user's message with a giphy", () => + participantRobot.quoteMessageWithGiphyInThread(false, false), + ); + await step('THEN user observes the quote reply in message list', () => + userRobot + .openThread(0, false) + .assertGiphyImage(true) + .assertQuotedMessage({ quote: firstMessage }) + .assertScrollToBottomButton(false), + ); + await step('WHEN user taps on a quoted message', () => userRobot.tapOnQuotedMessage()); + await step('THEN user is scrolled up to the quote', () => + userRobot + .assertGiphyImage(false) + .assertQuotedMessage({ isDisplayed: false, quote: firstMessage }) + .assertScrollToBottomButton(true), + ); + }); + + // https://linear.app/stream/issue/RN-490 + it.skip('test_userAddsQuotedReplyWithInvalidCommand_InThread', async () => { + allureId(13237); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1, repliesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('WHEN user quotes a message with invalid command in thread', () => + userRobot.openThread(0, false).quoteMessage(`/${invalidCommand}`), + ); + await step('THEN user observes invalid command alert', () => + userRobot + .assertInvalidCommandMessage(invalidCommand) + .assertQuotedMessage({ isDisplayed: false, text: quoteReply }), + ); + }); + + it('test_threadRepliesCount_One', async () => { + allureId(13238); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('WHEN participant sends one thread reply', () => + participantRobot.sendMessageInThread(sampleText), + ); + await step('THEN user observes one thread reply label in the channel', () => + userRobot.assertThreadReplyLabel(1), + ); + await step('WHEN user opens the thread', () => userRobot.openThread(0, false)); + await step('THEN user observes one reply in the thread', () => + userRobot.assertThreadReplyLabel(1, true), + ); + }); + + it('test_threadRepliesCount_Multiple', async () => { + allureId(13239); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('WHEN participant sends multiple thread replies', async () => { + for (let i = 0; i < 5; i++) { + await participantRobot.sendMessageInThread(`${sampleText} ${i}`); + } + }); + await step('THEN user observes the thread reply label in the channel', () => + userRobot.assertThreadReplyLabel(5), + ); + await step('WHEN user opens the thread', () => userRobot.openThread(0, false)); + await step('THEN user observes all replies in the thread', () => + userRobot.assertThreadReplyLabel(5, true), + ); + }); + + it('test_quotedReplyInThreadAndAlsoInChannel', async () => { + allureId(13240); + const quotedText = `${messagesCount}`; + await step('GIVEN user opens the channel', () => openChannelWithThread(messagesCount)); + await step('WHEN participant adds a quoted reply in thread and also in channel', () => + participantRobot.quoteMessageInThread(quoteReply, true), + ); + await step('THEN user observes the quoted reply in channel', () => + userRobot + .assertQuotedMessage({ quote: quotedText, text: quoteReply }) + .assertScrollToBottomButton(false), + ); + await step('AND user observes the quoted reply also in thread', () => + userRobot + .openThread(0, false) + .assertQuotedMessage({ quote: quotedText, text: quoteReply }) + .assertScrollToBottomButton(false), + ); + }); + + // https://linear.app/stream/issue/RN-487 + it.skip('test_jumpToQuotedMessageInThread_fromChannel', async () => { + allureId(13241); + await step('GIVEN user opens a channel with a thread', () => openChannelWithThread(10)); + await step('AND participant sends a quoted message in thread (also in channel)', async () => { + await participantRobot.quoteMessageInThread(quoteReply, true, false); + await userRobot.assertQuotedMessage({ quote: firstMessage, text: quoteReply }); + }); + await step('AND participant sends some more messages in thread', async () => { + for (const reply of ['thread reply 1', 'thread reply 2', 'thread reply 3']) { + await participantRobot.sendMessageInThread(reply); + } + }); + await step('WHEN user taps on the quoted message in channel', () => + userRobot.tapOnQuotedMessage(), + ); + await step('THEN user jumps to the quoted message in thread', () => + userRobot.assertThreadIsOpen().assertMessage(firstMessage), + ); + }); + + it('test_jumpToRegularMessageInThread_fromChannel', async () => { + allureId(13242); + const threadReply = 'also in channel'; + await step('GIVEN user opens a channel with a thread', () => openChannelWithThread(10)); + await step('AND participant sends a regular message in thread (also in channel)', async () => { + await participantRobot.sendMessageInThread(threadReply, true); + await userRobot.assertMessage(threadReply); + }); + await step('AND participant sends some more messages in thread', async () => { + for (const reply of ['thread reply 1', 'thread reply 2', 'thread reply 3']) { + await participantRobot.sendMessageInThread(reply); + } + }); + await step("WHEN user taps on 'View' in the header of the participant's message", () => + userRobot.tapOnSentToChannelHeaderLink(), + ); + await step("THEN user jumps to the participant's message in thread", () => + userRobot.assertThreadIsOpen().assertMessage(threadReply), + ); + }); + + it('test_quotedReplyIsDeletedByParticipant_deletedMessageIsShown_InThread', async () => { + allureId(13243); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('AND participant adds a quoted reply', async () => { + await participantRobot.quoteMessageInThread(quoteReply); + await userRobot.assertThreadReplyLabel(1); + }); + await step('WHEN participant deletes a quoted message', () => participantRobot.deleteMessage()); + await step('THEN user observes Message deleted in thread', () => + userRobot.openThread(0, false).assertDeletedMessage(quoteReply), + ); + }); + + // https://linear.app/stream/issue/RN-490 + it.skip('test_originalQuoteIsDeletedByParticipant_deletedMessageIsShown_InThread', async () => { + allureId(13244); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('AND participant sends a message', async () => { + await participantRobot.sendMessageInThread(sampleText); + await userRobot.assertThreadReplyLabel(1); + }); + await step('AND user adds a quoted reply', () => + userRobot + .openThread(0, false) + .assertMessage(sampleText) + .quoteMessageWithText(quoteReply, sampleText) + .assertQuotedMessage({ quote: sampleText, text: quoteReply }), + ); + await step('WHEN participant deletes an original message', () => + participantRobot.deleteMessage(), + ); + await step('THEN deleted message is shown', () => userRobot.assertDeletedMessage(sampleText)); + await step('AND deleted message is shown in quoted reply bubble', () => + userRobot.assertQuotedMessage({ quote: deletedText, text: quoteReply }), + ); + }); + + // https://linear.app/stream/issue/RN-490 + it.skip('test_quotedReplyIsDeletedByUser_deletedMessageIsShown_InThread', async () => { + allureId(13245); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ + channelsCount: 1, + messagesCount: 1, + messagesText: sampleText, + }); + await userRobot.login().openChannel(); + }); + await step('AND user adds a quoted reply in thread', () => + userRobot + .openThread() + .quoteMessageWithText(quoteReply, sampleText) + .assertQuotedMessage({ quote: sampleText, text: quoteReply }), + ); + await step('WHEN user deletes a quoted message', () => + userRobot.deleteMessageWithText(quoteReply), + ); + await step('THEN deleted message is shown', () => + userRobot.assertDeletedMessage(quoteReply).assertQuotedMessage({ isDisplayed: false }), + ); + }); + + // https://linear.app/stream/issue/RN-490 + it.skip('test_originalQuoteIsDeletedByUser_deletedMessageIsShown_InThread', async () => { + allureId(13246); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ + channelsCount: 1, + messagesCount: 1, + messagesText: sampleText, + }); + await userRobot.login().openChannel(); + }); + await step('AND user adds a quoted reply in thread', () => + userRobot + .openThread() + .quoteMessageWithText(quoteReply, sampleText) + .assertQuotedMessage({ quote: sampleText, text: quoteReply }), + ); + await step('WHEN user deletes an original message', () => + userRobot.deleteMessageWithText(sampleText), + ); + await step('THEN deleted message is shown', () => userRobot.assertDeletedMessage(sampleText)); + await step('AND deleted message is shown in quoted reply bubble', () => + userRobot.assertQuotedMessage({ quote: deletedText, text: quoteReply }), + ); + }); + + it('test_rootMessageShouldOnlyBeVisibleInTheLastPageInThread', async () => { + allureId(13247); + await step('GIVEN user opens the channel', () => openChannelWithThread(messagesCount)); + await step('WHEN user opens the thread', () => userRobot.openThread(0, false)); + await step('THEN parent message is not loaded', () => userRobot.assertMessages(sampleText, 0)); + await step('WHEN user scrolls up to load one more page', () => + userRobot.scrollMessageListUpToMessage(sampleText, 15), + ); + await step('THEN parent message is loaded', () => userRobot.assertMessages(sampleText, 1)); + }); + + it('test_rootMessageShouldBeVisibleInThreadIfMessageCountLessThanPageSize', async () => { + allureId(13248); + await step('GIVEN user opens the channel', () => openChannelWithThread(24)); + await step('WHEN user opens the thread with 24 replies', () => userRobot.openThread(0, false)); + await step('THEN parent message is loaded', () => + userRobot.scrollMessageListUpToMessage(sampleText, 10).assertMessages(sampleText, 1), + ); + }); + + it('test_rootMessageShouldNotBeVisibleInThreadIfMessageCountEqualToPageSize', async () => { + allureId(13249); + await step('GIVEN user opens the channel', () => openChannelWithThread(25)); + await step('WHEN user opens the thread with 25 replies', () => userRobot.openThread(0, false)); + await step('THEN parent message is not loaded', () => userRobot.assertMessages(sampleText, 0)); + await step('WHEN user scrolls up to load one more page', () => + userRobot.scrollMessageListUpToMessage(sampleText, 15), + ); + await step('THEN parent message is loaded', () => userRobot.assertMessages(sampleText, 1)); + }); + + // https://linear.app/stream/issue/RN-490 + it.skip('test_quoteReplyRootMessageWhenNotInTheList', async () => { + allureId(13250); + await step('GIVEN user opens the thread', async () => { + await openChannelWithThread(messagesCount); + await userRobot.openThread(0, false); + }); + await step('WHEN user quote replies root message', () => + userRobot + .scrollMessageListUpToMessage(sampleText, 15) + .quoteMessageWithText(quoteReply, sampleText), + ); + await step('AND user reenters the thread', () => + userRobot.tapOnBackButton().openThread(0, false), + ); + await step('AND user jumps to root message', () => userRobot.tapOnQuotedMessage()); + await step('THEN parent message is loaded', () => + userRobot + .assertQuotedMessage({ isDisplayed: false, quote: sampleText, text: quoteReply }) + .assertScrollToBottomButton(true), + ); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/ReactionsTests.e2e.ts b/examples/E2ETestApp/e2e/tests/ReactionsTests.e2e.ts new file mode 100644 index 0000000000..bcd613024b --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/ReactionsTests.e2e.ts @@ -0,0 +1,227 @@ +import { describe, it } from '@jest/globals'; + +import { MessageDeliveryStatus, ReactionType } from '../mock-server/DataTypes'; +import { ParticipantRobot } from '../robots'; +import { disableInternetConnection, enableInternetConnection } from '../support/Device'; +import { sleep } from '../support/Element'; +import { allureId, step } from '../support/step'; +import { itOnAndroid, setUpStreamTestCase } from '../support/StreamTestCase'; + +describe('ReactionsTests', () => { + const { backendRobot, participantRobot, userRobot } = setUpStreamTestCase(); + const sampleText = 'Test'; + + const participantSendsMessage = async () => { + await participantRobot.sendMessage(sampleText); + await userRobot.assertMessage(sampleText); + }; + + it('test_addsReaction', async () => { + allureId(13251); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN user sends the message', () => + userRobot.sendMessage(sampleText).assertMessage(sampleText), + ); + await step('AND user adds the reaction', () => userRobot.addReaction(ReactionType.LIKE)); + await step('THEN the reaction is added', () => + userRobot.assertReaction(ReactionType.LIKE, true), + ); + }); + + it('test_deletesReaction', async () => { + allureId(13252); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN user sends the message', () => + userRobot.sendMessage(sampleText).assertMessage(sampleText), + ); + await step('AND user adds the reaction', () => + userRobot.addReaction(ReactionType.WOW).assertReaction(ReactionType.WOW, true), + ); + await step('AND user removes the reaction', () => userRobot.deleteReaction(ReactionType.WOW)); + await step('THEN the reaction is removed', () => + userRobot.assertReaction(ReactionType.WOW, false), + ); + }); + + // https://linear.app/stream/issue/RN-498 + it.skip('test_userAddsReactionUsingExtendedReactionsPicker', async () => { + allureId(13253); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN participant sends the message', () => participantSendsMessage()); + await step('AND user adds the reaction using the extended reactions picker', () => + userRobot.toggleReactionUsingExtendedPicker(ReactionType.LOL), + ); + await step('THEN the reaction is added', () => + userRobot.assertReaction(ReactionType.LOL, true), + ); + }); + + // https://linear.app/stream/issue/RN-498 + it.skip('test_userRemovesReactionUsingExtendedReactionsPicker', async () => { + allureId(13254); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends the message', () => participantSendsMessage()); + await step('AND user adds the reaction using the extended reactions picker', () => + userRobot + .toggleReactionUsingExtendedPicker(ReactionType.LOL) + .assertReaction(ReactionType.LOL, true), + ); + await step('WHEN user selects the same reaction in the extended reactions picker', () => + userRobot.toggleReactionUsingExtendedPicker(ReactionType.LOL), + ); + await step('THEN the reaction is removed', () => + userRobot.assertReaction(ReactionType.LOL, false), + ); + }); + + it('test_reactionAuthorsSheetIsShown_whenUserTapsOnReaction', async () => { + allureId(13255); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends the message', () => participantSendsMessage()); + await step('AND participant adds the reaction', () => + participantRobot.addReaction(ReactionType.LOVE), + ); + await step('WHEN user taps on the message reaction', () => + userRobot.assertReaction(ReactionType.LOVE, true).tapOnMessageReaction(ReactionType.LOVE), + ); + await step('THEN the reaction authors sheet shows the participant', () => + userRobot.assertReactionAuthor(ParticipantRobot.userName), + ); + }); + + it('test_reactionIsAdded_whenReactingToParticipantsMessage', async () => { + allureId(13256); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN participant sends the message', () => participantSendsMessage()); + await step('AND user adds the reaction', () => userRobot.addReaction(ReactionType.LOVE)); + await step('THEN the reaction is added', () => + userRobot.assertReaction(ReactionType.LOVE, true), + ); + }); + + it('test_removesReaction_whenUnReactingToParticipantsMessage', async () => { + allureId(13257); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN participant sends the message', () => participantSendsMessage()); + await step('AND user adds the reaction', () => + userRobot.addReaction(ReactionType.LOL).assertReaction(ReactionType.LOL, true), + ); + await step('AND user removes the reaction', () => userRobot.deleteReaction(ReactionType.LOL)); + await step('THEN the reaction is removed', () => + userRobot.assertReaction(ReactionType.LOL, false), + ); + }); + + it('test_reactionIsAddedByParticipant_whenReactingToUsersMessage', async () => { + allureId(13258); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step("AND participant adds the reaction to user's message", () => + participantRobot.readMessage().addReaction(ReactionType.LIKE), + ); + await step('THEN the reaction is added', () => + userRobot.assertReaction(ReactionType.LIKE, true), + ); + }); + + it('test_reactionIsRemovedByParticipant_whenUnReactingToUsersMessage', async () => { + allureId(13259); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step("AND participant adds the reaction to user's message", async () => { + await participantRobot.readMessage().addReaction(ReactionType.LOL); + await userRobot.assertReaction(ReactionType.LOL, true); + }); + await step('AND participant removes the reaction', () => + participantRobot.deleteReaction(ReactionType.LOL), + ); + await step('THEN the reaction is removed', () => + userRobot.assertReaction(ReactionType.LOL, false), + ); + }); + + it('test_reactionIsAddedByParticipant_whenReactingToOwnMessage', async () => { + allureId(13260); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN participant sends the message', () => participantSendsMessage()); + await step('AND participant adds the reaction', () => + participantRobot.addReaction(ReactionType.WOW), + ); + await step('THEN the reaction is added', () => + userRobot.assertReaction(ReactionType.WOW, true), + ); + }); + + it('test_reactionIsRemovedByParticipant_whenUnReactingToOwnMessage', async () => { + allureId(13261); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN participant sends the message', () => participantSendsMessage()); + await step('AND participant adds the reaction', async () => { + await participantRobot.addReaction(ReactionType.SAD); + await userRobot.assertReaction(ReactionType.SAD, true); + }); + await step('AND participant removes the reaction', () => + participantRobot.deleteReaction(ReactionType.SAD), + ); + await step('THEN the reaction is removed', () => + userRobot.assertReaction(ReactionType.SAD, false), + ); + }); + + it('test_reactionIsAddedByParticipant_toThreadReply', async () => { + allureId(13262); + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('AND user sends a thread reply', () => + userRobot.openThread().sendMessage(sampleText).assertMessage(sampleText), + ); + await step('WHEN participant adds a reaction to the thread reply', () => + participantRobot.addReaction(ReactionType.LIKE), + ); + await step('THEN the reaction is added', () => + userRobot.assertReaction(ReactionType.LIKE, true), + ); + }); + + itOnAndroid('test_userAddsReactionWhileOffline', async () => { + allureId(13263); + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user sends a message', () => + userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(MessageDeliveryStatus.SENT), + ); + await step('AND user becomes offline', () => disableInternetConnection()); + await step('WHEN user adds a reaction', () => userRobot.addReaction(ReactionType.LIKE)); + await step('THEN user observes a new reaction', () => + userRobot.assertReaction(ReactionType.LIKE, true), + ); + await step('WHEN user becomes online', () => enableInternetConnection()); + await step('THEN user still observes a new reaction', () => + userRobot.assertReaction(ReactionType.LIKE, true), + ); + }); + + itOnAndroid('test_participantAddsReactionWhileUserIsOffline', async () => { + allureId(13264); + const delay = 3; + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND user sends a message', () => + userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(MessageDeliveryStatus.SENT), + ); + await step('AND user becomes offline while participant adds a reaction', async () => { + // Sent with a delay, because the mock server can't be reached while offline. + await participantRobot.addReaction(ReactionType.LIKE, delay); + await disableInternetConnection(); + await sleep((delay + 1) * 1000); + }); + await step('AND user becomes online', () => enableInternetConnection()); + await step('THEN user observes a new reaction', () => + userRobot.assertReaction(ReactionType.LIKE, true), + ); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/SlowModeTests.e2e.ts b/examples/E2ETestApp/e2e/tests/SlowModeTests.e2e.ts new file mode 100644 index 0000000000..121a23a6b8 --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/SlowModeTests.e2e.ts @@ -0,0 +1,109 @@ +import { describe, it } from '@jest/globals'; + +import { allureId, step } from '../support/step'; +import { setUpStreamTestCase } from '../support/StreamTestCase'; + +describe('SlowModeTests', () => { + const { backendRobot, participantRobot, userRobot } = setUpStreamTestCase({ + synchronization: false, + }); + // Long enough that the cooldown can't expire between the send and the assert on a slow emulator. + const cooldownDuration = 60; + const message = 'message'; + const replyMessage = 'reply message'; + const editedMessage = 'edited message'; + + const enableSlowModeAndOpenChannel = async () => { + await step('GIVEN slow mode is enabled on the channel', () => + backendRobot.setCooldown(true, cooldownDuration), + ); + await step('AND user opens the channel', () => userRobot.login().openChannel()); + }; + + it('test_cooldownIsShownWhenNewMessageIsSent', async () => { + allureId(13265); + await enableSlowModeAndOpenChannel(); + await step('WHEN user sends a new message', () => userRobot.sendMessage(message)); + await step('THEN slow mode is active and the cooldown is shown', () => + userRobot.assertMessage(message).assertCooldownIsShown(), + ); + }); + + // https://linear.app/stream/issue/RN-491 + it.skip('test_composerIsDisabledWhenSlowModeIsActive', async () => { + allureId(13266); + await enableSlowModeAndOpenChannel(); + await step('WHEN user sends a new message', () => userRobot.sendMessage(message)); + await step('THEN the cooldown is shown and the composer is locked', () => + userRobot.assertCooldownIsShown().assertComposerIsDisabledInSlowMode(), + ); + }); + + it('test_newMessageCantBeSent_whenSlowModeIsActiveAndCooldownIsShown', async () => { + allureId(13267); + await enableSlowModeAndOpenChannel(); + await step('AND user sends a new message', () => + userRobot.sendMessage(message).assertCooldownIsShown(), + ); + await step('WHEN user types a new message', () => userRobot.typeText('another message')); + await step('THEN the message can not be sent', () => userRobot.assertCooldownIsShown()); + }); + + it('test_slowModeIsActiveAndCooldownIsShown_whenAMessageIsReplied', async () => { + allureId(13268); + await enableSlowModeAndOpenChannel(); + await step('AND participant sends a new message', async () => { + await participantRobot.sendMessage(message); + await userRobot.assertMessage(message); + }); + await step('WHEN user replies to the message', () => userRobot.quoteMessage(replyMessage)); + await step('THEN slow mode is active and the cooldown is shown', () => + userRobot.assertCooldownIsShown(), + ); + await step('AND the reply is sent', () => + userRobot.assertQuotedMessage({ quote: message, text: replyMessage }), + ); + }); + + // https://linear.app/stream/issue/RN-491 + it.skip('test_aMessageCantBeReplied_whenSlowModeIsActiveAndCooldownIsShown', async () => { + allureId(13269); + await enableSlowModeAndOpenChannel(); + await step('AND user sends a new message', () => + userRobot.sendMessage(message).assertMessage(message), + ); + await step('WHEN user selects reply to the message from the context menu', () => + userRobot.selectReplyFromContextMenu(), + ); + await step('THEN the cooldown is shown and the composer is locked', () => + userRobot.assertCooldownIsShown().assertComposerIsDisabledInSlowMode(), + ); + }); + + // https://linear.app/stream/issue/RN-491 + it.skip('test_slowModeContinuesActiveAndCooldownIsShownInThreadMessage_whenSlowModeIsActiveAndCooldownIsShownInChannel', async () => { + allureId(13270); + await enableSlowModeAndOpenChannel(); + await step('AND user sends a new message', () => + userRobot.sendMessage(message).assertMessage(message), + ); + await step('WHEN user opens the thread on the message', () => userRobot.openThread()); + await step('THEN the cooldown is shown and the composer is locked in the thread', () => + userRobot.assertThreadIsOpen().assertCooldownIsShown().assertComposerIsDisabledInSlowMode(), + ); + }); + + it('test_slowModeIsNotActiveAndCooldownIsNotShown_whenAMessageIsEdited', async () => { + allureId(13271); + await step('GIVEN slow mode is enabled on a channel with a message', () => + backendRobot + .generateChannels({ channelsCount: 1, messagesCount: 1 }) + .setCooldown(true, cooldownDuration), + ); + await step('AND user opens the channel', () => userRobot.login().openChannel()); + await step('WHEN user edits the message', () => userRobot.editMessage(editedMessage)); + await step('THEN slow mode is not active and the cooldown is not shown', () => + userRobot.assertEditedMessage(editedMessage).assertCooldownIsNotShown(), + ); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/ThreadListTests.e2e.ts b/examples/E2ETestApp/e2e/tests/ThreadListTests.e2e.ts new file mode 100644 index 0000000000..276d11c795 --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/ThreadListTests.e2e.ts @@ -0,0 +1,72 @@ +import { describe, it } from '@jest/globals'; + +import { allureId, step } from '../support/step'; +import { setUpStreamTestCase } from '../support/StreamTestCase'; + +/** The thread list, opened from the threads tab of the root screen's bottom bar. */ +describe('ThreadListTests', () => { + const { participantRobot, userRobot } = setUpStreamTestCase(); + const parentMessageText = 'Test'; + const replyText = 'Reply'; + + const userRepliesInThread = async () => { + await step('GIVEN user opens the channel', () => userRobot.login().openChannel()); + await step('AND participant sends the message', async () => { + await participantRobot.sendMessage(parentMessageText); + await userRobot.assertMessage(parentMessageText); + }); + await step('AND user replies to the message in the thread', () => + userRobot.openThread().sendMessageInThread(replyText).assertThreadMessage(replyText), + ); + }; + + it('test_threadListIsEmpty_whenChannelHasNoThreads', async () => { + allureId(13272); + await step('GIVEN user logs in', () => userRobot.login().waitForChannelListToLoad()); + await step('WHEN user opens the thread list', () => userRobot.openThreadList()); + await step('THEN the thread list is empty', () => userRobot.assertThreadListIsEmpty()); + }); + + it('test_threadIsShownOnTheThreadList', async () => { + allureId(13273); + await userRepliesInThread(); + await step('WHEN user opens the thread list', () => + userRobot.moveToChannelListFromThread().openThreadList(), + ); + await step('THEN the thread is shown with one reply', () => + userRobot.assertThreadInThreadList(parentMessageText, 1), + ); + }); + + it('test_threadIsUpdatedOnTheThreadList_whenParticipantRepliesInThread', async () => { + allureId(13274); + await userRepliesInThread(); + await step('AND user opens the thread list', () => + userRobot + .moveToChannelListFromThread() + .openThreadList() + .assertThreadInThreadList(parentMessageText, 1), + ); + await step('WHEN participant replies in the thread', () => + participantRobot.sendMessageInThread('Participant reply'), + ); + await step('THEN the thread shows two replies', () => + userRobot.assertThreadInThreadList(parentMessageText, 2), + ); + }); + + it('test_userOpensThreadFromTheThreadList', async () => { + allureId(13275); + await userRepliesInThread(); + await step('AND user opens the thread list', () => + userRobot + .moveToChannelListFromThread() + .openThreadList() + .assertThreadInThreadList(parentMessageText, 1), + ); + await step('WHEN user taps on the thread', () => userRobot.openThreadFromThreadList()); + await step('THEN the thread is opened on the reply', () => + userRobot.assertThreadMessage(replyText), + ); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/UnreadCountsTests.e2e.ts b/examples/E2ETestApp/e2e/tests/UnreadCountsTests.e2e.ts new file mode 100644 index 0000000000..2409413b62 --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/UnreadCountsTests.e2e.ts @@ -0,0 +1,107 @@ +import { describe, it } from '@jest/globals'; + +import { MessageDeliveryStatus } from '../mock-server/DataTypes'; +import { allureId, step } from '../support/step'; +import { setUpStreamTestCase } from '../support/StreamTestCase'; + +describe('UnreadCountsTests', () => { + const { backendRobot, participantRobot, userRobot } = setUpStreamTestCase(); + const sampleText = 'Test'; + const participantMessage = 'Participant message'; + + const userSendsMessageAndLeaves = async () => { + await step('GIVEN user opens the channel and sends the message', () => + userRobot.login().openChannel().sendMessage(sampleText), + ); + await step('AND the message is delivered', () => + userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.SENT), + ); + await step('AND user moves back to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + }; + + it('test_newMessageInMutedChannel_doesNotIncrementUnreadCount', async () => { + allureId(13276); + await step('GIVEN user is in the channel list', () => + userRobot.login().waitForChannelListToLoad(), + ); + await step('AND user has a muted channel', () => + backendRobot.muteChannel().then(() => userRobot.assertChannelIsMuted(true)), + ); + await step('WHEN participant sends a message to the muted channel', async () => { + await participantRobot.sendMessage(participantMessage); + await userRobot.assertMessageInChannelPreview(participantMessage, false); + }); + await step('THEN unread messages count is not incremented', () => + userRobot.assertChannelUnreadCount(0), + ); + }); + + // https://linear.app/stream/issue/RN-486 + it.skip('test_newMessageFromMutedUser_doesNotIncrementUnreadCount', async () => { + allureId(13277); + await step('GIVEN user opens an unmuted channel', () => userRobot.login().openChannel()); + await step('AND the channel has muted participant', async () => { + await participantRobot.sendMessage(sampleText); + await userRobot + .assertMessage(sampleText) + .muteMessageAuthor(sampleText) + .assertMuteMessageAuthorOption(sampleText, true); + }); + await step('AND user is in the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('WHEN muted participant sends a message to the channel', async () => { + await participantRobot.sendMessage(participantMessage); + await userRobot.assertMessageInChannelPreview(participantMessage, false); + }); + await step('THEN unread messages count is not incremented', () => + userRobot.assertChannelUnreadCount(0), + ); + }); + + // https://linear.app/stream/issue/RN-495 + it.skip('test_newSystemMessage_incrementsUnreadCount', async () => { + allureId(13278); + const systemMessage = 'System message'; + await userSendsMessageAndLeaves(); + await step('WHEN participant performs an action that posts a system message', () => + participantRobot.sendSystemMessage(systemMessage), + ); + await step('THEN unread messages count is incremented', () => + userRobot.assertMessageInChannelPreview(systemMessage).assertChannelUnreadCount(1), + ); + }); + + it('test_newThreadReplyAlsoSentInChannel_incrementsUnreadCount', async () => { + allureId(13279); + await userSendsMessageAndLeaves(); + await step('WHEN participant sends a thread reply also to the channel', () => + participantRobot.sendMessageInThread(participantMessage, true), + ); + await step('THEN unread messages count is incremented', () => + userRobot + .assertMessageInChannelPreview(participantMessage, false) + .assertChannelUnreadCount(1), + ); + }); + + // https://linear.app/stream/issue/RN-486 + it.skip('test_hardDeleteOfUnseenMessage_decrementsUnreadCount', async () => { + allureId(13280); + await userSendsMessageAndLeaves(); + await step('AND participant sends a thread reply also to the channel', () => + participantRobot.sendMessageInThread(participantMessage, true), + ); + await step('AND the unread messages count is incremented', () => + userRobot.assertChannelUnreadCount(1), + ); + await step('WHEN participant hard deletes the message', () => + participantRobot.deleteMessage(true), + ); + await step('THEN unread messages count is decremented', () => + userRobot.assertChannelUnreadCount(0), + ); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/UnreadMessagesTests.e2e.ts b/examples/E2ETestApp/e2e/tests/UnreadMessagesTests.e2e.ts new file mode 100644 index 0000000000..50abcc87d6 --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/UnreadMessagesTests.e2e.ts @@ -0,0 +1,150 @@ +import { describe, it } from '@jest/globals'; + +import { MessageDeliveryStatus } from '../mock-server/DataTypes'; +import { allureId, step } from '../support/step'; +import { setUpStreamTestCase } from '../support/StreamTestCase'; + +describe('UnreadMessagesTests', () => { + const { participantRobot, userRobot } = setUpStreamTestCase(); + const sampleText = 'Test'; + + const userSendsMessageAndLeaves = async () => { + await step('GIVEN user opens the channel and sends the message', () => + userRobot.login().openChannel().sendMessage(sampleText), + ); + await step('AND the message is delivered', () => + userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.SENT), + ); + await step('AND user moves back to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + }; + + // https://linear.app/stream/issue/RN-502 + it.skip('test_unreadSeparatorIsShown_whenParticipantSendsMessagesWhileUserIsAway', async () => { + allureId(13281); + const unreadCount = 2; + await userSendsMessageAndLeaves(); + await step('WHEN participant sends new messages', () => + participantRobot.sendMultipleMessages('New', unreadCount), + ); + await step('AND the channel preview shows the unread count', () => + userRobot.assertChannelUnreadCount(unreadCount), + ); + await step('AND user reopens the channel', () => userRobot.openChannel()); + await step('THEN the unread separator is shown with the unread count', () => + userRobot.assertUnreadSeparator(unreadCount), + ); + }); + + it('test_channelUnreadCountIsReset_whenUserReadsTheChannel', async () => { + allureId(13282); + const unreadCount = 2; + await userSendsMessageAndLeaves(); + await step('AND participant sends new messages', () => + participantRobot.sendMultipleMessages('New', unreadCount), + ); + await step('AND the channel preview shows the unread count', () => + userRobot.assertChannelUnreadCount(unreadCount), + ); + await step('WHEN user reads the channel', () => + userRobot.openChannel().assertMessage(`New-${unreadCount}`), + ); + await step('AND user moves back to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('THEN the channel preview has no unread count', () => + userRobot.assertChannelUnreadCount(0), + ); + }); + + // https://linear.app/stream/issue/RN-502 + it.skip('test_userScrollsToFirstUnreadMessage', async () => { + allureId(13283); + const unreadCount = 25; + await userSendsMessageAndLeaves(); + await step('AND participant sends new messages', () => + participantRobot.sendMultipleMessages('New', unreadCount), + ); + await step('AND the channel preview shows the unread count', () => + userRobot.assertChannelUnreadCount(unreadCount), + ); + await step('WHEN user reopens the channel', () => userRobot.openChannel()); + await step('THEN the scroll to first unread button is shown with the unread count', () => + userRobot.assertScrollToFirstUnreadButton(unreadCount), + ); + await step('WHEN user taps on the scroll to first unread button', () => + userRobot.tapOnScrollToFirstUnreadButton(), + ); + await step('THEN the list scrolls to the unread separator', () => + userRobot.assertUnreadSeparator(unreadCount), + ); + }); + + // https://linear.app/stream/issue/RN-502 + it.skip('test_userMarksMessageAsUnread', async () => { + allureId(13284); + const unreadCount = 2; + await step('GIVEN user opens the channel and sends the message', () => + userRobot.login().openChannel().sendMessage(sampleText), + ); + await step('AND the message is delivered', () => + userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.SENT), + ); + await step('AND participant sends messages', async () => { + await participantRobot.sendMultipleMessages('New', unreadCount); + await userRobot.assertMessage(`New-${unreadCount}`); + }); + await step('WHEN user marks the first participant message as unread', () => + userRobot.markMessageAsUnread('New-1'), + ); + await step('THEN the unread separator is shown with the unread count', () => + userRobot.assertUnreadSeparator(unreadCount), + ); + await step('AND the channel preview shows the unread count', () => + userRobot.moveToChannelListFromMessageList().assertChannelUnreadCount(unreadCount), + ); + }); + + it('test_jumpToUnreadButtonShowsUnreadCount_whenUserMarksMessageAsUnread', async () => { + allureId(13285); + const unreadCount = 25; + await step('GIVEN user opens the channel and sends the message', () => + userRobot.login().openChannel().sendMessage(sampleText), + ); + await step('AND the message is delivered', () => + userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.SENT), + ); + await step('AND participant sends more messages than fit on the screen', async () => { + await participantRobot.sendMultipleMessages('New', unreadCount); + await userRobot.assertMessage(`New-${unreadCount}`); + }); + await step('WHEN user marks the first participant message as unread', () => + userRobot.scrollMessageListUpToMessage('New-1', 15).markMessageAsUnread('New-1'), + ); + await step('AND user scrolls back to the bottom', () => + userRobot.scrollMessageListDownToMessage(`New-${unreadCount}`, 15), + ); + await step('THEN the jump to unread button shows the unread count', () => + userRobot.assertScrollToFirstUnreadButton(unreadCount), + ); + }); + + it('test_userDismissesTheUnreadIndicator', async () => { + allureId(13286); + const unreadCount = 25; + await userSendsMessageAndLeaves(); + await step('AND participant sends new messages', () => + participantRobot.sendMultipleMessages('New', unreadCount), + ); + await step('AND user reopens the channel', () => + userRobot.assertChannelUnreadCount(unreadCount).openChannel(), + ); + await step('WHEN user dismisses the unread indicator', () => + userRobot.dismissUnreadIndicator(), + ); + await step('THEN the scroll to first unread button is hidden', () => + userRobot.assertScrollToFirstUnreadButton(undefined, false), + ); + }); +}); diff --git a/examples/E2ETestApp/index.js b/examples/E2ETestApp/index.js index d2660dcb40..247d602223 100644 --- a/examples/E2ETestApp/index.js +++ b/examples/E2ETestApp/index.js @@ -1,7 +1,13 @@ import 'react-native-gesture-handler'; -import { AppRegistry } from 'react-native'; +import { AppRegistry, LogBox } from 'react-native'; import App from './App'; import { name as appName } from './app.json'; +import { reportE2ECoverage } from './src/e2eCoverage'; + +// Debug builds only: LogBox's warning toast sits over the composer and swallows the e2e suite's taps. +LogBox.ignoreAllLogs(); + +reportE2ECoverage(); AppRegistry.registerComponent(appName, () => App); diff --git a/examples/E2ETestApp/ios/E2ETestApp/Info.plist b/examples/E2ETestApp/ios/E2ETestApp/Info.plist index cf6c5bcfd6..6c2773f4a2 100644 --- a/examples/E2ETestApp/ios/E2ETestApp/Info.plist +++ b/examples/E2ETestApp/ios/E2ETestApp/Info.plist @@ -34,6 +34,10 @@ NSAllowsLocalNetworking + NSCameraUsageDescription + $(PRODUCT_NAME) would like to use your camera to share image in a message. + NSPhotoLibraryUsageDescription + $(PRODUCT_NAME) would like access to your photo gallery to share image in a message. NSLocationWhenInUseUsageDescription RCTNewArchEnabled diff --git a/examples/E2ETestApp/ios/E2ETestApp/PrivacyInfo.xcprivacy b/examples/E2ETestApp/ios/E2ETestApp/PrivacyInfo.xcprivacy index fc90547a7a..d40fc3a7fa 100644 --- a/examples/E2ETestApp/ios/E2ETestApp/PrivacyInfo.xcprivacy +++ b/examples/E2ETestApp/ios/E2ETestApp/PrivacyInfo.xcprivacy @@ -10,6 +10,7 @@ NSPrivacyAccessedAPITypeReasons C617.1 + 3B52.1 diff --git a/examples/E2ETestApp/ios/Podfile.lock b/examples/E2ETestApp/ios/Podfile.lock index cb27ec2bdd..3a7b66a935 100644 --- a/examples/E2ETestApp/ios/Podfile.lock +++ b/examples/E2ETestApp/ios/Podfile.lock @@ -14,6 +14,29 @@ PODS: - hermes-engine (260318099.0.4): - hermes-engine/Pre-built (= 260318099.0.4) - hermes-engine/Pre-built (260318099.0.4) + - op-sqlite (18.2.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - RCT-Folly (2024.11.18.00): - RCT-Folly/Default (= 2024.11.18.00) - ReactNativeDependencies @@ -1349,6 +1372,75 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - react-native-cameraroll (7.10.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-document-picker (12.0.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-image-picker (8.2.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - react-native-launch-arguments (4.1.1): - React - react-native-netinfo (12.0.1): @@ -1446,6 +1538,77 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - react-native-video (6.19.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-video/Video (= 6.19.3) + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-video/Fabric (6.19.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-video/Video (6.19.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-video/Fabric + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - React-NativeModulesApple (0.88.0): - hermes-engine - React-bridging @@ -1838,6 +2001,29 @@ PODS: - React-utils (= 0.88.0) - ReactNativeDependencies - ReactNativeDependencies (0.88.0) + - RNCClipboard (1.16.3): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - RNGestureHandler (3.4.0): - hermes-engine - RCTRequired @@ -2088,7 +2274,7 @@ PODS: - Yoga - SocketRocket (0.7.1): - ReactNativeDependencies - - stream-chat-react-native (9.10.1): + - stream-chat-react-native (9.11.1): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2169,6 +2355,7 @@ DEPENDENCIES: - fmt (from `build/rndeps-facades/fmt`) - glog (from `build/rndeps-facades/glog`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - "op-sqlite (from `../node_modules/@op-engineering/op-sqlite`)" - RCT-Folly (from `build/rndeps-facades/RCT-Folly`) - RCTDeprecation (from `build/rncore-facades/RCTDeprecation`) - RCTRequired (from `build/rncore-facades/RCTRequired`) @@ -2210,9 +2397,13 @@ DEPENDENCIES: - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - React-mutationobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver`) + - "react-native-cameraroll (from `../node_modules/@react-native-camera-roll/camera-roll`)" + - "react-native-document-picker (from `../node_modules/@react-native-documents/picker`)" + - react-native-image-picker (from `../node_modules/react-native-image-picker`) - react-native-launch-arguments (from `../node_modules/react-native-launch-arguments`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) + - react-native-video (from `../node_modules/react-native-video`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-networking (from `../node_modules/react-native/ReactCommon/react/networking`) - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) @@ -2250,6 +2441,7 @@ DEPENDENCIES: - ReactCodegen (from `build/generated/ios/ReactCodegen`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`) + - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNSVG (from `../node_modules/react-native-svg`) @@ -2275,6 +2467,8 @@ EXTERNAL SOURCES: hermes-engine: :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :tag: hermes-v260318099.0.2 + op-sqlite: + :path: "../node_modules/@op-engineering/op-sqlite" RCT-Folly: :path: build/rndeps-facades/RCT-Folly RCTDeprecation: @@ -2355,12 +2549,20 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" React-mutationobservernativemodule: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver" + react-native-cameraroll: + :path: "../node_modules/@react-native-camera-roll/camera-roll" + react-native-document-picker: + :path: "../node_modules/@react-native-documents/picker" + react-native-image-picker: + :path: "../node_modules/react-native-image-picker" react-native-launch-arguments: :path: "../node_modules/react-native-launch-arguments" react-native-netinfo: :path: "../node_modules/@react-native-community/netinfo" react-native-safe-area-context: :path: "../node_modules/react-native-safe-area-context" + react-native-video: + :path: "../node_modules/react-native-video" React-NativeModulesApple: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" React-networking: @@ -2435,6 +2637,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" ReactNativeDependencies: :podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec" + RNCClipboard: + :path: "../node_modules/@react-native-clipboard/clipboard" RNGestureHandler: :path: "../node_modules/react-native-gesture-handler" RNReanimated: @@ -2460,6 +2664,7 @@ SPEC CHECKSUMS: fmt: ba0886e864ebb21d14c98b2e12e164b8002f5b93 glog: 3cf7cb46f743c4bc758ae3e6d9c15bd1b4cfd6d4 hermes-engine: a1158910bb7e300b9e3d2f1e2d433f817ec1a4b5 + op-sqlite: 883eed16d1f0c8b54a294ea71e5f0696d96e6142 RCT-Folly: 82921e585c0400eda46fac1068297aa9dfb035e4 RCTDeprecation: e134f2a2c096a6450f5fcbbe949531f913a2ff04 RCTRequired: f1a68295837ee8f91e112ed1b5694d12e7ff1784 @@ -2500,9 +2705,13 @@ SPEC CHECKSUMS: React-Mapbuffer: a2dae4f2cb335ebb97f2240494d0c2bab84cd339 React-microtasksnativemodule: 8b3aaa5f3b907ecf071b105589868d9f027a448d React-mutationobservernativemodule: 11d77d3f7360578f07847843544ada23cc473049 + react-native-cameraroll: 6a7f0cae784ce8f4e028909bb736b9bdd4c3bdab + react-native-document-picker: e32c9b6580e81d205793d256c1c40e8d2b4ab27b + react-native-image-picker: 19a3b13c7e56e95ebdcfb6600ffb018771f10c90 react-native-launch-arguments: eab68037b01ea7c78a0b3b55da3a5e967e9aaf92 react-native-netinfo: 687391ecedb199c6d67fbc46c87cbae3277de967 react-native-safe-area-context: b1c9f15a36e597e9cef4c5526e209ef2645bc2e0 + react-native-video: 8260e0ba520f0518a062fcdfaea2a86d2aa8190a React-NativeModulesApple: 52f3def085f129de1307e3eb78e26729ff70f421 React-networking: 1e6441349ac72bfd4f7a79c300c3dd76993a7058 React-oscompat: e64123dd8199f9e99cede19fa542c3da3445481e @@ -2540,12 +2749,13 @@ SPEC CHECKSUMS: ReactCodegen: 7b71f4464442e9b6eae41094d11076d56c51c0e3 ReactCommon: 145a14b942b19fc2fe38927f261955ada56978aa ReactNativeDependencies: d3b7423ba1e52525e42085c8470109abde583d2e + RNCClipboard: efcb0fcabe40529a32cb0c75fcef4f9c73052c2e RNGestureHandler: b5512a093e6923dd1c31fbdd8e6186cd0ebb3541 RNReanimated: 3018727dbf272754d0f31ef73f619e49ade7baf0 RNSVG: 8b5f2fdb9f0245a863cdd6e9c48a7689821a08b0 RNWorklets: 488806637a21eb17812a48dadca7cb8c698c8cfd SocketRocket: 37aec555668fb852ec12e3c0de59a86ca58f0871 - stream-chat-react-native: 7f1fdf2d1ce87c0b8a6c1d581308dff0c84e8e21 + stream-chat-react-native: 4b72c415c70bdb07d6a4f5b8b40c4220f0dbf57f Teleport: 90f0d7e81c6b40db9b253e1ef73b2f4f4f3f6b53 Yoga: d1c536142c5ff8ec8cd856ab2a7c227a1d875c8e diff --git a/examples/E2ETestApp/metro.config.js b/examples/E2ETestApp/metro.config.js index c96816d4be..d697e160c6 100644 --- a/examples/E2ETestApp/metro.config.js +++ b/examples/E2ETestApp/metro.config.js @@ -78,4 +78,9 @@ config.resolver.nodeModulesPaths = [PATH.resolve(__dirname, 'node_modules')]; // add the package dir for metro to access the package folder config.watchFolders = [packageDirPath]; +// The babel config depends on E2E_COVERAGE, which Metro's transform cache key doesn't include. +if (process.env.E2E_COVERAGE === '1') { + config.cacheVersion = 'e2e-coverage'; +} + module.exports = config; diff --git a/examples/E2ETestApp/package.json b/examples/E2ETestApp/package.json index 1cac88c3f7..9a35313b43 100644 --- a/examples/E2ETestApp/package.json +++ b/examples/E2ETestApp/package.json @@ -13,17 +13,23 @@ "e2e:test": "detox test" }, "dependencies": { + "@op-engineering/op-sqlite": "^18.2.5", + "@react-native-camera-roll/camera-roll": "^7.10.2", + "@react-native-clipboard/clipboard": "^1.16.3", "@react-native-community/netinfo": "^12.0.1", + "@react-native-documents/picker": "^12.0.2", "react": "19.3.0", "react-native": "0.88.0", "react-native-gesture-handler": "^3.4.0", + "react-native-image-picker": "^8.2.1", "react-native-launch-arguments": "^4.1.1", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "^5.10.1", "react-native-svg": "^15.15.5", "react-native-teleport": "^1.2.2", + "react-native-video": "^6.19.3", "react-native-worklets": "^0.13.0", - "stream-chat": "^10.0.0", + "stream-chat": "^10.0.1", "stream-chat-react-native": "workspace:^" }, "devDependencies": { @@ -42,6 +48,8 @@ "babel-jest": "30.0.0", "detox": "^20.51.4", "jest": "^30.4.2", + "jest-allure2-reporter": "^2.3.0", + "jest-metadata": "^1.6.0", "typescript": "6.0.3" }, "engines": { diff --git a/examples/E2ETestApp/src/JwtScreen.tsx b/examples/E2ETestApp/src/JwtScreen.tsx new file mode 100644 index 0000000000..94493b2e65 --- /dev/null +++ b/examples/E2ETestApp/src/JwtScreen.tsx @@ -0,0 +1,105 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { StreamChat } from 'stream-chat'; + +import { apiKey, appUser, mockServerUrl } from './config'; + +/** Fetches a token from the mock server, which decides whether it is valid, expired or broken. */ +const fetchToken = async () => { + const response = await fetch(`${mockServerUrl}/jwt/get?platform=js`); + return response.ok ? response.text() : ''; +}; + +/** Token provider calls since launch, so the screen can show whether the token was refreshed. */ +let tokenRequests = 0; +const tokenListeners = new Set<(count: number) => void>(); +const tokenProvider = () => { + tokenRequests += 1; + tokenListeners.forEach((listener) => listener(tokenRequests)); + return fetchToken(); +}; + +/** + * Connects with a token provider and shows the connection state, like the native SDKs' JWT test + * screens: `jwt-connection-status-connected` while connected, + * `jwt-has-been-disconnected-true` once the connection dropped after it was first up, and + * `jwt-token-refreshed-true` once the token provider was called again (a refresh; the mock server + * hands out tokens that expire, or are invalid, on request). + */ +export const JwtScreen = () => { + const [connected, setConnected] = useState(false); + const [disconnections, setDisconnections] = useState(0); + const [tokenRequestCount, setTokenRequestCount] = useState(tokenRequests); + + useEffect(() => { + tokenListeners.add(setTokenRequestCount); + return () => { + tokenListeners.delete(setTokenRequestCount); + }; + }, []); + const wasConnected = useRef(false); + + useEffect(() => { + const client = StreamChat.getInstance(apiKey, { + client: { axiosRequestConfig: { timeout: 6000 }, baseURL: mockServerUrl }, + }); + return client.wsConnection.state.subscribeWithSelector( + ({ isHealthy }) => ({ isHealthy }), + ({ isHealthy }) => { + const online = !!isHealthy; + setConnected(online); + if (online) { + wasConnected.current = true; + } else if (wasConnected.current) { + setDisconnections((count) => count + 1); + } + }, + ); + }, []); + + const connect = useCallback(async () => { + const client = StreamChat.getInstance(apiKey); + try { + await client.connectUser({ id: appUser.id }, tokenProvider); + } catch (error) { + console.warn('JWT connect failed', error); + } + }, []); + + const status = connected ? 'connected' : 'disconnected'; + return ( + + + JWT Test + + + Connection status: + + {status} + + + + Disconnections: + 0}`}> + {disconnections} + + + + Token requests: + 1}`}> + {tokenRequestCount} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { alignItems: 'center', backgroundColor: '#FFFFFF', flex: 1, paddingTop: 100 }, + label: { fontSize: 20, paddingHorizontal: 16 }, + row: { flexDirection: 'row', paddingTop: 60 }, + title: { fontSize: 33, fontWeight: '700' }, + value: { fontSize: 20, fontWeight: '700' }, +}); diff --git a/examples/E2ETestApp/src/config.ts b/examples/E2ETestApp/src/config.ts index 0f5d84d3f8..3a11beeca3 100644 --- a/examples/E2ETestApp/src/config.ts +++ b/examples/E2ETestApp/src/config.ts @@ -9,9 +9,19 @@ import type { ClientUser } from 'stream-chat'; type E2ELaunchArguments = { /** Base URL of the per-test `stream-chat-test-mock-server` instance, e.g. `http://localhost:54321`. */ MOCK_SERVER_URL?: string; + /** Opens the JWT test screen (token provider + connection status) instead of the chat. */ + JWT_TEST?: boolean | string; + /** Where to post the e2e coverage on going to the background (`src/e2eCoverage.ts`). */ + E2E_COVERAGE_URL?: string; }; -export const mockServerUrl = LaunchArguments.value().MOCK_SERVER_URL; +const launchArguments = LaunchArguments.value(); + +export const mockServerUrl = launchArguments.MOCK_SERVER_URL; + +export const e2eCoverageUrl = launchArguments.E2E_COVERAGE_URL; + +export const isJwtTest = launchArguments.JWT_TEST === true || launchArguments.JWT_TEST === 'true'; /** The app user every mock server fixture is recorded for. */ export const appUser: ClientUser = { diff --git a/examples/E2ETestApp/src/e2eCoverage.ts b/examples/E2ETestApp/src/e2eCoverage.ts new file mode 100644 index 0000000000..b33d5762d4 --- /dev/null +++ b/examples/E2ETestApp/src/e2eCoverage.ts @@ -0,0 +1,30 @@ +import { AppState } from 'react-native'; + +import { e2eCoverageUrl } from './config'; + +type Coverage = Record; + +/** + * Coverage runs of the e2e suite (`E2E_COVERAGE=1` bundles, `e2e/coverage/babelPlugin.js`): every + * time the app goes to the background, it posts the source files it has loaded and which of them ran + * code to the collector in the test process (`e2e/support/Coverage.ts`). The suite sends the app home + * after each test to collect it. + */ +export const reportE2ECoverage = () => { + const url = e2eCoverageUrl; + if (!url) { + return; + } + AppState.addEventListener('change', (state) => { + // eslint-disable-next-line no-underscore-dangle -- the global the coverage babel plugin writes + const coverage = (globalThis as { __e2eCoverage?: Coverage }).__e2eCoverage; + if (state !== 'background' || !coverage) { + return; + } + fetch(url, { + body: JSON.stringify(coverage), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }).catch((error) => console.warn('Could not report the e2e coverage', error)); + }); +}; diff --git a/examples/ExpoChatGPTSample/package.json b/examples/ExpoChatGPTSample/package.json index 5b61d4af1a..9ec164fe7f 100644 --- a/examples/ExpoChatGPTSample/package.json +++ b/examples/ExpoChatGPTSample/package.json @@ -29,7 +29,7 @@ "react-native-svg": "15.15.5", "react-native-teleport": "^1.2.2", "react-native-worklets": "0.13.0", - "stream-chat": "^10.0.0", + "stream-chat": "^10.0.1", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^", "victory-native": "^42.0.1" diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index 6c21830a18..e1d782aaa3 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -51,7 +51,7 @@ "react-native-teleport": "^1.2.2", "react-native-web": "~0.21.4", "react-native-worklets": "0.13.0", - "stream-chat": "^10.0.0", + "stream-chat": "^10.0.1", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/Gemfile.lock b/examples/SampleApp/Gemfile.lock index 398fa7a87f..2215b5ea20 100644 --- a/examples/SampleApp/Gemfile.lock +++ b/examples/SampleApp/Gemfile.lock @@ -186,7 +186,7 @@ GEM google-apis-firebaseappdistribution_v1 (~> 0.3.0) google-apis-firebaseappdistribution_v1alpha (~> 0.2.0) fastlane-plugin-load_json (0.0.1) - fastlane-plugin-stream_actions (0.4.14) + fastlane-plugin-stream_actions (0.4.19) xctest_list (= 1.2.1) fastlane-sirp (1.0.0) sysrandom (~> 1.0) @@ -356,7 +356,7 @@ DEPENDENCIES fastlane fastlane-plugin-firebase_app_distribution fastlane-plugin-load_json - fastlane-plugin-stream_actions (= 0.4.14) + fastlane-plugin-stream_actions (= 0.4.19) logger mutex_m nkf diff --git a/examples/SampleApp/fastlane/Allurefile b/examples/SampleApp/fastlane/Allurefile new file mode 100644 index 0000000000..6668c19930 --- /dev/null +++ b/examples/SampleApp/fastlane/Allurefile @@ -0,0 +1,72 @@ +#!/usr/bin/env ruby + +allure_project_id = '3' +allure_url = 'https://streamio.testops.cloud' +allure_api_url = "#{allure_url}/api" +e2e_app_path = File.absolute_path('../../E2ETestApp') +allure_results_path = File.join(e2e_app_path, 'allure-results') + +desc 'Creates an Allure TestOps launch and exports LAUNCH_ID to the following CI steps. Options: cron' +lane :allure_launch do |options| + next unless is_check_required(sources: @impact[:e2e][:sources], force_check: @force_check, required_checks: @impact[:e2e][:checks]) + + launch_id = allure_create_launch( + url: allure_api_url, + project_id: allure_project_id, + github_run_details: github_run_details, + cron: options[:cron] + ) + sh("echo 'LAUNCH_ID=#{launch_id}' >> $GITHUB_ENV") if is_ci +end + +desc 'Uploads the e2e Allure results. Options: launch_id' +lane :allure_upload do |options| + unless Dir.exist?(allure_results_path) && Dir.children(allure_results_path).any? + UI.important("No Allure results in #{allure_results_path}; skipping upload") + next + end + + allurectl = install_allurectl + allure_args = "-e #{allure_url} --project-id #{allure_project_id} --launch-id #{options[:launch_id]}" + sh("#{allurectl} launch reopen #{options[:launch_id]} || true") # allurectl can't upload to a closed launch + # Reporting stays optional: a failed upload is logged, it doesn't fail the job. + uploaded = true + sh("env BRANCH_NAME='#{current_branch}' #{allurectl} upload #{allure_args} #{allure_results_path}", + error_callback: ->(_) { uploaded = false }) + if uploaded + UI.success("Check out test results in Allure TestOps: #{allure_url}/launch/#{options[:launch_id]} ๐ŸŽ‰") + else + UI.important("Allure upload failed; launch #{options[:launch_id]} is missing these results") + end +end + +desc 'Deletes an Allure TestOps launch (for cancelled runs). Options: launch_id' +lane :allure_launch_removal do |options| + allure_api(url: allure_api_url, path: "launch/#{options[:launch_id]}", http_method: 'DELETE') +end + +desc 'Create test-case in Allure TestOps and get its id' +lane :allure_testcase do + allure_create_testcase(url: allure_api_url, project_id: allure_project_id) +end + +private_lane :install_allurectl do + path = File.join(e2e_app_path, 'allurectl') + next path if File.exist?(path) + + allure_version = '2.21.1' + os = `uname -s`.strip.downcase + arch = `uname -m`.strip == 'x86_64' ? 'amd64' : 'arm64' + sh("curl -sSL -o #{path} " \ + "https://github.com/allure-framework/allurectl/releases/download/#{allure_version}/allurectl_#{os}_#{arch}") + sh("chmod +x #{path}") + path +end + +def github_run_details + return nil unless is_ci + + github_path = "#{ENV.fetch('GITHUB_API_URL', nil)}/repos/#{ENV.fetch('GITHUB_REPOSITORY', nil)}/actions/runs/#{ENV.fetch('GITHUB_RUN_ID', nil)}" + output = sh(command: "curl -s -H 'authorization: Bearer #{ENV.fetch('GITHUB_TOKEN', nil)}' -X GET -G #{github_path}") + JSON.parse(output) +end diff --git a/examples/SampleApp/fastlane/Fastfile b/examples/SampleApp/fastlane/Fastfile index d68415c502..42baf1e14c 100644 --- a/examples/SampleApp/fastlane/Fastfile +++ b/examples/SampleApp/fastlane/Fastfile @@ -1,5 +1,7 @@ skip_docs +import 'Allurefile' + # Common Configuration github_repo = ENV['GITHUB_REPOSITORY'] || 'GetStream/stream-chat-react-native' root_path = File.absolute_path('../../../') @@ -271,8 +273,17 @@ end # The e2e suite drives the dedicated examples/E2ETestApp; its lanes live here with the repo's other lanes. e2e_app_path = File.absolute_path('../../E2ETestApp') +allure_results_path = File.join(e2e_app_path, 'allure-results') mock_server_repo_name = 'stream-chat-test-mock-server' mock_server_driver_port = ENV['MOCK_DRIVER_PORT'] || '4569' +# Impact analysis (e2e/README.md): pull requests run only the tests a coverage map says the diff can reach. +e2e_tests_path = File.join(e2e_app_path, 'e2e', 'tests') +e2e_selection_path = File.join(e2e_app_path, 'e2e_selection.json') +e2e_batch_selection_path = File.join(e2e_app_path, 'e2e_batch_selection.json') +e2e_max_batches = 8 +e2e_coverage_path = File.join(e2e_app_path, 'e2e-coverage') +e2e_coverage_map_path = File.join(e2e_app_path, 'e2e_coverage_map.json') +e2e_coverage_map_artifact = 'e2e-coverage-map' desc 'Starts the stream-chat-test-mock-server driver. Options: local_server (path to a checkout), branch' lane :start_mock_server do |options| @@ -320,13 +331,129 @@ private_lane :wait_for_mock_server_driver do |options| UI.user_error!("Mock server driver did not start on port #{options[:port]}") unless ready end -desc 'Writes `required=true|false` to $GITHUB_OUTPUT, so CI can skip the e2e jobs before building anything' +desc 'Writes `required=true|false` and `e2e_batches` to $GITHUB_OUTPUT, so CI can skip the e2e jobs before building anything' lane :e2e_check_required do required = is_check_required(sources: @impact[:e2e][:sources], force_check: @force_check, required_checks: @impact[:e2e][:checks]) - File.write(ENV['GITHUB_OUTPUT'], "required=#{required}\n", mode: 'a') if ENV['GITHUB_OUTPUT'] + tests = required ? select_e2e_tests['tests'] : [] + required &&= !tests.empty? + # Like the native Android SDK: 8 batches, or one per test when fewer are selected. + batches = (0...[tests.size, e2e_max_batches].min).to_a + UI.important("E2E batches: #{batches.size}") + File.write(ENV['GITHUB_OUTPUT'], "required=#{required}\ne2e_batches=#{batches.to_json}\n", mode: 'a') if ENV['GITHUB_OUTPUT'] required end +# `/` ids of the tests that can run, e.g. `MessageListTests.e2e/test_x`: the coverage +# map's keys. `it.skip` tests are left out; `itOnAndroid`, `itOnIOS` and `itExceptOnOldIOS` ones are +# in (Jest skips them where they don't run). +def e2e_test_ids(tests_path) + Dir.glob(File.join(tests_path, '*.e2e.ts')).sort.flat_map do |file| + source = File.read(file) + calls = source.scan(/^\s*(?:it|itOnAndroid|itOnIOS|itExceptOnOldIOS)(?:\.skip)?\(/).size + tests = source.scan(/^\s*(?:it|itOnAndroid|itOnIOS|itExceptOnOldIOS)(\.skip)?\(\s*'(test_\w+)'/) + # A test this misses would never run on a pull request, so fail loudly instead. + UI.user_error!("#{file}: read #{tests.size} of its #{calls} tests; name each one with a `test_*` literal") if tests.size != calls + + suite = File.basename(file, '.ts') + tests.reject { |skip, _| skip }.map { |_, name| "#{suite}/#{name}" } + end +end + +desc 'Selects the e2e tests a pull request can affect into e2e_selection.json.' +lane :select_e2e_tests do |options| + all_tests = e2e_test_ids(e2e_tests_path) + local = options[:coverage_map] || options[:changed_files] || options[:diff_base] + if !local && ENV['GITHUB_EVENT_NAME'] != 'pull_request' + # A dispatched run's tip can be a merge commit, which would pass for a pull request's. + selection = { 'run_all' => true, 'tests' => all_tests, 'reasons' => ["not a pull request (#{ENV['GITHUB_EVENT_NAME'] || 'local'})"] } + File.write(e2e_selection_path, JSON.pretty_generate(selection)) + UI.important("Running all #{all_tests.size} tests: #{selection['reasons'].first}") + next selection + end + + select_impacted_tests( + all_tests: all_tests, + coverage_map: options[:coverage_map] ? File.expand_path(options[:coverage_map], root_path) : nil, + coverage_map_artifact: e2e_coverage_map_artifact, + coverage_map_branch: 'develop', + changed_files: options[:changed_files]&.split(','), + diff_base: options[:diff_base], + source_root: root_path, + # The instrumented code: examples/E2ETestApp/e2e/coverage/sources.js + source_paths: ['package/src/', 'package/native-package/src/', 'examples/E2ETestApp/'], + test_paths: ['examples/E2ETestApp/e2e/tests/'], + ignore_paths: ['package/src/**/__tests__/**', 'package/src/**/*.test.{ts,tsx}', 'package/src/mock-builders/', '**/*.md'], + run_all_paths: [ + # The test harness, the app's native side and build setup, the SDK's native code + 'examples/E2ETestApp/e2e/{coverage,mock-server,pages,robots,support}/**/*', + 'examples/E2ETestApp/e2e/*', + 'examples/E2ETestApp/android/', + 'examples/E2ETestApp/ios/', + 'examples/E2ETestApp/*.{js,json}', + 'package/package.json', + 'package/shared-native/', + 'package/native-package/{package.json,*.podspec}', + 'package/native-package/android/', + 'package/native-package/ios/', + 'package.json' + ], + # Code nearly every test runs (startup, login, the channel list) selects one test per file instead. + hot_test_ratio: 0.9, + output_path: e2e_selection_path + ) +end + +# Ids of the tests whose last Allure result passed or was skipped, from the run's allure-results. +def e2e_passed_test_ids(results_path) + Dir.glob(File.join(results_path, '*-result.json')).map { |file| JSON.parse(File.read(file)) } + .group_by { |result| result['fullName'] }.values + .map { |runs| runs.max_by { |run| run['stop'].to_i } } + .select { |run| %w[passed skipped].include?(run['status']) } + .map { |run| "#{run['fullName'].split.first}.e2e/#{run['name']}" } +end + +desc 'Writes the failed tests of a `test_e2e coverage:true batch:` run to e2e-coverage/failed_tests_.json' +lane :e2e_failed_tests do |options| + # Every test of the batch that didn't pass, also those a dead emulator never ran. + tests = JSON.parse(File.read(e2e_batch_selection_path))['tests'] + failed = tests - e2e_passed_test_ids(allure_results_path) + FileUtils.mkdir_p(e2e_coverage_path) + File.write(File.join(e2e_coverage_path, "failed_tests_#{options[:batch] || 0}.json"), JSON.generate(failed)) + UI.important("Failed tests of batch #{options[:batch] || 0}: #{failed.join(', ')}") unless failed.empty? +end + +desc 'Builds the e2e coverage map from a `test_e2e coverage:true` run. Options: batch_count (merged CI batches)' +lane :build_e2e_coverage_map do |options| + if options[:batch_count] + # Every batch writes its failed tests even when they all pass, so a missing file means the batch + # never finished. + files = (0...options[:batch_count].to_i).map { |batch| File.join(e2e_coverage_path, "failed_tests_#{batch}.json") } + missing = files.each_index.reject { |batch| File.exist?(files[batch]) } + UI.user_error!("No coverage from batch(es) #{missing.join(', ')}; keeping the previous map") unless missing.empty? + failed = files.flat_map { |file| JSON.parse(File.read(file)) } + else + passed = e2e_passed_test_ids(allure_results_path) + failed = Dir.glob(File.join(allure_results_path, '*-result.json')) + .map { |file| JSON.parse(File.read(file)) } + .map { |run| "#{run['fullName'].split.first}.e2e/#{run['name']}" }.uniq - passed + end + UI.important("Failed tests, which keep their previous coverage: #{failed.join(', ')}") unless failed.empty? + build_test_coverage_map( + coverage_dir: e2e_coverage_path, + lcov_base_dir: root_path, + source_root: root_path, + output_path: e2e_coverage_map_path, + previous_map_artifact: e2e_coverage_map_artifact, + previous_map_branch: 'develop', + failed_tests: failed + ) +end + +desc 'Installs an iOS simulator runtime. Options: ios (e.g. 17.5)' +lane :install_runtime do |options| + install_ios_runtime(version: options[:ios], custom_script: File.join(root_path, 'bin/install_ios_runtime.sh')) +end + desc 'Installs the E2ETestApp pods with this bundle (the e2e app has no Gemfile of its own)' lane :e2e_pod_install do Dir.chdir(e2e_app_path) do @@ -344,14 +471,41 @@ private_lane :prepare_android_emulator do wait_android_emu_idle(timeout: 60) end -desc 'Runs the Detox e2e tests. Options: platform (ios|android), configuration (release|debug), ' \ - 'device (android: emu|att), skip_build, test (a test file or -t filter), local_server, mock_server_branch' +desc 'Runs the Detox e2e tests' lane :test_e2e do |options| next unless is_check_required(sources: @impact[:e2e][:sources], force_check: @force_check, required_checks: @impact[:e2e][:checks]) platform = options[:platform] || 'ios' device = platform == 'ios' ? 'sim' : (options[:device] || 'emu') detox_config = "#{platform}.#{device}.#{options[:configuration] || 'release'}" + coverage = options[:coverage] == true || options[:coverage].to_s == 'true' + + env = { 'MOCK_DRIVER_PORT' => mock_server_driver_port } + selection_path = options[:selection] && File.expand_path(options[:selection], root_path) + if selection_path + selection = JSON.parse(File.read(selection_path)) + if selection['tests'].empty? + UI.important("No e2e tests selected: #{selection['reasons'].join('; ')}") + next + end + UI.important("Running #{selection['run_all'] ? 'all' : selection['tests'].size} selected e2e tests") + end + if options[:batch] && options[:batch_count] + tests = selection ? selection['tests'] : e2e_test_ids(e2e_tests_path) + batch = options[:batch].to_i + batch_count = options[:batch_count].to_i + # Round-robin, as on native Android: slicing by a rounded-up size can leave the last batches empty. + batch_tests = tests.select.with_index { |_, i| i % batch_count == batch } + UI.user_error!("Batch #{batch} is empty: #{tests.size} tests cannot fill #{batch_count} batches") if batch_tests.empty? + UI.important("Batch #{batch + 1} of #{batch_count}: #{batch_tests.size} of #{tests.size} tests") + File.write(e2e_batch_selection_path, JSON.pretty_generate('run_all' => false, 'tests' => batch_tests)) + selection_path = e2e_batch_selection_path + end + env['E2E_SELECTION'] = selection_path if selection_path + if coverage + FileUtils.rm_rf(e2e_coverage_path) + env['E2E_COVERAGE_DIR'] = e2e_coverage_path + end start_mock_server(local_server: options[:local_server], branch: options[:mock_server_branch]) # An attached emulator is already booted; Detox boots `emu` devices itself. @@ -359,10 +513,12 @@ lane :test_e2e do |options| begin Dir.chdir(e2e_app_path) do - sh("yarn e2e:build -c #{detox_config}") unless options[:skip_build] + sh({ 'E2E_COVERAGE' => coverage ? '1' : '0' }, "yarn e2e:build -c #{detox_config}") unless options[:skip_build] + # Kept across Detox's --retries runs (jest-allure2-reporter `overwrite: false`), so cleared here. + FileUtils.rm_rf(allure_results_path) # On CI: retry flaky tests, run the simulator headless, and keep logs + screenshots of failures in ./artifacts. ci_flags = is_ci ? '--retries 2 --headless --record-logs failing --take-screenshots failing' : '' - sh("MOCK_DRIVER_PORT=#{mock_server_driver_port} yarn detox test -c #{detox_config} --cleanup #{ci_flags} #{options[:test]}") + sh(env, "yarn detox test -c #{detox_config} --cleanup #{ci_flags} #{options[:test]}") end ensure stop_mock_server diff --git a/examples/SampleApp/fastlane/Pluginfile b/examples/SampleApp/fastlane/Pluginfile index 280e94a8aa..93fe84e6b3 100644 --- a/examples/SampleApp/fastlane/Pluginfile +++ b/examples/SampleApp/fastlane/Pluginfile @@ -4,4 +4,4 @@ gem 'fastlane-plugin-firebase_app_distribution' gem 'fastlane-plugin-load_json' -gem 'fastlane-plugin-stream_actions', '0.4.14' +gem 'fastlane-plugin-stream_actions', '0.4.19' diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index ae2af1a81c..4f22e1be46 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -65,7 +65,7 @@ "react-native-teleport": "^1.2.2", "react-native-video": "^6.19.3", "react-native-worklets": "^0.13.0", - "stream-chat": "^10.0.0", + "stream-chat": "^10.0.1", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/src/components/NewDirectMessagingSendButton.tsx b/examples/SampleApp/src/components/NewDirectMessagingSendButton.tsx deleted file mode 100644 index 02cb592c1a..0000000000 --- a/examples/SampleApp/src/components/NewDirectMessagingSendButton.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import React from 'react'; -import { Alert } from 'react-native'; -import { TouchableOpacity } from 'react-native-gesture-handler'; - -import { useNavigation } from '@react-navigation/core'; - -import type { TextComposerState } from 'stream-chat'; -import { - Search, - SendRight, - useChannelContext, - useMessageComposer, - useStateStore, - useTheme, -} from 'stream-chat-react-native'; - -import { useAppContext } from '../context/AppContext'; -import { useUserSearchContext } from '../context/UserSearchContext'; -import { SendUp } from '../icons/SendUp'; -import { NewDirectMessagingScreenNavigationProp } from '../screens/NewDirectMessagingScreen'; -import { useLegacyColors } from '../theme/useLegacyColors'; - -const textComposerStateSelector = (state: TextComposerState) => ({ - command: state.command, -}); - -type NewDirectMessagingSendButtonPropsWithContext = { - /** Disables the button */ disabled: boolean; - giphyActive: boolean; - sendMessage: () => Promise; -}; - -const SendButtonWithContext = (props: NewDirectMessagingSendButtonPropsWithContext) => { - const { disabled = false, giphyActive, sendMessage } = props; - const { - theme: { - messageComposer: { sendButton }, - }, - } = useTheme(); - const { accent_blue, grey_gainsboro } = useLegacyColors(); - - return ( - - {giphyActive && } - {!giphyActive && disabled && } - {!giphyActive && !disabled && } - - ); -}; - -const areEqual = ( - prevProps: NewDirectMessagingSendButtonPropsWithContext, - nextProps: NewDirectMessagingSendButtonPropsWithContext, -) => { - const { - disabled: prevDisabled, - giphyActive: prevGiphyActive, - sendMessage: prevSendMessage, - } = prevProps; - const { - disabled: nextDisabled, - giphyActive: nextGiphyActive, - sendMessage: nextSendMessage, - } = nextProps; - - const disabledEqual = prevDisabled === nextDisabled; - if (!disabledEqual) { - return false; - } - - const giphyActiveEqual = prevGiphyActive === nextGiphyActive; - if (!giphyActiveEqual) { - return false; - } - - const sendMessageEqual = prevSendMessage === nextSendMessage; - if (!sendMessageEqual) { - return false; - } - - return true; -}; - -const MemoizedNewDirectMessagingSendButton = React.memo( - SendButtonWithContext, - areEqual, -) as typeof SendButtonWithContext; - -export type SendButtonProps = Partial; - -/** - * UI Component for send button in MessageComposer component. - */ -export const NewDirectMessagingSendButton = (props: SendButtonProps) => { - const { chatClient } = useAppContext(); - const navigation = useNavigation(); - const { channel } = useChannelContext(); - const { selectedUserIds, reset } = useUserSearchContext(); - const messageComposer = useMessageComposer(); - const { command } = useStateStore(messageComposer.textComposer.state, textComposerStateSelector); - const giphyActive = command?.name === 'giphy'; - - const sendMessage = async () => { - if (!channel) { - return; - } - if (!chatClient || !chatClient.user) { - return; - } - - const composition = await messageComposer.compose(); - if (!composition?.message) { - return; - } - - const members = [chatClient.user.id, ...selectedUserIds]; - const newChannel = chatClient.channel('messaging', { - members: members.map((user_id) => ({ user_id })), - }); - try { - await newChannel.watch(); - // v10 takes one request object: the message body plus the send flags that were the second - // positional argument in v9. - await newChannel.sendMessage({ - message: composition.message, - ...composition.sendOptions, - }); - messageComposer.clear(); - navigation.replace('ChannelScreen', { - channelId: newChannel.id, - }); - reset(); - } catch (e) { - if (e instanceof Error) { - Alert.alert('Error sending a message', e.message); - } - } - }; - - return ( - - ); -}; diff --git a/examples/SampleApp/src/hooks/useChatClient.ts b/examples/SampleApp/src/hooks/useChatClient.ts index 1e4a6bc914..f55899371d 100644 --- a/examples/SampleApp/src/hooks/useChatClient.ts +++ b/examples/SampleApp/src/hooks/useChatClient.ts @@ -14,6 +14,7 @@ import { SqliteClient } from 'stream-chat-react-native'; import { USER_TOKENS, USERS } from '../ChatUsers'; import type { LoginConfig } from '../types'; import AsyncStore from '../utils/AsyncStore'; +import { createOnFirstSend } from '../utils/startConversation'; import { clearLastToken, getTokenOrProvider, SAMPLE_APP_API_KEY } from '../utils/tokenProvider'; const messaging = getMessaging(); @@ -106,6 +107,11 @@ export const useChatClient = () => { // `linkPreviews.enabled` is deliberately not set: it defaults to `true` in v10 and is ANDed with // the channel type's `url_enrichment`, so the server has the final say either way. client.config.set({ + // Creates a new conversation on its first message (see `NewDirectMessagingScreen`). It + // applies to every channel, and every other channel's messages are sent the usual way. + channel: { + requestHandlers: { sendMessageRequest: createOnFirstSend(client) }, + }, messageComposer: { drafts: { enabled: true }, }, diff --git a/examples/SampleApp/src/i18n/de.ts b/examples/SampleApp/src/i18n/de.ts index f9e73940b0..ac40e67a56 100644 --- a/examples/SampleApp/src/i18n/de.ts +++ b/examples/SampleApp/src/i18n/de.ts @@ -312,6 +312,8 @@ export const de: TranslationDictionary = { 'message.pinnedBy.text': 'Angeheftet von', 'message.reactionList.more.accessibilityLabel_one': 'und {{count}} weitere Reaktion', 'message.reactionList.more.accessibilityLabel_other': 'und {{count}} weitere Reaktionen', + 'message.reactionList.total.accessibilityLabel_one': '{{count}} Reaktion', + 'message.reactionList.total.accessibilityLabel_other': '{{count}} Reaktionen', 'message.reactionList.viewHint.accessibilityLabel': 'Doppelt tippen, um die Reaktionen anzuzeigen', 'message.reactionList.youReacted.accessibilityLabel': 'du hast reagiert', diff --git a/examples/SampleApp/src/i18n/it.ts b/examples/SampleApp/src/i18n/it.ts index f33cf02133..28d28bfdd7 100644 --- a/examples/SampleApp/src/i18n/it.ts +++ b/examples/SampleApp/src/i18n/it.ts @@ -308,6 +308,8 @@ export const it: TranslationDictionary = { 'message.pinnedBy.text': 'Fissato da', 'message.reactionList.more.accessibilityLabel_one': "e un'altra reazione", 'message.reactionList.more.accessibilityLabel_other': 'e altre {{count}} reazioni', + 'message.reactionList.total.accessibilityLabel_one': '{{count}} reazione', + 'message.reactionList.total.accessibilityLabel_other': '{{count}} reazioni', 'message.reactionList.viewHint.accessibilityLabel': 'Tocca due volte per vedere le reazioni', 'message.reactionList.youReacted.accessibilityLabel': 'hai reagito', 'message.reminderOverdue.text': 'Promemoria scaduto', diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 478ea4775b..7039de0cf8 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -56,9 +56,13 @@ export type ChannelScreenProps = { export type ChannelHeaderProps = { channel: StreamChatChannel; + /** Whether a parent already applies the top safe-area inset. */ + inSafeArea?: boolean; + /** Replaces the default back behaviour. */ + onBack?: () => void; }; -const ChannelHeader: React.FC = ({ channel }) => { +export const ChannelHeader: React.FC = ({ channel, inSafeArea, onBack }) => { const { closePicker } = useAttachmentPickerContext(); const membersStatus = useChannelMembersStatus(channel); const displayName = useChannelPreviewDisplayName(channel); @@ -91,7 +95,8 @@ const ChannelHeader: React.FC = ({ channel }) => { return ( ( ({ initialized }); + +type NewConversationHeaderProps = { + onBack: () => void; + startedAsDraft: boolean; + userSearch: React.ReactNode; +}; + +/** + * Shows the user search until the conversation started on this screen is created by its first + * message, then the channel's header. An existing conversation keeps the user search, so the + * recipients can still be changed. + */ +const NewConversationHeader = ({ + onBack, + startedAsDraft, + userSearch, +}: NewConversationHeaderProps) => { + const { channel } = useChannelContext(); + // Re-renders when `watch()` creates the channel, which ends `isProvisional`. + useStateStore(channel.state, initializedSelector); + + if (startedAsDraft && !channel.isProvisional) { + return ; + } + return <>{userSearch}; +}; + export const EmptyMessagesIndicator = () => { const { grey } = useLegacyColors(); return ( @@ -212,13 +246,11 @@ export const NewDirectMessagingScreen: React.FC = currentChannel.current = channels[0]; isDraft.current = false; } else { - // Channel doesn't exist. `` leaves a channel built from members alone, and the - // send button creates it with the first message. + // Channel doesn't exist. `` leaves a channel built from members alone, and + // `createOnFirstSend` creates it with the first message, without leaving this screen. isDraft.current = true; - currentChannel.current = chatClient.channel('messaging', { - members: members.map((user_id) => ({ user_id })), - }); + currentChannel.current = startLocalConversation(chatClient, members); } if (messageInputRef.current) { @@ -384,16 +416,20 @@ export const NewDirectMessagingScreen: React.FC = channel={currentChannel.current} enforceUniqueReaction keyboardVerticalOffset={0} - overrideOwnCapabilities={{ sendMessage: true }} + overrideOwnCapabilities={ + currentChannel.current.isProvisional ? provisionalCapabilities : undefined + } setInputRef={(ref) => (messageInputRef.current = ref)} > - {renderUserSearch({ inSafeArea: true })} + {results && results.length >= 0 && !focusOnSearchInput && focusOnMessageInput && ( )} - - - + ); diff --git a/examples/SampleApp/src/utils/startConversation.ts b/examples/SampleApp/src/utils/startConversation.ts new file mode 100644 index 0000000000..8ef455bfc4 --- /dev/null +++ b/examples/SampleApp/src/utils/startConversation.ts @@ -0,0 +1,46 @@ +import { localMessageToNewMessagePayload } from 'stream-chat'; +import type { Channel, OperationRequestFn, StreamChat } from 'stream-chat'; + +/** + * A conversation with `memberIds` that exists only in this client until its first message: a + * channel created from its members, without an id, so it's provisional (`Channel.isProvisional`). + * If the client already has a loaded conversation created from exactly these members, + * `client.channel()` returns that one instead. + */ +export const startLocalConversation = (client: StreamChat, memberIds: string[]): Channel => { + const channel = client.channel('messaging', { + members: memberIds.map((user_id) => ({ user_id })), + }); + if (!channel.isProvisional || channel.messagePaginator.hasResults) { + return channel; + } + // Nothing is loaded for a channel the server doesn't have yet, so an empty first page, shorter + // than a page, makes the message list show its empty state with nothing older to load. + channel.messagePaginator.placeExternalQueryPage([], channel.messagePaginator.pageSize); + return channel; +}; + +/** + * Sends the first message of a provisional channel. `Channel.watch()` creates the channel on the + * server and resolves with the instance to use from then on, which is this one, or the one that + * superseded it when the client already had the channel under its real cid. Messages in every + * other channel are sent the usual way. + */ +export const createOnFirstSend = + (client: StreamChat): OperationRequestFn<'send'> => + async (params, defaultRequest) => { + const channel = client.channelManager.get(params.localMessage.cid); + if (!channel?.isProvisional) { + return defaultRequest(params); + } + + const created = await channel.watch(); + // Shows the new channel in the channel lists whose filters it matches. + client.channelManager.ingestChannel(created); + + const { message } = await created.sendMessage({ + message: params.message ?? localMessageToNewMessagePayload(params.localMessage), + ...params.options, + }); + return { message }; + }; diff --git a/package/expo-package/src/optionalDependencies/AudioVideo.ts b/package/expo-package/src/optionalDependencies/AudioVideo.ts index 538e341683..783155eb34 100644 --- a/package/expo-package/src/optionalDependencies/AudioVideo.ts +++ b/package/expo-package/src/optionalDependencies/AudioVideo.ts @@ -9,10 +9,4 @@ try { // do nothing } -if (!AudioComponent) { - console.log( - 'The audio library is currently not installed. To allow in-app audio playback, install the "expo-av" package.', - ); -} - export { AudioComponent, RecordingObject }; diff --git a/package/expo-package/src/optionalDependencies/Sound.ts b/package/expo-package/src/optionalDependencies/Sound.ts index 5dc0a1835e..600d4a496e 100644 --- a/package/expo-package/src/optionalDependencies/Sound.ts +++ b/package/expo-package/src/optionalDependencies/Sound.ts @@ -13,6 +13,13 @@ try { // do nothing } +// Playback and recording use expo-audio, or the deprecated expo-av when only that is installed. +if (!ExpoAudioComponent && !AudioComponent) { + console.log( + 'The audio library is currently not installed. To allow in-app audio playback and voice recording, install the "expo-audio" package.', + ); +} + export const Sound = { // Always try to prioritize expo-audio if it's there. initializeSound: ExpoAudioComponent diff --git a/package/expo-package/src/optionalDependencies/__tests__/Sound.test.ts b/package/expo-package/src/optionalDependencies/__tests__/Sound.test.ts new file mode 100644 index 0000000000..34f697a95c --- /dev/null +++ b/package/expo-package/src/optionalDependencies/__tests__/Sound.test.ts @@ -0,0 +1,48 @@ +// A missing module is a mock whose factory throws, as `require` does for a package that isn't installed. +const mockOptionalModule = (name: string, exports?: object) => + jest.doMock( + name, + () => { + if (!exports) { + throw new Error(`Cannot find module '${name}'`); + } + return exports; + }, + { virtual: true }, + ); + +const audioLibraryLogsOnLoad = () => { + const log = jest.spyOn(console, 'log').mockImplementation(() => undefined); + require('../Sound'); + return log.mock.calls + .flat() + .filter((message) => typeof message === 'string' && message.includes('audio library')); +}; + +describe('expo Sound', () => { + afterEach(() => { + jest.resetModules(); + jest.restoreAllMocks(); + }); + + it('asks for no audio library when only expo-audio is installed', () => { + mockOptionalModule('expo-audio', { AudioModule: {}, createAudioPlayer: jest.fn() }); + mockOptionalModule('expo-av'); + + expect(audioLibraryLogsOnLoad()).toEqual([]); + }); + + it('asks for no audio library when only expo-av is installed', () => { + mockOptionalModule('expo-audio'); + mockOptionalModule('expo-av', { Audio: {} }); + + expect(audioLibraryLogsOnLoad()).toEqual([]); + }); + + it('asks for expo-audio when no audio library is installed', () => { + mockOptionalModule('expo-audio'); + mockOptionalModule('expo-av'); + + expect(audioLibraryLogsOnLoad()).toEqual([expect.stringContaining('"expo-audio"')]); + }); +}); diff --git a/package/package.json b/package/package.json index a972d26890..406824b293 100644 --- a/package/package.json +++ b/package/package.json @@ -85,7 +85,7 @@ "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", "react-syntax-highlighter": "^16.1.0", - "stream-chat": "^10.0.0", + "stream-chat": "^10.0.1", "use-sync-external-store": "^1.7.0", "zod": "^4.1.12" }, diff --git a/package/src/components/Channel/ChannelShell.tsx b/package/src/components/Channel/ChannelShell.tsx index 8ec4b0465b..81962b36e6 100644 --- a/package/src/components/Channel/ChannelShell.tsx +++ b/package/src/components/Channel/ChannelShell.tsx @@ -61,32 +61,44 @@ import { toUnicodeScalarString } from '../MessageMenu/utils/toUnicodeScalarStrin export const reactionData: ReactionData[] = [ { - Icon: ({ size = 12 }: { size?: number }) => , + Icon: ({ size = 12, testID }: { size?: number; testID?: string }) => ( + + ), type: 'like', isMain: true, }, { - Icon: ({ size = 12 }: { size?: number }) => , + Icon: ({ size = 12, testID }: { size?: number; testID?: string }) => ( + + ), type: 'haha', isMain: true, }, { - Icon: ({ size = 12 }: { size?: number }) => , + Icon: ({ size = 12, testID }: { size?: number; testID?: string }) => ( + + ), type: 'love', isMain: true, }, { - Icon: ({ size = 12 }: { size?: number }) => , + Icon: ({ size = 12, testID }: { size?: number; testID?: string }) => ( + + ), type: 'wow', isMain: true, }, { - Icon: ({ size = 12 }: { size?: number }) => , + Icon: ({ size = 12, testID }: { size?: number; testID?: string }) => ( + + ), type: 'sad', isMain: true, }, ...emojis.map((emoji) => ({ - Icon: ({ size = 12 }: { size?: number }) => , + Icon: ({ size = 12, testID }: { size?: number; testID?: string }) => ( + + ), type: toUnicodeScalarString(emoji), })), ]; diff --git a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx index 665281bbec..430d6a2c5f 100644 --- a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx @@ -115,13 +115,20 @@ export const ChannelMessagePreviewDeliveryStatus = ({ {messageStatus === MessageStatusTypes.SENDING ? ( - + ) : messageStatus === MessageStatusTypes.RECEIVED && status === MessageDeliveryStatus.READ ? ( ) : status === MessageDeliveryStatus.DELIVERED ? ( @@ -129,10 +136,17 @@ export const ChannelMessagePreviewDeliveryStatus = ({ stroke={semantics.chatTextTimestamp} height={16} width={16} + testID='channel-preview-status-delivered' {...checkAllIcon} /> ) : status === MessageDeliveryStatus.SENT ? ( - + ) : null} {t('common.you.label', 'You')}: diff --git a/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx b/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx index 0d9d18ffce..892b01ae83 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx @@ -71,7 +71,10 @@ export const ChannelPreviewMessage = (props: ChannelPreviewMessageProps) => { if (!lastMessage) { return ( - + {t('channelPreview.noMessages.text', 'No messages yet')} ); @@ -92,7 +95,7 @@ export const ChannelPreviewMessage = (props: ChannelPreviewMessageProps) => { return ( - + {t('channelPreview.messageFailed.error', 'Message failed to send')} diff --git a/package/src/components/ChannelPreview/ChannelPreviewMutedStatus.tsx b/package/src/components/ChannelPreview/ChannelPreviewMutedStatus.tsx index e7f9dc5b0a..a8f39b3c29 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewMutedStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewMutedStatus.tsx @@ -18,7 +18,13 @@ export const ChannelPreviewMutedStatus = () => { return ( - + ); }; diff --git a/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx b/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx index 9b8d89ba19..aec959e57d 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewStatus.tsx @@ -63,7 +63,11 @@ export const ChannelPreviewStatus = (props: ChannelPreviewStatusProps) => { ); return ( - + {visibleDate} ); diff --git a/package/src/components/ChannelPreview/ChannelPreviewUnreadCount.tsx b/package/src/components/ChannelPreview/ChannelPreviewUnreadCount.tsx index cb66f7d4bc..fda1dc7e78 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewUnreadCount.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewUnreadCount.tsx @@ -28,6 +28,7 @@ export const ChannelPreviewUnreadCount = (props: ChannelPreviewUnreadCountProps) accessibilityLabel={accessibilityLabel} count={unread > maxUnreadCount ? maxUnreadCount : unread} size='sm' + testID='channel-preview-unread-count' type='primary' /> ); diff --git a/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx b/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx index 00b665c077..74c78cf064 100644 --- a/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx +++ b/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx @@ -49,7 +49,7 @@ const SentToChannelHeaderWithContext = (props: SentToChannelHeaderPropsWithConte > ยท - + {t('message.sentToChannelHeader.view.label', 'View')} diff --git a/package/src/components/Message/MessageItemView/ReactionList/ReactionListClustered.tsx b/package/src/components/Message/MessageItemView/ReactionList/ReactionListClustered.tsx index cf8c769ad9..6081f54302 100644 --- a/package/src/components/Message/MessageItemView/ReactionList/ReactionListClustered.tsx +++ b/package/src/components/Message/MessageItemView/ReactionList/ReactionListClustered.tsx @@ -21,18 +21,18 @@ import { primitives } from '../../../../theme'; import type { ReactionData } from '../../../../utils/utils'; import { HiddenA11yText } from '../../../Accessibility/HiddenA11yText'; -type Props = Pick & { +type Props = Pick & { size: number; type: string; supportedReactions?: ReactionData[]; }; -const Icon = ({ size, style, supportedReactions, type }: Props) => { +const Icon = ({ size, style, supportedReactions, testID, type }: Props) => { const { icons } = useComponentsContext(); const ReactionIcon = supportedReactions?.find((reaction) => reaction.type === type)?.Icon || icons.Unknown; - return ; + return ; }; export type ReactionListClusteredPropsWithContext = Partial< @@ -72,12 +72,12 @@ export const ReactionListClusteredWithContext = (props: ReactionListClusteredPro const supportedReactionTypes = supportedReactions?.map( (supportedReaction) => supportedReaction.type, ); - const reactionsCount = reactions?.length || 0; - const moreReactionsCount = reactionsCount - 4; + const totalReactionCount = + reactions?.reduce((total, reaction) => total + (reaction.count ?? 0), 0) ?? 0; const reactionsCountText = - moreReactionsCount < 99 ? moreReactionsCount : `+${moreReactionsCount}`; - const moreReactionsA11yText = useA11yLabel('message.reactionList.more.accessibilityLabel', { - count: moreReactionsCount, + totalReactionCount < 99 ? totalReactionCount : `+${totalReactionCount}`; + const totalReactionsA11yText = useA11yLabel('message.reactionList.total.accessibilityLabel', { + count: totalReactionCount, }); const hasSupportedReactions = reactions?.some((reaction) => @@ -127,13 +127,14 @@ export const ReactionListClusteredWithContext = (props: ReactionListClusteredPro size={icon.size} style={[styles.iconStyle, icon.style]} supportedReactions={supportedReactions} + testID={`reaction-list-item-${reaction.type}`} type={reaction.type} /> {reaction.own ? : null} ))} - {reactionsCount > 4 ? ( - + {totalReactionCount > 1 ? ( + {reactionsCountText} ) : null} diff --git a/package/src/components/Message/MessageItemView/__tests__/ReactionListClustered.test.tsx b/package/src/components/Message/MessageItemView/__tests__/ReactionListClustered.test.tsx new file mode 100644 index 0000000000..d1cb80bb24 --- /dev/null +++ b/package/src/components/Message/MessageItemView/__tests__/ReactionListClustered.test.tsx @@ -0,0 +1,113 @@ +import React from 'react'; + +import { cleanup, render, screen, waitFor } from '@testing-library/react-native'; +import type { Channel as ChannelType, StreamChat } from 'stream-chat'; + +import { AccessibilityProvider } from '../../../../contexts/accessibilityContext/AccessibilityContext'; +import { getOrCreateChannelApi } from '../../../../mock-builders/api/getOrCreateChannel'; +import { useMockedApis } from '../../../../mock-builders/api/useMockedApis'; +import { generateChannelResponse } from '../../../../mock-builders/generator/channel'; +import { generateMember } from '../../../../mock-builders/generator/member'; +import { generateMessage } from '../../../../mock-builders/generator/message'; +import { generateUser } from '../../../../mock-builders/generator/user'; +import { getTestClientWithUser } from '../../../../mock-builders/mock'; +import { Channel } from '../../../Channel/Channel'; +import { Chat } from '../../../Chat/Chat'; +import { ReactionListClustered } from '../ReactionList/ReactionListClustered'; + +describe('ReactionListClustered', () => { + let channel: ChannelType; + let chatClient: StreamChat; + let renderReactionList: ( + props: React.ComponentProps, + ) => ReturnType; + + const user = generateUser({ id: 'id', name: 'name' }); + const messages = [generateMessage({ user })]; + + beforeEach(async () => { + const members = [generateMember({ user })]; + const mockedChannel = generateChannelResponse({ + members, + messages, + }); + + chatClient = await getTestClientWithUser(user); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + channel = chatClient.channel('messaging', mockedChannel.channel.id); + + renderReactionList = (props) => + render( + + + + + , + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + it('shows the total reaction count across all types', async () => { + renderReactionList({ + hasReactions: true, + reactions: [ + { count: 3, own: true, type: 'love' }, + { count: 2, own: false, type: 'like' }, + ], + }); + + await waitFor(() => { + expect(screen.getByTestId('reaction-list-item-love')).toBeTruthy(); + expect(screen.getByText('5')).toBeTruthy(); + }); + }); + + it('shows the count for a single reaction type reacted more than once', async () => { + renderReactionList({ + hasReactions: true, + reactions: [{ count: 2, own: false, type: 'love' }], + }); + + await waitFor(() => { + expect(screen.getByText('2')).toBeTruthy(); + }); + }); + + it('labels the count with the total for screen readers', async () => { + render( + + + + + + + , + ); + + await waitFor(() => { + expect(screen.getByLabelText('5 reactions')).toBeTruthy(); + }); + }); + + it('hides the count when there is only one reaction', async () => { + renderReactionList({ + hasReactions: true, + reactions: [{ count: 1, own: true, type: 'love' }], + }); + + await waitFor(() => { + expect(screen.getByTestId('reaction-list-item-love')).toBeTruthy(); + }); + expect(screen.queryByText('1')).toBeNull(); + }); +}); diff --git a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx index bef332a262..0f1053a760 100644 --- a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx +++ b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx @@ -16,6 +16,7 @@ import { generateImageAttachment as generateImageAttachmentBase, generateVideoAttachment as generateVideoAttachmentBase, } from '../../../mock-builders/attachments'; +import { generateMessage } from '../../../mock-builders/generator/message'; import { FileState } from '../../../utils/utils'; import { Channel } from '../../Channel/Channel'; @@ -629,4 +630,27 @@ describe('AttachmentUploadPreviewList', () => { }); }); }); + + describe('VideoAttachmentUploadPreview', () => { + it('renders a video restored from a sent message, which has no local file', async () => { + act(() => { + channel.messageComposer.initState({ + composition: generateMessage({ + attachments: [ + generateVideoAttachmentBase({ + asset_url: 'https://video-attachment.mp4', + thumb_url: 'https://video-attachment-thumb.png', + }), + ], + }), + }); + }); + + renderComponent({ channel, client, props: {} }); + + await waitFor(() => { + expect(screen.queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); + }); + }); + }); }); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx index dcd3e69f31..7a74540a12 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx @@ -29,7 +29,7 @@ export const VideoAttachmentUploadPreview = ({ // `localMetadata.file` is `File | FileReference`; only the RN `FileReference` carries a // `duration` (and a required `uri` to discriminate on), so read it through a guard. const localFile = attachment.localMetadata.file; - const duration = 'uri' in localFile ? localFile.duration : undefined; + const duration = localFile && 'uri' in localFile ? localFile.duration : undefined; return previewUri ? ( <> diff --git a/package/src/components/MessageInput/components/LinkPreviewList.tsx b/package/src/components/MessageInput/components/LinkPreviewList.tsx index b5e335b60a..3aef1afc93 100644 --- a/package/src/components/MessageInput/components/LinkPreviewList.tsx +++ b/package/src/components/MessageInput/components/LinkPreviewList.tsx @@ -55,7 +55,11 @@ export const LinkPreviewCard = ({ linkPreview }: LinkPreviewProps) => { } return ( - + @@ -87,7 +91,7 @@ export const LinkPreviewCard = ({ linkPreview }: LinkPreviewProps) => { - + ); diff --git a/package/src/components/MessageList/InlineUnreadIndicator.tsx b/package/src/components/MessageList/InlineUnreadIndicator.tsx index bbf89058f9..85774b9f55 100644 --- a/package/src/components/MessageList/InlineUnreadIndicator.tsx +++ b/package/src/components/MessageList/InlineUnreadIndicator.tsx @@ -15,7 +15,11 @@ export const InlineUnreadIndicator = (props: InlineUnreadIndicatorProps) => { const { t } = useTranslationContext(); return ( - + {unreadCount ? t('messageList.newMessages.text', { diff --git a/package/src/components/MessageList/ScrollToBottomButton.tsx b/package/src/components/MessageList/ScrollToBottomButton.tsx index 51fa3510ee..0cf746bb7c 100644 --- a/package/src/components/MessageList/ScrollToBottomButton.tsx +++ b/package/src/components/MessageList/ScrollToBottomButton.tsx @@ -78,6 +78,7 @@ export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => { accessibilityElementsHidden importantForAccessibility='no-hide-descendants' style={styles.unreadCountNotificationContainer} + testID='scroll-to-bottom-button-unread-count' > {unreadCount ? ( diff --git a/package/src/components/MessageList/UnreadMessagesNotification.tsx b/package/src/components/MessageList/UnreadMessagesNotification.tsx index 0713a6943d..dcd538c5af 100644 --- a/package/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/package/src/components/MessageList/UnreadMessagesNotification.tsx @@ -82,7 +82,7 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp const styles = useStyles(); return ( - +