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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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 cee7d12931c3994fff979412f132ef94aa0e9d6a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 26 Oct 2026 15:12:10 +0100 Subject: [PATCH 17/19] fix(llc): keep drafts saved or deleted while offline Channel.createDraft and deleteDraft only called the API; the local state followed the draft.* events, so a draft saved while offline was lost. Both now update the channel state before the request, as Android and iOS do. A thread draft whose parent message isn't loaded no longer replaces the channel's draft. Fixes FLU-1086. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat/CHANGELOG.md | 7 +++ .../lib/src/client/channel/channel.dart | 12 +++++ .../client/channel/channel_client_state.dart | 23 ++++----- .../test/src/client/channel/channel_test.dart | 50 +++++++++++++++++++ 4 files changed, 79 insertions(+), 13 deletions(-) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 0a850f8f1c..b19ae2519f 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,10 @@ +## Upcoming + +🐞 Fixed + +- Fixed a draft saved or deleted while offline not being kept. +- Fixed a thread draft replacing the channel's draft when the thread's parent message isn't loaded. + ## 10.6.0 ✅ Added diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 1d162c5daa..b1b6816daf 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1294,10 +1294,15 @@ class Channel { ); /// Creates or updates a new [draft] for this channel. + /// + /// Updates the draft in [state] right away, even if the request fails. Future createDraft( DraftMessage draft, ) { _checkInitialized(); + state!.updateDraft( + Draft(channelCid: cid!, createdAt: DateTime.now(), message: draft, parentId: draft.parentId), + ); return _client.createDraft(draft, id!, type); } @@ -1315,10 +1320,17 @@ class Channel { /// /// Optionally, provide a [parentId] to delete the draft for a specific /// thread. + /// + /// Removes the draft from [state], even if the request fails. Future deleteDraft({ String? parentId, }) { _checkInitialized(); + final draft = switch (parentId) { + final parentId? => state!.threadDraft(parentId), + null => state!.draft, + }; + if (draft != null) state!.deleteDraft(draft); return _client.deleteDraft(id!, type, parentId: parentId); } diff --git a/packages/stream_chat/lib/src/client/channel/channel_client_state.dart b/packages/stream_chat/lib/src/client/channel/channel_client_state.dart index f544b97d49..524ba55c30 100644 --- a/packages/stream_chat/lib/src/client/channel/channel_client_state.dart +++ b/packages/stream_chat/lib/src/client/channel/channel_client_state.dart @@ -198,14 +198,15 @@ class ChannelClientState { ); } - /// Updates the [draft] in the channel state or the message if it exists. + /// Updates the [draft] in the channel state, or on its thread's parent + /// message for a thread draft. + /// + /// A thread draft whose parent message isn't loaded is left out. void updateDraft(Draft draft) { if (draft.parentId case final parentId?) { - for (final message in messages) { - if (message.id == parentId) { - return updateMessage(message.copyWith(draft: draft)); - } - } + final parent = messages.firstWhereOrNull((it) => it.id == parentId); + if (parent == null) return; + return updateMessage(parent.copyWith(draft: draft)); } updateChannelState( @@ -224,13 +225,9 @@ class ChannelClientState { ); if (draft.parentId case final parentId?) { - for (final message in messages) { - if (message.id == parentId) { - return updateMessage( - message.copyWith(draft: null), - ); - } - } + final parent = messages.firstWhereOrNull((it) => it.id == parentId); + if (parent == null) return; + return updateMessage(parent.copyWith(draft: null)); } updateChannelState( diff --git a/packages/stream_chat/test/src/client/channel/channel_test.dart b/packages/stream_chat/test/src/client/channel/channel_test.dart index 00007b7146..a26806f892 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -1226,6 +1226,56 @@ void main() { }); }); + group('`.createDraft` while offline', () { + final draftMessage = DraftMessage(text: 'Typed while offline'); + + setUp(() { + when( + () => client.createDraft(draftMessage, channelId, channelType), + ).thenAnswer((_) async => throw StreamChatNetworkError(ChatErrorCode.internalSystemError)); + }); + + test('keeps the draft in the channel state', () async { + await expectLater(() => channel.createDraft(draftMessage), throwsA(isA())); + + expect(channel.state!.draft?.message, draftMessage); + }); + + test('leaves the channel draft alone for a thread whose parent is not loaded', () async { + final threadDraft = DraftMessage(text: 'Thread reply', parentId: 'unloaded-parent-id'); + when( + () => client.createDraft(threadDraft, channelId, channelType), + ).thenAnswer((_) async => throw StreamChatNetworkError(ChatErrorCode.internalSystemError)); + + await expectLater(() => channel.createDraft(threadDraft), throwsA(isA())); + + expect(channel.state!.draft, isNull); + }); + }); + + group('`.deleteDraft` while offline', () { + setUp(() { + when( + () => client.deleteDraft(channelId, channelType, parentId: any(named: 'parentId')), + ).thenAnswer((_) async => throw StreamChatNetworkError(ChatErrorCode.internalSystemError)); + }); + + test('removes the draft from the channel state', () async { + channel.state!.updateDraft( + Draft( + channelCid: channelCid, + createdAt: DateTime.now(), + message: DraftMessage(text: 'Old draft'), + ), + ); + + await expectLater(() => channel.deleteDraft(), throwsA(isA())); + await Future.delayed(Duration.zero); + + expect(channel.state!.draft, isNull); + }); + }); + group('`.getDraft`', () { final draftMessage = DraftMessage(text: 'Draft message text'); From bcb16c14f4cd1b6b9fab1bfb58f04687dac3cfa4 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 26 Oct 2026 15:12:42 +0100 Subject: [PATCH 18/19] test(repo): enable the offline draft e2e test (FLU-1086) Co-Authored-By: Claude Opus 5.5 (1M context) --- e2e_app/integration_test/draft_messages_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/e2e_app/integration_test/draft_messages_test.dart b/e2e_app/integration_test/draft_messages_test.dart index 1d4a21043e..040b40377d 100644 --- a/e2e_app/integration_test/draft_messages_test.dart +++ b/e2e_app/integration_test/draft_messages_test.dart @@ -86,7 +86,6 @@ void main() { streamTestWithEnv( allureId: '12694', 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(); From a3fb39bae7ad8bd6a5a3cc92ece8b7a9836ade97 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 26 Oct 2026 15:42:34 +0100 Subject: [PATCH 19/19] refactor(llc): build the local draft before storing it Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat/lib/src/client/channel/channel.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index b1b6816daf..cd29cb61c3 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1300,9 +1300,13 @@ class Channel { DraftMessage draft, ) { _checkInitialized(); - state!.updateDraft( - Draft(channelCid: cid!, createdAt: DateTime.now(), message: draft, parentId: draft.parentId), + final localDraft = Draft( + channelCid: cid!, + createdAt: DateTime.now(), + message: draft, + parentId: draft.parentId, ); + state!.updateDraft(localDraft); return _client.createDraft(draft, id!, type); }