From 228075768ba9ba3dccaed803c9cf6a1f85d6c667 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Tue, 3 Nov 2026 20:25:04 +0000 Subject: [PATCH 01/18] ci(e2e): add test impact analysis --- .github/workflows/e2e-test-cron.yml | 164 ++++ .github/workflows/e2e-test.yml | 57 +- .gitignore | 2 + .prettierignore | 4 + eslint.config.mjs | 4 + examples/E2ETestApp/.gitignore | 4 + examples/E2ETestApp/App.tsx | 264 ++++- examples/E2ETestApp/android/app/build.gradle | 6 + .../android/app/src/main/AndroidManifest.xml | 4 + examples/E2ETestApp/babel.config.js | 6 +- examples/E2ETestApp/e2e/README.md | 107 +- .../E2ETestApp/e2e/coverage/babelPlugin.js | 85 ++ examples/E2ETestApp/e2e/coverage/sources.d.ts | 3 + examples/E2ETestApp/e2e/coverage/sources.js | 44 + examples/E2ETestApp/e2e/jest.config.js | 37 +- .../E2ETestApp/e2e/mock-server/MockServer.ts | 2 +- .../E2ETestApp/e2e/pages/ChannelListPage.ts | 59 +- .../E2ETestApp/e2e/pages/MessageListPage.ts | 224 ++++- .../E2ETestApp/e2e/robots/BackendRobot.ts | 5 + .../E2ETestApp/e2e/robots/ParticipantRobot.ts | 17 + examples/E2ETestApp/e2e/robots/UserRobot.ts | 598 +++++++++++- .../e2e/robots/UserRobotChannelListAsserts.ts | 134 ++- .../e2e/robots/UserRobotJwtAsserts.ts | 37 + .../e2e/robots/UserRobotMessageListAsserts.ts | 557 ++++++++++- examples/E2ETestApp/e2e/robots/index.ts | 1 + .../e2e/support/AndroidSystemDialogs.ts | 2 +- examples/E2ETestApp/e2e/support/Coverage.ts | 110 +++ examples/E2ETestApp/e2e/support/Device.ts | 31 + examples/E2ETestApp/e2e/support/Element.ts | 56 ++ .../E2ETestApp/e2e/support/MediaLibrary.ts | 42 + .../E2ETestApp/e2e/support/StreamTestCase.ts | 54 +- examples/E2ETestApp/e2e/support/step.ts | 28 +- .../e2e/tests/AttachmentsTests.e2e.ts | 98 ++ .../E2ETestApp/e2e/tests/AuthTests.e2e.ts | 128 +++ .../e2e/tests/ChannelActionsTests.e2e.ts | 91 ++ .../e2e/tests/ChannelListTests.e2e.ts | 343 ++++++- .../e2e/tests/DraftMessagesTests.e2e.ts | 85 ++ .../E2ETestApp/e2e/tests/GiphyTests.e2e.ts | 148 +++ .../e2e/tests/HyperLinksTests.e2e.ts | 88 ++ .../e2e/tests/MessageActionsTests.e2e.ts | 23 + .../tests/MessageDeliveryStatusTests.e2e.ts | 691 +++++++++++++ .../e2e/tests/MessageListTests.e2e.ts | 783 ++++++++++++++- .../e2e/tests/ModerationTests.e2e.ts | 168 ++++ .../e2e/tests/PinnedMessagesTests.e2e.ts | 103 ++ .../E2ETestApp/e2e/tests/PollsTests.e2e.ts | 107 ++ .../e2e/tests/QuotedReplyTests.e2e.ts | 911 ++++++++++++++++++ .../e2e/tests/ReactionsTests.e2e.ts | 227 +++++ .../E2ETestApp/e2e/tests/SlowModeTests.e2e.ts | 109 +++ .../e2e/tests/ThreadListTests.e2e.ts | 72 ++ .../e2e/tests/UnreadCountsTests.e2e.ts | 107 ++ .../e2e/tests/UnreadMessagesTests.e2e.ts | 150 +++ examples/E2ETestApp/index.js | 8 +- examples/E2ETestApp/ios/E2ETestApp/Info.plist | 4 + .../ios/E2ETestApp/PrivacyInfo.xcprivacy | 1 + examples/E2ETestApp/ios/Podfile.lock | 214 +++- examples/E2ETestApp/metro.config.js | 5 + examples/E2ETestApp/package.json | 8 + examples/E2ETestApp/src/JwtScreen.tsx | 105 ++ examples/E2ETestApp/src/config.ts | 12 +- examples/E2ETestApp/src/e2eCoverage.ts | 30 + examples/SampleApp/Gemfile.lock | 4 +- examples/SampleApp/fastlane/Fastfile | 182 +++- examples/SampleApp/fastlane/Pluginfile | 2 +- .../org.eclipse.buildship.core.prefs | 2 + .../ChannelMessagePreviewDeliveryStatus.tsx | 14 +- .../ChannelPreview/ChannelPreviewMessage.tsx | 7 +- .../ChannelPreviewMutedStatus.tsx | 8 +- .../ChannelPreview/ChannelPreviewStatus.tsx | 6 +- .../ChannelPreviewUnreadCount.tsx | 1 + .../Headers/SentToChannelHeader.tsx | 2 +- .../ReactionList/ReactionListClustered.tsx | 17 +- .../components/LinkPreviewList.tsx | 8 +- .../MessageList/InlineUnreadIndicator.tsx | 6 +- .../MessageList/ScrollToBottomButton.tsx | 1 + .../UnreadMessagesNotification.tsx | 4 +- .../ScrollToBottomButton.test.tsx.snap | 1 + .../MessageMenu/MessageActionListItem.tsx | 1 + .../components/MessageMenu/ReactionButton.tsx | 2 +- .../components/Poll/components/PollOption.tsx | 1 + package/src/components/Reply/Reply.tsx | 1 + .../UIComponents/SwipableWrapper.tsx | 6 +- yarn.lock | 179 +++- 82 files changed, 7889 insertions(+), 133 deletions(-) create mode 100644 .github/workflows/e2e-test-cron.yml create mode 100644 examples/E2ETestApp/e2e/coverage/babelPlugin.js create mode 100644 examples/E2ETestApp/e2e/coverage/sources.d.ts create mode 100644 examples/E2ETestApp/e2e/coverage/sources.js create mode 100644 examples/E2ETestApp/e2e/robots/UserRobotJwtAsserts.ts create mode 100644 examples/E2ETestApp/e2e/support/Coverage.ts create mode 100644 examples/E2ETestApp/e2e/support/Device.ts create mode 100644 examples/E2ETestApp/e2e/support/MediaLibrary.ts create mode 100644 examples/E2ETestApp/e2e/tests/AttachmentsTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/AuthTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/ChannelActionsTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/DraftMessagesTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/GiphyTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/HyperLinksTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/MessageActionsTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/MessageDeliveryStatusTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/ModerationTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/PinnedMessagesTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/PollsTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/QuotedReplyTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/ReactionsTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/SlowModeTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/ThreadListTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/UnreadCountsTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/UnreadMessagesTests.e2e.ts create mode 100644 examples/E2ETestApp/src/JwtScreen.tsx create mode 100644 examples/E2ETestApp/src/e2eCoverage.ts create mode 100644 package/native-package/android/.settings/org.eclipse.buildship.core.prefs diff --git a/.github/workflows/e2e-test-cron.yml b/.github/workflows/e2e-test-cron.yml new file mode 100644 index 0000000000..3a8d3ec67e --- /dev/null +++ b/.github/workflows/e2e-test-cron.yml @@ -0,0 +1,164 @@ +name: E2E Tests Nightly + +# Runs every e2e test on Android with an instrumented build and publishes the per-test coverage map +# (`e2e-coverage-map`) that pull requests select their tests from (impact analysis, e2e/README.md). +on: + schedule: + # Every night at 2:00 UTC, on the default branch (develop). + - cron: "0 2 * * *" + workflow_dispatch: + inputs: + 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: + android: + runs-on: ubuntu-latest + # One app launch per test, every test, plus retries. + 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" + # Caches Gradle's dependencies, wrapper and build cache (written on the default branch). + - 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..97e8c9d6ef 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,8 @@ concurrency: env: MOCK_SERVER_BRANCH: ${{ inputs.mock_server_branch || 'main' }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Allure TestOps (project 3). Empty for forks and Dependabot, which then skip the upload. + 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 }} @@ -53,11 +57,20 @@ jobs: required: ${{ steps.check.outputs.required }} 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 + # Also selects the tests the change can reach from the nightly coverage map (impact analysis). - 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 +203,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: @@ -295,6 +328,18 @@ jobs: 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 + 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 (Android emulator) uses: reactivecircus/android-emulator-runner@v2 with: @@ -304,7 +349,15 @@ 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" + script: bundle exec fastlane test_e2e platform:android device:att 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" # The emulator has vanished mid-run without any output; this shows whether the kernel killed it. - name: Diagnose emulator if: failure() diff --git a/.gitignore b/.gitignore index badc31e28f..d180b77fa7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ node_modules NEXT_RELEASE_CHANGELOG.md artifacts +allure-results +examples/E2ETestApp/allurectl e2e/.env *.log .DS_STORE diff --git a/.prettierignore b/.prettierignore index 25acaf5f04..00e29ac656 100644 --- a/.prettierignore +++ b/.prettierignore @@ -27,6 +27,10 @@ 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 # Generated SDK assets package/src/components/docs/ diff --git a/eslint.config.mjs b/eslint.config.mjs index c183ed0f82..9bfb3c7f88 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -47,6 +47,10 @@ 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', // Generated SDK assets 'package/src/components/docs/', diff --git a/examples/E2ETestApp/.gitignore b/examples/E2ETestApp/.gitignore index 9be6361de6..d3bed1a215 100644 --- a/examples/E2ETestApp/.gitignore +++ b/examples/E2ETestApp/.gitignore @@ -77,3 +77,7 @@ 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 diff --git a/examples/E2ETestApp/App.tsx b/examples/E2ETestApp/App.tsx index 4051222276..c84381e98a 100644 --- a/examples/E2ETestApp/App.tsx +++ b/examples/E2ETestApp/App.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { 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,17 +8,29 @@ import { Channel as ChannelType, SortParamRequest, StreamChat, + Thread as ThreadType, } from 'stream-chat'; import { + AlsoSentToChannelHeaderPressPayload, Channel, + ChannelActionItem, + ChannelDetails, + ChannelDetailsContextProvider, ChannelList, + GetChannelActionItems, + DEFAULT_HIGHLIGHT_DURATION, Chat, MessageComposer, MessageList, OverlayProvider, + setupCommandUIMiddlewares, + 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 }; @@ -42,45 +54,169 @@ 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], + ); // 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)} /> + + + + ); +}; + +/** A thread stacked over its channel, like the SampleApp's thread screen. */ +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} - - + + - + ); }; +/** The SDK's channel details (members, pinned messages, files…), like the SampleApp's screen. */ +const ChannelDetailsScreen = ({ + channel, + onBack, +}: { + channel: ChannelType; + onBack: () => void; +}) => { + useBackHandler(onBack); + return ( + + + + + + ); +}; + +type Tab = 'channels' | 'threads'; + +/** The bottom bar of the root screen, like the SampleApp's channels and threads tabs. */ +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 +238,73 @@ 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), []); + // "View Info" in the channel actions sheet, as the SampleApp adds it. + 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]; + }, + [], + ); + // A thread picked on the thread list opens over its channel. + 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 +316,15 @@ const App = () => { const chatClient = StreamChat.getInstance(apiKey, { client: { axiosRequestConfig: { timeout: 6000 }, baseURL: mockServerUrl }, }); + // As the v10 guide says: the composer only sends a command picked from the suggestions with + // these middlewares (RN-484). + chatClient.config.setSetupFunction('messageComposer', ({ composer }) => { + setupCommandUIMiddlewares(composer); + }); + // 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 +333,13 @@ const App = () => { - {client ? : } + {isJwtTest ? ( + + ) : client ? ( + + ) : ( + + )} @@ -148,6 +357,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,76 @@ 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 `allure_launch` / `allure_upload` / `allure_launch_removal` +lanes create the launch and upload the results on CI (they need the `ALLURE_TOKEN` repository secret). A new test gets a +new case: create it in project 3 (`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. + +## 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. + +`.github/workflows/e2e-test-cron.yml` runs nightly on `develop` (and on demand): every test on Android 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 +188,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..092d5f5bf8 --- /dev/null +++ b/examples/E2ETestApp/e2e/coverage/sources.js @@ -0,0 +1,44 @@ +/** + * 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$/, +]; + +/** Whether a repo-relative path is mapped code. */ +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..f72788aaaf 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,28 @@ export const ChannelListPage = { get messageSender() { return by.id('channel-preview-sender'); }, + /** The delivery status icon in front of the current user's last message. */ + 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..232e07606a 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 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'); + }, + /** A reaction of the menu's reaction picker. */ + reaction(type: string) { + return by.id(`reaction-button-${type}`); + }, + get showMoreReactions() { + return by.id('more-reactions-button'); + }, + /** An emoji in the extended reactions picker. */ + emoji(emoji: string) { + return by.text(emoji); + }, + }, + + /** The native confirmation dialogs (`Alert.alert`). */ + 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,106 @@ 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'); + }, + }, + /** The quote inside a quoted reply. */ + get quotedMessage() { + return inMessageList(by.id('quoted-message')); + }, + quotedMessageWithText(text: string) { + return by.text(text).withAncestor(by.id('quoted-message')); + }, + /** A link preview (OG card) under a message. */ + get linkPreview() { + return inMessageList(by.id('card-attachment')); + }, + /** An image or video of a message's gallery. */ + 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..9be5549758 100644 --- a/examples/E2ETestApp/e2e/robots/BackendRobot.ts +++ b/examples/E2ETestApp/e2e/robots/BackendRobot.ts @@ -85,6 +85,11 @@ export class BackendRobot extends Robot { ); } + /** Mutes the current channel for the user on the server side. */ + 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..f38a2d6413 100644 --- a/examples/E2ETestApp/e2e/robots/ParticipantRobot.ts +++ b/examples/E2ETestApp/e2e/robots/ParticipantRobot.ts @@ -48,6 +48,16 @@ export class ParticipantRobot extends Robot { ); } + /** Posts a system message, as a server-side action would. */ + sendSystemMessage(text: string): this { + return this.post('participant/message?system=true', text); + } + + /** Sends `text` to the channel named `channelName` instead of the current one. */ + 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 +77,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..91fcec937d 100644 --- a/examples/E2ETestApp/e2e/robots/UserRobot.ts +++ b/examples/E2ETestApp/e2e/robots/UserRobot.ts @@ -1,17 +1,36 @@ +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; + +/** The emoji each supported reaction type has in the extended reactions picker. */ +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 +58,446 @@ 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 })); + } + + /** Closes an open message menu without picking an action. */ + 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'); + } + + /** Quotes the `messageCellIndex`-th message from the newest and sends `text` with it. */ + quoteMessage(text: string, messageCellIndex = 0): this { + return this.selectReplyFromContextMenu(messageCellIndex).sendMessage(text); + } + + /** Quotes the message that reads `quotedMessageText` and sends `text` with it. */ + 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 }); } }); } + /** Creates a poll from the attachment picker. */ + 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') { + await device.pressBack(); + } 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)); + } + + /** Swipes a channel row right to left, revealing its swipe actions. */ + 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'); + } + + /** Confirms the leave or delete dialog. */ + 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 a hard delete, which the + * message menu doesn't offer yet (https://linear.app/stream/issue/RN-504), so it fails until then. + */ + 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 +505,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 +560,45 @@ export class UserRobot extends Robot { .waitForChannelListToLoad(); } + moveToChannelListFromThread(): this { + return this.tapOnBackButton() + .perform(() => waitToDisappear(Composer.alsoSendToChannelCheckbox)) + .moveToChannelListFromMessageList(); + } + + /** Taps the middle of the message list, away from any message action. */ + 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 }); + }); + } + + /** Typing `/` opens the command suggestions. */ + 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 +608,34 @@ 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); + } + + /** Scrolls down one swipe at a time until the message is on screen, up to `maxScrolls` swipes. */ + 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); + } + }); + } + + /** Scrolls up one swipe at a time until the message is on screen, up to `maxScrolls` swipes. */ + 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 +648,54 @@ 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); +}; + +/** The characters that open the composer's command (`/`) and mention (`@`) suggestions. */ +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..efbaa68a9a 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,23 @@ 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; + /** The sheet a group channel's "more" action opens. */ + 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 +77,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 +100,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..3fae0d5f9d 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,75 @@ 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; + /** Images of a message's gallery. */ + 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 +128,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 +158,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 +171,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 +198,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..5fe88a470f --- /dev/null +++ b/examples/E2ETestApp/e2e/support/Coverage.ts @@ -0,0 +1,110 @@ +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())); + } + + /** Forgets the previous test's report. */ + 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..c583809439 --- /dev/null +++ b/examples/E2ETestApp/e2e/support/Device.ts @@ -0,0 +1,31 @@ +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(); +}; + +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..41239dc795 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,23 @@ 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; 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..20b9ee5213 --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/GiphyTests.e2e.ts @@ -0,0 +1,148 @@ +import { describe, it } from '@jest/globals'; + +import { MessageDeliveryStatus } from '../mock-server/DataTypes'; +import { allureId, step } from '../support/step'; +import { setUpStreamTestCase } from '../support/StreamTestCase'; + +describe('GiphyTests', () => { + const { backendRobot, participantRobot, userRobot } = setUpStreamTestCase(); + + it('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), + ); + }); + + it('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()); + }); + + it('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()); + }); + + it('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(), + ); + }); + + it('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()); + }); + + it('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), + ); + }); + + it('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'), + ); + }); + + it('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'), + ); + }); + + it('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..13d788e2bf --- /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 } 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), + ); + }); + + // https://linear.app/stream/issue/RN-500 + it.skip('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 gonna freeze', async () => { + await backendRobot.freezeNewMessages(); + 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 + + // https://linear.app/stream/issue/RN-500 + it.skip('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 gonna freeze', async () => { + await backendRobot.freezeNewMessages(); + 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)); + }); + + // https://linear.app/stream/issue/RN-500 + it.skip('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 gonna freeze', async () => { + await backendRobot.freezeNewMessages(); + 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..d3ec967958 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), + ); + }); + + // https://linear.app/stream/issue/RN-500 + it.skip('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 pending indicator is shown for the message', () => + userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.PENDING, 1), + ); + 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), + ); + }); + + // https://linear.app/stream/issue/RN-504 + it.skip('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..283ff4cc91 --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/QuotedReplyTests.e2e.ts @@ -0,0 +1,911 @@ +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), + ); + }; + + it('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'), + ); + }); + + it('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 4141218c32..084478e9e2 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.30", "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/Fastfile b/examples/SampleApp/fastlane/Fastfile index d68415c502..47df039e1f 100644 --- a/examples/SampleApp/fastlane/Fastfile +++ b/examples/SampleApp/fastlane/Fastfile @@ -271,8 +271,18 @@ 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_url = 'https://streamio.testops.cloud' +allure_api_url = "#{allure_url}/api" +allure_project_id = '3' +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_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 +330,95 @@ 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` to $GITHUB_OUTPUT, so CI can skip the e2e jobs before building anything. ' \ + 'When required, also selects the tests to run (select_e2e_tests); none selected means not required' lane :e2e_check_required do required = is_check_required(sources: @impact[:e2e][:sources], force_check: @force_check, required_checks: @impact[:e2e][:checks]) + required &&= !select_e2e_tests['tests'].empty? File.write(ENV['GITHUB_OUTPUT'], "required=#{required}\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` ones are in (on iOS, Jest skips them). +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)(?:\.skip)?\(/).size + tests = source.scan(/^\s*(?:it|itOnAndroid)(\.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 (impact analysis)' +lane :build_e2e_coverage_map do + # Detox retries a failed test in a new run, so a test failed when its last Allure result did. Each + # file's `describe` is named after it: `MessageListTests` is `MessageListTests.e2e.ts`. + 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 +436,31 @@ 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 } + if options[:selection] + selection_path = File.expand_path(options[:selection], root_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") + env['E2E_SELECTION'] = selection_path + end + if coverage + # The app posts what it ran to the suite after each test (e2e/support/Coverage.ts). + 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,16 +468,79 @@ lane :test_e2e do |options| begin Dir.chdir(e2e_app_path) do - sh("yarn e2e:build -c #{detox_config}") unless options[:skip_build] + # A coverage build instruments the JS (babel.config.js). + 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 end end +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 + +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 + lane :file_size do |options| File.size(options[:path]) / 1024.0 end 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/native-package/android/.settings/org.eclipse.buildship.core.prefs b/package/native-package/android/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000000..16754904ec --- /dev/null +++ b/package/native-package/android/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,2 @@ +connection.project.dir=../../../android +eclipse.preferences.version=1 diff --git a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx index f6e07d425e..9ee2fc522b 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 && @@ -105,7 +117,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 e4777992ea..044091a770 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx @@ -72,7 +72,10 @@ export const ChannelPreviewMessage = (props: ChannelPreviewMessageProps) => { if (!lastMessage) { return ( - + {t('channelPreview.noMessages.text', 'No messages yet')} ); @@ -93,7 +96,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 34d97faf47..9b59931df5 100644 --- a/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx +++ b/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx @@ -50,7 +50,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 a5d79c0768..47ef3d8bf6 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 ( - +