From 5fd710f65150e359ed054c996d5169405b7ea78b Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Fri, 23 Oct 2026 14:55:23 +0100 Subject: [PATCH 01/21] test(e2e): add e2e_app, port native e2e suites, one app process per CI batch --- .claude/skills/flutter-version-bump/SKILL.md | 2 - .github/actions/allure-launch/action.yml | 6 +- .github/actions/setup-ios-runtime/action.yml | 4 +- .github/actions/setup-ruby/action.yml | 7 +- .github/workflows/e2e_test.yml | 46 +- .github/workflows/e2e_test_cron.yml | 40 +- .gitignore | 5 +- e2e_app/.gitignore | 45 + e2e_app/.metadata | 33 + e2e_app/Gemfile | 14 + e2e_app/Gemfile.lock | 409 ++++++ e2e_app/README.md | 17 + e2e_app/analysis_options.yaml | 17 + e2e_app/android/.gitignore | 14 + e2e_app/android/app/build.gradle.kts | 42 + .../android/app/src/debug/AndroidManifest.xml | 10 + .../android/app/src/main/AndroidManifest.xml | 51 + .../io/getstream/chat/e2e_app/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + e2e_app/android/build.gradle.kts | 24 + e2e_app/android/gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.properties | 5 + e2e_app/android/settings.gradle.kts | 26 + {sample_app => e2e_app}/fastlane/Allurefile | 0 e2e_app/fastlane/Fastfile | 503 +++++++ e2e_app/fastlane/Pluginfile | 5 + e2e_app/fastlane/README.md | 142 ++ {sample_app => e2e_app}/fastlane/e2e_log.rb | 0 {sample_app => e2e_app}/fastlane/e2e_tests.rb | 0 .../integration_test/allure/allure.dart | 0 .../integration_test/attachments_test.dart | 240 ++++ .../integration_test/authentication_test.dart | 205 +++ .../channel_actions_test.dart | 136 ++ .../integration_test/channel_list_test.dart | 74 ++ .../integration_test/channel_search_test.dart | 26 + e2e_app/integration_test/connection_test.dart | 91 ++ .../integration_test/draft_messages_test.dart | 110 ++ e2e_app/integration_test/giphy_test.dart | 184 +++ .../integration_test/hyper_links_test.dart | 210 +++ .../message_actions_test.dart | 25 + .../message_delivery_status_test.dart | 819 ++++++++++++ .../integration_test/message_list_test.dart | 1177 +++++++++++++++++ .../mock_server/data_types.dart | 2 +- .../mock_server/mock_server.dart | 0 e2e_app/integration_test/moderation_test.dart | 206 +++ .../pages/channel_list_page.dart | 48 +- .../pages/message_list_page.dart | 171 +++ .../pinned_messages_test.dart | 132 ++ e2e_app/integration_test/polls_test.dart | 112 ++ .../integration_test/quoted_reply_test.dart | 177 +++ .../integration_test/reactions_test.dart | 109 ++ e2e_app/integration_test/reminders_test.dart | 107 ++ .../robots/backend_robot.dart | 132 ++ .../robots/participant_robot.dart | 71 +- .../integration_test/robots/user_robot.dart | 907 +++++++++++++ .../user_robot_channel_list_asserts.dart | 123 ++ .../user_robot_message_list_asserts.dart | 933 +++++++++++++ e2e_app/integration_test/search_test.dart | 48 + e2e_app/integration_test/slow_mode_test.dart | 129 ++ .../support/failure_artifacts.dart | 0 .../support/fake_url_launcher.dart | 0 .../support/predefined_users.dart | 18 + .../integration_test/support/step.dart | 0 .../support/stream_test_case.dart | 18 +- .../support/stream_test_env.dart | 59 +- .../support/widget_test_extensions.dart | 12 +- .../integration_test/thread_list_test.dart | 71 + .../integration_test/unread_counts_test.dart | 126 ++ .../unread_messages_test.dart | 185 +++ e2e_app/ios/.gitignore | 34 + e2e_app/ios/Flutter/AppFrameworkInfo.plist | 24 + e2e_app/ios/Flutter/Debug.xcconfig | 1 + e2e_app/ios/Flutter/Release.xcconfig | 1 + e2e_app/ios/Runner.xcodeproj/project.pbxproj | 647 +++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 119 ++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + e2e_app/ios/Runner/AppDelegate.swift | 16 + .../AppIcon.appiconset/Contents.json | 122 ++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 + e2e_app/ios/Runner/Base.lproj/Main.storyboard | 26 + e2e_app/ios/Runner/Info.plist | 83 ++ e2e_app/ios/Runner/Runner-Bridging-Header.h | 1 + e2e_app/ios/Runner/SceneDelegate.swift | 6 + e2e_app/ios/RunnerTests/RunnerTests.swift | 12 + e2e_app/lib/app.dart | 62 + e2e_app/lib/auth/auth_controller.dart | 174 +++ e2e_app/lib/auth/users.dart | 30 + e2e_app/lib/main.dart | 5 + e2e_app/lib/navigation.dart | 55 + e2e_app/lib/pages/channel_actions_sheet.dart | 116 ++ e2e_app/lib/pages/channel_list_tab.dart | 171 +++ e2e_app/lib/pages/choose_user_page.dart | 53 + e2e_app/lib/pages/group_info_page.dart | 31 + e2e_app/lib/pages/home_page.dart | 81 ++ e2e_app/lib/pages/pinned_messages_page.dart | 37 + e2e_app/lib/pages/reminders_tab.dart | 68 + e2e_app/lib/pages/thread_list_tab.dart | 38 + e2e_app/lib/widgets/e2e_message_actions.dart | 39 + e2e_app/lib/widgets/search_text_field.dart | 25 + e2e_app/pubspec.yaml | 44 + .../scripts/install_ios_runtime.sh | 0 melos.yaml | 5 +- sample_app/Gemfile | 8 - sample_app/Gemfile.lock | 48 - sample_app/android/fastlane/Fastfile | 1 - .../firebase_core/lib/firebase_core.dart | 55 - .../e2e_stubs/firebase_core/pubspec.yaml | 7 - .../lib/firebase_crashlytics.dart | 32 - .../firebase_crashlytics/pubspec.yaml | 11 - .../lib/firebase_messaging.dart | 90 -- .../e2e_stubs/firebase_messaging/pubspec.yaml | 7 - sample_app/fastlane/Fastfile | 437 ------ .../integration_test/message_list_test.dart | 568 -------- .../robots/backend_robot.dart | 50 - .../integration_test/robots/user_robot.dart | 287 ---- .../user_robot_message_list_asserts.dart | 417 ------ .../support/predefined_users.dart | 12 - sample_app/ios/fastlane/Fastfile | 8 - sample_app/lib/app.dart | 5 +- sample_app/lib/auth/auth_controller.dart | 141 +- sample_app/pubspec.yaml | 3 - sample_app/pubspec_overrides.e2e.yaml | 23 - 156 files changed, 10915 insertions(+), 2270 deletions(-) create mode 100644 e2e_app/.gitignore create mode 100644 e2e_app/.metadata create mode 100644 e2e_app/Gemfile create mode 100644 e2e_app/Gemfile.lock create mode 100644 e2e_app/README.md create mode 100644 e2e_app/analysis_options.yaml create mode 100644 e2e_app/android/.gitignore create mode 100644 e2e_app/android/app/build.gradle.kts create mode 100644 e2e_app/android/app/src/debug/AndroidManifest.xml create mode 100644 e2e_app/android/app/src/main/AndroidManifest.xml create mode 100644 e2e_app/android/app/src/main/kotlin/io/getstream/chat/e2e_app/MainActivity.kt create mode 100644 e2e_app/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 e2e_app/android/app/src/main/res/drawable/launch_background.xml create mode 100644 e2e_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 e2e_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 e2e_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 e2e_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 e2e_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 e2e_app/android/app/src/main/res/values-night/styles.xml create mode 100644 e2e_app/android/app/src/main/res/values/styles.xml create mode 100644 e2e_app/android/app/src/profile/AndroidManifest.xml create mode 100644 e2e_app/android/build.gradle.kts create mode 100644 e2e_app/android/gradle.properties create mode 100644 e2e_app/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 e2e_app/android/settings.gradle.kts rename {sample_app => e2e_app}/fastlane/Allurefile (100%) create mode 100644 e2e_app/fastlane/Fastfile create mode 100644 e2e_app/fastlane/Pluginfile create mode 100644 e2e_app/fastlane/README.md rename {sample_app => e2e_app}/fastlane/e2e_log.rb (100%) rename {sample_app => e2e_app}/fastlane/e2e_tests.rb (100%) rename {sample_app => e2e_app}/integration_test/allure/allure.dart (100%) create mode 100644 e2e_app/integration_test/attachments_test.dart create mode 100644 e2e_app/integration_test/authentication_test.dart create mode 100644 e2e_app/integration_test/channel_actions_test.dart rename {sample_app => e2e_app}/integration_test/channel_list_test.dart (83%) create mode 100644 e2e_app/integration_test/channel_search_test.dart create mode 100644 e2e_app/integration_test/connection_test.dart create mode 100644 e2e_app/integration_test/draft_messages_test.dart create mode 100644 e2e_app/integration_test/giphy_test.dart create mode 100644 e2e_app/integration_test/hyper_links_test.dart create mode 100644 e2e_app/integration_test/message_actions_test.dart create mode 100644 e2e_app/integration_test/message_delivery_status_test.dart create mode 100644 e2e_app/integration_test/message_list_test.dart rename {sample_app => e2e_app}/integration_test/mock_server/data_types.dart (91%) rename {sample_app => e2e_app}/integration_test/mock_server/mock_server.dart (100%) create mode 100644 e2e_app/integration_test/moderation_test.dart rename {sample_app => e2e_app}/integration_test/pages/channel_list_page.dart (50%) rename {sample_app => e2e_app}/integration_test/pages/message_list_page.dart (56%) create mode 100644 e2e_app/integration_test/pinned_messages_test.dart create mode 100644 e2e_app/integration_test/polls_test.dart rename {sample_app => e2e_app}/integration_test/quoted_reply_test.dart (81%) rename {sample_app => e2e_app}/integration_test/reactions_test.dart (62%) create mode 100644 e2e_app/integration_test/reminders_test.dart create mode 100644 e2e_app/integration_test/robots/backend_robot.dart rename {sample_app => e2e_app}/integration_test/robots/participant_robot.dart (70%) create mode 100644 e2e_app/integration_test/robots/user_robot.dart rename {sample_app => e2e_app}/integration_test/robots/user_robot_channel_list_asserts.dart (52%) create mode 100644 e2e_app/integration_test/robots/user_robot_message_list_asserts.dart create mode 100644 e2e_app/integration_test/search_test.dart create mode 100644 e2e_app/integration_test/slow_mode_test.dart rename {sample_app => e2e_app}/integration_test/support/failure_artifacts.dart (100%) rename {sample_app => e2e_app}/integration_test/support/fake_url_launcher.dart (100%) create mode 100644 e2e_app/integration_test/support/predefined_users.dart rename {sample_app => e2e_app}/integration_test/support/step.dart (100%) rename {sample_app => e2e_app}/integration_test/support/stream_test_case.dart (84%) rename {sample_app => e2e_app}/integration_test/support/stream_test_env.dart (77%) rename {sample_app => e2e_app}/integration_test/support/widget_test_extensions.dart (95%) create mode 100644 e2e_app/integration_test/thread_list_test.dart create mode 100644 e2e_app/integration_test/unread_counts_test.dart create mode 100644 e2e_app/integration_test/unread_messages_test.dart create mode 100644 e2e_app/ios/.gitignore create mode 100644 e2e_app/ios/Flutter/AppFrameworkInfo.plist create mode 100644 e2e_app/ios/Flutter/Debug.xcconfig create mode 100644 e2e_app/ios/Flutter/Release.xcconfig create mode 100644 e2e_app/ios/Runner.xcodeproj/project.pbxproj create mode 100644 e2e_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 e2e_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 e2e_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 e2e_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 e2e_app/ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 e2e_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 e2e_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 e2e_app/ios/Runner/AppDelegate.swift create mode 100644 e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 e2e_app/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 e2e_app/ios/Runner/Base.lproj/Main.storyboard create mode 100644 e2e_app/ios/Runner/Info.plist create mode 100644 e2e_app/ios/Runner/Runner-Bridging-Header.h create mode 100644 e2e_app/ios/Runner/SceneDelegate.swift create mode 100644 e2e_app/ios/RunnerTests/RunnerTests.swift create mode 100644 e2e_app/lib/app.dart create mode 100644 e2e_app/lib/auth/auth_controller.dart create mode 100644 e2e_app/lib/auth/users.dart create mode 100644 e2e_app/lib/main.dart create mode 100644 e2e_app/lib/navigation.dart create mode 100644 e2e_app/lib/pages/channel_actions_sheet.dart create mode 100644 e2e_app/lib/pages/channel_list_tab.dart create mode 100644 e2e_app/lib/pages/choose_user_page.dart create mode 100644 e2e_app/lib/pages/group_info_page.dart create mode 100644 e2e_app/lib/pages/home_page.dart create mode 100644 e2e_app/lib/pages/pinned_messages_page.dart create mode 100644 e2e_app/lib/pages/reminders_tab.dart create mode 100644 e2e_app/lib/pages/thread_list_tab.dart create mode 100644 e2e_app/lib/widgets/e2e_message_actions.dart create mode 100644 e2e_app/lib/widgets/search_text_field.dart create mode 100644 e2e_app/pubspec.yaml rename {sample_app => e2e_app}/scripts/install_ios_runtime.sh (100%) delete mode 100644 sample_app/e2e_stubs/firebase_core/lib/firebase_core.dart delete mode 100644 sample_app/e2e_stubs/firebase_core/pubspec.yaml delete mode 100644 sample_app/e2e_stubs/firebase_crashlytics/lib/firebase_crashlytics.dart delete mode 100644 sample_app/e2e_stubs/firebase_crashlytics/pubspec.yaml delete mode 100644 sample_app/e2e_stubs/firebase_messaging/lib/firebase_messaging.dart delete mode 100644 sample_app/e2e_stubs/firebase_messaging/pubspec.yaml delete mode 100644 sample_app/integration_test/message_list_test.dart delete mode 100644 sample_app/integration_test/robots/backend_robot.dart delete mode 100644 sample_app/integration_test/robots/user_robot.dart delete mode 100644 sample_app/integration_test/robots/user_robot_message_list_asserts.dart delete mode 100644 sample_app/integration_test/support/predefined_users.dart delete mode 100644 sample_app/pubspec_overrides.e2e.yaml diff --git a/.claude/skills/flutter-version-bump/SKILL.md b/.claude/skills/flutter-version-bump/SKILL.md index 7de93c6512..8ec94fdfde 100644 --- a/.claude/skills/flutter-version-bump/SKILL.md +++ b/.claude/skills/flutter-version-bump/SKILL.md @@ -479,8 +479,6 @@ Version-carrying files, all of which must move together: - `.github/workflows/legacy_version_analyze.yml` — `env.flutter_version`. Set it to the **new floor** (never the new stable): this job exists to prove the floor still analyses and tests clean. -`sample_app/e2e_stubs/*/pubspec.yaml` pin `sdk: ^3.0.0` deliberately — they are throwaway stubs. Leave them. - ### The floor raise activates dormant lints — budget for it This is the step that surprises people. Raising the Dart constraint raises each package's **language version**, diff --git a/.github/actions/allure-launch/action.yml b/.github/actions/allure-launch/action.yml index 268003ff7c..9027e7a258 100644 --- a/.github/actions/allure-launch/action.yml +++ b/.github/actions/allure-launch/action.yml @@ -2,9 +2,9 @@ name: 'Allure Launch' description: 'Create an Allure TestOps launch and expose its id' inputs: working-directory: - description: 'Platform fastlane directory to run the lane from' + description: 'Directory to run the fastlane lane from' required: false - default: sample_app/android + default: e2e_app outputs: launch_id: description: 'The created launch id; empty when the e2e check was not required' @@ -13,6 +13,8 @@ runs: using: "composite" steps: - uses: ./.github/actions/setup-ruby + with: + working-directory: ${{ inputs.working-directory }} - name: Launch Allure TestOps shell: bash diff --git a/.github/actions/setup-ios-runtime/action.yml b/.github/actions/setup-ios-runtime/action.yml index 1f831dc6bc..ee6a47f184 100644 --- a/.github/actions/setup-ios-runtime/action.yml +++ b/.github/actions/setup-ios-runtime/action.yml @@ -29,13 +29,13 @@ runs: - name: Install iOS runtime shell: bash - working-directory: sample_app/ios + working-directory: e2e_app run: bundle exec fastlane install_runtime ios:${{ inputs.version }} - name: Cleanup iOS runtime install artifacts shell: bash run: | - sudo rm -rfv sample_app/*.dmg || true + sudo rm -rfv e2e_app/*.dmg || true xcrun simctl list runtimes - name: Create custom iOS simulator diff --git a/.github/actions/setup-ruby/action.yml b/.github/actions/setup-ruby/action.yml index 6bbccdabef..39f166e3f4 100644 --- a/.github/actions/setup-ruby/action.yml +++ b/.github/actions/setup-ruby/action.yml @@ -1,5 +1,10 @@ name: 'Setup Ruby' description: 'Install Ruby and Cache Gems' +inputs: + working-directory: + description: 'The directory holding the Gemfile to install' + required: false + default: sample_app runs: using: "composite" steps: @@ -7,4 +12,4 @@ runs: with: ruby-version: "3.3" bundler-cache: true - working-directory: sample_app + working-directory: ${{ inputs.working-directory }} diff --git a/.github/workflows/e2e_test.yml b/.github/workflows/e2e_test.yml index 1610221aa0..da98fa2b45 100644 --- a/.github/workflows/e2e_test.yml +++ b/.github/workflows/e2e_test.yml @@ -41,16 +41,18 @@ jobs: fetch-depth: 2 # the pull request merge commit and its base - uses: ./.github/actions/setup-ruby + with: + working-directory: e2e_app - name: Select e2e tests id: select - working-directory: sample_app/android + working-directory: e2e_app run: bundle exec fastlane select_e2e_tests - uses: actions/upload-artifact@v7 with: name: e2e-selection - path: sample_app/build/e2e_selection.json + path: e2e_app/build/e2e_selection.json allure_launch_android: needs: select @@ -65,7 +67,7 @@ jobs: - uses: ./.github/actions/allure-launch id: launch with: - working-directory: sample_app/android + working-directory: e2e_app allure_launch_ios: needs: select @@ -80,7 +82,7 @@ jobs: - uses: ./.github/actions/allure-launch id: launch with: - working-directory: sample_app/ios + working-directory: e2e_app android: # Always on pull_request; opt-out via the checkbox on workflow_dispatch. @@ -103,14 +105,13 @@ jobs: - uses: ./.github/actions/cache-gradle - uses: ./.github/actions/setup-ruby + with: + working-directory: e2e_app - uses: ./.github/actions/setup-flutter - uses: ./.github/actions/enable-kvm - - name: Use Firebase stubs - run: cp sample_app/pubspec_overrides.e2e.yaml sample_app/pubspec_overrides.yaml - - name: Bootstrap run: | flutter pub global activate melos @@ -119,7 +120,7 @@ jobs: - uses: actions/download-artifact@v8 with: name: e2e-selection - path: sample_app/build + path: e2e_app/build - name: Run e2e (Android emulator) uses: reactivecircus/android-emulator-runner@v2 @@ -127,23 +128,23 @@ jobs: api-level: ${{ env.ANDROID_API_LEVEL }} arch: x86_64 profile: pixel_6 - script: cd sample_app/android && bundle exec fastlane run_e2e_test device:emulator-5554 selection:build/e2e_selection.json batch:${{ matrix.batch }} batch_count:${{ strategy.job-total }} + script: cd e2e_app && bundle exec fastlane run_e2e_test device:emulator-5554 selection:build/e2e_selection.json batch:${{ matrix.batch }} batch_count:${{ strategy.job-total }} - name: Attach videos to failed tests if: failure() - working-directory: sample_app/android + working-directory: e2e_app run: | sudo apt-get update && sudo apt-get install -y ffmpeg bundle exec fastlane attach_videos - name: Upload Allure results if: env.LAUNCH_ID != '' && (success() || failure()) - working-directory: sample_app/android + working-directory: e2e_app run: bundle exec fastlane allure_upload launch_id:$LAUNCH_ID - name: Allure TestOps Launch Removal if: env.LAUNCH_ID != '' && cancelled() - working-directory: sample_app/android + working-directory: e2e_app run: bundle exec fastlane allure_launch_removal launch_id:$LAUNCH_ID - uses: actions/upload-artifact@v7 @@ -151,8 +152,8 @@ jobs: with: name: e2e-android-logs-${{ matrix.batch }} path: | - sample_app/stream-chat-test-mock-server/logs - sample_app/build/e2e-test.log + e2e_app/stream-chat-test-mock-server/logs + e2e_app/build/e2e-test.log ios: # Only on workflow_dispatch, when the checkbox is ticked. @@ -179,9 +180,8 @@ jobs: - uses: ./.github/actions/setup-flutter - uses: ./.github/actions/setup-ruby - - - name: Use Firebase stubs - run: cp sample_app/pubspec_overrides.e2e.yaml sample_app/pubspec_overrides.yaml + with: + working-directory: e2e_app - name: Bootstrap run: | @@ -192,20 +192,20 @@ jobs: - uses: actions/download-artifact@v8 with: name: e2e-selection - path: sample_app/build + path: e2e_app/build - name: Run e2e (iOS simulator) - working-directory: sample_app/ios + working-directory: e2e_app run: bundle exec fastlane run_e2e_test platform:ios ios:"${{ env.IOS_SIMULATOR_VERSION }}" device:"${{ env.IOS_SIMULATOR_DEVICE }}" selection:build/e2e_selection.json batch:${{ matrix.batch }} batch_count:${{ strategy.job-total }} - name: Upload Allure results if: env.LAUNCH_ID != '' && (success() || failure()) - working-directory: sample_app/ios + working-directory: e2e_app run: bundle exec fastlane allure_upload launch_id:$LAUNCH_ID - name: Allure TestOps Launch Removal if: env.LAUNCH_ID != '' && cancelled() - working-directory: sample_app/ios + working-directory: e2e_app run: bundle exec fastlane allure_launch_removal launch_id:$LAUNCH_ID - uses: actions/upload-artifact@v7 @@ -213,8 +213,8 @@ jobs: with: name: e2e-ios-logs-${{ matrix.batch }} path: | - sample_app/stream-chat-test-mock-server/logs - sample_app/build/e2e-test.log + e2e_app/stream-chat-test-mock-server/logs + e2e_app/build/e2e-test.log # The single required check for E2E: the batch count follows the selection, so # the per-batch job names come and go. diff --git a/.github/workflows/e2e_test_cron.yml b/.github/workflows/e2e_test_cron.yml index e18a85eaa2..90a6c84132 100644 --- a/.github/workflows/e2e_test_cron.yml +++ b/.github/workflows/e2e_test_cron.yml @@ -48,21 +48,20 @@ jobs: - uses: ./.github/actions/cache-gradle - uses: ./.github/actions/setup-ruby + with: + working-directory: e2e_app - uses: ./.github/actions/setup-flutter - uses: ./.github/actions/enable-kvm - - name: Use Firebase stubs - run: cp sample_app/pubspec_overrides.e2e.yaml sample_app/pubspec_overrides.yaml - - name: Bootstrap run: | flutter pub global activate melos melos bootstrap - name: Launch Allure TestOps - working-directory: sample_app/android + working-directory: e2e_app run: bundle exec fastlane allure_launch cron:true - name: Run e2e (Android emulator) @@ -72,35 +71,35 @@ jobs: arch: x86_64 profile: pixel_6 emulator-options: -no-snapshot-save -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect - script: cd sample_app/android && bundle exec fastlane run_e2e_test device:emulator-5554 coverage:${{ matrix.coverage == true }} mock_server_branch:${{ env.mock_server_branch }} + script: cd e2e_app && bundle exec fastlane run_e2e_test device:emulator-5554 coverage:${{ matrix.coverage == true }} mock_server_branch:${{ env.mock_server_branch }} - name: Build the coverage map if: matrix.coverage && !cancelled() - working-directory: sample_app/android + working-directory: e2e_app run: bundle exec fastlane build_e2e_coverage_map - uses: actions/upload-artifact@v7 if: matrix.coverage && !cancelled() with: name: e2e-coverage-map - path: sample_app/build/e2e_coverage_map.json + path: e2e_app/build/e2e_coverage_map.json retention-days: 14 - name: Attach videos to failed tests if: failure() - working-directory: sample_app/android + working-directory: e2e_app run: | sudo apt-get update && sudo apt-get install -y ffmpeg bundle exec fastlane attach_videos - name: Upload Allure results if: env.LAUNCH_ID != '' && (success() || failure()) - working-directory: sample_app/android + working-directory: e2e_app run: bundle exec fastlane allure_upload launch_id:$LAUNCH_ID - name: Allure TestOps Launch Removal if: env.LAUNCH_ID != '' && cancelled() - working-directory: sample_app/android + working-directory: e2e_app run: bundle exec fastlane allure_launch_removal launch_id:$LAUNCH_ID - uses: actions/upload-artifact@v7 @@ -108,8 +107,8 @@ jobs: with: name: e2e-nightly-android-${{ matrix.api-level }} path: | - sample_app/stream-chat-test-mock-server/logs - sample_app/build/e2e-test.log + e2e_app/stream-chat-test-mock-server/logs + e2e_app/build/e2e-test.log ios: runs-on: macos-15 @@ -133,6 +132,8 @@ jobs: xcode-version: '26.2' - uses: ./.github/actions/setup-ruby + with: + working-directory: e2e_app - uses: ./.github/actions/setup-flutter @@ -142,9 +143,6 @@ jobs: version: ${{ matrix.ios }} device: ${{ matrix.device }} - - name: Use Firebase stubs - run: cp sample_app/pubspec_overrides.e2e.yaml sample_app/pubspec_overrides.yaml - - name: Bootstrap run: | flutter precache --ios @@ -152,21 +150,21 @@ jobs: melos bootstrap - name: Launch Allure TestOps - working-directory: sample_app/ios + working-directory: e2e_app run: bundle exec fastlane allure_launch cron:true - name: Run e2e (iOS simulator) - working-directory: sample_app/ios + working-directory: e2e_app run: bundle exec fastlane run_e2e_test platform:ios ios:${{ matrix.ios }} device:"${{ matrix.device }} (${{ matrix.ios }})" mock_server_branch:${{ env.mock_server_branch }} - name: Upload Allure results if: env.LAUNCH_ID != '' && (success() || failure()) - working-directory: sample_app/ios + working-directory: e2e_app run: bundle exec fastlane allure_upload launch_id:$LAUNCH_ID - name: Allure TestOps Launch Removal if: env.LAUNCH_ID != '' && cancelled() - working-directory: sample_app/ios + working-directory: e2e_app run: bundle exec fastlane allure_launch_removal launch_id:$LAUNCH_ID - uses: actions/upload-artifact@v7 @@ -174,8 +172,8 @@ jobs: with: name: e2e-nightly-ios-${{ matrix.ios }} path: | - sample_app/stream-chat-test-mock-server/logs - sample_app/build/e2e-test.log + e2e_app/stream-chat-test-mock-server/logs + e2e_app/build/e2e-test.log slack: name: Slack Report diff --git a/.gitignore b/.gitignore index 5441d82386..3efecdbb29 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,9 @@ *.class *.log *.pyc -/sample_app/stream-chat-test-mock-server/ -/sample_app/allure-results/ +/e2e_app/stream-chat-test-mock-server/ +/e2e_app/integration_test/e2e_batch.g.dart +/e2e_app/allure-results/ /sample_app/allurectl *.swp .DS_Store diff --git a/e2e_app/.gitignore b/e2e_app/.gitignore new file mode 100644 index 0000000000..3820a95c65 --- /dev/null +++ b/e2e_app/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/e2e_app/.metadata b/e2e_app/.metadata new file mode 100644 index 0000000000..5d8703b672 --- /dev/null +++ b/e2e_app/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: android + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: ios + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/e2e_app/Gemfile b/e2e_app/Gemfile new file mode 100644 index 0000000000..6b76b506f2 --- /dev/null +++ b/e2e_app/Gemfile @@ -0,0 +1,14 @@ +source "https://rubygems.org" + +gem "fastlane" +gem "multi_json" +gem "sinatra", group: :sinatra_dependencies + +group :sinatra_dependencies do + gem "eventmachine" + gem "faye-websocket" + gem "puma" + gem "rackup" +end + +eval_gemfile("fastlane/Pluginfile") diff --git a/e2e_app/Gemfile.lock b/e2e_app/Gemfile.lock new file mode 100644 index 0000000000..e281864d1b --- /dev/null +++ b/e2e_app/Gemfile.lock @@ -0,0 +1,409 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1267.0) + aws-sdk-core (3.254.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.130.0) + aws-sdk-core (~> 3, >= 3.254.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.227.0) + aws-sdk-core (~> 3, >= 3.254.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + csv (3.3.5) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + eventmachine (1.2.7) + excon (1.5.0) + logger + faraday (1.10.6) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.4) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.1) + fastlane (2.237.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) + addressable (>= 2.9.0, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.197) + babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 2.4.0, < 5.0.0) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 2.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.1.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.3.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.10.3, < 4) + logger (>= 1.6, < 2.0) + mini_magick (>= 4.9.4, < 5.0.0) + multi_json (~> 1.12) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3) + naturally (~> 2.2) + nkf (~> 0.2) + optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-plugin-stream_actions (0.4.16) + xctest_list (= 1.2.1) + fastlane-sirp (1.1.0) + faye-websocket (0.12.0) + eventmachine (>= 0.12.0) + websocket-driver (>= 0.8.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.104.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) + mini_mime (~> 1.0) + mutex_m + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + google-apis-iamcredentials_v1 (0.28.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.18.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.64.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.9.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.2.2) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.7.0) + google-cloud-storage (1.62.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) + google-cloud-core (~> 1.6) + googleauth (~> 1.9) + mini_mime (~> 1.0) + google-logging-utils (0.2.0) + googleauth (1.17.1) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) + os (>= 0.9, < 2.0) + pstore (~> 0.1) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + jmespath (1.6.2) + json (2.21.2) + jwt (3.2.0) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.21.1) + multipart-post (2.4.1) + mustermann (3.1.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + nio4r (2.7.5) + nkf (0.3.0) + optparse (0.8.1) + os (1.1.4) + ostruct (0.6.3) + plist (3.7.2) + pstore (0.2.1) + public_suffix (4.0.7) + puma (8.0.2) + nio4r (~> 2.0) + rack (3.2.6) + rack-protection (4.2.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rackup (2.3.1) + rack (>= 3) + rake (13.4.2) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.8.0) + rexml (3.4.4) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.22.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + simctl (1.6.10) + CFPropertyList + naturally + sinatra (4.2.1) + logger (>= 1.6.0) + mustermann (~> 3.0) + rack (>= 3.0.0, < 4) + rack-protection (= 4.2.1) + rack-session (>= 2.0.0, < 3) + tilt (~> 2.0) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + tilt (2.8.0) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.6.0) + websocket-driver (0.8.2) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + word_wrap (1.0.0) + xcodeproj (1.28.1) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + base64 + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + nkf + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + xctest_list (1.2.1) + +PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + ruby + x86-linux-gnu + x86-linux-musl + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + eventmachine + fastlane + fastlane-plugin-stream_actions (= 0.4.16) + faye-websocket + multi_json + puma + rackup + sinatra + +CHECKSUMS + CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261 + abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263 + atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f + aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b + aws-partitions (1.1267.0) sha256=15a4c6bef8c303b09a1452c675cecb1ace0238dc91e26fe4646c5d761cb2221e + aws-sdk-core (3.254.0) sha256=ee3e3220b8468a3c9e59daba18e6ec897bf5c7ce8adcc0670cfa2f1f092112fe + aws-sdk-kms (1.130.0) sha256=a2e83662ca31b77a2a19c9aa2f40a98165a67270c18c718fe1c70d0cbd7cd749 + aws-sdk-s3 (1.227.0) sha256=552b23bf9a37c7db4957e9291543e61c8de886cf31d1d45ea3b429df0feea939 + aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 + babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bundler (4.0.12) sha256=7f8b757d28dfb636e7b24fba2344ac6dd13b5b24f4b46d62573d483f211825ac + claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e + colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c + colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a + commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9 + csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f + declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9 + digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07 + domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 + dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8 + emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b + eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972 + excon (1.5.0) sha256=c503ad1d0123bc8ab2a062ff3789dc891ec368cb9e13765ab88a9c58c8bb6d50 + faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d + faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb + faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689 + faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750 + faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940 + faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b + faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 + faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682 + faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335 + faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7 + faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0 + faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa + faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9 + fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20 + fastlane (2.237.0) sha256=bb1e867bc070fb328741b5e6e7606d5ba596941a9ecca0478f60ef22d1009db9 + fastlane-plugin-stream_actions (0.4.16) sha256=9393acac7d979a8930bd34f5789106171011487d366fdc2c6e4e30d26ce8caad + fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847 + faye-websocket (0.12.0) sha256=ad9f7dfcd0306d0a13baeee450729657661129af15bb5f38716c242484ab42e1 + gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 + google-apis-androidpublisher_v3 (0.104.0) sha256=3bf7f0dee23ae71e070e279848374834853204e304831cf6aa76fa435a4d6eff + google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee + google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106 + google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254 + google-apis-storage_v1 (0.64.0) sha256=75b11afa2edcee859b84c7a6972ee4456314eeef5f762827fd6cf5c5ffaf93f2 + google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4 + google-cloud-env (2.2.2) sha256=94bed40e05a67e9468ce1cb38389fba9a90aa8fc62fc9e173204c1dca59e21e7 + google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3 + google-cloud-storage (1.62.0) sha256=e2c3c08bf8fd40d50be92304084942203314d4fc0ee52028e99f9359c3ad1330 + google-logging-utils (0.2.0) sha256=675462b4ea5affa825a3442694ca2d75d0069455a1d0956127207498fca3df7b + googleauth (1.17.1) sha256=0f7e6fc70e204cee1b2d71f1e1de2d3b349d432404197fe68ebf7fa23d0821b9 + highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479 + http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 + httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 + jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080 + multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 + mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198 + mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 + nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723 + naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895 + optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a + os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42 + pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a + public_suffix (4.0.7) sha256=8be161e2421f8d45b0098c042c06486789731ea93dc3a896d30554ee38b573b8 + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 + rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace + retriable (3.8.0) sha256=9f2f1b0207594c7817f17f671587b8ec7587387ac6cebda6c941a802bb98a8e5 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20 + ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef + rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 + security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7 + signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780 + simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b + sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080 + terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea + terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91 + tilt (2.8.0) sha256=ba472eb2716fe1e04112d6d219a9dae938ec09a6a1e2ad3ecc7922e79bde3721 + trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3 + tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48 + tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50 + tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542 + uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc + unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a + websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7 + xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc + xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892 + xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93 + xctest_list (1.2.1) sha256=9adbcb1d03f05f486e395774604fae231db0d2ae8efa9d237b3c922b09d569e3 + +BUNDLED WITH + 4.0.12 diff --git a/e2e_app/README.md b/e2e_app/README.md new file mode 100644 index 0000000000..e768618493 --- /dev/null +++ b/e2e_app/README.md @@ -0,0 +1,17 @@ +# e2e_app + +Stream Chat Flutter app the e2e suite runs against. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/e2e_app/analysis_options.yaml b/e2e_app/analysis_options.yaml new file mode 100644 index 0000000000..10140eeeae --- /dev/null +++ b/e2e_app/analysis_options.yaml @@ -0,0 +1,17 @@ +include: ../analysis_options.yaml + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** + +linter: + rules: + cascade_invocations: false + public_member_api_docs: false + lines_longer_than_80_chars: false \ No newline at end of file diff --git a/e2e_app/android/.gitignore b/e2e_app/android/.gitignore new file mode 100644 index 0000000000..be3943c96d --- /dev/null +++ b/e2e_app/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/e2e_app/android/app/build.gradle.kts b/e2e_app/android/app/build.gradle.kts new file mode 100644 index 0000000000..96997280d3 --- /dev/null +++ b/e2e_app/android/app/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "io.getstream.chat.e2e_app" + // The SDK's plugins need a newer compile SDK than Flutter's default. + compileSdk = maxOf(flutter.compileSdkVersion, 36) + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + applicationId = "io.getstream.chat.e2e_app" + minSdk = maxOf(flutter.minSdkVersion, 24) + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // Never published, so the debug keys do. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/e2e_app/android/app/src/debug/AndroidManifest.xml b/e2e_app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..f5a7e4a68c --- /dev/null +++ b/e2e_app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/e2e_app/android/app/src/main/AndroidManifest.xml b/e2e_app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..cd622952e3 --- /dev/null +++ b/e2e_app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/e2e_app/android/app/src/main/kotlin/io/getstream/chat/e2e_app/MainActivity.kt b/e2e_app/android/app/src/main/kotlin/io/getstream/chat/e2e_app/MainActivity.kt new file mode 100644 index 0000000000..c005173d2a --- /dev/null +++ b/e2e_app/android/app/src/main/kotlin/io/getstream/chat/e2e_app/MainActivity.kt @@ -0,0 +1,5 @@ +package io.getstream.chat.e2e_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/e2e_app/android/app/src/main/res/drawable-v21/launch_background.xml b/e2e_app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000000..f74085f3f6 --- /dev/null +++ b/e2e_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/e2e_app/android/app/src/main/res/drawable/launch_background.xml b/e2e_app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000000..304732f884 --- /dev/null +++ b/e2e_app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/e2e_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/e2e_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/e2e_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/e2e_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/e2e_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/e2e_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/e2e_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/e2e_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/e2e_app/android/app/src/main/res/values-night/styles.xml b/e2e_app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000000..06952be745 --- /dev/null +++ b/e2e_app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/e2e_app/android/app/src/main/res/values/styles.xml b/e2e_app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..cb1ef88056 --- /dev/null +++ b/e2e_app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/e2e_app/android/app/src/profile/AndroidManifest.xml b/e2e_app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/e2e_app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/e2e_app/android/build.gradle.kts b/e2e_app/android/build.gradle.kts new file mode 100644 index 0000000000..dbee657bb5 --- /dev/null +++ b/e2e_app/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/e2e_app/android/gradle.properties b/e2e_app/android/gradle.properties new file mode 100644 index 0000000000..e96108cfe4 --- /dev/null +++ b/e2e_app/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/e2e_app/android/gradle/wrapper/gradle-wrapper.properties b/e2e_app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..2d428bfb16 --- /dev/null +++ b/e2e_app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/e2e_app/android/settings.gradle.kts b/e2e_app/android/settings.gradle.kts new file mode 100644 index 0000000000..c21f0c5b49 --- /dev/null +++ b/e2e_app/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/sample_app/fastlane/Allurefile b/e2e_app/fastlane/Allurefile similarity index 100% rename from sample_app/fastlane/Allurefile rename to e2e_app/fastlane/Allurefile diff --git a/e2e_app/fastlane/Fastfile b/e2e_app/fastlane/Fastfile new file mode 100644 index 0000000000..f8538f53b3 --- /dev/null +++ b/e2e_app/fastlane/Fastfile @@ -0,0 +1,503 @@ +opt_out_usage + +require 'net/http' +require 'shellwords' +require 'json' +require 'securerandom' +require 'fileutils' +require File.expand_path('e2e_log', __dir__) +require File.expand_path('e2e_tests', __dir__) +import File.expand_path('Allurefile', __dir__) + +mock_server_repo_name = 'stream-chat-test-mock-server' +mock_server_driver_port = ENV['MOCK_DRIVER_PORT'] || '4568' +github_repo = ENV['GITHUB_REPOSITORY'] || 'GetStream/stream-chat-flutter' +e2e_coverage_map_artifact = 'e2e-coverage-map' +@force_check = false +@impact = nil + +before_all do + @impact = impact_analysis + if is_ci + setup_ci + setup_git_config + end +end + +def root_path + Dir.pwd.sub(/.*\Kfastlane/, '').sub(/.*\Kandroid/, '').sub(/.*\Kios/, '').sub(/.*\K\/\//, '') +end + +def repo_root + File.expand_path('..', root_path) +end + +def e2e_selection_path + "#{root_path}/build/e2e_selection.json" +end + +def e2e_coverage_path + "#{root_path}/build/e2e-coverage" +end + +def ios_device?(device) + device.to_s.match?(/\A[0-9A-F]{8}-[0-9A-F]{4}-/i) +end + +# `runs` are `[test file, test names or nil for the whole file]` pairs. +def batch_targets(runs, batch:, batch_count:) + return runs if batch.nil? || batch_count.to_i <= 1 + return [] if runs.empty? + + count = batch_count.to_i + index = batch.to_i + tests_of = E2ETests.list('integration_test').group_by { |id| E2ETests.file(id, 'integration_test') } + .transform_values { |ids| ids.map { |id| E2ETests.name(id) } } + + # Split by test, not by file, as the files differ a lot in size: every batch takes an equal, + # contiguous slice of all the tests in run order, so every batch job computes the same split. + # A file cut by a slice boundary runs in two `flutter test --name` runs, which costs one more + # app build and launch. A file with no listed tests (all skipped) counts as one test. + tests = runs.flat_map { |target, names| (names || tests_of.fetch(target, [nil])).map { |name| [target, name] } } + slice = tests[(index * tests.size / count)...((index + 1) * tests.size / count)] + .group_by(&:first) + .map do |target, items| + names = items.map(&:last) + # A whole-file run that stayed in one batch keeps running the whole file. + whole = runs.to_h[target].nil? && (names == [nil] || names == tests_of[target]) + [target, whole ? nil : names] + end + + if slice.empty? + UI.important("Batch #{index + 1}/#{count}: no runs fell into this batch — nothing to run") + else + UI.important("Batch #{index + 1}/#{count}: #{slice.size} runs, #{slice.sum { |target, names| (names || tests_of.fetch(target, [nil])).size }} of #{tests.size} tests " \ + "(#{slice.map { |target, names| names ? "#{File.basename(target)}: #{names.join(', ')}" : File.basename(target) }.join('; ')})") + end + + slice +end + +# The entrypoint combine_runs generates, relative to the app (gitignored). +BATCH_ENTRYPOINT = 'integration_test/e2e_batch.g.dart' + +# Turns a batch's runs into one run of a generated entrypoint that calls every test file's +# `main()`, so the batch builds and launches the app once instead of once per file. The +# trade-off is the blast radius: a test that leaks async work can now fail the rest of the +# batch, not just the rest of its file. Returns `[entrypoint, test names or nil, test files]`. +def combine_runs(runs) + files = runs.map { |target, _| File.basename(target, '.dart') } + File.write(BATCH_ENTRYPOINT, <<~DART) + // Generated by run_e2e_test: one CI batch's test files in one app process. Do not edit. + // ignore_for_file: directives_ordering + + import 'support/stream_test_case.dart' show e2eTestFile; + #{files.map { |file| "import '#{file}.dart' as #{file};" }.join("\n")} + + void main() { + #{files.map { |file| " e2eTestFile = '#{file}';\n #{file}.main();" }.join("\n")} + } + DART + + names = + unless runs.all? { |_, selected| selected.nil? } + tests_of = E2ETests.list('integration_test').group_by { |id| E2ETests.file(id, 'integration_test') } + runs.flat_map { |target, selected| selected || tests_of.fetch(target, []).map { |id| E2ETests.name(id) } } + end + [BATCH_ENTRYPOINT, names, files] +end + +def boot_simulator(udid:) + return if `xcrun simctl list devices booted`.to_s.include?(udid) + return unless `xcrun simctl list devices`.to_s.include?(udid) + + sh("xcrun simctl bootstatus #{udid} -b") +end + +# Video is recorded on ANDROID ONLY — iOS recording is intentionally disabled. +def start_screen_recording(ios:, device:, path:) + return nil if ios + + FileUtils.mkdir_p(File.dirname(path)) + started_at_ms = (Time.now.to_f * 1000).round + serial = device ? "-s #{device} " : '' + remote = "/sdcard/#{File.basename(path)}" + pid = spawn("adb #{serial}shell screenrecord --bit-rate 4000000 #{remote}", + out: File::NULL, err: File::NULL) + { pid: pid, path: path, remote: remote, serial: serial, started_at_ms: started_at_ms } +rescue StandardError => e + UI.important("Screen recording failed to start: #{e.message}") + nil +end + +def stop_screen_recording(rec) + return unless rec + + rec[:ended_at_ms] = (Time.now.to_f * 1000).round + sh("adb #{rec[:serial]}shell pkill -INT screenrecord", log: false) rescue nil + sleep(3) # let screenrecord finalize + flush the file on-device + Process.kill('INT', rec[:pid]) rescue nil # reap the local adb client + Process.wait(rec[:pid]) rescue nil + sh("adb #{rec[:serial]}pull #{rec[:remote]} #{rec[:path]}", log: false) + sh("adb #{rec[:serial]}shell rm #{rec[:remote]}", log: false) rescue nil +rescue StandardError => e + UI.important("Screen recording failed to stop: #{e.message}") +end + +def attach_videos_to_failed_results(results_dir:, videos:) + return unless Dir.exist?(results_dir) + return if videos.empty? + + unless system('command -v ffmpeg > /dev/null 2>&1') + UI.important('ffmpeg not found; skipping per-test video attachments') + return + end + + Dir["#{results_dir}/*-result.json"].each do |json_path| + result = JSON.parse(File.read(json_path)) + next unless %w[failed broken].include?(result['status']) + next unless result['start'] && result['stop'] + + label = (result['labels'] || []).find { |l| l['name'] == 'testFile' } + recordings = label && videos[label['value']] + next unless recordings + + meta = recordings.find do |r| + r[:started_at_ms] && r[:ended_at_ms] && + result['start'] >= r[:started_at_ms] && result['start'] <= r[:ended_at_ms] + end + next unless meta && File.exist?(meta[:path]) && File.size(meta[:path]).positive? + + ss = [((result['start'] - meta[:started_at_ms]) / 1000.0) - 1.0, 0].max + duration = ((result['stop'] - result['start']) / 1000.0) + 2.0 + next unless duration.positive? + + clip = "#{results_dir}/#{SecureRandom.uuid}-attachment.mp4" + ok = system('ffmpeg', '-y', '-ss', ss.to_s, '-i', meta[:path], '-t', duration.to_s, + '-an', '-c:v', 'libx264', '-preset', 'veryfast', '-movflags', '+faststart', clip, + out: File::NULL, err: File::NULL) + + unless ok && File.exist?(clip) && File.size(clip) > 1024 + File.delete(clip) if File.exist?(clip) + next + end + + (result['attachments'] ||= []) << { 'name' => 'Video', 'source' => File.basename(clip), 'type' => 'video/mp4' } + File.write(json_path, JSON.generate(result)) + end +end + +lane :sh_on_root do |options| + Dir.chdir(root_path) { sh(options[:command]) } +end + +lane :fetch_dependencies do + sh_on_root(command: "flutter pub get --suppress-analytics") +end + +lane :start_mock_server do |options| + Dir.chdir(root_path) do + mock_server_repo = options[:local_server] || mock_server_repo_name + stop_mock_server unless is_ci + + unless options[:local_server] + branch = options[:branch].to_s.empty? ? 'main' : options[:branch] + sh("rm -rf #{mock_server_repo}") if File.directory?(mock_server_repo) + sh("git clone -b #{branch} https://github.com/#{github_repo.split('/').first}/#{mock_server_repo}.git") + end + + Dir.chdir(mock_server_repo) do + FileUtils.mkdir_p('logs') + sh("bundle exec ruby driver.rb #{mock_server_driver_port} > logs/driver.log 2>&1 &") + end + end +end + +lane :stop_mock_server do + Net::HTTP.get_response(URI("http://localhost:#{mock_server_driver_port}/stop")) rescue nil +end + +# Options: `selection` runs the tests select_e2e_tests chose (the file it wrote); +# `coverage` runs every test in its own app process and writes its lcov report +# to build/e2e-coverage///, for build_e2e_coverage_map. +lane :run_e2e_test do |options| + selection = options[:selection] && JSON.parse(File.read(File.expand_path(options[:selection], root_path))) + next if selection && selection['tests'].empty? + next unless selection || options[:coverage] || is_check_required(sources: @impact[:e2e][:sources], force_check: @force_check, required_checks: @impact[:e2e][:checks]) + + ios = options[:platform] == 'ios' || ios_device?(options[:device]) + device = options[:device] + coverage = options[:coverage] + FileUtils.rm_rf(e2e_coverage_path) if coverage + + start_mock_server(local_server: options[:local_server], branch: options[:mock_server_branch]) + + if ios + device = prepare_simulator(device: options[:device], reset: is_ci, wait_for_boot: false) + else + serial = device ? "-s #{device} " : '' + sh("adb #{serial}logcat -c") + end + + log_file = "#{root_path}/build/e2e-test.log" + FileUtils.mkdir_p(File.dirname(log_file)) + File.write(log_file, '') + + videos_dir = "#{root_path}/build/e2e-videos" + FileUtils.rm_rf(videos_dir) + FileUtils.mkdir_p(videos_dir) + videos = {} # test-file basename => [ { path:, started_at_ms:, ended_at_ms: }, ... ] (one per attempt) + failed = [] + + begin + Dir.chdir(root_path) do + runs = + if coverage + # The VM reports coverage per app process, so every test needs its own `flutter test`. + E2ETests.list('integration_test', platform: ios ? 'ios' : 'android').map do |id| + [E2ETests.file(id, 'integration_test'), [E2ETests.name(id)]] + end + elsif selection && !selection['run_all'] + selection['tests'].group_by { |id| E2ETests.file(id, 'integration_test') } + .map { |target, ids| [target, ids.map { |id| E2ETests.name(id) }] } + else + (options[:target] ? [options[:target]] : Dir['integration_test/*_test.dart'].sort).map { |target| [target, nil] } + end + UI.user_error!('No e2e test files found') if runs.empty? + + runs = batch_targets(runs, batch: options[:batch], batch_count: options[:batch_count]) + next if runs.empty? + + # Only CI batches share an app process; a whole-suite run keeps a fresh one per file. + runs = [combine_runs(runs)] if !coverage && options[:batch_count].to_i > 1 && runs.size > 1 + + retries = (options[:retries] || 2).to_i + + first_attempt = true + runs.each_with_index do |(target, selected, files), run_index| + test_name = File.basename(target, '.dart') + files ||= [test_name] + + attempt_log = "#{videos_dir}/#{test_name}.attempt.log" + coverage_dir = "#{e2e_coverage_path}/#{test_name}/#{selected.first}" if coverage + + # nil => run the whole file (first attempt); + # an array => run only those test descriptions. + only = selected + + ok = false + attempt = 0 + loop do + boot_simulator(udid: device) if ios && !first_attempt + first_attempt = false + attempt += 1 + + flags = [target, '--no-pub', "--dart-define=E2E_TARGET=#{test_name}"] + flags << "-d #{device}" if device + + if options[:verbose] + flags << '--verbose' + else + flags << '--quiet' + flags << "--reporter #{ENV['GITHUB_ACTIONS'] ? 'github' : 'expanded'}" + end + + flags << "--name #{Shellwords.escape(E2ELog.name_alternation(only))}" if only + if coverage + flags << "--coverage --coverage-path #{Shellwords.escape("#{coverage_dir}/lcov.info")}" + flags << "--coverage-package #{Shellwords.escape('^(e2e_app|stream_chat\w*)$')}" + end + # Per-run and per-attempt path so neither the next run of the same file + # (one per test in coverage mode) nor a retry overwrites a recording. + rec = start_screen_recording(ios: ios, device: device, path: "#{videos_dir}/#{test_name}.run#{run_index}.attempt#{attempt}.mp4") + + File.write(attempt_log, '') + cmd = "flutter test #{flags.join(' ')} 2>&1 | tee -a #{Shellwords.escape(log_file)} | " \ + "tee #{Shellwords.escape(attempt_log)} | " \ + "{ grep --line-buffered -vE 'ALLURE-(RESULT|ATTACH)::|E2E-TEST::' || true; }" + ok = begin + sh("bash -o pipefail -c #{Shellwords.escape(cmd)}") + true + rescue StandardError + false + end + + stop_screen_recording(rec) + + # Keep every attempt's recording + window, so failed results from an + # earlier attempt still match their own video even when a later retry + # narrows the test set or passes. + # A batch's recording covers each of its files. + if rec + files.each do |file| + (videos[file] ||= []) << { + path: rec[:path], + started_at_ms: rec[:started_at_ms], + ended_at_ms: rec[:ended_at_ms], + } + end + end + + # A run that reports tests it never ran must not go green, however + # `flutter test` exited. Also the best retry target when there is no + # failure to attribute — those are exactly the tests still owed a result. + phantom = E2ELog.phantom_test_names(attempt_log) + unless phantom.empty? + ok = false + UI.important("#{target}: #{phantom.size} test(s) reported without ever running " \ + "(no result emitted): #{phantom.join(', ')}") + end + + break if ok || attempt > retries + + # Not retriable: a whole-file retry can neither narrow nor fix a leaked + # async error, and a run that happens to pass would only be hiding it. + # Fail fast with the diagnosis instead of burning every attempt. + if E2ELog.post_completion_failure?(attempt_log) + blamed = E2ELog.post_completion_blamed_tests(attempt_log) + UI.important("#{target} (attempt #{attempt}/#{retries + 1}): a test leaked async work that threw " \ + 'AFTER it had completed, so the failure is not attributable to any single test' \ + "#{blamed.empty? ? '' : " (the runner blamed: #{blamed.join(', ')})"}. " \ + 'Not retrying — fix the leak; see the exception above.') + break + end + + # Both sets, not just the attributable ones: an attempt that reports a + # failure *and* phantoms would otherwise retry the failure alone, and a + # green retry of that one test would bury the tests still owed a result. + only = (E2ELog.failed_test_names(attempt_log) + phantom).uniq + if only.empty? + only = selected + UI.important("#{target} failed (attempt #{attempt}/#{retries + 1}); " \ + "retrying #{selected ? 'the selected tests' : 'whole file'}…") + else + UI.important("#{target} failed (attempt #{attempt}/#{retries + 1}); " \ + "retrying #{only.size} failed test(s): #{only.join(', ')}") + end + end + + if coverage + # A test that never reported a result (skipped, or the app died first) ran no test code. + FileUtils.rm_rf(coverage_dir) unless E2ELog.reported_test_names(attempt_log).include?(selected.first) + failed << "#{test_name}/#{selected.first}" unless ok + elsif !ok + # A batch names the tests that still fail, not every file it ran. + still_failing = (E2ELog.failed_test_names(attempt_log) + E2ELog.phantom_test_names(attempt_log)).uniq + failed.concat(files.size > 1 && !still_failing.empty? ? still_failing : [target]) + end + end + + UI.user_error!("E2E failed after retries: #{failed.join(', ')}") unless failed.empty? + end + ensure + # build_e2e_coverage_map keeps the previous coverage of these, as a failed test stops early. + if coverage + FileUtils.mkdir_p(e2e_coverage_path) + File.write("#{e2e_coverage_path}/failed_tests_#{options[:batch] || 0}.json", JSON.generate(failed)) + end + collect_allure_results(log_file: log_file) rescue nil + File.write("#{videos_dir}/manifest.json", JSON.generate(videos)) unless videos.empty? + stop_mock_server + end +end + +lane :attach_videos do + manifest = "#{root_path}/build/e2e-videos/manifest.json" + unless File.exist?(manifest) + UI.important("No video manifest at #{manifest}; nothing to attach") + next + end + + videos = JSON.parse(File.read(manifest)).transform_values do |recordings| + recordings.map do |v| + { path: v['path'], started_at_ms: v['started_at_ms'], ended_at_ms: v['ended_at_ms'] } + end + end + attach_videos_to_failed_results(results_dir: allure_results_path, videos: videos) +end + +# Picks the e2e tests a pull request can affect from the nightly coverage map and +# writes them to build/e2e_selection.json for `run_e2e_test selection:`. Outputs +# `e2e_batches`, the batch matrix of the e2e jobs (empty when nothing runs). +lane :select_e2e_tests do |options| + # A manual run's tip may be a merge commit, which the selection would mistake for a pull request merge. + run_all_reason = 'not a pull request' if is_ci && ENV['GITHUB_EVENT_NAME'] != 'pull_request' + required = run_all_reason || is_check_required(sources: @impact[:e2e][:sources], force_check: @force_check, required_checks: @impact[:e2e][:checks]) + all_tests = required ? E2ETests.list("#{root_path}/integration_test") : [] + + if run_all_reason + UI.important("Running all #{all_tests.size} tests: #{run_all_reason}") + selection = { 'run_all' => true, 'tests' => all_tests, 'reasons' => [run_all_reason] } + elsif required + selection = select_impacted_tests( + all_tests: all_tests, + # Relative to e2e_app, like `run_e2e_test selection:`; the action itself reads from the repository root. + coverage_map: options[:coverage_map] && File.expand_path(options[:coverage_map], root_path), + coverage_map_artifact: e2e_coverage_map_artifact, + coverage_map_branch: 'master', + diff_base: options[:diff_base], + changed_files: options[:changed_files]&.split(','), + source_root: repo_root, + source_paths: ['e2e_app/lib', 'packages/*/lib/**/*'], + test_paths: ['e2e_app/integration_test/*_test.dart'], + ignore_paths: ['**/*.md'], + run_all_paths: [ + 'e2e_app/integration_test/allure', + 'e2e_app/integration_test/mock_server', + 'e2e_app/integration_test/pages', + 'e2e_app/integration_test/robots', + 'e2e_app/integration_test/support', + 'e2e_app/android', + 'e2e_app/ios', + 'e2e_app/pubspec.yaml' + ], + # Changes to code nearly every test runs (launch, login, channel list) take one test per file; the nightly run covers the rest. + hot_test_ratio: 0.9 + ) + else + selection = { 'run_all' => false, 'tests' => [], 'reasons' => ['no e2e sources changed'] } + end + FileUtils.mkdir_p(File.dirname(e2e_selection_path)) + File.write(e2e_selection_path, JSON.pretty_generate(selection)) + + # run_e2e_test batches by test file, so there are no more batches than files. + files = selection['tests'].map { |id| E2ETests.file(id, 'integration_test') }.uniq + batches = (0...[files.size, 8].min).to_a + UI.important("E2E batches: #{batches.size}") + File.write(ENV['GITHUB_OUTPUT'], "e2e_batches=#{batches.to_json}\n", mode: 'a') if ENV['GITHUB_OUTPUT'] + batches +end + +# Builds the coverage map select_e2e_tests reads from the lcov reports of +# `run_e2e_test coverage:true` (every batch's, merged into one folder on CI). +lane :build_e2e_coverage_map do |options| + coverage_dir = options[:coverage_dir] ? File.expand_path(options[:coverage_dir], root_path) : e2e_coverage_path + failed_tests = Dir["#{coverage_dir}/failed_tests_*.json"].flat_map { |file| JSON.parse(File.read(file)) } + build_test_coverage_map( + coverage_dir: coverage_dir, + lcov_base_dir: root_path, + source_root: repo_root, + output_path: "#{root_path}/build/e2e_coverage_map.json", + failed_tests: failed_tests, + previous_map_artifact: e2e_coverage_map_artifact, + previous_map_branch: 'master' + ) +end + +# What each gated check runs on: a pull request runs it when `sources` changed, or +# when its `checks` have not passed yet on a commit with the current sources. +lane :impact_analysis do + { + e2e: { + sources: ['e2e_app', 'packages'], + checks: ['E2E result'] + } + } +end + +lane :install_runtime do |options| + install_ios_runtime( + version: options[:ios], + custom_script: File.join(root_path, 'scripts/install_ios_runtime.sh'), + ) +end diff --git a/e2e_app/fastlane/Pluginfile b/e2e_app/fastlane/Pluginfile new file mode 100644 index 0000000000..9d4fd06599 --- /dev/null +++ b/e2e_app/fastlane/Pluginfile @@ -0,0 +1,5 @@ +# Autogenerated by fastlane +# +# Ensure this file is checked in to source control! + +gem 'fastlane-plugin-stream_actions', '0.4.16' diff --git a/e2e_app/fastlane/README.md b/e2e_app/fastlane/README.md new file mode 100644 index 0000000000..fccf7f9996 --- /dev/null +++ b/e2e_app/fastlane/README.md @@ -0,0 +1,142 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +### collect_allure_results + +```sh +[bundle exec] fastlane collect_allure_results +``` + + + +### install_allurectl + +```sh +[bundle exec] fastlane install_allurectl +``` + + + +### allure_upload + +```sh +[bundle exec] fastlane allure_upload +``` + + + +### allure_launch + +```sh +[bundle exec] fastlane allure_launch +``` + + + +### allure_launch_removal + +```sh +[bundle exec] fastlane allure_launch_removal +``` + + + +### sh_on_root + +```sh +[bundle exec] fastlane sh_on_root +``` + + + +### fetch_dependencies + +```sh +[bundle exec] fastlane fetch_dependencies +``` + + + +### start_mock_server + +```sh +[bundle exec] fastlane start_mock_server +``` + + + +### stop_mock_server + +```sh +[bundle exec] fastlane stop_mock_server +``` + + + +### run_e2e_test + +```sh +[bundle exec] fastlane run_e2e_test +``` + + + +### attach_videos + +```sh +[bundle exec] fastlane attach_videos +``` + + + +### select_e2e_tests + +```sh +[bundle exec] fastlane select_e2e_tests +``` + + + +### build_e2e_coverage_map + +```sh +[bundle exec] fastlane build_e2e_coverage_map +``` + + + +### impact_analysis + +```sh +[bundle exec] fastlane impact_analysis +``` + + + +### install_runtime + +```sh +[bundle exec] fastlane install_runtime +``` + + + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/sample_app/fastlane/e2e_log.rb b/e2e_app/fastlane/e2e_log.rb similarity index 100% rename from sample_app/fastlane/e2e_log.rb rename to e2e_app/fastlane/e2e_log.rb diff --git a/sample_app/fastlane/e2e_tests.rb b/e2e_app/fastlane/e2e_tests.rb similarity index 100% rename from sample_app/fastlane/e2e_tests.rb rename to e2e_app/fastlane/e2e_tests.rb diff --git a/sample_app/integration_test/allure/allure.dart b/e2e_app/integration_test/allure/allure.dart similarity index 100% rename from sample_app/integration_test/allure/allure.dart rename to e2e_app/integration_test/allure/allure.dart diff --git a/e2e_app/integration_test/attachments_test.dart b/e2e_app/integration_test/attachments_test.dart new file mode 100644 index 0000000000..1f8fdecee4 --- /dev/null +++ b/e2e_app/integration_test/attachments_test.dart @@ -0,0 +1,240 @@ +import 'mock_server/data_types.dart'; +import 'robots/user_robot.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + // MARK: User uploads + + streamTestWithEnv( + description: 'user uploads an image', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user attaches an image'); + await env.userRobot.attachFile(AttachmentType.image); + + step('THEN the image is displayed in the composer'); + await env.userRobot.assertAttachmentsInComposer(AttachmentType.image); + + step('WHEN the user sends the image'); + await env.userRobot.tapOnSendButton(); + + step('THEN the user can see the uploaded image'); + await env.userRobot.assertImage(isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'user uploads multiple images', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user attaches multiple images'); + await env.userRobot.attachFile(AttachmentType.image, count: 2); + + step('THEN the images are displayed in the composer'); + await env.userRobot.assertAttachmentsInComposer(AttachmentType.image, count: 2); + + step('WHEN the user sends the images'); + await env.userRobot.tapOnSendButton(); + + step('THEN the user can see the uploaded images'); + await env.userRobot.assertImage(isDisplayed: true, count: 2); + }, + ); + + streamTestWithEnv( + description: 'user deletes an image', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends an image'); + await env.userRobot.attachFile(AttachmentType.image).tapOnSendButton().assertImage(isDisplayed: true); + + step('AND the user deletes the image'); + await env.userRobot.deleteMessage(); + + step('THEN the user can see the deleted message'); + await env.userRobot.assertImage(isDisplayed: false).assertDeletedMessage(); + }, + ); + + streamTestWithEnv( + description: 'user uploads a file', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user attaches a file'); + await env.userRobot.attachFile(AttachmentType.file); + + step('THEN the file is displayed in the composer'); + await env.userRobot.assertAttachmentsInComposer(AttachmentType.file); + + step('WHEN the user sends the file'); + await env.userRobot.tapOnSendButton(); + + step('THEN the user can see the uploaded file'); + await env.userRobot.assertFile(isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'user uploads multiple files', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user attaches multiple files'); + await env.userRobot.attachFile(AttachmentType.file, count: 2); + + step('THEN the files are displayed in the composer'); + await env.userRobot.assertAttachmentsInComposer(AttachmentType.file, count: 2); + + step('WHEN the user sends the files'); + await env.userRobot.tapOnSendButton(); + + step('THEN the user can see the uploaded files'); + await env.userRobot.assertFile(isDisplayed: true, count: 2); + }, + ); + + streamTestWithEnv( + description: 'user deletes a file', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a file'); + await env.userRobot.attachFile(AttachmentType.file).tapOnSendButton().assertFile(isDisplayed: true); + + step('AND the user deletes the file'); + await env.userRobot.deleteMessage(); + + step('THEN the user can see the deleted message'); + await env.userRobot.assertFile(isDisplayed: false).assertDeletedMessage(); + }, + ); + + streamTestWithEnv( + description: 'user uploads a video', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a video'); + await env.userRobot.attachFile(AttachmentType.video).tapOnSendButton(); + + step('THEN the user can see the uploaded video'); + await env.userRobot.assertVideo(isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'image upload recovers when the user comes back online', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user goes offline'); + await env.goOffline(); + + step('WHEN the user sends an image while offline'); + await env.userRobot.attachFile(AttachmentType.image).tapOnSendButton(); + // The composer reports the failed send through FlutterError. + await env.takeExpectedError(); + + step('AND the user comes back online'); + await env.goOnline(); + + step('THEN the image is uploaded'); + await env.userRobot.assertImage(isDisplayed: true).assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + }, + ); + + // MARK: Participant uploads + + streamTestWithEnv( + description: 'participant uploads an image', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant uploads an image'); + await env.participantRobot.uploadAttachment(AttachmentType.image); + + step('THEN the user can see the uploaded image'); + await env.userRobot.assertImage(isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'participant uploads multiple images', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant uploads multiple images'); + await env.participantRobot.uploadAttachment(AttachmentType.image, count: 2); + + step('THEN the user can see the images in one message'); + await env.userRobot.assertImage(isDisplayed: true, count: 2); + }, + ); + + streamTestWithEnv( + description: 'participant uploads a video', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant uploads a video'); + await env.participantRobot.uploadAttachment(AttachmentType.video); + + step('THEN the user can see the uploaded video'); + await env.userRobot.assertVideo(isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'participant uploads a file', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant uploads a file'); + await env.participantRobot.uploadAttachment(AttachmentType.file); + + step('THEN the user can see the uploaded file'); + await env.userRobot.assertFile(isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'user swipes between images in the gallery', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant uploads multiple images'); + await env.participantRobot.uploadAttachment(AttachmentType.image, count: 2); + + step('WHEN the user opens the first image'); + await env.userRobot.assertImage(isDisplayed: true, count: 2).openImageInGallery(); + + step('THEN the gallery shows the first image'); + await env.userRobot.assertGalleryPosition(1, total: 2); + + step('WHEN the user swipes to the next image'); + await env.userRobot.swipeToNextImageInGallery(); + + step('THEN the gallery shows the second image'); + await env.userRobot.assertGalleryPosition(2, total: 2); + }, + ); +} diff --git a/e2e_app/integration_test/authentication_test.dart b/e2e_app/integration_test/authentication_test.dart new file mode 100644 index 0000000000..694db804c0 --- /dev/null +++ b/e2e_app/integration_test/authentication_test.dart @@ -0,0 +1,205 @@ +import 'support/step.dart'; +import 'support/stream_test_case.dart'; +import 'support/stream_test_env.dart'; + +// Each scenario connects with a token the mock server hands out (valid for 5 s), +// so the SDK has to refresh it as the backend robot breaks it. +void main() { + Future login(StreamTestEnv env) async { + await env.userRobot.login(); + await env.assertConnected(); + } + + streamTestWithEnv( + description: 'token invalidates before the user logs in', + skip: 'https://linear.app/stream/issue/FLU-1084', + jwt: true, + body: (env) async { + step('GIVEN the token is invalid'); + await env.backendRobot.invalidateToken(); + + step('WHEN the user tries to log in'); + await env.userRobot.login(); + + step('THEN the app requests a token refresh'); + await env.assertConnected(); + }, + ); + + streamTestWithEnv( + description: 'token invalidates after the user logs in', + skip: 'https://linear.app/stream/issue/FLU-1084', + jwt: true, + body: (env) async { + step('GIVEN the user logs in'); + await login(env); + + step('WHEN the token invalidates'); + await env.backendRobot.invalidateToken(); + + step('THEN the app requests a token refresh'); + await env.assertTokenHasExpired(); + await env.assertConnected(); + }, + ); + + streamTestWithEnv( + description: 'token date invalidates before the user logs in', + skip: 'https://linear.app/stream/issue/FLU-1083', + jwt: true, + body: (env) async { + step('GIVEN the token date is invalid'); + await env.backendRobot.invalidateTokenDate(); + + step('WHEN the user tries to log in'); + await env.userRobot.login(); + + step('THEN the app requests a token refresh'); + await env.assertConnected(); + }, + ); + + streamTestWithEnv( + description: 'token date invalidates after the user logs in', + skip: 'https://linear.app/stream/issue/FLU-1083', + jwt: true, + body: (env) async { + step('GIVEN the user logs in'); + await login(env); + + step('WHEN the token date invalidates'); + await env.backendRobot.invalidateTokenDate(); + + step('THEN the app requests a token refresh'); + await env.assertTokenHasExpired(); + await env.assertConnected(); + }, + ); + + streamTestWithEnv( + description: 'token signature invalidates before the user logs in', + skip: 'https://linear.app/stream/issue/FLU-1083', + jwt: true, + body: (env) async { + step('GIVEN the token signature is invalid'); + await env.backendRobot.invalidateTokenSignature(); + + step('WHEN the user tries to log in'); + await env.userRobot.login(); + + step('THEN the app requests a token refresh'); + await env.assertConnected(); + }, + ); + + streamTestWithEnv( + description: 'token signature invalidates after the user logs in', + skip: 'https://linear.app/stream/issue/FLU-1083', + jwt: true, + body: (env) async { + step('GIVEN the user logs in'); + await login(env); + + step('WHEN the token signature invalidates'); + await env.backendRobot.invalidateTokenSignature(); + + step('THEN the app requests a token refresh'); + await env.assertTokenHasExpired(); + await env.assertConnected(); + }, + ); + + streamTestWithEnv( + description: 'token expires before the user logs in', + jwt: true, + body: (env) async { + step('GIVEN the token expires'); + await env.backendRobot.revokeToken(); + + step('WHEN the user tries to log in'); + await env.userRobot.login(); + + step('THEN the app requests a token refresh'); + await env.assertConnected(); + }, + ); + + streamTestWithEnv( + description: 'token expires after the user logs in', + jwt: true, + body: (env) async { + step('GIVEN the user logs in'); + await login(env); + + step('WHEN the token expires'); + // Nothing to do: the mock server's token is only valid for 5 seconds. + + step('THEN the app requests a token refresh'); + await env.assertTokenHasExpired(); + await env.assertConnected(); + }, + ); + + streamTestWithEnv( + description: 'token expires while the user is in the background', + jwt: true, + body: (env) async { + step('GIVEN the user logs in'); + await login(env); + + step('AND the user goes to the background'); + await env.moveToBackground(); + + step('AND the token expires'); + // Not pumped: frames are disabled while backgrounded. + await Future.delayed(const Duration(seconds: 6)); + + step('WHEN the user comes back to the foreground'); + await env.moveToForeground(); + + step('THEN the app requests a token refresh'); + await env.assertTokenHasExpired(); + await env.assertConnected(); + }, + ); + + streamTestWithEnv( + description: 'token expires while the user is offline', + jwt: true, + body: (env) async { + step('GIVEN the user logs in'); + await login(env); + + step('AND the user goes offline'); + await env.goOffline(); + + step('WHEN the token expires'); + await env.waitForTokenToExpire(); + + step('AND the user comes back online'); + await env.goOnline(); + + step('THEN the app requests a token refresh'); + await env.assertTokenHasExpired(); + await env.assertConnected(); + }, + ); + + streamTestWithEnv( + description: 'token generation fails', + jwt: true, + body: (env) async { + step('GIVEN the user logs in'); + await login(env); + + step('AND JWT generation breaks on the server side'); + await env.backendRobot.breakTokenGeneration(); + + step('WHEN the app tries to refresh the token'); + await env.assertTokenHasExpired(); + + step('THEN the app refreshes the token once JWT generation recovers'); + await env.assertConnected(); + }, + ); +} diff --git a/e2e_app/integration_test/channel_actions_test.dart b/e2e_app/integration_test/channel_actions_test.dart new file mode 100644 index 0000000000..abc74675d4 --- /dev/null +++ b/e2e_app/integration_test/channel_actions_test.dart @@ -0,0 +1,136 @@ +import 'robots/user_robot.dart'; +import 'robots/user_robot_channel_list_asserts.dart'; +import 'support/predefined_users.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + streamTestWithEnv( + description: 'channel actions sheet is shown when the user long-presses the channel', + body: (env) async { + step('GIVEN the user logs in'); + await env.userRobot.login().waitForChannelListToLoad(); + + step('WHEN the user long-presses the channel'); + await env.userRobot.openChannelMenu(); + + step('THEN the channel actions sheet is shown'); + await env.userRobot.assertChannelActionsSheetForGroupChannel(); + }, + ); + + streamTestWithEnv( + description: 'channel actions sheet is shown when the user swipes the channel', + body: (env) async { + step('GIVEN the user logs in'); + await env.userRobot.login().waitForChannelListToLoad(); + + step('WHEN the user swipes the channel and taps the more action'); + await env.userRobot.swipeChannel().tapOnMoreSwipeAction(); + + step('THEN the channel actions sheet is shown'); + await env.userRobot.assertChannelActionsSheetForGroupChannel(); + }, + ); + + streamTestWithEnv( + description: 'user opens the channel info from the channel actions sheet', + body: (env) async { + step('GIVEN the user logs in'); + await env.userRobot.login().waitForChannelListToLoad(); + + step('AND the user long-presses the channel'); + await env.userRobot.openChannelMenu(); + + step('WHEN the user taps the view info option'); + await env.userRobot.tapOnViewChannelInfo(); + + step('THEN the group channel info screen is shown'); + await env.userRobot.assertGroupChannelInfoScreen(); + }, + ); + + streamTestWithEnv( + description: 'user leaves a group channel', + body: (env) async { + step('GIVEN the user logs in'); + await env.userRobot.login().waitForChannelListToLoad(); + + step('AND the user long-presses the channel'); + await env.userRobot.openChannelMenu(); + + step('WHEN the user leaves the group'); + await env.userRobot.tapOnLeaveGroup().confirmChannelAction(); + + step('THEN the channel is gone from the channel list'); + await env.userRobot.assertChannelListIsEmpty(); + }, + ); + + streamTestWithEnv( + description: 'user deletes a group channel', + body: (env) async { + step('GIVEN the user logs in'); + await env.userRobot.login().waitForChannelListToLoad(); + + step('AND the user long-presses the channel'); + await env.userRobot.openChannelMenu(); + + step('WHEN the user deletes the group'); + await env.userRobot.tapOnDeleteGroup().confirmChannelAction(); + + step('THEN the channel is gone from the channel list'); + await env.userRobot.assertChannelListIsEmpty(); + }, + ); + + streamTestWithEnv( + description: 'user mutes a channel from the swipe action', + body: (env) async { + step('GIVEN the user logs in'); + await env.userRobot.login().waitForChannelListToLoad(); + + step('WHEN the user swipes the channel and taps the mute action'); + await env.userRobot.swipeChannel().tapOnMuteSwipeAction(); + + step('THEN the channel shows the muted icon'); + await env.userRobot.assertChannelIsMuted(isMuted: true); + }, + ); + + streamTestWithEnv( + description: 'user unmutes a channel from the swipe action', + body: (env) async { + step('GIVEN the user logs in'); + await env.userRobot.login().waitForChannelListToLoad(); + + step('AND the user mutes the channel from the swipe action'); + await env.userRobot.swipeChannel().tapOnMuteSwipeAction().assertChannelIsMuted(isMuted: true); + + step('WHEN the user swipes the channel and taps the unmute action'); + await env.userRobot.swipeChannel().tapOnUnmuteSwipeAction(); + + step('THEN the channel shows no muted icon'); + await env.userRobot.assertChannelIsMuted(isMuted: false); + }, + ); + + streamTestWithEnv( + description: 'app does not crash when the user goes back after being removed from the open channel', + skip: 'https://linear.app/stream/issue/FLU-1085', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user is removed from the channel while it is open'); + await env.backendRobot.removeMember(PredefinedUsers.mockServerUser.id); + await env.userRobot.waitForMessageListToLoad(); + + step('WHEN the user goes back to the channel list'); + await env.userRobot.tapBackButton(); + + step('THEN the channel is gone from the channel list'); + await env.userRobot.assertChannelListIsEmpty(); + }, + ); +} diff --git a/sample_app/integration_test/channel_list_test.dart b/e2e_app/integration_test/channel_list_test.dart similarity index 83% rename from sample_app/integration_test/channel_list_test.dart rename to e2e_app/integration_test/channel_list_test.dart index 5aecb6ed9c..81d8a001ca 100644 --- a/sample_app/integration_test/channel_list_test.dart +++ b/e2e_app/integration_test/channel_list_test.dart @@ -346,4 +346,78 @@ void main() { await env.userRobot.assertChannelListPagination(channelsCount: channelsCount); }, ); + + streamTestWithEnv( + description: 'channel preview is updated when the participant edits the preview message', + body: (env) async { + const message = 'message'; + const editedMessage = 'edited message'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(message); + await env.userRobot.assertMessage(message); + + step('WHEN the participant edits the message'); + await env.participantRobot.editMessage(editedMessage); + await env.userRobot.assertMessage(editedMessage); + + step('AND the user goes back to the channel list'); + await env.userRobot.tapBackButton(); + + step('THEN the channel preview shows the edited message'); + await env.userRobot.assertMessageInChannelPreview(editedMessage, fromCurrentUser: false); + }, + ); + + streamTestWithEnv( + description: 'typing indicator is shown in the channel preview when the participant types', + body: (env) async { + step('GIVEN the user opens the channel list'); + await env.userRobot.login().waitForChannelListToLoad(); + + step('WHEN the participant starts typing'); + await env.participantRobot.startTyping(); + + step('THEN the channel preview shows the typing indicator'); + await env.userRobot.assertTypingIndicatorInChannelPreview(isDisplayed: true); + + step('WHEN the participant stops typing'); + await env.participantRobot.stopTyping(); + + step('THEN the channel preview hides the typing indicator'); + await env.userRobot.assertTypingIndicatorInChannelPreview(isDisplayed: false); + }, + ); + + streamTestWithEnv( + description: 'channel list is reordered after the oldest channel gets a message while offline', + body: (env) async { + const message = 'message'; + const oldestChannel = '3'; + + step('GIVEN the user opens the channel list'); + await env.backendRobot.generateChannels(channelsCount: 3); + await env.userRobot + .login() + .assertChannelName('1', channelIndex: 0) + .assertChannelName(oldestChannel, channelIndex: 2); + + step('AND the user goes offline'); + await env.goOffline(); + + step('WHEN the participant sends a new message in the oldest channel'); + await env.participantRobot.sendMessage(message, channelName: oldestChannel); + + step('AND the user comes back online'); + await env.goOnline(); + + step('THEN the channel list reorders accordingly'); + await env.userRobot + .assertChannelName(oldestChannel, channelIndex: 0) + .assertMessageInChannelPreview(message, fromCurrentUser: false); + }, + ); } diff --git a/e2e_app/integration_test/channel_search_test.dart b/e2e_app/integration_test/channel_search_test.dart new file mode 100644 index 0000000000..f869456e69 --- /dev/null +++ b/e2e_app/integration_test/channel_search_test.dart @@ -0,0 +1,26 @@ +import 'robots/user_robot.dart'; +import 'robots/user_robot_channel_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + const channelName = 'Blue Team'; + const searchQuery = 'Blue'; + + streamTestWithEnv( + description: 'user searches for a channel', + body: (env) async { + step('GIVEN channels exist, one with a searchable name'); + await env.backendRobot.generateChannels(channelsCount: 3, channelNames: [channelName]); + + step('AND the user logs in'); + await env.userRobot.login().waitForChannelListToLoad().assertChannelCount(3); + + step('WHEN the user searches for the channel by its name'); + await env.userRobot.selectSearchScope('Channels').search(searchQuery); + + step('THEN only the matching channel is shown'); + await env.userRobot.assertChannelCount(1).assertChannelWithName(channelName); + }, + ); +} diff --git a/e2e_app/integration_test/connection_test.dart b/e2e_app/integration_test/connection_test.dart new file mode 100644 index 0000000000..3fc94d801d --- /dev/null +++ b/e2e_app/integration_test/connection_test.dart @@ -0,0 +1,91 @@ +import 'mock_server/data_types.dart'; +import 'robots/user_robot.dart'; +import 'robots/user_robot_channel_list_asserts.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/predefined_users.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + streamTestWithEnv( + description: 'channel list is shown when the user logs out and logs back in', + body: (env) async { + step('GIVEN the user logs in'); + await env.userRobot.login().waitForChannelListToLoad(); + + step('WHEN the user logs out'); + await env.userRobot.logout(); + + step('THEN the start page is shown'); + await env.userRobot.assertStartPageIsShown(); + + step('WHEN the user logs back in'); + await env.userRobot.login(); + + step('THEN the channel list is shown again'); + await env.assertConnected(); + await env.userRobot.waitForChannelListToLoad(); + }, + ); + + streamTestWithEnv( + description: 'channel list is shown when the user logs out while it is loading and logs back in', + body: (env) async { + step('GIVEN the user logs in'); + // Long enough to outlast the bounded settle that follows the login tap. + await env.backendRobot.delayChannelList(seconds: 10); + await env.userRobot.login(); + + step('AND the user logs out while the channel list is loading'); + await env.userRobot.assertChannelListIsLoading().logout(); + + step('THEN the start page is shown'); + await env.userRobot.assertStartPageIsShown(); + + step('WHEN the user logs in'); + await env.userRobot.login(); + + step('THEN the user observes the channel list'); + await env.userRobot.waitForChannelListToLoad(); + }, + ); + + streamTestWithEnv( + description: 'user logs in as another user after logging out', + body: (env) async { + const firstUserMessage = 'message from the first user'; + const secondUserMessage = 'message from the second user'; + const unreadCount = 2; + + step('GIVEN the user logs in and sends a message'); + await env.userRobot.login().openChannel().sendMessage(firstUserMessage).assertMessage(firstUserMessage); + await env.userRobot.tapBackButton(); + + step('AND the user logs out'); + await env.userRobot.logout().assertStartPageIsShown(); + + step('WHEN the user logs in as another user'); + await env.backendRobot.setAppUser(id: PredefinedUsers.qaTest2.id, name: PredefinedUsers.qaTest2.name); + await env.userRobot.login(PredefinedUsers.qaTest2); + + step('THEN the user observes the channel list'); + await env.userRobot.waitForChannelListToLoad(); + + step("AND the previous user's message is shown as someone else's"); + // The mock server answered the first login as its own fixture user, which + // is what the native test asserts too. + await env.userRobot.openChannel().assertMessageAuthor( + firstUserMessage, + authorName: PredefinedUsers.mockServerUser.name, + ); + + step('AND the user can send a message'); + await env.userRobot.sendMessage(secondUserMessage).assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the unread count updates as expected'); + await env.userRobot.tapBackButton(); + await env.participantRobot.sendMultipleMessages('New', count: unreadCount); + await env.userRobot.assertChannelUnreadCount(unreadCount); + }, + ); +} diff --git a/e2e_app/integration_test/draft_messages_test.dart b/e2e_app/integration_test/draft_messages_test.dart new file mode 100644 index 0000000000..d06929067f --- /dev/null +++ b/e2e_app/integration_test/draft_messages_test.dart @@ -0,0 +1,110 @@ +import 'robots/user_robot.dart'; +import 'robots/user_robot_channel_list_asserts.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + const firstMessage = 'message'; + const draftMessage = 'alright'; + + streamTestWithEnv( + description: 'user updates a channel draft message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user sends a message'); + await env.userRobot.sendMessage(firstMessage).assertMessage(firstMessage); + + step('WHEN the user inputs some text in the composer'); + await env.userRobot.typeText(draftMessage); + + step('AND the user leaves the channel'); + await env.userRobot.tapBackButton(); + + step('THEN the draft message is in the channel preview'); + await env.userRobot.assertMessageInChannelPreview('Draft: $draftMessage'); + + step('WHEN the user comes back to the channel'); + await env.userRobot.openChannel(); + + step('THEN the draft message is in the composer'); + await env.userRobot.assertComposerText(draftMessage); + + step('WHEN the user deletes the draft message'); + await env.userRobot.clearComposer(); + + step('AND the user leaves the channel'); + await env.userRobot.tapBackButton(); + + step('THEN there is no draft message in the channel preview'); + await env.userRobot.assertMessageInChannelPreview(firstMessage, fromCurrentUser: true); + + step('WHEN the user comes back to the channel'); + await env.userRobot.openChannel(); + + step('THEN there is no draft message in the composer'); + await env.userRobot.assertComposerText(''); + }, + ); + + streamTestWithEnv( + description: 'user updates a thread draft message', + body: (env) async { + step('GIVEN the user opens a thread'); + await env.backendRobot.generateChannels( + channelsCount: 1, + messagesCount: 1, + repliesCount: 1, + repliesText: firstMessage, + ); + await env.userRobot.login().openChannel().openThreadFromReplies(); + + step('WHEN the user inputs some text in the composer'); + await env.userRobot.typeText(draftMessage); + + step('AND the user leaves the thread and comes back to it'); + await env.userRobot.tapBackButton().openThreadFromReplies(); + + step('THEN the draft message is in the composer'); + await env.userRobot.assertComposerText(draftMessage); + + step('WHEN the user deletes the draft message'); + await env.userRobot.clearComposer(); + + step('AND the user leaves the thread and comes back to it'); + await env.userRobot.tapBackButton().openThreadFromReplies(); + + step('THEN there is no draft message in the composer'); + await env.userRobot.assertComposerText(''); + }, + ); + + streamTestWithEnv( + description: 'user updates a draft message while offline', + skip: 'https://linear.app/stream/issue/FLU-1086', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user becomes offline'); + await env.goOffline(); + + step('WHEN the user inputs some text in the composer'); + await env.userRobot.typeText(draftMessage); + + step('AND the user leaves the channel'); + await env.userRobot.tapBackButton(); + + step('THEN the draft message is in the channel preview'); + await env.userRobot.assertMessageInChannelPreview('Draft: $draftMessage'); + + step('WHEN the user comes back to the channel'); + await env.userRobot.openChannel(); + + step('THEN the draft message is in the composer'); + await env.userRobot.assertComposerText(draftMessage); + }, + ); +} diff --git a/e2e_app/integration_test/giphy_test.dart b/e2e_app/integration_test/giphy_test.dart new file mode 100644 index 0000000000..a5e4fb871d --- /dev/null +++ b/e2e_app/integration_test/giphy_test.dart @@ -0,0 +1,184 @@ +import 'mock_server/data_types.dart'; +import 'robots/user_robot.dart'; +import 'robots/user_robot_channel_list_asserts.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + streamTestWithEnv( + description: 'user observes a Giphy when the user adds it', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a Giphy using the giphy command'); + await env.userRobot.uploadGiphy(); + + step('THEN the user observes the Giphy'); + await env.userRobot.assertGiphy(isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'user observes a Giphy when the participant adds it', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends a Giphy'); + await env.participantRobot.uploadGiphy(); + + step('THEN the user observes the Giphy'); + await env.userRobot.assertGiphy(isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'user observes a Giphy when the user adds it in a thread', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('WHEN the user runs the giphy command in a thread'); + await env.userRobot.openThread().uploadGiphy(); + + step('THEN the user observes the Giphy in the thread'); + await env.userRobot.assertGiphy(isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'user observes a Giphy when the participant adds it in a thread', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends a Giphy in a thread'); + await env.participantRobot.uploadGiphyInThread(); + + step('THEN the user observes the Giphy in the thread'); + await env.userRobot.openThreadFromReplies().assertGiphy(isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'user observes a Giphy after adding it through the composer commands', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a Giphy picked from the command suggestions'); + await env.userRobot.uploadGiphy(useComposerCommand: true); + + step('THEN the user observes the Giphy'); + await env.userRobot.assertGiphy(isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'message is not sent when the user sends an invalid command', + body: (env) async { + const invalidCommand = 'invalid command'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user sends a message'); + await env.userRobot.sendMessage('Test').assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('WHEN the user sends a message with an invalid command'); + await env.userRobot.sendMessage('/$invalidCommand'); + + step('THEN the user observes an error message'); + await env.userRobot.assertInvalidCommandMessage(invalidCommand); + + step('AND the previous message shows its timestamp and delivery status'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent).assertMessageTimestamps(1); + }, + ); + + streamTestWithEnv( + description: 'channel list is not modified when an ephemeral message is shown', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user runs the giphy command'); + await env.userRobot.uploadGiphy(send: false); + + step('AND the user goes back to the channel list'); + await env.userRobot.tapBackButton(); + + step('THEN the message is not added to the channel preview'); + await env.userRobot.assertMessageInChannelPreview('No messages yet'); + }, + ); + + streamTestWithEnv( + description: 'delivery status is hidden when an ephemeral message is shown', + skip: 'https://linear.app/stream/issue/FLU-1087', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user runs the giphy command'); + await env.userRobot.uploadGiphy(send: false); + + step('THEN the ephemeral message has no delivery status'); + await env.userRobot.assertOnlyVisibleToYou().assertEphemeralMessageDeliveryStatusHidden(); + }, + ); + + streamTestWithEnv( + description: 'delivery status is hidden when an ephemeral message is shown in a thread', + skip: 'https://linear.app/stream/issue/FLU-1087', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('WHEN the user runs the giphy command in a thread'); + await env.userRobot.openThread().uploadGiphy(send: false); + + step('THEN the ephemeral message has no delivery status'); + await env.userRobot.assertOnlyVisibleToYou().assertEphemeralMessageDeliveryStatusHidden(); + }, + ); + + streamTestWithEnv( + description: 'message is not sent when the user cancels the ephemeral message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user cancels a Giphy'); + await env.userRobot.uploadGiphy(send: false).tapOnCancelGiphyButton(); + + step('THEN the user does not observe the Giphy'); + await env.userRobot.assertGiphy(isDisplayed: false).assertGiphyButtons(areDisplayed: false); + }, + ); + + streamTestWithEnv( + description: 'user observes a Giphy after shuffling it', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user shuffles a Giphy'); + await env.userRobot.uploadGiphy(send: false).tapOnShuffleGiphyButton(); + + step('THEN the Giphy is shuffled but not sent'); + await env.userRobot.assertGiphy(isDisplayed: true).assertGiphyButtons(areDisplayed: true); + + step('WHEN the user sends the Giphy'); + await env.userRobot.tapOnSendGiphyButton(); + + step('THEN the user observes the Giphy'); + await env.userRobot.assertGiphy(isDisplayed: true).assertGiphyButtons(areDisplayed: false); + }, + ); +} diff --git a/e2e_app/integration_test/hyper_links_test.dart b/e2e_app/integration_test/hyper_links_test.dart new file mode 100644 index 0000000000..9bad634c4d --- /dev/null +++ b/e2e_app/integration_test/hyper_links_test.dart @@ -0,0 +1,210 @@ +import 'robots/user_robot.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + // The mock server serves a canned OG response for each of these hosts. + const unsplashLink = 'https://unsplash.com/photos/1_2d3MRbI9c'; + const youtubeLink = 'https://youtube.com/watch?v=xOX7MsrbaPY'; + const giphyLink = 'https://giphy.com/gifs/test-gw3IWyGkC0rsazTi'; + + // An OG attachment without `title_link` renders as an image, not a preview. + const noPreviewForImageOg = 'https://linear.app/stream/issue/FLU-1088'; + // Dismissing the composer's preview doesn't send `skip_enrich_url`. + const enrichedAfterDismissal = 'https://linear.app/stream/issue/FLU-1089'; + + streamTestWithEnv( + description: 'user sends a Unsplash link with a preview', + skip: noPreviewForImageOg, + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user types a Unsplash url'); + await env.userRobot.typeText('Look at $unsplashLink'); + + step('THEN the user observes a link preview in the composer'); + await env.userRobot.assertLinkPreviewInComposer(isDisplayed: true); + + step('WHEN the user taps on the send button'); + await env.userRobot.tapOnSendButton(); + + step('THEN the user observes the message with a link preview'); + await env.userRobot + .assertMessageWithLink('Look at $unsplashLink', link: unsplashLink) + .assertLinkPreviewInMessageList(isDisplayed: true); + }, + ); + + // Passes only because the Unsplash preview never renders at all (FLU-1088). + streamTestWithEnv( + description: 'user sends a Unsplash link without a preview', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user types a Unsplash url'); + await env.userRobot.typeText('Look at $unsplashLink').assertLinkPreviewInComposer(isDisplayed: true); + + step('AND the user cancels the link preview'); + await env.userRobot.tapOnLinkPreviewCancelButton(); + + step('THEN the link preview disappears'); + await env.userRobot.assertLinkPreviewInComposer(isDisplayed: false); + + step('WHEN the user taps on the send button'); + await env.userRobot.tapOnSendButton(); + + step('THEN the user observes the message without a link preview'); + await env.userRobot + .assertMessageWithLink('Look at $unsplashLink', link: unsplashLink) + .assertLinkPreviewInMessageList(isDisplayed: false); + }, + ); + + streamTestWithEnv( + description: 'participant sends a Unsplash link', + skip: noPreviewForImageOg, + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends a Unsplash url'); + await env.participantRobot.sendMessage('Look at $unsplashLink'); + + step('THEN the user observes the message with a link preview'); + await env.userRobot + .assertMessageWithLink('Look at $unsplashLink', link: unsplashLink) + .assertLinkPreviewInMessageList(isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'user sends a YouTube link with a preview', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user types a YouTube url'); + await env.userRobot.typeText('Look at $youtubeLink'); + + step('THEN the user observes a link preview in the composer'); + await env.userRobot.assertLinkPreviewInComposer(isDisplayed: true); + + step('WHEN the user taps on the send button'); + await env.userRobot.tapOnSendButton(); + + step('THEN the user observes the message with a link preview'); + await env.userRobot + .assertMessageWithLink('Look at $youtubeLink', link: youtubeLink) + .assertLinkPreviewInMessageList(isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'user sends a YouTube link without a preview', + skip: enrichedAfterDismissal, + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user types a YouTube url'); + await env.userRobot.typeText('Look at $youtubeLink').assertLinkPreviewInComposer(isDisplayed: true); + + step('AND the user cancels the link preview'); + await env.userRobot.tapOnLinkPreviewCancelButton(); + + step('THEN the link preview disappears'); + await env.userRobot.assertLinkPreviewInComposer(isDisplayed: false); + + step('WHEN the user taps on the send button'); + await env.userRobot.tapOnSendButton(); + + step('THEN the user observes the message without a link preview'); + await env.userRobot + .assertMessageWithLink('Look at $youtubeLink', link: youtubeLink) + .assertLinkPreviewInMessageList(isDisplayed: false); + }, + ); + + streamTestWithEnv( + description: 'participant sends a YouTube link', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends a YouTube url'); + await env.participantRobot.sendMessage('Look at $youtubeLink'); + + step('THEN the user observes the message with a link preview'); + await env.userRobot + .assertMessageWithLink('Look at $youtubeLink', link: youtubeLink) + .assertLinkPreviewInMessageList(isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'user sends a Giphy link with a preview', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user types a Giphy url'); + await env.userRobot.typeText('Look at $giphyLink'); + + step('THEN the user observes a link preview in the composer'); + await env.userRobot.assertLinkPreviewInComposer(isDisplayed: true); + + step('WHEN the user taps on the send button'); + await env.userRobot.tapOnSendButton(); + + step('THEN the user observes the message with a link preview'); + await env.userRobot + .assertMessageWithLink('Look at $giphyLink', link: giphyLink) + .assertLinkPreviewInMessageList(isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'user sends a Giphy link without a preview', + skip: enrichedAfterDismissal, + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user types a Giphy url'); + await env.userRobot.typeText('Look at $giphyLink').assertLinkPreviewInComposer(isDisplayed: true); + + step('AND the user cancels the link preview'); + await env.userRobot.tapOnLinkPreviewCancelButton(); + + step('THEN the link preview disappears'); + await env.userRobot.assertLinkPreviewInComposer(isDisplayed: false); + + step('WHEN the user taps on the send button'); + await env.userRobot.tapOnSendButton(); + + step('THEN the user observes the message without a link preview'); + await env.userRobot + .assertMessageWithLink('Look at $giphyLink', link: giphyLink) + .assertLinkPreviewInMessageList(isDisplayed: false); + }, + ); + + streamTestWithEnv( + description: 'participant sends a Giphy link', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends a Giphy url'); + await env.participantRobot.sendMessage('Look at $giphyLink'); + + step('THEN the user observes the message with a link preview'); + await env.userRobot + .assertMessageWithLink('Look at $giphyLink', link: giphyLink) + .assertLinkPreviewInMessageList(isDisplayed: true); + }, + ); +} diff --git a/e2e_app/integration_test/message_actions_test.dart b/e2e_app/integration_test/message_actions_test.dart new file mode 100644 index 0000000000..a5a66d9429 --- /dev/null +++ b/e2e_app/integration_test/message_actions_test.dart @@ -0,0 +1,25 @@ +import 'robots/user_robot.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + const sampleText = 'Test'; + + streamTestWithEnv( + description: 'user copies a message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + + step('AND the user copies the message'); + await env.userRobot.assertMessage(sampleText).copyMessage(); + + step('THEN the message text is in the clipboard'); + await env.userRobot.assertMessageCopied(sampleText); + }, + ); +} diff --git a/e2e_app/integration_test/message_delivery_status_test.dart b/e2e_app/integration_test/message_delivery_status_test.dart new file mode 100644 index 0000000000..f64f7c6780 --- /dev/null +++ b/e2e_app/integration_test/message_delivery_status_test.dart @@ -0,0 +1,819 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'mock_server/data_types.dart'; +import 'pages/message_list_page.dart'; +import 'robots/participant_robot.dart'; +import 'robots/user_robot.dart'; +import 'robots/user_robot_channel_list_asserts.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + const sampleText = 'Test'; + const threadReply = 'thread reply'; + const newParticipantId = 'leia_organa'; + + // MARK: Message list + + streamTestWithEnv( + description: 'single checkmark is shown when a message is sent', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a new message'); + await env.userRobot.sendMessage(sampleText); + + step('THEN the user spots a single checkmark below the message'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + }, + ); + + streamTestWithEnv( + description: 'delivery status shows clocks when a message is pending', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a new message that is held by the server'); + // The mock server's `freeze_messages` answers 408 at once, which Flutter + // treats as a failed send; holding the response keeps it pending instead. + await env.backendRobot.delayNewMessages(); + await env.userRobot.sendMessage('pending message'); + + step('THEN the message delivery status shows clocks'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.pending); + // Let the held send complete, so it can't land in the next test. + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + }, + ); + + streamTestWithEnv( + description: 'error indicator is shown when a message fails to be sent', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a new message that is going to fail'); + await env.backendRobot.failNewMessages(); + await env.userRobot.sendMessage('failed message'); + await env.takeExpectedError(); + + step('THEN an error indicator is shown for the message'); + await env.userRobot.assertMessageFailedToBeSent(); + }, + ); + + streamTestWithEnv( + description: 'double checkmark is shown when the participant reads a message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user successfully sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step("WHEN the participant reads the user's message"); + await env.participantRobot.readMessage(); + + step('THEN the user spots a double checkmark below the message'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.read); + }, + ); + + streamTestWithEnv( + description: 'delivered checkmark turns read when the participant reads the message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user successfully sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the message is delivered to the participant but not read'); + await env.participantRobot.markAsDelivered(); + + step('THEN the message delivery status shows it as delivered'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.delivered); + + step('WHEN the participant reads the message'); + await env.participantRobot.readMessage(); + + step('THEN the message delivery status shows it as read'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.read); + }, + ); + + streamTestWithEnv( + description: 'delivery status is shown for the last message in a group', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user successfully sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(MessageDeliveryStatus.sent, count: 1); + + step('WHEN the user sends another message'); + await env.userRobot.sendMessage('$sampleText 2').assertMessage('$sampleText 2'); + + step('THEN the delivery status of the previous message is hidden'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent, count: 1); + }, + ); + + streamTestWithEnv( + // The native suites disagree: Android and UIKit hide the status of a deleted + // message, SwiftUI keeps it (`test_deliveryStatusShown_whenMessageIsDeleted`). + description: 'delivery status is shown when a message is deleted', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user successfully sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('WHEN the user deletes the message'); + await env.userRobot.deleteMessage().assertDeletedMessage(); + + step('THEN the delivery status is still shown'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + }, + ); + + streamTestWithEnv( + description: 'delivery status is shown for the previous message when an error message is shown', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user successfully sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('WHEN the user sends a message with an invalid command'); + await env.userRobot.sendMessage('/command').assertInvalidCommandMessage('command'); + + step('THEN the delivery status is shown for the first message'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent, count: 1); + }, + ); + + streamTestWithEnv( + description: 'delivery status is shown for the previous message when a new message fails to be sent', + skip: 'https://linear.app/stream/issue/FLU-1090', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user successfully sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('WHEN the user sends a message that is going to fail'); + await env.backendRobot.failNewMessages(); + await env.userRobot.sendMessage('failed message'); + await env.takeExpectedError(); + + step('THEN an error indicator is shown for the failed message'); + await env.userRobot.assertMessageFailedToBeSent(); + + step('AND the delivery status is shown for the previous message'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent, messageIndex: 1); + }, + ); + + streamTestWithEnv( + description: 'message that failed to be sent cannot be previewed in a thread', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user becomes offline'); + await env.goOffline(); + + step('WHEN the user sends a new message'); + await env.userRobot.sendMessage('failed message'); + await env.takeExpectedError(); + + step('THEN an error indicator is shown for the message'); + await env.userRobot.assertMessageFailedToBeSent().assertMessageDeliveryStatus(MessageDeliveryStatus.nil); + + step('AND the user cannot preview the message in a thread'); + expect(await env.userRobot.hasMessageAction(MessageListPage.actions.threadReply), isFalse); + + await env.goOnline(); + }, + ); + + streamTestWithEnv( + description: 'double checkmark is still shown when a new participant is added', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user successfully sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the participant reads the message'); + await env.participantRobot.readMessage(); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.read); + + step('WHEN a new participant is added to the channel'); + await env.backendRobot.addMember(newParticipantId); + + step('THEN the user still spots a double checkmark below the message'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.read); + }, + ); + + streamTestWithEnv( + description: 'read status is dropped when the participant is removed', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user successfully sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the participant reads the message'); + await env.participantRobot.readMessage(); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.read); + + step('WHEN the participant is removed from the channel'); + await env.backendRobot.removeMember(ParticipantRobot.id); + + step('THEN the user observes a single checkmark below the message'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + }, + ); + + // MARK: Threads + + streamTestWithEnv( + description: 'single checkmark is shown when a message is sent and previewed in a thread', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user successfully sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('WHEN the user previews the thread of the message'); + await env.userRobot.openThread(); + + step('THEN the user spots a single checkmark below the message'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('WHEN the participant reads the message'); + await env.participantRobot.readMessage(); + + step('THEN the user spots a double checkmark below the message'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.read); + }, + ); + + streamTestWithEnv( + description: 'single checkmark is shown when a thread reply is sent', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends a new message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the user replies to the message in a thread'); + await env.userRobot.openThread().sendMessageInThread(threadReply); + + step('THEN the user spots a single checkmark below the thread reply'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + }, + ); + + streamTestWithEnv( + description: 'error indicator is shown when a thread reply fails to be sent', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a thread reply that is going to fail'); + await env.backendRobot.failNewMessages(); + await env.userRobot.openThread().sendMessageInThread(sampleText); + await env.takeExpectedError(); + + step('THEN an error indicator is shown for the thread reply'); + await env.userRobot.assertMessageFailedToBeSent(); + }, + ); + + streamTestWithEnv( + description: 'double checkmark is shown when the participant reads a thread reply', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('WHEN the user replies to the message in a thread'); + await env.userRobot.openThread().sendMessageInThread(sampleText); + + step('AND the thread reply is delivered'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step("AND the participant reads the user's thread reply"); + await env.participantRobot.readMessage(); + + step('THEN the user spots a double checkmark below the thread reply'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.read); + }, + ); + + streamTestWithEnv( + description: 'double checkmark is shown when the participant reads a thread reply also sent to the channel', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a new message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the user sends a thread reply also to the channel'); + await env.userRobot.openThread().sendMessageInThread(threadReply, alsoSendInChannel: true); + + step('AND the thread reply delivery status shows a single checkmark'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('WHEN the participant reads the thread reply'); + await env.participantRobot.readMessage(); + + step('THEN the thread reply delivery status shows a double checkmark'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.read); + + step('AND the thread reply delivery status shows a double checkmark in the channel'); + await env.userRobot.tapBackButton().assertMessageDeliveryStatus(MessageDeliveryStatus.read); + }, + ); + + streamTestWithEnv( + description: 'delivery status is shown for the last thread reply in a group', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('WHEN the user replies to the message in a thread'); + await env.userRobot.openThread().sendMessageInThread(sampleText); + + step('AND the delivery status shows a single checkmark'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent, count: 2); + + step('AND the user sends another thread reply'); + await env.userRobot.sendMessageInThread('$sampleText 2').assertMessage('$sampleText 2'); + + step('THEN the delivery status of the previous thread reply is hidden'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent, count: 2); + }, + ); + + streamTestWithEnv( + // SwiftUI's `test_deliveryStatusShown_whenThreadReplyIsDeleted`; see the + // deleted message case above. + description: 'delivery status is shown when a thread reply is deleted', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('AND the user successfully sends a thread reply'); + await env.userRobot.openThread().sendMessageInThread(sampleText); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent, count: 2); + + step('WHEN the user deletes the thread reply'); + await env.userRobot.deleteMessage().assertDeletedMessage(); + + step('THEN the delivery status is still shown for the thread reply'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent, count: 2); + }, + ); + + streamTestWithEnv( + description: 'thread reply has no double checkmark when a new participant is added', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a new message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the user replies to the message in a thread'); + await env.userRobot.openThread().sendMessageInThread(threadReply); + + step('WHEN a new participant is added to the channel'); + await env.backendRobot.addMember(newParticipantId); + + step('THEN the user spots a single checkmark below the thread reply'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + }, + ); + + streamTestWithEnv( + description: 'read status of a thread reply is dropped when the participant is removed', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('AND the user replies to the message in a thread'); + await env.userRobot.openThread().sendMessageInThread(sampleText); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the participant reads the thread reply'); + await env.participantRobot.readMessage(); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.read); + + step('WHEN the participant is removed from the channel'); + await env.backendRobot.removeMember(ParticipantRobot.id); + + step('THEN the user observes a single checkmark below the thread reply'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + }, + ); + + // MARK: Channel preview + + streamTestWithEnv( + description: 'channel preview shows clocks when the last message is pending', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a new message that is held by the server'); + // The mock server's `freeze_messages` answers 408 at once, which Flutter + // treats as a failed send; holding the response keeps it pending instead. + await env.backendRobot.delayNewMessages(); + await env.userRobot.sendMessage(sampleText); + + step('AND the user returns to the channel list'); + await env.userRobot.tapBackButton(); + + step('THEN the channel preview shows clocks'); + await env.userRobot.assertMessagePreviewDeliveryStatus(MessageDeliveryStatus.pending); + // Let the held send complete, so it can't land in the next test. + await env.userRobot.assertMessagePreviewDeliveryStatus(MessageDeliveryStatus.sent); + }, + ); + + streamTestWithEnv( + description: 'channel preview shows a single checkmark when the last message is sent', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('WHEN the user returns to the channel list'); + await env.userRobot.tapBackButton(); + + step('THEN the channel preview shows a single checkmark'); + await env.userRobot.assertMessagePreviewDeliveryStatus(MessageDeliveryStatus.sent); + }, + ); + + streamTestWithEnv( + description: 'channel preview shows an error indicator when the message fails to be sent', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a new message that is going to fail'); + await env.backendRobot.failNewMessages(); + await env.userRobot.sendMessage(sampleText); + await env.takeExpectedError(); + + step('AND the user returns to the channel list'); + await env.userRobot.tapBackButton(); + + step('THEN the channel preview shows an error indicator'); + await env.userRobot.assertMessagePreviewDeliveryStatus(MessageDeliveryStatus.failed); + }, + ); + + streamTestWithEnv( + description: 'channel preview shows a double checkmark when the participant reads the message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('WHEN the user returns to the channel list'); + await env.userRobot.tapBackButton(); + + step('AND the participant reads the message'); + await env.participantRobot.readMessage(); + + step('THEN the channel preview shows a double checkmark'); + await env.userRobot.assertMessagePreviewDeliveryStatus(MessageDeliveryStatus.read); + }, + ); + + streamTestWithEnv( + description: 'channel preview shows no delivery status when the participant sends the message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends a new message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the user returns to the channel list'); + await env.userRobot.tapBackButton(); + + step('THEN the delivery status is hidden'); + await env.userRobot + .assertMessageInChannelPreview(sampleText, fromCurrentUser: false) + .assertMessagePreviewDeliveryStatus(MessageDeliveryStatus.nil); + }, + ); + + streamTestWithEnv( + description: 'channel preview shows no delivery status when the user sends a thread reply', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a new message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the user replies to the message in a thread'); + await env.userRobot.openThread().sendMessageInThread(threadReply).assertThreadReply(threadReply); + + step('WHEN the user returns to the channel list'); + await env.userRobot.moveToChannelListFromThread(); + + step('THEN the delivery status is hidden'); + await env.userRobot + .assertMessageInChannelPreview(sampleText, fromCurrentUser: false) + .assertMessagePreviewDeliveryStatus(MessageDeliveryStatus.nil); + }, + ); + + streamTestWithEnv( + description: 'channel preview shows a single checkmark when a thread reply is pending', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user sends a new message'); + await env.userRobot.sendMessage('1').assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the user sends a thread reply that is held by the server'); + await env.userRobot.openThread(); + // The mock server's `freeze_messages` answers 408 at once, which Flutter + // treats as a failed send; holding the response keeps it pending instead. + await env.backendRobot.delayNewMessages(); + await env.userRobot.sendMessageInThread('2'); + + step('WHEN the user returns to the channel list'); + await env.userRobot.moveToChannelListFromThread(); + + step('THEN the channel preview shows a single checkmark'); + await env.userRobot.assertMessagePreviewDeliveryStatus(MessageDeliveryStatus.sent); + // Let the held thread reply complete, so it can't land in the next test. + await env.userRobot.openChannel().openThreadFromReplies().assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + }, + ); + + streamTestWithEnv( + description: 'channel preview shows no delivery status when the participant reads a thread reply', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a new message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the user replies to the message in a thread'); + await env.userRobot.openThread().sendMessageInThread(threadReply).assertThreadReply(threadReply); + + step("AND the participant reads the user's thread reply"); + await env.participantRobot.readMessage(); + + step('WHEN the user returns to the channel list'); + await env.userRobot.moveToChannelListFromThread(); + + step('THEN the delivery status is hidden'); + await env.userRobot.assertMessagePreviewDeliveryStatus(MessageDeliveryStatus.nil); + }, + ); + + streamTestWithEnv( + description: 'channel preview shows no delivery status when the participant sends a thread reply', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a new message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the participant replies to the message in a thread'); + await env.participantRobot.sendMessageInThread(threadReply); + await env.userRobot.assertThreadReplyLabel(replies: 1); + + step('WHEN the user returns to the channel list'); + await env.userRobot.tapBackButton(); + + step('THEN the delivery status is hidden'); + await env.userRobot.assertMessagePreviewDeliveryStatus(MessageDeliveryStatus.nil); + }, + ); + + // MARK: Read events disabled + + streamTestWithEnv( + description: 'delivery status is hidden when a message is sent and read events are disabled', + skip: 'https://linear.app/stream/issue/FLU-1091', + body: (env) async { + step('GIVEN read events are disabled and the user opens a channel'); + await env.backendRobot.setReadEvents(enabled: false); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); + + step('THEN the delivery status is hidden'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.nil); + }, + ); + + streamTestWithEnv( + description: 'delivery status shows clocks when a message is pending and read events are disabled', + body: (env) async { + step('GIVEN read events are disabled and the user opens a channel'); + await env.backendRobot.setReadEvents(enabled: false); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a new message that is held by the server'); + // The mock server's `freeze_messages` answers 408 at once, which Flutter + // treats as a failed send; holding the response keeps it pending instead. + await env.backendRobot.delayNewMessages(); + await env.userRobot.sendMessage('pending message'); + + step('THEN the message delivery status shows clocks'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.pending); + // Let the held send complete, so it can't land in the next test. + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + }, + ); + + streamTestWithEnv( + description: 'error indicator is shown when a message fails to be sent and read events are disabled', + body: (env) async { + step('GIVEN read events are disabled and the user opens a channel'); + await env.backendRobot.setReadEvents(enabled: false); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a new message that is going to fail'); + await env.backendRobot.failNewMessages(); + await env.userRobot.sendMessage('failed message'); + await env.takeExpectedError(); + + step('THEN an error indicator is shown for the message'); + await env.userRobot.assertMessageFailedToBeSent(); + }, + ); + + streamTestWithEnv( + description: 'delivery status is hidden when the participant reads a message and read events are disabled', + skip: 'https://linear.app/stream/issue/FLU-1091', + body: (env) async { + step('GIVEN read events are disabled and the user opens a channel'); + await env.backendRobot.setReadEvents(enabled: false); + await env.userRobot.login().openChannel(); + + step('AND the user sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); + + step('WHEN the participant reads the message'); + await env.participantRobot.readMessage(); + + step('THEN the delivery status is hidden'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.nil); + }, + ); + + streamTestWithEnv( + description: 'delivery status is hidden when a new participant is added and read events are disabled', + skip: 'https://linear.app/stream/issue/FLU-1091', + body: (env) async { + step('GIVEN read events are disabled and the user opens a channel'); + await env.backendRobot.setReadEvents(enabled: false); + await env.userRobot.login().openChannel(); + + step('AND the user sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); + + step('AND the participant reads the message'); + await env.participantRobot.readMessage(); + + step('WHEN a new participant is added to the channel'); + await env.backendRobot.addMember(newParticipantId); + + step('THEN the delivery status is hidden'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.nil); + }, + ); + + streamTestWithEnv( + description: 'delivery status is hidden when the participant is removed and read events are disabled', + skip: 'https://linear.app/stream/issue/FLU-1091', + body: (env) async { + step('GIVEN read events are disabled and the user opens a channel'); + await env.backendRobot.setReadEvents(enabled: false); + await env.userRobot.login().openChannel(); + + step('AND the user sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); + + step('AND the participant reads the message'); + await env.participantRobot.readMessage(); + + step('WHEN the participant is removed from the channel'); + await env.backendRobot.removeMember(ParticipantRobot.id); + + step('THEN the delivery status is hidden'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.nil); + }, + ); + + streamTestWithEnv( + description: 'delivery status is hidden for grouped messages when read events are disabled', + skip: 'https://linear.app/stream/issue/FLU-1091', + body: (env) async { + step('GIVEN read events are disabled and the user opens a channel'); + await env.backendRobot.setReadEvents(enabled: false); + await env.userRobot.login().openChannel(); + + step('AND the user sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); + + step('WHEN the user sends another message'); + await env.userRobot.sendMessage('second message').assertMessage('second message'); + + step('THEN the delivery status is hidden for all messages'); + await env.userRobot + .assertMessageDeliveryStatus(MessageDeliveryStatus.nil) + .assertMessageDeliveryStatus(MessageDeliveryStatus.nil, messageIndex: 1); + }, + ); + + streamTestWithEnv( + description: 'delivery status is hidden when a message is deleted and read events are disabled', + skip: 'https://linear.app/stream/issue/FLU-1091', + body: (env) async { + step('GIVEN read events are disabled and the user opens a channel'); + await env.backendRobot.setReadEvents(enabled: false); + await env.userRobot.login().openChannel(); + + step('AND the user sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); + + step('AND the delivery status is hidden'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.nil); + + step('WHEN the user deletes the message'); + await env.userRobot.deleteMessage().assertDeletedMessage(); + + step('THEN the delivery status stays hidden'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.nil); + }, + ); + + streamTestWithEnv( + description: 'channel preview shows no delivery status when read events are disabled', + skip: 'https://linear.app/stream/issue/FLU-1091', + body: (env) async { + step('GIVEN read events are disabled and the user opens a channel'); + await env.backendRobot.setReadEvents(enabled: false); + await env.userRobot.login().openChannel(); + + step('AND the user sends a new message'); + await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); + + step('WHEN the user returns to the channel list'); + await env.userRobot.tapBackButton(); + + step('THEN the delivery status is hidden in the channel preview'); + await env.userRobot + .assertMessageInChannelPreview(sampleText, fromCurrentUser: true) + .assertMessagePreviewDeliveryStatus(MessageDeliveryStatus.nil); + }, + ); +} diff --git a/e2e_app/integration_test/message_list_test.dart b/e2e_app/integration_test/message_list_test.dart new file mode 100644 index 0000000000..a435236dcd --- /dev/null +++ b/e2e_app/integration_test/message_list_test.dart @@ -0,0 +1,1177 @@ +import 'mock_server/data_types.dart'; +import 'robots/participant_robot.dart'; +import 'robots/user_robot.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + const sampleText = 'Test'; + // A message long enough to wrap across several lines, so editing to/from it + // produces a real change in cell height (single newlines collapse in markdown). + const longText = + 'This is a much longer message that will certainly wrap across several lines ' + 'inside the chat bubble, making the message cell noticeably taller than a short one.'; + + // MARK: Message sending + + streamTestWithEnv( + allureId: '11188', + description: 'message list updates when the user sends a message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a message'); + await env.userRobot.sendMessage(sampleText); + + step('THEN the message is displayed'); + await env.userRobot.assertMessage(sampleText); + }, + ); + + streamTestWithEnv( + allureId: '11346', + description: 'message list updates when the participant sends a message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + + step('THEN the message is displayed'); + await env.userRobot.assertMessage(sampleText); + }, + ); + + streamTestWithEnv( + allureId: '11336', + description: 'user sends a message with one emoji', + body: (env) async { + const message = '🍏'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends the emoji: $message'); + await env.userRobot.sendMessage(message); + + step('THEN the message is delivered'); + await env.userRobot.assertMessage(message); + }, + ); + + streamTestWithEnv( + allureId: '11347', + description: 'user sends a message with multiple emojis', + body: (env) async { + const message = '🍏🙂👍'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a message with multiple emojis: $message'); + await env.userRobot.sendMessage(message); + + step('THEN the message is delivered'); + await env.userRobot.assertMessage(message); + }, + ); + + streamTestWithEnv( + allureId: '11339', + description: 'user receives a message with an emoji', + body: (env) async { + const message = '🚢'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends the emoji: $message'); + await env.participantRobot.sendMessage(message); + + step('THEN the message is delivered'); + await env.userRobot.assertMessage(message); + }, + ); + + streamTestWithEnv( + allureId: '11345', + description: 'user sends a message with multiple lines', + body: (env) async { + const message = 'alpha\nbeta\ngamma'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a message with multiple lines'); + await env.userRobot.sendMessage(message); + + step('THEN each line of the message is displayed'); + // The markdown renderer collapses single newlines, so assert on the + // individual line tokens rather than the literal multi-line string. + await env.userRobot.assertMessageContains('alpha').assertMessageContains('gamma'); + }, + ); + + // MARK: Message editing + + streamTestWithEnv( + allureId: '11337', + description: 'user edits a message', + body: (env) async { + const editedMessage = 'hello'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends the message'); + await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); + + step('AND the user edits the message'); + await env.userRobot.editMessage(editedMessage); + + step('THEN the message is edited'); + await env.userRobot.assertEditedMessage(editedMessage); + }, + ); + + streamTestWithEnv( + allureId: '11341', + description: 'user sees a message edited by the participant', + body: (env) async { + const editedMessage = 'hello'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends the message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the participant edits the message'); + await env.participantRobot.editMessage(editedMessage); + + step('THEN the message is edited'); + await env.userRobot.assertEditedMessage(editedMessage); + }, + ); + + // MARK: Typing indicator + + streamTestWithEnv( + allureId: '11343', + description: 'user observes the typing indicator', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('WHEN the participant starts typing'); + await env.participantRobot.startTyping(); + + step('THEN the typing indicator is shown'); + await env.userRobot.assertTypingIndicator(isDisplayed: true); + + step('WHEN the participant stops typing'); + await env.participantRobot.stopTyping(); + + step('THEN the typing indicator disappears'); + await env.userRobot.assertTypingIndicator(isDisplayed: false); + }, + ); + + // MARK: Offline + + streamTestWithEnv( + allureId: '11338', + description: 'user sees the participant message received while offline', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user becomes offline'); + await env.goOffline(); + + step('WHEN the participant sends a new message'); + await env.participantRobot.sendMessage(sampleText); + + step('AND the user becomes online'); + await env.goOnline(); + + step('THEN the user observes the new message from the participant'); + await env.userRobot.assertMessage(sampleText); + }, + ); + + // Both offline cases run with persistence on, mirroring the native + // `setIsLocalStorageEnabled(to: .on)`. + streamTestWithEnv( + allureId: '11527', + description: 'user adds a message while offline', + persistence: true, + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user becomes offline'); + await env.goOffline(); + + step('WHEN the user sends a new message'); + await env.userRobot.sendMessage(sampleText); + + step('THEN the message is shown as failed to be sent'); + // The send is optimistic, so the text shows up right away; the SDK marks + // the message failed once its retries are exhausted. + await env.userRobot.assertMessage(sampleText).assertMessageFailedToBeSent(); + // The composer reports the failed send through FlutterError, which the + // failed bubble asserted above already covers. + await env.takeExpectedError(); + + step('WHEN the user becomes online'); + await env.goOnline(); + + step('THEN the message is delivered'); + // The channel's retry queue replays failed messages on + // `connection.recovered`, which flips the status to sent. + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + }, + ); + + streamTestWithEnv( + allureId: '11526', + description: 'user recovers a message received while in the background', + persistence: true, + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user goes to the background'); + await env.moveToBackground(); + + step('WHEN the participant sends a new message'); + // Nothing is pumped between here and the foregrounding — see + // `StreamTestEnv.moveToBackground`. + await env.participantRobot.sendMessage(sampleText); + + step('AND the user comes back to the foreground'); + await env.moveToForeground(); + + step('THEN the new message is delivered'); + await env.userRobot.assertMessage(sampleText); + }, + ); + + // MARK: Thread replies + + streamTestWithEnv( + allureId: '11350', + description: 'thread reply appears in the thread when the participant adds it', + body: (env) async { + const threadReply = 'thread reply'; + + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('WHEN the participant adds a thread reply'); + await env.participantRobot.sendMessageInThread(threadReply); + + step('AND the user opens the thread'); + await env.userRobot.openThread(); + + step('THEN the user observes the thread reply in the thread'); + await env.userRobot.assertThreadReply(threadReply); + }, + ); + + streamTestWithEnv( + allureId: '11492', + description: 'thread reply also sent to the channel appears in both places', + body: (env) async { + const threadReply = 'thread reply'; + + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('WHEN the participant adds a thread reply and also sends it to the channel'); + await env.participantRobot.sendMessageInThread(threadReply, alsoSendInChannel: true); + + step('THEN the user observes the thread reply in the channel'); + await env.userRobot.assertMessage(threadReply); + + step('AND the user observes the thread reply in the thread'); + // The in-channel copy is the newest message and has no 'Thread Reply' + // action, so the thread is opened from the parent's replies footer. + await env.userRobot.openThreadFromReplies().assertThreadReply(threadReply); + }, + ); + + streamTestWithEnv( + allureId: '11329', + description: 'thread typing indicator hides when the participant stops typing', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('AND the user opens the thread'); + await env.userRobot.openThread(); + + step('WHEN the participant starts typing in the thread'); + await env.participantRobot.startTypingInThread(); + + step('THEN the typing indicator is shown'); + await env.userRobot.assertTypingIndicator(isDisplayed: true); + + step('WHEN the participant stops typing in the thread'); + await env.participantRobot.stopTypingInThread(); + + step('THEN the typing indicator disappears'); + await env.userRobot.assertTypingIndicator(isDisplayed: false); + }, + ); + + streamTestWithEnv( + description: 'thread reply also sent to the channel by the user appears in both places', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('WHEN the user adds a thread reply and also sends it to the channel'); + await env.userRobot.openThread().sendMessageInThread(sampleText, alsoSendInChannel: true); + + step('THEN the user observes the thread reply in the thread'); + await env.userRobot.assertThreadReply(sampleText).assertAlsoInTheChannelLabel(inThread: true); + + step('AND the user observes the thread reply in the channel'); + await env.userRobot.tapBackButton().assertMessage(sampleText).assertAlsoInTheChannelLabel(inThread: false); + }, + ); + + // MARK: Deleted messages + + streamTestWithEnv( + allureId: '11332', + description: 'user deletes a message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends the message'); + await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); + + step('AND the user deletes the message'); + await env.userRobot.deleteMessage(); + + step('THEN the message is deleted'); + await env.userRobot.assertDeletedMessage(); + }, + ); + + streamTestWithEnv( + allureId: '11333', + description: 'user sees a message deleted by the participant', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends the message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the participant deletes the message'); + await env.participantRobot.deleteMessage(); + + step('THEN the message is deleted'); + await env.userRobot.assertDeletedMessage(); + }, + ); + + streamTestWithEnv( + allureId: '11335', + description: 'user sees a message hard-deleted by the participant', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends the message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the participant hard-deletes the message'); + await env.participantRobot.deleteMessage(hard: true); + + step('THEN the message is hard-deleted'); + await env.userRobot.assertHardDeletedMessage(sampleText); + }, + ); + + streamTestWithEnv( + description: 'thread reply is removed everywhere when the participant removes it from the channel', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('AND the participant adds a thread reply and also sends it to the channel'); + await env.participantRobot.sendMessageInThread(sampleText, alsoSendInChannel: true); + await env.userRobot.assertMessage(sampleText); + + step('WHEN the participant removes the thread reply'); + await env.participantRobot.deleteMessage(); + + step('THEN the user observes the thread reply removed in the channel'); + await env.userRobot.assertDeletedMessage().assertAlsoInTheChannelLabel(inThread: false); + + step('AND the user observes the thread reply removed in the thread'); + await env.userRobot.openThreadFromReplies().assertDeletedMessage().assertAlsoInTheChannelLabel(inThread: true); + }, + ); + + streamTestWithEnv( + description: 'thread reply is removed everywhere when the user removes it from the channel', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('AND the user adds a thread reply and also sends it to the channel'); + await env.userRobot.openThread().sendMessageInThread(sampleText, alsoSendInChannel: true); + await env.userRobot.assertThreadReply(sampleText); + + step('WHEN the user removes the thread reply from the channel'); + await env.userRobot.tapBackButton().assertMessage(sampleText).deleteMessage(); + + step('THEN the user observes the thread reply removed in the channel'); + await env.userRobot.assertDeletedMessage().assertAlsoInTheChannelLabel(inThread: false); + + step('AND the user observes the thread reply removed in the thread'); + await env.userRobot.openThreadFromReplies().assertDeletedMessage().assertAlsoInTheChannelLabel(inThread: true); + }, + ); + + streamTestWithEnv( + description: 'thread reply is removed everywhere when the user removes it from the thread', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('AND the user adds a thread reply and also sends it to the channel'); + await env.userRobot.openThread().sendMessageInThread(sampleText, alsoSendInChannel: true); + await env.userRobot.assertThreadReply(sampleText); + + step('WHEN the user removes the thread reply from the thread'); + await env.userRobot.deleteMessage(); + + step('THEN the user observes the thread reply removed in the thread'); + await env.userRobot.assertDeletedMessage().assertAlsoInTheChannelLabel(inThread: true); + + step('AND the user observes the thread reply removed in the channel'); + await env.userRobot.tapBackButton().assertDeletedMessage().assertAlsoInTheChannelLabel(inThread: false); + }, + ); + + streamTestWithEnv( + description: 'participant removes a thread reply', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('AND the participant adds a thread reply'); + await env.participantRobot.sendMessageInThread(sampleText); + await env.userRobot.assertThreadReplyLabel(replies: 1); + + step('WHEN the participant removes the thread reply'); + await env.participantRobot.deleteMessage(); + + step('THEN the user observes the thread reply removed in the thread'); + await env.userRobot + .assertThreadReplyLabel(replies: 1) + .assertThreadReplyLabelAvatars(count: 1) + .openThreadFromReplies() + .assertDeletedMessage(); + }, + ); + + streamTestWithEnv( + description: 'user removes a thread reply', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('AND the user adds a thread reply'); + await env.userRobot.openThread().sendMessageInThread(sampleText); + await env.userRobot.assertThreadReply(sampleText); + + step('WHEN the user removes the thread reply'); + await env.userRobot.deleteMessage(); + + step('THEN the user observes the thread reply removed in the thread'); + await env.userRobot.assertDeletedMessage(); + + step('AND the user observes the thread reply count on the parent message'); + await env.userRobot.tapBackButton().assertThreadReplyLabel(replies: 1).assertThreadReplyLabelAvatars(count: 1); + }, + ); + + streamTestWithEnv( + description: 'thread is not locked after the user deletes the parent message', + skip: 'https://linear.app/stream/issue/FLU-1092', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('AND the participant adds a thread reply'); + await env.participantRobot.sendMessageInThread(sampleText); + await env.userRobot.assertThreadReplyLabel(replies: 1); + + step('WHEN the user deletes the parent message'); + await env.userRobot.deleteMessage(); + + step('THEN the thread is not locked'); + await env.userRobot.openThreadFromReplies().assertThreadReply(sampleText); + }, + ); + + streamTestWithEnv( + description: 'thread is not locked after the participant deletes the parent message', + skip: 'https://linear.app/stream/issue/FLU-1092', + body: (env) async { + const threadReply = 'thread reply'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the user sends a message in the thread'); + await env.userRobot.openThread().sendMessageInThread(threadReply).assertThreadReply(threadReply).tapBackButton(); + + step('WHEN the participant deletes the parent message'); + await env.participantRobot.deleteMessage(); + await env.userRobot.assertDeletedMessage(); + + step('THEN the thread is not locked'); + await env.userRobot.openThreadFromReplies().assertThreadReply(threadReply); + }, + ); + + streamTestWithEnv( + description: 'user hard-deletes a message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a message'); + await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); + + step('AND the user hard-deletes the message'); + await env.userRobot.deleteMessage(hard: true); + + step('THEN the message is hard-deleted'); + await env.userRobot.assertHardDeletedMessage(sampleText); + }, + ); + + streamTestWithEnv( + description: 'user hard-deletes a message in a channel with history', + body: (env) async { + const message = 'test message'; + + step('GIVEN the user opens a channel with a message'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a message'); + await env.userRobot.sendMessage(message).assertMessage(message); + + step('AND the user hard-deletes the message'); + await env.userRobot.deleteMessage(hard: true); + + step('THEN the message is hard-deleted'); + await env.userRobot.assertHardDeletedMessage(message); + }, + ); + + // MARK: Mentions + + streamTestWithEnv( + allureId: '11349', + description: 'user sees the mentions overlay when typing @', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user types @'); + await env.userRobot.typeText('@'); + + step('THEN the mentions overlay appears'); + await env.userRobot.assertMentionsOverlay(isDisplayed: true); + + step('WHEN the user removes @'); + await env.userRobot.clearComposer(); + + step('THEN the mentions overlay disappears'); + await env.userRobot.assertMentionsOverlay(isDisplayed: false); + }, + ); + + streamTestWithEnv( + description: 'user fills the composer by mentioning the participant through the mentions overlay', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step("WHEN the user taps on the participant's name"); + await env.userRobot.mentionParticipant(send: false); + + step("THEN the composer fills in the participant's name"); + await env.userRobot.assertMentionWasApplied(); + }, + ); + + // MARK: Commands + + streamTestWithEnv( + description: 'selecting a command hides the attachments button', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user observes the attachments button'); + await env.userRobot.assertComposerAttachmentsButton(isDisplayed: true); + + step('WHEN the user selects the giphy command from the suggestions'); + await env.userRobot.openComposerCommands().assertComposerCommands(isDisplayed: true).selectCommand('giphy'); + + step('THEN the attachments button disappears'); + await env.userRobot.assertComposerAttachmentsButton(isDisplayed: false); + }, + ); + + streamTestWithEnv( + description: 'command suggestions disappear when the user taps on the message list', + skip: 'https://linear.app/stream/issue/FLU-1093', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 30); + await env.userRobot.login().openChannel(); + + step('AND the user opens the command suggestions'); + await env.userRobot.openComposerCommands().assertComposerCommands(isDisplayed: true); + + step('WHEN the user taps on the message list'); + await env.userRobot.tapOnMessageList(); + + step('THEN the command suggestions disappear'); + await env.userRobot.assertComposerCommands(isDisplayed: false); + }, + ); + + // MARK: Scroll to bottom + + streamTestWithEnv( + allureId: '11334', + description: 'message list scrolls down when the user receives a new message at the bottom', + body: (env) async { + const newMessage = 'New message'; + + step('GIVEN the user opens a channel with history'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 30); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends a message'); + await env.participantRobot.sendMessage(newMessage); + + step('THEN the new message is visible'); + await env.userRobot.assertMessage(newMessage); + }, + ); + + streamTestWithEnv( + allureId: '11348', + description: 'message list does not scroll down when scrolled up and the user receives a message', + body: (env) async { + const newMessage = 'New message'; + + step('GIVEN the user opens a channel with history'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 30); + await env.userRobot.login().openChannel(); + + step('WHEN the user scrolls up'); + await env.userRobot.scrollMessageListUp(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(newMessage); + + step('THEN the new message is not visible'); + await env.userRobot.assertMessage(newMessage, isDisplayed: false); + }, + ); + + streamTestWithEnv( + description: 'message list scrolls down when scrolled up and the user sends a message', + body: (env) async { + step('GIVEN the user opens a channel with history'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 30); + await env.userRobot.login().openChannel(); + + step('WHEN the user scrolls up'); + await env.userRobot.scrollMessageListUp().assertScrollToBottomButton(isDisplayed: true); + + step('AND the user sends a new message'); + await env.userRobot.sendMessage(sampleText); + + step('THEN the message list is scrolled down'); + await env.userRobot.assertScrollToBottomButton(isDisplayed: false).assertMessageOnScreen(sampleText); + }, + ); + + streamTestWithEnv( + description: 'message list scrolls down when the user taps the scroll to bottom button', + body: (env) async { + const newMessage = 'New message'; + + step('GIVEN the user opens a channel with history'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 30); + await env.userRobot.login().openChannel(); + + step('AND the user sends a new message'); + await env.userRobot.sendMessage(newMessage).assertMessage(newMessage); + + step('WHEN the user scrolls up'); + await env.userRobot.scrollMessageListUp().assertScrollToBottomButton(isDisplayed: true); + + step('AND the user taps the scroll to bottom button'); + await env.userRobot.tapOnScrollToBottomButton(); + + step('THEN the message list is scrolled down'); + await env.userRobot.assertMessageOnScreen(newMessage).assertScrollToBottomButton(isDisplayed: false); + }, + ); + + streamTestWithEnv( + description: 'skipped messages are reloaded when the user scrolls to the bottom', + body: (env) async { + step('GIVEN the user opens a channel with history'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 30); + await env.userRobot.login().openChannel(); + + step('AND the user scrolls up'); + await env.userRobot.scrollMessageListUp().assertScrollToBottomButton(isDisplayed: true); + + step('AND the participant sends some messages'); + await env.participantRobot.sendMultipleMessages('Some message', count: 16); + + step('WHEN the user scrolls to the bottom'); + await env.userRobot.tapOnScrollToBottomButton(); + + step('THEN the skipped messages are reloaded'); + await env.userRobot.assertMessageOnScreen('Some message-16').assertScrollToBottomButton(isDisplayed: false); + }, + ); + + streamTestWithEnv( + description: 'scroll to bottom button shows the unread count', + body: (env) async { + const newMessage = 'New message'; + + step('GIVEN the user opens a channel with history'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 30); + await env.userRobot.login().openChannel(); + + step('WHEN the user scrolls up'); + await env.userRobot.scrollMessageListUp().assertScrollToBottomButton(isDisplayed: true); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(newMessage); + + step('THEN the message list is not scrolled down'); + await env.userRobot + .assertMessageOnScreen(newMessage, isDisplayed: false) + .assertScrollToBottomButton(isDisplayed: true, unreadCount: 1); + + step('WHEN the user taps the scroll to bottom button'); + await env.userRobot.tapOnScrollToBottomButton().assertMessageOnScreen(newMessage); + + step('AND the user scrolls up'); + await env.userRobot.scrollMessageListUp(); + + step('THEN the unread count is not displayed'); + await env.userRobot.assertScrollToBottomButton(isDisplayed: true, unreadCount: 0); + }, + ); + + // MARK: Pagination + + streamTestWithEnv( + allureId: '11331', + description: 'user paginates the message list', + body: (env) async { + const messagesCount = 60; + + step('GIVEN the user opens a channel with a long history'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: messagesCount); + await env.userRobot.login().openChannel(); + + step('THEN the whole history can be paged through'); + await env.userRobot.assertMessageListPagination(messagesCount: messagesCount); + }, + ); + + streamTestWithEnv( + description: 'user paginates a thread', + body: (env) async { + const repliesCount = 60; + + step('GIVEN the user opens a channel with a long thread'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1, repliesCount: repliesCount); + await env.userRobot.login().openChannel(); + + step('WHEN the user opens the thread'); + await env.userRobot.openThreadFromReplies(); + + step('THEN the whole thread history can be paged through'); + await env.userRobot.assertMessageListPagination(messagesCount: repliesCount + 1); + }, + ); + + // MARK: Link previews + + // Link previews are asserted on a participant-sent YouTube message: the + // composer suppresses enrichment on the user's own outgoing link, and the + // mock server only serves a canned OG response for YouTube URLs. + streamTestWithEnv( + allureId: '11340', + description: 'user sees a link preview for a YouTube link', + body: (env) async { + const link = 'https://youtube.com/watch?v=xOX7MsrbaPY'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends a message with a YouTube link'); + await env.participantRobot.sendMessage(link); + + step('THEN a link preview is displayed'); + await env.userRobot.assertLinkPreview(); + }, + ); + + // Mirrors the native test_messageWithLinkOpensSafari: the browser is stood in + // for by a fake `UrlLauncherPlatform`, so the tap is verified without the real + // browser taking over the device (which suspends the app and hangs the run). + streamTestWithEnv( + allureId: '11493', + description: 'message with a link opens the browser', + body: (env) async { + const link = 'https://youtube.com/watch?v=xOX7MsrbaPY'; + // Tapping the preview opens the attachment's asset URL, which comes from + // the mock server's canned OG payload — the canonical (`www.`) form of the + // link, not the text the participant typed. + const openedLink = 'https://www.youtube.com/watch?v=xOX7MsrbaPY'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends a message with a YouTube link'); + await env.participantRobot.sendMessage(link); + await env.userRobot.assertLinkPreview(); + + step('AND the user taps the link preview'); + await env.userRobot.tapLinkPreview(); + + step('THEN the browser opens the link'); + await env.assertBrowserOpened(url: openedLink); + }, + ); + + streamTestWithEnv( + description: 'user sees a link preview for their own YouTube link', + body: (env) async { + const link = 'https://youtube.com/watch?v=xOX7MsrbaPY'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a message with a YouTube link'); + await env.userRobot.sendMessage(link); + + step('THEN a link preview is displayed'); + await env.userRobot.assertLinkPreview(); + }, + ); + + streamTestWithEnv( + description: 'user sees a link preview for their own Unsplash link', + skip: 'https://linear.app/stream/issue/FLU-1088', + body: (env) async { + const link = 'https://unsplash.com/photos/1_2d3MRbI9c'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a message with an Unsplash link'); + await env.userRobot.sendMessage(link); + + step('THEN a link preview is displayed'); + await env.userRobot.assertLinkPreview(); + }, + ); + + streamTestWithEnv( + description: 'user sees a link preview for an Unsplash link', + skip: 'https://linear.app/stream/issue/FLU-1088', + body: (env) async { + const link = 'https://unsplash.com/photos/1_2d3MRbI9c'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends a message with an Unsplash link'); + await env.participantRobot.sendMessage(link); + + step('THEN a link preview is displayed'); + await env.userRobot.assertLinkPreview(); + }, + ); + + streamTestWithEnv( + description: 'message with a link without a scheme opens the browser', + skip: 'https://linear.app/stream/issue/FLU-1094', + body: (env) async { + const link = 'youtube.com'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a message with a link without https://'); + await env.userRobot.sendMessage('Some link: $link'); + + step('AND the user taps the link'); + await env.userRobot.tapOnLinkText(link); + + step('THEN the browser opens the link'); + await env.assertBrowserOpened(); + }, + ); + + // MARK: Message grouping & size + + streamTestWithEnv( + allureId: '11330', + description: 'message cell grows when the user edits it to a longer message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user sends a short message'); + await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); + + step('THEN the message cell grows after editing to a longer message'); + await env.userRobot.assertMessageSizeChangesAfterEditing(longText, increased: true); + }, + ); + + streamTestWithEnv( + allureId: '11344', + description: 'message cell shrinks when the user edits it to a shorter message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user sends a long message'); + await env.userRobot.sendMessage(longText).assertMessage(longText); + + step('THEN the message cell shrinks after editing to a shorter message'); + await env.userRobot.assertMessageSizeChangesAfterEditing(sampleText, increased: false); + }, + ); + + streamTestWithEnv( + allureId: '11342', + description: 'composer does not grow beyond its line limit', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('THEN the composer grows with input but stops at its cap'); + await env.userRobot.assertComposerGrowsWithinLimit(); + }, + ); + + streamTestWithEnv( + description: 'message ends its group when followed by an error message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user sends the 1st message'); + await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); + + step('AND the timestamp is shown under the 1st message'); + await env.userRobot.assertMessageTimestamps(1); + + step('WHEN the user sends a message that does not pass moderation'); + await env.userRobot.sendMessage(forbiddenWord); + + step('THEN the messages are not grouped and the 1st message shows the timestamp'); + await env.userRobot.assertMessageTimestamps(1); + }, + ); + + streamTestWithEnv( + description: 'message renders its timestamp again when the last message of its group is hard-deleted', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends 3 grouped messages'); + await env.participantRobot.sendMessage('1').sendMessage('2').sendMessage('3'); + await env.userRobot.assertMessage('3').assertMessageTimestamps(1); + + step('WHEN the participant hard-deletes the last message'); + await env.participantRobot.deleteMessage(hard: true); + await env.userRobot.assertMessage('3', isDisplayed: false); + + step('THEN the previous message renders the timestamp again'); + await env.userRobot.assertMessageTimestamps(1); + }, + ); + + streamTestWithEnv( + description: 'message renders its timestamp again when the last message of its group is soft-deleted', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends 3 grouped messages'); + await env.participantRobot.sendMessage('1').sendMessage('2').sendMessage('3'); + await env.userRobot.assertMessage('3').assertMessageTimestamps(1); + + step('WHEN the participant soft-deletes the last message'); + await env.participantRobot.deleteMessage(); + await env.userRobot.assertDeletedMessage(); + + step('THEN the previous message renders the timestamp again'); + await env.userRobot.assertMessageTimestamps(1); + }, + ); + + // MARK: Thread reply editing + + streamTestWithEnv( + description: 'user edits a thread reply', + body: (env) async { + const threadReply = 'thread reply'; + const editedThreadReply = 'edited thread reply'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the user replies to the message in a thread'); + await env.userRobot.openThread().sendMessageInThread(threadReply).assertThreadReply(threadReply); + + step('WHEN the user edits the thread reply'); + await env.userRobot.editMessage(editedThreadReply); + + step('THEN the thread reply is edited and marked as edited'); + await env.userRobot.assertEditedMessage(editedThreadReply); + }, + ); + + streamTestWithEnv( + description: 'participant edits a thread reply', + body: (env) async { + const threadReply = 'thread reply'; + const editedThreadReply = 'edited thread reply'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user sends a message'); + await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); + + step('AND the participant replies to the message in a thread'); + await env.participantRobot.sendMessageInThread(threadReply); + + step('AND the user opens the thread'); + await env.userRobot.openThreadFromReplies().assertThreadReply(threadReply); + + step('WHEN the participant edits the thread reply'); + await env.participantRobot.editMessageInThread(editedThreadReply); + + step('THEN the thread reply is edited and marked as edited'); + await env.userRobot.assertEditedMessage(editedThreadReply); + }, + ); + + streamTestWithEnv( + description: 'thread reply also sent to the channel is edited everywhere when the participant edits it', + body: (env) async { + const threadReply = 'thread reply'; + const editedThreadReply = 'edited thread reply'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user sends a message'); + await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); + + step('AND the participant replies in a thread and also sends it to the channel'); + await env.participantRobot.sendMessageInThread(threadReply, alsoSendInChannel: true); + await env.userRobot.assertMessage(threadReply); + + step('WHEN the participant edits the thread reply'); + await env.participantRobot.editMessageInThread(editedThreadReply, alsoSendInChannel: true); + + step('THEN the thread reply is edited in the channel'); + await env.userRobot.assertEditedMessage(editedThreadReply); + + step('AND the thread reply is edited in the thread'); + await env.userRobot.openThreadFromReplies().assertEditedMessage(editedThreadReply); + }, + ); + + streamTestWithEnv( + description: 'thread reply is removed everywhere when the participant removes it from the thread', + body: (env) async { + const threadReply = 'thread reply'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user sends a message'); + await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); + + step('WHEN the participant replies in a thread and also sends it to the channel'); + await env.participantRobot.sendMessageInThread(threadReply, alsoSendInChannel: true); + + step('AND the user opens the thread'); + await env.userRobot.openThreadFromReplies().assertThreadReply(threadReply); + + step('AND the participant removes the thread reply'); + await env.participantRobot.deleteMessage(); + + step('THEN the message is deleted from the thread'); + await env.userRobot.assertDeletedMessage(); + + step('AND the message is deleted from the channel'); + await env.userRobot.tapBackButton().assertDeletedMessage(); + }, + ); +} diff --git a/sample_app/integration_test/mock_server/data_types.dart b/e2e_app/integration_test/mock_server/data_types.dart similarity index 91% rename from sample_app/integration_test/mock_server/data_types.dart rename to e2e_app/integration_test/mock_server/data_types.dart index fdba1af294..eed98c5bf2 100644 --- a/sample_app/integration_test/mock_server/data_types.dart +++ b/e2e_app/integration_test/mock_server/data_types.dart @@ -27,6 +27,6 @@ enum ReactionType { final String emoji; } -enum MessageDeliveryStatus { read, pending, sent, failed, nil } +enum MessageDeliveryStatus { read, delivered, pending, sent, failed, nil } const forbiddenWord = 'wth'; diff --git a/sample_app/integration_test/mock_server/mock_server.dart b/e2e_app/integration_test/mock_server/mock_server.dart similarity index 100% rename from sample_app/integration_test/mock_server/mock_server.dart rename to e2e_app/integration_test/mock_server/mock_server.dart diff --git a/e2e_app/integration_test/moderation_test.dart b/e2e_app/integration_test/moderation_test.dart new file mode 100644 index 0000000000..3f42cadc44 --- /dev/null +++ b/e2e_app/integration_test/moderation_test.dart @@ -0,0 +1,206 @@ +import 'robots/participant_robot.dart'; +import 'robots/user_robot.dart'; +import 'robots/user_robot_channel_list_asserts.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + const sampleText = 'Test'; + // Seeded messages alternate authors starting with the user, so with two + // seeded messages the one with text "2" belongs to the participant. + const participantMessageText = '2'; + const groupChannelName = '1'; + + streamTestWithEnv( + description: 'user flags a message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('WHEN the user taps the flag option of the message'); + await env.userRobot.flagMessage(sampleText); + + step('THEN the flag confirmation is shown'); + await env.userRobot.assertFlagMessageDialog(isDisplayed: true); + + step('WHEN the user confirms flagging the message'); + await env.userRobot.confirmFlagMessage(); + + step('THEN the confirmation is dismissed and the message stays in the message list'); + await env.userRobot.assertFlagMessageDialog(isDisplayed: false).assertMessage(sampleText); + }, + ); + + streamTestWithEnv( + description: 'user mutes the message author', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('WHEN the user mutes the message author'); + await env.userRobot.muteMessageAuthor(sampleText); + + step('THEN the message actions offer to unmute the author'); + await env.userRobot.assertMuteMessageAuthorOption(sampleText, isAuthorMuted: true); + }, + ); + + streamTestWithEnv( + description: 'user unmutes the message author', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the user mutes the message author'); + await env.userRobot.muteMessageAuthor(sampleText).assertMuteMessageAuthorOption(sampleText, isAuthorMuted: true); + + step('WHEN the user unmutes the message author'); + await env.userRobot.unmuteMessageAuthor(sampleText); + + step('THEN the message actions offer to mute the author again'); + await env.userRobot.assertMuteMessageAuthorOption(sampleText, isAuthorMuted: false); + }, + ); + + streamTestWithEnv( + description: 'muted author stays muted when the user reopens the channel', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the user mutes the message author'); + await env.userRobot.muteMessageAuthor(sampleText).assertMuteMessageAuthorOption(sampleText, isAuthorMuted: true); + + step('WHEN the user reopens the channel'); + await env.userRobot.tapBackButton().openChannel(); + + step('THEN the message actions still offer to unmute the author'); + await env.userRobot.assertMuteMessageAuthorOption(sampleText, isAuthorMuted: true); + }, + ); + + streamTestWithEnv( + description: 'user blocks the message author', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('WHEN the user blocks the message author'); + await env.userRobot.blockMessageAuthor(sampleText); + + step('THEN the message actions offer to unblock the author'); + await env.userRobot.assertBlockMessageAuthorOption(sampleText, isAuthorBlocked: true); + }, + ); + + streamTestWithEnv( + description: 'user unblocks the message author', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the user blocks the message author'); + await env.userRobot + .blockMessageAuthor(sampleText) + .assertBlockMessageAuthorOption(sampleText, isAuthorBlocked: true); + + step('WHEN the user unblocks the message author'); + await env.userRobot.unblockMessageAuthor(sampleText); + + step('THEN the message actions offer to block the author again'); + await env.userRobot.assertBlockMessageAuthorOption(sampleText, isAuthorBlocked: false); + }, + ); + + streamTestWithEnv( + description: 'blocked author stays blocked when the user reopens the channel', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the user blocks the message author'); + await env.userRobot + .blockMessageAuthor(sampleText) + .assertBlockMessageAuthorOption(sampleText, isAuthorBlocked: true); + + step('WHEN the user reopens the channel'); + await env.userRobot.tapBackButton().openChannel(); + + step('THEN the message actions still offer to unblock the author'); + await env.userRobot.assertBlockMessageAuthorOption(sampleText, isAuthorBlocked: true); + }, + ); + + streamTestWithEnv( + description: 'user blocks the participant in a direct message channel', + skip: 'https://linear.app/stream/issue/FLU-1085', + body: (env) async { + step('GIVEN a direct message channel with the participant exists'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 2, withDirectMessageChannel: true); + + step('AND the user opens the direct message channel'); + await env.userRobot.login().openChannelNamed(ParticipantRobot.name); + + step('WHEN the user blocks the participant'); + await env.userRobot.blockMessageAuthor(participantMessageText); + + step('THEN the message actions offer to unblock the participant'); + await env.userRobot.assertBlockMessageAuthorOption(participantMessageText, isAuthorBlocked: true); + }, + ); + + streamTestWithEnv( + description: 'direct message channel disappears when the user blocks the participant', + skip: 'https://linear.app/stream/issue/FLU-1085', + body: (env) async { + step('GIVEN a direct message channel with the participant exists'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 2, withDirectMessageChannel: true); + + step('AND the user sees it in the channel list'); + await env.userRobot.login().assertChannelCount(2).assertChannelWithName(ParticipantRobot.name); + + step('WHEN the user blocks the participant in the direct message channel'); + await env.userRobot.openChannelNamed(ParticipantRobot.name).blockMessageAuthor(participantMessageText); + await env.userRobot.tapBackButton(); + + step('THEN the direct message channel disappears from the channel list'); + await env.userRobot.assertChannelCount(1).assertChannelWithName(ParticipantRobot.name, isDisplayed: false); + + step('WHEN the user unblocks the participant from the group channel'); + await env.userRobot.openChannelNamed(groupChannelName).unblockMessageAuthor(participantMessageText); + await env.userRobot.tapBackButton(); + + step('THEN the direct message channel is shown in the channel list again'); + await env.userRobot.assertChannelCount(2).assertChannelWithName(ParticipantRobot.name); + }, + ); +} diff --git a/sample_app/integration_test/pages/channel_list_page.dart b/e2e_app/integration_test/pages/channel_list_page.dart similarity index 50% rename from sample_app/integration_test/pages/channel_list_page.dart rename to e2e_app/integration_test/pages/channel_list_page.dart index c1fbd5297f..ebee717ebd 100644 --- a/sample_app/integration_test/pages/channel_list_page.dart +++ b/e2e_app/integration_test/pages/channel_list_page.dart @@ -1,4 +1,6 @@ -import 'package:flutter/widgets.dart'; +import 'package:e2e_app/widgets/search_text_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -16,6 +18,47 @@ abstract final class ChannelListPage { /// the item carries the channel it was built from, so rows can be told apart /// by identity. static Finder get channels => find.byType(StreamChannelListItem); + + /// The channel row titled [name]. + static Finder channelNamed(String name) => find.descendant(of: find.byType(channelTile), matching: find.text(name)); + + /// A channel row whose unread badge reads [count] (none when 0). + static Finder channelWithUnreadCount(int count) => find.byWidgetPredicate( + (it) => it is StreamChannelListTile && it.unreadCount == count, + description: 'channel row with $count unread', + ); + + /// The swipe actions revealed behind a channel row: "more" (which opens the + /// detail sheet) first, then mute / unmute. + static Finder swipeAction(int index) => find.byType(CustomSlidableAction).at(index); + + /// The confirm button ([label]) of the app's leave / delete dialog. + static Finder dialogButton(String label) => find.descendant(of: find.byType(AlertDialog), matching: find.text(label)); + + /// The search field above the channel list. + static Finder get searchField => find.descendant(of: find.byType(SearchTextField), matching: find.byType(TextField)); + + static Finder get searchResults => find.byType(StreamMessageSearchListTile); + + /// The chip choosing what the search field searches: 'Messages' or 'Channels'. + static Finder searchScope(String label) => find.widgetWithText(ChoiceChip, label); + + /// A row of the channel detail sheet opened by long-pressing a channel. + static Finder channelAction(String label) => find.text(label); +} + +/// The app's channel info screen and the pinned messages screen it +/// lead to. +abstract final class ChannelInfoPage { + static Finder get pinnedMessagesOption => find.text('Pinned Messages'); + + static Finder get pinnedMessagesTitle => find.text('Pinned Messages'); + + static Finder get pinnedMessages => find.byType(StreamMessageSearchListTile); + + static Finder get pinnedMessagesEmpty => find.text('No pinned messages'); + + static Finder get groupInfoTitle => find.text('Group Info'); } /// The parts of a single channel row. Each finder is unscoped — the asserts @@ -34,6 +77,9 @@ final class _ChannelPreview { /// message and a plain [Text] for the empty-channel placeholder. Finder get previewText => find.descendant(of: preview, matching: find.byType(Text)); + /// The "… is typing" text the subtitle shows in place of the last message. + Finder get typingIndicator => find.descendant(of: preview, matching: find.textContaining('typing')); + /// The last-message timestamp. [ChannelLastMessageDate] renders nothing while /// the channel has no message to preview, so the widget's presence *is* the /// "timestamp is shown" signal. diff --git a/sample_app/integration_test/pages/message_list_page.dart b/e2e_app/integration_test/pages/message_list_page.dart similarity index 56% rename from sample_app/integration_test/pages/message_list_page.dart rename to e2e_app/integration_test/pages/message_list_page.dart index cbc5e26cff..caea982e05 100644 --- a/sample_app/integration_test/pages/message_list_page.dart +++ b/e2e_app/integration_test/pages/message_list_page.dart @@ -35,18 +35,103 @@ final class _Composer { /// Command suggestions overlay shown while typing `/`. Finder get commandsOverlay => find.byType(StreamCommandAutocompleteOptions); + /// The suggestion for the command [name] (e.g. `giphy`) in [commandsOverlay]. + /// The overlay titles each command by its sentence-cased name. + Finder commandSuggestion(String name) => find.descendant( + of: commandsOverlay, + matching: find.text('${name[0].toUpperCase()}${name.substring(1)}'), + ); + + /// The countdown the send button turns into while slow mode is active. + /// `_SlowModeCountdownButton` is private, so it is located by the + /// placeholder the input shows alongside it (`slowModeOnLabel`). + Finder get slowModePlaceholder => find.textContaining('Slow mode, wait'); + + /// The link preview the composer shows above the input while the typed text + /// holds an enrichable URL. The core widget is not exported by the SDK, hence + /// matching on the runtime type. + Finder get linkPreview => + find.byWidgetPredicate((it) => it.runtimeType.toString() == 'StreamMessageComposerLinkPreviewAttachment'); + + /// The remove ("x") control on [linkPreview], which drops the preview so the + /// message is sent without one. + Finder get linkPreviewRemoveButton => find.descendant( + of: linkPreview, + matching: find.byWidgetPredicate((it) => it.runtimeType.toString() == 'StreamRemoveControl'), + ); + + /// The attachments staged in the composer, waiting to be sent, whose type is + /// [type]. + Finder stagedAttachments(String type) => find.byWidgetPredicate( + (it) => it is DefaultMessageComposerAttachment && it.props.attachment.type == type, + description: 'staged $type attachment', + ); + + /// The attachments ("+") button at the composer's leading edge. The default + /// leading drops the button altogether while a command is selected. + Finder get attachmentsButton => find.descendant( + of: find.byType(DefaultStreamMessageComposerLeading), + matching: find.byType(StreamButton), + ); + /// The "also send in channel" checkbox, which the composer only shows while /// inside a thread. `DmCheckboxListTile` is not exported, so it is located by /// its label (`alsoSendAsDirectMessageLabel`). Finder get alsoSendInChannelCheckbox => find.text('Also send in Channel'); } +/// The attachment picker and the poll creator it leads to. +abstract final class AttachmentPickerPage { + static Finder get pollTab => find.byTooltip('Create a new poll'); + + static Finder get pollCreatorTitle => find.text('Create Poll'); + + /// The question field: the first editable field of the poll creator. + static Finder get questionField => find.descendant( + of: find.byWidgetPredicate((it) => it.runtimeType.toString() == 'PollQuestionTextField'), + matching: find.byType(EditableText), + ); + + /// The option fields of the poll creator, in order. A new empty one is + /// appended as soon as the last one is filled in. + static Finder get optionFields => find.descendant( + of: find.byWidgetPredicate((it) => it.runtimeType.toString() == 'PollOptionListItem'), + matching: find.byType(EditableText), + ); + + static Finder get addOptionButton => find.text('Add an option'); + + static Finder get multipleAnswersSwitch => find.descendant( + of: find.ancestor( + of: find.text('Multiple answers'), + matching: find.byWidgetPredicate((it) => it.runtimeType.toString() == 'PollConfigOption'), + ), + matching: find.byWidgetPredicate((it) => it.runtimeType.toString() == 'StreamSwitch'), + ); + + static Finder get savePollButton => find.byTooltip('Save poll'); +} + final class _Reactions { const _Reactions(); /// Key of a reaction in the reaction picker (message actions modal). /// The SDK keys each picker button by its reaction type string. Key pickerReaction(ReactionType type) => ValueKey(type.reaction); + + /// The "+" at the end of the reaction picker, opening the full emoji sheet. + Key get addReactionButton => const ValueKey('add_reaction'); + + /// The emoji [type] in the full emoji sheet. The sheet is not exported, + /// hence matching on the runtime type. + Finder extendedPickerReaction(ReactionType type) => find.descendant( + of: find.byWidgetPredicate((it) => it.runtimeType.toString() == 'StreamEmojiPickerSheet'), + matching: find.text(type.emoji), + ); + + /// The sheet listing who reacted to a message, opened by tapping its + /// reactions. + Finder get authorsSheet => find.byType(ReactionDetailSheet); } /// Rows inside the message actions modal (opened by long-pressing a message). @@ -63,6 +148,28 @@ final class _MessageActions { Finder get delete => find.text('Delete Message'); Finder get threadReply => find.text('Thread Reply'); + Finder get flag => find.text('Flag Message'); + Finder get muteUser => find.text('Mute User'); + Finder get unmuteUser => find.text('Unmute User'); + Finder get blockUser => find.text('Block User'); + Finder get unblockUser => find.text('Unblock User'); + + /// The question the flag confirmation dialog asks (`flagMessageQuestion`). + Finder get flagDialog => find.textContaining('send a copy of this message to a moderator'); + + /// The confirm button of the flag dialog (`flagLabel`). + Finder get flagConfirm => find.text('Flag'); + + Finder get markUnread => find.text('Mark Unread'); + Finder get copy => find.text('Copy Message'); + + /// The e2e app's own actions (see its `e2eMessageItemBuilder`). + Finder get hardDelete => find.text('Hard Delete Message'); + Finder get copyId => find.text('Copy Message ID'); + + Finder get pin => find.text('Pin to Conversation'); + Finder get unpin => find.text('Unpin from Conversation'); + /// Starts a quoted reply (`replyLabel`). Distinct from [threadReply]. Finder get reply => find.text('Reply'); @@ -126,6 +233,32 @@ final class _MessageList { /// [StreamMessageItem]. Finder get ephemeralMessage => find.byType(StreamEphemeralMessage); + /// A single rendered image. Several images in one message render as a + /// [gallery] instead. + Finder get image => find.byType(StreamImageAttachment); + + /// Several images (or videos) of one message, rendered as a grid. + Finder get gallery => find.byType(StreamGalleryAttachment); + + Finder get video => find.byType(StreamVideoAttachment); + + Finder get file => find.byType(StreamFileAttachment); + + /// The full-screen media viewer opened by tapping an image. + Finder get mediaGalleryPreview => find.byType(StreamMediaGalleryPreview); + + /// A button ('Send', 'Shuffle' or 'Cancel') on the ephemeral Giphy preview. + Finder giphyButton(String label) => find.descendant(of: ephemeralMessage, matching: find.text(label)); + + Finder get poll => find.byType(StreamPollAttachment); + + /// The row of the poll option [text] in the poll on the message list. + /// `PollOptionItem` is not exported, hence matching on the runtime type. + Finder pollOption(String text) => find.byWidgetPredicate( + (it) => it.runtimeType.toString() == 'PollOptionItem' && ((it as dynamic).option as PollOption).text == text, + description: 'poll option "$text"', + ); + /// A rendered Giphy attachment. Finder get giphy => find.byType(StreamGiphyAttachment); @@ -168,6 +301,33 @@ final class _MessageList { /// The "Edited" footnote under an edited message (`editedMessageLabel`). Finder get editedLabel => find.text('Edited'); + /// The timestamps under the message rows. The SDK only builds a footer for the + /// last message of a group, so the count is the number of message groups. + Finder get timestamps => find.descendant( + of: find.byType(StreamMessageItem), + matching: find.byType(StreamTimestamp), + ); + + /// The annotation above a thread reply that was also sent to the channel: + /// `repliedToThreadAnnotationLabel` on the in-channel copy, + /// `alsoSentInChannelAnnotationLabel` inside the thread. + Finder showInChannelAnnotation({required bool inThread}) => + find.text(inThread ? 'Also sent in channel' : 'Replied to a thread'); + + /// The "Pinned by …" annotation above a pinned message. + Finder pinnedLabel({String? pinnedBy}) => + pinnedBy == null ? find.textContaining('Pinned by') : find.text('Pinned by $pinnedBy'); + + /// The separator above the first unread message. + Finder get unreadSeparator => find.byType(UnreadMessagesSeparator); + + /// The floating "N unread" pill that jumps to the first unread message. + Finder get jumpToUnreadButton => find.byType(UnreadIndicatorButton); + + /// The jump area ("N unread" label) and the dismiss ("x") button of + /// [jumpToUnreadButton], in that order. + Finder get jumpToUnreadButtonParts => find.descendant(of: jumpToUnreadButton, matching: find.byType(StreamButton)); + /// A rendered URL preview card on a message. Finder get linkPreview => find.byType(StreamLinkPreviewAttachment); @@ -181,6 +341,15 @@ final class _MessageList { /// `ThreadSeparator` is not exported, hence matching on the runtime type. Finder get threadSeparator => find.byWidgetPredicate((it) => it.runtimeType.toString() == 'ThreadSeparator'); + /// The row the thread view renders its parent message in, on top of the + /// replies. Reply rows are keyed by their message id; the parent row is the + /// one thread-root row without a key, which tells it apart from a duplicate + /// copy of the parent among the replies. + Finder get threadParent => find.byWidgetPredicate( + (widget) => widget is StreamMessageItem && widget.key == null && widget.props.message.parentId == null, + description: 'thread parent row', + ); + /// The badge the SDK overlays on a message that failed to send (or bounced). /// `StreamMessageItem` only builds it while the message is in a failed state, /// so its presence *is* the failure indicator. @@ -202,6 +371,8 @@ final class _MessageList { switch (status) { case MessageDeliveryStatus.read: return it.isMessageRead; + case MessageDeliveryStatus.delivered: + return !it.isMessageRead && it.isMessageDelivered; case MessageDeliveryStatus.pending: return unacknowledged && state.isOutgoing; case MessageDeliveryStatus.sent: diff --git a/e2e_app/integration_test/pinned_messages_test.dart b/e2e_app/integration_test/pinned_messages_test.dart new file mode 100644 index 0000000000..05329e35c8 --- /dev/null +++ b/e2e_app/integration_test/pinned_messages_test.dart @@ -0,0 +1,132 @@ +import 'mock_server/data_types.dart'; +import 'robots/participant_robot.dart'; +import 'robots/user_robot.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + const sampleText = 'Test'; + + streamTestWithEnv( + description: 'user pins a message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the user pins the message'); + await env.userRobot.pinMessage(); + + step('THEN the message shows the pinned by you label'); + await env.userRobot.assertMessagePinnedLabel(); + }, + ); + + streamTestWithEnv( + description: 'user unpins a message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the user pins the message'); + await env.userRobot.pinMessage().assertMessagePinnedLabel(); + + step('WHEN the user unpins the message'); + await env.userRobot.unpinMessage(); + + step('THEN the message shows no pinned label'); + await env.userRobot.assertMessagePinnedLabel(isDisplayed: false); + }, + ); + + streamTestWithEnv( + description: 'participant pins a message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a message'); + await env.userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the participant pins the message'); + await env.participantRobot.pinMessage(); + + step('THEN the message shows the pinned by participant label'); + await env.userRobot.assertMessagePinnedLabel(pinnedBy: ParticipantRobot.name); + }, + ); + + streamTestWithEnv( + description: 'participant unpins a message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the user sends a message'); + await env.userRobot.sendMessage(sampleText).assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the participant pins the message'); + await env.participantRobot.pinMessage(); + await env.userRobot.assertMessagePinnedLabel(pinnedBy: ParticipantRobot.name); + + step('WHEN the participant unpins the message'); + await env.participantRobot.unpinMessage(); + + step('THEN the message shows no pinned label'); + await env.userRobot.assertMessagePinnedLabel(pinnedBy: ParticipantRobot.name, isDisplayed: false); + }, + ); + + streamTestWithEnv( + description: 'pinned message is shown on the pinned messages screen', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the user pins the message'); + await env.userRobot.pinMessage().assertMessagePinnedLabel(); + + step('WHEN the user opens the pinned messages screen'); + await env.userRobot.tapBackButton().openChannelMenu().tapOnViewChannelInfo().tapOnPinnedMessagesOption(); + + step('THEN the pinned message is shown'); + await env.userRobot.assertPinnedMessagesScreen().assertMessageInPinnedMessages(sampleText); + }, + ); + + streamTestWithEnv( + description: 'unpinned message is not shown on the pinned messages screen', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the user pins the message'); + await env.userRobot.pinMessage().assertMessagePinnedLabel(); + + step('WHEN the user unpins the message'); + await env.userRobot.unpinMessage().assertMessagePinnedLabel(isDisplayed: false); + + step('AND the user opens the pinned messages screen'); + await env.userRobot.tapBackButton().openChannelMenu().tapOnViewChannelInfo().tapOnPinnedMessagesOption(); + + step('THEN no pinned message is shown'); + await env.userRobot.assertPinnedMessagesScreen().assertPinnedMessagesAreEmpty(); + }, + ); +} diff --git a/e2e_app/integration_test/polls_test.dart b/e2e_app/integration_test/polls_test.dart new file mode 100644 index 0000000000..189c720212 --- /dev/null +++ b/e2e_app/integration_test/polls_test.dart @@ -0,0 +1,112 @@ +import 'mock_server/data_types.dart'; +import 'robots/participant_robot.dart'; +import 'robots/user_robot.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + const question = 'Best color?'; + const firstOption = 'Red'; + const secondOption = 'Blue'; + const options = [firstOption, secondOption]; + + streamTestWithEnv( + description: 'poll message is shown when the user creates a poll', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user creates a poll from the attachment picker'); + await env.userRobot.createPoll(question, options); + + step('THEN the poll message shows the question and the options'); + await env.userRobot.assertPollMessage(question).assertPollOption(firstOption).assertPollOption(secondOption); + }, + ); + + streamTestWithEnv( + description: 'option is checked when the user votes in a poll', + body: (env) async { + step('GIVEN the user creates a poll'); + await env.userRobot.login().openChannel().createPoll(question, options); + + step('WHEN the user votes for an option'); + await env.userRobot.castPollVote(firstOption); + + step('THEN the option is checked'); + await env.userRobot.assertPollOption(firstOption, isChecked: true); + }, + ); + + streamTestWithEnv( + description: 'option is unchecked when the user removes the poll vote', + body: (env) async { + step('GIVEN the user creates a poll and votes'); + await env.userRobot + .login() + .openChannel() + .createPoll(question, options) + .castPollVote(firstOption) + .assertPollOption(firstOption, isChecked: true); + + step('WHEN the user taps the voted option again'); + await env.userRobot.removePollVote(firstOption); + + step('THEN the option is unchecked'); + await env.userRobot.assertPollOption(firstOption, isChecked: false); + }, + ); + + streamTestWithEnv( + description: 'participant vote is shown in the poll results', + body: (env) async { + step('GIVEN the user creates a poll'); + await env.userRobot.login().openChannel().createPoll(question, options); + + step('AND the participant votes for an option'); + // A vote arriving before the poll message is delivered is rejected. + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + await env.participantRobot.castPollVote(firstOption); + + step('WHEN the user opens the poll results'); + await env.userRobot.assertPollOptionVoteCount(firstOption, count: 1).openPollResults(); + + step('THEN the participant vote is shown'); + await env.userRobot.assertPollResults(voterName: ParticipantRobot.name); + }, + ); + + streamTestWithEnv( + description: 'poll is closed when the user ends it', + body: (env) async { + step('GIVEN the user creates a poll'); + await env.userRobot.login().openChannel().createPoll(question, options); + + step('WHEN the user ends the poll'); + await env.userRobot.endPoll(); + + step('THEN the poll is shown as ended'); + await env.userRobot.assertPollClosed(); + }, + ); + + streamTestWithEnv( + description: 'user votes for several options when the poll allows multiple votes', + body: (env) async { + step('GIVEN the user creates a poll that allows multiple votes'); + await env.userRobot.login().openChannel().createPoll(question, options, multipleAnswers: true); + + step('WHEN the user votes for both options'); + await env.userRobot + .castPollVote(firstOption) + .assertPollOption(firstOption, isChecked: true) + .castPollVote(secondOption); + + step('THEN both options are checked'); + await env.userRobot + .assertPollOption(secondOption, isChecked: true) + .assertPollOption(firstOption, isChecked: true); + }, + ); +} diff --git a/sample_app/integration_test/quoted_reply_test.dart b/e2e_app/integration_test/quoted_reply_test.dart similarity index 81% rename from sample_app/integration_test/quoted_reply_test.dart rename to e2e_app/integration_test/quoted_reply_test.dart index 6ba4ced18a..a5cca6936b 100644 --- a/sample_app/integration_test/quoted_reply_test.dart +++ b/e2e_app/integration_test/quoted_reply_test.dart @@ -1,8 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; + import 'mock_server/data_types.dart'; +import 'pages/message_list_page.dart'; import 'robots/user_robot.dart'; +import 'robots/user_robot_channel_list_asserts.dart'; import 'robots/user_robot_message_list_asserts.dart'; import 'support/step.dart'; import 'support/stream_test_case.dart'; +import 'support/widget_test_extensions.dart'; void main() { const sampleText = 'Test message'; @@ -778,4 +783,176 @@ void main() { await env.userRobot.assertMessageOnScreen(sampleText).assertScrollToBottomButton(isDisplayed: true); }, ); + + // MARK: Jumping + + streamTestWithEnv( + description: 'channel preview does not change when the user leaves the channel after jumping to a mid page', + body: (env) async { + const messageCount = 60; + const quotedText = '30'; + + step('GIVEN the user opens a channel with $messageCount messages'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: messageCount); + await env.userRobot.login().openChannel(); + + step('AND the user quotes a mid-page message'); + await env.userRobot.quoteMessage(quoteReply, quotedText: quotedText); + await env.userRobot.assertQuotedMessage(text: quoteReply, quote: quotedText); + + step('AND the user reopens the channel'); + await env.userRobot + .tapBackButton() + .assertMessageInChannelPreview(quoteReply, fromCurrentUser: true) + .openChannel(); + + step('AND the user jumps to the mid page'); + await env.userRobot + .tapQuotedMessage() + .assertScrollToBottomButton(isDisplayed: true) + .assertMessageOnScreen(quotedText); + + step('WHEN the user leaves the channel'); + await env.userRobot.tapBackButton(); + + step('THEN the channel preview does not change'); + await env.userRobot.assertMessageInChannelPreview(quoteReply, fromCurrentUser: true); + }, + ); + + streamTestWithEnv( + description: 'quoted message is present after re-entering the channel and loading new messages', + body: (env) async { + const messageCount = 60; + const quotedText = '30'; + + step('GIVEN the user opens a channel with $messageCount messages'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: messageCount); + await env.userRobot.login().openChannel(); + + step('AND the user quotes a message on an old page'); + await env.userRobot.quoteMessage(quoteReply, quotedText: quotedText); + await env.userRobot.assertQuotedMessage(text: quoteReply, quote: quotedText); + + step('AND the user leaves the channel and re-enters it'); + await env.userRobot.tapBackButton().openChannel(); + + step('WHEN the user jumps to the quoted message'); + await env.userRobot + .tapQuotedMessage() + .assertScrollToBottomButton(isDisplayed: true) + .assertMessageOnScreen(quotedText); + + step('AND the user loads all the newest messages until reaching the bottom'); + await env.userRobot.scrollMessageListDownUntilVisible(quoteReply).assertMessageOnScreen(quoteReply); + + step('AND the user scrolls back to the quoted message'); + await env.userRobot.scrollMessageListUpUntilVisible(quotedText); + + step('THEN the quoted message is still there'); + await env.userRobot.assertMessageOnScreen(quotedText); + }, + ); + + streamTestWithEnv( + description: 'user jumps from the channel to a quoted message in a thread', + skip: 'https://linear.app/stream/issue/FLU-1095', + body: (env) async { + step('GIVEN the user opens a channel with a thread'); + await env.backendRobot.generateChannels( + channelsCount: 1, + messagesCount: 1, + repliesCount: 10, + messagesText: parentText, + ); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a quoted reply in the thread, also to the channel'); + await env.participantRobot.quoteMessageInThread(quoteReply, alsoSendInChannel: true, last: false); + await env.userRobot.assertQuotedMessage(text: quoteReply); + final quotedText = env.userRobot.tester.renderedText( + find.descendant( + of: MessageListPage.list.messageWithText(quoteReply), + matching: MessageListPage.list.quotedMessageText, + ), + )!; + + step('AND the participant sends some more messages in the thread'); + for (final reply in ['thread reply 1', 'thread reply 2', 'thread reply 3']) { + await env.participantRobot.sendMessageInThread(reply); + } + + step('WHEN the user taps on the quoted message in the channel'); + await env.userRobot.tapQuotedMessage(); + + step('THEN the user jumps to the quoted message in the thread'); + await env.userRobot.tester.waitUntilVisible(MessageListPage.threadHeader); + await env.userRobot.assertMessageOnScreen(quotedText); + }, + ); + + streamTestWithEnv( + description: 'user jumps from the channel to a regular message in a thread', + body: (env) async { + const threadReply = 'also in channel'; + + step('GIVEN the user opens a channel with a thread'); + await env.backendRobot.generateChannels( + channelsCount: 1, + messagesCount: 1, + repliesCount: 10, + messagesText: parentText, + ); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a regular message in the thread, also to the channel'); + await env.participantRobot.sendMessageInThread(threadReply, alsoSendInChannel: true); + await env.userRobot.assertMessage(threadReply); + + step('AND the participant sends some more messages in the thread'); + for (final reply in ['thread reply 1', 'thread reply 2', 'thread reply 3']) { + await env.participantRobot.sendMessageInThread(reply); + } + + step("WHEN the user taps on the thread annotation of the participant's message in the channel"); + await env.userRobot.tapOnThreadReplyAnnotation(); + + step("THEN the user jumps to the participant's message in the thread"); + await env.userRobot.assertMessageOnScreen(threadReply); + }, + ); + + streamTestWithEnv( + description: 'user jumps to a message in a thread opened by its id from the channel list', + body: (env) async { + const threadReply = 'jump target'; + + step('GIVEN the user opens a channel with a thread'); + await env.backendRobot.generateChannels( + channelsCount: 1, + messagesCount: 1, + repliesCount: 10, + messagesText: parentText, + ); + await env.userRobot.login().openChannel(); + + step('AND the user sends a message in the thread'); + await env.userRobot.openThreadFromReplies().sendMessageInThread(threadReply).assertThreadReply(threadReply); + + step('AND the user copies the message id'); + await env.userRobot.copyMessageId(threadReply); + + step('AND the user sends some more messages in the thread'); + for (final reply in ['thread reply 1', 'thread reply 2', 'thread reply 3']) { + await env.userRobot.sendMessageInThread(reply).assertThreadReply(reply); + } + + step('WHEN the user opens the copied message id from the channel list'); + await env.userRobot.moveToChannelListFromThread().openMessageByIdFromChannelList(); + + step('THEN the user jumps to the message in the thread'); + await env.userRobot.tester.waitUntilVisible(MessageListPage.threadHeader); + await env.userRobot.assertMessageOnScreen(threadReply); + }, + ); } diff --git a/sample_app/integration_test/reactions_test.dart b/e2e_app/integration_test/reactions_test.dart similarity index 62% rename from sample_app/integration_test/reactions_test.dart rename to e2e_app/integration_test/reactions_test.dart index 0eee3c19b1..c52698a8dd 100644 --- a/sample_app/integration_test/reactions_test.dart +++ b/e2e_app/integration_test/reactions_test.dart @@ -213,4 +213,113 @@ void main() { await env.userRobot.assertReaction(type: ReactionType.like, isDisplayed: true); }, ); + + // MARK: Extended reactions + + streamTestWithEnv( + description: 'user adds a reaction using the extended reactions picker', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the participant sends a message'); + await env.participantRobot.sendMessage('Test'); + await env.userRobot.assertMessage('Test'); + + step('AND the user adds a reaction using the extended reactions picker'); + await env.userRobot.toggleReactionUsingExtendedPicker(ReactionType.lol); + + step('THEN the reaction is added'); + await env.userRobot.assertReaction(type: ReactionType.lol, isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'user removes a reaction using the extended reactions picker', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage('Test'); + await env.userRobot.assertMessage('Test'); + + step('AND the user adds a reaction using the extended reactions picker'); + await env.userRobot + .toggleReactionUsingExtendedPicker(ReactionType.lol) + .assertReaction(type: ReactionType.lol, isDisplayed: true); + + step('WHEN the user removes the reaction using the extended reactions picker'); + await env.userRobot.toggleReactionUsingExtendedPicker(ReactionType.lol); + + step('THEN the reaction is removed'); + await env.userRobot.assertReaction(type: ReactionType.lol, isDisplayed: false); + }, + ); + + streamTestWithEnv( + description: 'reaction authors sheet is shown when the user taps on a reaction', + body: (env) async { + const message = 'test message'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(message); + await env.userRobot.assertMessage(message); + + step('AND the user adds a reaction'); + await env.userRobot.addReaction(ReactionType.wow).assertReaction(type: ReactionType.wow, isDisplayed: true); + + step('AND the participant adds a reaction'); + await env.participantRobot.addReaction(ReactionType.love); + await env.userRobot.assertReaction(type: ReactionType.love, isDisplayed: true); + + step('WHEN the user taps on the message reactions'); + await env.userRobot.tapOnMessageReactions(ReactionType.love); + + step('THEN the reaction authors sheet shows the participant and the user'); + // The sheet lists the authors of one reaction type at a time. + await env.userRobot + .assertReactionAuthor(ParticipantRobot.name) + .selectReactionInAuthors(ReactionType.wow) + .assertOwnReactionInAuthors(); + + step('WHEN the participant removes the reaction'); + await env.userRobot.closeReactionAuthors(); + await env.participantRobot.deleteReaction(ReactionType.love); + await env.userRobot.assertReaction(type: ReactionType.love, isDisplayed: false); + + step('AND the user taps on the message reactions'); + await env.userRobot.tapOnMessageReactions(ReactionType.wow); + + step('THEN the reaction authors sheet shows only the user'); + await env.userRobot.assertOwnReactionInAuthors().assertReactionAuthor(ParticipantRobot.name, isDisplayed: false); + }, + ); + + streamTestWithEnv( + description: 'participant adds a reaction to a thread reply', + body: (env) async { + const message = 'message'; + const threadReply = 'thread reply'; + + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(message); + await env.userRobot.assertMessage(message); + + step('AND the user replies to the message in a thread'); + await env.userRobot.openThread().sendMessageInThread(threadReply).assertThreadReply(threadReply); + + step('WHEN the participant adds a reaction to the thread reply'); + await env.participantRobot.addReaction(ReactionType.wow); + + step('THEN the user observes the reaction on the thread reply'); + await env.userRobot.assertReaction(type: ReactionType.wow, isDisplayed: true); + }, + ); } diff --git a/e2e_app/integration_test/reminders_test.dart b/e2e_app/integration_test/reminders_test.dart new file mode 100644 index 0000000000..56e8c0dbbc --- /dev/null +++ b/e2e_app/integration_test/reminders_test.dart @@ -0,0 +1,107 @@ +import 'robots/user_robot.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + const sampleText = 'Test'; + const oneHourInSeconds = 3600; + + streamTestWithEnv( + description: 'reminders screen is empty when the user has no reminders', + body: (env) async { + step('GIVEN the user logs in'); + await env.userRobot.login().waitForChannelListToLoad(); + + step('WHEN the user opens the reminders screen'); + await env.userRobot.openReminders(); + + step('THEN no reminder is shown'); + await env.userRobot.assertRemindersScreen().assertRemindersAreEmpty(); + }, + ); + + streamTestWithEnv( + description: 'reminder saved for later is shown on the reminders screen', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the message is saved for later'); + await env.backendRobot.createReminder(); + + step('WHEN the user opens the reminders screen'); + await env.userRobot.tapBackButton().openReminders(); + + step('THEN the message is shown as saved for later'); + await env.userRobot.assertRemindersScreen().assertReminder(sampleText, savedForLater: true); + }, + ); + + streamTestWithEnv( + description: 'scheduled reminder is shown on the reminders screen', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND a reminder for the message is due in one hour'); + await env.backendRobot.createReminder(remindAtSeconds: oneHourInSeconds); + + step('WHEN the user opens the reminders screen'); + await env.userRobot.tapBackButton().openReminders(); + + step('THEN the message is shown on the reminders screen'); + await env.userRobot.assertRemindersScreen().assertReminder(sampleText); + }, + ); + + streamTestWithEnv( + description: 'reminder saved for later is shown on the message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND the message is saved for later'); + await env.backendRobot.createReminder(); + + step('WHEN the user reopens the channel'); + await env.userRobot.tapBackButton().openChannel(); + + step('THEN the message is shown as saved for later'); + await env.userRobot.assertMessageReminder(remindAtIsSet: false); + }, + ); + + streamTestWithEnv( + description: 'scheduled reminder is shown on the message', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessage(sampleText); + + step('AND a reminder for the message is due in one hour'); + await env.backendRobot.createReminder(remindAtSeconds: oneHourInSeconds); + + step('WHEN the user reopens the channel'); + await env.userRobot.tapBackButton().openChannel(); + + step('THEN the message shows when the reminder is due'); + await env.userRobot.assertMessageReminder(remindAtIsSet: true); + }, + ); +} diff --git a/e2e_app/integration_test/robots/backend_robot.dart b/e2e_app/integration_test/robots/backend_robot.dart new file mode 100644 index 0000000000..b294e43d53 --- /dev/null +++ b/e2e_app/integration_test/robots/backend_robot.dart @@ -0,0 +1,132 @@ +import '../mock_server/mock_server.dart'; + +class BackendRobot { + BackendRobot(this._mockServer); + + final MockServer _mockServer; + + Future generateChannels({ + required int channelsCount, + int messagesCount = 0, + int repliesCount = 0, + String? messagesText, + String? repliesText, + List channelNames = const [], + bool withDirectMessageChannel = false, + }) async { + final messagesTextParam = messagesText != null ? 'messages_text=${Uri.encodeQueryComponent(messagesText)}&' : ''; + final repliesTextParam = repliesText != null ? 'replies_text=${Uri.encodeQueryComponent(repliesText)}&' : ''; + final channelNamesParam = channelNames.isNotEmpty + ? 'channel_names=${Uri.encodeQueryComponent(channelNames.join(','))}&' + : ''; + final dmParam = withDirectMessageChannel ? 'dm=true&' : ''; + await _mockServer.post( + 'mock?' + '$messagesTextParam' + '$repliesTextParam' + '$channelNamesParam' + '$dmParam' + 'channels=$channelsCount&' + 'messages=$messagesCount&' + 'replies=$repliesCount', + ); + return this; + } + + Future failNewMessages() async { + await _mockServer.post('fail_messages'); + return this; + } + + /// Holds every new message on the server for [seconds] before answering, so + /// it stays pending meanwhile. + Future delayNewMessages({int seconds = 10}) async { + await _mockServer.post('delay_messages?delay=$seconds'); + return this; + } + + Future freezeNewMessages() async { + await _mockServer.post('freeze_messages'); + return this; + } + + /// Turns slow mode on (or off) for the channel, with a cooldown of + /// [duration] seconds between the user's messages. + Future setCooldown({required bool enabled, int duration = 15}) async { + await _mockServer.post('config/cooldown?enabled=$enabled&duration=$duration'); + return this; + } + + /// Turns the channel's read events (read receipts) on or off. + Future setReadEvents({required bool enabled}) async { + await _mockServer.post('config/read_events?value=$enabled'); + return this; + } + + /// Truncates the channel server-side, as if another client had done it. + Future truncateChannel({required bool withMessage}) async { + await _mockServer.post('truncate_channel?with_message=$withMessage'); + return this; + } + + /// Makes the mock server treat the app as logged in as [id] from now on, so + /// a test can log in as a second user after logging out. + Future setAppUser({required String id, required String name}) async { + await _mockServer.post('app_user?id=$id&name=${Uri.encodeQueryComponent(name)}'); + return this; + } + + /// Delays the channel list query by [seconds]. + Future delayChannelList({required int seconds}) async { + await _mockServer.post('delay_channel_list?delay=$seconds'); + return this; + } + + /// Mutes the current channel for the user, server-side. + Future muteChannel() async { + await _mockServer.post('mute_channel'); + return this; + } + + Future addMember(String userId) async { + await _mockServer.post('add_member?user_id=$userId'); + return this; + } + + Future removeMember(String userId) async { + await _mockServer.post('remove_member?user_id=$userId'); + return this; + } + + /// Creates a reminder on the last message, due in [remindAtSeconds], or a + /// "saved for later" one with no due date when it is null. + Future createReminder({int? remindAtSeconds}) async { + await _mockServer.post(remindAtSeconds == null ? 'create_reminder' : 'create_reminder?remind_at=$remindAtSeconds'); + return this; + } + + /// Waits until the app has closed its WebSocket to the mock server, so a + /// server-side event that follows cannot be delivered live. + Future waitForWebSocketDisconnection({Duration timeout = const Duration(seconds: 10)}) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + final status = await _mockServer.get('ws/status'); + if (status.contains('"connected":false')) return this; + await Future.delayed(const Duration(milliseconds: 500)); + } + throw StateError('The WebSocket was not disconnected within $timeout'); + } + + Future revokeToken({int duration = 5}) => _mockServer.post('jwt/revoke_token?duration=$duration'); + + Future invalidateToken({int duration = 5}) => _mockServer.post('jwt/invalidate_token?duration=$duration'); + + Future invalidateTokenDate({int duration = 5}) => + _mockServer.post('jwt/invalidate_token_date?duration=$duration'); + + Future invalidateTokenSignature({int duration = 5}) => + _mockServer.post('jwt/invalidate_token_signature?duration=$duration'); + + Future breakTokenGeneration({int duration = 5}) => + _mockServer.post('jwt/break_token_generation?duration=$duration'); +} diff --git a/sample_app/integration_test/robots/participant_robot.dart b/e2e_app/integration_test/robots/participant_robot.dart similarity index 70% rename from sample_app/integration_test/robots/participant_robot.dart rename to e2e_app/integration_test/robots/participant_robot.dart index 00882b5fcb..3450cb8cde 100644 --- a/sample_app/integration_test/robots/participant_robot.dart +++ b/e2e_app/integration_test/robots/participant_robot.dart @@ -7,6 +7,7 @@ class ParticipantRobot { final MockServer _mockServer; static const name = 'Count Dooku'; + static const id = 'count_dooku'; Future startTyping() async { await _mockServer.post('participant/typing/start'); @@ -33,10 +34,36 @@ class ParticipantRobot { return this; } - Future sendMessage(String text, {int delay = 0}) async { - var endpoint = 'participant/message'; - if (delay > 0) endpoint += '?delay=$delay'; - await _mockServer.post(endpoint, body: text); + /// Sends [text] to the current channel, or to the channel named + /// [channelName]. + Future sendMessage(String text, {int delay = 0, String? channelName}) async { + final query = [ + if (delay > 0) 'delay=$delay', + if (channelName != null) 'channel_name=${Uri.encodeQueryComponent(channelName)}', + ].join('&'); + await _mockServer.post(query.isEmpty ? 'participant/message' : 'participant/message?$query', body: text); + return this; + } + + /// Sends [count] messages, `"$text-1"` to `"$text-$count"`, spaced out so + /// they arrive in order. + Future sendMultipleMessages(String text, {required int count}) async { + for (var i = 1; i <= count; i++) { + await sendMessage('$text-$i'); + await Future.delayed(const Duration(milliseconds: 300)); + } + return this; + } + + /// Posts a system message, as an action of the participant would. + Future sendSystemMessage(String text) async { + await _mockServer.post('participant/message?system=true', body: text); + return this; + } + + /// Marks the newest message as delivered to the participant. + Future markAsDelivered() async { + await _mockServer.post('participant/delivered'); return this; } @@ -56,6 +83,14 @@ class ParticipantRobot { return this; } + Future editMessageInThread(String text, {bool alsoSendInChannel = false}) async { + await _mockServer.post( + 'participant/message?action=edit&thread=true&thread_and_channel=$alsoSendInChannel', + body: text, + ); + return this; + } + Future deleteMessage({bool hard = false}) async { await _mockServer.post('participant/message?action=delete&hard_delete=$hard'); return this; @@ -162,6 +197,18 @@ class ParticipantRobot { return this; } + /// Votes for [option] in the poll on the newest message. + Future castPollVote(String option) async { + await _mockServer.post('participant/poll_vote?option=${Uri.encodeQueryComponent(option)}'); + return this; + } + + /// Adds [answer] as a comment to the poll on the newest message. + Future addPollAnswer(String answer) async { + await _mockServer.post('participant/poll_vote?answer=${Uri.encodeQueryComponent(answer)}'); + return this; + } + Future addReaction(ReactionType type, {int delay = 0}) async { var endpoint = 'participant/reaction?type=${type.reaction}'; if (delay > 0) endpoint += '&delay=$delay'; @@ -183,8 +230,20 @@ class ParticipantRobot { extension ParticipantRobotChain on Future { Future readMessage() => then((it) => it.readMessage()); - Future sendMessage(String text, {int delay = 0}) => - then((it) => it.sendMessage(text, delay: delay)); + Future sendMessage(String text, {int delay = 0, String? channelName}) => + then((it) => it.sendMessage(text, delay: delay, channelName: channelName)); + + Future sendMultipleMessages(String text, {required int count}) => + then((it) => it.sendMultipleMessages(text, count: count)); + + Future pinMessage() => then((it) => it.pinMessage()); + + Future unpinMessage() => then((it) => it.unpinMessage()); + + Future uploadGiphy() => then((it) => it.uploadGiphy()); + + Future uploadAttachment(AttachmentType type, {int count = 1}) => + then((it) => it.uploadAttachment(type, count: count)); Future editMessage(String text) => then((it) => it.editMessage(text)); diff --git a/e2e_app/integration_test/robots/user_robot.dart b/e2e_app/integration_test/robots/user_robot.dart new file mode 100644 index 0000000000..bfed1d2b52 --- /dev/null +++ b/e2e_app/integration_test/robots/user_robot.dart @@ -0,0 +1,907 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart' hide AttachmentType; + +import '../mock_server/data_types.dart'; +import '../pages/channel_list_page.dart'; +import '../pages/message_list_page.dart'; +import '../support/predefined_users.dart'; +import '../support/widget_test_extensions.dart'; +import 'participant_robot.dart'; + +class UserRobot { + UserRobot(this.tester); + + final WidgetTester tester; + + Future login([ + UserCredentials user = PredefinedUsers.currentUser, + ]) async { + await tester.scrollToText(user.name); + await tester.tapText(user.name); + return this; + } + + /// Signs out from the drawer behind the current user's avatar on the channel + /// list. + Future logout() async { + await _openDrawer(); + await tester.tapText('Sign out'); + return this; + } + + Future _openDrawer() async { + final avatar = find.descendant(of: find.byType(StreamChannelListHeader), matching: find.byType(StreamUserAvatar)); + await tester.tapFinder(avatar); + return this; + } + + /// Opens the reminders screen from the bottom navigation. + Future openReminders() async { + await tester.tapFinder(find.descendant(of: find.byType(StreamBottomNavBar), matching: find.text('Reminders'))); + return this; + } + + /// Waits for the channel list to render its rows. + Future waitForChannelListToLoad() async { + await tester.waitUntilVisible(find.byType(ChannelListPage.channelTile)); + return this; + } + + Future openChannel({int index = 0}) async { + final tile = find.byType(ChannelListPage.channelTile).at(index); + await tester.waitUntilVisible(tile); + await tester.tap(tile); + await tester.pumpAndSettle(); + return this; + } + + Future sendMessage(String text) async { + await tester.enterTextInField(MessageListPage.composer.inputField, text); + await tester.tapByKey(MessageListPage.composer.sendButton); + return this; + } + + /// Types [text] into the composer without sending it. + Future typeText(String text) async { + await tester.enterTextInField(MessageListPage.composer.inputField, text); + return this; + } + + /// Clears whatever is currently in the composer. + Future clearComposer() => typeText(''); + + /// The text currently in the composer. + String get composerText => + tester.widget(find.byType(MessageListPage.composer.inputField)).controller.text; + + Future tapOnSendButton() async { + await tester.tapByKey(MessageListPage.composer.sendButton); + return this; + } + + /// Dismisses the link preview the composer built for the typed URL. + Future tapOnLinkPreviewCancelButton() async { + await tester.tapFinder(MessageListPage.composer.linkPreviewRemoveButton); + return this; + } + + /// Stages [count] attachments of [type] in the composer, ready to be sent. + /// + /// The native suites pick files through the OS photo / document pickers, + /// which run out of process and so are out of reach of an in-process test. + /// Staging the file on the composer's own controller is the closest seam: the + /// composer still shows it, and sending still goes through the real send + /// button and the SDK's upload to the mock server. + Future attachFile(AttachmentType type, {int count = 1}) async { + final leading = find.byType(DefaultStreamMessageComposerLeading); + await tester.waitUntilVisible(leading); + final controller = tester.widget(leading).props.controller; + for (var i = 1; i <= count; i++) { + final (name, bytes) = switch (type) { + AttachmentType.image => ('image$i.png', _png), + AttachmentType.video => ('video$i.mp4', _mp4), + AttachmentType.file => ('file$i.pdf', _pdf), + }; + controller.addAttachment( + Attachment( + type: type.attachment, + file: AttachmentFile(size: bytes.length, bytes: bytes, name: name), + ), + ); + } + await tester.settle(); + return this; + } + + /// Taps the image at [index] (0 being the first one) of the newest message + /// with media, opening the full-screen gallery. + Future openImageInGallery({int index = 0}) async { + final images = find.descendant( + of: MessageListPage.list.gallery, + matching: find.byType(StreamMessageAttachment), + ); + await tester.waitUntilVisible(images.at(index)); + await tester.tap(images.at(index)); + await tester.settle(); + await tester.waitUntilVisible(MessageListPage.list.mediaGalleryPreview); + return this; + } + + /// Swipes the full-screen gallery to its next image. + Future swipeToNextImageInGallery() async { + final gallery = MessageListPage.list.mediaGalleryPreview; + await tester.fling(find.descendant(of: gallery, matching: find.byType(PageView)), const Offset(-300, 0), 1000); + await tester.settle(); + return this; + } + + /// Runs the giphy command, then sends the Giphy it previews unless [send] is + /// false. [useComposerCommand] picks the command from the suggestions + /// instead of typing it out. + Future uploadGiphy({bool useComposerCommand = false, bool send = true}) async { + // Any text yields a Giphy from the mock server. + const query = 'G'; + if (useComposerCommand) { + await openComposerCommands(); + await selectCommand('giphy'); + await typeText(query); + await tapOnSendButton(); + } else { + await sendMessage('/giphy $query'); + } + await tester.waitUntilVisible(MessageListPage.list.ephemeralMessage); + if (send) await tapOnSendGiphyButton(); + return this; + } + + Future tapOnSendGiphyButton() => _tapGiphyButton('Send'); + + Future tapOnShuffleGiphyButton() => _tapGiphyButton('Shuffle'); + + Future tapOnCancelGiphyButton() => _tapGiphyButton('Cancel'); + + Future _tapGiphyButton(String label) async { + await tester.tapFinder(MessageListPage.list.giphyButton(label)); + return this; + } + + /// Opens the message actions of the message at [messageIndex] and returns + /// whether [action] is among them, then closes the actions again. + Future hasMessageAction(Finder action, {int messageIndex = 0}) async { + final message = find.byType(MessageListPage.messageItem).at(messageIndex); + await tester.longPressUntilVisible(message, find.byKey(MessageListPage.list.actionsModalMessageKey)); + final available = action.evaluate().isNotEmpty; + await tester.tapAt(const Offset(20, 80)); + await tester.waitUntilNotVisible(find.byKey(MessageListPage.list.actionsModalMessageKey)); + return available; + } + + Future copyMessage({int messageIndex = 0}) => + _selectMessageAction(MessageListPage.actions.copy, messageIndex: messageIndex); + + /// Searches messages from the channel list's search field. + Future search(String query) async { + await tester.waitUntilVisible(ChannelListPage.searchField); + await tester.enterText(ChannelListPage.searchField, query); + await tester.settle(); + return this; + } + + Future tapOnSearchResult({int index = 0}) async { + await tester.tapFinder(ChannelListPage.searchResults.at(index)); + return this; + } + + Future markMessageAsUnread(String text) => + _selectMessageAction(MessageListPage.actions.markUnread, text: text); + + Future tapOnJumpToUnreadButton() async { + await tester.tapFinder(MessageListPage.list.jumpToUnreadButtonParts.first); + return this; + } + + Future dismissUnreadIndicator() async { + await tester.tapFinder(MessageListPage.list.jumpToUnreadButtonParts.last); + return this; + } + + /// Scrolls the message list up until the message [text] is rendered. + Future scrollMessageListUpUntilVisible(String text) async { + final message = MessageListPage.list.messageWithText(text); + await tester.scrollUpUntil(() => message.evaluate().isNotEmpty, description: 'the message "$text"'); + return this; + } + + /// Scrolls the message list down until the message [text] is rendered. + Future scrollMessageListDownUntilVisible(String text) async { + final message = MessageListPage.list.messageWithText(text); + await tester.scrollDownUntil(() => message.evaluate().isNotEmpty, description: 'the message "$text"'); + return this; + } + + Future pinMessage({int messageIndex = 0}) => + _selectMessageAction(MessageListPage.actions.pin, messageIndex: messageIndex); + + Future unpinMessage({int messageIndex = 0}) => + _selectMessageAction(MessageListPage.actions.unpin, messageIndex: messageIndex); + + /// Picks 'Reply' from the message's actions without sending anything. + Future selectReplyFromContextMenu({int messageIndex = 0}) => + _selectMessageAction(MessageListPage.actions.reply, messageIndex: messageIndex); + + Future flagMessage(String text) => _selectMessageAction(MessageListPage.actions.flag, text: text); + + Future confirmFlagMessage() async { + await tester.tapFinder(MessageListPage.actions.flagConfirm); + return this; + } + + Future muteMessageAuthor(String text) => + _selectMessageAction(MessageListPage.actions.muteUser, text: text); + + Future unmuteMessageAuthor(String text) => + _selectMessageAction(MessageListPage.actions.unmuteUser, text: text); + + Future blockMessageAuthor(String text) => + _selectMessageAction(MessageListPage.actions.blockUser, text: text); + + Future unblockMessageAuthor(String text) => + _selectMessageAction(MessageListPage.actions.unblockUser, text: text); + + /// Opens the actions of the message [text], checks whether each of [options] + /// is offered, then closes the actions again. + Future> messageActionsOffered(String text, List options) async { + final message = await _resolveMessage(messageIndex: 0, text: text); + final modal = find.byKey(MessageListPage.list.actionsModalMessageKey); + await tester.longPressUntilVisible(message, modal); + final offered = [for (final option in options) option.evaluate().isNotEmpty]; + await closeMessageActions(); + return offered; + } + + /// Dismisses the open message actions by tapping outside of them. + Future closeMessageActions() async { + final modal = find.byKey(MessageListPage.list.actionsModalMessageKey); + await tester.tapAt(const Offset(10, 120)); + await tester.waitUntilNotVisible(modal); + return this; + } + + Future _selectMessageAction(Finder action, {int messageIndex = 0, String? text}) async { + final message = await _resolveMessage(messageIndex: messageIndex, text: text); + await tester.longPressUntilVisible(message, action); + await tester.tapFinder(action); + return this; + } + + /// Opens the channel detail sheet by long-pressing the channel row. + Future openChannelMenu({int index = 0}) async { + final tile = find.byType(ChannelListPage.channelTile).at(index); + await tester.longPressUntilVisible(tile, ChannelListPage.channelAction('View Info')); + return this; + } + + Future tapOnChannelAction(String label) async { + await tester.tapFinder(ChannelListPage.channelAction(label)); + return this; + } + + Future tapOnViewChannelInfo() => tapOnChannelAction('View Info'); + + Future tapOnLeaveGroup() => tapOnChannelAction('Leave Group'); + + Future tapOnDeleteGroup() => tapOnChannelAction('Delete Group'); + + /// Confirms the leave / delete dialog the app shows. + Future confirmChannelAction() async { + final confirm = ChannelListPage.dialogButton('Leave').evaluate().isNotEmpty ? 'Leave' : 'Delete'; + await tester.tapFinder(ChannelListPage.dialogButton(confirm)); + return this; + } + + /// Swipes the channel row at [index] to reveal its swipe actions. + Future swipeChannel({int index = 0}) async { + final tile = find.byType(ChannelListPage.channelTile).at(index); + await tester.waitUntilVisible(tile); + await tester.drag(tile, Offset(-tester.getSize(tile).width * 0.5, 0)); + await tester.settle(); + await tester.waitUntilVisible(ChannelListPage.swipeAction(0)); + return this; + } + + Future tapOnMoreSwipeAction() => _tapSwipeAction(0); + + Future tapOnMuteSwipeAction() => _tapSwipeAction(1); + + // A plain tap: scrolling the action into view first (as `tapFinder` does) + // closes the open slidable before the tap lands. + Future _tapSwipeAction(int index) async { + final action = ChannelListPage.swipeAction(index); + await tester.waitUntilVisible(action); + await tester.tap(action); + await tester.settle(); + return this; + } + + /// The mute swipe action toggles, so unmuting taps the same slot. + Future tapOnUnmuteSwipeAction() => tapOnMuteSwipeAction(); + + /// Opens the channel whose row is titled [name]. + Future openChannelNamed(String name) async { + final tile = ChannelListPage.channelNamed(name); + await tester.waitUntilVisible(tile); + await tester.tap(tile.first); + await tester.pumpAndSettle(); + return this; + } + + Future tapOnPinnedMessagesOption() async { + await tester.tapFinder(ChannelInfoPage.pinnedMessagesOption); + return this; + } + + /// Creates a poll asking [question] with [options] through the attachment + /// picker, and sends it. + Future createPoll(String question, List options, {bool multipleAnswers = false}) async { + await tester.tapFinder(MessageListPage.composer.attachmentsButton); + await tester.tapFinder(AttachmentPickerPage.pollTab); + await tester.waitUntilVisible(AttachmentPickerPage.pollCreatorTitle); + + await tester.enterText(AttachmentPickerPage.questionField.first, question); + await tester.settle(); + for (final (index, option) in options.indexed) { + final field = AttachmentPickerPage.optionFields.at(index); + // The creator starts with one option field; each further one is added + // from its "Add an option" row. + if (index > 0) await _addPollOptionField(field); + await tester.waitUntilVisible(field); + await tester.enterText(field, option); + await tester.settle(); + } + if (multipleAnswers) await tester.tapFinder(AttachmentPickerPage.multipleAnswersSwitch); + + await tester.tapFinder(AttachmentPickerPage.savePollButton); + await tester.waitUntilNotVisible(AttachmentPickerPage.pollCreatorTitle); + await tester.waitUntilVisible(MessageListPage.list.poll); + return this; + } + + // The first tap on "Add an option" can be spent on moving focus off the + // field just filled in, so it is retried until the new field shows up. + Future _addPollOptionField(Finder field) async { + for (var attempt = 0; attempt < 5 && field.evaluateSafely().isEmpty; attempt++) { + await tester.tap(AttachmentPickerPage.addOptionButton.last); + await tester.settle(); + } + } + + /// Taps the poll [option], which votes for it, or removes the vote when it + /// was already cast. + Future castPollVote(String option) async { + await tester.tapFinder(MessageListPage.list.pollOption(option)); + return this; + } + + Future removePollVote(String option) => castPollVote(option); + + Future openPollResults() async { + await tester.tapFinder(find.descendant(of: MessageListPage.list.poll, matching: find.text('View Results'))); + await tester.waitUntilVisible(find.text('Poll Results')); + return this; + } + + /// Ends the poll, confirming the dialog that asks for it. + Future endPoll() async { + await tester.tapFinder(find.descendant(of: MessageListPage.list.poll, matching: find.text('End Poll'))); + await tester.tapFinder(find.text('End')); + return this; + } + + /// Opens the thread list from the bottom navigation. + Future openThreadList() async { + await tester.tapText('Threads'); + return this; + } + + /// Opens the thread at [index] of the thread list. + Future openThreadFromThreadList({int index = 0}) async { + await tester.tapFinder(find.byType(StreamThreadListTile).at(index)); + await tester.waitUntilVisible(MessageListPage.threadHeader); + return this; + } + + /// Opens the command suggestions by typing `/`. + Future openComposerCommands() => typeText('/'); + + /// Picks the command [name] from the open command suggestions. + Future selectCommand(String name) async { + await tester.tapFinder(MessageListPage.composer.commandSuggestion(name)); + return this; + } + + /// Mentions the participant through the mentions overlay, and sends the + /// message unless [send] is false. + Future mentionParticipant({bool send = true}) async { + await typeText('@'); + await tester.tapFinder( + find.descendant(of: MessageListPage.composer.mentionsOverlay, matching: find.text(ParticipantRobot.name)), + ); + if (send) await tester.tapByKey(MessageListPage.composer.sendButton); + return this; + } + + /// Taps an empty spot in the message list (just under the header). + Future tapOnMessageList() async { + final list = find.byType(MessageListPage.list.view); + await tester.waitUntilVisible(list); + await tester.tapAt(tester.getTopLeft(list) + const Offset(20, 20)); + await tester.settle(); + return this; + } + + /// Taps the floating scroll-to-bottom button. + Future tapOnScrollToBottomButton() async { + await tester.tapFinder(MessageListPage.list.scrollToBottomButton); + return this; + } + + Future editMessage(String newText, {int messageIndex = 0}) async { + final message = find.byType(MessageListPage.messageItem).at(messageIndex); + await tester.longPressUntilVisible(message, MessageListPage.actions.edit); + await tester.tapFinder(MessageListPage.actions.edit); + await tester.enterTextInField(MessageListPage.composer.inputField, newText); + await tester.tapByKey(MessageListPage.composer.sendButton); + return this; + } + + /// Deletes the message, or hard-deletes it when [hard] is set — through the + /// e2e app's own action, as the SDK offers a hard delete only for a message + /// that failed to send. + Future deleteMessage({int messageIndex = 0, String? text, bool hard = false}) async { + final message = await _resolveMessage(messageIndex: messageIndex, text: text); + if (hard) { + await tester.longPressUntilVisible(message, MessageListPage.actions.hardDelete); + await tester.tapFinder(MessageListPage.actions.hardDelete); + return this; + } + await tester.longPressUntilVisible(message, MessageListPage.actions.delete); + await tester.tapFinder(MessageListPage.actions.delete); + await tester.tapFinder(MessageListPage.actions.deleteConfirm); + return this; + } + + /// Copies the id of the message [text], for [openMessageByIdFromChannelList]. + Future copyMessageId(String text) => _selectMessageAction(MessageListPage.actions.copyId, text: text); + + /// Opens the message whose id [copyMessageId] copied, through the channel + /// actions sheet's debug action. + Future openMessageByIdFromChannelList({int index = 0}) async { + await openChannelMenu(index: index); + await tester.tapFinder(ChannelListPage.channelAction('Open Message by ID')); + return this; + } + + /// Picks what the channel list's search field searches. + Future selectSearchScope(String label) async { + await tester.tapFinder(ChannelListPage.searchScope(label)); + return this; + } + + /// Quotes a message and sends [text] as the reply. + Future quoteMessage( + String text, { + int messageIndex = 0, + String? quotedText, + }) async { + final message = await _resolveMessage(messageIndex: messageIndex, text: quotedText); + await tester.longPressUntilVisible(message, MessageListPage.actions.reply); + await tester.tapFinder(MessageListPage.actions.reply); + return sendMessage(text); + } + + /// Swipes a message, which the SDK treats as starting a quoted reply. + Future swipeMessage({int messageIndex = 0, String? text}) async { + await tester.swipeToReply(await _resolveMessage(messageIndex: messageIndex, text: text)); + return this; + } + + /// Taps "View" on the "Replied to a thread" annotation of the newest message + /// that was also sent to the channel, which opens it in its thread. + Future tapOnThreadReplyAnnotation() async { + await tester.tapFinder(find.text('View')); + await tester.waitUntilVisible(MessageListPage.threadHeader); + return this; + } + + /// Taps a reply's quoted bubble, which jumps the list to the original message. + Future tapQuotedMessage() async { + await tester.tapFinder(MessageListPage.list.quotedMessage); + return this; + } + + /// The message row to act on: the one whose text is [text] when given, + /// otherwise the one at [messageIndex] (0 being the newest). + /// + /// Naming a message also scrolls up to it, so an older message that has not + /// been reached yet can still be acted on. + Future _resolveMessage({required int messageIndex, String? text}) async { + if (text == null) return find.byType(MessageListPage.messageItem).at(messageIndex); + + final message = MessageListPage.list.messageWithText(text); + await tester.scrollUpUntil( + () => message.evaluate().isNotEmpty, + description: 'the message with text "$text"', + ); + // A thread renders its root message on top of the paged-in history, so the + // same text can resolve to more than one row; acting on the first is enough. + return message.first; + } + + /// Opens the thread of a message. + /// + /// [parentText] must be given whenever the channel already holds thread + /// replies: the mock server's replies are rendered in the channel list too, so + /// the newest row is a *reply*, and the SDK offers no 'Thread Reply' action on + /// one (its `parentId` is set). Naming the parent also handles it being + /// scrolled out of view, since it is the oldest message. + Future openThread({int messageIndex = 0, String? parentText}) async { + final message = await _resolveMessage(messageIndex: messageIndex, text: parentText); + await tester.longPressUntilVisible(message, MessageListPage.actions.threadReply); + await tester.tapFinder(MessageListPage.actions.threadReply); + await tester.waitUntilVisible(MessageListPage.threadHeader); + return this; + } + + /// Opens the thread from a parent message's "N replies" footer, mirroring the + /// native robot's `threadReplyCountButton` route into a thread. + /// + /// [openThread]'s long-press route can't be used when a thread reply was also + /// sent to the channel: that in-channel copy is the newest message and exposes + /// no 'Thread Reply' action. The footer renders only on the parent, so it is + /// unambiguous. + Future openThreadFromReplies() async { + // The footer only renders on the parent, which is the channel's oldest + // message — and the mock server's replies are rendered in the channel as + // well, so it can start out scrolled past. + await tester.scrollUpUntil( + () => MessageListPage.list.threadReplies.evaluate().isNotEmpty, + description: 'the thread replies footer', + ); + await tester.tapFinder(MessageListPage.list.threadReplies); + await tester.waitUntilVisible(MessageListPage.threadHeader); + return this; + } + + /// Sends [text] from inside a thread, optionally ticking the composer's + /// "also send in channel" checkbox first so the reply is copied into the + /// channel as well. + Future sendMessageInThread( + String text, { + bool alsoSendInChannel = false, + }) async { + if (alsoSendInChannel) { + await tester.tapFinder(MessageListPage.composer.alsoSendInChannelCheckbox); + } + return sendMessage(text); + } + + /// Navigates back (out of a thread, or out of the channel). + Future tapBackButton() async { + await tester.tapFinder(MessageListPage.backButton); + return this; + } + + /// Leaves the thread and then the channel, landing back on the channel list. + Future moveToChannelListFromThread() async { + await tapBackButton(); + await tester.waitUntilNotVisible(MessageListPage.threadHeader); + return tapBackButton(); + } + + /// Waits for the message list to be interactive. + Future waitForMessageListToLoad() async { + await tester.waitUntilVisible(find.byType(MessageListPage.composer.inputField)); + return this; + } + + /// Nudges the message list up by one drag. To *arrive* somewhere, scroll + /// until it is visible instead — a fixed number of drags is viewport- + /// dependent. + Future scrollMessageListUp() async { + await tester.scrollMessageList(300); + return this; + } + + /// Scrolls a thread up to its top, paging in older replies as needed. + /// + /// Stops once both the separator and the parent row above it are built, so + /// the parent and any duplicate copy of it among the replies are both built. + /// The separator alone is not enough: the list can build it a whole row + /// before it builds the parent. + Future scrollToTopOfThread() async { + await tester.scrollUpUntil( + () => + MessageListPage.list.threadSeparator.evaluate().isNotEmpty && + MessageListPage.list.threadParent.evaluate().isNotEmpty, + description: 'the top of the thread', + ); + return this; + } + + Future scrollMessageListDown() async { + await tester.scrollMessageList(-300); + return this; + } + + /// Taps the [link] inside a message's text, rather than the middle of the + /// whole text, which may not be part of the link. + Future tapOnLinkText(String link) async { + await tester.waitUntilVisible(find.textContaining(link)); + await tester.tapOnText(find.textRange.ofSubstring(link)); + await tester.settle(); + return this; + } + + Future tapLinkPreview() async { + await tester.tapFinder(MessageListPage.list.linkPreview); + return this; + } + + Future addReaction( + ReactionType type, { + int messageIndex = 0, + }) async { + final reaction = MessageListPage.reactions.pickerReaction(type); + final message = find.byType(MessageListPage.messageItem).at(messageIndex); + await tester.longPressUntilVisible(message, find.byKey(reaction)); + await tester.tapByKey(reaction); + return this; + } + + /// Toggles the reaction [type] through the full emoji sheet behind the + /// reaction picker's "+". + Future toggleReactionUsingExtendedPicker(ReactionType type, {int messageIndex = 0}) async { + final add = find.byKey(MessageListPage.reactions.addReactionButton); + final message = find.byType(MessageListPage.messageItem).at(messageIndex); + await tester.longPressUntilVisible(message, add); + await tester.tapFinder(add); + await tester.tapFinder(MessageListPage.reactions.extendedPickerReaction(type)); + return this; + } + + /// Taps the reaction [type] shown on a message, opening the sheet of who + /// reacted. + Future tapOnMessageReactions(ReactionType type) async { + await tester.tapFinder(find.text(type.emoji)); + await tester.waitUntilVisible(MessageListPage.reactions.authorsSheet); + return this; + } + + /// Switches the reaction authors sheet to the authors of [type]. + Future selectReactionInAuthors(ReactionType type) async { + await tester.tapFinder( + find.descendant(of: MessageListPage.reactions.authorsSheet, matching: find.textContaining(type.emoji)), + ); + return this; + } + + Future closeReactionAuthors() async { + await tester.tapAt(const Offset(10, 120)); + await tester.waitUntilNotVisible(MessageListPage.reactions.authorsSheet); + return this; + } + + /// Re-selecting an own reaction in the picker toggles it off, so deleting a + /// reaction reuses the same flow as adding it. + Future deleteReaction( + ReactionType type, { + int messageIndex = 0, + }) { + return addReaction(type, messageIndex: messageIndex); + } +} + +/// Fluent chaining over async [UserRobot] actions so test steps read like the +/// native robots (`userRobot.login().openChannel()`) instead of a block of +/// sequential `await`s. Each method mirrors the instance method above. +extension UserRobotChain on Future { + Future login([ + UserCredentials user = PredefinedUsers.currentUser, + ]) => then((it) => it.login(user)); + + Future waitForChannelListToLoad() => then((it) => it.waitForChannelListToLoad()); + + Future logout() => then((it) => it.logout()); + + Future openReminders() => then((it) => it.openReminders()); + + Future openChannel({int index = 0}) => then((it) => it.openChannel(index: index)); + + Future sendMessage(String text) => then((it) => it.sendMessage(text)); + + Future typeText(String text) => then((it) => it.typeText(text)); + + Future clearComposer() => then((it) => it.clearComposer()); + + Future tapOnSendButton() => then((it) => it.tapOnSendButton()); + + Future tapOnLinkPreviewCancelButton() => then((it) => it.tapOnLinkPreviewCancelButton()); + + Future attachFile(AttachmentType type, {int count = 1}) => then((it) => it.attachFile(type, count: count)); + + Future openImageInGallery({int index = 0}) => then((it) => it.openImageInGallery(index: index)); + + Future swipeToNextImageInGallery() => then((it) => it.swipeToNextImageInGallery()); + + Future uploadGiphy({bool useComposerCommand = false, bool send = true}) => + then((it) => it.uploadGiphy(useComposerCommand: useComposerCommand, send: send)); + + Future tapOnSendGiphyButton() => then((it) => it.tapOnSendGiphyButton()); + + Future tapOnShuffleGiphyButton() => then((it) => it.tapOnShuffleGiphyButton()); + + Future tapOnCancelGiphyButton() => then((it) => it.tapOnCancelGiphyButton()); + + Future copyMessage({int messageIndex = 0}) => then((it) => it.copyMessage(messageIndex: messageIndex)); + + Future search(String query) => then((it) => it.search(query)); + + Future tapOnSearchResult({int index = 0}) => then((it) => it.tapOnSearchResult(index: index)); + + Future markMessageAsUnread(String text) => then((it) => it.markMessageAsUnread(text)); + + Future tapOnJumpToUnreadButton() => then((it) => it.tapOnJumpToUnreadButton()); + + Future dismissUnreadIndicator() => then((it) => it.dismissUnreadIndicator()); + + Future scrollMessageListUpUntilVisible(String text) => + then((it) => it.scrollMessageListUpUntilVisible(text)); + + Future scrollMessageListDownUntilVisible(String text) => + then((it) => it.scrollMessageListDownUntilVisible(text)); + + Future pinMessage({int messageIndex = 0}) => then((it) => it.pinMessage(messageIndex: messageIndex)); + + Future unpinMessage({int messageIndex = 0}) => then((it) => it.unpinMessage(messageIndex: messageIndex)); + + Future selectReplyFromContextMenu({int messageIndex = 0}) => + then((it) => it.selectReplyFromContextMenu(messageIndex: messageIndex)); + + Future openChannelMenu({int index = 0}) => then((it) => it.openChannelMenu(index: index)); + + Future tapOnChannelAction(String label) => then((it) => it.tapOnChannelAction(label)); + + Future tapOnViewChannelInfo() => then((it) => it.tapOnViewChannelInfo()); + + Future tapOnLeaveGroup() => then((it) => it.tapOnLeaveGroup()); + + Future tapOnDeleteGroup() => then((it) => it.tapOnDeleteGroup()); + + Future confirmChannelAction() => then((it) => it.confirmChannelAction()); + + Future swipeChannel({int index = 0}) => then((it) => it.swipeChannel(index: index)); + + Future tapOnMoreSwipeAction() => then((it) => it.tapOnMoreSwipeAction()); + + Future tapOnMuteSwipeAction() => then((it) => it.tapOnMuteSwipeAction()); + + Future tapOnUnmuteSwipeAction() => then((it) => it.tapOnUnmuteSwipeAction()); + + Future openChannelNamed(String name) => then((it) => it.openChannelNamed(name)); + + Future flagMessage(String text) => then((it) => it.flagMessage(text)); + + Future confirmFlagMessage() => then((it) => it.confirmFlagMessage()); + + Future muteMessageAuthor(String text) => then((it) => it.muteMessageAuthor(text)); + + Future unmuteMessageAuthor(String text) => then((it) => it.unmuteMessageAuthor(text)); + + Future blockMessageAuthor(String text) => then((it) => it.blockMessageAuthor(text)); + + Future unblockMessageAuthor(String text) => then((it) => it.unblockMessageAuthor(text)); + + Future closeMessageActions() => then((it) => it.closeMessageActions()); + + Future tapOnPinnedMessagesOption() => then((it) => it.tapOnPinnedMessagesOption()); + + Future createPoll(String question, List options, {bool multipleAnswers = false}) => + then((it) => it.createPoll(question, options, multipleAnswers: multipleAnswers)); + + Future castPollVote(String option) => then((it) => it.castPollVote(option)); + + Future removePollVote(String option) => then((it) => it.removePollVote(option)); + + Future openPollResults() => then((it) => it.openPollResults()); + + Future endPoll() => then((it) => it.endPoll()); + + Future openThreadList() => then((it) => it.openThreadList()); + + Future openThreadFromThreadList({int index = 0}) => + then((it) => it.openThreadFromThreadList(index: index)); + + Future openComposerCommands() => then((it) => it.openComposerCommands()); + + Future selectCommand(String name) => then((it) => it.selectCommand(name)); + + Future mentionParticipant({bool send = true}) => then((it) => it.mentionParticipant(send: send)); + + Future tapOnMessageList() => then((it) => it.tapOnMessageList()); + + Future tapOnScrollToBottomButton() => then((it) => it.tapOnScrollToBottomButton()); + + Future editMessage(String newText, {int messageIndex = 0}) => + then((it) => it.editMessage(newText, messageIndex: messageIndex)); + + Future deleteMessage({int messageIndex = 0, String? text, bool hard = false}) => + then((it) => it.deleteMessage(messageIndex: messageIndex, text: text, hard: hard)); + + Future copyMessageId(String text) => then((it) => it.copyMessageId(text)); + + Future openMessageByIdFromChannelList({int index = 0}) => + then((it) => it.openMessageByIdFromChannelList(index: index)); + + Future selectSearchScope(String label) => then((it) => it.selectSearchScope(label)); + + Future quoteMessage(String text, {int messageIndex = 0, String? quotedText}) => + then((it) => it.quoteMessage(text, messageIndex: messageIndex, quotedText: quotedText)); + + Future swipeMessage({int messageIndex = 0, String? text}) => + then((it) => it.swipeMessage(messageIndex: messageIndex, text: text)); + + Future tapQuotedMessage() => then((it) => it.tapQuotedMessage()); + + Future tapOnThreadReplyAnnotation() => then((it) => it.tapOnThreadReplyAnnotation()); + + Future waitForMessageListToLoad() => then((it) => it.waitForMessageListToLoad()); + + Future openThread({int messageIndex = 0, String? parentText}) => + then((it) => it.openThread(messageIndex: messageIndex, parentText: parentText)); + + Future openThreadFromReplies() => then((it) => it.openThreadFromReplies()); + + Future sendMessageInThread(String text, {bool alsoSendInChannel = false}) => + then((it) => it.sendMessageInThread(text, alsoSendInChannel: alsoSendInChannel)); + + Future tapBackButton() => then((it) => it.tapBackButton()); + + Future moveToChannelListFromThread() => then((it) => it.moveToChannelListFromThread()); + + Future scrollMessageListUp() => then((it) => it.scrollMessageListUp()); + + Future scrollMessageListDown() => then((it) => it.scrollMessageListDown()); + + Future scrollToTopOfThread() => then((it) => it.scrollToTopOfThread()); + + Future tapLinkPreview() => then((it) => it.tapLinkPreview()); + + Future tapOnLinkText(String link) => then((it) => it.tapOnLinkText(link)); + + Future addReaction(ReactionType type, {int messageIndex = 0}) => + then((it) => it.addReaction(type, messageIndex: messageIndex)); + + Future toggleReactionUsingExtendedPicker(ReactionType type, {int messageIndex = 0}) => + then((it) => it.toggleReactionUsingExtendedPicker(type, messageIndex: messageIndex)); + + Future tapOnMessageReactions(ReactionType type) => then((it) => it.tapOnMessageReactions(type)); + + Future selectReactionInAuthors(ReactionType type) => then((it) => it.selectReactionInAuthors(type)); + + Future closeReactionAuthors() => then((it) => it.closeReactionAuthors()); + + Future deleteReaction(ReactionType type, {int messageIndex = 0}) => + then((it) => it.deleteReaction(type, messageIndex: messageIndex)); +} + +// The smallest valid payload of each type, enough for the SDK to upload and +// for the mock server to answer with a URL of its own. +final _png = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', +); +final _pdf = Uint8List.fromList(utf8.encode('%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n')); +final _mp4 = Uint8List.fromList([0, 0, 0, 24, ...utf8.encode('ftypmp42'), 0, 0, 0, 0, ...utf8.encode('mp42isom')]); diff --git a/sample_app/integration_test/robots/user_robot_channel_list_asserts.dart b/e2e_app/integration_test/robots/user_robot_channel_list_asserts.dart similarity index 52% rename from sample_app/integration_test/robots/user_robot_channel_list_asserts.dart rename to e2e_app/integration_test/robots/user_robot_channel_list_asserts.dart index 9add6ad6cf..e31e314c65 100644 --- a/sample_app/integration_test/robots/user_robot_channel_list_asserts.dart +++ b/e2e_app/integration_test/robots/user_robot_channel_list_asserts.dart @@ -107,6 +107,101 @@ extension UserRobotChannelListAsserts on UserRobot { return this; } + Future assertChannelActionsSheetForGroupChannel() async { + for (final label in const ['View Info', 'Pin Chat', 'Leave Group', 'Delete Group']) { + await tester.waitUntilVisible(ChannelListPage.channelAction(label)); + } + return this; + } + + Future assertGroupChannelInfoScreen() async { + await tester.waitUntilVisible(ChannelInfoPage.groupInfoTitle); + return this; + } + + /// Asserts the user chooser — the app's start page — is shown. + Future assertStartPageIsShown() async { + await tester.waitUntilVisible(find.text('Welcome to Stream Chat')); + return this; + } + + /// Asserts the channel list shows its loading skeleton. + Future assertChannelListIsLoading() async { + // The skeleton is not exported, hence matching on the runtime type. + await tester.waitUntilVisible( + find.byWidgetPredicate((it) => it.runtimeType.toString() == 'StreamChannelListSkeletonLoading'), + ); + return this; + } + + Future assertChannelListIsEmpty() async { + await tester.waitUntilNotVisible(find.byType(ChannelListPage.channelTile)); + return this; + } + + /// Asserts whether the channel row at [channelIndex] shows as muted. + Future assertChannelIsMuted({required bool isMuted, int channelIndex = 0}) async { + final row = find.byType(ChannelListPage.channelTile).at(channelIndex); + await tester.waitUntilVisible(row); + bool muted() => tester.widget(row).isMuted; + final end = DateTime.now().add(const Duration(seconds: 30)); + while (muted() != isMuted && DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(muted(), isMuted, reason: 'channel muted'); + return this; + } + + Future assertTypingIndicatorInChannelPreview({required bool isDisplayed, int channelIndex = 0}) async { + final typing = _inChannel(ChannelListPage.channel.typingIndicator, channelIndex); + if (isDisplayed) { + await tester.waitUntilVisible(typing); + } else { + await tester.waitUntilNotVisible(typing); + } + return this; + } + + /// Asserts the channel row at [channelIndex] is titled [name]. + Future assertChannelName(String name, {required int channelIndex}) async { + await tester.waitUntilVisible(_inChannel(find.text(name), channelIndex)); + return this; + } + + /// Asserts the unread badge of the channel at [channelIndex] reads [count]. + Future assertChannelUnreadCount(int count, {int channelIndex = 0}) async { + final row = find.byType(ChannelListPage.channelTile).at(channelIndex); + await tester.waitUntilVisible(row); + final end = DateTime.now().add(const Duration(seconds: 30)); + int unread() => tester.widget(row).unreadCount; + while (unread() != count && DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(unread(), count, reason: 'channel unread count'); + return this; + } + + /// Asserts the channel list shows exactly [count] channels. + Future assertChannelCount(int count) async { + final tiles = find.byType(ChannelListPage.channelTile); + final end = DateTime.now().add(const Duration(seconds: 30)); + while (tiles.evaluate().length != count && DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(tiles, findsNWidgets(count)); + return this; + } + + Future assertChannelWithName(String name, {bool isDisplayed = true}) async { + final channel = ChannelListPage.channelNamed(name); + if (isDisplayed) { + await tester.waitUntilVisible(channel); + } else { + await tester.waitUntilNotVisible(channel); + } + return this; + } + /// Scopes [finder] to the channel row at [channelIndex] (0 being the top of /// the list), so a neighbouring row can't satisfy an assertion. Finder _inChannel(Finder finder, int channelIndex) => find.descendant( @@ -139,6 +234,34 @@ extension UserRobotChannelListAssertsChain on Future { int channelIndex = 0, }) => then((it) => it.assertMessagePreviewDeliveryStatus(status, channelIndex: channelIndex)); + Future assertChannelActionsSheetForGroupChannel() => + then((it) => it.assertChannelActionsSheetForGroupChannel()); + + Future assertGroupChannelInfoScreen() => then((it) => it.assertGroupChannelInfoScreen()); + + Future assertStartPageIsShown() => then((it) => it.assertStartPageIsShown()); + + Future assertChannelListIsLoading() => then((it) => it.assertChannelListIsLoading()); + + Future assertChannelListIsEmpty() => then((it) => it.assertChannelListIsEmpty()); + + Future assertChannelIsMuted({required bool isMuted, int channelIndex = 0}) => + then((it) => it.assertChannelIsMuted(isMuted: isMuted, channelIndex: channelIndex)); + + Future assertChannelUnreadCount(int count, {int channelIndex = 0}) => + then((it) => it.assertChannelUnreadCount(count, channelIndex: channelIndex)); + + Future assertTypingIndicatorInChannelPreview({required bool isDisplayed, int channelIndex = 0}) => + then((it) => it.assertTypingIndicatorInChannelPreview(isDisplayed: isDisplayed, channelIndex: channelIndex)); + + Future assertChannelName(String name, {required int channelIndex}) => + then((it) => it.assertChannelName(name, channelIndex: channelIndex)); + + Future assertChannelCount(int count) => then((it) => it.assertChannelCount(count)); + + Future assertChannelWithName(String name, {bool isDisplayed = true}) => + then((it) => it.assertChannelWithName(name, isDisplayed: isDisplayed)); + Future assertChannelListPagination({required int channelsCount}) => then((it) => it.assertChannelListPagination(channelsCount: channelsCount)); } diff --git a/e2e_app/integration_test/robots/user_robot_message_list_asserts.dart b/e2e_app/integration_test/robots/user_robot_message_list_asserts.dart new file mode 100644 index 0000000000..9c41a0219e --- /dev/null +++ b/e2e_app/integration_test/robots/user_robot_message_list_asserts.dart @@ -0,0 +1,933 @@ +import 'package:e2e_app/pages/reminders_tab.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart' hide AttachmentType; + +import '../mock_server/data_types.dart'; +import '../pages/channel_list_page.dart'; +import '../pages/message_list_page.dart'; +import '../support/widget_test_extensions.dart'; +import 'backend_robot.dart'; +import 'participant_robot.dart'; +import 'user_robot.dart'; + +extension UserRobotMessageListAsserts on UserRobot { + Future assertMessage(String text, {bool isDisplayed = true}) async { + final message = find.text(text); + if (isDisplayed) { + await tester.waitUntilVisible(message); + } else { + await tester.waitUntilNotVisible(message); + } + return this; + } + + /// Asserts a message whose rendered text merely *contains* [text]. Useful for + /// multi-line messages: the markdown renderer collapses single newlines, so + /// the literal `'a\nb'` never matches — but each token still appears. + Future assertMessageContains(String text) async { + await tester.waitUntilVisible(find.textContaining(text)); + return this; + } + + /// A thread reply is rendered as a regular message inside the thread view. + Future assertThreadReply(String text) => assertMessage(text); + + Future assertEditedMessage(String text) async { + await assertMessage(text); + await tester.waitUntilVisible(MessageListPage.list.editedLabel); + return this; + } + + Future assertDeletedMessage({bool isDisplayed = true}) async { + final deleted = MessageListPage.list.deletedMessage; + if (isDisplayed) { + await tester.waitUntilVisible(deleted); + } else { + await tester.waitUntilNotVisible(deleted); + } + return this; + } + + /// A hard-deleted message vanishes entirely: neither its text nor the + /// soft-delete placeholder remains. + Future assertHardDeletedMessage(String text) async { + await assertMessage(text, isDisplayed: false); + await assertDeletedMessage(isDisplayed: false); + return this; + } + + /// Asserts a quoted reply is (or is not) on screen, and — when [quote] is + /// given — that its quoted bubble previews [quote]. + /// + /// [text] identifies the reply by its own text. Leave it null for a reply that + /// has none (an attachment-only quote, which the mock server sends with no + /// body): the assertion then applies to whichever quoted bubble is on screen. + /// + /// [isDisplayed] `false` is how the native suites check that tapping a quote + /// jumped the list away from the reply. + Future assertQuotedMessage({ + String? text, + String? quote, + bool isDisplayed = true, + }) async { + final reply = switch (text) { + final text? => MessageListPage.list.messageWithText(text), + _ => MessageListPage.list.quotedMessage.first, + }; + + if (!isDisplayed) { + await tester.waitUntilNotVisible(reply); + return this; + } + + await tester.waitUntilVisible(reply); + if (quote == null) return this; + + // Scoping to the reply only makes sense when it was located by its own text; + // otherwise `reply` already *is* the bubble. + final bubbleText = switch (text) { + != null => find.descendant(of: reply, matching: MessageListPage.list.quotedMessageText), + _ => MessageListPage.list.quotedMessageText.first, + }; + await tester.expectRenderedText(bubbleText, quote); + return this; + } + + /// Asserts the message [text] is actually on screen. + /// + /// `hitTestable()` is what makes this a *visibility* check: a plain finder + /// matches a row that is merely built, so it can report a row the list has + /// already scrolled past — which makes "the row is gone" a poor stand-in for + /// "the list scrolled away from it". + Future assertMessageOnScreen(String text, {bool isDisplayed = true}) async { + final message = MessageListPage.list.messageWithText(text).hitTestable(); + if (isDisplayed) { + await tester.waitUntilVisible(message); + } else { + await tester.waitUntilNotVisible(message); + } + return this; + } + + /// Asserts the backend's "unknown command" error message is shown. + Future assertInvalidCommandMessage(String command) async { + await tester.waitUntilVisible(MessageListPage.list.moderatedMessage); + await tester.waitUntilVisible(find.textContaining("command $command doesn't exist")); + return this; + } + + /// Asserts [count] attachments of [type] are staged in the composer. + Future assertAttachmentsInComposer(AttachmentType type, {int count = 1}) async { + final staged = MessageListPage.composer.stagedAttachments(type.attachment); + await tester.waitUntilVisible(staged); + expect(staged, findsNWidgets(count)); + return this; + } + + /// Asserts the newest media message shows [count] images: one renders as a + /// single image, several as a gallery grid of that many tiles. + Future assertImage({required bool isDisplayed, int count = 1}) async { + final images = count == 1 + ? MessageListPage.list.image + : find.descendant(of: MessageListPage.list.gallery, matching: find.byType(StreamMessageAttachment)); + if (!isDisplayed) return _assertVisibility(images, isDisplayed: false); + + await tester.waitUntilVisible(images); + expect(images, findsNWidgets(count)); + return this; + } + + Future assertVideo({required bool isDisplayed}) => + _assertVisibility(MessageListPage.list.video, isDisplayed: isDisplayed); + + Future assertFile({required bool isDisplayed, int count = 1}) async { + final files = MessageListPage.list.file; + if (!isDisplayed) return _assertVisibility(files, isDisplayed: false); + + await tester.waitUntilVisible(files); + expect(files, findsNWidgets(count)); + return this; + } + + /// Asserts the full-screen gallery shows image [position] of [total]. + Future assertGalleryPosition(int position, {required int total}) async { + await tester.waitUntilVisible( + find.descendant(of: MessageListPage.list.mediaGalleryPreview, matching: find.text('$position of $total')), + ); + return this; + } + + Future assertGiphy({required bool isDisplayed}) async { + final giphy = MessageListPage.list.giphy; + if (isDisplayed) { + await tester.waitUntilVisible(giphy); + } else { + await tester.waitUntilNotVisible(giphy); + } + return this; + } + + /// Asserts whether the Send / Shuffle / Cancel buttons of an ephemeral Giphy + /// preview are shown. + Future assertGiphyButtons({required bool areDisplayed}) async { + for (final label in const ['Send', 'Shuffle', 'Cancel']) { + await _assertVisibility(MessageListPage.list.giphyButton(label), isDisplayed: areDisplayed); + } + return this; + } + + /// Asserts the "only visible to you" note an ephemeral message carries. + Future assertOnlyVisibleToYou() async { + await tester.waitUntilVisible( + find.descendant(of: MessageListPage.list.ephemeralMessage, matching: find.textContaining('Only visible to you')), + ); + return this; + } + + /// Asserts the ephemeral message shows no delivery status. + Future assertEphemeralMessageDeliveryStatusHidden() async { + final ephemeral = MessageListPage.list.ephemeralMessage; + await tester.waitUntilVisible(ephemeral); + // The row may build an indicator; what matters is that it shows no status. + for (final status in MessageDeliveryStatus.values.where((it) => it != MessageDeliveryStatus.nil)) { + expect(find.descendant(of: ephemeral, matching: MessageListPage.list.sendingStatus(status)), findsNothing); + } + return this; + } + + Future assertMessagePinnedLabel({String pinnedBy = 'You', bool isDisplayed = true}) => + _assertVisibility(MessageListPage.list.pinnedLabel(pinnedBy: pinnedBy), isDisplayed: isDisplayed); + + Future assertPinnedMessagesScreen() async { + await tester.waitUntilVisible(ChannelInfoPage.pinnedMessagesTitle); + return this; + } + + Future assertMessageInPinnedMessages(String text) async { + await tester.waitUntilVisible( + find.descendant(of: ChannelInfoPage.pinnedMessages, matching: find.textContaining(text)), + ); + return this; + } + + Future assertPinnedMessagesAreEmpty() async { + await tester.waitUntilVisible(ChannelInfoPage.pinnedMessagesEmpty); + expect(ChannelInfoPage.pinnedMessages, findsNothing); + return this; + } + + /// Asserts the slow-mode cooldown is (or is not) running in the composer. + Future assertCooldown({required bool isDisplayed}) => + _assertVisibility(MessageListPage.composer.slowModePlaceholder, isDisplayed: isDisplayed); + + /// Asserts the composer is locked by slow mode: typing is ignored and there + /// is no send button, only the countdown. + Future assertComposerIsDisabledInSlowMode() async { + await assertCooldown(isDisplayed: true); + expect(find.byKey(MessageListPage.composer.sendButton), findsNothing); + return this; + } + + Future assertFlagMessageDialog({required bool isDisplayed}) => + _assertVisibility(MessageListPage.actions.flagDialog, isDisplayed: isDisplayed); + + /// Asserts the actions of the message [text] offer to unmute its author when + /// [isAuthorMuted], and to mute them otherwise. + Future assertMuteMessageAuthorOption(String text, {required bool isAuthorMuted}) async { + const actions = MessageListPage.actions; + final offered = await messageActionsOffered(text, [actions.muteUser, actions.unmuteUser]); + expect(offered, [!isAuthorMuted, isAuthorMuted], reason: '[Mute User, Unmute User] offered'); + return this; + } + + Future assertBlockMessageAuthorOption(String text, {required bool isAuthorBlocked}) async { + const actions = MessageListPage.actions; + final offered = await messageActionsOffered(text, [actions.blockUser, actions.unblockUser]); + expect(offered, [!isAuthorBlocked, isAuthorBlocked], reason: '[Block User, Unblock User] offered'); + return this; + } + + Future assertPollMessage(String question) async { + await tester.waitUntilVisible(find.descendant(of: MessageListPage.list.poll, matching: find.text(question))); + return this; + } + + /// Asserts the poll option [text] is shown and, when [isChecked] is given, + /// whether the user's vote is on it. + Future assertPollOption(String text, {bool? isChecked}) async { + final option = MessageListPage.list.pollOption(text); + await tester.waitUntilVisible(option); + if (isChecked == null) return this; + + bool checked() => + (tester.widget(find.descendant(of: option, matching: _checkbox).first) as dynamic).props.value == true; + final end = DateTime.now().add(const Duration(seconds: 10)); + while (checked() != isChecked && DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(checked(), isChecked, reason: 'poll option "$text" checked'); + return this; + } + + static final _checkbox = find.byWidgetPredicate((it) => it.runtimeType.toString() == 'StreamCheckbox'); + + Future assertPollOptionVoteCount(String text, {required int count}) async { + await tester.waitUntilVisible( + find.descendant(of: MessageListPage.list.pollOption(text), matching: find.text('$count')), + ); + return this; + } + + Future assertPollResults({required String voterName}) async { + await tester.waitUntilVisible(find.text(voterName)); + return this; + } + + /// A closed poll replaces its voting-mode subtitle with "Vote ended". + Future assertPollClosed() async { + await tester.waitUntilVisible(find.descendant(of: MessageListPage.list.poll, matching: find.text('Vote ended'))); + return this; + } + + Future assertThreadListIsEmpty() async { + await tester.waitUntilVisible(find.text('Reply to a message to start a thread')); + expect(find.byType(StreamThreadListTile), findsNothing); + return this; + } + + Future assertThreadInThreadList(String parentText, {required int replies}) async { + final tile = find.byType(StreamThreadListTile); + await tester.waitUntilVisible(find.descendant(of: tile, matching: find.textContaining(parentText))); + await tester.waitUntilVisible( + find.descendant(of: tile, matching: find.text(replies == 1 ? '1 reply' : '$replies replies')), + ); + return this; + } + + /// Asserts the unread separator is shown, reading [unreadCount] when given. + Future assertUnreadSeparator({int? unreadCount, bool isDisplayed = true}) async { + final separator = MessageListPage.list.unreadSeparator; + if (!isDisplayed) return _assertVisibility(separator, isDisplayed: false); + + await tester.waitUntilVisible(separator); + if (unreadCount != null) { + final label = unreadCount == 1 ? '1 unread message' : '$unreadCount unread messages'; + await tester.waitUntilVisible(find.descendant(of: separator, matching: find.text(label))); + } + return this; + } + + /// Asserts the jump-to-unread pill is shown, reading [unreadCount] when given. + Future assertJumpToUnreadButton({int? unreadCount, bool isDisplayed = true}) async { + final button = MessageListPage.list.jumpToUnreadButton; + final parts = MessageListPage.list.jumpToUnreadButtonParts; + if (!isDisplayed) return _assertVisibility(parts, isDisplayed: false); + + await tester.waitUntilVisible(parts); + if (unreadCount != null) { + await tester.waitUntilVisible(find.descendant(of: button, matching: find.text('$unreadCount unread'))); + } + return this; + } + + Future assertMessageInSearchResults(String text) async { + await tester.waitUntilVisible( + find.descendant(of: ChannelListPage.searchResults, matching: find.textContaining(text)), + ); + return this; + } + + Future assertMessageCopied(String text) async { + final end = DateTime.now().add(const Duration(seconds: 5)); + String? copied; + while (DateTime.now().isBefore(end)) { + copied = (await Clipboard.getData(Clipboard.kTextPlain))?.text; + if (copied == text) break; + await tester.pump(const Duration(milliseconds: 100)); + } + expect(copied, text); + return this; + } + + /// Asserts the newest message named [text] is attributed to [authorName]. + Future assertMessageAuthor(String text, {required String authorName}) async { + final message = MessageListPage.list.messageWithText(text); + await tester.waitUntilVisible(message); + expect(tester.widget(message.first).props.message.user?.name, authorName); + return this; + } + + Future assertRemindersScreen() async { + await tester.waitUntilVisible(find.byType(RemindersTab)); + return this; + } + + Future assertRemindersAreEmpty() async { + await tester.waitUntilVisible(find.text('No reminders yet')); + expect(find.byType(MessageReminderListTile), findsNothing); + return this; + } + + /// Asserts the reminders screen lists the message [text], saved for later + /// (no due date) when [savedForLater], or due at a set time otherwise. + Future assertReminder(String text, {bool savedForLater = false}) async { + final tile = find.ancestor(of: find.text(text), matching: find.byType(MessageReminderListTile)); + await tester.waitUntilVisible(tile); + final status = savedForLater ? find.byType(SavedForLaterIndicator) : find.byType(TimedReminderIndicator); + await tester.waitUntilVisible(find.descendant(of: tile, matching: status)); + return this; + } + + /// Asserts the annotation above the newest message: "Saved for later" for a + /// reminder without a due date, "Reminder set" for one with. + Future assertMessageReminder({required bool remindAtIsSet}) async { + await tester.waitUntilVisible(find.text(remindAtIsSet ? 'Reminder set' : 'Saved for later')); + return this; + } + + /// Asserts the reaction authors sheet does (or does not) list [name]. + Future assertReactionAuthor(String name, {bool isDisplayed = true}) => _assertVisibility( + find.descendant(of: MessageListPage.reactions.authorsSheet, matching: find.text(name)), + isDisplayed: isDisplayed, + ); + + /// Asserts the authors sheet lists the user's own reaction. The sheet names + /// every author, the user included, and marks the own row with + /// `tapToRemoveReactionLabel` — the counterpart of the native "You". + Future assertOwnReactionInAuthors() async { + await tester.waitUntilVisible( + find.descendant(of: MessageListPage.reactions.authorsSheet, matching: find.text('Tap to remove')), + ); + return this; + } + + /// Asserts the thread-reply count, either on the parent's footer in the + /// channel or on the separator inside the thread. + /// + /// The two go through different translations — the footer uses + /// `threadReplyCountText`, the separator `threadSeparatorText` — but both read + /// the same in English, so [inThread] only picks which one to look at. + Future assertThreadReplyLabel({ + required int replies, + bool inThread = false, + }) async { + final label = replies == 1 ? '1 reply' : '$replies replies'; + + final scope = switch (inThread) { + true => find.text(label), + false => find.descendant(of: MessageListPage.list.threadReplies, matching: find.text(label)), + }; + await tester.waitUntilVisible(scope); + return this; + } + + Future assertThreadReplyLabelAvatars({required int count}) async { + await tester.waitUntilVisible(MessageListPage.list.threadReplies); + expect(MessageListPage.list.threadRepliesAvatars, findsNWidgets(count)); + return this; + } + + /// Asserts how many rows currently render the message [text]. + Future assertMessages({required String text, required int count}) async { + final messages = MessageListPage.list.messageWithText(text); + + final end = DateTime.now().add(const Duration(seconds: 30)); + while (messages.evaluate().length != count && DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(messages, findsNWidgets(count)); + return this; + } + + /// Asserts the message list holds exactly [count] rows. + /// + /// `buildMessage` returns a row of its own — never a [MessageListPage.messageItem] — + /// for system, ephemeral and error messages, so all four types are counted here, + /// mirroring the native cell count. The list is lazy, so this is only meaningful + /// for counts small enough to fit on screen. + Future assertMessageCount(int count) async { + int rowCount() => + find.byType(MessageListPage.messageItem).evaluate().length + + MessageListPage.list.systemMessage.evaluate().length + + MessageListPage.list.ephemeralMessage.evaluate().length + + MessageListPage.list.moderatedMessage.evaluate().length; + + final end = DateTime.now().add(const Duration(seconds: 30)); + while (rowCount() != count && DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(rowCount(), count); + return this; + } + + /// Asserts whether the floating scroll-to-bottom button is shown. + /// + /// When it is not, its unread-count badge cannot be either — the SDK only + /// wraps the button once the count is above zero — which is what the native + /// `assertScrollToBottomButtonUnreadCount(0)` checks. + Future assertScrollToBottomButton({ + required bool isDisplayed, + int? unreadCount, + }) async { + final button = MessageListPage.list.scrollToBottomButton; + final badge = MessageListPage.list.scrollToBottomUnreadBadge; + + if (isDisplayed) { + await tester.waitUntilVisible(button); + } else { + await tester.waitUntilNotVisible(button); + await tester.waitUntilNotVisible(badge); + } + + // The SDK only wraps the button in a badge once the count is above zero, so + // an expected count of 0 is asserted as the badge being absent. + switch (unreadCount) { + case null: + break; + case 0: + await tester.waitUntilNotVisible(badge); + case final count: + await tester.expectRenderedText(find.descendant(of: badge, matching: find.byType(Text)), '$count'); + } + return this; + } + + Future assertTypingIndicator({required bool isDisplayed}) async { + final indicator = MessageListPage.list.typingIndicator; + if (isDisplayed) { + await tester.waitUntilVisible(indicator); + } else { + await tester.waitUntilNotVisible(indicator); + } + return this; + } + + Future assertMentionsOverlay({required bool isDisplayed}) async { + final overlay = MessageListPage.composer.mentionsOverlay; + if (isDisplayed) { + await tester.waitUntilVisible(overlay); + } else { + await tester.waitUntilNotVisible(overlay); + } + return this; + } + + Future assertComposerText(String expected) async { + final end = DateTime.now().add(const Duration(seconds: 10)); + while (composerText != expected && DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(composerText, expected); + return this; + } + + /// A mention picked from the overlay is completed with a trailing space. + Future assertMentionWasApplied() => assertComposerText('@${ParticipantRobot.name} '); + + Future assertComposerAttachmentsButton({required bool isDisplayed}) => + _assertVisibility(MessageListPage.composer.attachmentsButton, isDisplayed: isDisplayed); + + Future assertComposerCommands({required bool isDisplayed}) => + _assertVisibility(MessageListPage.composer.commandsOverlay, isDisplayed: isDisplayed); + + /// Asserts how many message timestamps are rendered, i.e. how many message + /// groups the visible messages form. + Future assertMessageTimestamps(int count) async { + final timestamps = MessageListPage.list.timestamps; + final end = DateTime.now().add(const Duration(seconds: 10)); + while (timestamps.evaluate().length != count && DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(timestamps, findsNWidgets(count)); + return this; + } + + /// Asserts the annotation a thread reply sent also to the channel carries, + /// in the channel or inside the thread. + Future assertAlsoInTheChannelLabel({required bool inThread}) async { + await tester.waitUntilVisible(MessageListPage.list.showInChannelAnnotation(inThread: inThread)); + return this; + } + + Future _assertVisibility(Finder finder, {required bool isDisplayed}) async { + if (isDisplayed) { + await tester.waitUntilVisible(finder); + } else { + await tester.waitUntilNotVisible(finder); + } + return this; + } + + Future assertLinkPreview() async { + await tester.waitUntilVisible(MessageListPage.list.linkPreview); + return this; + } + + /// Asserts the message list does (or does not) show a link preview card. + /// + /// Absence is asserted only once the message itself has rendered — waiting + /// for "not there" right away would pass before the message even arrives. + Future assertLinkPreviewInMessageList({required bool isDisplayed}) => + _assertVisibility(MessageListPage.list.linkPreview, isDisplayed: isDisplayed); + + Future assertLinkPreviewInComposer({required bool isDisplayed}) => + _assertVisibility(MessageListPage.composer.linkPreview, isDisplayed: isDisplayed); + + /// Asserts the message [text] is rendered and its link is tappable, i.e. the + /// markdown renderer turned the URL into a link span. + Future assertMessageWithLink(String text, {required String link}) async { + final message = MessageListPage.list.messageWithText(text); + await tester.waitUntilVisible(message); + final linkSpans = find.descendant( + of: message, + matching: find.byWidgetPredicate((widget) { + if (widget is! RichText) return false; + var found = false; + widget.text.visitChildren((span) { + if (span is TextSpan && span.recognizer != null && (span.text?.contains(link) ?? false)) found = true; + return !found; + }); + return found; + }, description: 'tappable link "$link"'), + ); + await tester.waitUntilVisible(linkSpans); + return this; + } + + /// Asserts the message at [messageIndex] is marked as failed to be sent. + /// Mirrors the native `assertMessageFailedToBeSent`. + Future assertMessageFailedToBeSent({int messageIndex = 0}) async { + await _waitOnMessage(MessageListPage.list.errorBadge, messageIndex: messageIndex); + return this; + } + + /// Asserts the delivery status shown on the message at [messageIndex]. + /// + /// With [count], asserts instead how many messages in the list show + /// [status] — the SDK only shows it on the last message of a group. + /// + /// [MessageDeliveryStatus.nil] is the absence of any status on the message: + /// the indicator either renders nothing or is not built at all (a deleted or + /// an ephemeral message has none). + Future assertMessageDeliveryStatus( + MessageDeliveryStatus status, { + int messageIndex = 0, + int? count, + }) async { + if (count != null) { + final statuses = MessageListPage.list.sendingStatus(status); + final end = DateTime.now().add(const Duration(seconds: 30)); + while (statuses.evaluate().length != count && DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(statuses, findsNWidgets(count)); + return this; + } + + if (status == MessageDeliveryStatus.nil) { + final message = find.byType(MessageListPage.messageItem).at(messageIndex); + await tester.waitUntilVisible(message); + // A failed message's indicator renders nothing (the error badge marks it), + // so `failed` is not a status shown here. + const shown = [ + MessageDeliveryStatus.read, + MessageDeliveryStatus.delivered, + MessageDeliveryStatus.pending, + MessageDeliveryStatus.sent, + ]; + for (final shown in shown) { + await tester.waitUntilNotVisible( + find.descendant(of: message, matching: MessageListPage.list.sendingStatus(shown)), + ); + } + return this; + } + + await _waitOnMessage(MessageListPage.list.sendingStatus(status), messageIndex: messageIndex); + return this; + } + + /// Waits for [finder] to appear inside the message at [messageIndex] (index 0 + /// being the newest message), so a status on an older message can't satisfy + /// the assertion. + Future _waitOnMessage(Finder finder, {required int messageIndex}) { + final message = find.byType(MessageListPage.messageItem).at(messageIndex); + return tester.waitUntilVisible(find.descendant(of: message, matching: finder)); + } + + /// Scrolls up through the history until all [messagesCount] messages are + /// loaded and the oldest one is on screen. + /// + /// Both ends come from the channel's own state instead of the message text: + /// what the mock server seeds as text depends on how the channel was generated + /// (see `messagesText` on [BackendRobot.generateChannels]), message identity + /// does not. + Future assertMessageListPagination({required int messagesCount}) async { + await tester.waitUntilVisible(find.byType(MessageListPage.list.view)); + + // Pages in whatever the channel query didn't return. The mock server hands + // over the whole history up front, so today this holds without scrolling. + await tester.scrollUpUntil( + () => _loadedMessages.length >= messagesCount, + description: 'all $messagesCount messages to load', + ); + expect(_loadedMessages, hasLength(messagesCount)); + + final oldest = MessageListPage.list.message(_loadedMessages.first.id); + // Guards against a vacuous pass: the oldest message has to be out of view + // (i.e. actually reached by scrolling), and it would be on screen already if + // the loaded history were ordered newest-first. + expect(oldest, findsNothing); + await tester.scrollUpUntil( + () => oldest.evaluate().isNotEmpty, + description: 'the oldest message to be reached', + ); + return this; + } + + /// The slice of the history the message list has loaded so far, oldest + /// first: the channel's messages, or inside a thread the parent followed by + /// its replies. + List get _loadedMessages { + final view = find.byType(MessageListPage.list.view); + final state = StreamChannel.of(tester.element(view)).channel.state; + final parent = tester.widget(view).parentMessage; + if (parent == null) return state?.messages ?? const []; + return [parent, ...?state?.threads[parent.id]]; + } + + /// Edits the first message to [newText] and asserts its cell grew (or shrank) + /// accordingly. + Future assertMessageSizeChangesAfterEditing( + String newText, { + required bool increased, + }) async { + final message = find.byType(MessageListPage.messageItem); + final before = tester.getSize(message.first).height; + + await editMessage(newText); + await assertMessage(newText); + + final after = tester.getSize(message.first).height; + if (increased) { + expect(after, greaterThan(before)); + } else { + expect(after, lessThan(before)); + } + return this; + } + + /// Asserts the composer grows with multi-line input but stops growing once it + /// reaches its height cap. + Future assertComposerGrowsWithinLimit() async { + final field = find.byType(MessageListPage.composer.inputField); + + final emptyHeight = tester.getSize(field).height; + await typeText('1\n2\n3'); + expect(tester.getSize(field).height, greaterThan(emptyHeight)); + + await typeText('1\n2\n3\n4\n5\n6\n7\n8\n9\n10'); + final cappedHeight = tester.getSize(field).height; + await typeText('1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12'); + expect(tester.getSize(field).height, cappedHeight); + + await clearComposer(); + return this; + } + + Future assertReaction({ + required ReactionType type, + required bool isDisplayed, + }) async { + // Reaction display chips carry no keys, so they're located by emoji glyph. + final reaction = find.text(type.emoji); + if (isDisplayed) { + await tester.waitUntilVisible(reaction); + } else { + await tester.waitUntilNotVisible(reaction); + } + return this; + } +} + +/// Chainable counterparts to [UserRobotMessageListAsserts], so an assertion can +/// follow a fluent action chain (`userRobot.addReaction(x).assertReaction(...)`). +extension UserRobotMessageListAssertsChain on Future { + Future assertMessage(String text, {bool isDisplayed = true}) => + then((it) => it.assertMessage(text, isDisplayed: isDisplayed)); + + Future assertMessageContains(String text) => then((it) => it.assertMessageContains(text)); + + Future assertThreadReply(String text) => then((it) => it.assertThreadReply(text)); + + Future assertEditedMessage(String text) => then((it) => it.assertEditedMessage(text)); + + Future assertDeletedMessage({bool isDisplayed = true}) => + then((it) => it.assertDeletedMessage(isDisplayed: isDisplayed)); + + Future assertHardDeletedMessage(String text) => then((it) => it.assertHardDeletedMessage(text)); + + Future assertQuotedMessage({String? text, String? quote, bool isDisplayed = true}) => + then((it) => it.assertQuotedMessage(text: text, quote: quote, isDisplayed: isDisplayed)); + + Future assertMessageOnScreen(String text, {bool isDisplayed = true}) => + then((it) => it.assertMessageOnScreen(text, isDisplayed: isDisplayed)); + + Future assertInvalidCommandMessage(String command) => + then((it) => it.assertInvalidCommandMessage(command)); + + Future assertGiphy({required bool isDisplayed}) => then((it) => it.assertGiphy(isDisplayed: isDisplayed)); + + Future assertGiphyButtons({required bool areDisplayed}) => + then((it) => it.assertGiphyButtons(areDisplayed: areDisplayed)); + + Future assertOnlyVisibleToYou() => then((it) => it.assertOnlyVisibleToYou()); + + Future assertMessageInSearchResults(String text) => then((it) => it.assertMessageInSearchResults(text)); + + Future assertMessageAuthor(String text, {required String authorName}) => + then((it) => it.assertMessageAuthor(text, authorName: authorName)); + + Future assertRemindersScreen() => then((it) => it.assertRemindersScreen()); + + Future assertRemindersAreEmpty() => then((it) => it.assertRemindersAreEmpty()); + + Future assertReminder(String text, {bool savedForLater = false}) => + then((it) => it.assertReminder(text, savedForLater: savedForLater)); + + Future assertMessageReminder({required bool remindAtIsSet}) => + then((it) => it.assertMessageReminder(remindAtIsSet: remindAtIsSet)); + + Future assertReactionAuthor(String name, {bool isDisplayed = true}) => + then((it) => it.assertReactionAuthor(name, isDisplayed: isDisplayed)); + + Future assertOwnReactionInAuthors() => then((it) => it.assertOwnReactionInAuthors()); + + Future assertMessageCopied(String text) => then((it) => it.assertMessageCopied(text)); + + Future assertUnreadSeparator({int? unreadCount, bool isDisplayed = true}) => + then((it) => it.assertUnreadSeparator(unreadCount: unreadCount, isDisplayed: isDisplayed)); + + Future assertJumpToUnreadButton({int? unreadCount, bool isDisplayed = true}) => + then((it) => it.assertJumpToUnreadButton(unreadCount: unreadCount, isDisplayed: isDisplayed)); + + Future assertPollMessage(String question) => then((it) => it.assertPollMessage(question)); + + Future assertPollOption(String text, {bool? isChecked}) => + then((it) => it.assertPollOption(text, isChecked: isChecked)); + + Future assertPollOptionVoteCount(String text, {required int count}) => + then((it) => it.assertPollOptionVoteCount(text, count: count)); + + Future assertPollResults({required String voterName}) => + then((it) => it.assertPollResults(voterName: voterName)); + + Future assertPollClosed() => then((it) => it.assertPollClosed()); + + Future assertThreadListIsEmpty() => then((it) => it.assertThreadListIsEmpty()); + + Future assertThreadInThreadList(String parentText, {required int replies}) => + then((it) => it.assertThreadInThreadList(parentText, replies: replies)); + + Future assertFlagMessageDialog({required bool isDisplayed}) => + then((it) => it.assertFlagMessageDialog(isDisplayed: isDisplayed)); + + Future assertMuteMessageAuthorOption(String text, {required bool isAuthorMuted}) => + then((it) => it.assertMuteMessageAuthorOption(text, isAuthorMuted: isAuthorMuted)); + + Future assertBlockMessageAuthorOption(String text, {required bool isAuthorBlocked}) => + then((it) => it.assertBlockMessageAuthorOption(text, isAuthorBlocked: isAuthorBlocked)); + + Future assertMessagePinnedLabel({String pinnedBy = 'You', bool isDisplayed = true}) => + then((it) => it.assertMessagePinnedLabel(pinnedBy: pinnedBy, isDisplayed: isDisplayed)); + + Future assertPinnedMessagesScreen() => then((it) => it.assertPinnedMessagesScreen()); + + Future assertMessageInPinnedMessages(String text) => then((it) => it.assertMessageInPinnedMessages(text)); + + Future assertPinnedMessagesAreEmpty() => then((it) => it.assertPinnedMessagesAreEmpty()); + + Future assertCooldown({required bool isDisplayed}) => + then((it) => it.assertCooldown(isDisplayed: isDisplayed)); + + Future assertComposerIsDisabledInSlowMode() => then((it) => it.assertComposerIsDisabledInSlowMode()); + + Future assertEphemeralMessageDeliveryStatusHidden() => + then((it) => it.assertEphemeralMessageDeliveryStatusHidden()); + + Future assertAttachmentsInComposer(AttachmentType type, {int count = 1}) => + then((it) => it.assertAttachmentsInComposer(type, count: count)); + + Future assertImage({required bool isDisplayed, int count = 1}) => + then((it) => it.assertImage(isDisplayed: isDisplayed, count: count)); + + Future assertVideo({required bool isDisplayed}) => then((it) => it.assertVideo(isDisplayed: isDisplayed)); + + Future assertFile({required bool isDisplayed, int count = 1}) => + then((it) => it.assertFile(isDisplayed: isDisplayed, count: count)); + + Future assertGalleryPosition(int position, {required int total}) => + then((it) => it.assertGalleryPosition(position, total: total)); + + Future assertThreadReplyLabel({required int replies, bool inThread = false}) => + then((it) => it.assertThreadReplyLabel(replies: replies, inThread: inThread)); + + Future assertThreadReplyLabelAvatars({required int count}) => + then((it) => it.assertThreadReplyLabelAvatars(count: count)); + + Future assertMessages({required String text, required int count}) => + then((it) => it.assertMessages(text: text, count: count)); + + Future assertMessageCount(int count) => then((it) => it.assertMessageCount(count)); + + Future assertScrollToBottomButton({required bool isDisplayed, int? unreadCount}) => + then((it) => it.assertScrollToBottomButton(isDisplayed: isDisplayed, unreadCount: unreadCount)); + + Future assertTypingIndicator({required bool isDisplayed}) => + then((it) => it.assertTypingIndicator(isDisplayed: isDisplayed)); + + Future assertMentionsOverlay({required bool isDisplayed}) => + then((it) => it.assertMentionsOverlay(isDisplayed: isDisplayed)); + + Future assertLinkPreview() => then((it) => it.assertLinkPreview()); + + Future assertLinkPreviewInMessageList({required bool isDisplayed}) => + then((it) => it.assertLinkPreviewInMessageList(isDisplayed: isDisplayed)); + + Future assertLinkPreviewInComposer({required bool isDisplayed}) => + then((it) => it.assertLinkPreviewInComposer(isDisplayed: isDisplayed)); + + Future assertMessageWithLink(String text, {required String link}) => + then((it) => it.assertMessageWithLink(text, link: link)); + + Future assertComposerText(String expected) => then((it) => it.assertComposerText(expected)); + + Future assertMentionWasApplied() => then((it) => it.assertMentionWasApplied()); + + Future assertComposerAttachmentsButton({required bool isDisplayed}) => + then((it) => it.assertComposerAttachmentsButton(isDisplayed: isDisplayed)); + + Future assertComposerCommands({required bool isDisplayed}) => + then((it) => it.assertComposerCommands(isDisplayed: isDisplayed)); + + Future assertMessageTimestamps(int count) => then((it) => it.assertMessageTimestamps(count)); + + Future assertAlsoInTheChannelLabel({required bool inThread}) => + then((it) => it.assertAlsoInTheChannelLabel(inThread: inThread)); + + Future assertMessageFailedToBeSent({int messageIndex = 0}) => + then((it) => it.assertMessageFailedToBeSent(messageIndex: messageIndex)); + + Future assertMessageDeliveryStatus( + MessageDeliveryStatus status, { + int messageIndex = 0, + int? count, + }) => then((it) => it.assertMessageDeliveryStatus(status, messageIndex: messageIndex, count: count)); + + Future assertReaction({ + required ReactionType type, + required bool isDisplayed, + }) => then((it) => it.assertReaction(type: type, isDisplayed: isDisplayed)); +} diff --git a/e2e_app/integration_test/search_test.dart b/e2e_app/integration_test/search_test.dart new file mode 100644 index 0000000000..f5c13a97b8 --- /dev/null +++ b/e2e_app/integration_test/search_test.dart @@ -0,0 +1,48 @@ +import 'robots/user_robot.dart'; +import 'robots/user_robot_channel_list_asserts.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + const sampleText = 'Test'; + + streamTestWithEnv( + description: 'user searches for a message', + body: (env) async { + step('GIVEN the user opens the channel list'); + await env.userRobot.login().waitForChannelListToLoad(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessageInChannelPreview(sampleText, fromCurrentUser: false); + + step('WHEN the user searches for the message on the channel list'); + await env.userRobot.search(sampleText); + + step('THEN the message is shown in the search results'); + await env.userRobot.assertMessageInSearchResults(sampleText); + }, + ); + + streamTestWithEnv( + description: 'user opens a message from the search results', + body: (env) async { + step('GIVEN the user opens the channel list'); + await env.userRobot.login().waitForChannelListToLoad(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot.assertMessageInChannelPreview(sampleText, fromCurrentUser: false); + + step('AND the user searches for the message on the channel list'); + await env.userRobot.search(sampleText).assertMessageInSearchResults(sampleText); + + step('WHEN the user taps the search result'); + await env.userRobot.tapOnSearchResult(); + + step('THEN the message list is opened on the message'); + await env.userRobot.waitForMessageListToLoad().assertMessageOnScreen(sampleText); + }, + ); +} diff --git a/e2e_app/integration_test/slow_mode_test.dart b/e2e_app/integration_test/slow_mode_test.dart new file mode 100644 index 0000000000..3cdcce16c1 --- /dev/null +++ b/e2e_app/integration_test/slow_mode_test.dart @@ -0,0 +1,129 @@ +import 'robots/user_robot.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + // Long enough that the cooldown cannot run out between sending and asserting + // on a slow CI device; no test waits for it to end. + const cooldownDuration = 60; + const message = 'message'; + const replyMessage = 'reply message'; + const editedMessage = 'edited message'; + + streamTestWithEnv( + description: 'cooldown is shown when a new message is sent', + body: (env) async { + step('GIVEN slow mode is enabled on the channel'); + await env.backendRobot.setCooldown(enabled: true, duration: cooldownDuration); + + step('AND the user opens the channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a new message'); + await env.userRobot.sendMessage(message); + + step('THEN slow mode is active and the cooldown is shown'); + await env.userRobot.assertCooldown(isDisplayed: true); + }, + ); + + streamTestWithEnv( + description: 'composer is disabled while slow mode is active', + body: (env) async { + step('GIVEN slow mode is enabled on the channel'); + await env.backendRobot.setCooldown(enabled: true, duration: cooldownDuration); + + step('AND the user opens the channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user sends a new message'); + await env.userRobot.sendMessage(message); + + step('THEN the cooldown is shown and the composer is locked'); + await env.userRobot.assertComposerIsDisabledInSlowMode(); + }, + ); + + streamTestWithEnv( + description: 'cooldown is shown when the user replies to a message', + body: (env) async { + step('GIVEN slow mode is enabled on the channel'); + await env.backendRobot.setCooldown(enabled: true, duration: cooldownDuration); + + step('AND the user opens the channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a new message'); + await env.participantRobot.sendMessage(message); + await env.userRobot.assertMessage(message); + + step('WHEN the user replies to the message'); + await env.userRobot.quoteMessage(replyMessage); + + step('THEN slow mode is active and the cooldown is shown'); + await env.userRobot.assertCooldown(isDisplayed: true); + + step('AND the reply is sent'); + await env.userRobot.assertQuotedMessage(text: replyMessage, quote: message); + }, + ); + + streamTestWithEnv( + description: 'message cannot be replied to while the cooldown is shown', + body: (env) async { + step('GIVEN slow mode is enabled on the channel'); + await env.backendRobot.setCooldown(enabled: true, duration: cooldownDuration); + + step('AND the user opens the channel'); + await env.userRobot.login().openChannel(); + + step('AND the user sends a new message'); + await env.userRobot.sendMessage(message).assertMessage(message); + + step('WHEN the user selects reply from the message actions'); + await env.userRobot.selectReplyFromContextMenu(); + + step('THEN the cooldown is shown and the composer is locked'); + await env.userRobot.assertComposerIsDisabledInSlowMode(); + }, + ); + + streamTestWithEnv( + description: 'cooldown carries over into a thread', + body: (env) async { + step('GIVEN slow mode is enabled on the channel'); + await env.backendRobot.setCooldown(enabled: true, duration: cooldownDuration); + + step('AND the user opens the channel'); + await env.userRobot.login().openChannel(); + + step('AND the user sends a new message'); + await env.userRobot.sendMessage(message).assertMessage(message); + + step('WHEN the user opens the thread of the message'); + await env.userRobot.openThread(); + + step('THEN the cooldown is shown and the composer is locked in the thread'); + await env.userRobot.assertComposerIsDisabledInSlowMode(); + }, + ); + + streamTestWithEnv( + description: 'cooldown is not shown when a message is edited', + body: (env) async { + step('GIVEN slow mode is enabled on a channel with a message'); + await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); + await env.backendRobot.setCooldown(enabled: true, duration: cooldownDuration); + + step('AND the user opens the channel'); + await env.userRobot.login().openChannel(); + + step('WHEN the user edits the message'); + await env.userRobot.editMessage(editedMessage).assertEditedMessage(editedMessage); + + step('THEN slow mode is not active and the cooldown is not shown'); + await env.userRobot.assertCooldown(isDisplayed: false); + }, + ); +} diff --git a/sample_app/integration_test/support/failure_artifacts.dart b/e2e_app/integration_test/support/failure_artifacts.dart similarity index 100% rename from sample_app/integration_test/support/failure_artifacts.dart rename to e2e_app/integration_test/support/failure_artifacts.dart diff --git a/sample_app/integration_test/support/fake_url_launcher.dart b/e2e_app/integration_test/support/fake_url_launcher.dart similarity index 100% rename from sample_app/integration_test/support/fake_url_launcher.dart rename to e2e_app/integration_test/support/fake_url_launcher.dart diff --git a/e2e_app/integration_test/support/predefined_users.dart b/e2e_app/integration_test/support/predefined_users.dart new file mode 100644 index 0000000000..26b6a13c12 --- /dev/null +++ b/e2e_app/integration_test/support/predefined_users.dart @@ -0,0 +1,18 @@ +class UserCredentials { + const UserCredentials({required this.id, required this.name}); + + final String id; + final String name; +} + +abstract final class PredefinedUsers { + static const qaTest1 = UserCredentials(id: 'qatest1', name: 'QA test 1'); + static const qaTest2 = UserCredentials(id: 'qatest2', name: 'QA test 2'); + + static const currentUser = qaTest1; + + /// Who the mock server takes the logged-in user to be, whichever user was + /// picked: it answers every connect with its own fixture user, so this is + /// the id the app ends up with as its current user. + static const mockServerUser = UserCredentials(id: 'luke_skywalker', name: 'Luke Skywalker'); +} diff --git a/sample_app/integration_test/support/step.dart b/e2e_app/integration_test/support/step.dart similarity index 100% rename from sample_app/integration_test/support/step.dart rename to e2e_app/integration_test/support/step.dart diff --git a/sample_app/integration_test/support/stream_test_case.dart b/e2e_app/integration_test/support/stream_test_case.dart similarity index 84% rename from sample_app/integration_test/support/stream_test_case.dart rename to e2e_app/integration_test/support/stream_test_case.dart index 3c6782a877..51e1cd8b88 100644 --- a/sample_app/integration_test/support/stream_test_case.dart +++ b/e2e_app/integration_test/support/stream_test_case.dart @@ -11,10 +11,13 @@ import '../allure/allure.dart'; import 'failure_artifacts.dart'; import 'stream_test_env.dart'; -/// The test file currently running, injected by the Fastlane lane -/// (`--dart-define=E2E_TARGET=...`). Lets the lane attach that file's host-side -/// video recording to its failed tests. Empty when run ad-hoc. -const _e2eTarget = String.fromEnvironment('E2E_TARGET'); +/// The test file declaring tests, which lets the lane attach the host-side video +/// recording to that file's failed tests. Empty when run ad-hoc. +/// +/// A file run on its own gets it from the lane (`--dart-define=E2E_TARGET=...`). +/// A CI batch runs several files in one app process from a generated entrypoint, +/// which sets it before calling each file's `main()`. +String e2eTestFile = const String.fromEnvironment('E2E_TARGET'); /// The platforms the e2e suite runs on. /// @@ -48,6 +51,8 @@ void streamTest({ Set? skipPlatforms, }) { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + // Read while the test is declared: by the time it runs, a batch has moved on to the next file. + final testFile = e2eTestFile; testWidgets(description, skip: skipsHere(skip, skipPlatforms), (tester) async { Allure.instance.startTest( @@ -55,7 +60,7 @@ void streamTest({ fullName: Invoker.current?.liveTest.test.name ?? description, labels: { if (allureId != null) 'AS_ID': allureId, - if (_e2eTarget.isNotEmpty) 'testFile': _e2eTarget, + if (testFile.isNotEmpty) 'testFile': testFile, }, ); @@ -107,6 +112,7 @@ void streamTestWithEnv({ String? skip, Set? skipPlatforms, bool persistence = false, + bool jwt = false, }) { streamTest( allureId: allureId, @@ -118,7 +124,7 @@ void streamTestWithEnv({ // Registered before setUp so cleanup also runs when setup fails partway // (e.g. the mock server started but the app failed to boot). addTearDown(env.tearDown); - await env.setUp(tester, persistence: persistence); + await env.setUp(tester, persistence: persistence, jwt: jwt); await body(env); }, ); diff --git a/sample_app/integration_test/support/stream_test_env.dart b/e2e_app/integration_test/support/stream_test_env.dart similarity index 77% rename from sample_app/integration_test/support/stream_test_env.dart rename to e2e_app/integration_test/support/stream_test_env.dart index 2dea50bfd0..b7bc89f66a 100644 --- a/sample_app/integration_test/support/stream_test_env.dart +++ b/e2e_app/integration_test/support/stream_test_env.dart @@ -1,9 +1,9 @@ import 'dart:async'; +import 'package:e2e_app/app.dart'; +import 'package:e2e_app/auth/auth_controller.dart'; import 'package:flutter/widgets.dart' show AppLifecycleState; import 'package:flutter_test/flutter_test.dart'; -import 'package:sample_app/app.dart'; -import 'package:sample_app/auth/auth_controller.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; @@ -35,7 +35,7 @@ class StreamTestEnv { /// The external URLs the app has opened so far, in order. List get launchedUrls => _urlLauncher.launchedUrls; - Future setUp(WidgetTester tester, {bool persistence = false}) async { + Future setUp(WidgetTester tester, {bool persistence = false, bool jwt = false}) async { _tester = tester; _realUrlLauncher = UrlLauncherPlatform.instance; UrlLauncherPlatform.instance = _urlLauncher; @@ -50,10 +50,13 @@ class StreamTestEnv { baseURL: server.url, baseWsUrl: server.wsUrl, usePersistence: persistence, + // The mock server hands out a short-lived JWT and plays the token + // failure scenarios the backend robot sets up. + tokenProvider: jwt ? (_) => server.get('jwt/get') : null, ) ..debugConnectivityStream = _connectivity.stream; - await tester.pumpWidget(const StreamChatSampleApp()); + await tester.pumpWidget(const E2eApp()); await tester.pumpAndSettle(); } @@ -87,18 +90,59 @@ class StreamTestEnv { }) async { final end = DateTime.now().add(timeout); while (DateTime.now().isBefore(end)) { - if (_tester.binding.takeException() != null) return; + if (_tester.binding.takeException() != null) { + // The same failure can be reported more than once (e.g. by the composer + // and by the send itself); drain the repeats so none outlives the test. + final drainEnd = DateTime.now().add(const Duration(seconds: 2)); + while (DateTime.now().isBefore(drainEnd)) { + await _tester.pump(const Duration(milliseconds: 100)); + _tester.binding.takeException(); + } + return; + } await _tester.pump(const Duration(milliseconds: 100)); } throw TestFailure('Timed out waiting for a framework error to be reported'); } + StreamSubscription? _connectionStatusSubscription; + var _disconnections = 0; + + /// Waits until the user is connected, and from then on counts how often the + /// connection drops. Mirrors the native JWT screen's connection status. + Future assertConnected({Duration timeout = const Duration(seconds: 30)}) async { + await _waitForConnection(ConnectionStatus.connected, timeout: timeout); + _connectionStatusSubscription ??= authController.client!.wsConnectionStatusStream.listen((status) { + if (status != ConnectionStatus.connected) _disconnections++; + }); + } + + /// Waits until the connection has dropped at least once since + /// [assertConnected], which is what an expired token causes before the SDK + /// refreshes it. + Future assertTokenHasExpired({Duration timeout = const Duration(seconds: 30)}) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + if (_disconnections > 0) return; + await _tester.pump(const Duration(milliseconds: 200)); + } + throw TestFailure('The connection never dropped, so the token never expired'); + } + + /// Lets the mock server's short-lived token (5 s) run out. + Future waitForTokenToExpire() async { + final end = DateTime.now().add(const Duration(seconds: 6)); + while (DateTime.now().isBefore(end)) { + await _tester.pump(const Duration(milliseconds: 200)); + } + } + /// Truncates the channel the user currently has open, leaving the system /// message [withSystemMessage] behind when one is given. /// /// This calls the SDK rather than driving the UI because there is nothing to - /// drive: the native suites reach truncation through their sample app's debug - /// menu, and this sample app exposes no such action. Truncation is a + /// drive: the native suites reach truncation through their test app's debug + /// menu, and the channel page here is the SDK's own. Truncation is a /// server-side event as far as the app is concerned, so what the tests care /// about is how the message list and the channel preview react to it. Future truncateChannel({String? withSystemMessage}) async { @@ -199,6 +243,7 @@ class StreamTestEnv { } Future tearDown() async { + await _connectionStatusSubscription?.cancel(); try { await authController.debugReset(); } finally { diff --git a/sample_app/integration_test/support/widget_test_extensions.dart b/e2e_app/integration_test/support/widget_test_extensions.dart similarity index 95% rename from sample_app/integration_test/support/widget_test_extensions.dart rename to e2e_app/integration_test/support/widget_test_extensions.dart index 792f7cb658..60da378fb4 100644 --- a/sample_app/integration_test/support/widget_test_extensions.dart +++ b/e2e_app/integration_test/support/widget_test_extensions.dart @@ -33,10 +33,16 @@ extension E2EFinder on Finder { } } +/// The first vertical [Scrollable]: a single-line text field above a list (the channel +/// search) has a horizontal one of its own, which a plain `byType` would find first. +final _verticalScrollable = find + .byWidgetPredicate((it) => it is Scrollable && axisDirectionToAxis(it.axisDirection) == Axis.vertical) + .first; + extension E2EWidgetTester on WidgetTester { Future scrollToText(String text) async { final target = find.text(text); - final scrollable = find.byType(Scrollable).first; + final scrollable = _verticalScrollable; await waitUntilVisible(scrollable); // On a cold boot the chooser list rebuilds mid-drag, so dragUntilVisible can // momentarily see an empty scrollable and throw "Bad state: No element". @@ -93,7 +99,7 @@ extension E2EWidgetTester on WidgetTester { /// drags the finger downwards, revealing older messages (scroll up); a /// negative [delta] reveals newer messages (scroll down). Future scrollMessageList(double delta) async { - final scrollable = find.byType(Scrollable).first; + final scrollable = _verticalScrollable; await waitUntilVisible(scrollable); await drag(scrollable, Offset(0, delta)); await settle(); @@ -122,7 +128,7 @@ extension E2EWidgetTester on WidgetTester { required String description, required Duration timeout, }) async { - final scrollable = find.byType(Scrollable).first; + final scrollable = _verticalScrollable; await waitUntilVisible(scrollable); final end = DateTime.now().add(timeout); while (DateTime.now().isBefore(end)) { diff --git a/e2e_app/integration_test/thread_list_test.dart b/e2e_app/integration_test/thread_list_test.dart new file mode 100644 index 0000000000..388263fb55 --- /dev/null +++ b/e2e_app/integration_test/thread_list_test.dart @@ -0,0 +1,71 @@ +import 'robots/user_robot.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + const parentMessageText = 'Test'; + const replyText = 'Reply'; + + streamTestWithEnv( + description: 'thread list is empty when the channel has no threads', + body: (env) async { + step('GIVEN the user logs in'); + await env.userRobot.login().waitForChannelListToLoad(); + + step('WHEN the user opens the thread list'); + await env.userRobot.openThreadList(); + + step('THEN the thread list is empty'); + await env.userRobot.assertThreadListIsEmpty(); + }, + ); + + streamTestWithEnv( + description: 'thread is shown on the thread list', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(parentMessageText); + await env.userRobot.assertMessage(parentMessageText); + + step('AND the user replies to the message in the thread'); + await env.userRobot.openThread().sendMessageInThread(replyText).assertThreadReply(replyText); + + step('WHEN the user opens the thread list'); + await env.userRobot.moveToChannelListFromThread().openThreadList(); + + step('THEN the thread is shown with one reply'); + await env.userRobot.assertThreadInThreadList(parentMessageText, replies: 1); + }, + ); + + streamTestWithEnv( + description: 'user opens a thread from the thread list', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant sends a message'); + await env.participantRobot.sendMessage(parentMessageText); + await env.userRobot.assertMessage(parentMessageText); + + step('AND the user replies to the message in the thread'); + await env.userRobot.openThread().sendMessageInThread(replyText).assertThreadReply(replyText); + + step('AND the user opens the thread list'); + await env.userRobot.moveToChannelListFromThread().openThreadList().assertThreadInThreadList( + parentMessageText, + replies: 1, + ); + + step('WHEN the user taps the thread'); + await env.userRobot.openThreadFromThreadList(); + + step('THEN the thread is opened on the reply'); + await env.userRobot.assertThreadReply(replyText); + }, + ); +} diff --git a/e2e_app/integration_test/unread_counts_test.dart b/e2e_app/integration_test/unread_counts_test.dart new file mode 100644 index 0000000000..76462afe99 --- /dev/null +++ b/e2e_app/integration_test/unread_counts_test.dart @@ -0,0 +1,126 @@ +import 'mock_server/data_types.dart'; +import 'robots/user_robot.dart'; +import 'robots/user_robot_channel_list_asserts.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + const sampleText = 'Test'; + const participantMessage = 'Participant message'; + + streamTestWithEnv( + description: 'new message in a muted channel does not increment the unread count', + body: (env) async { + step('GIVEN the user is in the channel list'); + await env.userRobot.login().waitForChannelListToLoad(); + + step('AND the user has a muted channel'); + await env.backendRobot.muteChannel(); + + step('WHEN the participant sends a message to the muted channel'); + await env.participantRobot.sendMessage(participantMessage); + await env.userRobot.assertMessageInChannelPreview(participantMessage, fromCurrentUser: false); + + step('THEN the unread count is not incremented'); + await env.userRobot.assertChannelUnreadCount(0); + }, + ); + + streamTestWithEnv( + description: 'new message from a muted user does not increment the unread count', + body: (env) async { + step('GIVEN the user opens a channel'); + await env.userRobot.login().openChannel(); + + step('AND the participant in the channel is muted'); + await env.participantRobot.sendMessage(sampleText); + await env.userRobot + .assertMessage(sampleText) + .muteMessageAuthor(sampleText) + .assertMuteMessageAuthorOption(sampleText, isAuthorMuted: true); + + step('AND the user is in the channel list'); + await env.userRobot.tapBackButton(); + + step('WHEN the muted participant sends a message to the channel'); + await env.participantRobot.sendMessage(participantMessage); + await env.userRobot.assertMessageInChannelPreview(participantMessage, fromCurrentUser: false); + + step('THEN the unread count is not incremented'); + await env.userRobot.assertChannelUnreadCount(0); + }, + ); + + streamTestWithEnv( + description: 'new system message increments the unread count', + skip: 'https://linear.app/stream/issue/FLU-1096', + body: (env) async { + const systemMessage = 'System message'; + + step('GIVEN the user opens a channel and sends a message'); + await env.userRobot.login().openChannel().sendMessage(sampleText); + + step('AND the message is delivered'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the user moves back to the channel list'); + await env.userRobot.tapBackButton(); + + step('WHEN the participant performs an action that posts a system message to the channel'); + await env.participantRobot.sendSystemMessage(systemMessage); + + step('THEN the unread count is incremented'); + await env.userRobot.assertMessageInChannelPreview(systemMessage).assertChannelUnreadCount(1); + }, + ); + + streamTestWithEnv( + description: 'new thread reply also sent to the channel increments the unread count', + body: (env) async { + step('GIVEN the user opens a channel and sends a message'); + await env.userRobot.login().openChannel().sendMessage(sampleText); + + step('AND the message is delivered'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the user moves back to the channel list'); + await env.userRobot.tapBackButton(); + + step('WHEN the participant sends a thread reply also to the channel'); + await env.participantRobot.sendMessageInThread(participantMessage, alsoSendInChannel: true); + + step('THEN the unread count is incremented'); + await env.userRobot + .assertMessageInChannelPreview(participantMessage, fromCurrentUser: false) + .assertChannelUnreadCount(1); + }, + ); + + streamTestWithEnv( + description: 'hard delete of an unseen message decrements the unread count', + skip: 'https://linear.app/stream/issue/FLU-1097', + body: (env) async { + step('GIVEN the user opens a channel and sends a message'); + await env.userRobot.login().openChannel().sendMessage(sampleText); + + step('AND the message is delivered'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the user moves back to the channel list'); + await env.userRobot.tapBackButton(); + + step('AND the participant sends a thread reply also to the channel'); + await env.participantRobot.sendMessageInThread(participantMessage, alsoSendInChannel: true); + + step('AND the unread count is incremented'); + await env.userRobot.assertChannelUnreadCount(1); + + step('WHEN the participant hard-deletes the message'); + await env.participantRobot.deleteMessage(hard: true); + + step('THEN the unread count is decremented'); + await env.userRobot.assertChannelUnreadCount(0); + }, + ); +} diff --git a/e2e_app/integration_test/unread_messages_test.dart b/e2e_app/integration_test/unread_messages_test.dart new file mode 100644 index 0000000000..752796c31d --- /dev/null +++ b/e2e_app/integration_test/unread_messages_test.dart @@ -0,0 +1,185 @@ +import 'mock_server/data_types.dart'; +import 'robots/user_robot.dart'; +import 'robots/user_robot_channel_list_asserts.dart'; +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test_case.dart'; + +void main() { + const sampleText = 'Test'; + + streamTestWithEnv( + description: 'unread separator is shown when the participant sends messages while the user is away', + skip: 'https://linear.app/stream/issue/FLU-1098', + body: (env) async { + const unreadCount = 2; + + step('GIVEN the user opens a channel and sends a message'); + await env.userRobot.login().openChannel().sendMessage(sampleText); + + step('AND the message is delivered'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the user moves back to the channel list'); + await env.userRobot.tapBackButton(); + + step('WHEN the participant sends new messages'); + await env.participantRobot.sendMultipleMessages('New', count: unreadCount); + + step('AND the channel preview shows the unread count'); + await env.userRobot.assertChannelUnreadCount(unreadCount); + + step('AND the user reopens the channel'); + await env.userRobot.openChannel(); + + step('THEN the unread separator is shown with the unread count'); + await env.userRobot.assertUnreadSeparator(unreadCount: unreadCount); + }, + ); + + streamTestWithEnv( + description: 'user scrolls to the first unread message', + skip: 'https://linear.app/stream/issue/FLU-1098', + body: (env) async { + const unreadCount = 25; + + step('GIVEN the user opens a channel and sends a message'); + await env.userRobot.login().openChannel().sendMessage(sampleText); + + step('AND the message is delivered'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the user moves back to the channel list'); + await env.userRobot.tapBackButton(); + + step('AND the participant sends new messages'); + await env.participantRobot.sendMultipleMessages('New', count: unreadCount); + + step('AND the channel preview shows the unread count'); + await env.userRobot.assertChannelUnreadCount(unreadCount); + + step('WHEN the user reopens the channel'); + await env.userRobot.openChannel(); + + step('THEN the jump to unread button is shown with the unread count'); + await env.userRobot.assertJumpToUnreadButton(unreadCount: unreadCount); + + step('WHEN the user taps the jump to unread button'); + await env.userRobot.tapOnJumpToUnreadButton(); + + step('THEN the list scrolls to the unread separator'); + await env.userRobot.assertUnreadSeparator(unreadCount: unreadCount); + }, + ); + + streamTestWithEnv( + description: 'user marks a message as unread', + body: (env) async { + const unreadCount = 2; + + step('GIVEN the user opens a channel and sends a message'); + await env.userRobot.login().openChannel().sendMessage(sampleText); + + step('AND the message is delivered'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the participant sends messages'); + await env.participantRobot.sendMultipleMessages('New', count: unreadCount); + await env.userRobot.assertMessage('New-$unreadCount'); + + step('WHEN the user marks the first participant message as unread'); + await env.userRobot.markMessageAsUnread('New-1'); + + step('THEN the unread separator is shown with the unread count'); + await env.userRobot.assertUnreadSeparator(unreadCount: unreadCount); + + step('AND the channel preview shows the unread count'); + await env.userRobot.tapBackButton().assertChannelUnreadCount(unreadCount); + }, + ); + + streamTestWithEnv( + description: 'jump to unread button shows the unread count when the user marks a message as unread', + body: (env) async { + const unreadCount = 25; + + step('GIVEN the user opens a channel and sends a message'); + await env.userRobot.login().openChannel().sendMessage(sampleText); + + step('AND the message is delivered'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the participant sends more messages than fit on the screen'); + await env.participantRobot.sendMultipleMessages('New', count: unreadCount); + await env.userRobot.assertMessage('New-$unreadCount'); + + step('WHEN the user marks the first participant message as unread'); + await env.userRobot.scrollMessageListUpUntilVisible('New-1').markMessageAsUnread('New-1'); + + step('AND the user scrolls back to the bottom'); + await env.userRobot.scrollMessageListDownUntilVisible('New-$unreadCount'); + + step('THEN the jump to unread button shows the unread count'); + await env.userRobot.assertJumpToUnreadButton(unreadCount: unreadCount); + }, + ); + + streamTestWithEnv( + description: 'user dismisses the unread indicator', + body: (env) async { + const unreadCount = 25; + + step('GIVEN the user opens a channel and sends a message'); + await env.userRobot.login().openChannel().sendMessage(sampleText); + + step('AND the message is delivered'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the user moves back to the channel list'); + await env.userRobot.tapBackButton(); + + step('AND the participant sends new messages'); + await env.participantRobot.sendMultipleMessages('New', count: unreadCount); + + step('AND the user reopens the channel'); + await env.userRobot.assertChannelUnreadCount(unreadCount).openChannel(); + + step('WHEN the user dismisses the unread indicator'); + await env.userRobot.assertJumpToUnreadButton().dismissUnreadIndicator(); + + step('THEN the jump to unread button is hidden'); + await env.userRobot.assertJumpToUnreadButton(isDisplayed: false); + }, + ); + + streamTestWithEnv( + description: 'channel unread count is reset when the user reads the channel', + body: (env) async { + const unreadCount = 2; + + step('GIVEN the user opens a channel and sends a message'); + await env.userRobot.login().openChannel().sendMessage(sampleText); + + step('AND the message is delivered'); + await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); + + step('AND the user moves back to the channel list'); + await env.userRobot.tapBackButton(); + + step('AND the participant sends new messages'); + await env.participantRobot.sendMultipleMessages('New', count: unreadCount); + + step('AND the channel preview shows the unread count'); + await env.userRobot.assertChannelUnreadCount(unreadCount); + + step('WHEN the user reads the channel'); + await env.userRobot.openChannel().assertMessage('New-$unreadCount'); + + step('AND the user moves back to the channel list'); + await env.userRobot.tapBackButton(); + + step('THEN the channel preview has no unread count'); + await env.userRobot.assertChannelUnreadCount(0); + }, + ); +} diff --git a/e2e_app/ios/.gitignore b/e2e_app/ios/.gitignore new file mode 100644 index 0000000000..7a7f9873ad --- /dev/null +++ b/e2e_app/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/e2e_app/ios/Flutter/AppFrameworkInfo.plist b/e2e_app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000000..391a902b2b --- /dev/null +++ b/e2e_app/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/e2e_app/ios/Flutter/Debug.xcconfig b/e2e_app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000000..592ceee85b --- /dev/null +++ b/e2e_app/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/e2e_app/ios/Flutter/Release.xcconfig b/e2e_app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000000..592ceee85b --- /dev/null +++ b/e2e_app/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/e2e_app/ios/Runner.xcodeproj/project.pbxproj b/e2e_app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..8b646e07af --- /dev/null +++ b/e2e_app/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,647 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = EHV7XZLAHA; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.getstream.chat.e2eApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.getstream.chat.e2eApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.getstream.chat.e2eApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.getstream.chat.e2eApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = EHV7XZLAHA; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.getstream.chat.e2eApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = EHV7XZLAHA; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.getstream.chat.e2eApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/e2e_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/e2e_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/e2e_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/e2e_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/e2e_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/e2e_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/e2e_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/e2e_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/e2e_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/e2e_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/e2e_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..c3fedb29c9 --- /dev/null +++ b/e2e_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/e2e_app/ios/Runner.xcworkspace/contents.xcworkspacedata b/e2e_app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..1d526a16ed --- /dev/null +++ b/e2e_app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/e2e_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/e2e_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/e2e_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/e2e_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/e2e_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/e2e_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/e2e_app/ios/Runner/AppDelegate.swift b/e2e_app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000000..c30b367ec0 --- /dev/null +++ b/e2e_app/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..d36b1fab2d --- /dev/null +++ b/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 zcmeHN2~<R zh`|8`A_PQ1nSu(UMFx?8j8PC!!VDphaL#`F42fd#7Vlc`zIE4n%Y~eiz4y1j|NDpi z?<@|pSJ-HM`qifhf@m%MamgwK83`XpBA<+azdF#2QsT{X@z0A9Bq>~TVErigKH1~P zRX-!h-f0NJ4Mh++{D}J+K>~~rq}d%o%+4dogzXp7RxX4C>Km5XEI|PAFDmo;DFm6G zzjVoB`@qW98Yl0Kvc-9w09^PrsobmG*Eju^=3f?0o-t$U)TL1B3;sZ^!++3&bGZ!o-*6w?;oOhf z=A+Qb$scV5!RbG+&2S}BQ6YH!FKb0``VVX~T$dzzeSZ$&9=X$3)_7Z{SspSYJ!lGE z7yig_41zpQ)%5dr4ff0rh$@ky3-JLRk&DK)NEIHecf9c*?Z1bUB4%pZjQ7hD!A0r-@NF(^WKdr(LXj|=UE7?gBYGgGQV zidf2`ZT@pzXf7}!NH4q(0IMcxsUGDih(0{kRSez&z?CFA0RVXsVFw3^u=^KMtt95q z43q$b*6#uQDLoiCAF_{RFc{!H^moH_cmll#Fc^KXi{9GDl{>%+3qyfOE5;Zq|6#Hb zp^#1G+z^AXfRKaa9HK;%b3Ux~U@q?xg<2DXP%6k!3E)PA<#4$ui8eDy5|9hA5&{?v z(-;*1%(1~-NTQ`Is1_MGdQ{+i*ccd96ab$R$T3=% zw_KuNF@vI!A>>Y_2pl9L{9h1-C6H8<)J4gKI6{WzGBi<@u3P6hNsXG=bRq5c+z;Gc3VUCe;LIIFDmQAGy+=mRyF++u=drBWV8-^>0yE9N&*05XHZpPlE zxu@?8(ZNy7rm?|<+UNe0Vs6&o?l`Pt>P&WaL~M&#Eh%`rg@Mbb)J&@DA-wheQ>hRV z<(XhigZAT z>=M;URcdCaiO3d^?H<^EiEMDV+7HsTiOhoaMX%P65E<(5xMPJKxf!0u>U~uVqnPN7T!X!o@_gs3Ct1 zlZ_$5QXP4{Aj645wG_SNT&6m|O6~Tsl$q?nK*)(`{J4b=(yb^nOATtF1_aS978$x3 zx>Q@s4i3~IT*+l{@dx~Hst21fR*+5}S1@cf>&8*uLw-0^zK(+OpW?cS-YG1QBZ5q! zgTAgivzoF#`cSz&HL>Ti!!v#?36I1*l^mkrx7Y|K6L#n!-~5=d3;K<;Zqi|gpNUn_ z_^GaQDEQ*jfzh;`j&KXb66fWEk1K7vxQIMQ_#Wu_%3 z4Oeb7FJ`8I>Px;^S?)}2+4D_83gHEq>8qSQY0PVP?o)zAv3K~;R$fnwTmI-=ZLK`= zTm+0h*e+Yfr(IlH3i7gUclNH^!MU>id$Jw>O?2i0Cila#v|twub21@e{S2v}8Z13( zNDrTXZVgris|qYm<0NU(tAPouG!QF4ZNpZPkX~{tVf8xY690JqY1NVdiTtW+NqyRP zZ&;T0ikb8V{wxmFhlLTQ&?OP7 z;(z*<+?J2~z*6asSe7h`$8~Se(@t(#%?BGLVs$p``;CyvcT?7Y!{tIPva$LxCQ&4W z6v#F*);|RXvI%qnoOY&i4S*EL&h%hP3O zLsrFZhv&Hu5tF$Lx!8(hs&?!Kx5&L(fdu}UI5d*wn~A`nPUhG&Rv z2#ixiJdhSF-K2tpVL=)5UkXRuPAFrEW}7mW=uAmtVQ&pGE-&az6@#-(Te^n*lrH^m@X-ftVcwO_#7{WI)5v(?>uC9GG{lcGXYJ~Q8q zbMFl7;t+kV;|;KkBW2!P_o%Czhw&Q(nXlxK9ak&6r5t_KH8#1Mr-*0}2h8R9XNkr zto5-b7P_auqTJb(TJlmJ9xreA=6d=d)CVbYP-r4$hDn5|TIhB>SReMfh&OVLkMk-T zYf%$taLF0OqYF?V{+6Xkn>iX@TuqQ?&cN6UjC9YF&%q{Ut3zv{U2)~$>-3;Dp)*(? zg*$mu8^i=-e#acaj*T$pNowo{xiGEk$%DusaQiS!KjJH96XZ-hXv+jk%ard#fu=@Q z$AM)YWvE^{%tDfK%nD49=PI|wYu}lYVbB#a7wtN^Nml@CE@{Gv7+jo{_V?I*jkdLD zJE|jfdrmVbkfS>rN*+`#l%ZUi5_bMS<>=MBDNlpiSb_tAF|Zy`K7kcp@|d?yaTmB^ zo?(vg;B$vxS|SszusORgDg-*Uitzdi{dUV+glA~R8V(?`3GZIl^egW{a919!j#>f` znL1o_^-b`}xnU0+~KIFLQ)$Q6#ym%)(GYC`^XM*{g zv3AM5$+TtDRs%`2TyR^$(hqE7Y1b&`Jd6dS6B#hDVbJlUXcG3y*439D8MrK!2D~6gn>UD4Imctb z+IvAt0iaW73Iq$K?4}H`7wq6YkTMm`tcktXgK0lKPmh=>h+l}Y+pDtvHnG>uqBA)l zAH6BV4F}v$(o$8Gfo*PB>IuaY1*^*`OTx4|hM8jZ?B6HY;F6p4{`OcZZ(us-RVwDx zUzJrCQlp@mz1ZFiSZ*$yX3c_#h9J;yBE$2g%xjmGF4ca z&yL`nGVs!Zxsh^j6i%$a*I3ZD2SoNT`{D%mU=LKaEwbN(_J5%i-6Va?@*>=3(dQy` zOv%$_9lcy9+(t>qohkuU4r_P=R^6ME+wFu&LA9tw9RA?azGhjrVJKy&8=*qZT5Dr8g--d+S8zAyJ$1HlW3Olryt`yE zFIph~Z6oF&o64rw{>lgZISC6p^CBer9C5G6yq%?8tC+)7*d+ib^?fU!JRFxynRLEZ zj;?PwtS}Ao#9whV@KEmwQgM0TVP{hs>dg(1*DiMUOKHdQGIqa0`yZnHk9mtbPfoLx zo;^V6pKUJ!5#n`w2D&381#5#_t}AlTGEgDz$^;u;-vxDN?^#5!zN9ngytY@oTv!nc zp1Xn8uR$1Z;7vY`-<*?DfPHB;x|GUi_fI9@I9SVRv1)qETbNU_8{5U|(>Du84qP#7 z*l9Y$SgA&wGbj>R1YeT9vYjZuC@|{rajTL0f%N@>3$DFU=`lSPl=Iv;EjuGjBa$Gw zHD-;%YOE@<-!7-Mn`0WuO3oWuL6tB2cpPw~Nvuj|KM@))ixuDK`9;jGMe2d)7gHin zS<>k@!x;!TJEc#HdL#RF(`|4W+H88d4V%zlh(7#{q2d0OQX9*FW^`^_<3r$kabWAB z$9BONo5}*(%kx zOXi-yM_cmB3>inPpI~)duvZykJ@^^aWzQ=eQ&STUa}2uT@lV&WoRzkUoE`rR0)`=l zFT%f|LA9fCw>`enm$p7W^E@U7RNBtsh{_-7vVz3DtB*y#*~(L9+x9*wn8VjWw|Q~q zKFsj1Yl>;}%MG3=PY`$g$_mnyhuV&~O~u~)968$0b2!Jkd;2MtAP#ZDYw9hmK_+M$ zb3pxyYC&|CuAbtiG8HZjj?MZJBFbt`ryf+c1dXFuC z0*ZQhBzNBd*}s6K_G}(|Z_9NDV162#y%WSNe|FTDDhx)K!c(mMJh@h87@8(^YdK$&d*^WQe8Z53 z(|@MRJ$Lk-&ii74MPIs80WsOFZ(NX23oR-?As+*aq6b?~62@fSVmM-_*cb1RzZ)`5$agEiL`-E9s7{GM2?(KNPgK1(+c*|-FKoy}X(D_b#etO|YR z(BGZ)0Ntfv-7R4GHoXp?l5g#*={S1{u-QzxCGng*oWr~@X-5f~RA14b8~B+pLKvr4 zfgL|7I>jlak9>D4=(i(cqYf7#318!OSR=^`xxvI!bBlS??`xxWeg?+|>MxaIdH1U~#1tHu zB{QMR?EGRmQ_l4p6YXJ{o(hh-7Tdm>TAX380TZZZyVkqHNzjUn*_|cb?T? zt;d2s-?B#Mc>T-gvBmQZx(y_cfkXZO~{N zT6rP7SD6g~n9QJ)8F*8uHxTLCAZ{l1Y&?6v)BOJZ)=R-pY=Y=&1}jE7fQ>USS}xP#exo57uND0i*rEk@$;nLvRB@u~s^dwRf?G?_enN@$t* zbL%JO=rV(3Ju8#GqUpeE3l_Wu1lN9Y{D4uaUe`g>zlj$1ER$6S6@{m1!~V|bYkhZA z%CvrDRTkHuajMU8;&RZ&itnC~iYLW4DVkP<$}>#&(`UO>!n)Po;Mt(SY8Yb`AS9lt znbX^i?Oe9r_o=?})IHKHoQGKXsps_SE{hwrg?6dMI|^+$CeC&z@*LuF+P`7LfZ*yr+KN8B4{Nzv<`A(wyR@!|gw{zB6Ha ziwPAYh)oJ(nlqSknu(8g9N&1hu0$vFK$W#mp%>X~AU1ay+EKWcFdif{% z#4!4aoVVJ;ULmkQf!ke2}3hqxLK>eq|-d7Ly7-J9zMpT`?dxo6HdfJA|t)?qPEVBDv z{y_b?4^|YA4%WW0VZd8C(ZgQzRI5(I^)=Ub`Y#MHc@nv0w-DaJAqsbEHDWG8Ia6ju zo-iyr*sq((gEwCC&^TYBWt4_@|81?=B-?#P6NMff(*^re zYqvDuO`K@`mjm_Jd;mW_tP`3$cS?R$jR1ZN09$YO%_iBqh5ftzSpMQQtxKFU=FYmP zeY^jph+g<4>YO;U^O>-NFLn~-RqlHvnZl2yd2A{Yc1G@Ga$d+Q&(f^tnPf+Z7serIU};17+2DU_f4Z z@GaPFut27d?!YiD+QP@)T=77cR9~MK@bd~pY%X(h%L={{OIb8IQmf-!xmZkm8A0Ga zQSWONI17_ru5wpHg3jI@i9D+_Y|pCqVuHJNdHUauTD=R$JcD2K_liQisqG$(sm=k9;L* z!L?*4B~ql7uioSX$zWJ?;q-SWXRFhz2Jt4%fOHA=Bwf|RzhwqdXGr78y$J)LR7&3T zE1WWz*>GPWKZ0%|@%6=fyx)5rzUpI;bCj>3RKzNG_1w$fIFCZ&UR0(7S?g}`&Pg$M zf`SLsz8wK82Vyj7;RyKmY{a8G{2BHG%w!^T|Njr!h9TO2LaP^_f22Q1=l$QiU84ao zHe_#{S6;qrC6w~7{y(hs-?-j?lbOfgH^E=XcSgnwW*eEz{_Z<_xN#0001NP)t-s|Ns9~ z#rXRE|M&d=0au&!`~QyF`q}dRnBDt}*!qXo`c{v z{Djr|@Adh0(D_%#_&mM$D6{kE_x{oE{l@J5@%H*?%=t~i_`ufYOPkAEn!pfkr2$fs z652Tz0001XNklqeeKN4RM4i{jKqmiC$?+xN>3Apn^ z0QfuZLym_5b<*QdmkHjHlj811{If)dl(Z2K0A+ekGtrFJb?g|wt#k#pV-#A~bK=OT ts8>{%cPtyC${m|1#B1A6#u!Q;umknL1chzTM$P~L002ovPDHLkV1lTfnu!1a literal 0 HcmV?d00001 diff --git a/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 zcmV;526g#~P){YQnis^a@{&-nmRmq)<&%Mztj67_#M}W?l>kYSliK<%xAp;0j{!}J0!o7b zE>q9${Lb$D&h7k=+4=!ek^n+`0zq>LL1O?lVyea53S5x`Nqqo2YyeuIrQrJj9XjOp z{;T5qbj3}&1vg1VK~#9!?b~^C5-}JC@Pyrv-6dSEqJqT}#j9#dJ@GzT@B8}x zU&J@bBI>f6w6en+CeI)3^kC*U?}X%OD8$Fd$H&LV$H&LV$H&LV#|K5~mLYf|VqzOc zkc7qL~0sOYuM{tG`rYEDV{DWY`Z8&)kW*hc2VkBuY+^Yx&92j&StN}Wp=LD zxoGxXw6f&8sB^u})h@b@z0RBeD`K7RMR9deyL(ZJu#39Z>rT)^>v}Khq8U-IbIvT> z?4pV9qGj=2)TNH3d)=De<+^w;>S7m_eFKTvzeaBeir45xY!^m!FmxnljbSS_3o=g( z->^wC9%qkR{kbGnW8MfFew_o9h3(r55Is`L$8KI@d+*%{=Nx+FXJ98L0PjFIu;rGnnfY zn1R5Qnp<{Jq0M1vX=X&F8gtLmcWv$1*M@4ZfF^9``()#hGTeKeP`1!iED ztNE(TN}M5}3Bbc*d=FIv`DNv&@|C6yYj{sSqUj5oo$#*0$7pu|Dd2TLI>t5%I zIa4Dvr(iayb+5x=j*Vum9&irk)xV1`t509lnPO0%skL8_1c#Xbamh(2@f?4yUI zhhuT5<#8RJhGz4%b$`PJwKPAudsm|at?u;*hGgnA zU1;9gnxVBC)wA(BsB`AW54N{|qmikJR*%x0c`{LGsSfa|NK61pYH(r-UQ4_JXd!Rsz)=k zL{GMc5{h138)fF5CzHEDM>+FqY)$pdN3}Ml+riTgJOLN0F*Vh?{9ESR{SVVg>*>=# zix;VJHPtvFFCRY$Ks*F;VX~%*r9F)W`PmPE9F!(&s#x07n2<}?S{(ygpXgX-&B&OM zONY&BRQ(#%0%jeQs?oJ4P!p*R98>qCy5p8w>_gpuh39NcOlp)(wOoz0sY-Qz55eB~ z7OC-fKBaD1sE3$l-6QgBJO!n?QOTza`!S_YK z_v-lm^7{VO^8Q@M_^8F)09Ki6%=s?2_5eupee(w1FB%aqSweusQ-T+CH0Xt{` zFjMvW{@C&TB)k25()nh~_yJ9coBRL(0oO@HK~z}7?bm5j;y@69;bvlHb2tf!$ReA~x{22wTq550 z?f?Hnw(;m3ip30;QzdV~7pi!wyMYhDtXW#cO7T>|f=bdFhu+F!zMZ2UFj;GUKX7tI z;hv3{q~!*pMj75WP_c}>6)IWvg5_yyg<9Op()eD1hWC19M@?_9_MHec{Z8n3FaF{8 z;u`Mw0ly(uE>*CgQYv{be6ab2LWhlaH1^iLIM{olnag$78^Fd}%dR7;JECQ+hmk|o z!u2&!3MqPfP5ChDSkFSH8F2WVOEf0(E_M(JL17G}Y+fg0_IuW%WQ zG(mG&u?|->YSdk0;8rc{yw2@2Z&GA}z{Wb91Ooz9VhA{b2DYE7RmG zjL}?eq#iX%3#k;JWMx_{^2nNax`xPhByFiDX+a7uTGU|otOvIAUy|dEKkXOm-`aWS z27pUzD{a)Ct<6p{{3)+lq@i`t@%>-wT4r?*S}k)58e09WZYP0{{R3FC5Sl00039P)t-s|Ns9~ z#rP?<_5oL$Q^olD{r_0T`27C={r>*`|Nj71npVa5OTzc(_WfbW_({R{p56NV{r*M2 z_xt?)2V0#0NsfV0u>{42ctGP(8vQj-Btk1n|O0ZD=YLwd&R{Ko41Gr9H= zY@z@@bOAMB5Ltl$E>bJJ{>JP30ZxkmI%?eW{k`b?Wy<&gOo;dS`~CR$Vwb@XWtR|N zi~t=w02?-0&j0TD{>bb6sNwsK*!p?V`RMQUl(*DVjk-9Cx+-z1KXab|Ka2oXhX5f% z`$|e!000AhNklrxs)5QTeTVRiEmz~MKK1WAjCw(c-JK6eox;2O)?`? zTG`AHia671e^vgmp!llKp|=5sVHk#C7=~epA~VAf-~%aPC=%Qw01h8mnSZ|p?hz91 z7p83F3%LVu9;S$tSI$C^%^yud1dfTM_6p2|+5Ejp$bd`GDvbR|xit>i!ZD&F>@CJrPmu*UjD&?DfZs=$@e3FQA(vNiU+$A*%a} z?`XcG2jDxJ_ZQ#Md`H{4Lpf6QBDp81_KWZ6Tk#yCy1)32zO#3<7>b`eT7UyYH1eGz z;O(rH$=QR*L%%ZcBpc=eGua?N55nD^K(8<#gl2+pN_j~b2MHs4#mcLmv%DkspS-3< zpI1F=^9siI0s-;IN_IrA;5xm~3?3!StX}pUv0vkxMaqm+zxrg7X7(I&*N~&dEd0kD z-FRV|g=|QuUsuh>-xCI}vD2imzYIOIdcCVV=$Bz@*u0+Bs<|L^)32nN*=wu3n%Ynw z@1|eLG>!8ruU1pFXUfb`j>(=Gy~?Rn4QJ-c3%3T|(Frd!bI`9u&zAnyFYTqlG#&J7 zAkD(jpw|oZLNiA>;>hgp1KX7-wxC~31II47gc zHcehD6Uxlf%+M^^uN5Wc*G%^;>D5qT{>=uxUhX%WJu^Z*(_Wq9y}npFO{Hhb>s6<9 zNi0pHXWFaVZnb)1+RS&F)xOv6&aeILcI)`k#0YE+?e)5&#r7J#c`3Z7x!LpTc01dx zrdC3{Z;joZ^KN&))zB_i)I9fWedoN>Zl-6_Iz+^G&*ak2jpF07*qoM6N<$f;w%0(f|Me literal 0 HcmV?d00001 diff --git a/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/e2e_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000000..0bedcf2fd4 --- /dev/null +++ b/e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000000..89c2725b70 --- /dev/null +++ b/e2e_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/e2e_app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/e2e_app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..f2e259c7c9 --- /dev/null +++ b/e2e_app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/e2e_app/ios/Runner/Base.lproj/Main.storyboard b/e2e_app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..f3c28516fb --- /dev/null +++ b/e2e_app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/e2e_app/ios/Runner/Info.plist b/e2e_app/ios/Runner/Info.plist new file mode 100644 index 0000000000..18800003e5 --- /dev/null +++ b/e2e_app/ios/Runner/Info.plist @@ -0,0 +1,83 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Chat E2E + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + e2e_app + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + NSCameraUsageDescription + Used to send message attachments + NSMicrophoneUsageDescription + Used to send message attachments + NSPhotoLibraryUsageDescription + Used to send message attachments + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/e2e_app/ios/Runner/Runner-Bridging-Header.h b/e2e_app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000000..308a2a560b --- /dev/null +++ b/e2e_app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/e2e_app/ios/Runner/SceneDelegate.swift b/e2e_app/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000000..b9ce8ea2b2 --- /dev/null +++ b/e2e_app/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/e2e_app/ios/RunnerTests/RunnerTests.swift b/e2e_app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..86a7c3b1b6 --- /dev/null +++ b/e2e_app/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/e2e_app/lib/app.dart b/e2e_app/lib/app.dart new file mode 100644 index 0000000000..30bf6761f7 --- /dev/null +++ b/e2e_app/lib/app.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_localizations/stream_chat_localizations.dart'; + +import 'auth/auth_controller.dart'; +import 'pages/choose_user_page.dart'; +import 'pages/home_page.dart'; +import 'widgets/e2e_message_actions.dart'; + +/// Root widget of the e2e app. +/// +/// [StreamChat] stays mounted whenever [AuthController.client] exists, so the +/// screens that read `StreamChat.of(context)` keep their ancestor across a +/// logout and the next login. +class E2eApp extends StatelessWidget { + const E2eApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Stream Chat E2E', + theme: ThemeData(extensions: [StreamTheme(brightness: Brightness.light)]), + localizationsDelegates: const [ + GlobalStreamChatLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + builder: (context, child) => ListenableBuilder( + listenable: authController, + builder: (context, child) { + final wrapped = StreamSnackbarScope(child: child ?? const SizedBox.shrink()); + final client = authController.client; + if (client == null) return StreamChatTheme(data: StreamChatThemeData(), child: wrapped); + + return StreamChat( + client: client, + connectivityStream: authController.debugConnectivityStream, + configData: StreamChatConfigurationData( + messageListViewConfiguration: const StreamMessageListViewConfiguration( + highlightInitialMessage: true, + swipeToReply: true, + ), + ), + componentBuilders: StreamComponentBuilders( + extensions: streamChatComponentBuilders(messageItem: e2eMessageItemBuilder), + ), + child: wrapped, + ); + }, + child: child, + ), + home: ListenableBuilder( + listenable: authController, + builder: (context, _) => switch (authController.value) { + Authenticated() => const HomePage(), + Authenticating() => const Scaffold(body: Center(child: CircularProgressIndicator())), + Unauthenticated() => const ChooseUserPage(), + }, + ), + ); + } +} diff --git a/e2e_app/lib/auth/auth_controller.dart b/e2e_app/lib/auth/auth_controller.dart new file mode 100644 index 0000000000..5ec0836596 --- /dev/null +++ b/e2e_app/lib/auth/auth_controller.dart @@ -0,0 +1,174 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_persistence/stream_chat_persistence.dart'; + +/// The API key the app connects with. The mock server accepts any key. +const kStreamApiKey = String.fromEnvironment('STREAM_API_KEY', defaultValue: 'kv7mcsxr24p8'); + +/// Where the app connects, and how. The e2e harness points it at the mock +/// server it started for the test and decides the rest per test. +class StreamConnectionOverride { + const StreamConnectionOverride({ + this.baseURL, + this.baseWsUrl, + this.usePersistence = false, + this.tokenProvider, + }); + + final String? baseURL; + final String? baseWsUrl; + + /// Whether the client caches channels and messages on disk. + final bool usePersistence; + + /// When set, the user connects with a token from this provider instead of + /// the static token, so the SDK refreshes it whenever it expires. + final Future Function(String userId)? tokenProvider; +} + +/// One persistence client for every [StreamChatClient], so reconnecting does +/// not open a second SQLite connection. +final _chatPersistenceClient = StreamChatPersistenceClient(logLevel: Level.SEVERE); + +StreamChatClient _buildStreamChatClient(StreamConnectionOverride? connectionOverride) { + return StreamChatClient( + kStreamApiKey, + logLevel: kDebugMode ? Level.WARNING : Level.OFF, + retryPolicy: RetryPolicy( + maxRetryAttempts: 3, + shouldRetry: (client, attempt, error) => error is StreamChatNetworkError && error.isRetriable, + ), + baseURL: connectionOverride?.baseURL, + baseWsUrl: connectionOverride?.baseWsUrl, + // Lets the harness simulate a full network outage by failing every HTTP + // request, paired with the WebSocket close from `debugConnectivityStream`. + chatApiInterceptors: [_offlineSimulationInterceptor], + ) + ..chatPersistenceClient = switch (connectionOverride?.usePersistence) { + true => _chatPersistenceClient, + _ => null, + }; +} + +/// Rejects every Stream API request with a connection error while +/// [AuthController.debugForceOffline] is set, the way a device without a +/// network would. +final _offlineSimulationInterceptor = InterceptorsWrapper( + onRequest: (options, handler) { + if (!authController.debugForceOffline) return handler.next(options); + return handler.reject( + DioException( + requestOptions: options, + type: DioExceptionType.connectionError, + error: 'e2e: simulated offline', + ), + ); + }, +); + +/// Authentication state exposed by [AuthController]. +sealed class AuthState { + const AuthState(); +} + +/// No user is connected; the login screen shows. +final class Unauthenticated extends AuthState { + const Unauthenticated(); +} + +/// An [AuthController.connect] call is in flight. +final class Authenticating extends AuthState { + const Authenticating(); +} + +/// A user is connected; the app shell shows. +final class Authenticated extends AuthState { + const Authenticated(this.user); + + final OwnUser user; +} + +/// Owns the [StreamChatClient] lifecycle. Use the process-wide +/// [authController]. +/// +/// The client is kept across [disconnect] and [connect], so the `StreamChat` +/// ancestor stays mounted through the transition. +class AuthController extends ValueNotifier { + AuthController() : super(const Unauthenticated()); + + StreamChatClient? _client; + + /// The active client, or `null` before the first [connect]. + StreamChatClient? get client => _client; + + /// Where the next client connects. Read once, when the client is built. + StreamConnectionOverride? debugConnectionOverride; + + /// Replaces the real `connectivity_plus` monitor, so the harness can drive + /// the client offline and online. Null keeps the real monitor. + Stream>? debugConnectivityStream; + + /// While true, every Stream API HTTP request fails with a simulated + /// connection error (see [_offlineSimulationInterceptor]). + bool debugForceOffline = false; + + /// Connects [user] with [token], or with the override's token provider when + /// it has one. Rethrows any error from the SDK. + Future connect({required User user, required String token}) async { + value = const Authenticating(); + final client = _client ??= _buildStreamChatClient(debugConnectionOverride); + + try { + final ownUser = switch (debugConnectionOverride?.tokenProvider) { + final tokenProvider? => await client.connectUserWithProvider(user, tokenProvider), + _ => await client.connectUser(user, token), + }; + value = Authenticated(ownUser); + } catch (_) { + value = const Unauthenticated(); + rethrow; + } + } + + /// Disconnects the current user, keeping [client] for the next [connect]. + Future disconnect() async { + if (value is! Authenticated) return; + final client = _client; + if (client == null) return; + + value = const Unauthenticated(); + // Let the authenticated screens unmount before `disconnectUser` + // synchronously disposes the channel state they read. + await SchedulerBinding.instance.endOfFrame; + client.disconnectUser(flushChatPersistence: true).ignore(); + } + + /// Tears the client down between tests, which share one app process. + Future debugReset() async { + // Detach persistence before closing the client: a debounced state write + // or an in-flight response could otherwise reach a closed database after + // the test ended, and fail whichever test runs next. + final persistence = _client?.chatPersistenceClient; + _client?.chatPersistenceClient = null; + await persistence?.disconnect(flush: true); + await _client?.dispose(); + _client = null; + debugConnectionOverride = null; + debugConnectivityStream = null; + debugForceOffline = false; + value = const Unauthenticated(); + } + + @override + void dispose() { + _client?.dispose().ignore(); + _client = null; + super.dispose(); + } +} + +/// Process-wide [AuthController]. +final authController = AuthController(); diff --git a/e2e_app/lib/auth/users.dart b/e2e_app/lib/auth/users.dart new file mode 100644 index 0000000000..eb871d1886 --- /dev/null +++ b/e2e_app/lib/auth/users.dart @@ -0,0 +1,30 @@ +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A user the login screen offers, with a token for it. +/// +/// The SDK decodes the token on the device, so it has to be a well-formed +/// JWT; the mock server does not check it. +class E2eUser { + const E2eUser({required this.id, required this.name, required this.token}); + + final String id; + final String name; + final String token; + + User toUser() => User(id: id, name: name); +} + +const e2eUsers = [ + E2eUser( + id: 'qatest1', + name: 'QA test 1', + token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicWF0ZXN0MSJ9.fnelU7HcP7QoEEsCGteNlF1fppofzNlrnpDQuIgeKCU', + ), + E2eUser( + id: 'qatest2', + name: 'QA test 2', + token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicWF0ZXN0MiJ9.vSCqAEbs2WVmMWsOsa7065Fsjq-rsTih6qsHPynl7XM', + ), +]; diff --git a/e2e_app/lib/main.dart b/e2e_app/lib/main.dart new file mode 100644 index 0000000000..daef18bf68 --- /dev/null +++ b/e2e_app/lib/main.dart @@ -0,0 +1,5 @@ +import 'package:flutter/material.dart'; + +import 'app.dart'; + +void main() => runApp(const E2eApp()); diff --git a/e2e_app/lib/navigation.dart b/e2e_app/lib/navigation.dart new file mode 100644 index 0000000000..2d364d8b1e --- /dev/null +++ b/e2e_app/lib/navigation.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'pages/group_info_page.dart'; + +/// Opens [channel], or the thread of [parent] in it when given. +/// +/// [messageId] is the message the list opens on instead of the newest one — +/// inside the thread when [parent] is given. +Future openChannel( + BuildContext context, + Channel channel, { + String? messageId, + Message? parent, + bool replace = false, +}) { + final route = MaterialPageRoute( + builder: (_) => StreamChannel( + channel: channel, + initialMessageId: messageId, + openAtFirstUnread: false, + child: switch (parent) { + final parent? => StreamThreadPage( + parent: parent, + onViewInChannelTap: (message) => openChannel(context, channel, messageId: message.id, replace: true), + ), + _ => const StreamChannelPage(onChannelAvatarPressed: openGroupInfo), + }, + ), + ); + + final navigator = Navigator.of(context); + return replace ? navigator.pushReplacement(route) : navigator.push(route); +} + +/// Opens the message [messageId] of [channel] on that message, inside its +/// thread when it is a reply. +Future openMessage(BuildContext context, Channel channel, String messageId) async { + final client = StreamChat.of(context).client; + if (channel.state == null) await channel.watch(); + + final message = (await client.getMessage(messageId)).message; + final parentId = message.parentId; + final parent = parentId == null ? null : (await client.getMessage(parentId)).message; + if (!context.mounted) return; + return openChannel(context, channel, messageId: messageId, parent: parent); +} + +Future openGroupInfo(BuildContext context, Channel channel) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StreamChannel(channel: channel, child: const GroupInfoPage()), + ), + ); +} diff --git a/e2e_app/lib/pages/channel_actions_sheet.dart b/e2e_app/lib/pages/channel_actions_sheet.dart new file mode 100644 index 0000000000..ec7b73f3ee --- /dev/null +++ b/e2e_app/lib/pages/channel_actions_sheet.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../navigation.dart'; + +/// The sheet a long press (or the swipe's "more" action) opens on a channel. +Future showChannelActionsSheet(BuildContext context, Channel channel) { + return showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: _ChannelActions(channel: channel, hostContext: context), + ), + ); +} + +class _ChannelActions extends StatelessWidget { + const _ChannelActions({required this.channel, required this.hostContext}); + + final Channel channel; + + /// The page the sheet was opened from, which outlives the sheet. + final BuildContext hostContext; + + @override + Widget build(BuildContext context) { + final client = StreamChat.of(context).client; + final currentUserId = StreamChat.of(context).currentUser?.id; + final otherMember = channel.isOneToOne + ? channel.state?.members.where((it) => it.userId != currentUserId).firstOrNull?.user + : null; + + void run(Future Function() action) { + Navigator.of(context).pop(); + action().ignore(); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: const Text('View Info'), onTap: () => run(() => openGroupInfo(hostContext, channel))), + BetterStreamBuilder( + stream: channel.isPinnedStream, + initialData: channel.isPinned, + builder: (context, isPinned) => ListTile( + title: Text(isPinned ? 'Unpin Chat' : 'Pin Chat'), + onTap: () => run(isPinned ? channel.unpin : channel.pin), + ), + ), + if (otherMember case final user?) ...[ + ListTile(title: const Text('Mute User'), onTap: () => run(() => client.muteUser(user.id))), + ListTile(title: const Text('Block User'), onTap: () => run(() => client.blockUser(user.id))), + ] else + ListTile( + title: const Text('Leave Group'), + onTap: () => run(() => _leave(hostContext, channel, currentUserId)), + ), + ListTile( + title: Text(channel.isOneToOne ? 'Delete Chat' : 'Delete Group'), + onTap: () => run(() => _delete(hostContext, channel)), + ), + // Debug: opens the message whose id "Copy Message ID" put on the clipboard. + ListTile( + title: const Text('Open Message by ID'), + onTap: () => run(() async { + final messageId = (await Clipboard.getData(Clipboard.kTextPlain))?.text; + if (messageId == null || messageId.isEmpty || !hostContext.mounted) return; + return openMessage(hostContext, channel, messageId); + }), + ), + ], + ); + } +} + +Future _leave(BuildContext context, Channel channel, String? userId) async { + if (userId == null) return; + final confirmed = await _confirm( + context, + title: 'Leave group', + content: 'Are you sure you want to leave this group?', + confirmLabel: 'Leave', + ); + if (confirmed) await channel.removeMembers([userId]); +} + +Future _delete(BuildContext context, Channel channel) async { + final subject = channel.isOneToOne ? 'conversation' : 'group'; + final confirmed = await _confirm( + context, + title: 'Delete $subject', + content: 'Are you sure you want to delete this $subject?', + confirmLabel: 'Delete', + ); + if (confirmed) await channel.delete(); +} + +Future _confirm( + BuildContext context, { + required String title, + required String content, + required String confirmLabel, +}) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(content), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.of(context).pop(true), child: Text(confirmLabel)), + ], + ), + ); + return confirmed ?? false; +} diff --git a/e2e_app/lib/pages/channel_list_tab.dart b/e2e_app/lib/pages/channel_list_tab.dart new file mode 100644 index 0000000000..64e6918818 --- /dev/null +++ b/e2e_app/lib/pages/channel_list_tab.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../navigation.dart'; +import '../widgets/search_text_field.dart'; +import 'channel_actions_sheet.dart'; + +/// What the search field searches. +enum SearchScope { messages, channels } + +/// The channel list, with a search field that searches either messages or +/// channels by name. +class ChannelListTab extends StatefulWidget { + const ChannelListTab({super.key}); + + @override + State createState() => _ChannelListTabState(); +} + +class _ChannelListTabState extends State { + late final _client = StreamChat.of(context).client; + late final _userId = StreamChat.of(context).currentUser!.id; + + late final _channelListController = StreamChannelListController( + client: _client, + filter: Filter.in_('members', [_userId]), + limit: 30, + ); + + late final _messageSearchController = StreamMessageSearchListController( + client: _client, + filter: Filter.in_('members', [_userId]), + limit: 5, + searchQuery: '', + ); + + final _searchController = TextEditingController(); + var _scope = SearchScope.messages; + StreamChannelListController? _channelSearchController; + + @override + void initState() { + super.initState(); + _searchController.addListener(_onQueryChanged); + } + + void _onQueryChanged() { + final query = _searchController.text; + setState(() {}); + if (query.isEmpty) return _messageSearchController.clearResults(); + + switch (_scope) { + case SearchScope.messages: + _messageSearchController.search(query); + case SearchScope.channels: + _channelSearchController?.dispose(); + _channelSearchController = StreamChannelListController( + client: _client, + filter: Filter.and([ + Filter.in_('members', [_userId]), + Filter.autoComplete('name', query), + ]), + limit: 30, + ); + } + } + + void _onScopeChanged(SearchScope scope) { + setState(() => _scope = scope); + _onQueryChanged(); + } + + @override + void dispose() { + _searchController.dispose(); + _channelListController.dispose(); + _messageSearchController.dispose(); + _channelSearchController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isSearching = _searchController.text.isNotEmpty; + + return Column( + children: [ + SearchTextField(controller: _searchController), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + spacing: 8, + children: [ + for (final scope in SearchScope.values) + ChoiceChip( + label: Text(scope == SearchScope.messages ? 'Messages' : 'Channels'), + selected: _scope == scope, + onSelected: (_) => _onScopeChanged(scope), + ), + ], + ), + ), + Expanded( + child: switch ((isSearching, _scope)) { + (false, _) => _ChannelList(controller: _channelListController), + (true, SearchScope.messages) => StreamMessageSearchListView( + controller: _messageSearchController, + onMessageTap: (response) { + final [type, id] = response.channel!.cid.split(':'); + openMessage(context, _client.channel(type, id: id), response.message.id).ignore(); + }, + ), + (true, SearchScope.channels) => StreamChannelListView( + key: ValueKey(_searchController.text), + controller: _channelSearchController!, + onChannelTap: (channel) => openChannel(context, channel), + ), + }, + ), + ], + ); + } +} + +class _ChannelList extends StatelessWidget { + const _ChannelList({required this.controller}); + + final StreamChannelListController controller; + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final colorScheme = context.streamColorScheme; + + return SlidableAutoCloseBehavior( + child: StreamChannelListView( + controller: controller, + itemBuilder: (context, channels, index, defaultWidget) { + final channel = channels[index]; + return Slidable( + groupTag: 'channel-actions', + endActionPane: ActionPane( + extentRatio: 0.4, + motion: const BehindMotion(), + children: [ + CustomSlidableAction( + backgroundColor: colorScheme.backgroundSurface, + onPressed: (_) => showChannelActionsSheet(context, channel), + child: Icon(icons.moreHorizontal, size: 20), + ), + BetterStreamBuilder( + stream: channel.isMutedStream, + initialData: channel.isMuted, + builder: (context, isMuted) => CustomSlidableAction( + backgroundColor: colorScheme.accentPrimary, + onPressed: (_) => (isMuted ? channel.unmute() : channel.mute()).ignore(), + child: Icon(isMuted ? icons.audio : icons.mute, size: 20), + ), + ), + ], + ), + child: defaultWidget, + ); + }, + onChannelTap: (channel) => openChannel(context, channel), + onChannelLongPress: (channel) => showChannelActionsSheet(context, channel), + ), + ); + } +} diff --git a/e2e_app/lib/pages/choose_user_page.dart b/e2e_app/lib/pages/choose_user_page.dart new file mode 100644 index 0000000000..c530984c91 --- /dev/null +++ b/e2e_app/lib/pages/choose_user_page.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../auth/auth_controller.dart'; +import '../auth/users.dart'; + +/// The start page: picks the user to log in as. +class ChooseUserPage extends StatefulWidget { + const ChooseUserPage({super.key}); + + @override + State createState() => _ChooseUserPageState(); +} + +class _ChooseUserPageState extends State { + Object? _error; + + Future _login(E2eUser user) async { + setState(() => _error = null); + try { + await authController.connect(user: user.toUser(), token: user.token); + } catch (error) { + // Shown rather than rethrown: an uncaught error would fail whichever + // test happens to be running when it lands. + if (mounted) setState(() => _error = error); + } + } + + @override + Widget build(BuildContext context) { + final textTheme = context.streamTextTheme; + return Scaffold( + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('Welcome to Stream Chat', style: textTheme.headingLg), + const SizedBox(height: 8), + const Text('Select a user to log in as:'), + const SizedBox(height: 16), + for (final user in e2eUsers) + ListTile( + leading: StreamUserAvatar(size: .md, user: user.toUser(), showOnlineIndicator: false), + title: Text(user.name), + onTap: () => _login(user), + ), + if (_error case final error?) Text('Login failed: $error'), + ], + ), + ), + ); + } +} diff --git a/e2e_app/lib/pages/group_info_page.dart b/e2e_app/lib/pages/group_info_page.dart new file mode 100644 index 0000000000..815e8e8be4 --- /dev/null +++ b/e2e_app/lib/pages/group_info_page.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'pinned_messages_page.dart'; + +/// The channel's info screen, reached from the channel actions sheet or the +/// channel header's avatar. +class GroupInfoPage extends StatelessWidget { + const GroupInfoPage({super.key}); + + @override + Widget build(BuildContext context) { + final channel = StreamChannel.of(context).channel; + return Scaffold( + appBar: AppBar(title: const Text('Group Info')), + body: ListView( + children: [ + ListTile( + leading: Icon(context.streamIcons.pin), + title: const Text('Pinned Messages'), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StreamChannel(channel: channel, child: const PinnedMessagesPage()), + ), + ), + ), + ], + ), + ); + } +} diff --git a/e2e_app/lib/pages/home_page.dart b/e2e_app/lib/pages/home_page.dart new file mode 100644 index 0000000000..8f7ab1be30 --- /dev/null +++ b/e2e_app/lib/pages/home_page.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../auth/auth_controller.dart'; +import 'channel_list_tab.dart'; +import 'reminders_tab.dart'; +import 'thread_list_tab.dart'; + +/// The authenticated shell: channels, threads and reminders tabs, and a drawer +/// behind the user's avatar. +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + var _currentIndex = 0; + + static const _labels = ['Chats', 'Threads', 'Reminders']; + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final textTheme = context.streamTextTheme; + + return StreamScaffold( + backgroundColor: context.streamColorScheme.backgroundApp, + appBar: StreamChannelListHeader(title: Text(_labels[_currentIndex], style: textTheme.headingSm)), + drawer: const _HomeDrawer(), + bottom: StreamBottomNavBar( + currentIndex: _currentIndex, + onTap: (index) => setState(() => _currentIndex = index), + items: [ + StreamBottomNavBarItem(icon: Icon(icons.messageBubble), label: _labels[0]), + StreamBottomNavBarItem(icon: Icon(icons.thread), label: _labels[1]), + StreamBottomNavBarItem(icon: Icon(icons.bell), label: _labels[2]), + ], + ), + // Built on demand rather than kept alive, so a tab queries its list when + // it is opened, the way the native apps do. + body: switch (_currentIndex) { + 1 => const ThreadListTab(), + 2 => const RemindersTab(), + _ => const ChannelListTab(), + }, + ); + } +} + +class _HomeDrawer extends StatelessWidget { + const _HomeDrawer(); + + @override + Widget build(BuildContext context) { + final user = StreamChat.of(context).currentUser; + return Drawer( + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + leading: StreamUserAvatar(size: .md, user: user!, showOnlineIndicator: false), + title: Text(user.name), + ), + const Spacer(), + ListTile( + leading: Icon(context.streamIcons.leave), + title: const Text('Sign out'), + onTap: () { + Navigator.of(context).pop(); + authController.disconnect().ignore(); + }, + ), + ], + ), + ), + ); + } +} diff --git a/e2e_app/lib/pages/pinned_messages_page.dart b/e2e_app/lib/pages/pinned_messages_page.dart new file mode 100644 index 0000000000..7bf410d694 --- /dev/null +++ b/e2e_app/lib/pages/pinned_messages_page.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// The channel's pinned messages, found through message search. +class PinnedMessagesPage extends StatefulWidget { + const PinnedMessagesPage({super.key}); + + @override + State createState() => _PinnedMessagesPageState(); +} + +class _PinnedMessagesPageState extends State { + late final _controller = StreamMessageSearchListController( + client: StreamChat.of(context).client, + filter: Filter.in_('cid', [StreamChannel.of(context).channel.cid!]), + messageFilter: Filter.equal('pinned', true), + sort: const [SortOption.asc('created_at')], + limit: 20, + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Pinned Messages')), + body: StreamMessageSearchListView( + controller: _controller, + emptyBuilder: (_) => const Center(child: Text('No pinned messages')), + ), + ); + } +} diff --git a/e2e_app/lib/pages/reminders_tab.dart b/e2e_app/lib/pages/reminders_tab.dart new file mode 100644 index 0000000000..6f2483da5c --- /dev/null +++ b/e2e_app/lib/pages/reminders_tab.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// The user's message reminders: saved for later, or due at a set time. +class RemindersTab extends StatefulWidget { + const RemindersTab({super.key}); + + @override + State createState() => _RemindersTabState(); +} + +class _RemindersTabState extends State { + late final _controller = StreamMessageReminderListController(client: StreamChat.of(context).client); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PagedValueListView( + controller: _controller, + separatorBuilder: (_, __, ___) => const Divider(height: 1), + itemBuilder: (context, reminders, index) => MessageReminderListTile(reminder: reminders[index]), + emptyBuilder: (context) => const Center(child: Text('No reminders yet')), + loadMoreErrorBuilder: (context, error) => ListTile(onTap: _controller.retry, title: Text(error.message)), + loadMoreIndicatorBuilder: (context) => const Center(child: CircularProgressIndicator()), + loadingBuilder: (context) => const Center(child: CircularProgressIndicator()), + errorBuilder: (context, error) => Center(child: Text(error.message)), + ); + } +} + +/// A reminder: its message, and whether it is saved for later or due. +class MessageReminderListTile extends StatelessWidget { + const MessageReminderListTile({super.key, required this.reminder}); + + final MessageReminder reminder; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(reminder.message?.text ?? ''), + trailing: switch (reminder.remindAt) { + final remindAt? => TimedReminderIndicator(remindAt: remindAt), + null => const SavedForLaterIndicator(), + }, + ); + } +} + +class SavedForLaterIndicator extends StatelessWidget { + const SavedForLaterIndicator({super.key}); + + @override + Widget build(BuildContext context) => const Text('Saved for later'); +} + +class TimedReminderIndicator extends StatelessWidget { + const TimedReminderIndicator({super.key, required this.remindAt}); + + final DateTime remindAt; + + @override + Widget build(BuildContext context) => Text(Jiffy.parseFromDateTime(remindAt.toLocal()).jm); +} diff --git a/e2e_app/lib/pages/thread_list_tab.dart b/e2e_app/lib/pages/thread_list_tab.dart new file mode 100644 index 0000000000..a9532e5816 --- /dev/null +++ b/e2e_app/lib/pages/thread_list_tab.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../navigation.dart'; + +/// The user's threads, opened on their latest reply. +class ThreadListTab extends StatefulWidget { + const ThreadListTab({super.key}); + + @override + State createState() => _ThreadListTabState(); +} + +class _ThreadListTabState extends State { + late final _controller = StreamThreadListController(client: StreamChat.of(context).client); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamThreadListView( + controller: _controller, + onThreadTap: (thread) async { + final client = StreamChat.of(context).client; + final [type, id] = thread.channelCid.split(':'); + final channel = client.channel(type, id: id); + if (channel.state == null) await channel.watch(); + final parent = thread.parentMessage ?? (await client.getMessage(thread.parentMessageId)).message; + if (!context.mounted) return; + return openChannel(context, channel, parent: parent); + }, + ); + } +} diff --git a/e2e_app/lib/widgets/e2e_message_actions.dart b/e2e_app/lib/widgets/e2e_message_actions.dart new file mode 100644 index 0000000000..bb3ef1fb14 --- /dev/null +++ b/e2e_app/lib/widgets/e2e_message_actions.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Adds the message actions the native test apps offer and the SDK does not: +/// +/// * "Hard Delete Message", for the user's own messages — the SDK only +/// offers a hard delete for a message that failed to send. +/// * "Copy Message ID", so a test can open the message later by its id. +Widget e2eMessageItemBuilder(BuildContext context, StreamMessageItemProps props) { + final message = props.message; + + return DefaultStreamMessageItem( + props: props.copyWith( + actionsBuilder: (context, defaultActions) { + final currentUser = StreamChat.of(context).currentUser; + final isOwn = message.user?.id == currentUser?.id; + final canHardDelete = isOwn && !message.isDeleted && !message.state.isFailed; + + return StreamContextMenuAction.partitioned( + items: [ + ...defaultActions, + if (canHardDelete) + StreamContextMenuAction.destructive( + label: const Text('Hard Delete Message'), + leading: Icon(context.streamIcons.delete), + onTap: () => StreamChannel.of(context).channel.deleteMessage(message, hard: true).ignore(), + ), + StreamContextMenuAction( + label: const Text('Copy Message ID'), + leading: Icon(context.streamIcons.copy), + onTap: () => Clipboard.setData(ClipboardData(text: message.id)).ignore(), + ), + ], + ); + }, + ), + ); +} diff --git a/e2e_app/lib/widgets/search_text_field.dart b/e2e_app/lib/widgets/search_text_field.dart new file mode 100644 index 0000000000..44ccc9f12d --- /dev/null +++ b/e2e_app/lib/widgets/search_text_field.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// The search field above the channel list. +class SearchTextField extends StatelessWidget { + const SearchTextField({super.key, required this.controller}); + + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: TextField( + controller: controller, + decoration: InputDecoration( + hintText: 'Search', + prefixIcon: Icon(context.streamIcons.search), + border: const OutlineInputBorder(), + isDense: true, + ), + ), + ); + } +} diff --git a/e2e_app/pubspec.yaml b/e2e_app/pubspec.yaml new file mode 100644 index 0000000000..384a345f07 --- /dev/null +++ b/e2e_app/pubspec.yaml @@ -0,0 +1,44 @@ +name: e2e_app +description: The Stream Chat Flutter app the e2e suite runs against. +publish_to: "none" +version: 1.0.0 + +# The app exists only to be driven by `integration_test/` against the Stream +# Chat mock server. It keeps to the screens the suite exercises, carries no +# Firebase or push setup, and may expose debug-only affordances the sample app +# would not ship. +# +# Note: The environment configuration and dependency versions are managed by Melos. +# +# Do not edit them manually. +# +# Steps to update dependencies: +# 1. Modify the version in the melos.yaml file. +# 2. Run `melos bootstrap` to apply changes. +# +# Steps to add a new dependency: +# 1. Add the dependency to this list. +# 2. Add it to the melos.yaml file for future updates. + +environment: + sdk: ^3.12.0 + flutter: ">=3.44.0" + +dependencies: + collection: ^1.19.1 + flutter: + sdk: flutter + flutter_slidable: ^3.1.1 + stream_chat_flutter: ^10.6.0 + stream_chat_localizations: ^10.6.0 + stream_chat_persistence: ^10.6.0 + +dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + url_launcher_platform_interface: ^2.3.2 + +flutter: + uses-material-design: true diff --git a/sample_app/scripts/install_ios_runtime.sh b/e2e_app/scripts/install_ios_runtime.sh similarity index 100% rename from sample_app/scripts/install_ios_runtime.sh rename to e2e_app/scripts/install_ios_runtime.sh diff --git a/melos.yaml b/melos.yaml index 612e2f3a7f..75add421eb 100644 --- a/melos.yaml +++ b/melos.yaml @@ -3,6 +3,7 @@ repository: https://github.com/GetStream/stream-chat-flutter packages: - docs/* + - e2e_app - sample_app - packages/* - packages/*/example @@ -10,6 +11,8 @@ packages: categories: docs: - docs/* + e2e_app: + - e2e_app sample_app: - sample_app packages: @@ -213,7 +216,7 @@ scripts: dirExists: test e2e:run: - run: melos exec -c 1 --scope="sample_app" -- "flutter test integration_test/" + run: melos exec -c 1 --scope="e2e_app" -- "flutter test integration_test/" description: > Run the integration_test e2e suite. Start the mock-server driver first (`fastlane start_mock_server`), or use the `run_e2e_test` Fastlane lane to diff --git a/sample_app/Gemfile b/sample_app/Gemfile index 6b76b506f2..ca1cc88253 100644 --- a/sample_app/Gemfile +++ b/sample_app/Gemfile @@ -2,13 +2,5 @@ source "https://rubygems.org" gem "fastlane" gem "multi_json" -gem "sinatra", group: :sinatra_dependencies - -group :sinatra_dependencies do - gem "eventmachine" - gem "faye-websocket" - gem "puma" - gem "rackup" -end eval_gemfile("fastlane/Pluginfile") diff --git a/sample_app/Gemfile.lock b/sample_app/Gemfile.lock index 7c2622eaab..8a4e9490fc 100644 --- a/sample_app/Gemfile.lock +++ b/sample_app/Gemfile.lock @@ -44,7 +44,6 @@ GEM domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - eventmachine (1.2.7) excon (1.5.0) logger faraday (1.10.6) @@ -138,9 +137,6 @@ GEM fastlane-plugin-stream_actions (0.4.16) xctest_list (= 1.2.1) fastlane-sirp (1.1.0) - faye-websocket (0.12.0) - eventmachine (>= 0.12.0) - websocket-driver (>= 0.8.0) gh_inspector (1.1.3) google-apis-androidpublisher_v3 (0.104.0) google-apis-core (>= 0.15.0, < 2.a) @@ -205,11 +201,9 @@ GEM mini_mime (1.1.5) multi_json (1.21.1) multipart-post (2.4.1) - mustermann (3.1.1) mutex_m (0.3.0) nanaimo (0.4.0) naturally (2.3.0) - nio4r (2.7.5) nkf (0.3.0) optparse (0.8.1) os (1.1.4) @@ -217,18 +211,6 @@ GEM plist (3.7.2) pstore (0.2.1) public_suffix (4.0.7) - puma (8.0.2) - nio4r (~> 2.0) - rack (3.2.6) - rack-protection (4.2.1) - base64 (>= 0.1.0) - logger (>= 1.6.0) - rack (>= 3.0.0, < 4) - rack-session (2.1.2) - base64 (>= 0.1.0) - rack (>= 3.0.0) - rackup (2.3.1) - rack (>= 3) rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) @@ -247,17 +229,9 @@ GEM simctl (1.6.10) CFPropertyList naturally - sinatra (4.2.1) - logger (>= 1.6.0) - mustermann (~> 3.0) - rack (>= 3.0.0, < 4) - rack-protection (= 4.2.1) - rack-session (>= 2.0.0, < 3) - tilt (~> 2.0) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) - tilt (2.8.0) trailblazer-option (0.1.2) tty-cursor (0.7.1) tty-screen (0.8.2) @@ -265,10 +239,6 @@ GEM tty-cursor (~> 0.7) uber (0.1.0) unicode-display_width (2.6.0) - websocket-driver (0.8.2) - base64 - websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.5) word_wrap (1.0.0) xcodeproj (1.28.1) CFPropertyList (>= 2.3.3, < 4.0) @@ -299,16 +269,11 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES - eventmachine fastlane fastlane-plugin-aws_s3 fastlane-plugin-firebase_app_distribution fastlane-plugin-stream_actions (= 0.4.16) - faye-websocket multi_json - puma - rackup - sinatra CHECKSUMS CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261 @@ -338,7 +303,6 @@ CHECKSUMS domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8 emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b - eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972 excon (1.5.0) sha256=c503ad1d0123bc8ab2a062ff3789dc891ec368cb9e13765ab88a9c58c8bb6d50 faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb @@ -359,7 +323,6 @@ CHECKSUMS fastlane-plugin-firebase_app_distribution (1.0.0) sha256=321f554d16194ea465c71202274c75609cf986f147fc7203901d44a1a6df736f fastlane-plugin-stream_actions (0.4.16) sha256=9393acac7d979a8930bd34f5789106171011487d366fdc2c6e4e30d26ce8caad fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847 - faye-websocket (0.12.0) sha256=ad9f7dfcd0306d0a13baeee450729657661129af15bb5f38716c242484ab42e1 gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 google-apis-androidpublisher_v3 (0.104.0) sha256=3bf7f0dee23ae71e070e279848374834853204e304831cf6aa76fa435a4d6eff google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee @@ -387,11 +350,9 @@ CHECKSUMS mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080 multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 - mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198 mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723 naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01 - nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895 optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f @@ -399,11 +360,6 @@ CHECKSUMS plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42 pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a public_suffix (4.0.7) sha256=8be161e2421f8d45b0098c042c06486789731ea93dc3a896d30554ee38b573b8 - puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb - rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 - rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac - rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 - rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace retriable (3.8.0) sha256=9f2f1b0207594c7817f17f671587b8ec7587387ac6cebda6c941a802bb98a8e5 @@ -414,18 +370,14 @@ CHECKSUMS security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7 signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780 simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b - sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080 terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91 - tilt (2.8.0) sha256=ba472eb2716fe1e04112d6d219a9dae938ec09a6a1e2ad3ecc7922e79bde3721 trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3 tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48 tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50 tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542 uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a - websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 - websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7 xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892 diff --git a/sample_app/android/fastlane/Fastfile b/sample_app/android/fastlane/Fastfile index 0cdc234578..e7e1aaaa5d 100644 --- a/sample_app/android/fastlane/Fastfile +++ b/sample_app/android/fastlane/Fastfile @@ -3,7 +3,6 @@ import '../../fastlane/Fastfile' default_platform(:android) before_all do - @impact = impact_analysis if is_ci setup_ci setup_git_config diff --git a/sample_app/e2e_stubs/firebase_core/lib/firebase_core.dart b/sample_app/e2e_stubs/firebase_core/lib/firebase_core.dart deleted file mode 100644 index bde97276a3..0000000000 --- a/sample_app/e2e_stubs/firebase_core/lib/firebase_core.dart +++ /dev/null @@ -1,55 +0,0 @@ -/// No-op stand-in for `firebase_core`, swapped in via `pubspec_overrides.e2e.yaml`. -/// -/// Being a pure-Dart package (no `flutter.plugin` section), it removes the Firebase -/// pods / Gradle dependencies from e2e builds entirely: nothing is downloaded, -/// compiled, or initialized. Only the API surface used by `sample_app` is stubbed. -library; - -/// No-op replacement for the Firebase entry point. -class Firebase { - /// Does nothing and returns a dummy [FirebaseApp]. - static Future initializeApp({String? name, FirebaseOptions? options}) async => const FirebaseApp(); -} - -/// Inert replacement for a configured Firebase app. -class FirebaseApp { - /// Creates the inert app handle. - const FirebaseApp(); -} - -/// Plain data holder matching the real `FirebaseOptions` constructor, so the -/// FlutterFire-generated `firebase_options.dart` compiles unchanged. -class FirebaseOptions { - /// Mirrors the real constructor's signature; values are never read. - const FirebaseOptions({ - required this.apiKey, - required this.appId, - required this.messagingSenderId, - required this.projectId, - this.authDomain, - this.databaseURL, - this.storageBucket, - this.measurementId, - this.trackingId, - this.deepLinkURLScheme, - this.androidClientId, - this.iosClientId, - this.iosBundleId, - this.appGroupId, - }); - - /// See the real `firebase_core` for field semantics; all unused here. - final String apiKey, appId, messagingSenderId, projectId; - - /// Optional platform-specific fields accepted for constructor compatibility. - final String? authDomain, - databaseURL, - storageBucket, - measurementId, - trackingId, - deepLinkURLScheme, - androidClientId, - iosClientId, - iosBundleId, - appGroupId; -} diff --git a/sample_app/e2e_stubs/firebase_core/pubspec.yaml b/sample_app/e2e_stubs/firebase_core/pubspec.yaml deleted file mode 100644 index 8560881059..0000000000 --- a/sample_app/e2e_stubs/firebase_core/pubspec.yaml +++ /dev/null @@ -1,7 +0,0 @@ -name: firebase_core -description: No-op pure-Dart stand-in for firebase_core, swapped in by e2e builds to keep the native Firebase SDK out of the app. -version: 0.0.1 -publish_to: none - -environment: - sdk: ^3.0.0 diff --git a/sample_app/e2e_stubs/firebase_crashlytics/lib/firebase_crashlytics.dart b/sample_app/e2e_stubs/firebase_crashlytics/lib/firebase_crashlytics.dart deleted file mode 100644 index b96f25a1c5..0000000000 --- a/sample_app/e2e_stubs/firebase_crashlytics/lib/firebase_crashlytics.dart +++ /dev/null @@ -1,32 +0,0 @@ -/// No-op stand-in for `firebase_crashlytics`, swapped in via `pubspec_overrides.e2e.yaml`. -/// -/// See the `firebase_core` stub for the rationale. Only the API surface used by -/// `sample_app` is stubbed; every call silently does nothing. -library; - -import 'package:flutter/foundation.dart'; - -/// No-op replacement for the Crashlytics reporter. -class FirebaseCrashlytics { - FirebaseCrashlytics._(); - - /// The shared no-op instance. - static final FirebaseCrashlytics instance = FirebaseCrashlytics._(); - - /// Does nothing. The positional bool must match the real API's signature. - // ignore: avoid_positional_boolean_parameters - Future setCrashlyticsCollectionEnabled(bool enabled) async {} - - /// Does nothing. - Future recordError( - dynamic exception, - StackTrace? stack, { - dynamic reason, - Iterable information = const [], - bool? printDetails, - bool fatal = false, - }) async {} - - /// Does nothing. - Future recordFlutterFatalError(FlutterErrorDetails flutterErrorDetails) async {} -} diff --git a/sample_app/e2e_stubs/firebase_crashlytics/pubspec.yaml b/sample_app/e2e_stubs/firebase_crashlytics/pubspec.yaml deleted file mode 100644 index bafb8ff019..0000000000 --- a/sample_app/e2e_stubs/firebase_crashlytics/pubspec.yaml +++ /dev/null @@ -1,11 +0,0 @@ -name: firebase_crashlytics -description: No-op pure-Dart stand-in for firebase_crashlytics, swapped in by e2e builds to keep the native Firebase SDK out of the app. -version: 0.0.1 -publish_to: none - -environment: - sdk: ^3.0.0 - -dependencies: - flutter: - sdk: flutter diff --git a/sample_app/e2e_stubs/firebase_messaging/lib/firebase_messaging.dart b/sample_app/e2e_stubs/firebase_messaging/lib/firebase_messaging.dart deleted file mode 100644 index f38097fd69..0000000000 --- a/sample_app/e2e_stubs/firebase_messaging/lib/firebase_messaging.dart +++ /dev/null @@ -1,90 +0,0 @@ -/// No-op stand-in for `firebase_messaging`, swapped in via `pubspec_overrides.e2e.yaml`. -/// -/// See the `firebase_core` stub for the rationale. Only the API surface used by -/// `sample_app` is stubbed: permission requests report denied, token getters -/// return null, and the message streams never emit. -library; - -/// Signature of the top-level FCM background handler. -typedef BackgroundMessageHandler = Future Function(RemoteMessage message); - -/// No-op replacement for the FCM entry point. -class FirebaseMessaging { - FirebaseMessaging._(); - - /// The shared no-op instance. - static final FirebaseMessaging instance = FirebaseMessaging._(); - - /// Never emits. - static Stream get onMessage => const Stream.empty(); - - /// Never emits. - static Stream get onMessageOpenedApp => const Stream.empty(); - - /// Does nothing; [handler] is never invoked. - static void onBackgroundMessage(BackgroundMessageHandler handler) {} - - /// Never emits. - Stream get onTokenRefresh => const Stream.empty(); - - /// Always resolves to null. - Future getToken({String? vapidKey}) async => null; - - /// Always resolves to null. - Future getAPNSToken() async => null; - - /// Always resolves to null. - Future getInitialMessage() async => null; - - /// Always reports [AuthorizationStatus.denied]. - Future requestPermission({ - bool alert = true, - bool announcement = false, - bool badge = true, - bool carPlay = false, - bool criticalAlert = false, - bool provisional = false, - bool providesAppNotificationSettings = false, - bool sound = true, - }) async => const NotificationSettings(authorizationStatus: AuthorizationStatus.denied); - - /// Does nothing. - Future setForegroundNotificationPresentationOptions({ - bool alert = false, - bool badge = false, - bool sound = false, - }) async {} -} - -/// Mirrors the real enum values. -enum AuthorizationStatus { - /// Permission granted. - authorized, - - /// Permission denied — the stub's fixed answer. - denied, - - /// Permission not requested yet. - notDetermined, - - /// Provisional (quiet) permission. - provisional, -} - -/// Minimal result type for [FirebaseMessaging.requestPermission]. -class NotificationSettings { - /// Creates settings with the given [authorizationStatus]. - const NotificationSettings({required this.authorizationStatus}); - - /// The reported permission state. - final AuthorizationStatus authorizationStatus; -} - -/// Minimal FCM message: only [data] is read by `sample_app`. -class RemoteMessage { - /// Creates a message carrying [data]. - const RemoteMessage({this.data = const {}}); - - /// The message payload. - final Map data; -} diff --git a/sample_app/e2e_stubs/firebase_messaging/pubspec.yaml b/sample_app/e2e_stubs/firebase_messaging/pubspec.yaml deleted file mode 100644 index 63d1fe314b..0000000000 --- a/sample_app/e2e_stubs/firebase_messaging/pubspec.yaml +++ /dev/null @@ -1,7 +0,0 @@ -name: firebase_messaging -description: No-op pure-Dart stand-in for firebase_messaging, swapped in by e2e builds to keep the native Firebase SDK out of the app. -version: 0.0.1 -publish_to: none - -environment: - sdk: ^3.0.0 diff --git a/sample_app/fastlane/Fastfile b/sample_app/fastlane/Fastfile index 0fa071de9f..41a54b0eb6 100644 --- a/sample_app/fastlane/Fastfile +++ b/sample_app/fastlane/Fastfile @@ -1,152 +1,9 @@ opt_out_usage -require 'net/http' -require 'shellwords' -require 'json' -require 'securerandom' -require 'fileutils' -require File.expand_path('e2e_log', __dir__) -require File.expand_path('e2e_tests', __dir__) -import File.expand_path('Allurefile', __dir__) - -mock_server_repo_name = 'stream-chat-test-mock-server' -mock_server_driver_port = ENV['MOCK_DRIVER_PORT'] || '4568' -github_repo = ENV['GITHUB_REPOSITORY'] || 'GetStream/stream-chat-flutter' -e2e_coverage_map_artifact = 'e2e-coverage-map' -@force_check = false -@impact = nil - def root_path Dir.pwd.sub(/.*\Kfastlane/, '').sub(/.*\Kandroid/, '').sub(/.*\Kios/, '').sub(/.*\K\/\//, '') end -def repo_root - File.expand_path('..', root_path) -end - -def e2e_selection_path - "#{root_path}/build/e2e_selection.json" -end - -def e2e_coverage_path - "#{root_path}/build/e2e-coverage" -end - -def ios_device?(device) - device.to_s.match?(/\A[0-9A-F]{8}-[0-9A-F]{4}-/i) -end - -# `runs` are `[test file, test names or nil for the whole file]` pairs. -def batch_targets(runs, batch:, batch_count:) - return runs if batch.nil? || batch_count.to_i <= 1 - return [] if runs.empty? - - count = batch_count.to_i - index = batch.to_i - # Balanced by test count, as the files differ a lot in size: the biggest run goes to the - # lightest batch. Every batch job computes the same split, as ties keep the run order. - tests_per_file = E2ETests.list('integration_test').group_by { |id| E2ETests.file(id, 'integration_test') }.transform_values(&:size) - loads = Array.new(count, 0) - batch_of = {} - runs.each_with_index - .sort_by { |(target, names), i| [-(names&.size || tests_per_file.fetch(target, 1)), i] } - .each do |(target, names), i| - lightest = loads.index(loads.min) - batch_of[i] = lightest - loads[lightest] += names&.size || tests_per_file.fetch(target, 1) - end - slice = runs.select.with_index { |_, i| batch_of[i] == index } - - if slice.empty? - UI.important("Batch #{index + 1}/#{count}: no runs fell into this batch — nothing to run") - else - UI.important("Batch #{index + 1}/#{count}: #{slice.size} of #{runs.size} runs " \ - "(#{slice.map { |target, names| names ? "#{File.basename(target)}: #{names.join(', ')}" : File.basename(target) }.join('; ')})") - end - - slice -end - -def boot_simulator(udid:) - return if `xcrun simctl list devices booted`.to_s.include?(udid) - return unless `xcrun simctl list devices`.to_s.include?(udid) - - sh("xcrun simctl bootstatus #{udid} -b") -end - -# Video is recorded on ANDROID ONLY — iOS recording is intentionally disabled. -def start_screen_recording(ios:, device:, path:) - return nil if ios - - FileUtils.mkdir_p(File.dirname(path)) - started_at_ms = (Time.now.to_f * 1000).round - serial = device ? "-s #{device} " : '' - remote = "/sdcard/#{File.basename(path)}" - pid = spawn("adb #{serial}shell screenrecord --bit-rate 4000000 #{remote}", - out: File::NULL, err: File::NULL) - { pid: pid, path: path, remote: remote, serial: serial, started_at_ms: started_at_ms } -rescue StandardError => e - UI.important("Screen recording failed to start: #{e.message}") - nil -end - -def stop_screen_recording(rec) - return unless rec - - rec[:ended_at_ms] = (Time.now.to_f * 1000).round - sh("adb #{rec[:serial]}shell pkill -INT screenrecord", log: false) rescue nil - sleep(3) # let screenrecord finalize + flush the file on-device - Process.kill('INT', rec[:pid]) rescue nil # reap the local adb client - Process.wait(rec[:pid]) rescue nil - sh("adb #{rec[:serial]}pull #{rec[:remote]} #{rec[:path]}", log: false) - sh("adb #{rec[:serial]}shell rm #{rec[:remote]}", log: false) rescue nil -rescue StandardError => e - UI.important("Screen recording failed to stop: #{e.message}") -end - -def attach_videos_to_failed_results(results_dir:, videos:) - return unless Dir.exist?(results_dir) - return if videos.empty? - - unless system('command -v ffmpeg > /dev/null 2>&1') - UI.important('ffmpeg not found; skipping per-test video attachments') - return - end - - Dir["#{results_dir}/*-result.json"].each do |json_path| - result = JSON.parse(File.read(json_path)) - next unless %w[failed broken].include?(result['status']) - next unless result['start'] && result['stop'] - - label = (result['labels'] || []).find { |l| l['name'] == 'testFile' } - recordings = label && videos[label['value']] - next unless recordings - - meta = recordings.find do |r| - r[:started_at_ms] && r[:ended_at_ms] && - result['start'] >= r[:started_at_ms] && result['start'] <= r[:ended_at_ms] - end - next unless meta && File.exist?(meta[:path]) && File.size(meta[:path]).positive? - - ss = [((result['start'] - meta[:started_at_ms]) / 1000.0) - 1.0, 0].max - duration = ((result['stop'] - result['start']) / 1000.0) + 2.0 - next unless duration.positive? - - clip = "#{results_dir}/#{SecureRandom.uuid}-attachment.mp4" - ok = system('ffmpeg', '-y', '-ss', ss.to_s, '-i', meta[:path], '-t', duration.to_s, - '-an', '-c:v', 'libx264', '-preset', 'veryfast', '-movflags', '+faststart', clip, - out: File::NULL, err: File::NULL) - - unless ok && File.exist?(clip) && File.size(clip) > 1024 - File.delete(clip) if File.exist?(clip) - next - end - - (result['attachments'] ||= []) << { 'name' => 'Video', 'source' => File.basename(clip), 'type' => 'video/mp4' } - File.write(json_path, JSON.generate(result)) - end -end - lane :sh_on_root do |options| Dir.chdir(root_path) { sh(options[:command]) } end @@ -154,297 +11,3 @@ end lane :fetch_dependencies do sh_on_root(command: "flutter pub get --suppress-analytics") end - -lane :start_mock_server do |options| - Dir.chdir(root_path) do - mock_server_repo = options[:local_server] || mock_server_repo_name - stop_mock_server unless is_ci - - unless options[:local_server] - branch = options[:branch].to_s.empty? ? 'main' : options[:branch] - sh("rm -rf #{mock_server_repo}") if File.directory?(mock_server_repo) - sh("git clone -b #{branch} https://github.com/#{github_repo.split('/').first}/#{mock_server_repo}.git") - end - - Dir.chdir(mock_server_repo) do - FileUtils.mkdir_p('logs') - sh("bundle exec ruby driver.rb #{mock_server_driver_port} > logs/driver.log 2>&1 &") - end - end -end - -lane :stop_mock_server do - Net::HTTP.get_response(URI("http://localhost:#{mock_server_driver_port}/stop")) rescue nil -end - -# Options: `selection` runs the tests select_e2e_tests chose (the file it wrote); -# `coverage` runs every test in its own app process and writes its lcov report -# to build/e2e-coverage///, for build_e2e_coverage_map. -lane :run_e2e_test do |options| - selection = options[:selection] && JSON.parse(File.read(File.expand_path(options[:selection], root_path))) - next if selection && selection['tests'].empty? - next unless selection || options[:coverage] || is_check_required(sources: @impact[:e2e][:sources], force_check: @force_check, required_checks: @impact[:e2e][:checks]) - - ios = options[:platform] == 'ios' || ios_device?(options[:device]) - device = options[:device] - coverage = options[:coverage] - FileUtils.rm_rf(e2e_coverage_path) if coverage - - start_mock_server(local_server: options[:local_server], branch: options[:mock_server_branch]) - - if ios - device = prepare_simulator(device: options[:device], reset: is_ci, wait_for_boot: false) - else - serial = device ? "-s #{device} " : '' - sh("adb #{serial}logcat -c") - end - - log_file = "#{root_path}/build/e2e-test.log" - FileUtils.mkdir_p(File.dirname(log_file)) - File.write(log_file, '') - - videos_dir = "#{root_path}/build/e2e-videos" - FileUtils.rm_rf(videos_dir) - FileUtils.mkdir_p(videos_dir) - videos = {} # test-file basename => [ { path:, started_at_ms:, ended_at_ms: }, ... ] (one per attempt) - failed = [] - - begin - Dir.chdir(root_path) do - runs = - if coverage - # The VM reports coverage per app process, so every test needs its own `flutter test`. - E2ETests.list('integration_test', platform: ios ? 'ios' : 'android').map do |id| - [E2ETests.file(id, 'integration_test'), [E2ETests.name(id)]] - end - elsif selection && !selection['run_all'] - selection['tests'].group_by { |id| E2ETests.file(id, 'integration_test') } - .map { |target, ids| [target, ids.map { |id| E2ETests.name(id) }] } - else - (options[:target] ? [options[:target]] : Dir['integration_test/*_test.dart'].sort).map { |target| [target, nil] } - end - UI.user_error!('No e2e test files found') if runs.empty? - - runs = batch_targets(runs, batch: options[:batch], batch_count: options[:batch_count]) - next if runs.empty? - - retries = (options[:retries] || 2).to_i - - first_attempt = true - runs.each_with_index do |(target, selected), run_index| - test_name = File.basename(target, '.dart') - - attempt_log = "#{videos_dir}/#{test_name}.attempt.log" - coverage_dir = "#{e2e_coverage_path}/#{test_name}/#{selected.first}" if coverage - - # nil => run the whole file (first attempt); - # an array => run only those test descriptions. - only = selected - - ok = false - attempt = 0 - loop do - boot_simulator(udid: device) if ios && !first_attempt - first_attempt = false - attempt += 1 - - flags = [target, '--no-pub', "--dart-define=E2E_TARGET=#{test_name}"] - flags << "-d #{device}" if device - - if options[:verbose] - flags << '--verbose' - else - flags << '--quiet' - flags << "--reporter #{ENV['GITHUB_ACTIONS'] ? 'github' : 'expanded'}" - end - - flags << "--name #{Shellwords.escape(E2ELog.name_alternation(only))}" if only - if coverage - flags << "--coverage --coverage-path #{Shellwords.escape("#{coverage_dir}/lcov.info")}" - flags << "--coverage-package #{Shellwords.escape('^(sample_app|stream_chat\w*)$')}" - end - # Per-run and per-attempt path so neither the next run of the same file - # (one per test in coverage mode) nor a retry overwrites a recording. - rec = start_screen_recording(ios: ios, device: device, path: "#{videos_dir}/#{test_name}.run#{run_index}.attempt#{attempt}.mp4") - - File.write(attempt_log, '') - cmd = "flutter test #{flags.join(' ')} 2>&1 | tee -a #{Shellwords.escape(log_file)} | " \ - "tee #{Shellwords.escape(attempt_log)} | " \ - "{ grep --line-buffered -vE 'ALLURE-(RESULT|ATTACH)::|E2E-TEST::' || true; }" - ok = begin - sh("bash -o pipefail -c #{Shellwords.escape(cmd)}") - true - rescue StandardError - false - end - - stop_screen_recording(rec) - - # Keep every attempt's recording + window, so failed results from an - # earlier attempt still match their own video even when a later retry - # narrows the test set or passes. - if rec - (videos[test_name] ||= []) << { - path: rec[:path], - started_at_ms: rec[:started_at_ms], - ended_at_ms: rec[:ended_at_ms], - } - end - - # A run that reports tests it never ran must not go green, however - # `flutter test` exited. Also the best retry target when there is no - # failure to attribute — those are exactly the tests still owed a result. - phantom = E2ELog.phantom_test_names(attempt_log) - unless phantom.empty? - ok = false - UI.important("#{target}: #{phantom.size} test(s) reported without ever running " \ - "(no result emitted): #{phantom.join(', ')}") - end - - break if ok || attempt > retries - - # Not retriable: a whole-file retry can neither narrow nor fix a leaked - # async error, and a run that happens to pass would only be hiding it. - # Fail fast with the diagnosis instead of burning every attempt. - if E2ELog.post_completion_failure?(attempt_log) - blamed = E2ELog.post_completion_blamed_tests(attempt_log) - UI.important("#{target} (attempt #{attempt}/#{retries + 1}): a test leaked async work that threw " \ - 'AFTER it had completed, so the failure is not attributable to any single test' \ - "#{blamed.empty? ? '' : " (the runner blamed: #{blamed.join(', ')})"}. " \ - 'Not retrying — fix the leak; see the exception above.') - break - end - - # Both sets, not just the attributable ones: an attempt that reports a - # failure *and* phantoms would otherwise retry the failure alone, and a - # green retry of that one test would bury the tests still owed a result. - only = (E2ELog.failed_test_names(attempt_log) + phantom).uniq - if only.empty? - only = selected - UI.important("#{target} failed (attempt #{attempt}/#{retries + 1}); " \ - "retrying #{selected ? 'the selected tests' : 'whole file'}…") - else - UI.important("#{target} failed (attempt #{attempt}/#{retries + 1}); " \ - "retrying #{only.size} failed test(s): #{only.join(', ')}") - end - end - - if coverage - # A test that never reported a result (skipped, or the app died first) ran no test code. - FileUtils.rm_rf(coverage_dir) unless E2ELog.reported_test_names(attempt_log).include?(selected.first) - failed << "#{test_name}/#{selected.first}" unless ok - else - failed << target unless ok - end - end - - UI.user_error!("E2E failed after retries: #{failed.join(', ')}") unless failed.empty? - end - ensure - # build_e2e_coverage_map keeps the previous coverage of these, as a failed test stops early. - if coverage - FileUtils.mkdir_p(e2e_coverage_path) - File.write("#{e2e_coverage_path}/failed_tests_#{options[:batch] || 0}.json", JSON.generate(failed)) - end - collect_allure_results(log_file: log_file) rescue nil - File.write("#{videos_dir}/manifest.json", JSON.generate(videos)) unless videos.empty? - stop_mock_server - end -end - -lane :attach_videos do - manifest = "#{root_path}/build/e2e-videos/manifest.json" - unless File.exist?(manifest) - UI.important("No video manifest at #{manifest}; nothing to attach") - next - end - - videos = JSON.parse(File.read(manifest)).transform_values do |recordings| - recordings.map do |v| - { path: v['path'], started_at_ms: v['started_at_ms'], ended_at_ms: v['ended_at_ms'] } - end - end - attach_videos_to_failed_results(results_dir: allure_results_path, videos: videos) -end - -# Picks the e2e tests a pull request can affect from the nightly coverage map and -# writes them to build/e2e_selection.json for `run_e2e_test selection:`. Outputs -# `e2e_batches`, the batch matrix of the e2e jobs (empty when nothing runs). -lane :select_e2e_tests do |options| - # A manual run's tip may be a merge commit, which the selection would mistake for a pull request merge. - run_all_reason = 'not a pull request' if is_ci && ENV['GITHUB_EVENT_NAME'] != 'pull_request' - required = run_all_reason || is_check_required(sources: @impact[:e2e][:sources], force_check: @force_check, required_checks: @impact[:e2e][:checks]) - all_tests = required ? E2ETests.list("#{root_path}/integration_test") : [] - - if run_all_reason - UI.important("Running all #{all_tests.size} tests: #{run_all_reason}") - selection = { 'run_all' => true, 'tests' => all_tests, 'reasons' => [run_all_reason] } - elsif required - selection = select_impacted_tests( - all_tests: all_tests, - # Relative to sample_app, like `run_e2e_test selection:`; the action itself reads from the repository root. - coverage_map: options[:coverage_map] && File.expand_path(options[:coverage_map], root_path), - coverage_map_artifact: e2e_coverage_map_artifact, - coverage_map_branch: 'master', - diff_base: options[:diff_base], - changed_files: options[:changed_files]&.split(','), - source_root: repo_root, - source_paths: ['sample_app/lib', 'packages/*/lib/**/*'], - test_paths: ['sample_app/integration_test/*_test.dart'], - ignore_paths: ['**/*.md'], - run_all_paths: [ - 'sample_app/integration_test/allure', - 'sample_app/integration_test/mock_server', - 'sample_app/integration_test/pages', - 'sample_app/integration_test/robots', - 'sample_app/integration_test/support', - 'sample_app/android', - 'sample_app/ios', - 'sample_app/assets', - 'sample_app/e2e_stubs', - 'sample_app/pubspec.yaml', - 'sample_app/pubspec_overrides.e2e.yaml' - ], - # Changes to code nearly every test runs (launch, login, channel list) take one test per file; the nightly run covers the rest. - hot_test_ratio: 0.9 - ) - else - selection = { 'run_all' => false, 'tests' => [], 'reasons' => ['no e2e sources changed'] } - end - FileUtils.mkdir_p(File.dirname(e2e_selection_path)) - File.write(e2e_selection_path, JSON.pretty_generate(selection)) - - # run_e2e_test batches by test file, so there are no more batches than files. - files = selection['tests'].map { |id| E2ETests.file(id, 'integration_test') }.uniq - batches = (0...[files.size, 3].min).to_a - UI.important("E2E batches: #{batches.size}") - File.write(ENV['GITHUB_OUTPUT'], "e2e_batches=#{batches.to_json}\n", mode: 'a') if ENV['GITHUB_OUTPUT'] - batches -end - -# Builds the coverage map select_e2e_tests reads from the lcov reports of -# `run_e2e_test coverage:true` (every batch's, merged into one folder on CI). -lane :build_e2e_coverage_map do |options| - coverage_dir = options[:coverage_dir] ? File.expand_path(options[:coverage_dir], root_path) : e2e_coverage_path - failed_tests = Dir["#{coverage_dir}/failed_tests_*.json"].flat_map { |file| JSON.parse(File.read(file)) } - build_test_coverage_map( - coverage_dir: coverage_dir, - lcov_base_dir: root_path, - source_root: repo_root, - output_path: "#{root_path}/build/e2e_coverage_map.json", - failed_tests: failed_tests, - previous_map_artifact: e2e_coverage_map_artifact, - previous_map_branch: 'master' - ) -end - -# What each gated check runs on: a pull request runs it when `sources` changed, or -# when its `checks` have not passed yet on a commit with the current sources. -lane :impact_analysis do - { - e2e: { - sources: ['sample_app', 'packages'], - checks: ['E2E result'] - } - } -end diff --git a/sample_app/integration_test/message_list_test.dart b/sample_app/integration_test/message_list_test.dart deleted file mode 100644 index a2e2bef919..0000000000 --- a/sample_app/integration_test/message_list_test.dart +++ /dev/null @@ -1,568 +0,0 @@ -import 'mock_server/data_types.dart'; -import 'robots/user_robot.dart'; -import 'robots/user_robot_message_list_asserts.dart'; -import 'support/step.dart'; -import 'support/stream_test_case.dart'; - -void main() { - const sampleText = 'Test'; - // A message long enough to wrap across several lines, so editing to/from it - // produces a real change in cell height (single newlines collapse in markdown). - const longText = - 'This is a much longer message that will certainly wrap across several lines ' - 'inside the chat bubble, making the message cell noticeably taller than a short one.'; - - // MARK: Message sending - - streamTestWithEnv( - allureId: '11188', - description: 'message list updates when the user sends a message', - body: (env) async { - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('WHEN the user sends a message'); - await env.userRobot.sendMessage(sampleText); - - step('THEN the message is displayed'); - await env.userRobot.assertMessage(sampleText); - }, - ); - - streamTestWithEnv( - allureId: '11346', - description: 'message list updates when the participant sends a message', - body: (env) async { - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('WHEN the participant sends a message'); - await env.participantRobot.sendMessage(sampleText); - - step('THEN the message is displayed'); - await env.userRobot.assertMessage(sampleText); - }, - ); - - streamTestWithEnv( - allureId: '11336', - description: 'user sends a message with one emoji', - body: (env) async { - const message = '🍏'; - - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('WHEN the user sends the emoji: $message'); - await env.userRobot.sendMessage(message); - - step('THEN the message is delivered'); - await env.userRobot.assertMessage(message); - }, - ); - - streamTestWithEnv( - allureId: '11347', - description: 'user sends a message with multiple emojis', - body: (env) async { - const message = '🍏🙂👍'; - - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('WHEN the user sends a message with multiple emojis: $message'); - await env.userRobot.sendMessage(message); - - step('THEN the message is delivered'); - await env.userRobot.assertMessage(message); - }, - ); - - streamTestWithEnv( - allureId: '11339', - description: 'user receives a message with an emoji', - body: (env) async { - const message = '🚢'; - - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('WHEN the participant sends the emoji: $message'); - await env.participantRobot.sendMessage(message); - - step('THEN the message is delivered'); - await env.userRobot.assertMessage(message); - }, - ); - - streamTestWithEnv( - allureId: '11345', - description: 'user sends a message with multiple lines', - body: (env) async { - const message = 'alpha\nbeta\ngamma'; - - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('WHEN the user sends a message with multiple lines'); - await env.userRobot.sendMessage(message); - - step('THEN each line of the message is displayed'); - // The markdown renderer collapses single newlines, so assert on the - // individual line tokens rather than the literal multi-line string. - await env.userRobot.assertMessageContains('alpha').assertMessageContains('gamma'); - }, - ); - - // MARK: Message editing - - streamTestWithEnv( - allureId: '11337', - description: 'user edits a message', - body: (env) async { - const editedMessage = 'hello'; - - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('WHEN the user sends the message'); - await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); - - step('AND the user edits the message'); - await env.userRobot.editMessage(editedMessage); - - step('THEN the message is edited'); - await env.userRobot.assertEditedMessage(editedMessage); - }, - ); - - streamTestWithEnv( - allureId: '11341', - description: 'user sees a message edited by the participant', - body: (env) async { - const editedMessage = 'hello'; - - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('WHEN the participant sends the message'); - await env.participantRobot.sendMessage(sampleText); - await env.userRobot.assertMessage(sampleText); - - step('AND the participant edits the message'); - await env.participantRobot.editMessage(editedMessage); - - step('THEN the message is edited'); - await env.userRobot.assertEditedMessage(editedMessage); - }, - ); - - // MARK: Typing indicator - - streamTestWithEnv( - allureId: '11343', - description: 'user observes the typing indicator', - body: (env) async { - step('GIVEN the user opens a channel'); - await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); - await env.userRobot.login().openChannel(); - - step('WHEN the participant starts typing'); - await env.participantRobot.startTyping(); - - step('THEN the typing indicator is shown'); - await env.userRobot.assertTypingIndicator(isDisplayed: true); - - step('WHEN the participant stops typing'); - await env.participantRobot.stopTyping(); - - step('THEN the typing indicator disappears'); - await env.userRobot.assertTypingIndicator(isDisplayed: false); - }, - ); - - // MARK: Offline - - streamTestWithEnv( - allureId: '11338', - description: 'user sees the participant message received while offline', - body: (env) async { - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('AND the user becomes offline'); - await env.goOffline(); - - step('WHEN the participant sends a new message'); - await env.participantRobot.sendMessage(sampleText); - - step('AND the user becomes online'); - await env.goOnline(); - - step('THEN the user observes the new message from the participant'); - await env.userRobot.assertMessage(sampleText); - }, - ); - - // Both offline cases run with persistence on, mirroring the native - // `setIsLocalStorageEnabled(to: .on)`. - streamTestWithEnv( - allureId: '11527', - description: 'user adds a message while offline', - persistence: true, - body: (env) async { - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('AND the user becomes offline'); - await env.goOffline(); - - step('WHEN the user sends a new message'); - await env.userRobot.sendMessage(sampleText); - - step('THEN the message is shown as failed to be sent'); - // The send is optimistic, so the text shows up right away; the SDK marks - // the message failed once its retries are exhausted. - await env.userRobot.assertMessage(sampleText).assertMessageFailedToBeSent(); - // The composer reports the failed send through FlutterError, which the - // failed bubble asserted above already covers. - await env.takeExpectedError(); - - step('WHEN the user becomes online'); - await env.goOnline(); - - step('THEN the message is delivered'); - // The channel's retry queue replays failed messages on - // `connection.recovered`, which flips the status to sent. - await env.userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.sent); - }, - ); - - streamTestWithEnv( - allureId: '11526', - description: 'user recovers a message received while in the background', - persistence: true, - body: (env) async { - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('AND the user goes to the background'); - await env.moveToBackground(); - - step('WHEN the participant sends a new message'); - // Nothing is pumped between here and the foregrounding — see - // `StreamTestEnv.moveToBackground`. - await env.participantRobot.sendMessage(sampleText); - - step('AND the user comes back to the foreground'); - await env.moveToForeground(); - - step('THEN the new message is delivered'); - await env.userRobot.assertMessage(sampleText); - }, - ); - - // MARK: Thread replies - - streamTestWithEnv( - allureId: '11350', - description: 'thread reply appears in the thread when the participant adds it', - body: (env) async { - const threadReply = 'thread reply'; - - step('GIVEN the user opens a channel'); - await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); - await env.userRobot.login().openChannel(); - - step('WHEN the participant adds a thread reply'); - await env.participantRobot.sendMessageInThread(threadReply); - - step('AND the user opens the thread'); - await env.userRobot.openThread(); - - step('THEN the user observes the thread reply in the thread'); - await env.userRobot.assertThreadReply(threadReply); - }, - ); - - streamTestWithEnv( - allureId: '11492', - description: 'thread reply also sent to the channel appears in both places', - body: (env) async { - const threadReply = 'thread reply'; - - step('GIVEN the user opens a channel'); - await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); - await env.userRobot.login().openChannel(); - - step('WHEN the participant adds a thread reply and also sends it to the channel'); - await env.participantRobot.sendMessageInThread(threadReply, alsoSendInChannel: true); - - step('THEN the user observes the thread reply in the channel'); - await env.userRobot.assertMessage(threadReply); - - step('AND the user observes the thread reply in the thread'); - // The in-channel copy is the newest message and has no 'Thread Reply' - // action, so the thread is opened from the parent's replies footer. - await env.userRobot.openThreadFromReplies().assertThreadReply(threadReply); - }, - ); - - streamTestWithEnv( - allureId: '11329', - description: 'thread typing indicator hides when the participant stops typing', - body: (env) async { - step('GIVEN the user opens a channel'); - await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); - await env.userRobot.login().openChannel(); - - step('AND the user opens the thread'); - await env.userRobot.openThread(); - - step('WHEN the participant starts typing in the thread'); - await env.participantRobot.startTypingInThread(); - - step('THEN the typing indicator is shown'); - await env.userRobot.assertTypingIndicator(isDisplayed: true); - - step('WHEN the participant stops typing in the thread'); - await env.participantRobot.stopTypingInThread(); - - step('THEN the typing indicator disappears'); - await env.userRobot.assertTypingIndicator(isDisplayed: false); - }, - ); - - // MARK: Deleted messages - - streamTestWithEnv( - allureId: '11332', - description: 'user deletes a message', - body: (env) async { - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('WHEN the user sends the message'); - await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); - - step('AND the user deletes the message'); - await env.userRobot.deleteMessage(); - - step('THEN the message is deleted'); - await env.userRobot.assertDeletedMessage(); - }, - ); - - streamTestWithEnv( - allureId: '11333', - description: 'user sees a message deleted by the participant', - body: (env) async { - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('WHEN the participant sends the message'); - await env.participantRobot.sendMessage(sampleText); - await env.userRobot.assertMessage(sampleText); - - step('AND the participant deletes the message'); - await env.participantRobot.deleteMessage(); - - step('THEN the message is deleted'); - await env.userRobot.assertDeletedMessage(); - }, - ); - - streamTestWithEnv( - allureId: '11335', - description: 'user sees a message hard-deleted by the participant', - body: (env) async { - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('WHEN the participant sends the message'); - await env.participantRobot.sendMessage(sampleText); - await env.userRobot.assertMessage(sampleText); - - step('AND the participant hard-deletes the message'); - await env.participantRobot.deleteMessage(hard: true); - - step('THEN the message is hard-deleted'); - await env.userRobot.assertHardDeletedMessage(sampleText); - }, - ); - - // MARK: Mentions - - streamTestWithEnv( - allureId: '11349', - description: 'user sees the mentions overlay when typing @', - body: (env) async { - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('WHEN the user types @'); - await env.userRobot.typeText('@'); - - step('THEN the mentions overlay appears'); - await env.userRobot.assertMentionsOverlay(isDisplayed: true); - - step('WHEN the user removes @'); - await env.userRobot.clearComposer(); - - step('THEN the mentions overlay disappears'); - await env.userRobot.assertMentionsOverlay(isDisplayed: false); - }, - ); - - // MARK: Scroll to bottom - - streamTestWithEnv( - allureId: '11334', - description: 'message list scrolls down when the user receives a new message at the bottom', - body: (env) async { - const newMessage = 'New message'; - - step('GIVEN the user opens a channel with history'); - await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 30); - await env.userRobot.login().openChannel(); - - step('WHEN the participant sends a message'); - await env.participantRobot.sendMessage(newMessage); - - step('THEN the new message is visible'); - await env.userRobot.assertMessage(newMessage); - }, - ); - - streamTestWithEnv( - allureId: '11348', - description: 'message list does not scroll down when scrolled up and the user receives a message', - body: (env) async { - const newMessage = 'New message'; - - step('GIVEN the user opens a channel with history'); - await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 30); - await env.userRobot.login().openChannel(); - - step('WHEN the user scrolls up'); - await env.userRobot.scrollMessageListUp(); - - step('AND the participant sends a message'); - await env.participantRobot.sendMessage(newMessage); - - step('THEN the new message is not visible'); - await env.userRobot.assertMessage(newMessage, isDisplayed: false); - }, - ); - - // MARK: Pagination - - streamTestWithEnv( - allureId: '11331', - description: 'user paginates the message list', - body: (env) async { - const messagesCount = 60; - - step('GIVEN the user opens a channel with a long history'); - await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: messagesCount); - await env.userRobot.login().openChannel(); - - step('THEN the whole history can be paged through'); - await env.userRobot.assertMessageListPagination(messagesCount: messagesCount); - }, - ); - - // MARK: Link previews - - // Link previews are asserted on a participant-sent YouTube message: the - // composer suppresses enrichment on the user's own outgoing link, and the - // mock server only serves a canned OG response for YouTube URLs. - streamTestWithEnv( - allureId: '11340', - description: 'user sees a link preview for a YouTube link', - body: (env) async { - const link = 'https://youtube.com/watch?v=xOX7MsrbaPY'; - - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('WHEN the participant sends a message with a YouTube link'); - await env.participantRobot.sendMessage(link); - - step('THEN a link preview is displayed'); - await env.userRobot.assertLinkPreview(); - }, - ); - - // Mirrors the native test_messageWithLinkOpensSafari: the browser is stood in - // for by a fake `UrlLauncherPlatform`, so the tap is verified without the real - // browser taking over the device (which suspends the app and hangs the run). - streamTestWithEnv( - allureId: '11493', - description: 'message with a link opens the browser', - body: (env) async { - const link = 'https://youtube.com/watch?v=xOX7MsrbaPY'; - // Tapping the preview opens the attachment's asset URL, which comes from - // the mock server's canned OG payload — the canonical (`www.`) form of the - // link, not the text the participant typed. - const openedLink = 'https://www.youtube.com/watch?v=xOX7MsrbaPY'; - - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('WHEN the participant sends a message with a YouTube link'); - await env.participantRobot.sendMessage(link); - await env.userRobot.assertLinkPreview(); - - step('AND the user taps the link preview'); - await env.userRobot.tapLinkPreview(); - - step('THEN the browser opens the link'); - await env.assertBrowserOpened(url: openedLink); - }, - ); - - // MARK: Message grouping & size - - streamTestWithEnv( - allureId: '11330', - description: 'message cell grows when the user edits it to a longer message', - body: (env) async { - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('AND the user sends a short message'); - await env.userRobot.sendMessage(sampleText).assertMessage(sampleText); - - step('THEN the message cell grows after editing to a longer message'); - await env.userRobot.assertMessageSizeChangesAfterEditing(longText, increased: true); - }, - ); - - streamTestWithEnv( - allureId: '11344', - description: 'message cell shrinks when the user edits it to a shorter message', - body: (env) async { - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('AND the user sends a long message'); - await env.userRobot.sendMessage(longText).assertMessage(longText); - - step('THEN the message cell shrinks after editing to a shorter message'); - await env.userRobot.assertMessageSizeChangesAfterEditing(sampleText, increased: false); - }, - ); - - streamTestWithEnv( - allureId: '11342', - description: 'composer does not grow beyond its line limit', - body: (env) async { - step('GIVEN the user opens a channel'); - await env.userRobot.login().openChannel(); - - step('THEN the composer grows with input but stops at its cap'); - await env.userRobot.assertComposerGrowsWithinLimit(); - }, - ); -} diff --git a/sample_app/integration_test/robots/backend_robot.dart b/sample_app/integration_test/robots/backend_robot.dart deleted file mode 100644 index 5b528e0454..0000000000 --- a/sample_app/integration_test/robots/backend_robot.dart +++ /dev/null @@ -1,50 +0,0 @@ -import '../mock_server/mock_server.dart'; - -class BackendRobot { - BackendRobot(this._mockServer); - - final MockServer _mockServer; - - Future generateChannels({ - required int channelsCount, - int messagesCount = 0, - int repliesCount = 0, - String? messagesText, - String? repliesText, - }) async { - final messagesTextParam = messagesText != null ? 'messages_text=${Uri.encodeQueryComponent(messagesText)}&' : ''; - final repliesTextParam = repliesText != null ? 'replies_text=${Uri.encodeQueryComponent(repliesText)}&' : ''; - await _mockServer.post( - 'mock?' - '$messagesTextParam' - '$repliesTextParam' - 'channels=$channelsCount&' - 'messages=$messagesCount&' - 'replies=$repliesCount', - ); - return this; - } - - Future failNewMessages() async { - await _mockServer.post('fail_messages'); - return this; - } - - Future freezeNewMessages() async { - await _mockServer.post('freeze_messages'); - return this; - } - - Future revokeToken({int duration = 5}) => _mockServer.post('jwt/revoke_token?duration=$duration'); - - Future invalidateToken({int duration = 5}) => _mockServer.post('jwt/invalidate_token?duration=$duration'); - - Future invalidateTokenDate({int duration = 5}) => - _mockServer.post('jwt/invalidate_token_date?duration=$duration'); - - Future invalidateTokenSignature({int duration = 5}) => - _mockServer.post('jwt/invalidate_token_signature?duration=$duration'); - - Future breakTokenGeneration({int duration = 5}) => - _mockServer.post('jwt/break_token_generation?duration=$duration'); -} diff --git a/sample_app/integration_test/robots/user_robot.dart b/sample_app/integration_test/robots/user_robot.dart deleted file mode 100644 index 9aca138020..0000000000 --- a/sample_app/integration_test/robots/user_robot.dart +++ /dev/null @@ -1,287 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import '../mock_server/data_types.dart'; -import '../pages/channel_list_page.dart'; -import '../pages/message_list_page.dart'; -import '../support/predefined_users.dart'; -import '../support/widget_test_extensions.dart'; - -class UserRobot { - UserRobot(this.tester); - - final WidgetTester tester; - - Future login([ - UserCredentials user = PredefinedUsers.currentUser, - ]) async { - await tester.scrollToText(user.name); - await tester.tapText(user.name); - return this; - } - - /// Waits for the channel list to render its rows. - Future waitForChannelListToLoad() async { - await tester.waitUntilVisible(find.byType(ChannelListPage.channelTile)); - return this; - } - - Future openChannel({int index = 0}) async { - final tile = find.byType(ChannelListPage.channelTile).at(index); - await tester.waitUntilVisible(tile); - await tester.tap(tile); - await tester.pumpAndSettle(); - return this; - } - - Future sendMessage(String text) async { - await tester.enterTextInField(MessageListPage.composer.inputField, text); - await tester.tapByKey(MessageListPage.composer.sendButton); - return this; - } - - /// Types [text] into the composer without sending it. - Future typeText(String text) async { - await tester.enterTextInField(MessageListPage.composer.inputField, text); - return this; - } - - /// Clears whatever is currently in the composer. - Future clearComposer() => typeText(''); - - Future editMessage(String newText, {int messageIndex = 0}) async { - final message = find.byType(MessageListPage.messageItem).at(messageIndex); - await tester.longPressUntilVisible(message, MessageListPage.actions.edit); - await tester.tapFinder(MessageListPage.actions.edit); - await tester.enterTextInField(MessageListPage.composer.inputField, newText); - await tester.tapByKey(MessageListPage.composer.sendButton); - return this; - } - - Future deleteMessage({int messageIndex = 0, String? text}) async { - final message = await _resolveMessage(messageIndex: messageIndex, text: text); - await tester.longPressUntilVisible(message, MessageListPage.actions.delete); - await tester.tapFinder(MessageListPage.actions.delete); - await tester.tapFinder(MessageListPage.actions.deleteConfirm); - return this; - } - - /// Quotes a message and sends [text] as the reply. - Future quoteMessage( - String text, { - int messageIndex = 0, - String? quotedText, - }) async { - final message = await _resolveMessage(messageIndex: messageIndex, text: quotedText); - await tester.longPressUntilVisible(message, MessageListPage.actions.reply); - await tester.tapFinder(MessageListPage.actions.reply); - return sendMessage(text); - } - - /// Swipes a message, which the SDK treats as starting a quoted reply. - Future swipeMessage({int messageIndex = 0, String? text}) async { - await tester.swipeToReply(await _resolveMessage(messageIndex: messageIndex, text: text)); - return this; - } - - /// Taps a reply's quoted bubble, which jumps the list to the original message. - Future tapQuotedMessage() async { - await tester.tapFinder(MessageListPage.list.quotedMessage); - return this; - } - - /// The message row to act on: the one whose text is [text] when given, - /// otherwise the one at [messageIndex] (0 being the newest). - /// - /// Naming a message also scrolls up to it, so an older message that has not - /// been reached yet can still be acted on. - Future _resolveMessage({required int messageIndex, String? text}) async { - if (text == null) return find.byType(MessageListPage.messageItem).at(messageIndex); - - final message = MessageListPage.list.messageWithText(text); - await tester.scrollUpUntil( - () => message.evaluate().isNotEmpty, - description: 'the message with text "$text"', - ); - // A thread renders its root message on top of the paged-in history, so the - // same text can resolve to more than one row; acting on the first is enough. - return message.first; - } - - /// Opens the thread of a message. - /// - /// [parentText] must be given whenever the channel already holds thread - /// replies: the mock server's replies are rendered in the channel list too, so - /// the newest row is a *reply*, and the SDK offers no 'Thread Reply' action on - /// one (its `parentId` is set). Naming the parent also handles it being - /// scrolled out of view, since it is the oldest message. - Future openThread({int messageIndex = 0, String? parentText}) async { - final message = await _resolveMessage(messageIndex: messageIndex, text: parentText); - await tester.longPressUntilVisible(message, MessageListPage.actions.threadReply); - await tester.tapFinder(MessageListPage.actions.threadReply); - await tester.waitUntilVisible(MessageListPage.threadHeader); - return this; - } - - /// Opens the thread from a parent message's "N replies" footer, mirroring the - /// native robot's `threadReplyCountButton` route into a thread. - /// - /// [openThread]'s long-press route can't be used when a thread reply was also - /// sent to the channel: that in-channel copy is the newest message and exposes - /// no 'Thread Reply' action. The footer renders only on the parent, so it is - /// unambiguous. - Future openThreadFromReplies() async { - // The footer only renders on the parent, which is the channel's oldest - // message — and the mock server's replies are rendered in the channel as - // well, so it can start out scrolled past. - await tester.scrollUpUntil( - () => MessageListPage.list.threadReplies.evaluate().isNotEmpty, - description: 'the thread replies footer', - ); - await tester.tapFinder(MessageListPage.list.threadReplies); - await tester.waitUntilVisible(MessageListPage.threadHeader); - return this; - } - - /// Sends [text] from inside a thread, optionally ticking the composer's - /// "also send in channel" checkbox first so the reply is copied into the - /// channel as well. - Future sendMessageInThread( - String text, { - bool alsoSendInChannel = false, - }) async { - if (alsoSendInChannel) { - await tester.tapFinder(MessageListPage.composer.alsoSendInChannelCheckbox); - } - return sendMessage(text); - } - - /// Navigates back (out of a thread, or out of the channel). - Future tapBackButton() async { - await tester.tapFinder(MessageListPage.backButton); - return this; - } - - /// Leaves the thread and then the channel, landing back on the channel list. - Future moveToChannelListFromThread() async { - await tapBackButton(); - await tester.waitUntilNotVisible(MessageListPage.threadHeader); - return tapBackButton(); - } - - /// Waits for the message list to be interactive. - Future waitForMessageListToLoad() async { - await tester.waitUntilVisible(find.byType(MessageListPage.composer.inputField)); - return this; - } - - /// Nudges the message list up by one drag. To *arrive* somewhere, scroll - /// until it is visible instead — a fixed number of drags is viewport- - /// dependent. - Future scrollMessageListUp() async { - await tester.scrollMessageList(300); - return this; - } - - /// Scrolls a thread up to its top, paging in older replies as needed. - /// - /// Stops on the separator, the row right under the parent message, so the - /// parent and any duplicate copy of it among the replies are both built. - Future scrollToTopOfThread() async { - await tester.scrollUpUntil( - () => MessageListPage.list.threadSeparator.evaluate().isNotEmpty, - description: 'the top of the thread', - ); - return this; - } - - Future scrollMessageListDown() async { - await tester.scrollMessageList(-300); - return this; - } - - Future tapLinkPreview() async { - await tester.tapFinder(MessageListPage.list.linkPreview); - return this; - } - - Future addReaction( - ReactionType type, { - int messageIndex = 0, - }) async { - final reaction = MessageListPage.reactions.pickerReaction(type); - final message = find.byType(MessageListPage.messageItem).at(messageIndex); - await tester.longPressUntilVisible(message, find.byKey(reaction)); - await tester.tapByKey(reaction); - return this; - } - - /// Re-selecting an own reaction in the picker toggles it off, so deleting a - /// reaction reuses the same flow as adding it. - Future deleteReaction( - ReactionType type, { - int messageIndex = 0, - }) { - return addReaction(type, messageIndex: messageIndex); - } -} - -/// Fluent chaining over async [UserRobot] actions so test steps read like the -/// native robots (`userRobot.login().openChannel()`) instead of a block of -/// sequential `await`s. Each method mirrors the instance method above. -extension UserRobotChain on Future { - Future login([ - UserCredentials user = PredefinedUsers.currentUser, - ]) => then((it) => it.login(user)); - - Future waitForChannelListToLoad() => then((it) => it.waitForChannelListToLoad()); - - Future openChannel({int index = 0}) => then((it) => it.openChannel(index: index)); - - Future sendMessage(String text) => then((it) => it.sendMessage(text)); - - Future typeText(String text) => then((it) => it.typeText(text)); - - Future clearComposer() => then((it) => it.clearComposer()); - - Future editMessage(String newText, {int messageIndex = 0}) => - then((it) => it.editMessage(newText, messageIndex: messageIndex)); - - Future deleteMessage({int messageIndex = 0, String? text}) => - then((it) => it.deleteMessage(messageIndex: messageIndex, text: text)); - - Future quoteMessage(String text, {int messageIndex = 0, String? quotedText}) => - then((it) => it.quoteMessage(text, messageIndex: messageIndex, quotedText: quotedText)); - - Future swipeMessage({int messageIndex = 0, String? text}) => - then((it) => it.swipeMessage(messageIndex: messageIndex, text: text)); - - Future tapQuotedMessage() => then((it) => it.tapQuotedMessage()); - - Future waitForMessageListToLoad() => then((it) => it.waitForMessageListToLoad()); - - Future openThread({int messageIndex = 0, String? parentText}) => - then((it) => it.openThread(messageIndex: messageIndex, parentText: parentText)); - - Future openThreadFromReplies() => then((it) => it.openThreadFromReplies()); - - Future sendMessageInThread(String text, {bool alsoSendInChannel = false}) => - then((it) => it.sendMessageInThread(text, alsoSendInChannel: alsoSendInChannel)); - - Future tapBackButton() => then((it) => it.tapBackButton()); - - Future moveToChannelListFromThread() => then((it) => it.moveToChannelListFromThread()); - - Future scrollMessageListUp() => then((it) => it.scrollMessageListUp()); - - Future scrollMessageListDown() => then((it) => it.scrollMessageListDown()); - - Future scrollToTopOfThread() => then((it) => it.scrollToTopOfThread()); - - Future tapLinkPreview() => then((it) => it.tapLinkPreview()); - - Future addReaction(ReactionType type, {int messageIndex = 0}) => - then((it) => it.addReaction(type, messageIndex: messageIndex)); - - Future deleteReaction(ReactionType type, {int messageIndex = 0}) => - then((it) => it.deleteReaction(type, messageIndex: messageIndex)); -} diff --git a/sample_app/integration_test/robots/user_robot_message_list_asserts.dart b/sample_app/integration_test/robots/user_robot_message_list_asserts.dart deleted file mode 100644 index 4ce26e11b0..0000000000 --- a/sample_app/integration_test/robots/user_robot_message_list_asserts.dart +++ /dev/null @@ -1,417 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../mock_server/data_types.dart'; -import '../pages/message_list_page.dart'; -import '../support/widget_test_extensions.dart'; -import 'backend_robot.dart'; -import 'user_robot.dart'; - -extension UserRobotMessageListAsserts on UserRobot { - Future assertMessage(String text, {bool isDisplayed = true}) async { - final message = find.text(text); - if (isDisplayed) { - await tester.waitUntilVisible(message); - } else { - await tester.waitUntilNotVisible(message); - } - return this; - } - - /// Asserts a message whose rendered text merely *contains* [text]. Useful for - /// multi-line messages: the markdown renderer collapses single newlines, so - /// the literal `'a\nb'` never matches — but each token still appears. - Future assertMessageContains(String text) async { - await tester.waitUntilVisible(find.textContaining(text)); - return this; - } - - /// A thread reply is rendered as a regular message inside the thread view. - Future assertThreadReply(String text) => assertMessage(text); - - Future assertEditedMessage(String text) async { - await assertMessage(text); - await tester.waitUntilVisible(MessageListPage.list.editedLabel); - return this; - } - - Future assertDeletedMessage({bool isDisplayed = true}) async { - final deleted = MessageListPage.list.deletedMessage; - if (isDisplayed) { - await tester.waitUntilVisible(deleted); - } else { - await tester.waitUntilNotVisible(deleted); - } - return this; - } - - /// A hard-deleted message vanishes entirely: neither its text nor the - /// soft-delete placeholder remains. - Future assertHardDeletedMessage(String text) async { - await assertMessage(text, isDisplayed: false); - await assertDeletedMessage(isDisplayed: false); - return this; - } - - /// Asserts a quoted reply is (or is not) on screen, and — when [quote] is - /// given — that its quoted bubble previews [quote]. - /// - /// [text] identifies the reply by its own text. Leave it null for a reply that - /// has none (an attachment-only quote, which the mock server sends with no - /// body): the assertion then applies to whichever quoted bubble is on screen. - /// - /// [isDisplayed] `false` is how the native suites check that tapping a quote - /// jumped the list away from the reply. - Future assertQuotedMessage({ - String? text, - String? quote, - bool isDisplayed = true, - }) async { - final reply = switch (text) { - final text? => MessageListPage.list.messageWithText(text), - _ => MessageListPage.list.quotedMessage.first, - }; - - if (!isDisplayed) { - await tester.waitUntilNotVisible(reply); - return this; - } - - await tester.waitUntilVisible(reply); - if (quote == null) return this; - - // Scoping to the reply only makes sense when it was located by its own text; - // otherwise `reply` already *is* the bubble. - final bubbleText = switch (text) { - != null => find.descendant(of: reply, matching: MessageListPage.list.quotedMessageText), - _ => MessageListPage.list.quotedMessageText.first, - }; - await tester.expectRenderedText(bubbleText, quote); - return this; - } - - /// Asserts the message [text] is actually on screen. - /// - /// `hitTestable()` is what makes this a *visibility* check: a plain finder - /// matches a row that is merely built, so it can report a row the list has - /// already scrolled past — which makes "the row is gone" a poor stand-in for - /// "the list scrolled away from it". - Future assertMessageOnScreen(String text, {bool isDisplayed = true}) async { - final message = MessageListPage.list.messageWithText(text).hitTestable(); - if (isDisplayed) { - await tester.waitUntilVisible(message); - } else { - await tester.waitUntilNotVisible(message); - } - return this; - } - - /// Asserts the backend's "unknown command" error message is shown. - Future assertInvalidCommandMessage(String command) async { - await tester.waitUntilVisible(MessageListPage.list.moderatedMessage); - await tester.waitUntilVisible(find.textContaining("command $command doesn't exist")); - return this; - } - - Future assertGiphy({required bool isDisplayed}) async { - final giphy = MessageListPage.list.giphy; - if (isDisplayed) { - await tester.waitUntilVisible(giphy); - } else { - await tester.waitUntilNotVisible(giphy); - } - return this; - } - - /// Asserts the thread-reply count, either on the parent's footer in the - /// channel or on the separator inside the thread. - /// - /// The two go through different translations — the footer uses - /// `threadReplyCountText`, the separator `threadSeparatorText` — but both read - /// the same in English, so [inThread] only picks which one to look at. - Future assertThreadReplyLabel({ - required int replies, - bool inThread = false, - }) async { - final label = replies == 1 ? '1 reply' : '$replies replies'; - - final scope = switch (inThread) { - true => find.text(label), - false => find.descendant(of: MessageListPage.list.threadReplies, matching: find.text(label)), - }; - await tester.waitUntilVisible(scope); - return this; - } - - Future assertThreadReplyLabelAvatars({required int count}) async { - await tester.waitUntilVisible(MessageListPage.list.threadReplies); - expect(MessageListPage.list.threadRepliesAvatars, findsNWidgets(count)); - return this; - } - - /// Asserts how many rows currently render the message [text]. - Future assertMessages({required String text, required int count}) async { - final messages = MessageListPage.list.messageWithText(text); - - final end = DateTime.now().add(const Duration(seconds: 30)); - while (messages.evaluate().length != count && DateTime.now().isBefore(end)) { - await tester.pump(const Duration(milliseconds: 100)); - } - expect(messages, findsNWidgets(count)); - return this; - } - - /// Asserts the message list holds exactly [count] rows. - /// - /// `buildMessage` returns a row of its own — never a [MessageListPage.messageItem] — - /// for system, ephemeral and error messages, so all four types are counted here, - /// mirroring the native cell count. The list is lazy, so this is only meaningful - /// for counts small enough to fit on screen. - Future assertMessageCount(int count) async { - int rowCount() => - find.byType(MessageListPage.messageItem).evaluate().length + - MessageListPage.list.systemMessage.evaluate().length + - MessageListPage.list.ephemeralMessage.evaluate().length + - MessageListPage.list.moderatedMessage.evaluate().length; - - final end = DateTime.now().add(const Duration(seconds: 30)); - while (rowCount() != count && DateTime.now().isBefore(end)) { - await tester.pump(const Duration(milliseconds: 100)); - } - expect(rowCount(), count); - return this; - } - - /// Asserts whether the floating scroll-to-bottom button is shown. - /// - /// When it is not, its unread-count badge cannot be either — the SDK only - /// wraps the button once the count is above zero — which is what the native - /// `assertScrollToBottomButtonUnreadCount(0)` checks. - Future assertScrollToBottomButton({ - required bool isDisplayed, - int? unreadCount, - }) async { - final button = MessageListPage.list.scrollToBottomButton; - final badge = MessageListPage.list.scrollToBottomUnreadBadge; - - if (isDisplayed) { - await tester.waitUntilVisible(button); - } else { - await tester.waitUntilNotVisible(button); - await tester.waitUntilNotVisible(badge); - } - - // The SDK only wraps the button in a badge once the count is above zero, so - // an expected count of 0 is asserted as the badge being absent. - switch (unreadCount) { - case null: - break; - case 0: - await tester.waitUntilNotVisible(badge); - case final count: - await tester.expectRenderedText(find.descendant(of: badge, matching: find.byType(Text)), '$count'); - } - return this; - } - - Future assertTypingIndicator({required bool isDisplayed}) async { - final indicator = MessageListPage.list.typingIndicator; - if (isDisplayed) { - await tester.waitUntilVisible(indicator); - } else { - await tester.waitUntilNotVisible(indicator); - } - return this; - } - - Future assertMentionsOverlay({required bool isDisplayed}) async { - final overlay = MessageListPage.composer.mentionsOverlay; - if (isDisplayed) { - await tester.waitUntilVisible(overlay); - } else { - await tester.waitUntilNotVisible(overlay); - } - return this; - } - - Future assertLinkPreview() async { - await tester.waitUntilVisible(MessageListPage.list.linkPreview); - return this; - } - - /// Asserts the message at [messageIndex] is marked as failed to be sent. - /// Mirrors the native `assertMessageFailedToBeSent`. - Future assertMessageFailedToBeSent({int messageIndex = 0}) async { - await _waitOnMessage(MessageListPage.list.errorBadge, messageIndex: messageIndex); - return this; - } - - /// Asserts the delivery status shown on the message at [messageIndex]. - Future assertMessageDeliveryStatus( - MessageDeliveryStatus status, { - int messageIndex = 0, - }) async { - await _waitOnMessage(MessageListPage.list.sendingStatus(status), messageIndex: messageIndex); - return this; - } - - /// Waits for [finder] to appear inside the message at [messageIndex] (index 0 - /// being the newest message), so a status on an older message can't satisfy - /// the assertion. - Future _waitOnMessage(Finder finder, {required int messageIndex}) { - final message = find.byType(MessageListPage.messageItem).at(messageIndex); - return tester.waitUntilVisible(find.descendant(of: message, matching: finder)); - } - - /// Scrolls up through the history until all [messagesCount] messages are - /// loaded and the oldest one is on screen. - /// - /// Both ends come from the channel's own state instead of the message text: - /// what the mock server seeds as text depends on how the channel was generated - /// (see `messagesText` on [BackendRobot.generateChannels]), message identity - /// does not. - Future assertMessageListPagination({required int messagesCount}) async { - await tester.waitUntilVisible(find.byType(MessageListPage.list.view)); - - // Pages in whatever the channel query didn't return. The mock server hands - // over the whole history up front, so today this holds without scrolling. - await tester.scrollUpUntil( - () => _loadedMessages.length >= messagesCount, - description: 'all $messagesCount messages to load', - ); - expect(_loadedMessages, hasLength(messagesCount)); - - final oldest = MessageListPage.list.message(_loadedMessages.first.id); - // Guards against a vacuous pass: the oldest message has to be out of view - // (i.e. actually reached by scrolling), and it would be on screen already if - // the loaded history were ordered newest-first. - expect(oldest, findsNothing); - await tester.scrollUpUntil( - () => oldest.evaluate().isNotEmpty, - description: 'the oldest message to be reached', - ); - return this; - } - - /// The slice of the channel's history the message list has loaded so far, - /// oldest first. - List get _loadedMessages { - final context = tester.element(find.byType(MessageListPage.list.view)); - return StreamChannel.of(context).channel.state?.messages ?? const []; - } - - /// Edits the first message to [newText] and asserts its cell grew (or shrank) - /// accordingly. - Future assertMessageSizeChangesAfterEditing( - String newText, { - required bool increased, - }) async { - final message = find.byType(MessageListPage.messageItem); - final before = tester.getSize(message.first).height; - - await editMessage(newText); - await assertMessage(newText); - - final after = tester.getSize(message.first).height; - if (increased) { - expect(after, greaterThan(before)); - } else { - expect(after, lessThan(before)); - } - return this; - } - - /// Asserts the composer grows with multi-line input but stops growing once it - /// reaches its height cap. - Future assertComposerGrowsWithinLimit() async { - final field = find.byType(MessageListPage.composer.inputField); - - final emptyHeight = tester.getSize(field).height; - await typeText('1\n2\n3'); - expect(tester.getSize(field).height, greaterThan(emptyHeight)); - - await typeText('1\n2\n3\n4\n5\n6\n7\n8\n9\n10'); - final cappedHeight = tester.getSize(field).height; - await typeText('1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12'); - expect(tester.getSize(field).height, cappedHeight); - - await clearComposer(); - return this; - } - - Future assertReaction({ - required ReactionType type, - required bool isDisplayed, - }) async { - // Reaction display chips carry no keys, so they're located by emoji glyph. - final reaction = find.text(type.emoji); - if (isDisplayed) { - await tester.waitUntilVisible(reaction); - } else { - await tester.waitUntilNotVisible(reaction); - } - return this; - } -} - -/// Chainable counterparts to [UserRobotMessageListAsserts], so an assertion can -/// follow a fluent action chain (`userRobot.addReaction(x).assertReaction(...)`). -extension UserRobotMessageListAssertsChain on Future { - Future assertMessage(String text, {bool isDisplayed = true}) => - then((it) => it.assertMessage(text, isDisplayed: isDisplayed)); - - Future assertMessageContains(String text) => then((it) => it.assertMessageContains(text)); - - Future assertThreadReply(String text) => then((it) => it.assertThreadReply(text)); - - Future assertEditedMessage(String text) => then((it) => it.assertEditedMessage(text)); - - Future assertDeletedMessage({bool isDisplayed = true}) => - then((it) => it.assertDeletedMessage(isDisplayed: isDisplayed)); - - Future assertHardDeletedMessage(String text) => then((it) => it.assertHardDeletedMessage(text)); - - Future assertQuotedMessage({String? text, String? quote, bool isDisplayed = true}) => - then((it) => it.assertQuotedMessage(text: text, quote: quote, isDisplayed: isDisplayed)); - - Future assertInvalidCommandMessage(String command) => - then((it) => it.assertInvalidCommandMessage(command)); - - Future assertGiphy({required bool isDisplayed}) => then((it) => it.assertGiphy(isDisplayed: isDisplayed)); - - Future assertThreadReplyLabel({required int replies, bool inThread = false}) => - then((it) => it.assertThreadReplyLabel(replies: replies, inThread: inThread)); - - Future assertThreadReplyLabelAvatars({required int count}) => - then((it) => it.assertThreadReplyLabelAvatars(count: count)); - - Future assertMessages({required String text, required int count}) => - then((it) => it.assertMessages(text: text, count: count)); - - Future assertMessageCount(int count) => then((it) => it.assertMessageCount(count)); - - Future assertScrollToBottomButton({required bool isDisplayed, int? unreadCount}) => - then((it) => it.assertScrollToBottomButton(isDisplayed: isDisplayed, unreadCount: unreadCount)); - - Future assertTypingIndicator({required bool isDisplayed}) => - then((it) => it.assertTypingIndicator(isDisplayed: isDisplayed)); - - Future assertMentionsOverlay({required bool isDisplayed}) => - then((it) => it.assertMentionsOverlay(isDisplayed: isDisplayed)); - - Future assertLinkPreview() => then((it) => it.assertLinkPreview()); - - Future assertMessageFailedToBeSent({int messageIndex = 0}) => - then((it) => it.assertMessageFailedToBeSent(messageIndex: messageIndex)); - - Future assertMessageDeliveryStatus( - MessageDeliveryStatus status, { - int messageIndex = 0, - }) => then((it) => it.assertMessageDeliveryStatus(status, messageIndex: messageIndex)); - - Future assertReaction({ - required ReactionType type, - required bool isDisplayed, - }) => then((it) => it.assertReaction(type: type, isDisplayed: isDisplayed)); -} diff --git a/sample_app/integration_test/support/predefined_users.dart b/sample_app/integration_test/support/predefined_users.dart deleted file mode 100644 index 3b2a5bdceb..0000000000 --- a/sample_app/integration_test/support/predefined_users.dart +++ /dev/null @@ -1,12 +0,0 @@ -class UserCredentials { - const UserCredentials({required this.id, required this.name}); - - final String id; - final String name; -} - -abstract final class PredefinedUsers { - static const qaTest1 = UserCredentials(id: 'qatest1', name: 'QA test 1'); - - static const currentUser = qaTest1; -} diff --git a/sample_app/ios/fastlane/Fastfile b/sample_app/ios/fastlane/Fastfile index d70b187e62..9cfbf43b92 100644 --- a/sample_app/ios/fastlane/Fastfile +++ b/sample_app/ios/fastlane/Fastfile @@ -6,7 +6,6 @@ default_platform(:ios) # Xcode selection is the workflows' job, via .github/actions/setup-xcode. before_all do |lane| - @impact = impact_analysis if is_ci setup_ci setup_git_config @@ -197,11 +196,4 @@ platform :ios do in_house: false, ) end - - lane :install_runtime do |options| - install_ios_runtime( - version: options[:ios], - custom_script: File.join(root_path, 'scripts/install_ios_runtime.sh'), - ) - end end diff --git a/sample_app/lib/app.dart b/sample_app/lib/app.dart index c098b5173c..b9ddd3ba14 100644 --- a/sample_app/lib/app.dart +++ b/sample_app/lib/app.dart @@ -104,7 +104,7 @@ class _StreamChatSampleAppState extends State void initState() { super.initState(); _notificationService.onNotificationTap = _onNotificationTap; - if (!isE2eTestRun) _notificationService.initialize(); + _notificationService.initialize(); final timeOfStartMs = DateTime.now().millisecondsSinceEpoch; @@ -219,9 +219,6 @@ class _StreamChatSampleAppState extends State if (client != null) { return StreamChat( client: client, - // Null in production → the SDK uses the real - // connectivity monitor; set only by e2e tests. - connectivityStream: authController.debugConnectivityStream, componentBuilders: StreamComponentBuilders( extensions: streamChatComponentBuilders( messageItem: customMessageItemBuilder, diff --git a/sample_app/lib/auth/auth_controller.dart b/sample_app/lib/auth/auth_controller.dart index 8886e3981a..258c39ba60 100644 --- a/sample_app/lib/auth/auth_controller.dart +++ b/sample_app/lib/auth/auth_controller.dart @@ -30,11 +30,7 @@ final _chatPersistenceClient = StreamChatPersistenceClient( logLevel: Level.SEVERE, ); -bool get isE2eTestRun => authController.debugConnectionOverride != null; - Future _sampleAppLogHandler(LogRecord record) async { - if (isE2eTestRun) return; - if (kDebugMode) StreamChatClient.defaultLogHandler(record); // report errors to Firebase Crashlytics @@ -47,68 +43,21 @@ Future _sampleAppLogHandler(LogRecord record) async { } } -@visibleForTesting -class StreamConnectionOverride { - const StreamConnectionOverride({ - this.baseURL, - this.baseWsUrl, - this.usePersistence = false, - }); - - final String? baseURL; - final String? baseWsUrl; - final bool usePersistence; -} - -StreamChatClient _buildStreamChatClient( - String apiKey, { - String? baseUrl, - StreamConnectionOverride? connectionOverride, -}) { - final logLevel = connectionOverride != null ? Level.OFF : (kDebugMode ? Level.INFO : Level.SEVERE); +StreamChatClient _buildStreamChatClient(String apiKey, {String? baseUrl}) { return StreamChatClient( - apiKey, - logLevel: logLevel, - logHandlerFunction: _sampleAppLogHandler, - retryPolicy: RetryPolicy( - maxRetryAttempts: 3, - shouldRetry: (client, attempt, error) { - return error is StreamChatNetworkError && error.isRetriable; - }, - ), - baseURL: connectionOverride?.baseURL ?? baseUrl, - baseWsUrl: connectionOverride?.baseWsUrl, - // e2e only: lets the harness simulate a full network outage by failing - // every HTTP request (paired with the WebSocket close from - // debugConnectivityStream). See `AuthController.debugForceOffline`. - chatApiInterceptors: connectionOverride != null ? [_offlineSimulationInterceptor] : null, - ) - ..chatPersistenceClient = switch (connectionOverride) { - // Production always persists. - null => _chatPersistenceClient, - // Under e2e, persistence is opt-in per test; the harness empties the DB - // on teardown (see AuthController.debugReset) so it can't leak between - // tests. Off by default keeps most e2e tests fast and DB-free. - final override => override.usePersistence ? _chatPersistenceClient : null, - }; + apiKey, + logLevel: kDebugMode ? Level.INFO : Level.SEVERE, + logHandlerFunction: _sampleAppLogHandler, + retryPolicy: RetryPolicy( + maxRetryAttempts: 3, + shouldRetry: (client, attempt, error) { + return error is StreamChatNetworkError && error.isRetriable; + }, + ), + baseURL: baseUrl, + )..chatPersistenceClient = _chatPersistenceClient; } -/// Rejects every Stream API request with a connection error while -/// [AuthController.debugForceOffline] is set, mimicking a device that has lost -/// its network — the HTTP half of the e2e offline switch. -final _offlineSimulationInterceptor = InterceptorsWrapper( - onRequest: (options, handler) { - if (!authController.debugForceOffline) return handler.next(options); - return handler.reject( - DioException( - requestOptions: options, - type: DioExceptionType.connectionError, - error: 'e2e: simulated offline', - ), - ); - }, -); - /// Authentication state exposed by [AuthController]. sealed class AuthState { const AuthState(); @@ -158,24 +107,6 @@ class AuthController extends ValueNotifier { return apiKey != kDefaultStreamApiKey || baseUrl != kStreamBaseUrl; } - @visibleForTesting - StreamConnectionOverride? debugConnectionOverride; - - /// Test-controlled connectivity source fed to the `StreamChat` widget. - /// - /// When set, it replaces the real `connectivity_plus` monitor so e2e tests - /// can deterministically drive the client offline/online (see the e2e - /// harness's `goOffline`/`goOnline`). Null in production (read by `app.dart`, - /// so it can't be `@visibleForTesting` like [debugConnectionOverride]). - Stream>? debugConnectivityStream; - - /// e2e only: when true, every Stream API HTTP request fails with a simulated - /// connection error (see [_offlineSimulationInterceptor]). Paired with the - /// WebSocket close driven by [debugConnectivityStream] to simulate a full - /// network outage. Toggled by the harness's `goOffline`/`goOnline`. - @visibleForTesting - bool debugForceOffline = false; - String? _activeApiKey; String? _activeBaseUrl; PushTokenManager? _pushTokenManager; @@ -235,7 +166,6 @@ class AuthController extends ValueNotifier { final client = _client ??= _buildStreamChatClient( apiKey, baseUrl: baseUrl, - connectionOverride: debugConnectionOverride, ); _activeApiKey = apiKey; _activeBaseUrl = baseUrl; @@ -253,13 +183,11 @@ class AuthController extends ValueNotifier { ]); } - if (!isE2eTestRun) { - _pushTokenManager = PushTokenManager( - client: client, - iosPushProvider: _kIosPushProvider, - androidPushProvider: _kAndroidPushProvider, - )..registerDevice(); - } + _pushTokenManager = PushTokenManager( + client: client, + iosPushProvider: _kIosPushProvider, + androidPushProvider: _kAndroidPushProvider, + )..registerDevice(); value = Authenticated(ownUser); } catch (_) { @@ -292,41 +220,6 @@ class AuthController extends ValueNotifier { client.disconnectUser(flushChatPersistence: flushPersistence).ignore(); } - @visibleForTesting - Future debugReset() async { - _pushTokenManager?.dispose().ignore(); - _pushTokenManager = null; - - // Detach persistence from the client *before* closing it. Every SDK path - // that reaches for it either uses `chatPersistenceClient?.` or is guarded by - // `persistenceEnabled` (which is false once this is null), so anything that - // lands after this point short-circuits instead of asserting against a - // disconnected database. Ordering the teardown differently could not close - // that window: a debounced channel-state write fires up to a second later, - // and an HTTP request already on the wire cannot be cancelled at all — its - // response builds a `ChannelClientState`, whose constructor reads the cached - // threads. Both used to throw an async StateError, which the test framework - // then blamed on an unrelated test that had already passed. - final persistence = _client?.chatPersistenceClient; - _client?.chatPersistenceClient = null; - // dispose() won't flush; empty the DB so it can't leak into the next test. - await persistence?.disconnect(flush: true); - await _client?.dispose(); - _client = null; - _activeApiKey = null; - _activeBaseUrl = null; - debugConnectionOverride = null; - debugConnectivityStream = null; - debugForceOffline = false; - - if (platformSupportsPersistenceCredentials) { - const secureStorage = FlutterSecureStorage(); - await secureStorage.deleteAll(); - } - - value = const Unauthenticated(); - } - @override void dispose() { _pushTokenManager?.dispose().ignore(); diff --git a/sample_app/pubspec.yaml b/sample_app/pubspec.yaml index a30dc8a106..b2930e5428 100644 --- a/sample_app/pubspec.yaml +++ b/sample_app/pubspec.yaml @@ -53,9 +53,6 @@ dependencies: dev_dependencies: flutter_launcher_icons: ^0.14.4 - integration_test: - sdk: flutter - url_launcher_platform_interface: ^2.3.2 flutter: uses-material-design: true diff --git a/sample_app/pubspec_overrides.e2e.yaml b/sample_app/pubspec_overrides.e2e.yaml deleted file mode 100644 index 510fc97fe4..0000000000 --- a/sample_app/pubspec_overrides.e2e.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# Copied over pubspec_overrides.yaml by the e2e CI jobs BEFORE `melos bootstrap` -# (melos merges its own managed overrides into the existing file, preserving these -# entries). Swaps the Firebase plugins for the pure-Dart no-op stubs in -# e2e_stubs/ so no native Firebase SDK is downloaded, compiled, or -# initialized during e2e runs. -dependency_overrides: - firebase_core: - path: e2e_stubs/firebase_core - firebase_crashlytics: - path: e2e_stubs/firebase_crashlytics - firebase_messaging: - path: e2e_stubs/firebase_messaging - - # TODO(e2e): temporary — remove this pin once the e2e suite no longer depends - # on animation frame timing. - # - # lottie 3.6.1 schedules frames at the composition frame rate instead of every - # vsync, which changes when the widget tree goes quiescent. The harness settles - # the tree between acting on it, so the splash screen's animation shifted when - # settling completed and the suite began failing across every branch. The SDK - # itself is compatible with 3.6.1, so this is pinned for e2e only rather than - # capped in melos.yaml, which would constrain integrators. - lottie: 3.5.1 From 0b8aa6d46d5a5706f5d1c2913b98d4f5bcca1f9c Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Fri, 23 Oct 2026 15:17:55 +0100 Subject: [PATCH 02/21] chore(repo): ignore e2e mock server, Allure results and allurectl in any app --- .gitignore | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 3efecdbb29..7ee8c39cd1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,11 @@ *.class *.log *.pyc -/e2e_app/stream-chat-test-mock-server/ +# e2e: the cloned mock server, Allure results and CLI, and the generated batch entrypoint +stream-chat-test-mock-server/ +allure-results/ +allurectl /e2e_app/integration_test/e2e_batch.g.dart -/e2e_app/allure-results/ -/sample_app/allurectl *.swp .DS_Store .atom/ From b4979e400bec0d75428b4cece1cc8259defba67c Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Fri, 23 Oct 2026 15:43:50 +0100 Subject: [PATCH 03/21] test(repo): link every e2e test to its Allure TestOps case --- .../integration_test/attachments_test.dart | 13 +++++++ .../integration_test/authentication_test.dart | 11 ++++++ .../channel_actions_test.dart | 8 ++++ .../integration_test/channel_list_test.dart | 3 ++ .../integration_test/channel_search_test.dart | 1 + e2e_app/integration_test/connection_test.dart | 3 ++ .../integration_test/draft_messages_test.dart | 3 ++ e2e_app/integration_test/giphy_test.dart | 11 ++++++ .../integration_test/hyper_links_test.dart | 9 +++++ .../message_actions_test.dart | 1 + .../message_delivery_status_test.dart | 39 +++++++++++++++++++ .../integration_test/message_list_test.dart | 29 ++++++++++++++ e2e_app/integration_test/moderation_test.dart | 9 +++++ .../pinned_messages_test.dart | 6 +++ e2e_app/integration_test/polls_test.dart | 6 +++ .../integration_test/quoted_reply_test.dart | 5 +++ e2e_app/integration_test/reactions_test.dart | 4 ++ e2e_app/integration_test/reminders_test.dart | 5 +++ e2e_app/integration_test/search_test.dart | 2 + e2e_app/integration_test/slow_mode_test.dart | 6 +++ .../integration_test/thread_list_test.dart | 3 ++ .../integration_test/unread_counts_test.dart | 5 +++ .../unread_messages_test.dart | 6 +++ 23 files changed, 188 insertions(+) diff --git a/e2e_app/integration_test/attachments_test.dart b/e2e_app/integration_test/attachments_test.dart index 1f8fdecee4..c4935434e7 100644 --- a/e2e_app/integration_test/attachments_test.dart +++ b/e2e_app/integration_test/attachments_test.dart @@ -8,6 +8,7 @@ void main() { // MARK: User uploads streamTestWithEnv( + allureId: '12653', description: 'user uploads an image', body: (env) async { step('GIVEN the user opens a channel'); @@ -28,6 +29,7 @@ void main() { ); streamTestWithEnv( + allureId: '12654', description: 'user uploads multiple images', body: (env) async { step('GIVEN the user opens a channel'); @@ -48,6 +50,7 @@ void main() { ); streamTestWithEnv( + allureId: '12655', description: 'user deletes an image', body: (env) async { step('GIVEN the user opens a channel'); @@ -65,6 +68,7 @@ void main() { ); streamTestWithEnv( + allureId: '12656', description: 'user uploads a file', body: (env) async { step('GIVEN the user opens a channel'); @@ -85,6 +89,7 @@ void main() { ); streamTestWithEnv( + allureId: '12657', description: 'user uploads multiple files', body: (env) async { step('GIVEN the user opens a channel'); @@ -105,6 +110,7 @@ void main() { ); streamTestWithEnv( + allureId: '12658', description: 'user deletes a file', body: (env) async { step('GIVEN the user opens a channel'); @@ -122,6 +128,7 @@ void main() { ); streamTestWithEnv( + allureId: '12659', description: 'user uploads a video', body: (env) async { step('GIVEN the user opens a channel'); @@ -136,6 +143,7 @@ void main() { ); streamTestWithEnv( + allureId: '12660', description: 'image upload recovers when the user comes back online', body: (env) async { step('GIVEN the user opens a channel'); @@ -160,6 +168,7 @@ void main() { // MARK: Participant uploads streamTestWithEnv( + allureId: '12661', description: 'participant uploads an image', body: (env) async { step('GIVEN the user opens a channel'); @@ -174,6 +183,7 @@ void main() { ); streamTestWithEnv( + allureId: '12662', description: 'participant uploads multiple images', body: (env) async { step('GIVEN the user opens a channel'); @@ -188,6 +198,7 @@ void main() { ); streamTestWithEnv( + allureId: '12663', description: 'participant uploads a video', body: (env) async { step('GIVEN the user opens a channel'); @@ -202,6 +213,7 @@ void main() { ); streamTestWithEnv( + allureId: '12664', description: 'participant uploads a file', body: (env) async { step('GIVEN the user opens a channel'); @@ -216,6 +228,7 @@ void main() { ); streamTestWithEnv( + allureId: '12665', description: 'user swipes between images in the gallery', body: (env) async { step('GIVEN the user opens a channel'); diff --git a/e2e_app/integration_test/authentication_test.dart b/e2e_app/integration_test/authentication_test.dart index 694db804c0..6a41f10db4 100644 --- a/e2e_app/integration_test/authentication_test.dart +++ b/e2e_app/integration_test/authentication_test.dart @@ -11,6 +11,7 @@ void main() { } streamTestWithEnv( + allureId: '12666', description: 'token invalidates before the user logs in', skip: 'https://linear.app/stream/issue/FLU-1084', jwt: true, @@ -27,6 +28,7 @@ void main() { ); streamTestWithEnv( + allureId: '12667', description: 'token invalidates after the user logs in', skip: 'https://linear.app/stream/issue/FLU-1084', jwt: true, @@ -44,6 +46,7 @@ void main() { ); streamTestWithEnv( + allureId: '12668', description: 'token date invalidates before the user logs in', skip: 'https://linear.app/stream/issue/FLU-1083', jwt: true, @@ -60,6 +63,7 @@ void main() { ); streamTestWithEnv( + allureId: '12669', description: 'token date invalidates after the user logs in', skip: 'https://linear.app/stream/issue/FLU-1083', jwt: true, @@ -77,6 +81,7 @@ void main() { ); streamTestWithEnv( + allureId: '12670', description: 'token signature invalidates before the user logs in', skip: 'https://linear.app/stream/issue/FLU-1083', jwt: true, @@ -93,6 +98,7 @@ void main() { ); streamTestWithEnv( + allureId: '12671', description: 'token signature invalidates after the user logs in', skip: 'https://linear.app/stream/issue/FLU-1083', jwt: true, @@ -110,6 +116,7 @@ void main() { ); streamTestWithEnv( + allureId: '12672', description: 'token expires before the user logs in', jwt: true, body: (env) async { @@ -125,6 +132,7 @@ void main() { ); streamTestWithEnv( + allureId: '12673', description: 'token expires after the user logs in', jwt: true, body: (env) async { @@ -141,6 +149,7 @@ void main() { ); streamTestWithEnv( + allureId: '12674', description: 'token expires while the user is in the background', jwt: true, body: (env) async { @@ -164,6 +173,7 @@ void main() { ); streamTestWithEnv( + allureId: '12675', description: 'token expires while the user is offline', jwt: true, body: (env) async { @@ -186,6 +196,7 @@ void main() { ); streamTestWithEnv( + allureId: '12676', description: 'token generation fails', jwt: true, body: (env) async { diff --git a/e2e_app/integration_test/channel_actions_test.dart b/e2e_app/integration_test/channel_actions_test.dart index abc74675d4..4c3062f838 100644 --- a/e2e_app/integration_test/channel_actions_test.dart +++ b/e2e_app/integration_test/channel_actions_test.dart @@ -6,6 +6,7 @@ import 'support/stream_test_case.dart'; void main() { streamTestWithEnv( + allureId: '12677', description: 'channel actions sheet is shown when the user long-presses the channel', body: (env) async { step('GIVEN the user logs in'); @@ -20,6 +21,7 @@ void main() { ); streamTestWithEnv( + allureId: '12678', description: 'channel actions sheet is shown when the user swipes the channel', body: (env) async { step('GIVEN the user logs in'); @@ -34,6 +36,7 @@ void main() { ); streamTestWithEnv( + allureId: '12679', description: 'user opens the channel info from the channel actions sheet', body: (env) async { step('GIVEN the user logs in'); @@ -51,6 +54,7 @@ void main() { ); streamTestWithEnv( + allureId: '12680', description: 'user leaves a group channel', body: (env) async { step('GIVEN the user logs in'); @@ -68,6 +72,7 @@ void main() { ); streamTestWithEnv( + allureId: '12681', description: 'user deletes a group channel', body: (env) async { step('GIVEN the user logs in'); @@ -85,6 +90,7 @@ void main() { ); streamTestWithEnv( + allureId: '12682', description: 'user mutes a channel from the swipe action', body: (env) async { step('GIVEN the user logs in'); @@ -99,6 +105,7 @@ void main() { ); streamTestWithEnv( + allureId: '12683', description: 'user unmutes a channel from the swipe action', body: (env) async { step('GIVEN the user logs in'); @@ -116,6 +123,7 @@ void main() { ); streamTestWithEnv( + allureId: '12684', description: 'app does not crash when the user goes back after being removed from the open channel', skip: 'https://linear.app/stream/issue/FLU-1085', body: (env) async { diff --git a/e2e_app/integration_test/channel_list_test.dart b/e2e_app/integration_test/channel_list_test.dart index 81d8a001ca..4a89d3e2dd 100644 --- a/e2e_app/integration_test/channel_list_test.dart +++ b/e2e_app/integration_test/channel_list_test.dart @@ -348,6 +348,7 @@ void main() { ); streamTestWithEnv( + allureId: '12685', description: 'channel preview is updated when the participant edits the preview message', body: (env) async { const message = 'message'; @@ -373,6 +374,7 @@ void main() { ); streamTestWithEnv( + allureId: '12686', description: 'typing indicator is shown in the channel preview when the participant types', body: (env) async { step('GIVEN the user opens the channel list'); @@ -393,6 +395,7 @@ void main() { ); streamTestWithEnv( + allureId: '12687', description: 'channel list is reordered after the oldest channel gets a message while offline', body: (env) async { const message = 'message'; diff --git a/e2e_app/integration_test/channel_search_test.dart b/e2e_app/integration_test/channel_search_test.dart index f869456e69..72b100de4f 100644 --- a/e2e_app/integration_test/channel_search_test.dart +++ b/e2e_app/integration_test/channel_search_test.dart @@ -8,6 +8,7 @@ void main() { const searchQuery = 'Blue'; streamTestWithEnv( + allureId: '12688', description: 'user searches for a channel', body: (env) async { step('GIVEN channels exist, one with a searchable name'); diff --git a/e2e_app/integration_test/connection_test.dart b/e2e_app/integration_test/connection_test.dart index 3fc94d801d..3e307fa85e 100644 --- a/e2e_app/integration_test/connection_test.dart +++ b/e2e_app/integration_test/connection_test.dart @@ -8,6 +8,7 @@ import 'support/stream_test_case.dart'; void main() { streamTestWithEnv( + allureId: '12689', description: 'channel list is shown when the user logs out and logs back in', body: (env) async { step('GIVEN the user logs in'); @@ -29,6 +30,7 @@ void main() { ); streamTestWithEnv( + allureId: '12690', description: 'channel list is shown when the user logs out while it is loading and logs back in', body: (env) async { step('GIVEN the user logs in'); @@ -51,6 +53,7 @@ void main() { ); streamTestWithEnv( + allureId: '12691', description: 'user logs in as another user after logging out', body: (env) async { const firstUserMessage = 'message from the first user'; diff --git a/e2e_app/integration_test/draft_messages_test.dart b/e2e_app/integration_test/draft_messages_test.dart index d06929067f..1d4a21043e 100644 --- a/e2e_app/integration_test/draft_messages_test.dart +++ b/e2e_app/integration_test/draft_messages_test.dart @@ -9,6 +9,7 @@ void main() { const draftMessage = 'alright'; streamTestWithEnv( + allureId: '12692', description: 'user updates a channel draft message', body: (env) async { step('GIVEN the user opens a channel'); @@ -50,6 +51,7 @@ void main() { ); streamTestWithEnv( + allureId: '12693', description: 'user updates a thread draft message', body: (env) async { step('GIVEN the user opens a thread'); @@ -82,6 +84,7 @@ void main() { ); streamTestWithEnv( + allureId: '12694', description: 'user updates a draft message while offline', skip: 'https://linear.app/stream/issue/FLU-1086', body: (env) async { diff --git a/e2e_app/integration_test/giphy_test.dart b/e2e_app/integration_test/giphy_test.dart index a5e4fb871d..299166d51e 100644 --- a/e2e_app/integration_test/giphy_test.dart +++ b/e2e_app/integration_test/giphy_test.dart @@ -7,6 +7,7 @@ import 'support/stream_test_case.dart'; void main() { streamTestWithEnv( + allureId: '12695', description: 'user observes a Giphy when the user adds it', body: (env) async { step('GIVEN the user opens a channel'); @@ -21,6 +22,7 @@ void main() { ); streamTestWithEnv( + allureId: '12696', description: 'user observes a Giphy when the participant adds it', body: (env) async { step('GIVEN the user opens a channel'); @@ -35,6 +37,7 @@ void main() { ); streamTestWithEnv( + allureId: '12697', description: 'user observes a Giphy when the user adds it in a thread', body: (env) async { step('GIVEN the user opens a channel'); @@ -50,6 +53,7 @@ void main() { ); streamTestWithEnv( + allureId: '12698', description: 'user observes a Giphy when the participant adds it in a thread', body: (env) async { step('GIVEN the user opens a channel'); @@ -65,6 +69,7 @@ void main() { ); streamTestWithEnv( + allureId: '12699', description: 'user observes a Giphy after adding it through the composer commands', body: (env) async { step('GIVEN the user opens a channel'); @@ -79,6 +84,7 @@ void main() { ); streamTestWithEnv( + allureId: '12700', description: 'message is not sent when the user sends an invalid command', body: (env) async { const invalidCommand = 'invalid command'; @@ -101,6 +107,7 @@ void main() { ); streamTestWithEnv( + allureId: '12701', description: 'channel list is not modified when an ephemeral message is shown', body: (env) async { step('GIVEN the user opens a channel'); @@ -118,6 +125,7 @@ void main() { ); streamTestWithEnv( + allureId: '12702', description: 'delivery status is hidden when an ephemeral message is shown', skip: 'https://linear.app/stream/issue/FLU-1087', body: (env) async { @@ -133,6 +141,7 @@ void main() { ); streamTestWithEnv( + allureId: '12703', description: 'delivery status is hidden when an ephemeral message is shown in a thread', skip: 'https://linear.app/stream/issue/FLU-1087', body: (env) async { @@ -149,6 +158,7 @@ void main() { ); streamTestWithEnv( + allureId: '12704', description: 'message is not sent when the user cancels the ephemeral message', body: (env) async { step('GIVEN the user opens a channel'); @@ -163,6 +173,7 @@ void main() { ); streamTestWithEnv( + allureId: '12705', description: 'user observes a Giphy after shuffling it', body: (env) async { step('GIVEN the user opens a channel'); diff --git a/e2e_app/integration_test/hyper_links_test.dart b/e2e_app/integration_test/hyper_links_test.dart index 9bad634c4d..11df7ad61e 100644 --- a/e2e_app/integration_test/hyper_links_test.dart +++ b/e2e_app/integration_test/hyper_links_test.dart @@ -15,6 +15,7 @@ void main() { const enrichedAfterDismissal = 'https://linear.app/stream/issue/FLU-1089'; streamTestWithEnv( + allureId: '12706', description: 'user sends a Unsplash link with a preview', skip: noPreviewForImageOg, body: (env) async { @@ -39,6 +40,7 @@ void main() { // Passes only because the Unsplash preview never renders at all (FLU-1088). streamTestWithEnv( + allureId: '12707', description: 'user sends a Unsplash link without a preview', body: (env) async { step('GIVEN the user opens a channel'); @@ -64,6 +66,7 @@ void main() { ); streamTestWithEnv( + allureId: '12708', description: 'participant sends a Unsplash link', skip: noPreviewForImageOg, body: (env) async { @@ -81,6 +84,7 @@ void main() { ); streamTestWithEnv( + allureId: '12709', description: 'user sends a YouTube link with a preview', body: (env) async { step('GIVEN the user opens a channel'); @@ -103,6 +107,7 @@ void main() { ); streamTestWithEnv( + allureId: '12710', description: 'user sends a YouTube link without a preview', skip: enrichedAfterDismissal, body: (env) async { @@ -129,6 +134,7 @@ void main() { ); streamTestWithEnv( + allureId: '12711', description: 'participant sends a YouTube link', body: (env) async { step('GIVEN the user opens a channel'); @@ -145,6 +151,7 @@ void main() { ); streamTestWithEnv( + allureId: '12712', description: 'user sends a Giphy link with a preview', body: (env) async { step('GIVEN the user opens a channel'); @@ -167,6 +174,7 @@ void main() { ); streamTestWithEnv( + allureId: '12713', description: 'user sends a Giphy link without a preview', skip: enrichedAfterDismissal, body: (env) async { @@ -193,6 +201,7 @@ void main() { ); streamTestWithEnv( + allureId: '12714', description: 'participant sends a Giphy link', body: (env) async { step('GIVEN the user opens a channel'); diff --git a/e2e_app/integration_test/message_actions_test.dart b/e2e_app/integration_test/message_actions_test.dart index a5a66d9429..fda93f706c 100644 --- a/e2e_app/integration_test/message_actions_test.dart +++ b/e2e_app/integration_test/message_actions_test.dart @@ -7,6 +7,7 @@ void main() { const sampleText = 'Test'; streamTestWithEnv( + allureId: '12715', description: 'user copies a message', body: (env) async { step('GIVEN the user opens a channel'); diff --git a/e2e_app/integration_test/message_delivery_status_test.dart b/e2e_app/integration_test/message_delivery_status_test.dart index f64f7c6780..d0639b3937 100644 --- a/e2e_app/integration_test/message_delivery_status_test.dart +++ b/e2e_app/integration_test/message_delivery_status_test.dart @@ -17,6 +17,7 @@ void main() { // MARK: Message list streamTestWithEnv( + allureId: '12716', description: 'single checkmark is shown when a message is sent', body: (env) async { step('GIVEN the user opens a channel'); @@ -31,6 +32,7 @@ void main() { ); streamTestWithEnv( + allureId: '12717', description: 'delivery status shows clocks when a message is pending', body: (env) async { step('GIVEN the user opens a channel'); @@ -50,6 +52,7 @@ void main() { ); streamTestWithEnv( + allureId: '12718', description: 'error indicator is shown when a message fails to be sent', body: (env) async { step('GIVEN the user opens a channel'); @@ -66,6 +69,7 @@ void main() { ); streamTestWithEnv( + allureId: '12719', description: 'double checkmark is shown when the participant reads a message', body: (env) async { step('GIVEN the user opens a channel'); @@ -83,6 +87,7 @@ void main() { ); streamTestWithEnv( + allureId: '12720', description: 'delivered checkmark turns read when the participant reads the message', body: (env) async { step('GIVEN the user opens a channel'); @@ -106,6 +111,7 @@ void main() { ); streamTestWithEnv( + allureId: '12721', description: 'delivery status is shown for the last message in a group', body: (env) async { step('GIVEN the user opens a channel'); @@ -125,6 +131,7 @@ void main() { streamTestWithEnv( // The native suites disagree: Android and UIKit hide the status of a deleted // message, SwiftUI keeps it (`test_deliveryStatusShown_whenMessageIsDeleted`). + allureId: '12722', description: 'delivery status is shown when a message is deleted', body: (env) async { step('GIVEN the user opens a channel'); @@ -142,6 +149,7 @@ void main() { ); streamTestWithEnv( + allureId: '12723', description: 'delivery status is shown for the previous message when an error message is shown', body: (env) async { step('GIVEN the user opens a channel'); @@ -159,6 +167,7 @@ void main() { ); streamTestWithEnv( + allureId: '12724', description: 'delivery status is shown for the previous message when a new message fails to be sent', skip: 'https://linear.app/stream/issue/FLU-1090', body: (env) async { @@ -182,6 +191,7 @@ void main() { ); streamTestWithEnv( + allureId: '12725', description: 'message that failed to be sent cannot be previewed in a thread', body: (env) async { step('GIVEN the user opens a channel'); @@ -205,6 +215,7 @@ void main() { ); streamTestWithEnv( + allureId: '12726', description: 'double checkmark is still shown when a new participant is added', body: (env) async { step('GIVEN the user opens a channel'); @@ -226,6 +237,7 @@ void main() { ); streamTestWithEnv( + allureId: '12727', description: 'read status is dropped when the participant is removed', body: (env) async { step('GIVEN the user opens a channel'); @@ -249,6 +261,7 @@ void main() { // MARK: Threads streamTestWithEnv( + allureId: '12728', description: 'single checkmark is shown when a message is sent and previewed in a thread', body: (env) async { step('GIVEN the user opens a channel'); @@ -272,6 +285,7 @@ void main() { ); streamTestWithEnv( + allureId: '12729', description: 'single checkmark is shown when a thread reply is sent', body: (env) async { step('GIVEN the user opens a channel'); @@ -290,6 +304,7 @@ void main() { ); streamTestWithEnv( + allureId: '12730', description: 'error indicator is shown when a thread reply fails to be sent', body: (env) async { step('GIVEN the user opens a channel'); @@ -307,6 +322,7 @@ void main() { ); streamTestWithEnv( + allureId: '12731', description: 'double checkmark is shown when the participant reads a thread reply', body: (env) async { step('GIVEN the user opens a channel'); @@ -328,6 +344,7 @@ void main() { ); streamTestWithEnv( + allureId: '12732', description: 'double checkmark is shown when the participant reads a thread reply also sent to the channel', body: (env) async { step('GIVEN the user opens a channel'); @@ -355,6 +372,7 @@ void main() { ); streamTestWithEnv( + allureId: '12733', description: 'delivery status is shown for the last thread reply in a group', body: (env) async { step('GIVEN the user opens a channel'); @@ -378,6 +396,7 @@ void main() { streamTestWithEnv( // SwiftUI's `test_deliveryStatusShown_whenThreadReplyIsDeleted`; see the // deleted message case above. + allureId: '12734', description: 'delivery status is shown when a thread reply is deleted', body: (env) async { step('GIVEN the user opens a channel'); @@ -397,6 +416,7 @@ void main() { ); streamTestWithEnv( + allureId: '12735', description: 'thread reply has no double checkmark when a new participant is added', body: (env) async { step('GIVEN the user opens a channel'); @@ -418,6 +438,7 @@ void main() { ); streamTestWithEnv( + allureId: '12736', description: 'read status of a thread reply is dropped when the participant is removed', body: (env) async { step('GIVEN the user opens a channel'); @@ -443,6 +464,7 @@ void main() { // MARK: Channel preview streamTestWithEnv( + allureId: '12737', description: 'channel preview shows clocks when the last message is pending', body: (env) async { step('GIVEN the user opens a channel'); @@ -465,6 +487,7 @@ void main() { ); streamTestWithEnv( + allureId: '12738', description: 'channel preview shows a single checkmark when the last message is sent', body: (env) async { step('GIVEN the user opens a channel'); @@ -482,6 +505,7 @@ void main() { ); streamTestWithEnv( + allureId: '12739', description: 'channel preview shows an error indicator when the message fails to be sent', body: (env) async { step('GIVEN the user opens a channel'); @@ -501,6 +525,7 @@ void main() { ); streamTestWithEnv( + allureId: '12740', description: 'channel preview shows a double checkmark when the participant reads the message', body: (env) async { step('GIVEN the user opens a channel'); @@ -521,6 +546,7 @@ void main() { ); streamTestWithEnv( + allureId: '12741', description: 'channel preview shows no delivery status when the participant sends the message', body: (env) async { step('GIVEN the user opens a channel'); @@ -541,6 +567,7 @@ void main() { ); streamTestWithEnv( + allureId: '12742', description: 'channel preview shows no delivery status when the user sends a thread reply', body: (env) async { step('GIVEN the user opens a channel'); @@ -564,6 +591,7 @@ void main() { ); streamTestWithEnv( + allureId: '12743', description: 'channel preview shows a single checkmark when a thread reply is pending', body: (env) async { step('GIVEN the user opens a channel'); @@ -590,6 +618,7 @@ void main() { ); streamTestWithEnv( + allureId: '12744', description: 'channel preview shows no delivery status when the participant reads a thread reply', body: (env) async { step('GIVEN the user opens a channel'); @@ -614,6 +643,7 @@ void main() { ); streamTestWithEnv( + allureId: '12745', description: 'channel preview shows no delivery status when the participant sends a thread reply', body: (env) async { step('GIVEN the user opens a channel'); @@ -638,6 +668,7 @@ void main() { // MARK: Read events disabled streamTestWithEnv( + allureId: '12746', description: 'delivery status is hidden when a message is sent and read events are disabled', skip: 'https://linear.app/stream/issue/FLU-1091', body: (env) async { @@ -654,6 +685,7 @@ void main() { ); streamTestWithEnv( + allureId: '12747', description: 'delivery status shows clocks when a message is pending and read events are disabled', body: (env) async { step('GIVEN read events are disabled and the user opens a channel'); @@ -674,6 +706,7 @@ void main() { ); streamTestWithEnv( + allureId: '12748', description: 'error indicator is shown when a message fails to be sent and read events are disabled', body: (env) async { step('GIVEN read events are disabled and the user opens a channel'); @@ -691,6 +724,7 @@ void main() { ); streamTestWithEnv( + allureId: '12749', description: 'delivery status is hidden when the participant reads a message and read events are disabled', skip: 'https://linear.app/stream/issue/FLU-1091', body: (env) async { @@ -710,6 +744,7 @@ void main() { ); streamTestWithEnv( + allureId: '12750', description: 'delivery status is hidden when a new participant is added and read events are disabled', skip: 'https://linear.app/stream/issue/FLU-1091', body: (env) async { @@ -732,6 +767,7 @@ void main() { ); streamTestWithEnv( + allureId: '12751', description: 'delivery status is hidden when the participant is removed and read events are disabled', skip: 'https://linear.app/stream/issue/FLU-1091', body: (env) async { @@ -754,6 +790,7 @@ void main() { ); streamTestWithEnv( + allureId: '12752', description: 'delivery status is hidden for grouped messages when read events are disabled', skip: 'https://linear.app/stream/issue/FLU-1091', body: (env) async { @@ -775,6 +812,7 @@ void main() { ); streamTestWithEnv( + allureId: '12753', description: 'delivery status is hidden when a message is deleted and read events are disabled', skip: 'https://linear.app/stream/issue/FLU-1091', body: (env) async { @@ -797,6 +835,7 @@ void main() { ); streamTestWithEnv( + allureId: '12754', description: 'channel preview shows no delivery status when read events are disabled', skip: 'https://linear.app/stream/issue/FLU-1091', body: (env) async { diff --git a/e2e_app/integration_test/message_list_test.dart b/e2e_app/integration_test/message_list_test.dart index a435236dcd..d91dd100f0 100644 --- a/e2e_app/integration_test/message_list_test.dart +++ b/e2e_app/integration_test/message_list_test.dart @@ -335,6 +335,7 @@ void main() { ); streamTestWithEnv( + allureId: '12755', description: 'thread reply also sent to the channel by the user appears in both places', body: (env) async { step('GIVEN the user opens a channel'); @@ -411,6 +412,7 @@ void main() { ); streamTestWithEnv( + allureId: '12756', description: 'thread reply is removed everywhere when the participant removes it from the channel', body: (env) async { step('GIVEN the user opens a channel'); @@ -433,6 +435,7 @@ void main() { ); streamTestWithEnv( + allureId: '12757', description: 'thread reply is removed everywhere when the user removes it from the channel', body: (env) async { step('GIVEN the user opens a channel'); @@ -455,6 +458,7 @@ void main() { ); streamTestWithEnv( + allureId: '12758', description: 'thread reply is removed everywhere when the user removes it from the thread', body: (env) async { step('GIVEN the user opens a channel'); @@ -477,6 +481,7 @@ void main() { ); streamTestWithEnv( + allureId: '12759', description: 'participant removes a thread reply', body: (env) async { step('GIVEN the user opens a channel'); @@ -500,6 +505,7 @@ void main() { ); streamTestWithEnv( + allureId: '12760', description: 'user removes a thread reply', body: (env) async { step('GIVEN the user opens a channel'); @@ -522,6 +528,7 @@ void main() { ); streamTestWithEnv( + allureId: '12761', description: 'thread is not locked after the user deletes the parent message', skip: 'https://linear.app/stream/issue/FLU-1092', body: (env) async { @@ -542,6 +549,7 @@ void main() { ); streamTestWithEnv( + allureId: '12762', description: 'thread is not locked after the participant deletes the parent message', skip: 'https://linear.app/stream/issue/FLU-1092', body: (env) async { @@ -567,6 +575,7 @@ void main() { ); streamTestWithEnv( + allureId: '12763', description: 'user hard-deletes a message', body: (env) async { step('GIVEN the user opens a channel'); @@ -584,6 +593,7 @@ void main() { ); streamTestWithEnv( + allureId: '12764', description: 'user hard-deletes a message in a channel with history', body: (env) async { const message = 'test message'; @@ -627,6 +637,7 @@ void main() { ); streamTestWithEnv( + allureId: '12765', description: 'user fills the composer by mentioning the participant through the mentions overlay', body: (env) async { step('GIVEN the user opens a channel'); @@ -643,6 +654,7 @@ void main() { // MARK: Commands streamTestWithEnv( + allureId: '12766', description: 'selecting a command hides the attachments button', body: (env) async { step('GIVEN the user opens a channel'); @@ -660,6 +672,7 @@ void main() { ); streamTestWithEnv( + allureId: '12767', description: 'command suggestions disappear when the user taps on the message list', skip: 'https://linear.app/stream/issue/FLU-1093', body: (env) async { @@ -720,6 +733,7 @@ void main() { ); streamTestWithEnv( + allureId: '12768', description: 'message list scrolls down when scrolled up and the user sends a message', body: (env) async { step('GIVEN the user opens a channel with history'); @@ -738,6 +752,7 @@ void main() { ); streamTestWithEnv( + allureId: '12769', description: 'message list scrolls down when the user taps the scroll to bottom button', body: (env) async { const newMessage = 'New message'; @@ -761,6 +776,7 @@ void main() { ); streamTestWithEnv( + allureId: '12770', description: 'skipped messages are reloaded when the user scrolls to the bottom', body: (env) async { step('GIVEN the user opens a channel with history'); @@ -782,6 +798,7 @@ void main() { ); streamTestWithEnv( + allureId: '12771', description: 'scroll to bottom button shows the unread count', body: (env) async { const newMessage = 'New message'; @@ -830,6 +847,7 @@ void main() { ); streamTestWithEnv( + allureId: '12772', description: 'user paginates a thread', body: (env) async { const repliesCount = 60; @@ -897,6 +915,7 @@ void main() { ); streamTestWithEnv( + allureId: '12773', description: 'user sees a link preview for their own YouTube link', body: (env) async { const link = 'https://youtube.com/watch?v=xOX7MsrbaPY'; @@ -913,6 +932,7 @@ void main() { ); streamTestWithEnv( + allureId: '12774', description: 'user sees a link preview for their own Unsplash link', skip: 'https://linear.app/stream/issue/FLU-1088', body: (env) async { @@ -930,6 +950,7 @@ void main() { ); streamTestWithEnv( + allureId: '12775', description: 'user sees a link preview for an Unsplash link', skip: 'https://linear.app/stream/issue/FLU-1088', body: (env) async { @@ -947,6 +968,7 @@ void main() { ); streamTestWithEnv( + allureId: '12776', description: 'message with a link without a scheme opens the browser', skip: 'https://linear.app/stream/issue/FLU-1094', body: (env) async { @@ -1011,6 +1033,7 @@ void main() { ); streamTestWithEnv( + allureId: '12777', description: 'message ends its group when followed by an error message', body: (env) async { step('GIVEN the user opens a channel'); @@ -1031,6 +1054,7 @@ void main() { ); streamTestWithEnv( + allureId: '12778', description: 'message renders its timestamp again when the last message of its group is hard-deleted', body: (env) async { step('GIVEN the user opens a channel'); @@ -1050,6 +1074,7 @@ void main() { ); streamTestWithEnv( + allureId: '12779', description: 'message renders its timestamp again when the last message of its group is soft-deleted', body: (env) async { step('GIVEN the user opens a channel'); @@ -1071,6 +1096,7 @@ void main() { // MARK: Thread reply editing streamTestWithEnv( + allureId: '12780', description: 'user edits a thread reply', body: (env) async { const threadReply = 'thread reply'; @@ -1095,6 +1121,7 @@ void main() { ); streamTestWithEnv( + allureId: '12781', description: 'participant edits a thread reply', body: (env) async { const threadReply = 'thread reply'; @@ -1121,6 +1148,7 @@ void main() { ); streamTestWithEnv( + allureId: '12782', description: 'thread reply also sent to the channel is edited everywhere when the participant edits it', body: (env) async { const threadReply = 'thread reply'; @@ -1148,6 +1176,7 @@ void main() { ); streamTestWithEnv( + allureId: '12783', description: 'thread reply is removed everywhere when the participant removes it from the thread', body: (env) async { const threadReply = 'thread reply'; diff --git a/e2e_app/integration_test/moderation_test.dart b/e2e_app/integration_test/moderation_test.dart index 3f42cadc44..786f1d2cd5 100644 --- a/e2e_app/integration_test/moderation_test.dart +++ b/e2e_app/integration_test/moderation_test.dart @@ -13,6 +13,7 @@ void main() { const groupChannelName = '1'; streamTestWithEnv( + allureId: '12784', description: 'user flags a message', body: (env) async { step('GIVEN the user opens a channel'); @@ -37,6 +38,7 @@ void main() { ); streamTestWithEnv( + allureId: '12785', description: 'user mutes the message author', body: (env) async { step('GIVEN the user opens a channel'); @@ -55,6 +57,7 @@ void main() { ); streamTestWithEnv( + allureId: '12786', description: 'user unmutes the message author', body: (env) async { step('GIVEN the user opens a channel'); @@ -76,6 +79,7 @@ void main() { ); streamTestWithEnv( + allureId: '12787', description: 'muted author stays muted when the user reopens the channel', body: (env) async { step('GIVEN the user opens a channel'); @@ -97,6 +101,7 @@ void main() { ); streamTestWithEnv( + allureId: '12788', description: 'user blocks the message author', body: (env) async { step('GIVEN the user opens a channel'); @@ -115,6 +120,7 @@ void main() { ); streamTestWithEnv( + allureId: '12789', description: 'user unblocks the message author', body: (env) async { step('GIVEN the user opens a channel'); @@ -138,6 +144,7 @@ void main() { ); streamTestWithEnv( + allureId: '12790', description: 'blocked author stays blocked when the user reopens the channel', body: (env) async { step('GIVEN the user opens a channel'); @@ -161,6 +168,7 @@ void main() { ); streamTestWithEnv( + allureId: '12791', description: 'user blocks the participant in a direct message channel', skip: 'https://linear.app/stream/issue/FLU-1085', body: (env) async { @@ -179,6 +187,7 @@ void main() { ); streamTestWithEnv( + allureId: '12792', description: 'direct message channel disappears when the user blocks the participant', skip: 'https://linear.app/stream/issue/FLU-1085', body: (env) async { diff --git a/e2e_app/integration_test/pinned_messages_test.dart b/e2e_app/integration_test/pinned_messages_test.dart index 05329e35c8..2b11d6a244 100644 --- a/e2e_app/integration_test/pinned_messages_test.dart +++ b/e2e_app/integration_test/pinned_messages_test.dart @@ -9,6 +9,7 @@ void main() { const sampleText = 'Test'; streamTestWithEnv( + allureId: '12793', description: 'user pins a message', body: (env) async { step('GIVEN the user opens a channel'); @@ -27,6 +28,7 @@ void main() { ); streamTestWithEnv( + allureId: '12794', description: 'user unpins a message', body: (env) async { step('GIVEN the user opens a channel'); @@ -48,6 +50,7 @@ void main() { ); streamTestWithEnv( + allureId: '12795', description: 'participant pins a message', body: (env) async { step('GIVEN the user opens a channel'); @@ -65,6 +68,7 @@ void main() { ); streamTestWithEnv( + allureId: '12796', description: 'participant unpins a message', body: (env) async { step('GIVEN the user opens a channel'); @@ -86,6 +90,7 @@ void main() { ); streamTestWithEnv( + allureId: '12797', description: 'pinned message is shown on the pinned messages screen', body: (env) async { step('GIVEN the user opens a channel'); @@ -107,6 +112,7 @@ void main() { ); streamTestWithEnv( + allureId: '12798', description: 'unpinned message is not shown on the pinned messages screen', body: (env) async { step('GIVEN the user opens a channel'); diff --git a/e2e_app/integration_test/polls_test.dart b/e2e_app/integration_test/polls_test.dart index 189c720212..efa84f1172 100644 --- a/e2e_app/integration_test/polls_test.dart +++ b/e2e_app/integration_test/polls_test.dart @@ -12,6 +12,7 @@ void main() { const options = [firstOption, secondOption]; streamTestWithEnv( + allureId: '12799', description: 'poll message is shown when the user creates a poll', body: (env) async { step('GIVEN the user opens a channel'); @@ -26,6 +27,7 @@ void main() { ); streamTestWithEnv( + allureId: '12800', description: 'option is checked when the user votes in a poll', body: (env) async { step('GIVEN the user creates a poll'); @@ -40,6 +42,7 @@ void main() { ); streamTestWithEnv( + allureId: '12801', description: 'option is unchecked when the user removes the poll vote', body: (env) async { step('GIVEN the user creates a poll and votes'); @@ -59,6 +62,7 @@ void main() { ); streamTestWithEnv( + allureId: '12802', description: 'participant vote is shown in the poll results', body: (env) async { step('GIVEN the user creates a poll'); @@ -78,6 +82,7 @@ void main() { ); streamTestWithEnv( + allureId: '12803', description: 'poll is closed when the user ends it', body: (env) async { step('GIVEN the user creates a poll'); @@ -92,6 +97,7 @@ void main() { ); streamTestWithEnv( + allureId: '12804', description: 'user votes for several options when the poll allows multiple votes', body: (env) async { step('GIVEN the user creates a poll that allows multiple votes'); diff --git a/e2e_app/integration_test/quoted_reply_test.dart b/e2e_app/integration_test/quoted_reply_test.dart index a5cca6936b..a3bce39bed 100644 --- a/e2e_app/integration_test/quoted_reply_test.dart +++ b/e2e_app/integration_test/quoted_reply_test.dart @@ -787,6 +787,7 @@ void main() { // MARK: Jumping streamTestWithEnv( + allureId: '12805', description: 'channel preview does not change when the user leaves the channel after jumping to a mid page', body: (env) async { const messageCount = 60; @@ -821,6 +822,7 @@ void main() { ); streamTestWithEnv( + allureId: '12806', description: 'quoted message is present after re-entering the channel and loading new messages', body: (env) async { const messageCount = 60; @@ -855,6 +857,7 @@ void main() { ); streamTestWithEnv( + allureId: '12807', description: 'user jumps from the channel to a quoted message in a thread', skip: 'https://linear.app/stream/issue/FLU-1095', body: (env) async { @@ -892,6 +895,7 @@ void main() { ); streamTestWithEnv( + allureId: '12808', description: 'user jumps from the channel to a regular message in a thread', body: (env) async { const threadReply = 'also in channel'; @@ -923,6 +927,7 @@ void main() { ); streamTestWithEnv( + allureId: '12809', description: 'user jumps to a message in a thread opened by its id from the channel list', body: (env) async { const threadReply = 'jump target'; diff --git a/e2e_app/integration_test/reactions_test.dart b/e2e_app/integration_test/reactions_test.dart index c52698a8dd..716838ac5a 100644 --- a/e2e_app/integration_test/reactions_test.dart +++ b/e2e_app/integration_test/reactions_test.dart @@ -217,6 +217,7 @@ void main() { // MARK: Extended reactions streamTestWithEnv( + allureId: '12810', description: 'user adds a reaction using the extended reactions picker', body: (env) async { step('GIVEN the user opens a channel'); @@ -235,6 +236,7 @@ void main() { ); streamTestWithEnv( + allureId: '12811', description: 'user removes a reaction using the extended reactions picker', body: (env) async { step('GIVEN the user opens a channel'); @@ -258,6 +260,7 @@ void main() { ); streamTestWithEnv( + allureId: '12812', description: 'reaction authors sheet is shown when the user taps on a reaction', body: (env) async { const message = 'test message'; @@ -300,6 +303,7 @@ void main() { ); streamTestWithEnv( + allureId: '12813', description: 'participant adds a reaction to a thread reply', body: (env) async { const message = 'message'; diff --git a/e2e_app/integration_test/reminders_test.dart b/e2e_app/integration_test/reminders_test.dart index 56e8c0dbbc..af8dc47b19 100644 --- a/e2e_app/integration_test/reminders_test.dart +++ b/e2e_app/integration_test/reminders_test.dart @@ -8,6 +8,7 @@ void main() { const oneHourInSeconds = 3600; streamTestWithEnv( + allureId: '12814', description: 'reminders screen is empty when the user has no reminders', body: (env) async { step('GIVEN the user logs in'); @@ -22,6 +23,7 @@ void main() { ); streamTestWithEnv( + allureId: '12815', description: 'reminder saved for later is shown on the reminders screen', body: (env) async { step('GIVEN the user opens a channel'); @@ -43,6 +45,7 @@ void main() { ); streamTestWithEnv( + allureId: '12816', description: 'scheduled reminder is shown on the reminders screen', body: (env) async { step('GIVEN the user opens a channel'); @@ -64,6 +67,7 @@ void main() { ); streamTestWithEnv( + allureId: '12817', description: 'reminder saved for later is shown on the message', body: (env) async { step('GIVEN the user opens a channel'); @@ -85,6 +89,7 @@ void main() { ); streamTestWithEnv( + allureId: '12818', description: 'scheduled reminder is shown on the message', body: (env) async { step('GIVEN the user opens a channel'); diff --git a/e2e_app/integration_test/search_test.dart b/e2e_app/integration_test/search_test.dart index f5c13a97b8..ce2cdc5f7b 100644 --- a/e2e_app/integration_test/search_test.dart +++ b/e2e_app/integration_test/search_test.dart @@ -8,6 +8,7 @@ void main() { const sampleText = 'Test'; streamTestWithEnv( + allureId: '12819', description: 'user searches for a message', body: (env) async { step('GIVEN the user opens the channel list'); @@ -26,6 +27,7 @@ void main() { ); streamTestWithEnv( + allureId: '12820', description: 'user opens a message from the search results', body: (env) async { step('GIVEN the user opens the channel list'); diff --git a/e2e_app/integration_test/slow_mode_test.dart b/e2e_app/integration_test/slow_mode_test.dart index 3cdcce16c1..08a79c814d 100644 --- a/e2e_app/integration_test/slow_mode_test.dart +++ b/e2e_app/integration_test/slow_mode_test.dart @@ -12,6 +12,7 @@ void main() { const editedMessage = 'edited message'; streamTestWithEnv( + allureId: '12821', description: 'cooldown is shown when a new message is sent', body: (env) async { step('GIVEN slow mode is enabled on the channel'); @@ -29,6 +30,7 @@ void main() { ); streamTestWithEnv( + allureId: '12822', description: 'composer is disabled while slow mode is active', body: (env) async { step('GIVEN slow mode is enabled on the channel'); @@ -46,6 +48,7 @@ void main() { ); streamTestWithEnv( + allureId: '12823', description: 'cooldown is shown when the user replies to a message', body: (env) async { step('GIVEN slow mode is enabled on the channel'); @@ -70,6 +73,7 @@ void main() { ); streamTestWithEnv( + allureId: '12824', description: 'message cannot be replied to while the cooldown is shown', body: (env) async { step('GIVEN slow mode is enabled on the channel'); @@ -90,6 +94,7 @@ void main() { ); streamTestWithEnv( + allureId: '12825', description: 'cooldown carries over into a thread', body: (env) async { step('GIVEN slow mode is enabled on the channel'); @@ -110,6 +115,7 @@ void main() { ); streamTestWithEnv( + allureId: '12826', description: 'cooldown is not shown when a message is edited', body: (env) async { step('GIVEN slow mode is enabled on a channel with a message'); diff --git a/e2e_app/integration_test/thread_list_test.dart b/e2e_app/integration_test/thread_list_test.dart index 388263fb55..f004da031c 100644 --- a/e2e_app/integration_test/thread_list_test.dart +++ b/e2e_app/integration_test/thread_list_test.dart @@ -8,6 +8,7 @@ void main() { const replyText = 'Reply'; streamTestWithEnv( + allureId: '12827', description: 'thread list is empty when the channel has no threads', body: (env) async { step('GIVEN the user logs in'); @@ -22,6 +23,7 @@ void main() { ); streamTestWithEnv( + allureId: '12828', description: 'thread is shown on the thread list', body: (env) async { step('GIVEN the user opens a channel'); @@ -43,6 +45,7 @@ void main() { ); streamTestWithEnv( + allureId: '12829', description: 'user opens a thread from the thread list', body: (env) async { step('GIVEN the user opens a channel'); diff --git a/e2e_app/integration_test/unread_counts_test.dart b/e2e_app/integration_test/unread_counts_test.dart index 76462afe99..fe0b17c638 100644 --- a/e2e_app/integration_test/unread_counts_test.dart +++ b/e2e_app/integration_test/unread_counts_test.dart @@ -10,6 +10,7 @@ void main() { const participantMessage = 'Participant message'; streamTestWithEnv( + allureId: '12830', description: 'new message in a muted channel does not increment the unread count', body: (env) async { step('GIVEN the user is in the channel list'); @@ -28,6 +29,7 @@ void main() { ); streamTestWithEnv( + allureId: '12831', description: 'new message from a muted user does not increment the unread count', body: (env) async { step('GIVEN the user opens a channel'); @@ -53,6 +55,7 @@ void main() { ); streamTestWithEnv( + allureId: '12832', description: 'new system message increments the unread count', skip: 'https://linear.app/stream/issue/FLU-1096', body: (env) async { @@ -76,6 +79,7 @@ void main() { ); streamTestWithEnv( + allureId: '12833', description: 'new thread reply also sent to the channel increments the unread count', body: (env) async { step('GIVEN the user opens a channel and sends a message'); @@ -98,6 +102,7 @@ void main() { ); streamTestWithEnv( + allureId: '12834', description: 'hard delete of an unseen message decrements the unread count', skip: 'https://linear.app/stream/issue/FLU-1097', body: (env) async { diff --git a/e2e_app/integration_test/unread_messages_test.dart b/e2e_app/integration_test/unread_messages_test.dart index 752796c31d..1daad669eb 100644 --- a/e2e_app/integration_test/unread_messages_test.dart +++ b/e2e_app/integration_test/unread_messages_test.dart @@ -9,6 +9,7 @@ void main() { const sampleText = 'Test'; streamTestWithEnv( + allureId: '12835', description: 'unread separator is shown when the participant sends messages while the user is away', skip: 'https://linear.app/stream/issue/FLU-1098', body: (env) async { @@ -38,6 +39,7 @@ void main() { ); streamTestWithEnv( + allureId: '12836', description: 'user scrolls to the first unread message', skip: 'https://linear.app/stream/issue/FLU-1098', body: (env) async { @@ -73,6 +75,7 @@ void main() { ); streamTestWithEnv( + allureId: '12837', description: 'user marks a message as unread', body: (env) async { const unreadCount = 2; @@ -99,6 +102,7 @@ void main() { ); streamTestWithEnv( + allureId: '12838', description: 'jump to unread button shows the unread count when the user marks a message as unread', body: (env) async { const unreadCount = 25; @@ -125,6 +129,7 @@ void main() { ); streamTestWithEnv( + allureId: '12839', description: 'user dismisses the unread indicator', body: (env) async { const unreadCount = 25; @@ -153,6 +158,7 @@ void main() { ); streamTestWithEnv( + allureId: '12840', description: 'channel unread count is reset when the user reads the channel', body: (env) async { const unreadCount = 2; From b03e78ee6e680333c642e12c6c1c5067a61c3fe9 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Fri, 23 Oct 2026 16:32:19 +0100 Subject: [PATCH 04/21] test(repo): unmount the e2e app before resetting the client, verify clearComposer --- e2e_app/integration_test/robots/user_robot.dart | 14 ++++++++++++-- .../integration_test/support/stream_test_env.dart | 8 +++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/e2e_app/integration_test/robots/user_robot.dart b/e2e_app/integration_test/robots/user_robot.dart index bfed1d2b52..94e4bfc895 100644 --- a/e2e_app/integration_test/robots/user_robot.dart +++ b/e2e_app/integration_test/robots/user_robot.dart @@ -71,8 +71,18 @@ class UserRobot { return this; } - /// Clears whatever is currently in the composer. - Future clearComposer() => typeText(''); + /// Clears whatever is currently in the composer, and fails right here when the + /// composer still holds text: a later assertion would only see a draft that + /// was never deleted, without telling whether the clear itself failed. + Future clearComposer() async { + await typeText(''); + final end = DateTime.now().add(const Duration(seconds: 10)); + while (composerText.isNotEmpty && DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(composerText, isEmpty, reason: 'The composer still holds text after clearing it'); + return this; + } /// The text currently in the composer. String get composerText => diff --git a/e2e_app/integration_test/support/stream_test_env.dart b/e2e_app/integration_test/support/stream_test_env.dart index b7bc89f66a..97e9e26f4f 100644 --- a/e2e_app/integration_test/support/stream_test_env.dart +++ b/e2e_app/integration_test/support/stream_test_env.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:e2e_app/app.dart'; import 'package:e2e_app/auth/auth_controller.dart'; -import 'package:flutter/widgets.dart' show AppLifecycleState; +import 'package:flutter/widgets.dart' show AppLifecycleState, SizedBox; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; @@ -245,6 +245,12 @@ class StreamTestEnv { Future tearDown() async { await _connectionStatusSubscription?.cancel(); try { + // Unmount the app while the client is still alive. A test that fails with + // a page open leaves its widgets mounted, and disposing the client first + // makes their deactivate() throw (the composer saves its draft on the way + // out). That aborts the unmount, and the half-disposed tree keeps global + // pointer routes that break every later test sharing the process. + await _tester.pumpWidget(const SizedBox.shrink()); await authController.debugReset(); } finally { // Null when tearDown runs before setUp installed the fake. From 3bb8bd34f1066629947d04c41e3c0e694b64861e Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Fri, 23 Oct 2026 17:06:28 +0100 Subject: [PATCH 05/21] ci(repo): run PR e2e on API 36, use a preinstalled NDK, retry failed builds whole --- .github/workflows/e2e_test.yml | 2 +- e2e_app/android/app/build.gradle.kts | 5 ++++- e2e_app/fastlane/e2e_log.rb | 5 +++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e_test.yml b/.github/workflows/e2e_test.yml index da98fa2b45..e4830eea12 100644 --- a/.github/workflows/e2e_test.yml +++ b/.github/workflows/e2e_test.yml @@ -95,7 +95,7 @@ jobs: matrix: batch: ${{ fromJson(needs.select.outputs.e2e_batches) }} env: - ANDROID_API_LEVEL: "34" + ANDROID_API_LEVEL: "36" LAUNCH_ID: ${{ needs.allure_launch_android.outputs.launch_id }} steps: - uses: actions/checkout@v7 diff --git a/e2e_app/android/app/build.gradle.kts b/e2e_app/android/app/build.gradle.kts index 96997280d3..0463a6a0e4 100644 --- a/e2e_app/android/app/build.gradle.kts +++ b/e2e_app/android/app/build.gradle.kts @@ -8,7 +8,10 @@ android { namespace = "io.getstream.chat.e2e_app" // The SDK's plugins need a newer compile SDK than Flutter's default. compileSdk = maxOf(flutter.compileSdkVersion, 36) - ndkVersion = flutter.ndkVersion + // GitHub's Ubuntu runners preinstall a few NDKs, but not Flutter's default, so every CI + // job used to download one mid-build (a failure point, and 1-2 min per job). Use the newest + // preinstalled NDK there; a newer NDK also serves plugins that ask for an older one. + ndkVersion = System.getenv("ANDROID_NDK_LATEST_HOME")?.let { File(it).name } ?: flutter.ndkVersion compileOptions { sourceCompatibility = JavaVersion.VERSION_17 diff --git a/e2e_app/fastlane/e2e_log.rb b/e2e_app/fastlane/e2e_log.rb index 0ef1c02a9f..6a25fbce7e 100644 --- a/e2e_app/fastlane/e2e_log.rb +++ b/e2e_app/fastlane/e2e_log.rb @@ -30,6 +30,10 @@ module E2ELog # What the runner appends to the name of the test it pinned such an error on. POST_COMPLETION_SUFFIX = ' (failed after test completion)' + # The runner's entry for a test file that failed to load, e.g. when the app did + # not build. It is not a test, so it is neither owed a result nor a `--name`. + LOAD_FAILURE = /\Aloading \S+\.dart\z/ + class << self # The tests that reported a failure — what a retry should narrow to. def failed_test_names(log) @@ -45,6 +49,7 @@ def failed_test_names(log) def phantom_test_names(log) accounted = read(log).scan(REPORTED_TEST).flatten .map { |name| name.delete_suffix(POST_COMPLETION_SUFFIX).delete_suffix(FAILED_SUFFIX).strip } + .reject { |name| name.match?(LOAD_FAILURE) } .uniq return [] if accounted.empty? From e7510b3d0a6b8d4f544f0c6019bdfd6c0d0523a1 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Fri, 23 Oct 2026 17:34:20 +0100 Subject: [PATCH 06/21] test(repo): retype into a field that ignored the text --- .../support/widget_test_extensions.dart | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/e2e_app/integration_test/support/widget_test_extensions.dart b/e2e_app/integration_test/support/widget_test_extensions.dart index 60da378fb4..03970b4608 100644 --- a/e2e_app/integration_test/support/widget_test_extensions.dart +++ b/e2e_app/integration_test/support/widget_test_extensions.dart @@ -88,11 +88,33 @@ extension E2EWidgetTester on WidgetTester { await settle(); } + /// Types [text] into the field, retrying while the field ignores it. + /// + /// [enterText] focuses the field and pumps one frame before sending the text to + /// whichever input connection is attached. On a slow device the field's connection + /// can open later (e.g. while its page is still transitioning in), and the text then + /// goes to a stale connection and is silently dropped. A field that rewrites what was + /// typed (a composer command) still counts as having taken it, so only an unchanged + /// field is retried. Future enterTextInField(Type inputFieldType, String text) async { final finder = find.byType(inputFieldType); await waitUntilVisible(finder); - await enterText(finder, text); - await settle(); + String fieldText() => + widget(find.descendant(of: finder, matching: find.byType(EditableText))).controller.text; + + final before = fieldText(); + final end = DateTime.now().add(const Duration(seconds: 10)); + while (true) { + await enterText(finder, text); + await settle(); + if (before == text || fieldText() != before || DateTime.now().isAfter(end)) break; + await pump(const Duration(milliseconds: 250)); + } + expect( + before == text || fieldText() != before, + isTrue, + reason: 'The field ignored the typed text: it still holds "${fieldText()}"', + ); } /// Drags the message list by [delta] logical pixels. A positive [delta] From 1e7542864bb8bc3d59fa0f75750f96fa1703319a Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Fri, 23 Oct 2026 19:48:38 +0100 Subject: [PATCH 07/21] test(repo): keep the copied message id in the e2e app, bound the video step --- .github/workflows/e2e_test.yml | 5 ++++- .github/workflows/e2e_test_cron.yml | 5 ++++- .../integration_test/support/stream_test_env.dart | 2 ++ e2e_app/lib/pages/channel_actions_sheet.dart | 5 +++-- e2e_app/lib/widgets/e2e_message_actions.dart | 12 +++++++++++- 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/e2e_test.yml b/.github/workflows/e2e_test.yml index e4830eea12..326b680a84 100644 --- a/.github/workflows/e2e_test.yml +++ b/.github/workflows/e2e_test.yml @@ -132,9 +132,12 @@ jobs: - name: Attach videos to failed tests if: failure() + # Videos only help debugging: a slow apt mirror (19 min once) must not hold the job. + timeout-minutes: 5 + continue-on-error: true working-directory: e2e_app run: | - sudo apt-get update && sudo apt-get install -y ffmpeg + sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg bundle exec fastlane attach_videos - name: Upload Allure results diff --git a/.github/workflows/e2e_test_cron.yml b/.github/workflows/e2e_test_cron.yml index 90a6c84132..77fb7fee81 100644 --- a/.github/workflows/e2e_test_cron.yml +++ b/.github/workflows/e2e_test_cron.yml @@ -87,9 +87,12 @@ jobs: - name: Attach videos to failed tests if: failure() + # Videos only help debugging: a slow apt mirror (19 min once) must not hold the job. + timeout-minutes: 5 + continue-on-error: true working-directory: e2e_app run: | - sudo apt-get update && sudo apt-get install -y ffmpeg + sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg bundle exec fastlane attach_videos - name: Upload Allure results diff --git a/e2e_app/integration_test/support/stream_test_env.dart b/e2e_app/integration_test/support/stream_test_env.dart index 97e9e26f4f..9d07640ba4 100644 --- a/e2e_app/integration_test/support/stream_test_env.dart +++ b/e2e_app/integration_test/support/stream_test_env.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:e2e_app/app.dart'; import 'package:e2e_app/auth/auth_controller.dart'; +import 'package:e2e_app/widgets/e2e_message_actions.dart' show lastCopiedMessageId; import 'package:flutter/widgets.dart' show AppLifecycleState, SizedBox; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -252,6 +253,7 @@ class StreamTestEnv { // pointer routes that break every later test sharing the process. await _tester.pumpWidget(const SizedBox.shrink()); await authController.debugReset(); + lastCopiedMessageId = null; } finally { // Null when tearDown runs before setUp installed the fake. if (_realUrlLauncher case final real?) UrlLauncherPlatform.instance = real; diff --git a/e2e_app/lib/pages/channel_actions_sheet.dart b/e2e_app/lib/pages/channel_actions_sheet.dart index ec7b73f3ee..ed51e3aeed 100644 --- a/e2e_app/lib/pages/channel_actions_sheet.dart +++ b/e2e_app/lib/pages/channel_actions_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../navigation.dart'; +import '../widgets/e2e_message_actions.dart'; /// The sheet a long press (or the swipe's "more" action) opens on a channel. Future showChannelActionsSheet(BuildContext context, Channel channel) { @@ -59,11 +60,11 @@ class _ChannelActions extends StatelessWidget { title: Text(channel.isOneToOne ? 'Delete Chat' : 'Delete Group'), onTap: () => run(() => _delete(hostContext, channel)), ), - // Debug: opens the message whose id "Copy Message ID" put on the clipboard. + // Debug: opens the message whose id "Copy Message ID" copied. ListTile( title: const Text('Open Message by ID'), onTap: () => run(() async { - final messageId = (await Clipboard.getData(Clipboard.kTextPlain))?.text; + final messageId = lastCopiedMessageId ?? (await Clipboard.getData(Clipboard.kTextPlain))?.text; if (messageId == null || messageId.isEmpty || !hostContext.mounted) return; return openMessage(hostContext, channel, messageId); }), diff --git a/e2e_app/lib/widgets/e2e_message_actions.dart b/e2e_app/lib/widgets/e2e_message_actions.dart index bb3ef1fb14..3aa7f6872f 100644 --- a/e2e_app/lib/widgets/e2e_message_actions.dart +++ b/e2e_app/lib/widgets/e2e_message_actions.dart @@ -2,6 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +/// The id the last "Copy Message ID" copied. +/// +/// "Open Message by ID" reads it from here rather than from the system clipboard, +/// which Android may refuse to read back (an app without input focus gets nothing). +/// The id still goes on the clipboard for manual use. +String? lastCopiedMessageId; + /// Adds the message actions the native test apps offer and the SDK does not: /// /// * "Hard Delete Message", for the user's own messages — the SDK only @@ -29,7 +36,10 @@ Widget e2eMessageItemBuilder(BuildContext context, StreamMessageItemProps props) StreamContextMenuAction( label: const Text('Copy Message ID'), leading: Icon(context.streamIcons.copy), - onTap: () => Clipboard.setData(ClipboardData(text: message.id)).ignore(), + onTap: () { + lastCopiedMessageId = message.id; + Clipboard.setData(ClipboardData(text: message.id)).ignore(); + }, ), ], ); From 8d9cef2a17efffa4f05b15c1beff13fad8ad5cd2 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Fri, 23 Oct 2026 22:02:36 +0100 Subject: [PATCH 08/21] test(repo): scroll until a message is on screen, stop when the emulator dies --- e2e_app/fastlane/Fastfile | 25 +++++++++++++++++++ .../integration_test/robots/user_robot.dart | 12 ++++++--- .../support/stream_test_env.dart | 13 ++++++++-- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/e2e_app/fastlane/Fastfile b/e2e_app/fastlane/Fastfile index f8538f53b3..48c677128b 100644 --- a/e2e_app/fastlane/Fastfile +++ b/e2e_app/fastlane/Fastfile @@ -107,6 +107,19 @@ def combine_runs(runs) [BATCH_ENTRYPOINT, names, files] end +# Whether the Android device is online, after giving one that dropped off (e.g. a CI +# emulator that hung) a minute to come back. +def android_device_online?(device) + serial = device ? "-s #{device} " : '' + 30.times do |i| + return true if `adb #{serial}get-state 2>/dev/null`.strip == 'device' + + UI.important("Android device #{device || '(default)'} is not online; waiting for it to come back…") if i.zero? + sleep(2) + end + false +end + def boot_simulator(udid:) return if `xcrun simctl list devices booted`.to_s.include?(udid) return unless `xcrun simctl list devices`.to_s.include?(udid) @@ -278,6 +291,18 @@ lane :run_e2e_test do |options| test_name = File.basename(target, '.dart') files ||= [test_name] + # A dead emulator turns every remaining run into an instant "No supported devices" + # failure. Stop instead, and count the remaining runs as failed, so the coverage + # map keeps their previous coverage rather than dropping them. + if !ios && !android_device_online?(device) + remaining = runs.drop(run_index) + UI.important("Android device #{device || '(default)'} is gone; skipping the #{remaining.size} remaining run(s)") + remaining.each do |t, s, _| + failed << (coverage ? "#{File.basename(t, '.dart')}/#{s.first}" : t) + end + break + end + attempt_log = "#{videos_dir}/#{test_name}.attempt.log" coverage_dir = "#{e2e_coverage_path}/#{test_name}/#{selected.first}" if coverage diff --git a/e2e_app/integration_test/robots/user_robot.dart b/e2e_app/integration_test/robots/user_robot.dart index 94e4bfc895..121e00267c 100644 --- a/e2e_app/integration_test/robots/user_robot.dart +++ b/e2e_app/integration_test/robots/user_robot.dart @@ -219,16 +219,20 @@ class UserRobot { return this; } - /// Scrolls the message list up until the message [text] is rendered. + /// Scrolls the message list up until the message [text] is on screen. Future scrollMessageListUpUntilVisible(String text) async { - final message = MessageListPage.list.messageWithText(text); + // Hit-testable, not just built: the list also builds rows in its cache extent, just + // outside the viewport, where a tap or an on-screen assertion can't reach them. + final message = MessageListPage.list.messageWithText(text).hitTestable(); await tester.scrollUpUntil(() => message.evaluate().isNotEmpty, description: 'the message "$text"'); return this; } - /// Scrolls the message list down until the message [text] is rendered. + /// Scrolls the message list down until the message [text] is on screen. Future scrollMessageListDownUntilVisible(String text) async { - final message = MessageListPage.list.messageWithText(text); + // Hit-testable, not just built: the list also builds rows in its cache extent, just + // outside the viewport, where a tap or an on-screen assertion can't reach them. + final message = MessageListPage.list.messageWithText(text).hitTestable(); await tester.scrollDownUntil(() => message.evaluate().isNotEmpty, description: 'the message "$text"'); return this; } diff --git a/e2e_app/integration_test/support/stream_test_env.dart b/e2e_app/integration_test/support/stream_test_env.dart index 9d07640ba4..1000b0fd64 100644 --- a/e2e_app/integration_test/support/stream_test_env.dart +++ b/e2e_app/integration_test/support/stream_test_env.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:e2e_app/app.dart'; import 'package:e2e_app/auth/auth_controller.dart'; import 'package:e2e_app/widgets/e2e_message_actions.dart' show lastCopiedMessageId; -import 'package:flutter/widgets.dart' show AppLifecycleState, SizedBox; +import 'package:flutter/widgets.dart' show AppLifecycleState, FlutterError, SizedBox, debugPrint; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; @@ -251,7 +251,16 @@ class StreamTestEnv { // makes their deactivate() throw (the composer saves its draft on the way // out). That aborts the unmount, and the half-disposed tree keeps global // pointer routes that break every later test sharing the process. - await _tester.pumpWidget(const SizedBox.shrink()); + try { + await _tester.pumpWidget(const SizedBox.shrink()); + // The conflict is reported as a FlutterError, which is an Error. + // ignore: avoid_catching_errors + } on FlutterError catch (e) { + // A test that failed while one of its pumps was still running (an async + // error) leaves that pump guarded, and pumping again is a "Guarded function + // conflict". The test has already failed; still reset the client below. + debugPrint('Could not unmount the app before resetting the client: ${e.message}'); + } await authController.debugReset(); lastCopiedMessageId = null; } finally { From 0e4da80d1a9fb05899a755e1d29c2bcfb9037e50 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Fri, 23 Oct 2026 23:28:09 +0100 Subject: [PATCH 09/21] ci(repo): give the e2e app's Gradle the sample app's memory settings --- e2e_app/android/gradle.properties | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/e2e_app/android/gradle.properties b/e2e_app/android/gradle.properties index e96108cfe4..22fbb9124f 100644 --- a/e2e_app/android/gradle.properties +++ b/e2e_app/android/gradle.properties @@ -1,4 +1,8 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +# Same as sample_app. The template's 8G heap (+4G metaspace) left too little of a CI +# runner's 16 GB for the emulator, which hung during builds. +org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC +org.gradle.caching=true +org.gradle.parallel=true android.useAndroidX=true # This newDsl flag was added by the Flutter template android.newDsl=false From 4a66a48b04a7577ef10b45427e9f7256ded7b628 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Sat, 24 Oct 2026 18:19:13 +0100 Subject: [PATCH 10/21] Bump timeout --- .github/workflows/e2e_test_cron.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e_test_cron.yml b/.github/workflows/e2e_test_cron.yml index 77fb7fee81..458831cfee 100644 --- a/.github/workflows/e2e_test_cron.yml +++ b/.github/workflows/e2e_test_cron.yml @@ -26,8 +26,7 @@ env: jobs: android: runs-on: ubuntu-latest - # The coverage entry starts the app once per test, which takes well over an hour. - timeout-minutes: 180 + timeout-minutes: 360 permissions: contents: read actions: read # downloads the previous map, for the coverage of failed tests @@ -115,7 +114,7 @@ jobs: ios: runs-on: macos-15 - timeout-minutes: 120 + timeout-minutes: 360 strategy: fail-fast: false matrix: From ab90f314695f98f8b92c3feba8240b3d0c9d0477 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Sat, 24 Oct 2026 20:15:36 +0100 Subject: [PATCH 11/21] test(repo): restart a dead emulator during the e2e run --- e2e_app/fastlane/Fastfile | 100 +++++++++++++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 7 deletions(-) diff --git a/e2e_app/fastlane/Fastfile b/e2e_app/fastlane/Fastfile index 48c677128b..dd6c138064 100644 --- a/e2e_app/fastlane/Fastfile +++ b/e2e_app/fastlane/Fastfile @@ -107,19 +107,97 @@ def combine_runs(runs) [BATCH_ENTRYPOINT, names, files] end +# The stripped output of an adb command, or nil when it fails or takes over [timeout] +# seconds. A frozen emulator can keep adb's connection open and still be listed as +# `device`, while every command sent to it hangs. +def adb_output(device, *args, timeout: 10) + serial = device ? ['-s', device] : [] + reader, writer = IO.pipe + pid = Process.spawn('adb', *serial, *args, out: writer, err: File::NULL) + writer.close + deadline = Time.now + timeout + until (status = Process.wait2(pid, Process::WNOHANG)&.last) + if Time.now > deadline + Process.kill('KILL', pid) rescue nil + Process.wait(pid) rescue nil + return nil + end + sleep(0.2) + end + status.success? ? reader.read.strip : nil +ensure + reader&.close +end + # Whether the Android device is online, after giving one that dropped off (e.g. a CI # emulator that hung) a minute to come back. def android_device_online?(device) - serial = device ? "-s #{device} " : '' - 30.times do |i| - return true if `adb #{serial}get-state 2>/dev/null`.strip == 'device' + 6.times do |i| + return true if adb_output(device, 'shell', 'echo', 'ok') == 'ok' - UI.important("Android device #{device || '(default)'} is not online; waiting for it to come back…") if i.zero? + UI.important("Android device #{device || '(default)'} is not responding; waiting for it to come back…") if i.zero? sleep(2) end false end +# The AVD behind an emulator serial (e.g. emulator-5554), or nil for a physical device +# or one that is not answering. +def android_avd_name(device) + return nil unless device.to_s.start_with?('emulator-') + + name = adb_output(device, 'emu', 'avd', 'name').to_s.lines.first.to_s.strip + name.empty? || name == 'OK' ? nil : name +end + +# Kills a dead emulator and cold-boots the same AVD on the same port, so a long run +# can carry on after the emulator hung (it does on CI, after an hour or more of tests). +# Returns whether the device is back. +def restart_android_emulator(device:, avd:) + UI.important("Restarting emulator #{device} (#{avd})…") + # Killed outright: `adb emu kill` hangs on a frozen emulator. The bracket keeps the + # pattern from matching the shell that runs pkill, whose own command line holds it. + sh("pkill -9 -f #{Shellwords.escape("[e]mulator.*-avd #{avd}")}", error_callback: ->(_) {}) + sleep(5) + sh('adb reconnect offline', error_callback: ->(_) {}) + + sdk = ENV['ANDROID_HOME'] || ENV['ANDROID_SDK_ROOT'] || File.expand_path('~/Library/Android/sdk') + options = ENV['E2E_EMULATOR_OPTIONS'] || '-no-snapshot -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect' + log = "#{root_path}/build/emulator-restart.log" + pid = Process.spawn( + "#{sdk}/emulator/emulator -avd #{Shellwords.escape(avd)} -port #{device.delete_prefix('emulator-')} #{options}", + out: [log, 'a'], err: [log, 'a'], pgroup: true, + ) + Process.detach(pid) + + deadline = Time.now + 300 + until adb_output(device, 'shell', 'getprop', 'sys.boot_completed') == '1' + if Time.now > deadline + UI.error("Emulator #{device} did not boot within 5 minutes; see #{log}") + return false + end + sleep(5) + end + + # What android-emulator-runner does after its own boot. + %w[window_animation_scale transition_animation_scale animator_duration_scale].each do |setting| + sh("adb -s #{device} shell settings put global #{setting} 0.0") + end + sh("adb -s #{device} shell input keyevent 82") + sh("adb -s #{device} logcat -c") + true +end + +# Whether the Android device is (back) online. On CI a dead emulator is restarted, +# up to [max_restarts] times per lane run. +def ensure_android_device(device:, avd:, restarts:, max_restarts: 3) + return true if android_device_online?(device) + return false unless is_ci && avd && restarts[:count] < max_restarts + + restarts[:count] += 1 + restart_android_emulator(device: device, avd: avd) +end + def boot_simulator(udid:) return if `xcrun simctl list devices booted`.to_s.include?(udid) return unless `xcrun simctl list devices`.to_s.include?(udid) @@ -250,6 +328,9 @@ lane :run_e2e_test do |options| else serial = device ? "-s #{device} " : '' sh("adb #{serial}logcat -c") + # Read while the emulator answers, so a dead one can be restarted with the same AVD. + avd = android_avd_name(device) + emulator_restarts = { count: 0 } end log_file = "#{root_path}/build/e2e-test.log" @@ -292,9 +373,9 @@ lane :run_e2e_test do |options| files ||= [test_name] # A dead emulator turns every remaining run into an instant "No supported devices" - # failure. Stop instead, and count the remaining runs as failed, so the coverage - # map keeps their previous coverage rather than dropping them. - if !ios && !android_device_online?(device) + # failure. If it can't be restarted, stop instead, and count the remaining runs as + # failed, so the coverage map keeps their previous coverage rather than dropping them. + if !ios && !ensure_android_device(device: device, avd: avd, restarts: emulator_restarts) remaining = runs.drop(run_index) UI.important("Android device #{device || '(default)'} is gone; skipping the #{remaining.size} remaining run(s)") remaining.each do |t, s, _| @@ -315,6 +396,11 @@ lane :run_e2e_test do |options| loop do boot_simulator(udid: device) if ios && !first_attempt first_attempt = false + # The emulator may have died during the attempt being retried. + if !ios && attempt.positive? && !ensure_android_device(device: device, avd: avd, restarts: emulator_restarts) + break + end + attempt += 1 flags = [target, '--no-pub', "--dart-define=E2E_TARGET=#{test_name}"] From c883599b57f2607123c36909aef7e840d5150ce5 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Sat, 24 Oct 2026 21:49:54 +0100 Subject: [PATCH 12/21] test(repo): time-limit e2e runs so a dead emulator can't hang the job --- e2e_app/fastlane/Fastfile | 62 ++++++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/e2e_app/fastlane/Fastfile b/e2e_app/fastlane/Fastfile index dd6c138064..940ad64c97 100644 --- a/e2e_app/fastlane/Fastfile +++ b/e2e_app/fastlane/Fastfile @@ -5,6 +5,7 @@ require 'shellwords' require 'json' require 'securerandom' require 'fileutils' +require 'timeout' require File.expand_path('e2e_log', __dir__) require File.expand_path('e2e_tests', __dir__) import File.expand_path('Allurefile', __dir__) @@ -198,6 +199,22 @@ def ensure_android_device(device:, avd:, restarts:, max_restarts: 3) restart_android_emulator(device: device, avd: avd) end +# How long one `flutter test` may run before the lane kills it: a build plus a generous +# allowance per test. A device that dies mid-run can leave `flutter test` waiting +# forever (seen on CI: the emulator went offline while coverage was being collected), +# and integration tests have no timeout of their own. +def run_time_limit(target, selected, files) + tests = + if selected + selected.size + else + E2ETests.list('integration_test').count do |id| + files.include?(File.basename(E2ETests.file(id, 'integration_test'), '.dart')) + end + end + (15 + (2 * [tests, 1].max)) * 60 +end + def boot_simulator(udid:) return if `xcrun simctl list devices booted`.to_s.include?(udid) return unless `xcrun simctl list devices`.to_s.include?(udid) @@ -215,7 +232,7 @@ def start_screen_recording(ios:, device:, path:) remote = "/sdcard/#{File.basename(path)}" pid = spawn("adb #{serial}shell screenrecord --bit-rate 4000000 #{remote}", out: File::NULL, err: File::NULL) - { pid: pid, path: path, remote: remote, serial: serial, started_at_ms: started_at_ms } + { pid: pid, path: path, remote: remote, device: device, started_at_ms: started_at_ms } rescue StandardError => e UI.important("Screen recording failed to start: #{e.message}") nil @@ -225,12 +242,27 @@ def stop_screen_recording(rec) return unless rec rec[:ended_at_ms] = (Time.now.to_f * 1000).round - sh("adb #{rec[:serial]}shell pkill -INT screenrecord", log: false) rescue nil + # Bounded: every call here hangs on a frozen device, which would hold the lane before + # it can restart the emulator. + adb_output(rec[:device], 'shell', 'pkill', '-INT', 'screenrecord') sleep(3) # let screenrecord finalize + flush the file on-device Process.kill('INT', rec[:pid]) rescue nil # reap the local adb client - Process.wait(rec[:pid]) rescue nil - sh("adb #{rec[:serial]}pull #{rec[:remote]} #{rec[:path]}", log: false) - sh("adb #{rec[:serial]}shell rm #{rec[:remote]}", log: false) rescue nil + begin + Timeout.timeout(5) { Process.wait(rec[:pid]) } + rescue Timeout::Error + Process.kill('KILL', rec[:pid]) rescue nil + Process.wait(rec[:pid]) rescue nil + rescue Errno::ECHILD + nil + end + unless adb_output(rec[:device], 'shell', 'echo', 'ok') == 'ok' + UI.important("Screen recording lost: the device is not responding (#{rec[:remote]})") + return + end + unless adb_output(rec[:device], 'pull', rec[:remote], rec[:path], timeout: 120) + UI.important("Screen recording could not be pulled from the device: #{rec[:remote]}") + end + adb_output(rec[:device], 'shell', 'rm', rec[:remote]) rescue StandardError => e UI.important("Screen recording failed to stop: #{e.message}") end @@ -366,6 +398,8 @@ lane :run_e2e_test do |options| runs = [combine_runs(runs)] if !coverage && options[:batch_count].to_i > 1 && runs.size > 1 retries = (options[:retries] || 2).to_i + # GNU coreutils: present on the CI runners; on macOS only with coreutils installed. + timeout_bin = `command -v timeout`.strip first_attempt = true runs.each_with_index do |(target, selected, files), run_index| @@ -426,12 +460,20 @@ lane :run_e2e_test do |options| cmd = "flutter test #{flags.join(' ')} 2>&1 | tee -a #{Shellwords.escape(log_file)} | " \ "tee #{Shellwords.escape(attempt_log)} | " \ "{ grep --line-buffered -vE 'ALLURE-(RESULT|ATTACH)::|E2E-TEST::' || true; }" + time_limit = run_time_limit(target, selected, files) + # `timeout` signals its whole process group, so flutter and the tee pipeline go too. + guard = timeout_bin.empty? ? '' : "#{timeout_bin} --kill-after=30 #{time_limit} " + started_at = Time.now ok = begin - sh("bash -o pipefail -c #{Shellwords.escape(cmd)}") + sh("#{guard}bash -o pipefail -c #{Shellwords.escape(cmd)}") true rescue StandardError false end + if !ok && !guard.empty? && Time.now - started_at >= time_limit + UI.important("#{target} (attempt #{attempt}/#{retries + 1}): killed after #{time_limit / 60} min " \ + 'with no exit from flutter test; the device may have died') + end stop_screen_recording(rec) @@ -461,10 +503,16 @@ lane :run_e2e_test do |options| break if ok || attempt > retries + # A device that died mid-run also fails the run "after test completion" (the + # coverage collector loses the app), but that is retriable once the emulator + # is back, which the next attempt takes care of. + device_lost = !ios && adb_output(device, 'shell', 'echo', 'ok') != 'ok' + UI.important("#{target} (attempt #{attempt}/#{retries + 1}): the device stopped responding") if device_lost + # Not retriable: a whole-file retry can neither narrow nor fix a leaked # async error, and a run that happens to pass would only be hiding it. # Fail fast with the diagnosis instead of burning every attempt. - if E2ELog.post_completion_failure?(attempt_log) + if !device_lost && E2ELog.post_completion_failure?(attempt_log) blamed = E2ELog.post_completion_blamed_tests(attempt_log) UI.important("#{target} (attempt #{attempt}/#{retries + 1}): a test leaked async work that threw " \ 'AFTER it had completed, so the failure is not attributable to any single test' \ From 87586b95449a3726e24e6304681004ff70e69346 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Sat, 24 Oct 2026 22:09:37 +0100 Subject: [PATCH 13/21] fix(repo): look up timeout through sh so the lane runs on Linux --- e2e_app/fastlane/Fastfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e_app/fastlane/Fastfile b/e2e_app/fastlane/Fastfile index 940ad64c97..fced7818f8 100644 --- a/e2e_app/fastlane/Fastfile +++ b/e2e_app/fastlane/Fastfile @@ -399,7 +399,8 @@ lane :run_e2e_test do |options| retries = (options[:retries] || 2).to_i # GNU coreutils: present on the CI runners; on macOS only with coreutils installed. - timeout_bin = `command -v timeout`.strip + # The redirect makes Ruby run this through `sh`: `command` is a builtin with no binary on Linux. + timeout_bin = `command -v timeout 2>/dev/null`.strip first_attempt = true runs.each_with_index do |(target, selected, files), run_index| From d42a93a84baa6d3c3e1c818037a20d424a2c189b Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Sun, 25 Oct 2026 17:44:08 +0000 Subject: [PATCH 14/21] ci(repo): split the e2e coverage nightly into 4 batches --- .github/workflows/e2e_test_cron.yml | 75 ++++++++++++++++++++++------- e2e_app/fastlane/Fastfile | 11 ++++- 2 files changed, 67 insertions(+), 19 deletions(-) diff --git a/.github/workflows/e2e_test_cron.yml b/.github/workflows/e2e_test_cron.yml index 458831cfee..eed7f2bd32 100644 --- a/.github/workflows/e2e_test_cron.yml +++ b/.github/workflows/e2e_test_cron.yml @@ -27,18 +27,20 @@ jobs: android: runs-on: ubuntu-latest timeout-minutes: 360 - permissions: - contents: read - actions: read # downloads the previous map, for the coverage of failed tests strategy: fail-fast: false matrix: include: - - api-level: 36 - coverage: true # builds the map that selects e2e tests on pull requests - - api-level: 34 - - api-level: 31 - - api-level: 28 + # Coverage runs every test in its own app process for the map that selects e2e tests on + # pull requests. That takes hours, longer than a hosted runner lasts under the emulator's + # load, so its tests are split across batches. coverage_map's batch_count must match. + - { api-level: 36, coverage: true, batch: 0, batch_count: 4 } + - { api-level: 36, coverage: true, batch: 1, batch_count: 4 } + - { api-level: 36, coverage: true, batch: 2, batch_count: 4 } + - { api-level: 36, coverage: true, batch: 3, batch_count: 4 } + - { api-level: 34, batch: 0, batch_count: 1 } + - { api-level: 31, batch: 0, batch_count: 1 } + - { api-level: 28, batch: 0, batch_count: 1 } steps: - uses: actions/checkout@v7 @@ -70,19 +72,20 @@ jobs: arch: x86_64 profile: pixel_6 emulator-options: -no-snapshot-save -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect - script: cd e2e_app && bundle exec fastlane run_e2e_test device:emulator-5554 coverage:${{ matrix.coverage == true }} mock_server_branch:${{ env.mock_server_branch }} + script: cd e2e_app && bundle exec fastlane run_e2e_test device:emulator-5554 coverage:${{ matrix.coverage == true }} batch:${{ matrix.batch }} batch_count:${{ matrix.batch_count }} mock_server_branch:${{ env.mock_server_branch }} - - name: Build the coverage map + # A tarball, as upload-artifact rejects some characters a test name (the folder name) may hold. + - name: Pack the coverage if: matrix.coverage && !cancelled() - working-directory: e2e_app - run: bundle exec fastlane build_e2e_coverage_map + working-directory: e2e_app/build + run: tar -czf e2e-coverage-batch-${{ matrix.batch }}.tgz e2e-coverage - uses: actions/upload-artifact@v7 if: matrix.coverage && !cancelled() with: - name: e2e-coverage-map - path: e2e_app/build/e2e_coverage_map.json - retention-days: 14 + name: e2e-coverage-batch-${{ matrix.batch }} + path: e2e_app/build/e2e-coverage-batch-${{ matrix.batch }}.tgz + retention-days: 3 - name: Attach videos to failed tests if: failure() @@ -107,11 +110,49 @@ jobs: - uses: actions/upload-artifact@v7 if: failure() with: - name: e2e-nightly-android-${{ matrix.api-level }} + name: e2e-nightly-android-${{ matrix.api-level }}-batch-${{ matrix.batch }} path: | e2e_app/stream-chat-test-mock-server/logs e2e_app/build/e2e-test.log + coverage_map: + needs: android + # Failed tests keep their previous coverage; only a batch that never finished blocks the map. + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + actions: read # downloads the previous map, for the coverage of failed tests + steps: + - uses: actions/checkout@v7 + + - uses: ./.github/actions/setup-ruby + with: + working-directory: e2e_app + + - uses: actions/download-artifact@v8 + with: + pattern: e2e-coverage-batch-* + path: e2e_app/build/batches + merge-multiple: true + + - name: Merge the batches + working-directory: e2e_app/build + run: | + shopt -s nullglob + for batch in batches/*.tgz; do tar -xzf "$batch"; done + + - name: Build the coverage map + working-directory: e2e_app + run: bundle exec fastlane build_e2e_coverage_map batch_count:4 + + - uses: actions/upload-artifact@v7 + with: + name: e2e-coverage-map + path: e2e_app/build/e2e_coverage_map.json + retention-days: 14 + ios: runs-on: macos-15 timeout-minutes: 360 @@ -181,7 +222,7 @@ jobs: name: Slack Report runs-on: ubuntu-latest timeout-minutes: 10 - needs: [android, ios] + needs: [android, coverage_map, ios] if: failure() && github.event_name == 'schedule' steps: - uses: 8398a7/action-slack@v3 diff --git a/e2e_app/fastlane/Fastfile b/e2e_app/fastlane/Fastfile index fced7818f8..170abf84a9 100644 --- a/e2e_app/fastlane/Fastfile +++ b/e2e_app/fastlane/Fastfile @@ -392,6 +392,8 @@ lane :run_e2e_test do |options| UI.user_error!('No e2e test files found') if runs.empty? runs = batch_targets(runs, batch: options[:batch], batch_count: options[:batch_count]) + # A batch comes back grouped by file, but every coverage run still needs its own app process. + runs = runs.flat_map { |target, names| names.map { |name| [target, [name]] } } if coverage next if runs.empty? # Only CI batches share an app process; a whole-suite run keeps a fresh one per file. @@ -629,10 +631,15 @@ lane :select_e2e_tests do |options| end # Builds the coverage map select_e2e_tests reads from the lcov reports of -# `run_e2e_test coverage:true` (every batch's, merged into one folder on CI). +# `run_e2e_test coverage:true` (every batch's, merged into one folder on CI; +# `batch_count` makes it fail unless all of them finished). lane :build_e2e_coverage_map do |options| coverage_dir = options[:coverage_dir] ? File.expand_path(options[:coverage_dir], root_path) : e2e_coverage_path - failed_tests = Dir["#{coverage_dir}/failed_tests_*.json"].flat_map { |file| JSON.parse(File.read(file)) } + # Every batch writes its failed tests even when they all pass, so a missing file means the batch + # never finished (e.g. its runner died). A map without its tests would stop selecting them. + missing = (0...options[:batch_count].to_i).reject { |batch| File.exist?("#{coverage_dir}/failed_tests_#{batch}.json") } + UI.user_error!("No coverage from batch(es) #{missing.join(', ')}; keeping the previous map") unless missing.empty? + failed_tests =Dir["#{coverage_dir}/failed_tests_*.json"].flat_map { |file| JSON.parse(File.read(file)) } build_test_coverage_map( coverage_dir: coverage_dir, lcov_base_dir: root_path, From 8e73290b062434dff0f5de35fcd3aeacf83a1af9 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Sun, 25 Oct 2026 20:02:10 +0000 Subject: [PATCH 15/21] ci(repo): give the e2e runners memory headroom --- .github/workflows/e2e_test_cron.yml | 13 +++++++++++++ e2e_app/android/gradle.properties | 5 ++++- e2e_app/fastlane/Fastfile | 26 ++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e_test_cron.yml b/.github/workflows/e2e_test_cron.yml index eed7f2bd32..fe6ca4859f 100644 --- a/.github/workflows/e2e_test_cron.yml +++ b/.github/workflows/e2e_test_cron.yml @@ -56,6 +56,19 @@ jobs: - uses: ./.github/actions/enable-kvm + # The emulator, Gradle and flutter test together can briefly need more than the runner's + # 16 GB; without swap the host stalls and GitHub loses the runner. + - name: Make room for the builds + run: | + sudo fallocate -l 8G /mnt/e2e.swap + sudo chmod 600 /mnt/e2e.swap + sudo mkswap /mnt/e2e.swap + sudo swapon /mnt/e2e.swap + free -m + # Flutter's own Gradle plugin is an included build, which ignores e2e_app's + # gradle.properties; without this it starts a Kotlin daemon with Gradle's 4 GB heap. + echo 'kotlin.compiler.execution.strategy=in-process' >> ~/.gradle/gradle.properties + - name: Bootstrap run: | flutter pub global activate melos diff --git a/e2e_app/android/gradle.properties b/e2e_app/android/gradle.properties index 22fbb9124f..aeee16e005 100644 --- a/e2e_app/android/gradle.properties +++ b/e2e_app/android/gradle.properties @@ -1,6 +1,9 @@ # Same as sample_app. The template's 8G heap (+4G metaspace) left too little of a CI # runner's 16 GB for the emulator, which hung during builds. -org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC +# Kotlin compiles inside the Gradle daemon. A separate Kotlin daemon inherits the heap above, +# doubling what the builds can take next to the emulator. +kotlin.compiler.execution.strategy=in-process org.gradle.caching=true org.gradle.parallel=true android.useAndroidX=true diff --git a/e2e_app/fastlane/Fastfile b/e2e_app/fastlane/Fastfile index 170abf84a9..842df7cb68 100644 --- a/e2e_app/fastlane/Fastfile +++ b/e2e_app/fastlane/Fastfile @@ -189,6 +189,30 @@ def restart_android_emulator(device:, avd:) true end +# Prints the free memory and its biggest users every [interval] seconds. A runner that runs out +# of memory is lost with its artifacts, and the streamed job log is all that is left. Linux only. +def start_memory_log(interval: 30) + return unless File.exist?('/proc/meminfo') + + Thread.new do + loop do + mb = File.read('/proc/meminfo').scan(/^(\w+):\s+(\d+)/).to_h { |key, kb| [key, kb.to_i / 1024] } + top = `ps -eo rss=,args= --sort=-rss`.lines.first(5).map do |line| + rss, args = line.strip.split(' ', 2) + name = args[/GradleDaemon|KotlinCompileDaemon|qemu-system\S*|frontend_server\S*|flutter_tools\S*/] || + File.basename(args.split.first.to_s) + "#{name} #{rss.to_i / 1024}M" + end + puts("[memory] available #{mb['MemAvailable']}M of #{mb['MemTotal']}M, " \ + "swap free #{mb['SwapFree']}M of #{mb['SwapTotal']}M; #{top.join(', ')}") + $stdout.flush + sleep(interval) + end + rescue StandardError => e + UI.important("Memory log stopped: #{e.message}") + end +end + # Whether the Android device is (back) online. On CI a dead emulator is restarted, # up to [max_restarts] times per lane run. def ensure_android_device(device:, avd:, restarts:, max_restarts: 3) @@ -363,6 +387,7 @@ lane :run_e2e_test do |options| # Read while the emulator answers, so a dead one can be restarted with the same AVD. avd = android_avd_name(device) emulator_restarts = { count: 0 } + memory_log = start_memory_log if is_ci end log_file = "#{root_path}/build/e2e-test.log" @@ -552,6 +577,7 @@ lane :run_e2e_test do |options| UI.user_error!("E2E failed after retries: #{failed.join(', ')}") unless failed.empty? end ensure + memory_log&.kill # build_e2e_coverage_map keeps the previous coverage of these, as a failed test stops early. if coverage FileUtils.mkdir_p(e2e_coverage_path) From b6acb2ae2d105b9d84c269d27417432f0014b086 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Sun, 25 Oct 2026 22:06:58 +0000 Subject: [PATCH 16/21] test(repo): retry the tap that opens the image gallery --- .../integration_test/robots/user_robot.dart | 4 +-- .../support/widget_test_extensions.dart | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/e2e_app/integration_test/robots/user_robot.dart b/e2e_app/integration_test/robots/user_robot.dart index 121e00267c..2bee8d3765 100644 --- a/e2e_app/integration_test/robots/user_robot.dart +++ b/e2e_app/integration_test/robots/user_robot.dart @@ -135,9 +135,7 @@ class UserRobot { matching: find.byType(StreamMessageAttachment), ); await tester.waitUntilVisible(images.at(index)); - await tester.tap(images.at(index)); - await tester.settle(); - await tester.waitUntilVisible(MessageListPage.list.mediaGalleryPreview); + await tester.tapUntilVisible(images.at(index), MessageListPage.list.mediaGalleryPreview); return this; } diff --git a/e2e_app/integration_test/support/widget_test_extensions.dart b/e2e_app/integration_test/support/widget_test_extensions.dart index 03970b4608..851de17775 100644 --- a/e2e_app/integration_test/support/widget_test_extensions.dart +++ b/e2e_app/integration_test/support/widget_test_extensions.dart @@ -282,6 +282,31 @@ extension E2EWidgetTester on WidgetTester { throw TestFailure('Timed out long-pressing $target waiting for $appears'); } + /// Taps [target] until [appears] shows up. + /// + /// A tap that lands while the message list is still scrolling only stops the + /// scroll, so a single tap can be a no-op. + Future tapUntilVisible( + Finder target, + Finder appears, { + Duration timeout = const Duration(seconds: 30), + }) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + await pump(const Duration(milliseconds: 100)); + if (appears.evaluate().isNotEmpty) return settle(); + + if (target.evaluateSafely().isEmpty) continue; + + await tap(target); + await settle(); + if (appears.evaluate().isNotEmpty) return; + + await pump(const Duration(milliseconds: 500)); + } + throw TestFailure('Timed out tapping $target waiting for $appears'); + } + bool _tryInvokeMessageLongPress(Finder target) { final inkWells = find.descendant(of: target, matching: find.byType(InkWell)); if (inkWells.evaluateSafely().isEmpty) return false; From f5b2efee323d6234ec42f8d7c79e7f2f71eccdbe Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 26 Oct 2026 14:20:04 +0100 Subject: [PATCH 17/21] fix(ui): keep the replies count on a deleted thread parent A deleted message that had thread replies showed the "Message deleted" placeholder without its "N replies" row, so the thread could no longer be opened from the channel. Fixes FLU-1092. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 1 + .../components/stream_message_content.dart | 17 +++++++++++++---- .../stream_message_metadata_test.dart | 9 +++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index c5c8ed8bf4..3ded2fa6d5 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -15,6 +15,7 @@ 🐞 Fixed +- Fixed a deleted message hiding its replies count, which left its thread unreachable from the channel. - Fixed a voice recording sent as a reply without text showing inside a filled container instead of directly on the message bubble. - Fixed a mention rendering as another mentioned user's shorter mention followed by leftover text, such as `@Anna` next to `@Ann`. - Fixed the poll creator showing the reorder handle and remove button when there is only one option. diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart index f928c05fce..140df249bb 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart @@ -199,14 +199,23 @@ class _StreamMessageContentState extends State { final excluding = widget.excludeTextFromSemantics; // A deleted message keeps its metadata: the design shows the timestamp and - // the delivery status below the placeholder, same as any other message. + // the delivery status below the placeholder, same as any other message. A + // deleted thread parent also keeps its replies, so the thread stays + // reachable. if (widget.message.isDeleted) { return core.StreamMessageContent( header: widget.header, footer: widget.footer, - // The composed row label already speaks the placeholder, so announcing - // it here as well would repeat it. - child: ExcludeSemantics(excluding: excluding, child: const StreamMessageDeleted()), + child: core.StreamColumn( + mainAxisSize: .min, + crossAxisAlignment: crossAxisAlignment, + children: [ + // The composed row label already speaks the placeholder, so + // announcing it here as well would repeat it. + ExcludeSemantics(excluding: excluding, child: const StreamMessageDeleted()), + if (widget.replies case final replies?) replies, + ], + ), ); } diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_metadata_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_metadata_test.dart index 295cfcc68a..28629b6a49 100644 --- a/packages/stream_chat_flutter/test/src/message_widget/stream_message_metadata_test.dart +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_metadata_test.dart @@ -88,6 +88,15 @@ void main() { expect(find.byType(StreamMessageSendingStatus), findsOneWidget); }); + testWidgets('shows the replies count on a deleted thread parent', (tester) async { + final parent = deleted.copyWith(replyCount: 2, threadParticipants: [otherUser]); + + await tester.pumpWidget(wrap(StreamMessageItem(message: parent))); + await tester.pumpAndSettle(); + + expect(find.text(DefaultTranslations.instance.threadReplyCountText(2)), findsOneWidget); + }); + testWidgets('drops the edited marker', (tester) async { await tester.pumpWidget(wrap(StreamMessageItem(message: deleted))); await tester.pumpAndSettle(); From 6fdaf1d63a6343ac5e893935f5d618a497b1ef23 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 26 Oct 2026 14:20:14 +0100 Subject: [PATCH 18/21] test(repo): enable the deleted thread parent e2e tests (FLU-1092) Co-Authored-By: Claude Opus 5.5 (1M context) --- e2e_app/integration_test/message_list_test.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/e2e_app/integration_test/message_list_test.dart b/e2e_app/integration_test/message_list_test.dart index d91dd100f0..71a334e0e1 100644 --- a/e2e_app/integration_test/message_list_test.dart +++ b/e2e_app/integration_test/message_list_test.dart @@ -530,7 +530,6 @@ void main() { streamTestWithEnv( allureId: '12761', description: 'thread is not locked after the user deletes the parent message', - skip: 'https://linear.app/stream/issue/FLU-1092', body: (env) async { step('GIVEN the user opens a channel'); await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 1); @@ -551,7 +550,6 @@ void main() { streamTestWithEnv( allureId: '12762', description: 'thread is not locked after the participant deletes the parent message', - skip: 'https://linear.app/stream/issue/FLU-1092', body: (env) async { const threadReply = 'thread reply'; From f88666c1714a2e77a0d27ed8fdb29db5f9d9129e Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 26 Oct 2026 14:27:24 +0100 Subject: [PATCH 19/21] fix(ui): let a deleted thread parent with replies take new replies StreamThreadPage hid the composer whenever the parent message was deleted. A deleted parent that already has replies still accepts new ones, so the composer now stays unless the parent is deleted and has no replies. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 1 + .../stream_chat_flutter/lib/src/pages/thread_page.dart | 7 ++++--- .../test/src/channel/thread_page_test.dart | 10 +++++++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index c5c8ed8bf4..6cd836a56d 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -15,6 +15,7 @@ 🐞 Fixed +- Fixed `StreamThreadPage` hiding the composer for a deleted parent message that already has replies, which can still be replied to. - Fixed a voice recording sent as a reply without text showing inside a filled container instead of directly on the message bubble. - Fixed a mention rendering as another mentioned user's shorter mention followed by leftover text, such as `@Anna` next to `@Ann`. - Fixed the poll creator showing the reorder handle and remove button when there is only one option. diff --git a/packages/stream_chat_flutter/lib/src/pages/thread_page.dart b/packages/stream_chat_flutter/lib/src/pages/thread_page.dart index 00342721c0..3ca54fc8ab 100644 --- a/packages/stream_chat_flutter/lib/src/pages/thread_page.dart +++ b/packages/stream_chat_flutter/lib/src/pages/thread_page.dart @@ -8,7 +8,7 @@ import '../../stream_chat_flutter.dart'; /// out floating or regular according to the ambient [StreamSurfaceStyle]. Expects a /// [StreamChannel] ancestor. /// -/// The composer is omitted when [parent] is deleted. +/// The composer is omitted when [parent] is deleted and has no replies. /// /// ## Customizing this page /// @@ -101,7 +101,8 @@ class _StreamThreadPageState extends State { onBackPressed: widget.onBackPressed, ); - final composer = !widget.parent.isDeleted + final canReply = !widget.parent.isDeleted || (widget.parent.replyCount ?? 0) > 0; + final composer = canReply ? StreamMessageComposer( focusNode: _focusNode, messageComposerController: _messageComposerController, @@ -113,7 +114,7 @@ class _StreamThreadPageState extends State { appBar: appBar, bottom: composer, // Resolved from the slots so the body inset matches what the chrome draws. - // The bottom value is ignored when the composer is null (deleted parent). + // The bottom value is ignored when the composer is null. appBarSurfaceStyle: StreamThreadHeader.resolveSurfaceStyle(context), bottomSurfaceStyle: StreamMessageComposer.resolveSurfaceStyle(context), body: _ThreadBody( diff --git a/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart b/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart index 96048a8227..77d8cd7ce9 100644 --- a/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart @@ -33,7 +33,7 @@ void main() { expect(_composerController(tester).message.parentId, 'parent-id'); }); - testWidgets('hides the composer when the parent message is deleted', (tester) async { + testWidgets('hides the composer when the deleted parent message has no replies', (tester) async { final parent = Message(id: 'parent-id', text: 'Hello world!', type: 'deleted'); await _pumpThreadPage(tester, parent: parent); @@ -41,6 +41,14 @@ void main() { expect(find.byType(StreamMessageComposer), findsNothing); }); + testWidgets('shows the composer when the deleted parent message has replies', (tester) async { + final parent = Message(id: 'parent-id', text: 'Hello world!', type: 'deleted', replyCount: 1); + + await _pumpThreadPage(tester, parent: parent); + + expect(find.byType(StreamMessageComposer), findsOneWidget); + }); + testWidgets('still shows the thread when the parent message is deleted', (tester) async { final parent = Message(id: 'parent-id', text: 'Hello world!', type: 'deleted'); From d0d5fad9cd7ea24dc963c0848bb7657c46b4f20b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 27 Oct 2026 12:34:52 +0100 Subject: [PATCH 20/21] test(repo): assert the token date and signature policy (#3123) * test(repo): assert the token date and signature policy (FLU-1083) The SDK refreshes the token on code 40 only. A token used before its issue date (42) is reconnected as-is until the server's clock catches up, and a bad signature (43) is not retried with a new token. The four tests now assert that instead of a refresh. Co-Authored-By: Claude Opus 5.5 (1M context) * test(repo): tighten the token date and signature tests (FLU-1083) Keep the invalid window open past the refresh so the refreshed token is always rejected, count token requests through an env assertion, and fix the step and file comments to match what the tests assert. Co-Authored-By: Claude Opus 5.5 (1M context) * test(repo): drop the token signature after login test (FLU-1083) A signing secret doesn't go bad mid-session, and the test only timed the SDK's reconnect backoff. Co-Authored-By: Claude Opus 5.5 (1M context) * test(repo): keep the login error across the loading screen (FLU-1083) The chooser page is replaced while connecting, so an error it stored itself was gone by the time the login failed. The auth state now carries it, and the rebuilt page shows it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../integration_test/authentication_test.dart | 43 ++++++------------- .../integration_test/robots/user_robot.dart | 7 +++ .../support/stream_test_env.dart | 15 ++++++- e2e_app/lib/auth/auth_controller.dart | 9 ++-- e2e_app/lib/pages/choose_user_page.dart | 13 +++--- 5 files changed, 46 insertions(+), 41 deletions(-) diff --git a/e2e_app/integration_test/authentication_test.dart b/e2e_app/integration_test/authentication_test.dart index 6a41f10db4..b4c660f7f7 100644 --- a/e2e_app/integration_test/authentication_test.dart +++ b/e2e_app/integration_test/authentication_test.dart @@ -2,8 +2,8 @@ import 'support/step.dart'; import 'support/stream_test_case.dart'; import 'support/stream_test_env.dart'; -// Each scenario connects with a token the mock server hands out (valid for 5 s), -// so the SDK has to refresh it as the backend robot breaks it. +// Each scenario connects with a token the mock server hands out (valid for +// 5 s); the SDK refreshes it only once it expires. void main() { Future login(StreamTestEnv env) async { await env.userRobot.login(); @@ -48,7 +48,6 @@ void main() { streamTestWithEnv( allureId: '12668', description: 'token date invalidates before the user logs in', - skip: 'https://linear.app/stream/issue/FLU-1083', jwt: true, body: (env) async { step('GIVEN the token date is invalid'); @@ -57,33 +56,36 @@ void main() { step('WHEN the user tries to log in'); await env.userRobot.login(); - step('THEN the app requests a token refresh'); - await env.assertConnected(); + step('THEN the login fails without requesting another token'); + await env.userRobot.assertLoginFailed(code: 42); + env.assertTokenRequests(1); }, ); streamTestWithEnv( allureId: '12669', description: 'token date invalidates after the user logs in', - skip: 'https://linear.app/stream/issue/FLU-1083', + skip: 'https://github.com/GetStream/stream-chat-test-mock-server/pull/95', jwt: true, body: (env) async { step('GIVEN the user logs in'); await login(env); step('WHEN the token date invalidates'); - await env.backendRobot.invalidateTokenDate(); + // Outlasts the token's expiry and the refresh after it, so the refreshed + // token is still rejected. + await env.backendRobot.invalidateTokenDate(duration: 20); - step('THEN the app requests a token refresh'); + step('THEN the app reconnects with the same token once its date is valid'); await env.assertTokenHasExpired(); await env.assertConnected(); + env.assertTokenRequests(2); }, ); streamTestWithEnv( allureId: '12670', description: 'token signature invalidates before the user logs in', - skip: 'https://linear.app/stream/issue/FLU-1083', jwt: true, body: (env) async { step('GIVEN the token signature is invalid'); @@ -92,26 +94,9 @@ void main() { step('WHEN the user tries to log in'); await env.userRobot.login(); - step('THEN the app requests a token refresh'); - await env.assertConnected(); - }, - ); - - streamTestWithEnv( - allureId: '12671', - description: 'token signature invalidates after the user logs in', - skip: 'https://linear.app/stream/issue/FLU-1083', - jwt: true, - body: (env) async { - step('GIVEN the user logs in'); - await login(env); - - step('WHEN the token signature invalidates'); - await env.backendRobot.invalidateTokenSignature(); - - step('THEN the app requests a token refresh'); - await env.assertTokenHasExpired(); - await env.assertConnected(); + step('THEN the login fails without requesting another token'); + await env.userRobot.assertLoginFailed(code: 43); + env.assertTokenRequests(1); }, ); diff --git a/e2e_app/integration_test/robots/user_robot.dart b/e2e_app/integration_test/robots/user_robot.dart index 2bee8d3765..d527f65094 100644 --- a/e2e_app/integration_test/robots/user_robot.dart +++ b/e2e_app/integration_test/robots/user_robot.dart @@ -25,6 +25,13 @@ class UserRobot { return this; } + /// Asserts the login failed with the Stream error [code]. + Future assertLoginFailed({required int code}) async { + final error = find.textContaining('Login failed: StreamWebSocketError(code: $code,'); + await tester.waitUntilVisible(error); + return this; + } + /// Signs out from the drawer behind the current user's avatar on the channel /// list. Future logout() async { diff --git a/e2e_app/integration_test/support/stream_test_env.dart b/e2e_app/integration_test/support/stream_test_env.dart index 1000b0fd64..19d8a857ee 100644 --- a/e2e_app/integration_test/support/stream_test_env.dart +++ b/e2e_app/integration_test/support/stream_test_env.dart @@ -53,7 +53,12 @@ class StreamTestEnv { usePersistence: persistence, // The mock server hands out a short-lived JWT and plays the token // failure scenarios the backend robot sets up. - tokenProvider: jwt ? (_) => server.get('jwt/get') : null, + tokenProvider: jwt + ? (_) { + _tokenRequests += 1; + return server.get('jwt/get'); + } + : null, ) ..debugConnectivityStream = _connectivity.stream; @@ -106,6 +111,8 @@ class StreamTestEnv { throw TestFailure('Timed out waiting for a framework error to be reported'); } + var _tokenRequests = 0; + StreamSubscription? _connectionStatusSubscription; var _disconnections = 0; @@ -130,6 +137,12 @@ class StreamTestEnv { throw TestFailure('The connection never dropped, so the token never expired'); } + /// Asserts the app has requested [count] tokens from the mock server. + void assertTokenRequests(int count) { + if (_tokenRequests == count) return; + throw TestFailure('Expected $count token requests, but the app made $_tokenRequests'); + } + /// Lets the mock server's short-lived token (5 s) run out. Future waitForTokenToExpire() async { final end = DateTime.now().add(const Duration(seconds: 6)); diff --git a/e2e_app/lib/auth/auth_controller.dart b/e2e_app/lib/auth/auth_controller.dart index 5ec0836596..c0dcd24a0a 100644 --- a/e2e_app/lib/auth/auth_controller.dart +++ b/e2e_app/lib/auth/auth_controller.dart @@ -76,7 +76,10 @@ sealed class AuthState { /// No user is connected; the login screen shows. final class Unauthenticated extends AuthState { - const Unauthenticated(); + const Unauthenticated({this.error}); + + /// The error the last [AuthController.connect] failed with, if it failed. + final Object? error; } /// An [AuthController.connect] call is in flight. @@ -127,8 +130,8 @@ class AuthController extends ValueNotifier { _ => await client.connectUser(user, token), }; value = Authenticated(ownUser); - } catch (_) { - value = const Unauthenticated(); + } catch (error) { + value = Unauthenticated(error: error); rethrow; } } diff --git a/e2e_app/lib/pages/choose_user_page.dart b/e2e_app/lib/pages/choose_user_page.dart index c530984c91..94e4622e49 100644 --- a/e2e_app/lib/pages/choose_user_page.dart +++ b/e2e_app/lib/pages/choose_user_page.dart @@ -13,16 +13,13 @@ class ChooseUserPage extends StatefulWidget { } class _ChooseUserPageState extends State { - Object? _error; - Future _login(E2eUser user) async { - setState(() => _error = null); try { await authController.connect(user: user.toUser(), token: user.token); - } catch (error) { - // Shown rather than rethrown: an uncaught error would fail whichever - // test happens to be running when it lands. - if (mounted) setState(() => _error = error); + } catch (_) { + // Shown from the auth state rather than rethrown: an uncaught error + // would fail whichever test happens to be running when it lands. The + // state outlives this page, which the app replaces while connecting. } } @@ -44,7 +41,7 @@ class _ChooseUserPageState extends State { title: Text(user.name), onTap: () => _login(user), ), - if (_error case final error?) Text('Login failed: $error'), + if (authController.value case Unauthenticated(:final error?)) Text('Login failed: $error'), ], ), ), From bdf15fd1176b903e471be544a51612e43d54c174 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 27 Oct 2026 13:12:10 +0100 Subject: [PATCH 21/21] fix(ui): keep the footer of a sent message before a failed one (#3122) * fix(ui): keep the footer of a sent message before a failed one (FLU-1090) A message that failed now starts its own group, so the sent message before it keeps its timestamp and delivery status. Consecutive failed messages still group together. Co-Authored-By: Claude Opus 5.5 (1M context) * docs(ui): describe every failed message in the changelog (FLU-1090) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012vTgFwqhUc2wKCWrw635yC --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../message_delivery_status_test.dart | 1 - packages/stream_chat_flutter/CHANGELOG.md | 1 + .../message_list_view/message_list_view.dart | 12 +- .../lib/src/message_list_view/mlv_utils.dart | 2 + ...age_list_failed_message_grouping_test.dart | 155 ++++++++++++++++++ 5 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 packages/stream_chat_flutter/test/src/message_list_view/message_list_failed_message_grouping_test.dart diff --git a/e2e_app/integration_test/message_delivery_status_test.dart b/e2e_app/integration_test/message_delivery_status_test.dart index d0639b3937..94dcbc4526 100644 --- a/e2e_app/integration_test/message_delivery_status_test.dart +++ b/e2e_app/integration_test/message_delivery_status_test.dart @@ -169,7 +169,6 @@ void main() { streamTestWithEnv( allureId: '12724', description: 'delivery status is shown for the previous message when a new message fails to be sent', - skip: 'https://linear.app/stream/issue/FLU-1090', body: (env) async { step('GIVEN the user opens a channel'); await env.userRobot.login().openChannel(); diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index c5c8ed8bf4..73d9fe1e93 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -15,6 +15,7 @@ 🐞 Fixed +- Fixed a message that failed to send, update or delete hiding the timestamp and delivery status of the message before it. - Fixed a voice recording sent as a reply without text showing inside a filled container instead of directly on the message bubble. - Fixed a mention rendering as another mentioned user's shorter mention followed by leftover text, such as `@Anna` next to `@Ann`. - Fixed the poll creator showing the reorder handle and remove button when there is only one option. diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index c29b170d86..751c082a62 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -1074,7 +1074,8 @@ class _StreamMessageListViewState extends State { // The rules are evaluated in the following order: // - [SpacingType.timeDiff] — messages are more than a minute apart. // - [SpacingType.otherUser] — messages belong to different groups. A group - // boundary is forced when either message is a system or moderated message. + // boundary is forced when either message is a system or moderated message, + // or when only one of them failed. // - [SpacingType.thread] — the current message is part of a thread. // - [SpacingType.deleted] — the current message has been deleted. List? _resolveSpacingRules({ @@ -1095,8 +1096,13 @@ class _StreamMessageListViewState extends State { // Moderated (non-bounced error) messages always form their own group. final isModerated = (message.isError && !message.isBounced) || (nextMessage.isError && !nextMessage.isBounced); - // Two messages are from the same user group only if neither is system/moderated and they share the same sender. - final isNextUserSame = !isSystem && !isModerated && message.user!.id == nextMessage.user?.id; + // A failed message never groups with one that did not fail. + final onlyOneFailed = message.state.isFailed != nextMessage.state.isFailed; + + // Two messages are from the same user group only if they share the same + // sender and none of the checks above separates them. + final isSameSender = message.user!.id == nextMessage.user?.id; + final isNextUserSame = isSameSender && !isSystem && !isModerated && !onlyOneFailed; // Thread messages shown in channel are part of a thread. final isPartOfThread = message.replyCount! > 0 || message.showInChannel == true; diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart b/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart index 7b66bbcee9..da05eff474 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart @@ -116,6 +116,7 @@ bool isElementAtIndexVisible( /// - The sender changes /// - The timestamps fall in different calendar minutes /// - The neighbor is a system, ephemeral, or error message +/// - Only one of the two messages failed StreamMessageStackPosition computeStackPosition({ required Message message, Message? previous, @@ -136,6 +137,7 @@ bool _isGroupBoundary(Message message, Message? neighbor) { if (neighbor == null) return true; if (message.user?.id != neighbor.user?.id) return true; if (neighbor.isSystem || neighbor.isEphemeral || neighbor.isError) return true; + if (message.state.isFailed != neighbor.state.isFailed) return true; final createdAt = message.createdAt.toLocal(); final neighborCreatedAt = neighbor.createdAt.toLocal(); diff --git a/packages/stream_chat_flutter/test/src/message_list_view/message_list_failed_message_grouping_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/message_list_failed_message_grouping_test.dart new file mode 100644 index 0000000000..980f666dc2 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_list_view/message_list_failed_message_grouping_test.dart @@ -0,0 +1,155 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +void main() { + final maya = User(id: 'maya', name: 'Maya Ross'); + final failed = MessageState.sendingFailed(skipPush: false, skipEnrichUrl: false); + + Message message(String id, {MessageState state = MessageState.sent}) => Message( + id: id, + text: id, + createdAt: DateTime(2026, 8, 26, 14), + user: maya, + state: state, + ); + + Finder footerOf(String id) => find.byWidgetPredicate( + (widget) => widget is StreamMessageFooter && widget.props.message.id == id, + ); + + testWidgets('StreamMessageListView shows the footer of a sent message followed by a failed one', (tester) async { + final chat = _FakeChat(); + await chat.pumpMessages(tester, [ + message('sent'), + message('failed', state: failed), + ]); + + expect(footerOf('sent'), findsOneWidget); + expect(footerOf('failed'), findsOneWidget); + }); + + testWidgets('StreamMessageListView groups consecutive failed messages', (tester) async { + final chat = _FakeChat(); + await chat.pumpMessages(tester, [ + message('first failed', state: failed), + message('second failed', state: failed), + ]); + + expect(footerOf('first failed'), findsNothing); + expect(footerOf('second failed'), findsOneWidget); + }); + + testWidgets('StreamMessageListView groups consecutive sent messages', (tester) async { + final chat = _FakeChat(); + await chat.pumpMessages(tester, [ + message('first sent'), + message('second sent'), + ]); + + expect(footerOf('first sent'), findsNothing); + expect(footerOf('second sent'), findsOneWidget); + }); + + testWidgets('StreamMessageListView groups a failed message again once it is sent', (tester) async { + final chat = _FakeChat(); + await chat.pumpMessages(tester, [ + message('sent'), + message('retried', state: failed), + ]); + await chat.emitMessages(tester, [ + message('sent'), + message('retried'), + ]); + + expect(footerOf('sent'), findsNothing); + expect(footerOf('retried'), findsOneWidget); + }); + + testWidgets('StreamMessageListView spaces a failed message apart from the sent one before it', (tester) async { + final spacings = >[]; + final chat = _FakeChat(); + await chat.pumpMessages( + tester, + [ + message('sent'), + message('failed', state: failed), + ], + list: StreamMessageListView( + builders: StreamMessageListViewBuilders( + spacing: (context, spacingTypes) { + spacings.add(spacingTypes); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(spacings, everyElement(contains(SpacingType.otherUser))); + }); +} + +// A channel whose messages a test controls, for pumping a message list. +class _FakeChat { + _FakeChat() { + final clientState = MockClientState(); + final ownUser = OwnUser(id: 'maya', name: 'Maya Ross'); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(ownUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(ownUser)); + + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + + when(() => channelState.threadsStream).thenAnswer((_) => const Stream.empty()); + when(() => channelState.isUpToDate).thenReturn(true); + when(() => channelState.isUpToDateStream).thenAnswer((_) => Stream.value(true)); + when(() => channelState.unreadCount).thenReturn(0); + when(() => channelState.unreadCountStream).thenAnswer((_) => Stream.value(0)); + when(() => channelState.readStream).thenAnswer((_) => const Stream.empty()); + when(() => channelState.read).thenReturn([]); + when(() => channelState.membersStream).thenAnswer((_) => const Stream.empty()); + when(() => channelState.members).thenReturn([]); + when(() => channelState.currentUserRead).thenReturn(null); + when(() => channelState.currentUserReadStream).thenAnswer((_) => const Stream.empty()); + when(() => channelState.messagesStream).thenAnswer((_) => _messages.stream); + } + + final client = MockClient(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final _messages = StreamController>.broadcast(); + + // Pumps [list] below a channel showing [messages]. + Future pumpMessages( + WidgetTester tester, + List messages, { + Widget list = const StreamMessageListView(), + }) async { + addTearDown(_messages.close); + when(() => channelState.messages).thenReturn(messages); + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + connectivityStream: Stream.value(const [ConnectivityResult.mobile]), + child: StreamChannel(channel: channel, child: list), + ), + ), + ); + _messages.add(messages); + await tester.pumpAndSettle(); + } + + // Replaces the channel's messages with [messages] and lets the list rebuild. + Future emitMessages(WidgetTester tester, List messages) async { + when(() => channelState.messages).thenReturn(messages); + _messages.add(messages); + await tester.pumpAndSettle(); + } +}