diff --git a/.github/workflows/e2e-test-cron.yml b/.github/workflows/e2e-test-cron.yml
new file mode 100644
index 0000000000..ccd3749828
--- /dev/null
+++ b/.github/workflows/e2e-test-cron.yml
@@ -0,0 +1,322 @@
+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:
+ if: github.event_name == 'schedule' || inputs.ios
+ runs-on: macos-15
+ timeout-minutes: 180
+ env:
+ DETOX_IOS_DEVICE: "iPhone 17 Pro"
+ IOS_VERSION: "26.2"
+ 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 }}
+ # 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
+ 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 &
+ - 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
+ 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 }}
+
+ # --- 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
+ - 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
+ - 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"
+ - 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
+ path: |
+ examples/E2ETestApp/stream-chat-test-mock-server/logs
+ examples/E2ETestApp/artifacts
+
+ android:
+ if: github.event_name == 'schedule' || inputs.android
+ runs-on: ubuntu-latest
+ timeout-minutes: 180
+ 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"
+ 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:
+ - 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
+ - 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
+ 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, instrumented (E2E_COVERAGE): never cached with the pull requests' builds ---
+ # Built before the emulator boots so the emulator doesn't idle through the Gradle build.
+ - name: Build app
+ working-directory: examples/E2ETestApp
+ run: yarn e2e:build -c android.att.release
+ env:
+ E2E_COVERAGE: "1"
+ # Frees the daemons' memory for the emulator, which otherwise can get killed mid-run.
+ - name: Stop Gradle daemons
+ working-directory: examples/E2ETestApp/android
+ run: ./gradlew --stop
+
+ # --- 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
+
+ # --- 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"
+ - 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)
+ uses: reactivecircus/android-emulator-runner@v2
+ with:
+ api-level: ${{ env.ANDROID_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:true
+ # Also when tests failed: those keep their coverage from the previous map.
+ - name: Build e2e coverage map
+ if: success() || failure()
+ working-directory: examples/SampleApp
+ run: bundle exec fastlane build_e2e_coverage_map
+ - uses: actions/upload-artifact@v6
+ if: success() || failure()
+ with:
+ name: e2e-coverage-map
+ path: examples/E2ETestApp/e2e_coverage_map.json
+ retention-days: 14
+ - 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-android-coverage-logs
+ path: |
+ examples/E2ETestApp/stream-chat-test-mock-server/logs
+ examples/E2ETestApp/artifacts
+ /tmp/android-runner
diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml
index e71792358f..be075f9399 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
@@ -190,9 +202,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 +233,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:
@@ -259,7 +287,6 @@ 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
@@ -270,13 +297,76 @@ jobs:
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
- # --- Emulator: same setup as the native Android SDK's e2e jobs (AOSP image, no Play services) ---
+ 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) }}
+
+ 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, 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"
+ 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
@@ -295,6 +385,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 +398,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 (GiphyTests).
+ 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 +421,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 +434,8 @@ jobs:
if: always()
needs:
- check
+ - android-build
+ - android-allure-launch
- android
steps:
- name: Check E2E results
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/eslint.config.mjs b/eslint.config.mjs
index c183ed0f82..d65352f459 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -47,6 +47,11 @@ export default tsEslint.config(
// 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/examples/E2ETestApp/.detoxrc.js b/examples/E2ETestApp/.detoxrc.js
index 2e090e80c7..c927c5bacd 100644
--- a/examples/E2ETestApp/.detoxrc.js
+++ b/examples/E2ETestApp/.detoxrc.js
@@ -27,7 +27,7 @@ module.exports = {
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
testBinaryPath: 'android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk',
build:
- 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..',
+ 'cd android && ./gradlew :app:assembleDebug :app:assembleAndroidTest -DtestBuildType=debug && cd ..',
reversePorts: [8081],
},
'android.release': {
@@ -36,7 +36,7 @@ module.exports = {
testBinaryPath:
'android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk',
build:
- 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release && cd ..',
+ 'cd android && ./gradlew :app:assembleRelease :app:assembleAndroidTest -DtestBuildType=release && cd ..',
},
},
devices: {
diff --git a/examples/E2ETestApp/.gitignore b/examples/E2ETestApp/.gitignore
index 9be6361de6..58e1dd69c8 100644
--- a/examples/E2ETestApp/.gitignore
+++ b/examples/E2ETestApp/.gitignore
@@ -77,3 +77,8 @@ yarn-error.log
# Detox e2e: mock server checkout cloned by `fastlane start_mock_server`, test artifacts
/stream-chat-test-mock-server/
/artifacts/
+# Impact analysis: per-test coverage, the map built from it, the selected tests
+/e2e-coverage/
+/e2e_coverage_map.json
+/e2e_selection.json
+/e2e_batch_selection.json
diff --git a/examples/E2ETestApp/App.tsx b/examples/E2ETestApp/App.tsx
index 4051222276..28c56dd89c 100644
--- a/examples/E2ETestApp/App.tsx
+++ b/examples/E2ETestApp/App.tsx
@@ -1,5 +1,5 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
-import { BackHandler, Pressable, StyleSheet, Text, View } from 'react-native';
+import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
+import { Alert, BackHandler, Pressable, StyleSheet, Text, View } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider, SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -8,20 +8,34 @@ import {
Channel as ChannelType,
SortParamRequest,
StreamChat,
+ Thread as ThreadType,
} from 'stream-chat';
import {
+ AlsoSentToChannelHeaderPressPayload,
Channel,
+ ChannelActionItem,
+ ChannelDetails,
+ ChannelDetailsContextProvider,
ChannelList,
+ GetChannelActionItems,
+ DEFAULT_HIGHLIGHT_DURATION,
Chat,
+ MessageActionsParams,
+ messageActions as defaultMessageActions,
MessageComposer,
MessageList,
OverlayProvider,
+ SqliteClient,
+ Thread,
+ ThreadList,
} from 'stream-chat-react-native';
-import { apiKey, appUser, appUserToken, mockServerUrl } from './src/config';
+import { apiKey, appUser, appUserToken, isJwtTest, mockServerUrl } from './src/config';
+import { JwtScreen } from './src/JwtScreen';
// Same opt-in as the SampleApp, so the e2e suite sees the same accessibility tree.
const accessibility = { enabled: true };
+
const sort: SortParamRequest[] = [{ direction: -1, field: 'last_message_at' }];
/** Lists the mock server's app user, like the SampleApp's user selector. */
@@ -42,45 +56,226 @@ const LoginScreen = ({ onLogin }: { onLogin: () => 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..6c3226dd89 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' | 'none') {
+ 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..cb60ea40e1 100644
--- a/examples/E2ETestApp/e2e/pages/MessageListPage.ts
+++ b/examples/E2ETestApp/e2e/pages/MessageListPage.ts
@@ -24,21 +24,146 @@ export const MessageListPage = {
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: {
+ 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 +178,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 +196,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..4d688d67c5 100644
--- a/examples/E2ETestApp/e2e/robots/UserRobot.ts
+++ b/examples/E2ETestApp/e2e/robots/UserRobot.ts
@@ -1,17 +1,35 @@
+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,
+ isDisplayed,
+ sleep,
waitToAppear,
waitToAppearAndClick,
waitToAppearAndLongPress,
waitToDisappear,
+ waitUntil,
} from '../support/Element';
const { ChannelList } = ChannelListPage;
-const { Composer, Header, MessageList } = MessageListPage;
+const { Alert, Composer, ContextMenu, Header, MessageList } = MessageListPage;
+
+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 +57,442 @@ 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 () => {
+ const index = await indexFromBottom(MessageList.messageContents, messageCellIndex);
+ await waitToAppearAndLongPress(MessageList.messageContents, { index });
+ });
}
/**
- * 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 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));
+ await waitToAppearAndLongPress(selector, { index: candidates[0]?.index ?? 0 });
+ });
+ }
+
+ tapOnContextMenuAction(actionType: string): this {
+ return this.perform(() => waitToAppearAndClick(ContextMenu.action(actionType), { index: 0 }));
+ }
+
+ closeContextMenu(): this {
+ return this.perform(async () => {
+ if (device.getPlatform() === 'android') {
+ await device.pressBack();
+ } else {
+ await waitToAppearAndClick(ContextMenu.backdrop);
+ }
+ await waitToDisappear(ContextMenu.backdrop);
+ });
+ }
+
+ /** 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 () => {
- const inputField = await waitToAppear(Composer.inputField);
if (device.getPlatform() === 'android') {
- await inputField.replaceText(text);
+ // 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);
+ }
+ const inputField = await waitToAppearAndClick(Composer.inputField);
+ if (device.getPlatform() === 'android') {
+ adb('shell', 'input', 'keyevent', 'KEYCODE_PASTE');
} else {
- await inputField.typeText(text);
+ await inputField.longPress();
+ await waitToAppearAndClick(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++) {
+ await waitToAppearAndClick(MessageListPage.AttachmentPicker.images, { index: 0 });
}
});
}
+ createPoll(question: string, options: string[], { multipleVotes = false } = {}): this {
+ const { CreatePoll } = MessageListPage;
+ return this.openAttachmentsMenu()
+ .perform(() => waitToAppearAndClick(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();
+ }
+ await waitToAppearAndClick(CreatePoll.multipleVotesSwitch, { index: 0 });
+ }
+ await waitToAppearAndClick(CreatePoll.createButton);
+ });
+ }
+
+ 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)) {
+ await waitToAppearAndClick(confirm);
+ }
+ },
+ );
+ }
+
+ 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(() =>
+ waitToAppearAndClick(ChannelList.Channel.swipeAction('openSheet'), { index: 0 }),
+ );
+ }
+
+ tapOnMuteSwipeAction(): this {
+ return this.perform(() =>
+ waitToAppearAndClick(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(() =>
+ waitToAppearAndClick(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(() =>
+ waitToAppearAndClick(ContextMenu.reaction(type), { index: 0 }),
+ );
+ }
+
+ /** 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(() => waitToAppearAndClick(ContextMenu.showMoreReactions, { index: 0 }))
+ // 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(() =>
+ waitToAppearAndClick(MessageList.Message.Reactions.reaction(type), { index: 0 }),
+ );
+ }
+
+ tapOnAlertButton(text: string): this {
+ return this.perform(() => waitToAppearAndClick(Alert.button(text)));
+ }
+
+ /**
+ * 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);
+ await waitToAppearAndClick(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 +500,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 waitToAppearAndClick(Header.backButton, { index: backButtons });
+ });
}
/** Android hardware back; iOS has none, so it taps the header's back button there. */
@@ -99,6 +555,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 +601,32 @@ 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 isDisplayed(MessageList.Message.text(text))); i++) {
+ await (await waitToAppear(MessageList.scrollTarget, { index: 0 })).swipe('up', 'slow', 0.5);
+ await sleep(300);
+ }
+ });
+ }
+
+ scrollMessageListUpToMessage(text: string, maxScrolls = 10): this {
+ return this.perform(async () => {
+ for (let i = 0; i < maxScrolls && !(await isDisplayed(MessageList.Message.text(text))); i++) {
+ await (
+ await waitToAppear(MessageList.scrollTarget, { index: 0 })
+ ).swipe('down', 'slow', 0.5);
+ await sleep(300);
+ }
+ });
}
backgroundApp(): this {
@@ -126,10 +639,53 @@ 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) => {
+ const index = await indexFromBottom(MessageList.messageContents, messageCellIndex);
+ await waitToAppearAndLongPress(MessageList.messageContents, { index });
+ await waitToAppearAndClick(ContextMenu.action('editMessage'), { index: 0 });
+ 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..0e19e52b84 100644
--- a/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts
+++ b/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts
@@ -1,19 +1,27 @@
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,
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 +32,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;
}
}
@@ -51,7 +127,9 @@ 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.
+ const timestamps = await allAttributes(Message.timestamp);
+ jestExpect(timestamps.some((timestamp) => timestamp.visible)).toBe(true);
}
});
};
@@ -79,7 +157,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 +170,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 +197,455 @@ 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 {
+ await waitToAppear(Message.deletedMessage, { index: 0 });
+ 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();
+ const index = await indexFromBottom(MessageList.messageContents, 0);
+ const messageText = await textOf(MessageList.messageContents, index);
+ newText = increased ? `ok\n${messageText}\nnew line` : 'new line';
+ })
+ .perform(async () => {
+ // The edit's text depends on the message text read above.
+ 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 () => {
+ jestExpect(await isDisplayed(Message.text(`${messagesCount}`))).toBe(true);
+ for (let i = 0; i < 30 && !(await isDisplayed(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) => {
+ const quotes = (await allAttributes(Message.quotedMessage)).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.
+ await waitToAppear(MessageListPage.ThreadPage.repliesCountLabel);
+ 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 () => {
+ jestExpect(await waitForCount(Message.text(text), expected)).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;
+ const found = await waitUntil(async () =>
+ (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 () => {
+ await assertVisibility(Composer.mediaAttachmentPreview, shown);
+ if (shown) {
+ 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(() => assertVisibility(Message.gallery, shown, 30000));
+};
+
+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..e031700c0c 100644
--- a/examples/E2ETestApp/e2e/support/Element.ts
+++ b/examples/E2ETestApp/e2e/support/Element.ts
@@ -162,3 +162,59 @@ 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;
+};
+
+/** The frame of the `index`-th match of `selector`. */
+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/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..be875adb27 100644
--- a/examples/E2ETestApp/e2e/support/StreamTestCase.ts
+++ b/examples/E2ETestApp/e2e/support/StreamTestCase.ts
@@ -1,6 +1,15 @@
-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 { MockServer } from '../mock-server/MockServer';
import { BackendRobot, ParticipantRobot, UserRobot } from '../robots';
@@ -29,10 +38,31 @@ 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;
+
+ if (coverage) {
+ beforeAll(() => coverage.start());
+ afterAll(() => coverage.stop());
+ }
beforeEach(async () => {
+ coverage?.reset();
await testCase.mockServer.start(currentTestName());
if (device.getPlatform() === 'android') {
dismissAnrDialog();
@@ -40,11 +70,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 +87,30 @@ 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;
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..d345be1339
--- /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 { 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(),
+ );
+ });
+
+ it('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..70d544f4c0
--- /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();
+
+ // The Android CI emulator dies loading giphy media: 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),
+ );
+ });
+
+ // The Android CI emulator dies loading giphy media: 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());
+ });
+
+ // The Android CI emulator dies loading giphy media: 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());
+ });
+
+ // The Android CI emulator dies loading giphy media: 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(),
+ );
+ });
+
+ // The Android CI emulator dies loading giphy media: 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());
+ });
+
+ // The Android CI emulator dies loading giphy media: 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),
+ );
+ });
+
+ // The Android CI emulator dies loading giphy media: 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'),
+ );
+ });
+
+ // The Android CI emulator dies loading giphy media: 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'),
+ );
+ });
+
+ // The Android CI emulator dies loading giphy media: 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..10aa29d273 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 { 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,543 @@ 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.
+ it('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),
+ );
+ });
+
+ it('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..dfebd9571a
--- /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),
+ );
+ });
+
+ it('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 0a0aeb264f..c2033f3cdd 100644
--- a/examples/E2ETestApp/package.json
+++ b/examples/E2ETestApp/package.json
@@ -13,15 +13,21 @@
"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-rc.34",
"stream-chat-react-native": "workspace:^"
@@ -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/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..8bffe8f5b9 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,97 @@ 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` and `itOnIOS` ones are in (Jest skips them
+# on the other platform).
+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)(?:\.skip)?\(/).size
+ tests = source.scan(/^\s*(?:it|itOnAndroid|itOnIOS)(\.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
+
+desc 'Builds the e2e coverage map from a `test_e2e coverage:true` run'
+lane :build_e2e_coverage_map do
+ results = Dir.glob(File.join(allure_results_path, '*-result.json')).map { |file| JSON.parse(File.read(file)) }
+ failed = results.group_by { |result| result['fullName'] }.values
+ .map { |runs| runs.max_by { |run| run['stop'].to_i } }
+ .reject { |run| %w[passed skipped].include?(run['status']) }
+ .map { |run| "#{run['fullName'].split.first}.e2e/#{run['name']}" }
+ 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 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 +439,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 +481,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/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx
index 665281bbec..a3aade96af 100644
--- a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx
+++ b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx
@@ -87,6 +87,18 @@ export const ChannelMessagePreviewDeliveryStatus = ({
: 'message.status.sending.accessibilityLabel',
);
+ // Which icon the row shows, for e2e tests (the icons themselves carry no identifier).
+ const statusTestID =
+ messageStatus === MessageStatusTypes.SENDING
+ ? 'channel-preview-status-sending'
+ : messageStatus === MessageStatusTypes.RECEIVED && status === MessageDeliveryStatus.READ
+ ? 'channel-preview-status-read'
+ : status === MessageDeliveryStatus.DELIVERED
+ ? 'channel-preview-status-delivered'
+ : status === MessageDeliveryStatus.SENT
+ ? 'channel-preview-status-sent'
+ : 'channel-preview-status-none';
+
if (
!channel.data?.custom?.name &&
membersWithoutSelf.length === 1 &&
@@ -113,7 +125,7 @@ export const ChannelMessagePreviewDeliveryStatus = ({
return (
-
+
{messageStatus === MessageStatusTypes.SENDING ? (
) : messageStatus === MessageStatusTypes.RECEIVED &&
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..fe2b838855 100644
--- a/package/src/components/Message/MessageItemView/ReactionList/ReactionListClustered.tsx
+++ b/package/src/components/Message/MessageItemView/ReactionList/ReactionListClustered.tsx
@@ -1,5 +1,5 @@
import React, { Fragment, useMemo } from 'react';
-import { StyleProp, StyleSheet, Text, ViewStyle } from 'react-native';
+import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native';
import { ReactionListItemWrapper } from './ReactionListItemWrapper';
@@ -123,12 +123,15 @@ export const ReactionListClusteredWithContext = (props: ReactionListClusteredPro
>
{reactions?.slice(0, 4).map((reaction) => (
-
+ {/* Identifies each reaction type for e2e tests; adds no layout of its own. */}
+
+
+
{reaction.own ? : null}
))}
diff --git a/package/src/components/MessageInput/components/LinkPreviewList.tsx b/package/src/components/MessageInput/components/LinkPreviewList.tsx
index b5e335b60a..8a84b7d5e5 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 (
-
+
@@ -86,7 +90,7 @@ export const LinkPreviewCard = ({ linkPreview }: LinkPreviewProps) => {
) : null}
-
+
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 (
-
+
@@ -106,6 +107,7 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp
LeadingIcon={icons.NewClose}
onPress={handleClose}
size='md'
+ testID='unread-messages-notification-dismiss-button'
/>
diff --git a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.tsx.snap b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.tsx.snap
index ad7842d870..6a28875b07 100644
--- a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.tsx.snap
+++ b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.tsx.snap
@@ -218,6 +218,7 @@ exports[`ScrollToBottomButton should render the message notification and match s
"top": 0,
}
}
+ testID="scroll-to-bottom-button-unread-count"
/>
`;
diff --git a/package/src/components/MessageMenu/MessageActionListItem.tsx b/package/src/components/MessageMenu/MessageActionListItem.tsx
index 9c144f18f2..c425a9f273 100644
--- a/package/src/components/MessageMenu/MessageActionListItem.tsx
+++ b/package/src/components/MessageMenu/MessageActionListItem.tsx
@@ -79,6 +79,7 @@ export const MessageActionListItem = (props: MessageActionListItemProps) => {
styles.buttonContainer,
{ backgroundColor: pressed ? semantics.backgroundUtilityPressed : 'transparent' },
]}
+ testID={`message-action-${actionType}`}
>
{icon}
diff --git a/package/src/components/MessageMenu/ReactionButton.tsx b/package/src/components/MessageMenu/ReactionButton.tsx
index 628383d02a..757498b895 100644
--- a/package/src/components/MessageMenu/ReactionButton.tsx
+++ b/package/src/components/MessageMenu/ReactionButton.tsx
@@ -45,7 +45,7 @@ export const ReactionButton = (props: ReactionButtonProps) => {
);
return (
-
+