diff --git a/.github/workflows/e2e_test.yml b/.github/workflows/e2e_test.yml new file mode 100644 index 0000000000..976f478648 --- /dev/null +++ b/.github/workflows/e2e_test.yml @@ -0,0 +1,124 @@ +name: e2e + +on: + workflow_dispatch: + pull_request: + paths-ignore: + - 'README.md' + - '**/*.png' + +permissions: + contents: read + +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +env: + flutter_version: "3.x" + +jobs: + android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.flutter_version }} + channel: stable + cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + working-directory: sample_app/android + + - name: Bootstrap + run: | + flutter pub global activate melos + melos bootstrap + dart pub global activate patrol_cli + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run e2e (Android emulator) + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + arch: x86_64 + profile: pixel_6 + script: | + cd sample_app/android + bundle exec fastlane run_e2e_test device:emulator-5554 mock_server_branch:main + + - name: Upload Allure results + if: always() + env: + ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }} + run: cd sample_app/android && bundle exec fastlane allure_upload + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: e2e-android-logs + path: | + sample_app/stream-chat-test-mock-server/logs + sample_app/build/app/reports + + ios: + runs-on: macos-15 + steps: + - uses: actions/checkout@v6 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.flutter_version }} + channel: stable + cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + working-directory: sample_app/ios + + - name: Bootstrap + run: | + flutter pub global activate melos + melos bootstrap + dart pub global activate patrol_cli + + - name: Boot simulator + run: | + device_id=$(xcrun simctl list devices available --json \ + | python3 -c "import sys,json;d=json.load(sys.stdin);print(next(x['udid'] for r in d['devices'].values() for x in r if x['name'].startswith('iPhone')))") + echo "device_id=$device_id" >> "$GITHUB_ENV" + xcrun simctl boot "$device_id" + + - name: Run e2e (iOS simulator) + run: | + cd sample_app/ios + bundle exec fastlane run_e2e_test device:${{ env.device_id }} mock_server_branch:main + + - name: Upload Allure results + if: always() + env: + ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }} + run: cd sample_app/ios && bundle exec fastlane allure_upload + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: e2e-ios-logs + path: sample_app/stream-chat-test-mock-server/logs diff --git a/.github/workflows/e2e_test_cron.yml b/.github/workflows/e2e_test_cron.yml new file mode 100644 index 0000000000..8ed74e8f91 --- /dev/null +++ b/.github/workflows/e2e_test_cron.yml @@ -0,0 +1,171 @@ +name: E2E Tests Nightly + +on: + schedule: + - cron: "0 1 * * 1-5" # weeknights at 01:00 UTC + workflow_dispatch: + inputs: + mock_server_branch: + description: "Mock server branch" + type: string + required: true + default: main + +permissions: + contents: read + +concurrency: + group: e2e-nightly-${{ github.ref }} + cancel-in-progress: true + +env: + flutter_version: "3.x" + mock_server_branch: ${{ github.event.inputs.mock_server_branch || 'main' }} + +jobs: + android: + if: github.repository == 'GetStream/stream-chat-flutter' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + api-level: [34, 31, 28] + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.flutter_version }} + channel: stable + cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + working-directory: sample_app/android + + - name: Bootstrap + run: | + flutter pub global activate melos + melos bootstrap + dart pub global activate patrol_cli + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Create Allure launch + env: + ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }} + run: cd sample_app/android && bundle exec fastlane allure_launch + + - name: Run e2e (Android emulator) + uses: reactivecircus/android-emulator-runner@v2 + timeout-minutes: 90 + with: + api-level: ${{ matrix.api-level }} + 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 mock_server_branch:${{ env.mock_server_branch }} + + - name: Upload Allure results + if: success() || failure() + env: + ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }} + run: cd sample_app/android && bundle exec fastlane allure_upload + + - name: Remove Allure launch (on cancel) + if: cancelled() + env: + ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }} + run: cd sample_app/android && bundle exec fastlane allure_launch_removal + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: e2e-nightly-android-${{ matrix.api-level }} + path: sample_app/stream-chat-test-mock-server/logs + + ios: + if: github.repository == 'GetStream/stream-chat-flutter' + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + device: ["iPhone 16", "iPhone 15"] + steps: + - uses: actions/checkout@v6 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.flutter_version }} + channel: stable + cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + working-directory: sample_app/ios + + - name: Bootstrap + run: | + flutter pub global activate melos + melos bootstrap + dart pub global activate patrol_cli + + - name: Create Allure launch + env: + ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }} + run: cd sample_app/ios && bundle exec fastlane allure_launch + + - name: Boot simulator + run: | + device_id=$(xcrun simctl list devices available --json \ + | python3 -c "import sys,json;d=json.load(sys.stdin);print(next(x['udid'] for r in d['devices'].values() for x in r if x['name']=='${{ matrix.device }}'))") + echo "device_id=$device_id" >> "$GITHUB_ENV" + xcrun simctl boot "$device_id" + + - name: Run e2e (iOS simulator) + run: cd sample_app/ios && bundle exec fastlane run_e2e_test device:${{ env.device_id }} mock_server_branch:${{ env.mock_server_branch }} + + - name: Upload Allure results + if: success() || failure() + env: + ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }} + run: cd sample_app/ios && bundle exec fastlane allure_upload + + - name: Remove Allure launch (on cancel) + if: cancelled() + env: + ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }} + run: cd sample_app/ios && bundle exec fastlane allure_launch_removal + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: e2e-nightly-ios-${{ matrix.device }} + path: sample_app/stream-chat-test-mock-server/logs + + slack: + name: Slack Report + runs-on: ubuntu-latest + needs: [android, ios] + if: failure() && github.event_name == 'schedule' + steps: + - uses: 8398a7/action-slack@v3 + with: + status: failure + text: "Nightly e2e failed ๐ŸŒ™" + fields: repo,commit,author,workflow + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_NIGHTLY_CHECKS }} diff --git a/.gitignore b/.gitignore index 25467a43e0..7df70606ff 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,14 @@ *.class *.log *.pyc +# E2E: mock-server checkout cloned by the `start_mock_server` Fastlane lane +/sample_app/stream-chat-test-mock-server/ +# E2E: Patrol-generated test bundle (regenerated on every `patrol test`) +/sample_app/integration_test/test_bundle.dart +# E2E: Allure results assembled from test runs +/sample_app/allure-results/ +# E2E: allurectl binary downloaded by the Fastlane allure lanes +/sample_app/allurectl *.swp .DS_Store .atom/ diff --git a/melos.yaml b/melos.yaml index b568a523f4..aae8d2c48f 100644 --- a/melos.yaml +++ b/melos.yaml @@ -124,6 +124,7 @@ command: freezed: ^3.0.0 json_serializable: ^6.13.2 mocktail: ^1.0.5 + patrol: ^4.6.1 path: ^1.9.1 path_provider_platform_interface: ^2.1.2 plugin_platform_interface: ^2.1.8 @@ -203,12 +204,21 @@ scripts: # `docs_*` are excluded โ€” their goldens commit only the platform/macOS # variant and would fail on Linux CI runners. They're regenerated by # `update:goldens:docs` on a dedicated macOS workflow instead. + # Note: `flutter test` runs `test/` only, so `integration_test/` (the e2e + # suite, run via Patrol) is never picked up here. run: melos exec -c 4 --fail-fast --ignore="docs_*" -- "flutter test --coverage" description: Run Flutter tests for a specific package in this project. packageFilters: flutter: true dirExists: test + e2e:run: + run: cd sample_app && patrol test + description: > + Run the Patrol e2e suite in sample_app. Start the mock-server driver first + (`fastlane start_mock_server`), or use the `run_e2e_test` Fastlane lane to + orchestrate the mock server and tests in one step. + update:goldens: run: melos exec -c 1 --depends-on="alchemist" -- "flutter test --tags golden --update-goldens" description: Update golden files for all packages in this project. diff --git a/sample_app/Allurefile b/sample_app/Allurefile new file mode 100644 index 0000000000..a450b82786 --- /dev/null +++ b/sample_app/Allurefile @@ -0,0 +1,82 @@ +require 'base64' + +allure_endpoint = 'https://streamio.testops.cloud' +allure_project_id = '135' + +# Reassembles the chunked `ALLURE-RESULT::` markers the Dart reporter prints into +# an `allure-results/` dir for allurectl upload. The markers are read from the +# device log: logcat on Android, the simulator log on iOS (device: is a UUID). +lane :collect_allure_results do |options| + results_dir = options[:output] || "#{root_path}/allure-results" + FileUtils.mkdir_p(results_dir) + + ios = options[:device].to_s.match?(/\A[0-9A-F]{8}-[0-9A-F]{4}-/i) + log = if ios + `xcrun simctl spawn #{options[:device]} log show --last 30m --style compact 2>/dev/null` + else + `adb logcat -d 2>/dev/null` + end + + chunks = Hash.new { |hash, key| hash[key] = {} } + log.scan(/ALLURE-RESULT::([0-9a-f-]+):(\d+):([A-Za-z0-9+\/=]+)/) do |uuid, seq, chunk| + chunks[uuid][seq.to_i] = chunk + end + + chunks.each do |uuid, parts| + base64 = parts.sort_by(&:first).map(&:last).join + File.write("#{results_dir}/#{uuid}-result.json", Base64.decode64(base64)) + end + UI.message("Collected #{chunks.size} Allure result(s) into #{results_dir}") +end + +lane :install_allurectl do + path = "#{root_path}/allurectl" + next path if File.exist?(path) + + os = `uname -s`.strip.downcase + arch = `uname -m`.strip == 'x86_64' ? 'amd64' : 'arm64' + sh("curl -sSL -o #{path} " \ + "https://github.com/allure-framework/allurectl/releases/latest/download/allurectl_#{os}_#{arch}") + sh("chmod +x #{path}") + path +end + +# Uploads collected allure-results to Allure TestOps. Requires ALLURE_TOKEN in +# the env. Uses ALLURE_LAUNCH_ID (or launch_id:) to upload into an existing +# launch; otherwise allurectl creates one. +lane :allure_upload do |options| + results = options[:results] || "#{root_path}/allure-results" + unless Dir.exist?(results) && Dir.children(results).any? + UI.important("No Allure results in #{results}; skipping upload") + next + end + + allurectl = install_allurectl + branch = ENV['GITHUB_REF_NAME'] || `git rev-parse --abbrev-ref HEAD`.strip + launch_id = options[:launch_id] || ENV['ALLURE_LAUNCH_ID'] + args = launch_id ? "--launch-id #{launch_id}" : "--launch-name 'Flutter e2e (#{branch})'" + sh("ALLURE_ENDPOINT=#{allure_endpoint} ALLURE_PROJECT_ID=#{allure_project_id} " \ + "#{allurectl} upload #{args} #{results}") + UI.success("Uploaded Allure results โ†’ #{allure_endpoint} (project #{allure_project_id})") +end + +# Creates a launch up front and exports its id (ALLURE_LAUNCH_ID) so the nightly +# matrix jobs upload into the same launch. Requires ALLURE_TOKEN. +lane :allure_launch do + allurectl = install_allurectl + branch = ENV['GITHUB_REF_NAME'] || `git rev-parse --abbrev-ref HEAD`.strip + id = sh("ALLURE_ENDPOINT=#{allure_endpoint} ALLURE_PROJECT_ID=#{allure_project_id} " \ + "#{allurectl} launch create --launch-name 'Flutter e2e nightly (#{branch})' --format ID --no-header").strip + sh("echo 'ALLURE_LAUNCH_ID=#{id}' >> #{ENV['GITHUB_ENV']}") if ENV['GITHUB_ENV'] + id +end + +# Deletes the launch (cleanup when a nightly run is cancelled). Requires ALLURE_TOKEN. +lane :allure_launch_removal do |options| + id = options[:launch_id] || ENV['ALLURE_LAUNCH_ID'] + next unless id + + allurectl = install_allurectl + sh("ALLURE_ENDPOINT=#{allure_endpoint} ALLURE_PROJECT_ID=#{allure_project_id} " \ + "#{allurectl} launch delete #{id}") +end diff --git a/sample_app/Fastfile b/sample_app/Fastfile index 66d2bdf8a7..43980ad9a7 100644 --- a/sample_app/Fastfile +++ b/sample_app/Fastfile @@ -1,5 +1,14 @@ opt_out_usage +require 'net/http' + +import '../../Allurefile' + +mock_server_repo_name = 'stream-chat-test-mock-server' +mock_server_driver_port = ENV['MOCK_DRIVER_PORT'] || '4568' +github_org = (ENV['GITHUB_REPOSITORY'] || 'GetStream/stream-chat-flutter').split('/').first +@force_check = false + # Have an easy way to get the root of the project def root_path Dir.pwd.sub(/.*\Kfastlane/, '').sub(/.*\Kandroid/, '').sub(/.*\Kios/, '').sub(/.*\K\/\//, '') @@ -14,3 +23,82 @@ end lane :fetch_dependencies do sh_on_root(command: "flutter pub get --suppress-analytics") end + +# Clones (or reuses via local_server:) the mock-server repo and starts its +# driver in the background. branch: selects the repo branch (default 'main'). +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_org}/#{mock_server_repo_name}.git") + end + + Dir.chdir(mock_server_repo) do + FileUtils.mkdir_p('logs') + Bundler.with_unbundled_env do + sh('bundle install') + sh("bundle exec ruby driver.rb #{mock_server_driver_port} > logs/driver.log 2>&1 &") + end + end + end +end + +# Stops the mock-server driver (and any servers it spawned). +lane :stop_mock_server do + Net::HTTP.get_response(URI("http://localhost:#{mock_server_driver_port}/stop")) rescue nil +end + +# Runs the Patrol e2e suite against a fresh mock server, then tears it down. +# Requires `patrol` on PATH and, on Android, a Gradle-compatible JDK. +# Options: device:, target: (single file), local_server:, mock_server_branch:. +lane :run_e2e_test do |options| + next unless is_check_required(sources: sources_matrix[:e2e], force_check: @force_check) + + start_mock_server(local_server: options[:local_server], branch: options[:mock_server_branch]) + sh('adb logcat -c') rescue nil + + begin + Dir.chdir(root_path) do + flags = [] + flags << "--target #{options[:target]}" if options[:target] + flags << "--device #{options[:device]}" if options[:device] + sh("patrol test #{flags.join(' ')}".strip) + end + ensure + collect_allure_results(device: options[:device]) rescue nil + stop_mock_server + end +end + +# Builds the e2e test binaries without running them (useful for CI caching). +# `platform` is android or ios. +lane :build_e2e_test do |options| + next unless is_check_required(sources: sources_matrix[:e2e], force_check: @force_check) + + Dir.chdir(root_path) { sh("patrol build #{options[:platform]}") } +end + +lane :build_and_run_e2e_test do |options| + build_e2e_test(platform: options[:platform]) + run_e2e_test(options) +end + +# Maps each CI check to the source paths (repo-root-relative) that should +# trigger it; `is_check_required` skips a lane when none of them changed. +private_lane :sources_matrix do + { + e2e: [ + 'sample_app', + 'packages', + 'melos.yaml', + '.github/workflows/e2e_test.yml', + '.github/workflows/e2e_test_cron.yml', + ], + ruby: ['sample_app/Fastfile', 'sample_app/Allurefile', 'sample_app/android/fastlane', + 'sample_app/ios/fastlane', 'sample_app/android/Gemfile', 'sample_app/ios/Gemfile'], + } +end diff --git a/sample_app/android/Gemfile.lock b/sample_app/android/Gemfile.lock new file mode 100644 index 0000000000..1505cbe655 --- /dev/null +++ b/sample_app/android/Gemfile.lock @@ -0,0 +1,266 @@ +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) + apktools (0.7.5) + rubyzip (~> 2.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1253.0) + aws-sdk-core (3.249.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.128.0) + aws-sdk-core (~> 3, >= 3.248.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.224.0) + aws-sdk-core (~> 3, >= 3.248.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) + excon (0.112.0) + faraday (1.10.5) + 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.235.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) + addressable (>= 2.8, < 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, < 1.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.1.0, < 4) + logger (>= 1.6, < 2.0) + mini_magick (>= 4.9.4, < 5.0.0) + 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-aws_s3 (2.1.0) + apktools (~> 0.7) + aws-sdk-s3 (~> 1) + mime-types (~> 3.3) + fastlane-plugin-firebase_app_distribution (1.0.0) + fastlane (>= 2.232.0) + google-apis-firebaseappdistribution_v1 (>= 0.9.0) + google-apis-firebaseappdistribution_v1alpha (>= 0.12.0) + fastlane-plugin-stream_actions (0.4.3) + xctest_list (= 1.2.1) + fastlane-sirp (1.1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.101.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-firebaseappdistribution_v1 (0.19.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-firebaseappdistribution_v1alpha (0.28.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-iamcredentials_v1 (0.27.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.62.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.8.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.6.0) + google-cloud-storage (1.60.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.16.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + 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.19.5) + jwt (3.2.0) + base64 + logger (1.7.0) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2026.0414) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.21.1) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + nkf (0.2.0) + optparse (0.8.1) + os (1.1.4) + ostruct (0.6.3) + plist (3.7.2) + public_suffix (4.0.7) + 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.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + 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) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + 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 + arm64-darwin-25 + ruby + +DEPENDENCIES + fastlane + fastlane-plugin-aws_s3 + fastlane-plugin-firebase_app_distribution + fastlane-plugin-stream_actions + multi_json + +BUNDLED WITH + 2.6.8 diff --git a/sample_app/android/app/build.gradle b/sample_app/android/app/build.gradle index 5ae22c6dfb..6fabcb4429 100644 --- a/sample_app/android/app/build.gradle +++ b/sample_app/android/app/build.gradle @@ -33,6 +33,9 @@ android { targetSdkVersion flutter.targetSdkVersion versionCode flutter.versionCode versionName flutter.versionName + + // Patrol e2e + testInstrumentationRunner "pl.leancode.patrol.PatrolJUnitRunner" } signingConfigs { diff --git a/sample_app/android/app/src/androidTest/java/io/getstream/chat/android/flutter/sample/MainActivityTest.java b/sample_app/android/app/src/androidTest/java/io/getstream/chat/android/flutter/sample/MainActivityTest.java new file mode 100644 index 0000000000..e4660ed14b --- /dev/null +++ b/sample_app/android/app/src/androidTest/java/io/getstream/chat/android/flutter/sample/MainActivityTest.java @@ -0,0 +1,35 @@ +package io.getstream.chat.android.flutter.sample; + +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import pl.leancode.patrol.PatrolJUnitRunner; + +// Patrol e2e entry point: enumerates the Dart `patrolTest` cases and runs each +// as a parameterized JUnit test against MainActivity. Do not edit by hand. +@RunWith(Parameterized.class) +public class MainActivityTest { + @Parameterized.Parameters(name = "{0}") + public static Object[] testCases() { + PatrolJUnitRunner instrumentation = + (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + instrumentation.setUp(MainActivity.class); + instrumentation.waitForPatrolAppService(); + return instrumentation.listDartTests(); + } + + public MainActivityTest(String dartTestName) { + this.dartTestName = dartTestName; + } + + private final String dartTestName; + + @org.junit.Test + public void runDartTest() { + PatrolJUnitRunner instrumentation = + (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + instrumentation.runDartTest(dartTestName); + } +} diff --git a/sample_app/integration_test/PLAN.md b/sample_app/integration_test/PLAN.md new file mode 100644 index 0000000000..58fecd5545 --- /dev/null +++ b/sample_app/integration_test/PLAN.md @@ -0,0 +1,889 @@ +# E2E Testing Infrastructure for Flutter (iOS + Android) + +A detailed plan to port the Stream Chat **Android** e2e testing infrastructure +to the **Flutter** SDK's `sample_app`, using the +[Patrol](https://github.com/leancodepl/patrol) framework, while reusing the +existing `stream-chat-test-mock-server` and mimicking the Android Robot / +PageObject / test DSL as closely as possible. + +The goal is a single shared test suite that runs on **both Flutter iOS and +Android** with maximal code sharing. + +--- + +## 0. TL;DR + +- Add Patrol-based integration tests to `sample_app/`. +- Recreate the Android DSL in Dart: **PageObjects** (selector definitions), + **UserRobot** (fluent UI actions + assertion extensions), **BackendRobot** / + **ParticipantRobot** (mock-server control), a **StreamTestCase** base, and + the **mock server client**. +- Reuse `stream-chat-test-mock-server` **as-is** (Ruby/Sinatra driver + + per-test server). Tests talk to it over HTTP from Dart. **Every test gets its + own isolated mock-server process** (the driver forks one per `/start/:test`). +- **Decision: Option A โ€” direct in-process configuration.** Because Patrol tests + run **in-process** (Dart test code lives inside the app), point + `StreamChatClient` at the mock server **directly from Dart** via a + `debugConnectionOverride` on the `authController` singleton โ€” no Intent extras + / env vars needed (the big simplification vs. Android & iOS). Options B + (`--dart-define`) and C (native bridge) stay documented as alternatives; a few + cold-start/push tests may use a B-shaped escape hatch (ยง3.5). +- Add `Gemfile` + Fastlane lanes (`build_e2e`, `run_e2e`, `start/stop_mock_server`, + Allure upload), mirroring the Android lanes. +- Wire up Allure TestOps reporting and a GitHub Actions e2e job. + +--- + +## 1. Source material (what we are copying) + +### Android (primary source) +- App-side suite: `stream-chat-android-compose-sample/src/androidTestE2eDebug/` + - `pages/` โ€” PageObjects (selector definitions only) + - `robots/` โ€” `UserRobot` + assertion extension files + - `tests/` โ€” test classes (`StreamTestCase` base + 11 feature suites) + - `resources/allure.properties` +- Shared library: `stream-chat-android-e2e-test/src/main/kotlin/.../e2e/test/` + - `uiautomator/` โ€” thin DSL over UIAutomator (Base, Element, Wait, FindBy, Actions, Math, Permissions) + - `mockserver/` โ€” `MockServer`, `DataTypes` (enums) + - `robots/` โ€” `BackendRobot`, `ParticipantRobot` + - `rules/` โ€” `RetryRule` +- `fastlane/Fastfile`, `fastlane/Allurefile`, root `Gemfile` + +### iOS / Swift (reference for the mock-server integration approach) +- `stream-chat-swift/TestTools/StreamChatTestMockServer/` (MockServer, Robots) +- Uses env vars + launch args to pass the mock URL to the app. + +### Mock server (reused as-is) +- `stream-chat-test-mock-server/` โ€” Ruby + Sinatra. + - `driver.rb` (port `4567` by default): `GET /start/:test_name` spawns a fresh + mock server and returns its port; `GET /stop` shuts the driver down. + - `src/server.rb` (dynamic port): mocks the Stream REST API **and** the + WebSocket (`/connect`) including periodic health events. + - Control endpoints used by tests: + - **Backend setup:** `POST /mock?channels=&messages=&replies=&messages_text=&attachments=`, + `/fail_messages`, `/freeze_messages`, `/delay_messages?delay=`, + `/jwt/revoke_token`, `/jwt/invalidate_token[_date|_signature]`, + `/jwt/break_token_generation`, `/config/read_events`, `/config/cooldown`, + `GET /ping`, `GET /jwt/get?platform=`. + - **Participant actions:** `POST /participant/message` (+ `action`, `thread`, + `giphy`, `image|video|file`, `delay`, `quote_first|quote_last`), + `/participant/reaction` (`type`, `delete`, `delay`), + `/participant/typing/start|stop`, `/participant/read`, `/participant/push`. + +--- + +## 2. Key architectural difference: Patrol vs. UIAutomator / XCUITest + +This is the most important thing to internalize before porting, because it +**simplifies** the design relative to Android/iOS. + +| | Android (UIAutomator) | iOS (XCUITest) | **Flutter (Patrol)** | +|---|---|---|---| +| Where test code runs | Separate instrumentation process | Separate UI-test runner process | **In-process, inside the app (Dart)** | +| How it drives UI | OS-level accessibility tree | OS-level accessibility tree | Flutter `WidgetTester` finders (+ Patrol native automation for OS dialogs) | +| How app learns the mock URL | `Intent` extra `BASE_URL` | env vars + launch args | **Direct: Dart test builds the client with the mock URL** | +| Selector strategy | `By.res("Stream_X")` resource IDs | accessibility identifiers | `find.byKey` / `patrol.($('...'))` on widget `Key`s / semantics | + +**Implications:** +1. **No IPC needed to inject the mock URL.** The Patrol test `main()` constructs + the app widget itself, so it can pass the mock `baseURL`/`baseWsUrl` straight + into `StreamChatClient`. We expose a test entrypoint (e.g. + `runStreamApp(connectionOptions)`) the test calls directly. +2. **The mock-server client (`BackendRobot`/`ParticipantRobot`) runs in Dart** + inside the same process โ€” it just does HTTP calls to the driver/mock server. +3. **Selectors must be added to the production widgets.** UIAutomator relied on + `Stream_*` testTags already present in Compose. In Flutter we must add `Key`s + (or `Semantics(identifier:)`) to the relevant widgets in `stream_chat_flutter` + and/or `sample_app`. This is the largest cross-cutting code change. +4. **Patrol's native automation** still handles OS-level concerns the in-process + tester cannot: runtime permission dialogs (photos/camera/notifications), + notifications, backgrounding the app, toggling wifi/airplane mode โ€” these map + to the Android `device.*` helpers (`grantPermission`, `disableInternetConnection`, + `goToBackground`, etc.). + +--- + +## 3. Mock server integration โ€” DECISION + options + +> **Decision: we go with Option A (Direct in-process configuration).** Options B +> and C are documented below as alternatives / escape hatches, but A is the +> primary approach the rest of this plan assumes. A handful of tests that need a +> true OS cold start or push-tap navigation may use a B-shaped variant (see +> ยง3.5). + +Android passes the URL via an `Intent` extra; iOS passes it via environment +variables + launch arguments. Neither is necessary for Patrol because the test +runs in-process. Three viable options: + +### Option A โ€” Direct in-process configuration (CHOSEN) +The Patrol test resolves the mock-server URL (via the driver) and the app reads +it when it builds its `StreamChatClient`. + +**The real seam (grounded in the current code).** The client is *not* passed +into the widget โ€” `StreamChatSampleApp` takes no config (`const +StreamChatSampleApp({super.key})`), and the client is built inside a +**process-wide singleton** `authController` via the private +`_buildStreamChatClient(apiKey)` (`auth_controller.dart`, with `baseURL`/ +`baseWsUrl` currently hardcoded-and-commented at lines ~54โ€“55). So we inject the +override onto that singleton *before* the first `connect()` runs, rather than +threading a widget parameter: + +```dart +// auth_controller.dart โ€” test seam +StreamConnectionOverride? debugConnectionOverride; // null in production + +StreamChatClient _buildStreamChatClient(String apiKey) { + return StreamChatClient( + apiKey, + baseURL: debugConnectionOverride?.baseURL, // null โ†’ SDK default (prod) + baseWsUrl: debugConnectionOverride?.baseWsUrl, + logLevel: logLevel, + logHandlerFunction: _sampleAppLogHandler, + retryPolicy: RetryPolicy(...), + )..chatPersistenceClient = _chatPersistenceClient; +} +``` + +```dart +patrolTest('test_addsReaction', ($) async { + final mock = await MockServer.start(testName: 'test_addsReaction'); + authController.debugConnectionOverride = StreamConnectionOverride( + baseURL: mock.httpUrl, // http://10.0.2.2: (Android) / http://localhost: (iOS) + baseWsUrl: mock.wsUrl, // ws://... + ); + await $.pumpWidgetAndSettle(const StreamChatSampleApp()); + // ... robots/asserts +}); +``` + +Because the URL is read at **client-build time** (first `connect()`), the +driver's per-test **dynamic port** works without any build-time configuration. + +**Pros** +- **No IPC** โ€” the single biggest win. Android needs an `Intent` extra and iOS + needs env vars + launch args only because their runners are out-of-process; + Patrol runs in-process, so the test sets the URL by direct assignment. +- **One code path for iOS + Android** โ€” the seam is pure Dart; the only platform + branch is the mock host (`10.0.2.2` vs `localhost`), which lives in + `MockServer`, not in the app. +- **Trivial robots** โ€” `BackendRobot`/`ParticipantRobot` are plain `http` calls + in the same isolate as the test; no bridge, no serialization. +- **Prod-safe & tight** โ€” when the override is null, behaviour is unchanged (SDK + falls back to its default base URL); the shipped diff is a few lines. +- **Per-test dynamic ports work** โ€” URL resolved at runtime from the driver + (unlike Option B, which bakes a port at build time). + +**Cons** (each is one-time setup, not per-test friction) +- **Seam touches a global singleton, not a widget** โ€” `authController` is a + process-wide `final`; the override is ambient global state (see next point). +- **In-process global state leaks between bundled tests** โ€” Patrol runs all + tests in one app process. `authController` persists, `tryAutoConnect()` reads + `FlutterSecureStorage`, and `connect()` deliberately keeps `_client` alive + (lines ~140โ€“145). Each test must reset in teardown (clear secure storage, + dispose/reset client, re-set override). Android sidesteps this with + `clearPackageData true` per test; we emulate it in Dart `tearDown`. See ยง6. +- **Bypasses the real cold-start path** โ€” the test pumps the widget tree and + skips `main.dart` (Firebase init, push pipeline, deep-link / `_onNotificationTap` + entry). Tests that must validate a true OS cold start or push-tap navigation + don't fit A cleanly โ†’ use ยง3.5. +- **Boot-path side effects can fail under test** โ€” `initState` runs + `NotificationService.initialize()` and the log handler calls + `FirebaseCrashlytics.instance.recordError`. Pumping the root widget without + Firebase configured can throw; guard these behind the e2e flag (see ยง6). +- **JWT/auth suite needs extra wiring** โ€” the override only covers + `baseURL`/`baseWsUrl`; the token provider must separately be redirected to the + mock server's `GET /jwt/get` for the token-expiry tests. + +### Option B โ€” `--dart-define` compile-time configuration +Pass `MOCK_HOST`/`MOCK_PORT` via `--dart-define`; the app reads +`String.fromEnvironment(...)` at startup and overrides the client URL. +- **Pros:** No widget param; closest analog to iOS env-var approach; production + code path stays untouched at runtime (values baked at build). +- **Cons:** Port is dynamic per test (the driver assigns it), but `--dart-define` + is fixed at build time โ€” so you'd have to pin the mock server to a **fixed + port per run** (skip the driver's per-test port allocation) or only define the + host and resolve the port at runtime anyway. Less flexible than A. + +### Option C โ€” Native bridge (mimic Android/iOS exactly) +Replicate Android's `Intent`-extra / iOS's env-var mechanism through Patrol's +native layer. +- **Pros:** Most faithful to the existing mobile projects. +- **Cons:** Pointless indirection for Patrol (the test already runs in-process); + more native plumbing, two platform paths. Not recommended. + +### Option A.5 โ€” escape hatch for cold-start / push tests (B-shaped) +A small minority of tests need a true OS launch (deep links, push-tap +navigation via `_onNotificationTap`). Those don't fit in-process pumping. For +them, launch the app natively via Patrol and pass the mock **host** through +`--dart-define` (the app reads `String.fromEnvironment('MOCK_HOST')` at +startup), pinning the driver/server to a known port for that run. The project +ends up **A-primary with a B-shaped escape hatch** for the few launch/push +tests. + +### Per-test mock server isolation (independent of the chosen option) +This is determined by the mock-server *driver* design, not by Option A/B/C โ€” +and **yes, every test gets its own dedicated, isolated mock server instance.** + +Two processes, two roles: +1. **Driver** (`driver.rb`, one long-lived process on port `4567`) โ€” started + once per CI run by Fastlane. Stateless; only spawns/tracks servers. +2. **Mock server** (`server.rb`) โ€” one **fresh OS process per test**, on its own + dynamically-allocated port. + +``` +test_A โ†’ GET /start/test_A โ†’ driver forks server.rb โ†’ port 5012 (own channels/messages/JWT state) +test_B โ†’ GET /start/test_B โ†’ driver forks server.rb โ†’ port 5013 (completely separate) +``` + +So if `StreamTestEnv.setUp()` calls `MockServer.start(testName:)` per test (the +port of Android's `StreamTestCase`/`@Before`), no state leaks **at the server +layer**. The important caveat is the *app* side, because Patrol bundles all +tests into **one app process**: + +| Layer | Per-test fresh? | +|---|---| +| Mock server process (driver-spawned) | โœ… Yes, automatically | +| Dart app process (Patrol bundle) | โŒ No โ€” shared across all tests | +| `authController` singleton, secure storage, `_client` | โŒ No โ€” must be reset in `tearDown` | + +Therefore each test must (a) call `MockServer.start()` in `setUp` and re-inject +the *new* port via `debugConnectionOverride`, and (b) reset app-side globals in +`tearDown` (see ยง6). Server isolation is free; app isolation is manual. + +### Networking detail (applies to all options) +- **Android emulator** reaches the host loopback at `10.0.2.2`; **iOS simulator** + uses `localhost`/`127.0.0.1`. `MockServer` must pick the host per platform + (`Platform.isAndroid ? '10.0.2.2' : 'localhost'`). +- The mock server is plain **HTTP/WS** (not TLS). Android needs cleartext + permitted: add a debug `network_security_config` (or `usesCleartextTraffic`) + for the e2e build. The Stream client already supports `http://` base URLs; no + `forceInsecureConnection` equivalent is needed in Dart, but confirm Dio allows + cleartext on the platform. +- **JWT:** the mock server issues tokens at `GET /jwt/get?platform=`. For + parity, the sample app's token provider should fetch from the mock server when + running e2e (needed for the auth/token-expiry suite). For non-auth tests the + predefined static tokens in `app_config.dart` are sufficient. + +--- + +## 4. Target directory layout (Flutter) + +Two homes, mirroring Android's split between the shared lib +(`stream-chat-android-e2e-test`) and the app-side suite +(`androidTestE2eDebug`): + +``` +sample_app/ +โ”œโ”€โ”€ integration_test/ +โ”‚ โ”œโ”€โ”€ tests/ # โ† androidTestE2eDebug/tests +โ”‚ โ”‚ โ”œโ”€โ”€ stream_test_case.dart # StreamTestCase base (setUp/tearDown, robots) +โ”‚ โ”‚ โ”œโ”€โ”€ auth_test.dart +โ”‚ โ”‚ โ”œโ”€โ”€ message_list_test.dart +โ”‚ โ”‚ โ”œโ”€โ”€ reactions_test.dart +โ”‚ โ”‚ โ”œโ”€โ”€ quoted_reply_test.dart +โ”‚ โ”‚ โ”œโ”€โ”€ attachments_test.dart +โ”‚ โ”‚ โ”œโ”€โ”€ giphy_test.dart +โ”‚ โ”‚ โ”œโ”€โ”€ draft_messages_test.dart +โ”‚ โ”‚ โ”œโ”€โ”€ message_delivery_status_test.dart +โ”‚ โ”‚ โ”œโ”€โ”€ hyperlinks_test.dart +โ”‚ โ”‚ โ”œโ”€โ”€ channel_list_test.dart +โ”‚ โ”‚ โ””โ”€โ”€ backend_test.dart +โ”‚ โ”œโ”€โ”€ robots/ # โ† androidTestE2eDebug/robots +โ”‚ โ”‚ โ”œโ”€โ”€ user_robot.dart # fluent UI actions +โ”‚ โ”‚ โ”œโ”€โ”€ user_robot_channel_list_asserts.dart +โ”‚ โ”‚ โ””โ”€โ”€ user_robot_message_list_asserts.dart +โ”‚ โ”œโ”€โ”€ pages/ # โ† androidTestE2eDebug/pages +โ”‚ โ”‚ โ”œโ”€โ”€ login_page.dart +โ”‚ โ”‚ โ”œโ”€โ”€ channel_list_page.dart +โ”‚ โ”‚ โ”œโ”€โ”€ message_list_page.dart +โ”‚ โ”‚ โ”œโ”€โ”€ thread_page.dart +โ”‚ โ”‚ โ””โ”€โ”€ jwt_page.dart +โ”‚ โ””โ”€โ”€ test_bundle.dart # Patrol bundled-test entrypoint (generated) +โ”‚ +โ”œโ”€โ”€ test_driver/ +โ”‚ โ””โ”€โ”€ integration_test.dart # Patrol/integration_test driver +โ”‚ +โ””โ”€โ”€ e2e/ # shared helpers (โ‰ˆ stream-chat-android-e2e-test) + โ”œโ”€โ”€ mock_server/ + โ”‚ โ”œโ”€โ”€ mock_server.dart # driver client + per-test server lifecycle + โ”‚ โ””โ”€โ”€ data_types.dart # AttachmentType / ReactionType / MessageDeliveryStatus enums + โ”œโ”€โ”€ robots/ + โ”‚ โ”œโ”€โ”€ backend_robot.dart + โ”‚ โ””โ”€โ”€ participant_robot.dart + โ”œโ”€โ”€ patrol_ext/ # thin DSL over Patrol (โ‰ˆ uiautomator/) + โ”‚ โ”œโ”€โ”€ waits.dart # waitToAppear/waitToDisappear/waitForText + โ”‚ โ”œโ”€โ”€ actions.dart # typeText/longPress/swipe/background/connectivity + โ”‚ โ””โ”€โ”€ finders.dart # selector โ†’ PatrolFinder helpers + โ””โ”€โ”€ support/ + โ”œโ”€โ”€ retry.dart # retry harness (โ‰ˆ RetryRule) + โ”œโ”€โ”€ allure.dart # Allure step() + attachments + โ””โ”€โ”€ predefined_users.dart # โ‰ˆ PredefinedUserCredentials +``` + +> Patrol can also live in a separate package if we want the e2e suite isolated +> from `sample_app`'s own `test/`. Recommendation: keep it inside `sample_app` +> (Patrol expects `integration_test/` next to the app) and add `e2e/` as plain +> Dart source compiled into the test binary. + +--- + +## 5. Component-by-component port + +### 5.1 PageObjects (`pages/`) +Android uses nested companion objects of `By.res("Stream_*")`. In Dart, model +the same hierarchy with nested classes exposing **Patrol selectors / Keys**. + +Android: +```kotlin +class MessageListPage { + class Composer { + companion object { + val inputField = By.res("Stream_ComposerInputField") + val sendButton = By.res("Stream_ComposerSendButton") + } + } + class MessageList { + companion object { val messages = By.res("Stream_MessageCell") } + class Message { + companion object { + val text = By.res("Stream_MessageText") + class Reactions { + companion object { fun reaction(t: ReactionType): BySelector = ... } + } + } + } + } +} +``` + +Dart (keep the same names/structure so the DSL reads identically): +```dart +abstract class MessageListPage { + static const composer = _Composer(); + static const messageList = _MessageList(); +} + +class _Composer { + const _Composer(); + Key get inputField => const Key('Stream_ComposerInputField'); + Key get sendButton => const Key('Stream_ComposerSendButton'); +} + +class _MessageList { + const _MessageList(); + Key get messages => const Key('Stream_MessageCell'); + _Message get message => const _Message(); +} + +class _Message { + const _Message(); + Key get text => const Key('Stream_MessageText'); + Key reaction(ReactionType type) => Key('Stream_MessageReaction_${type.reaction}'); +} +``` + +**Required production change:** add these `Key`s to the corresponding widgets. +Build a mapping table (Compose `testTag` โ†’ Flutter widget โ†’ `Key`) and add keys +in `stream_chat_flutter` (and `sample_app` for app-specific screens). Prefer +`Key('Stream_*')` constants matching the Android resource-id strings so the +selector tables stay 1:1. Keep the constant strings in one shared file to avoid +drift between widget and page object. + +### 5.2 The Patrol DSL layer (`e2e/patrol_ext/` โ‰ˆ `uiautomator/`) +Recreate the wait/action helpers as extensions on `PatrolTester`/`PatrolFinder`: +- `waitToAppear({Duration timeout})`, `waitToDisappear()`, `waitForText()`, + `waitForCount()` โ†’ wrap `patrolTester.waitUntilVisible` / polling. +- `typeText`, `longPress`, `tap`, `swipeUp/Down`, `scrollUntilVisible`. +- Device/native (via Patrol native automator): + `grantPermission`, `disableInternetConnection`/`enableInternetConnection` + (Patrol `nativeAutomator` wifi/cellular toggles or `setAirplaneMode`), + `goToBackground`/`goToForeground`, `tapOnNotification`. +- `defaultTimeout = Duration(seconds: 5)` constant, mirroring Android. + +### 5.3 Data types (`e2e/mock_server/data_types.dart` โ‰ˆ `DataTypes.kt`) +Direct port of the enums: +```dart +enum AttachmentType { image, video, file } +enum ReactionType { + love('love'), lol('haha'), wow('wow'), sad('sad'), like('like'); + const ReactionType(this.reaction); + final String reaction; +} +enum MessageDeliveryStatus { read, pending, sent, failed, nil } +const forbiddenWord = 'wth'; +``` + +### 5.4 Mock server client (`e2e/mock_server/mock_server.dart` โ‰ˆ `MockServer.kt`) +```dart +class MockServer { + MockServer._(this.httpUrl, this.wsUrl); + final String httpUrl; + final String wsUrl; + + // Host differs per platform (emulator loopback vs simulator localhost). + static String get _host => Platform.isAndroid ? '10.0.2.2' : 'localhost'; + + // Single Flutter driver port, distinct from the native repos (android=4567, + // swift=4566) so Flutter e2e can run alongside them. Flutter-iOS and + // Flutter-Android SHARE this port: they aren't run simultaneously on the same + // host (CI runs them as separate matrix jobs). Overridable via --dart-define + // for the rare local case of running both platforms at once. + static const _driverPort = String.fromEnvironment('MOCK_DRIVER_PORT', defaultValue: '4568'); + + static Future start({required String testName}) async { + final driver = 'http://$_host:$_driverPort'; + final res = await http.get(Uri.parse('$driver/start/$testName')); + final port = res.body.trim(); + final http_ = 'http://$_host:$port'; + final ws = 'ws://$_host:$port'; + final server = MockServer._(http_, ws); + // The driver spawns server.rb asynchronously; it needs ~0.5โ€“1s to boot + // puma before it answers. Poll /ping until ready (verified in the Phase 0 + // spike) so the app doesn't try to connect before the server is listening. + await server._waitUntilReady(); + return server; + } + + Future _waitUntilReady({Duration timeout = const Duration(seconds: 10)}) async { + // poll `GET $httpUrl/ping` until 200 or timeout + } + + Future stop() { /* optional per-test stop */ } + Future post(String endpoint, [Object? body]) => ...; + Future get(String endpoint) => ...; +} +``` + +### 5.5 BackendRobot / ParticipantRobot (`e2e/robots/` โ‰ˆ Android robots) +Faithful Dart ports of the Kotlin robots; each method is one HTTP call and +returns `this` for chaining. +```dart +class BackendRobot { + BackendRobot(this._mock); + final MockServer _mock; + + Future generateChannels({ + required int channelsCount, + int messagesCount = 0, + int repliesCount = 0, + String? messagesText, + String? repliesText, + bool attachments = false, + }) async { await _mock.post('/mock?channels=$channelsCount&messages=$messagesCount&...'); return this; } + + Future failNewMessages() async { await _mock.post('/fail_messages'); return this; } + Future freezeNewMessages() async { ... } + Future revokeToken({int duration = 5}) async { ... } + Future invalidateToken({int duration = 5}) async { ... } + // ...invalidateTokenDate / invalidateTokenSignature / breakTokenGeneration / setReadEvents / setCooldown +} + +class ParticipantRobot { + static const name = 'Count Dooku'; + Future sendMessage(String text, {int delay = 0}) async { ... } + Future sendMessageInThread(String text, {bool alsoSendInChannel = false}) async { ... } + Future editMessage(String text) async { ... } + Future deleteMessage({bool hard = false}) async { ... } + Future quoteMessage(String text, {bool last = true}) async { ... } + Future addReaction(ReactionType type, {int delay = 0}) async { ... } + Future deleteReaction(ReactionType type) async { ... } + Future startTyping({bool thread = false}) async { ... } + Future stopTyping({bool thread = false}) async { ... } + Future readMessage({String? parentId}) async { ... } +} +``` +> Dart note: the Android robots are synchronous (blocking HTTP); Dart HTTP is +> async, so robot methods return `Future<...>` and tests `await` them. Keep the +> fluent feel via `await robot.x(); await robot.y();` or chained `.then`. This is +> the one unavoidable deviation from the Kotlin DSL. + +### 5.6 UserRobot (`robots/user_robot.dart` โ‰ˆ `UserRobot.kt`) +Fluent UI-action robot built on the Patrol DSL + page objects. Holds a reference +to the `PatrolTester`. Same method names as Android: `login`, `logout`, +`openChannel`, `sendMessage`, `typeText`, `editMessage`, `deleteMessage`, +`openContextMenu`, `addReaction`, `deleteReaction`, `quoteMessage`, `openThread`, +`sendMessageInThread`, `uploadAttachment`, `uploadGiphy`, `scrollMessageListUp/Down`, +`swipeMessage`, `mentionParticipant`, etc. +```dart +class UserRobot { + UserRobot(this.$); + final PatrolTester $; + + Future login() async { + await $(LoginPage.loginButton).waitToAppear().tap(); + return this; + } + Future openChannel({int index = 0}) async { ... } + Future sendMessage(String text) async { + await $(MessageListPage.composer.inputField).enterText(text); + await $(MessageListPage.composer.sendButton).tap(); + return this; + } +} +``` +Assertions go in **extension** files (`user_robot_message_list_asserts.dart`, +`user_robot_channel_list_asserts.dart`), mirroring `UserRobotMessageListAsserts.kt`: +```dart +extension MessageListAsserts on UserRobot { + Future assertMessage(String text, {bool isDisplayed = true}) async { + if (isDisplayed) { + expect($(MessageListPage.messageList.message.text).$(text), findsOneWidget); + } else { + expect($(text), findsNothing); + } + return this; + } + Future assertReaction(ReactionType type, {required bool isDisplayed}) async { ... } + Future assertMessageDeliveryStatus(MessageDeliveryStatus status, {int? count}) async { ... } +} +``` + +### 5.7 StreamTestCase base (`tests/stream_test_case.dart` โ‰ˆ `StreamTestCase.kt`) +Provide a helper that owns the mock server + robots and the app boot, so each +test reads like the Android one. Implemented as a setup function used inside +`patrolTest`: +```dart +class StreamTestEnv { + late final MockServer mockServer; + late final BackendRobot backendRobot; + late final ParticipantRobot participantRobot; + late final UserRobot userRobot; + + Future setUp(PatrolTester $, {required String testName, InitActivity init = InitActivity.userLogin}) async { + mockServer = await MockServer.start(testName: testName); // fresh per-test server (own port) + backendRobot = BackendRobot(mockServer); + participantRobot = ParticipantRobot(mockServer); + userRobot = UserRobot($); + + // Option A seam: inject this test's mock URL into the global singleton + // BEFORE the app boots and calls connect(). + authController.debugConnectionOverride = StreamConnectionOverride( + baseURL: mockServer.httpUrl, + baseWsUrl: mockServer.wsUrl, + jwtFromMockServer: init is JwtActivity, // token provider โ†’ /jwt/get + ); + + await $.pumpWidgetAndSettle(const StreamChatSampleApp()); + await grantAppPermissions($); // notifications, media + } + + // App-side reset (Patrol shares one process across bundled tests). + // debugReset() clears secure storage, disposes the client, and clears the + // connection override. + Future tearDown() async { + await authController.debugReset(); + await mockServer.stop(); + } +} +``` +`InitActivity` ports the Android `InitTestActivity` sealed class +(`UserLogin` / `Jwt(baseUrl)`). + +### 5.8 Tests (`tests/*.dart`) +Port the 11 Android suites. Use Patrol's `patrolTest` + an Allure-style `step()` +helper to keep the GIVEN/WHEN/THEN structure and Allure IDs. + +Android: +```kotlin +@AllureId("5675") +@Test fun test_addsReaction() { + step("GIVEN user opens the channel") { userRobot.login().openChannel() } + step("WHEN user sends the message") { userRobot.sendMessage(sampleText) } + step("AND user adds the reaction") { userRobot.addReaction(type = ReactionType.LIKE) } + step("THEN the reaction is added") { userRobot.assertReaction(ReactionType.LIKE, isDisplayed = true) } +} +``` +Dart (mimicked DSL): +```dart +patrolTest('test_addsReaction', tags: ['AllureId:5675'], ($) async { + final env = StreamTestEnv(); + await env.setUp($, testName: 'test_addsReaction'); + addTearDown(env.tearDown); + + await step('GIVEN user opens the channel', () async { await env.userRobot.login(); await env.userRobot.openChannel(); }); + await step('WHEN user sends the message', () async => env.userRobot.sendMessage(sampleText)); + await step('AND user adds the reaction', () async => env.userRobot.addReaction(ReactionType.like)); + await step('THEN the reaction is added', () async => env.userRobot.assertReaction(ReactionType.like, isDisplayed: true)); +}); +``` + +### 5.9 Retry + Allure (`e2e/support/`) +- **Retry:** Android's `RetryRule(3)` retries failures, clears DB, and captures + artifacts. Port as a wrapper around `patrolTest` (or use `flutter test`'s + `retry:` arg / a custom loop). On failure capture: screenshot + (`$.native.takeScreenshot` / `binding.takeScreenshot`), the widget tree dump, + and logs; attach to Allure. +- **Allure:** add `allure.properties`, emit results in the + `allure-results` format. `step(name, body)` records a step + timing; on + teardown write the `*-result.json`. Reuse the Fastlane `Allurefile` lanes + (`allure_upload`, `allure_launch`) unchanged โ€” they're tool-agnostic. + +--- + +## 6. Production-code changes required in `sample_app` / `stream_chat_flutter` + +1. **Connection-override seam (Option A).** Add a nullable + `debugConnectionOverride` (a tiny `StreamConnectionOverride` holding + `baseURL`/`baseWsUrl`) to `AuthController`, and have + `auth_controller.dart::_buildStreamChatClient` read it (replacing the + hardcoded-commented lines ~54โ€“55). Null in production โ†’ SDK default base URL, + so the shipped diff is inert. The Patrol test sets + `authController.debugConnectionOverride` before `pumpWidget`. +2. **App-side reset for bundled tests.** Because Patrol shares one app process + and `authController` is a process-wide singleton that keeps `_client` alive + and auto-connects from `FlutterSecureStorage`, add a test-only reset path + used in `tearDown`: clear secure storage, `dispose()`/null the client, and + clear `debugConnectionOverride`. (Mirrors Android's per-test + `clearPackageData true`.) Server-side state is already fresh per test via the + driver โ€” this only covers app-side leakage. +3. **Guard boot-path side effects under e2e.** When `debugConnectionOverride` is + set, skip/stub the side effects that assume a full native launch: + `NotificationService.initialize()` (in `app.dart` `initState`), push device + registration (`PushTokenManager`), and `FirebaseCrashlytics.recordError` (in + `_sampleAppLogHandler`). Otherwise pumping the root widget without Firebase + configured can throw. +4. **JWT via mock server (auth suite only).** When the e2e override indicates a + mock JWT flow, make the token provider fetch from `GET /jwt/get?platform=flutter`. +5. **Widget `Key`s / semantics identifiers.** Add `Stream_*` keys to the widgets + the page objects target (composer input/send, message cell/text, reactions, + channel list tile, avatar, headers, context-menu items, thread items, scroll + buttons, login button, JWT/connection screen). This touches + `stream_chat_flutter` components and some `sample_app` screens. +6. **Android cleartext for e2e build.** Add a debug `network_security_config` + permitting cleartext to `10.0.2.2` (mock server is HTTP/WS). +7. **iOS ATS exception** for `localhost` HTTP in the test build (Info.plist + `NSAppTransportSecurity` / `NSAllowsLocalNetworking`). + +--- + +## 7. Tooling: Gemfile + Fastlane + Patrol CLI + +### 7.1 Gemfile (root of repo or `sample_app/`) +Port the Android root `Gemfile`, dropping Sinatra deps (the mock server has its +own `Gemfile`): +```ruby +source 'https://rubygems.org' +gem 'fastlane', '2.225.0' +gem 'json' +gem 'rubocop', '1.38', group: :rubocop_dependencies +eval_gemfile('fastlane/Pluginfile') # include allure-testops plugin +``` +The mock server's own gems (`sinatra`, `puma`, `faye-websocket`, `eventmachine`, +`rackup`) come from cloning `stream-chat-test-mock-server` (its Gemfile). + +### 7.2 Fastlane lanes (port `Fastfile` + `Allurefile`) +New lanes under `sample_app/fastlane/` (or root), composing existing build lanes: +- `start_mock_server(local_server:, branch:)` โ€” clone (or use local) + `stream-chat-test-mock-server`, `bundle install`, run `ruby driver.rb 4567 &`, + log to `logs/`. (Port from Android `start_mock_server`; Swift uses port 4566 โ€” + pick one, e.g. **4567**, and keep it consistent in `MockServer._driverPort`.) +- `stop_mock_server` โ€” `GET http://localhost:4567/stop`. +- `build_e2e_test` โ€” `patrol build android` / `patrol build ios` (or + `flutter build` of the integration test target). Gate with the Android-style + `is_check_required` if desired. +- `run_e2e_test(batch:, batch_count:, device:, local_server:, mock_server_branch:)`: + 1. `start_mock_server` + 2. grant permissions / boot emulator-simulator (Patrol handles app perms; OS + setup via `adb`/`xcrun simctl` as on Android) + 3. `patrol test --target integration_test/tests/<...> [--dart-define ...]` + (Patrol drives both platforms; supports test sharding for batching) + 4. collect `allure-results` + 5. `stop_mock_server` +- `build_and_run_e2e_test` โ€” convenience wrapper (port of Android lane). +- Reuse `Allurefile` lanes verbatim: `allure_launch`, `allure_upload`, + `allure_start_regression`. + +### 7.3 Patrol CLI +Add `patrol_cli` as a tool dependency; `patrol` + `integration_test` as +`dev_dependencies` in `sample_app/pubspec.yaml`. Create `patrol.yaml` with the +app id (`io.getstream.chat.android.flutter.sample` / `io.getstream.flutter`) and +test target config. + +--- + +## 8. CI (GitHub Actions) + +Add an `e2e` job (new workflow `e2e_test.yml`, or a job in +`stream_flutter_workflow.yml`), modeled on the Android pipeline: +- Matrix over `{android-emulator, ios-simulator}` and optional test batches. +- Steps: checkout โ†’ setup Ruby + `bundle install` โ†’ setup Flutter + `melos bootstrap` + โ†’ boot emulator/simulator โ†’ `bundle exec fastlane run_e2e_test` (which starts + the mock server, runs Patrol, gathers Allure results) โ†’ `allure_upload`. +- Use `allure_launch` to create the TestOps launch and pass `LAUNCH_ID` between + jobs (as Android does). +- Keep it off the default PR path initially (manual / nightly / label-gated) to + avoid flakiness blocking PRs, matching how heavy e2e usually runs. + +--- + +## 9. melos wiring +- Add an `e2e` script group in `melos.yaml`, e.g. + `e2e:build`, `e2e:run` that `cd sample_app && patrol ...`, so it's invokable + like the existing `test:*` scripts. +- Ensure `sample_app` `integration_test/` doesn't get swept into the regular + `test:flutter` melos filter (it targets `dirExists: test`, so + `integration_test/` is naturally excluded โ€” verify). + +--- + +## 10. Phased implementation + +1. **Foundations** + - Add Patrol + integration_test deps, `patrol.yaml`, `test_driver/`. + - Add the connection-options test seam in `sample_app` (Option A) and verify a + trivial Patrol test boots the app pointed at a manually-started mock server. +2. **Mock server client + robots** + - Port `MockServer`, `data_types.dart`, `BackendRobot`, `ParticipantRobot`. + - Validate against a locally running `driver.rb` (channels generate, participant + sends a message that appears in the app). +3. **Selectors** + - Add `Stream_*` `Key`s to widgets; build the page object files. +4. **DSL + base** + - Port the Patrol-ext helpers, `UserRobot` + assertion extensions, + `StreamTestEnv`/`StreamTestCase`, `step()`/Allure, retry harness. +5. **Tests** + - Port suites in order of value: `MessageListTests`, `ReactionsTests`, + `QuotedReplyTests`, `ChannelListTests`, then `Attachments`, `Giphy`, + `Drafts`, `DeliveryStatus`, `HyperLinks`, `Auth`, `Backend`. +6. **Tooling + CI** + - Gemfile, Fastlane lanes, Allure config, GitHub Actions job; get green on + both an Android emulator and an iOS simulator. + +--- + +## 11. Risks / open questions +- **Selector coverage** is the biggest effort: every Android `Stream_*` testTag + needs a Flutter `Key`/semantics equivalent; some widgets may need upstream + changes in `stream_chat_flutter`. +- **Async robots:** Dart's async HTTP means `BackendRobot`/`ParticipantRobot` + methods are `Future`s โ€” slightly less terse than Kotlin's blocking chains. +- **Connectivity toggling** (offline/online for the auth/token tests) depends on + Patrol's native automator support per platform; verify wifi/airplane toggles + work on both iOS simulator and Android emulator (Android emulator can toggle; + iOS simulator network toggling is more limited โ€” may need a host-level toggle + or to mock via the server's `freeze/delay` endpoints instead). +- **Driver port โ€” one Flutter port, distinct from the native repos.** The port + is just the CLI arg to `driver.rb`, so we're free to choose. Flutter uses a + single port shared by both its platforms: + + | Suite | Host | Driver port | + |---|---|---| + | stream-chat-android (native) | `10.0.2.2` | 4567 *(existing)* | + | stream-chat-swift (native) | `localhost` | 4566 *(existing)* | + | **Flutter (iOS + Android)** | `10.0.2.2` / `localhost` | **4568** | + + - Distinct from 4566/4567 โ†’ Flutter e2e never clashes with the native repos if + a developer runs them together. + - Flutter-iOS and Flutter-Android **share** 4568: in CI they're separate + matrix jobs on separate runners, so they never share a host. Note the + `10.0.2.2`/`localhost` split does *not* prevent a collision if both ran on + one host โ€” both drivers bind the host's port โ€” so for the rare local case of + running both platforms at once, override via `--dart-define=MOCK_DRIVER_PORT=...` + (and pass the matching port to `driver.rb` in that run). + - Keep `MockServer._driverPort` and the Fastlane lanes in sync on `4568`. +- **Cleartext/ATS:** must be enabled only for the e2e build flavor, not release. +- **JWT flow** for the auth suite requires the token provider to call the mock + server โ€” confirm the sample app's auth path can be redirected in test mode. + +--- + +## 12. Implementation checklist + +Work top-to-bottom. Each phase ends in something runnable/verifiable so we never +go more than a step or two without feedback. Decision is **Option A** (ยง3). + +### Phase 0 โ€” Spike: prove the seam end-to-end +- [x] Add a `Gemfile` (fastlane) + Fastlane skeleton, and port the + `start_mock_server` / `stop_mock_server` lanes from the native repos' + Fastfiles (clone-or-local mock-server repo โ†’ `bundle install` โ†’ launch + `ruby driver.rb 4568` in the background โ†’ `/stop` on teardown). + โ†’ Done in `sample_app/Fastfile` (shared, imported by both platform Fastfiles). + Verified end-to-end against the real driver on 4568: `/start/:test` spawns a + per-test server that answers `/ping` 200 after ~0.6s; `/stop` tears it down. +- [x] Add `patrol` + `integration_test` to `sample_app/pubspec.yaml` dev deps (and `patrol` to melos.yaml); `melos bootstrap`. โ†’ patrol 4.6.1 resolved. `patrol_cli` is a global CLI (`dart pub global activate patrol_cli`, 4.4.0 used) โ€” not a package dep. +- [x] Add Patrol config as a `patrol:` section **in `pubspec.yaml`** with `test_directory: integration_test` and app ids `io.getstream.chat.android.flutter.sample` / `io.getstream.flutter`. +- [x] Add `StreamConnectionOverride` + `debugConnectionOverride` to `AuthController`; wire into `_buildStreamChatClient` (ยง6.1). Plus an `isE2eTestRun` flag guarding Firebase/Crashlytics, push registration, and notifications (ยง6.3 pulled forward โ€” required for in-process pumping). +- [x] Add platform host helper (`10.0.2.2` Android / `localhost` iOS). โ†’ in `integration_test/_spike/mock_server.dart`. +- [x] Write one throwaway `patrolTest` that starts a mock server, sets the override, pumps `StreamChatSampleApp`, asserts the channel list loads. โ†’ `integration_test/spike_seam_test.dart` (asserts `StreamChannelListHeader`). Analyzes clean. +- Connection config: **already satisfied** โ€” iOS `Info.plist` has `NSAllowsArbitraryLoads=true`; Android **debug** manifest has `usesCleartextTraffic="true"` (Patrol uses a debug build). ยง6.6/ยง6.7 need no work. +- [x] **Native Patrol test-target integration.** + - Android: `PatrolJUnitRunner` as `testInstrumentationRunner` + `ANDROIDX_TEST_ORCHESTRATOR` + `androidx.test:orchestrator` (`android/app/build.gradle`); `MainActivityTest.java` entry class under `android/app/src/androidTest/`. + - iOS: `RunnerUITests` UI-testing target added to `Runner.xcodeproj` via the `xcodeproj` gem (script `ios/add_patrol_target.rb`), `RunnerUITests/RunnerUITests.m` bridge (`PATROL_INTEGRATION_TEST_IOS_RUNNER`), and the `target 'RunnerUITests'` block in the Podfile. +- [x] Run on an Android emulator. **โœ… PASS** โ€” `patrol test` built, installed, ran the instrumentation, and the spike connected to the mock server and rendered the channel list. (Fix: placeholder token must be a structurally-valid JWT โ€” reused qatest1's.) +- [x] Run on an iOS simulator. **โœ… PASS** โ€” `TEST EXECUTE SUCCEEDED`, same spike. Two scripted-target gaps fixed (now baked into `ios/add_patrol_target.rb`): UI test bundle needs `GENERATE_INFOPLIST_FILE = YES` and `PRODUCT_NAME = $(TARGET_NAME)` (else the nameless `.xctest` collides as "Multiple commands produce โ€ฆ/PlugIns/.xctest"). +- **โœ… Phase 0 gate met: the Option A seam is proven on both Android emulator and iOS simulator.** Connection config (ยง6.6/ยง6.7) already satisfied โ€” iOS `NSAllowsArbitraryLoads`, Android debug `usesCleartextTraffic`. +- โš ๏ธ **Toolchain note:** system Java is 26.0.1, which Gradle 8.13 rejects. Android builds need a supported JDK โ€” set `JAVA_HOME=/opt/homebrew/opt/openjdk@21/...` (or `flutter config --jdk-dir`). Record this for CI. + +### Phase 1 โ€” Foundations & app-side plumbing +- Directory layout (ยง4): not pre-created โ€” folders form as files land in Phase 2+ (empty dirs aren't tracked by git and add nothing). +- [x] Implement `AuthController.debugReset()` (clear secure storage, dispose/null client, clear api key + push manager + connection override, back to `Unauthenticated`) for per-test teardown (ยง6.2). Wired into the spike's teardown. +- [x] Guard boot-path side effects under the e2e flag (`isE2eTestRun`): `NotificationService.initialize()`, `PushTokenManager`, `FirebaseCrashlytics.recordError` (ยง6.3). *(Done in Phase 0.)* +- [x] Android cleartext (ยง6.6) โ€” already satisfied by the debug manifest's `usesCleartextTraffic="true"`. +- [x] iOS ATS (ยง6.7) โ€” already satisfied by `NSAllowsArbitraryLoads`. +- [x] **Gate:** two tests in one bundle both pass on Android โ€” each starts its own mock server on a fresh port and connects, with `debugReset()` wiping client/credentials/override in between. Proves per-test isolation (run 2 would fail on a leaked client/override otherwise). + +### Phase 2 โ€” Mock server client + control robots +> Layout note: shared helpers live under `integration_test/` (`mock_server/`, +> `robots/`), not a sibling `e2e/` dir โ€” Dart has no module boundary to justify +> the split, and Patrol only bundles `*_test.dart`, so non-test helpers there are +> safe. +- [x] Port `data_types.dart` (`AttachmentType`, `ReactionType`, `MessageDeliveryStatus`, `forbiddenWord`). +- [x] Implement `MockServer` (driver `/start/:test`, dynamic port, per-platform host, `get`/`post`, `stop` โ†’ per-test server's `/stop`, `waitUntilReady`). +- [x] Port `BackendRobot` (generateChannels, fail/freeze messages, JWT revoke/invalidate/break). +- [x] Port `ParticipantRobot` (message/thread/edit/delete/quote, reactions, typing, read, giphy, attachments). +- [x] **Gate (scoped to robot layer):** `BackendRobot.generateChannels(...)` โ†’ the connected app queries the mock server and renders a `StreamChannelListTile` (validated on Android, two runs). `ParticipantRobot` is a faithful verbatim port; asserting a participant message *renders* needs the channel open + message-cell selectors, so that assertion moves to Phase 4's message-list tests (validating it earlier via the channel-list preview proved brittle). + +### Phase 3 โ€” Selectors +> Approach (revised): **reuse identifiers the SDK already exposes โ€” exported +> widget types and existing keys โ€” before adding anything to the source.** Add a +> `Stream_*` key only when no stable identifier exists, and only for a widget a +> landing suite actually needs (no speculative keys). Goal: as few source changes +> as possible. +- [x] Composer selectors with **zero SDK changes**: input field matched by its + exported type `StreamMessageComposerInputField`; send button by the key the + SDK already sets for its `AnimatedSwitcher` (`ValueKey('send_key')`). (An + earlier pass added `Stream_*` keys; reverted once existing identifiers + proved sufficient.) +- [x] PageObjects `pages/message_list_page.dart` (`composer.inputField` โ†’ type, `composer.sendButton` โ†’ `send_key`) and `pages/channel_list_page.dart` (channel tile โ†’ `StreamChannelListTile` type). +- [x] **Gate:** test navigates channel list โ†’ opens channel โ†’ `enterText` into the composer field (by type) โ†’ taps the send button (by existing key) โ†’ the sent message renders. โœ… on Android (`integration_test/message_list_test.dart`). +- Remaining selectors resolved per-suite in Phase 4/5: existing type/key first; add a `Stream_*` key only where the SDK exposes nothing stable (e.g. one message cell among many). + +### Phase 4 โ€” DSL, robots & base +> Lean scope: **no `patrol_ext` layer** โ€” Patrol's `$` already provides +> `waitUntilVisible`/`tap`/`enterText`/`scrollTo`/`at` (the Android `uiautomator` +> layer only existed because UIAutomator was low-level). `retry` + Allure wiring +> deferred to Phase 6; `InitActivity`/jwt deferred to the auth suite (Phase 5). +- [x] `support/predefined_users.dart` (`UserCredentials` + `PredefinedUsers.qaTest1`) and `support/step.dart` (BDD `step()`; Allure hook point for Phase 5). +- [x] `UserRobot` (`login` via the real choose-user UI, `openChannel`, `sendMessage`) + `user_robot_message_list_asserts.dart` (`assertMessage`). More actions/asserts added per-suite in Phase 5. +- [x] `StreamTestEnv` (`support/stream_test_env.dart`) โ€” `setUp` boots the app pointed at the mock server + owns the robots; `tearDown` = `debugReset` + `mockServer.stop`. +- [x] Removed the throwaway `spike_seam_test.dart` (the DSL test covers the flow; `debugReset` isolation runs in every teardown). +- [x] **Gate:** `message_list_test.dart` ported to the full DSL (`StreamTestEnv` + `UserRobot` + `step()`) passes on **both platforms** โœ… (Android + iOS). (Reactions test deferred to Phase 5 โ€” needs reaction/context-menu selectors.) + +### Phase 5 โ€” Tooling, Allure & CI +- [x] Fastlane lanes `run_e2e_test` / `build_e2e_test` / `build_and_run_e2e_test` (compose `start_mock_server`/`stop_mock_server`). `run_e2e_test` **validated locally on Android** end-to-end (start mock server โ†’ `patrol test` โ†’ stop). Fixed a real bug: the mock-server `bundle install`/driver must run under `Bundler.with_unbundled_env`, else the parent `bundle exec fastlane` context breaks it. +- [x] melos `e2e:run` script; confirmed `integration_test/` is excluded from `test:flutter` (`flutter test` only runs `test/`). +- [x] Single Flutter driver port `4568` used by `MockServer._driverPort` and the lanes (ยง11). +- [x] `sources_matrix` lane + `is_check_required` gating on `run_e2e_test`/`build_e2e_test` (iOS-style, via `fastlane-plugin-stream_actions`): the e2e lanes self-skip unless `sample_app`, `packages`, `melos.yaml`, or the e2e workflows changed (`@force_check` overrides). +- [x] GitHub Actions โ€” two workflows, mirroring the native repos' split: + - `e2e_test.yml` โ€” PR (label `e2e`) + manual `workflow_dispatch`. android + ios jobs; JDK 21; clones mock server; runs `run_e2e_test` + `allure_upload`; uploads logs. + - `e2e_test_cron.yml` โ€” **separate nightly** (weeknights 01:00 UTC) + manual. Broader matrix (Android API 34/31/28; iOS 16/15), per-job `allure_launch` โ†’ `run_e2e_test` โ†’ `allure_upload` (shared launch via `ALLURE_LAUNCH_ID`) โ†’ `allure_launch_removal` on cancel, plus a Slack-on-failure job. Guarded to the canonical repo. + - Both YAML-validated; not runnable here (no GH runner). Scheduled workflows only fire once on the default branch. +- [x] **Allure results producer โ€” built & validated on both platforms.** A Dart reporter (`integration_test/allure/allure.dart`) hooked into `step()` and a `streamTest()` wrapper produces standard Allure 2 result JSON (uuid, historyId, name, fullName, status, start/stop, nested steps). Captures `passed`/`failed`/`broken`. Since on-device files don't survive Android scoped-storage / `clearPackageData`, and Dart `print` doesn't reach patrol's stdout, the reporter emits **chunked `ALLURE-RESULT::` markers** (sub-1024 to dodge log truncation); `collect_allure_results` reassembles them from the device log โ€” `adb logcat` (Android) / `xcrun simctl โ€ฆ log show` (iOS) โ€” into `allure-results/`. Validated end-to-end via `run_e2e_test` on **Android and iOS**: green test โ†’ `allure-results/-result.json` with the 3 GIVEN/WHEN/THEN steps; a failing run produced a `broken` result. +- Dropped the Android test orchestrator + `clearPackageData` from `build.gradle` (added in Phase 3) โ€” they wiped the app's storage and aren't needed (isolation is in-process via `debugReset`). +- [x] **Allure upload โ€” built & validated against live TestOps.** `allure_upload` / `allure_launch` / `install_allurectl` lanes drive `allurectl` (endpoint `streamio.testops.cloud`, project `135`, `ALLURE_TOKEN` from env). Verified real uploads (launches 276343 Android-results, 276345 iOS-results). Wired into CI (`allure_upload` step on both jobs, `if: always()`, `ALLURE_TOKEN` from `secrets`). +- **Gate:** e2e suite runs green on both platforms via `run_e2e_test`, produces valid Allure results on both, and uploads to TestOps โ€” all validated locally. Only the GitHub-Actions run itself is unverified here (needs `ALLURE_TOKEN` added as a repo secret + a workflow trigger). + +### Phase 6 โ€” Port the test suites +Port in value order; each is a checkpoint: +- [ ] `MessageListTests` +- [ ] `ReactionsTests` +- [ ] `QuotedReplyTests` +- [ ] `ChannelListTests` +- [ ] `AttachmentsTests` +- [ ] `GiphyTests` +- [ ] `DraftMessagesTests` +- [ ] `MessageDeliveryStatusTests` +- [ ] `HyperLinksTests` +- [ ] `AuthTests` (needs JWT-from-mock-server + connectivity toggling) +- [ ] `BackendTests` +- [ ] **Gate:** full suite green locally on Android emulator + iOS simulator. \ No newline at end of file diff --git a/sample_app/integration_test/allure/allure.dart b/sample_app/integration_test/allure/allure.dart new file mode 100644 index 0000000000..beeb8988f2 --- /dev/null +++ b/sample_app/integration_test/allure/allure.dart @@ -0,0 +1,140 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart' show TestFailure; +import 'package:uuid/uuid.dart'; + +const allureResultMarker = 'ALLURE-RESULT::'; +const _chunkSize = 900; + +enum AllureStatus { passed, failed, broken, skipped } + +class _Step { + _Step(this.name, this.start); + + final String name; + final int start; + int? stop; + AllureStatus status = AllureStatus.passed; + Map? statusDetails; + final List<_Step> steps = []; + + Map toJson() => { + 'name': name, + 'status': status.name, + if (statusDetails != null) 'statusDetails': statusDetails, + 'stage': 'finished', + 'start': start, + 'stop': stop, + 'steps': [for (final s in steps) s.toJson()], + }; +} + +class _Result { + _Result({ + required this.uuid, + required this.name, + required this.fullName, + required this.start, + required this.labels, + }); + + final String uuid; + final String name; + final String fullName; + final int start; + int? stop; + AllureStatus status = AllureStatus.passed; + Map? statusDetails; + final List> labels; + final List<_Step> steps = []; + + Map toJson() => { + 'uuid': uuid, + 'historyId': base64.encode(utf8.encode(fullName)), + 'name': name, + 'fullName': fullName, + 'status': status.name, + if (statusDetails != null) 'statusDetails': statusDetails, + 'stage': 'finished', + 'start': start, + 'stop': stop, + 'labels': labels, + 'steps': [for (final s in steps) s.toJson()], + }; +} + +class Allure { + Allure._(); + + static final Allure instance = Allure._(); + + _Result? _result; + final List<_Step> _stepStack = []; + + int get _now => DateTime.now().millisecondsSinceEpoch; + + void startTest({ + required String name, + required String fullName, + Map labels = const {}, + }) { + _stepStack.clear(); + _result = _Result( + uuid: const Uuid().v4(), + name: name, + fullName: fullName, + start: _now, + labels: [ + for (final entry in labels.entries) {'name': entry.key, 'value': entry.value}, + ], + ); + } + + Future step(String name, Future Function() body) async { + final step = _Step(name, _now); + (_stepStack.isNotEmpty ? _stepStack.last.steps : _result?.steps)?.add(step); + _stepStack.add(step); + try { + return await body(); + } on TestFailure catch (e) { + step + ..status = AllureStatus.failed + ..statusDetails = {'message': '$e'}; + rethrow; + } catch (e) { + step + ..status = AllureStatus.broken + ..statusDetails = {'message': '$e'}; + rethrow; + } finally { + step.stop = _now; + _stepStack.removeLast(); + } + } + + void stopTest({ + required AllureStatus status, + Object? message, + StackTrace? trace, + }) { + final result = _result; + if (result == null) return; + result + ..status = status + ..stop = _now; + if (message != null) { + result.statusDetails = { + 'message': '$message', + if (trace != null) 'trace': '$trace', + }; + } + + final encoded = base64.encode(utf8.encode(jsonEncode(result.toJson()))); + for (var i = 0, seq = 0; i < encoded.length; i += _chunkSize, seq++) { + final chunk = encoded.substring(i, min(i + _chunkSize, encoded.length)); + print('$allureResultMarker${result.uuid}:$seq:$chunk'); + } + _result = null; + } +} diff --git a/sample_app/integration_test/message_list_test.dart b/sample_app/integration_test/message_list_test.dart new file mode 100644 index 0000000000..14c713e2be --- /dev/null +++ b/sample_app/integration_test/message_list_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'robots/user_robot_message_list_asserts.dart'; +import 'support/step.dart'; +import 'support/stream_test.dart'; +import 'support/stream_test_env.dart'; + +void main() { + const sampleText = 'Test'; + + streamTest('message list updates when the user sends a message', ($) async { + final env = StreamTestEnv(); + await env.setUp($); + addTearDown(env.tearDown); + + await step('GIVEN the user opens a channel', () async { + await env.backendRobot.generateChannels(channelsCount: 1); + await env.userRobot.login(); + await env.userRobot.openChannel(); + }); + + await step('WHEN the user sends a message', () async { + await env.userRobot.sendMessage(sampleText); + }); + + await step('THEN the message is displayed', () async { + await env.userRobot.assertMessage(sampleText); + }); + }); +} diff --git a/sample_app/integration_test/mock_server/data_types.dart b/sample_app/integration_test/mock_server/data_types.dart new file mode 100644 index 0000000000..447662cb06 --- /dev/null +++ b/sample_app/integration_test/mock_server/data_types.dart @@ -0,0 +1,25 @@ +enum AttachmentType { + image('image'), + video('video'), + file('file'); + + const AttachmentType(this.attachment); + + final String attachment; +} + +enum ReactionType { + love('love'), + lol('haha'), + wow('wow'), + sad('sad'), + like('like'); + + const ReactionType(this.reaction); + + final String reaction; +} + +enum MessageDeliveryStatus { read, pending, sent, failed, nil } + +const forbiddenWord = 'wth'; diff --git a/sample_app/integration_test/mock_server/mock_server.dart b/sample_app/integration_test/mock_server/mock_server.dart new file mode 100644 index 0000000000..2a4579e30a --- /dev/null +++ b/sample_app/integration_test/mock_server/mock_server.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; +import 'dart:io'; + +// ignore: implementation_imports +import 'package:test_api/src/backend/invoker.dart' show Invoker; + +class MockServer { + MockServer._(this.url, this.wsUrl); + + final String url; + final String wsUrl; + + static String get _host => Platform.isAndroid ? '10.0.2.2' : 'localhost'; + + static const _driverPort = + String.fromEnvironment('MOCK_DRIVER_PORT', defaultValue: '4568'); + + static Future start({String? testName}) async { + final name = testName ?? _currentTestName(); + final driverUrl = 'http://$_host:$_driverPort'; + final port = (await _get('$driverUrl/start/$name')).trim(); + final server = MockServer._('http://$_host:$port', 'ws://$_host:$port'); + await server.waitUntilReady(); + return server; + } + + static String _currentTestName() { + final name = Invoker.current?.liveTest.test.name ?? 'flutter_test'; + return name.replaceAll(RegExp('[^A-Za-z0-9_]+'), '_'); + } + + Future stop() => _get('$url/stop').catchError((_) => ''); + + Future post(String endpoint, {String? body}) async { + final client = HttpClient(); + try { + final req = await client.postUrl(Uri.parse('$url/$endpoint')); + if (body != null) { + req.headers.contentType = ContentType.text; + req.write(body); + } + final res = await req.close(); + await res.drain(); + } finally { + client.close(force: true); + } + } + + Future get(String endpoint) => _get('$url/$endpoint'); + + Future waitUntilReady({ + Duration timeout = const Duration(seconds: 15), + }) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + final ready = await _statusCode('$url/ping') + .then((code) => code == 200) + .catchError((Object _) => false); + if (ready) return; + await Future.delayed(const Duration(milliseconds: 250)); + } + throw StateError('Mock server at $url did not become ready in $timeout'); + } + + static Future _get(String url) async { + final client = HttpClient(); + try { + final req = await client.getUrl(Uri.parse(url)); + final res = await req.close(); + return res.transform(utf8.decoder).join(); + } finally { + client.close(force: true); + } + } + + static Future _statusCode(String url) async { + final client = HttpClient(); + try { + final req = await client.getUrl(Uri.parse(url)); + final res = await req.close(); + await res.drain(); + return res.statusCode; + } finally { + client.close(force: true); + } + } +} diff --git a/sample_app/integration_test/pages/channel_list_page.dart b/sample_app/integration_test/pages/channel_list_page.dart new file mode 100644 index 0000000000..90a1e861de --- /dev/null +++ b/sample_app/integration_test/pages/channel_list_page.dart @@ -0,0 +1,5 @@ +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +abstract final class ChannelListPage { + static const Type channelTile = StreamChannelListTile; +} diff --git a/sample_app/integration_test/pages/message_list_page.dart b/sample_app/integration_test/pages/message_list_page.dart new file mode 100644 index 0000000000..ba3ab3f5fd --- /dev/null +++ b/sample_app/integration_test/pages/message_list_page.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +abstract final class MessageListPage { + static const composer = _Composer(); +} + +final class _Composer { + const _Composer(); + + Type get inputField => StreamMessageComposerInputField; + Key get sendButton => const ValueKey('send_key'); +} diff --git a/sample_app/integration_test/robots/backend_robot.dart b/sample_app/integration_test/robots/backend_robot.dart new file mode 100644 index 0000000000..07c86e1ded --- /dev/null +++ b/sample_app/integration_test/robots/backend_robot.dart @@ -0,0 +1,54 @@ +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=$messagesText&' : ''; + final repliesTextParam = + repliesText != null ? 'replies_text=$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/participant_robot.dart b/sample_app/integration_test/robots/participant_robot.dart new file mode 100644 index 0000000000..4250467cfe --- /dev/null +++ b/sample_app/integration_test/robots/participant_robot.dart @@ -0,0 +1,176 @@ +import '../mock_server/data_types.dart'; +import '../mock_server/mock_server.dart'; + +class ParticipantRobot { + ParticipantRobot(this._mockServer); + + final MockServer _mockServer; + + static const name = 'Count Dooku'; + + Future startTyping() async { + await _mockServer.post('participant/typing/start'); + return this; + } + + Future startTypingInThread() async { + await _mockServer.post('participant/typing/start?thread=true'); + return this; + } + + Future stopTyping() async { + await _mockServer.post('participant/typing/stop'); + return this; + } + + Future stopTypingInThread() async { + await _mockServer.post('participant/typing/stop?thread=true'); + return this; + } + + Future readMessage() async { + await _mockServer.post('participant/read'); + 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); + return this; + } + + Future sendMessageInThread( + String text, { + bool alsoSendInChannel = false, + }) async { + await _mockServer.post( + 'participant/message?thread=true&thread_and_channel=$alsoSendInChannel', + body: text, + ); + return this; + } + + Future editMessage(String text) async { + await _mockServer.post('participant/message?action=edit', body: text); + return this; + } + + Future deleteMessage({bool hard = false}) async { + await _mockServer.post('participant/message?action=delete&hard_delete=$hard'); + return this; + } + + Future quoteMessage(String text, {bool last = true}) async { + final quote = last ? 'quote_last=true' : 'quote_first=true'; + await _mockServer.post('participant/message?$quote', body: text); + return this; + } + + Future quoteMessageInThread( + String text, { + bool alsoSendInChannel = false, + bool last = true, + }) async { + final quote = last ? 'quote_last=true' : 'quote_first=true'; + await _mockServer.post( + 'participant/message?$quote&thread=true&thread_and_channel=$alsoSendInChannel', + body: text, + ); + return this; + } + + Future uploadGiphy() async { + await _mockServer.post('participant/message?giphy=true'); + return this; + } + + Future uploadGiphyInThread() async { + await _mockServer.post('participant/message?giphy=true&thread=true'); + return this; + } + + Future quoteMessageWithGiphy({bool last = true}) async { + final quote = last ? 'quote_last=true' : 'quote_first=true'; + await _mockServer.post('participant/message?giphy=true&$quote'); + return this; + } + + Future quoteMessageWithGiphyInThread({ + bool alsoSendInChannel = false, + bool last = true, + }) async { + final quote = last ? 'quote_last=true' : 'quote_first=true'; + await _mockServer.post( + 'participant/message?giphy=true&$quote&thread=true&thread_and_channel=$alsoSendInChannel', + ); + return this; + } + + Future pinMessage() async { + await _mockServer.post('participant/message?action=pin'); + return this; + } + + Future unpinMessage() async { + await _mockServer.post('participant/message?action=unpin'); + return this; + } + + Future uploadAttachment( + AttachmentType type, { + int count = 1, + }) async { + await _mockServer.post('participant/message?${type.attachment}=$count'); + return this; + } + + Future quoteMessageWithAttachment( + AttachmentType type, { + int count = 1, + bool last = true, + }) async { + final quote = last ? 'quote_last=true' : 'quote_first=true'; + await _mockServer.post( + 'participant/message?$quote&${type.attachment}=$count', + ); + return this; + } + + Future uploadAttachmentInThread( + AttachmentType type, { + int count = 1, + bool alsoSendInChannel = false, + }) async { + await _mockServer.post( + 'participant/message?${type.attachment}=$count&thread=true&thread_and_channel=$alsoSendInChannel', + ); + return this; + } + + Future quoteMessageWithAttachmentInThread( + AttachmentType type, { + int count = 1, + bool alsoSendInChannel = false, + bool last = true, + }) async { + final quote = last ? 'quote_last=true' : 'quote_first=true'; + await _mockServer.post( + 'participant/message?$quote&${type.attachment}=$count' + '&thread=true&thread_and_channel=$alsoSendInChannel', + ); + return this; + } + + Future addReaction(ReactionType type, {int delay = 0}) async { + var endpoint = 'participant/reaction?type=${type.reaction}'; + if (delay > 0) endpoint += '&delay=$delay'; + await _mockServer.post(endpoint); + return this; + } + + Future deleteReaction(ReactionType type) async { + await _mockServer.post('participant/reaction?type=${type.reaction}&delete=true'); + return this; + } +} diff --git a/sample_app/integration_test/robots/user_robot.dart b/sample_app/integration_test/robots/user_robot.dart new file mode 100644 index 0000000000..8fcc785fe1 --- /dev/null +++ b/sample_app/integration_test/robots/user_robot.dart @@ -0,0 +1,33 @@ +import 'package:patrol/patrol.dart'; + +import '../pages/channel_list_page.dart'; +import '../pages/message_list_page.dart'; +import '../support/predefined_users.dart'; + +class UserRobot { + UserRobot(this.$); + + final PatrolIntegrationTester $; + + Future login([ + UserCredentials user = PredefinedUsers.currentUser, + ]) async { + final entry = $(user.name); + await entry.scrollTo(); + await entry.tap(); + return this; + } + + Future openChannel({int index = 0}) async { + final tile = $(ChannelListPage.channelTile).at(index); + await tile.waitUntilVisible(); + await tile.tap(); + return this; + } + + Future sendMessage(String text) async { + await $(MessageListPage.composer.inputField).enterText(text); + await $(MessageListPage.composer.sendButton).tap(); + return this; + } +} 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 new file mode 100644 index 0000000000..0b0fa8fdef --- /dev/null +++ b/sample_app/integration_test/robots/user_robot_message_list_asserts.dart @@ -0,0 +1,8 @@ +import 'user_robot.dart'; + +extension UserRobotMessageListAsserts on UserRobot { + Future assertMessage(String text) async { + await $(text).waitUntilVisible(); + return this; + } +} diff --git a/sample_app/integration_test/support/predefined_users.dart b/sample_app/integration_test/support/predefined_users.dart new file mode 100644 index 0000000000..e4061127a0 --- /dev/null +++ b/sample_app/integration_test/support/predefined_users.dart @@ -0,0 +1,26 @@ +import 'package:sample_app/utils/app_config.dart'; + +class UserCredentials { + const UserCredentials({ + required this.id, + required this.name, + required this.token, + this.apiKey = kDefaultStreamApiKey, + }); + + final String id; + final String name; + final String token; + final String apiKey; +} + +abstract final class PredefinedUsers { + static const qaTest1 = UserCredentials( + id: 'qatest1', + name: 'QA test 1', + token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicWF0ZXN0MSJ9.fnelU7HcP7QoEEsCGteNlF1fppofzNlrnpDQuIgeKCU', + ); + + static const currentUser = qaTest1; +} diff --git a/sample_app/integration_test/support/step.dart b/sample_app/integration_test/support/step.dart new file mode 100644 index 0000000000..d37db502cb --- /dev/null +++ b/sample_app/integration_test/support/step.dart @@ -0,0 +1,4 @@ +import '../allure/allure.dart'; + +Future step(String description, Future Function() body) => + Allure.instance.step(description, body); diff --git a/sample_app/integration_test/support/stream_test.dart b/sample_app/integration_test/support/stream_test.dart new file mode 100644 index 0000000000..99c4c3cad0 --- /dev/null +++ b/sample_app/integration_test/support/stream_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; + +// ignore: implementation_imports +import 'package:test_api/src/backend/invoker.dart' show Invoker; + +import '../allure/allure.dart'; + +void streamTest( + String description, + Future Function(PatrolIntegrationTester $) callback, { + String? allureId, +}) { + patrolTest(description, ($) async { + Allure.instance.startTest( + name: description, + fullName: Invoker.current?.liveTest.test.name ?? description, + labels: {if (allureId != null) 'AS_ID': allureId}, + ); + try { + await callback($); + Allure.instance.stopTest(status: AllureStatus.passed); + } on TestFailure catch (e, st) { + Allure.instance.stopTest(status: AllureStatus.failed, message: e, trace: st); + rethrow; + } catch (e, st) { + Allure.instance.stopTest(status: AllureStatus.broken, message: e, trace: st); + rethrow; + } + }); +} diff --git a/sample_app/integration_test/support/stream_test_env.dart b/sample_app/integration_test/support/stream_test_env.dart new file mode 100644 index 0000000000..b43c2034bd --- /dev/null +++ b/sample_app/integration_test/support/stream_test_env.dart @@ -0,0 +1,34 @@ +import 'package:patrol/patrol.dart'; +import 'package:sample_app/app.dart'; +import 'package:sample_app/auth/auth_controller.dart'; + +import '../mock_server/mock_server.dart'; +import '../robots/backend_robot.dart'; +import '../robots/participant_robot.dart'; +import '../robots/user_robot.dart'; + +class StreamTestEnv { + late final MockServer mockServer; + late final BackendRobot backendRobot; + late final ParticipantRobot participantRobot; + late final UserRobot userRobot; + + Future setUp(PatrolIntegrationTester $) async { + mockServer = await MockServer.start(); + backendRobot = BackendRobot(mockServer); + participantRobot = ParticipantRobot(mockServer); + userRobot = UserRobot($); + + authController.debugConnectionOverride = StreamConnectionOverride( + baseURL: mockServer.url, + baseWsUrl: mockServer.wsUrl, + ); + + await $.pumpWidgetAndSettle(const StreamChatSampleApp()); + } + + Future tearDown() async { + await authController.debugReset(); + await mockServer.stop(); + } +} diff --git a/sample_app/ios/Gemfile.lock b/sample_app/ios/Gemfile.lock new file mode 100644 index 0000000000..ad8148083b --- /dev/null +++ b/sample_app/ios/Gemfile.lock @@ -0,0 +1,329 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + abbrev (0.1.2) + activesupport (7.2.3.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1, < 6) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + algoliasearch (1.27.5) + httpclient (~> 2.8, >= 2.8.3) + json (>= 1.5.1) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1253.0) + aws-sdk-core (3.249.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.128.0) + aws-sdk-core (~> 3, >= 3.248.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.224.0) + aws-sdk-core (~> 3, >= 3.248.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) + cocoapods (1.16.2) + addressable (~> 2.8) + claide (>= 1.0.2, < 2.0) + cocoapods-core (= 1.16.2) + cocoapods-deintegrate (>= 1.0.3, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) + cocoapods-plugins (>= 1.0.0, < 2.0) + cocoapods-search (>= 1.0.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) + cocoapods-try (>= 1.1.0, < 2.0) + colored2 (~> 3.1) + escape (~> 0.0.4) + fourflusher (>= 2.3.0, < 3.0) + gh_inspector (~> 1.0) + molinillo (~> 0.8.0) + nap (~> 1.0) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.27.0, < 2.0) + cocoapods-core (1.16.2) + activesupport (>= 5.0, < 8) + addressable (~> 2.8) + algoliasearch (~> 1.0) + concurrent-ruby (~> 1.1) + fuzzy_match (~> 2.0.4) + nap (~> 1.0) + netrc (~> 0.11) + public_suffix (~> 4.0) + typhoeus (~> 1.0) + cocoapods-deintegrate (1.0.5) + cocoapods-downloader (2.1) + cocoapods-plugins (1.0.0) + nap + cocoapods-search (1.0.1) + cocoapods-trunk (1.6.0) + nap (>= 0.8, < 2.0) + netrc (~> 0.11) + cocoapods-try (1.2.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + 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) + drb (2.2.3) + emoji_regex (3.2.3) + escape (0.0.4) + ethon (0.18.0) + ffi (>= 1.15.0) + logger + excon (0.112.0) + faraday (1.10.5) + 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.235.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) + addressable (>= 2.8, < 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, < 1.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.1.0, < 4) + logger (>= 1.6, < 2.0) + mini_magick (>= 4.9.4, < 5.0.0) + 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-firebase_app_distribution (1.0.0) + fastlane (>= 2.232.0) + google-apis-firebaseappdistribution_v1 (>= 0.9.0) + google-apis-firebaseappdistribution_v1alpha (>= 0.12.0) + fastlane-plugin-stream_actions (0.4.3) + xctest_list (= 1.2.1) + fastlane-sirp (1.1.0) + ffi (1.17.4-arm64-darwin) + fourflusher (2.3.1) + fuzzy_match (2.0.4) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.101.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-firebaseappdistribution_v1 (0.19.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-firebaseappdistribution_v1alpha (0.28.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-iamcredentials_v1 (0.27.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.62.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.8.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.6.0) + google-cloud-storage (1.60.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.16.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + i18n (1.14.8) + concurrent-ruby (~> 1.0) + jmespath (1.6.2) + json (2.19.5) + jwt (3.2.0) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + minitest (5.27.0) + molinillo (0.8.0) + multi_json (1.21.1) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + nap (1.1.0) + naturally (2.3.0) + netrc (0.11.0) + nkf (0.2.0) + optparse (0.8.1) + os (1.1.4) + ostruct (0.6.3) + plist (3.7.2) + public_suffix (4.0.7) + 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) + ruby-macho (2.5.1) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + securerandom (0.4.1) + security (0.1.5) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + typhoeus (1.6.0) + ethon (>= 0.18.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + 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 + arm64-darwin + +DEPENDENCIES + cocoapods + fastlane + fastlane-plugin-firebase_app_distribution + fastlane-plugin-stream_actions + multi_json + +BUNDLED WITH + 2.6.8 diff --git a/sample_app/ios/Podfile b/sample_app/ios/Podfile index f17bddc919..0710354985 100644 --- a/sample_app/ios/Podfile +++ b/sample_app/ios/Podfile @@ -31,6 +31,11 @@ target 'Runner' do use_frameworks! use_modular_headers! + # Patrol e2e UI test target. + target 'RunnerUITests' do + inherit! :complete + end + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end diff --git a/sample_app/ios/Runner.xcodeproj/project.pbxproj b/sample_app/ios/Runner.xcodeproj/project.pbxproj index 061be6260d..bb58caead7 100644 --- a/sample_app/ios/Runner.xcodeproj/project.pbxproj +++ b/sample_app/ios/Runner.xcodeproj/project.pbxproj @@ -11,13 +11,27 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 8210C58B8A5DAB805ACF46A1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4899F8CDC11D7148C6179369 /* Pods_Runner.framework */; }; 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 */; }; + 984969FB0FCC70C82B1D01CC /* Pods_Runner_RunnerUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2979FD476C8342D4BE16EDE3 /* Pods_Runner_RunnerUITests.framework */; }; + C313D84D1A521524E97C5A08 /* RunnerUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = 845BFD3A8BBE590E7BAE83AE /* RunnerUITests.m */; }; C3E4B5F67890A1B2C3D4E5F7 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E4B5F67890A1B2C3D4E5F6 /* SceneDelegate.swift */; }; + F06BB2D34B124C927609E541 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6B213AE2D543B7BADA38E33 /* Foundation.framework */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + EC56DE3EBD0AD91F86EB6C86 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 0BC14C55242B5A7A0028DE94 /* Embed App Extensions */ = { isa = PBXCopyFilesBuildPhase; @@ -45,13 +59,19 @@ 0BC14C5B242B5FF50028DE94 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 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 = ""; }; + 2979FD476C8342D4BE16EDE3 /* Pods_Runner_RunnerUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner_RunnerUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 3F56E62AADA13256B99B3FD0 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 484CBB9AEE4F8FC97D0F6A54 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 4899F8CDC11D7148C6179369 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5F71825255CA4DD327F745DE /* RunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 62748C3D21C600131729B6A7 /* Pods-Runner-RunnerUITests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner-RunnerUITests.profile.xcconfig"; path = "Target Support Files/Pods-Runner-RunnerUITests/Pods-Runner-RunnerUITests.profile.xcconfig"; 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 = ""; }; + 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 = ""; }; + 845BFD3A8BBE590E7BAE83AE /* RunnerUITests.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = RunnerUITests.m; path = RunnerUITests/RunnerUITests.m; sourceTree = ""; }; + 87DD5B8A5D8FFF81FBE78F7A /* Pods-Runner-RunnerUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner-RunnerUITests.release.xcconfig"; path = "Target Support Files/Pods-Runner-RunnerUITests/Pods-Runner-RunnerUITests.release.xcconfig"; sourceTree = ""; }; 90B43020AC538F68084BBAFD /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.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 = ""; }; @@ -60,8 +80,10 @@ 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 = ""; }; + A6B213AE2D543B7BADA38E33 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; C3E4B5F67890A1B2C3D4E5F6 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; DF7052F0ED7A0F0A6487676A /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + F1BB505AA8ADA090AF0076DE /* Pods-Runner-RunnerUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner-RunnerUITests.debug.xcconfig"; path = "Target Support Files/Pods-Runner-RunnerUITests/Pods-Runner-RunnerUITests.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -69,17 +91,45 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 8210C58B8A5DAB805ACF46A1 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; + C1289C2FFEC46190EEF8C3CE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F06BB2D34B124C927609E541 /* Foundation.framework in Frameworks */, + 984969FB0FCC70C82B1D01CC /* Pods_Runner_RunnerUITests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 7926AAA8A8EB00DB5D149360 /* RunnerUITests */ = { + isa = PBXGroup; + children = ( + 845BFD3A8BBE590E7BAE83AE /* RunnerUITests.m */, + ); + name = RunnerUITests; + sourceTree = SOURCE_ROOT; + }; + 848E793A104447E77BE01B37 /* iOS */ = { + isa = PBXGroup; + children = ( + A6B213AE2D543B7BADA38E33 /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; 9278E7173A28D18D316BCCAC /* Frameworks */ = { isa = PBXGroup; children = ( 4899F8CDC11D7148C6179369 /* Pods_Runner.framework */, + 848E793A104447E77BE01B37 /* iOS */, + 2979FD476C8342D4BE16EDE3 /* Pods_Runner_RunnerUITests.framework */, ); name = Frameworks; sourceTree = ""; @@ -87,6 +137,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -104,6 +155,7 @@ CF168B61BAB91958681C7C21 /* Pods */, DF7052F0ED7A0F0A6487676A /* GoogleService-Info.plist */, 9278E7173A28D18D316BCCAC /* Frameworks */, + 7926AAA8A8EB00DB5D149360 /* RunnerUITests */, ); sourceTree = ""; }; @@ -111,6 +163,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, + 5F71825255CA4DD327F745DE /* RunnerUITests.xctest */, ); name = Products; sourceTree = ""; @@ -146,6 +199,9 @@ 3F56E62AADA13256B99B3FD0 /* Pods-Runner.debug.xcconfig */, 484CBB9AEE4F8FC97D0F6A54 /* Pods-Runner.release.xcconfig */, 90B43020AC538F68084BBAFD /* Pods-Runner.profile.xcconfig */, + 87DD5B8A5D8FFF81FBE78F7A /* Pods-Runner-RunnerUITests.release.xcconfig */, + F1BB505AA8ADA090AF0076DE /* Pods-Runner-RunnerUITests.debug.xcconfig */, + 62748C3D21C600131729B6A7 /* Pods-Runner-RunnerUITests.profile.xcconfig */, ); path = Pods; sourceTree = ""; @@ -153,6 +209,26 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 29BAFE1A25301A6CE2A3BA97 /* RunnerUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8A84C2230750F0B728CA3023 /* Build configuration list for PBXNativeTarget "RunnerUITests" */; + buildPhases = ( + 3921E106080881E83E998BFB /* [CP] Check Pods Manifest.lock */, + 08E8A4ECA2C1C2F244E229AF /* Sources */, + C1289C2FFEC46190EEF8C3CE /* Frameworks */, + 3DCFC2F9E1881E1CDF31C6A9 /* Resources */, + BE63BFB6842CF1965047723E /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 7BBDFC82F80F89255BBCEC2F /* PBXTargetDependency */, + ); + name = RunnerUITests; + productName = RunnerUITests; + productReference = 5F71825255CA4DD327F745DE /* RunnerUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; @@ -166,13 +242,15 @@ 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 0BC14C55242B5A7A0028DE94 /* Embed App Extensions */, 7BD3737C65F59B233B7F7FC6 /* [CP] Embed Pods Frameworks */, - 78D972C66518C545847BBB5A /* [CP] Copy Pods Resources */, ); buildRules = ( ); dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -202,16 +280,27 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, + 29BAFE1A25301A6CE2A3BA97 /* RunnerUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 3DCFC2F9E1881E1CDF31C6A9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -227,39 +316,43 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + 3921E106080881E83E998BFB /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + ); inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( ); - name = "Thin Binary"; outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-RunnerUITests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; }; - 78D972C66518C545847BBB5A /* [CP] Copy Pods Resources */ = { + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh", - "${PODS_CONFIGURATION_BUILD_DIR}/firebase_messaging/firebase_messaging_Privacy.bundle", + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); - name = "[CP] Copy Pods Resources"; + name = "Thin Binary"; outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/firebase_messaging_Privacy.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 7BD3737C65F59B233B7F7FC6 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; @@ -268,95 +361,23 @@ ); inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/DKImagePickerController/DKImagePickerController.framework", - "${BUILT_PRODUCTS_DIR}/DKPhotoGallery/DKPhotoGallery.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseCore/FirebaseCore.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseCoreExtension/FirebaseCoreExtension.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseCoreInternal/FirebaseCoreInternal.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseCrashlytics/FirebaseCrashlytics.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseInstallations/FirebaseInstallations.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseMessaging/FirebaseMessaging.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseRemoteConfigInterop/FirebaseRemoteConfigInterop.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseSessions/FirebaseSessions.framework", - "${BUILT_PRODUCTS_DIR}/GoogleDataTransport/GoogleDataTransport.framework", - "${BUILT_PRODUCTS_DIR}/GoogleUtilities/GoogleUtilities.framework", - "${BUILT_PRODUCTS_DIR}/PromisesObjC/FBLPromises.framework", - "${BUILT_PRODUCTS_DIR}/PromisesSwift/Promises.framework", - "${BUILT_PRODUCTS_DIR}/SDWebImage/SDWebImage.framework", - "${BUILT_PRODUCTS_DIR}/SwiftyGif/SwiftyGif.framework", - "${BUILT_PRODUCTS_DIR}/audio_session/audio_session.framework", - "${BUILT_PRODUCTS_DIR}/connectivity_plus/connectivity_plus.framework", - "${BUILT_PRODUCTS_DIR}/device_info_plus/device_info_plus.framework", - "${BUILT_PRODUCTS_DIR}/file_picker/file_picker.framework", - "${BUILT_PRODUCTS_DIR}/file_selector_ios/file_selector_ios.framework", + "${BUILT_PRODUCTS_DIR}/CocoaAsyncSocket/CocoaAsyncSocket.framework", "${BUILT_PRODUCTS_DIR}/flutter_app_badger/flutter_app_badger.framework", - "${BUILT_PRODUCTS_DIR}/flutter_local_notifications/flutter_local_notifications.framework", "${BUILT_PRODUCTS_DIR}/flutter_secure_storage/flutter_secure_storage.framework", - "${BUILT_PRODUCTS_DIR}/gal/gal.framework", - "${BUILT_PRODUCTS_DIR}/geolocator_apple/geolocator_apple.framework", "${BUILT_PRODUCTS_DIR}/get_thumbnail_video/get_thumbnail_video.framework", - "${BUILT_PRODUCTS_DIR}/image_picker_ios/image_picker_ios.framework", - "${BUILT_PRODUCTS_DIR}/just_audio/just_audio.framework", "${BUILT_PRODUCTS_DIR}/libwebp/libwebp.framework", "${BUILT_PRODUCTS_DIR}/media_kit_video/media_kit_video.framework", - "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", - "${BUILT_PRODUCTS_DIR}/package_info_plus/package_info_plus.framework", - "${BUILT_PRODUCTS_DIR}/photo_manager/photo_manager.framework", - "${BUILT_PRODUCTS_DIR}/record_ios/record_ios.framework", - "${BUILT_PRODUCTS_DIR}/share_plus/share_plus.framework", - "${BUILT_PRODUCTS_DIR}/shared_preferences_foundation/shared_preferences_foundation.framework", - "${BUILT_PRODUCTS_DIR}/sqflite_darwin/sqflite_darwin.framework", - "${BUILT_PRODUCTS_DIR}/sqlite3/sqlite3.framework", - "${BUILT_PRODUCTS_DIR}/sqlite3_flutter_libs/sqlite3_flutter_libs.framework", - "${BUILT_PRODUCTS_DIR}/url_launcher_ios/url_launcher_ios.framework", - "${BUILT_PRODUCTS_DIR}/video_player_avfoundation/video_player_avfoundation.framework", - "${BUILT_PRODUCTS_DIR}/wakelock_plus/wakelock_plus.framework", + "${BUILT_PRODUCTS_DIR}/patrol/patrol.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKImagePickerController.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKPhotoGallery.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCore.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCoreExtension.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCoreInternal.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCrashlytics.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseInstallations.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseMessaging.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseRemoteConfigInterop.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseSessions.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleDataTransport.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleUtilities.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBLPromises.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Promises.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftyGif.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/audio_session.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/connectivity_plus.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/device_info_plus.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_picker.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_selector_ios.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CocoaAsyncSocket.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_app_badger.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_local_notifications.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_secure_storage.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/gal.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/geolocator_apple.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/get_thumbnail_video.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/image_picker_ios.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/just_audio.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebp.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/media_kit_video.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info_plus.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/photo_manager.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/record_ios.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/share_plus.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/shared_preferences_foundation.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqflite_darwin.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqlite3.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqlite3_flutter_libs.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher_ios.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/video_player_avfoundation.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/wakelock_plus.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/patrol.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -378,6 +399,36 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + BE63BFB6842CF1965047723E /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner-RunnerUITests/Pods-Runner-RunnerUITests-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/CocoaAsyncSocket/CocoaAsyncSocket.framework", + "${BUILT_PRODUCTS_DIR}/flutter_app_badger/flutter_app_badger.framework", + "${BUILT_PRODUCTS_DIR}/flutter_secure_storage/flutter_secure_storage.framework", + "${BUILT_PRODUCTS_DIR}/get_thumbnail_video/get_thumbnail_video.framework", + "${BUILT_PRODUCTS_DIR}/libwebp/libwebp.framework", + "${BUILT_PRODUCTS_DIR}/media_kit_video/media_kit_video.framework", + "${BUILT_PRODUCTS_DIR}/patrol/patrol.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CocoaAsyncSocket.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_app_badger.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_secure_storage.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/get_thumbnail_video.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebp.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/media_kit_video.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/patrol.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner-RunnerUITests/Pods-Runner-RunnerUITests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; D9B8FF135C3B420557191353 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -403,6 +454,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 08E8A4ECA2C1C2F244E229AF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C313D84D1A521524E97C5A08 /* RunnerUITests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -415,6 +474,15 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 7BBDFC82F80F89255BBCEC2F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = Runner; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = EC56DE3EBD0AD91F86EB6C86 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -435,6 +503,25 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 193194185D13B875BF2C7E81 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 62748C3D21C600131729B6A7 /* Pods-Runner-RunnerUITests.profile.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = io.getstream.flutter.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Runner; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { @@ -519,6 +606,43 @@ }; name = Profile; }; + 77FD324475B8A26B07568B97 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 87DD5B8A5D8FFF81FBE78F7A /* Pods-Runner-RunnerUITests.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = io.getstream.flutter.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Runner; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 88F210A499A992DE5779D089 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F1BB505AA8ADA090AF0076DE /* Pods-Runner-RunnerUITests.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = io.getstream.flutter.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Runner; + }; + name = Debug; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -695,6 +819,16 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 8A84C2230750F0B728CA3023 /* Build configuration list for PBXNativeTarget "RunnerUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 77FD324475B8A26B07568B97 /* Release */, + 88F210A499A992DE5779D089 /* Debug */, + 193194185D13B875BF2C7E81 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -716,6 +850,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "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/sample_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/sample_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..521c8a269d --- /dev/null +++ b/sample_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,184 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "csqlite", + "kind" : "remoteSourceControl", + "location" : "https://github.com/simolus3/CSQLite.git", + "state" : { + "revision" : "1ee46d19a4f451a7aa64ffc64fc99b4748131e62" + } + }, + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", + "version" : "12.14.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", + "version" : "3.6.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", + "version" : "12.14.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", + "version" : "5.3.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", + "version" : "2.30910.1" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", + "version" : "2.4.1" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/sample_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/sample_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 5db441f58a..d77590b84e 100644 --- a/sample_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/sample_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> @@ -56,6 +56,16 @@ + + + + void initState() { super.initState(); _notificationService.onNotificationTap = _onNotificationTap; - _notificationService.initialize(); + // Notifications rely on Firebase, which isn't initialized under e2e tests. + if (!isE2eTestRun) _notificationService.initialize(); final timeOfStartMs = DateTime.now().millisecondsSinceEpoch; diff --git a/sample_app/lib/auth/auth_controller.dart b/sample_app/lib/auth/auth_controller.dart index 644647efa6..ea9af72db1 100644 --- a/sample_app/lib/auth/auth_controller.dart +++ b/sample_app/lib/auth/auth_controller.dart @@ -26,9 +26,17 @@ final _chatPersistenceClient = StreamChatPersistenceClient( logLevel: Level.SEVERE, ); +/// True when running under e2e tests, detected via the presence of a +/// [StreamConnectionOverride]. Used to skip boot-path side effects that assume +/// a full native launch (Firebase, push registration, notifications). +bool get isE2eTestRun => authController.debugConnectionOverride != null; + Future _sampleAppLogHandler(LogRecord record) async { if (kDebugMode) StreamChatClient.defaultLogHandler(record); + // Crashlytics isn't initialized under e2e tests (the app is pumped in-process). + if (isE2eTestRun) return; + // report errors to Firebase Crashlytics if (record.error != null || record.stackTrace != null) { await FirebaseCrashlytics.instance.recordError( @@ -39,7 +47,27 @@ Future _sampleAppLogHandler(LogRecord record) async { } } -StreamChatClient _buildStreamChatClient(String apiKey) { +/// Test-only override that points the [StreamChatClient] at a mock server. +/// +/// `null` in production, so the SDK uses its default base URL. E2E tests set +/// [AuthController.debugConnectionOverride] before the first [AuthController.connect] +/// so the client talks to the local mock server instead of the real backend. +@visibleForTesting +class StreamConnectionOverride { + /// Creates an override; pass `null` for any field to keep the SDK default. + const StreamConnectionOverride({this.baseURL, this.baseWsUrl}); + + /// REST base URL, e.g. `http://10.0.2.2:` (Android) / `http://localhost:` (iOS). + final String? baseURL; + + /// WebSocket base URL, e.g. `ws://10.0.2.2:` / `ws://localhost:`. + final String? baseWsUrl; +} + +StreamChatClient _buildStreamChatClient( + String apiKey, { + StreamConnectionOverride? connectionOverride, +}) { const logLevel = kDebugMode ? Level.INFO : Level.SEVERE; return StreamChatClient( apiKey, @@ -51,8 +79,9 @@ StreamChatClient _buildStreamChatClient(String apiKey) { return error is StreamChatNetworkError && error.isRetriable; }, ), - //baseURL: 'http://:3030', - //baseWsUrl: 'ws://:8800', + // Null in production โ†’ SDK defaults; set by e2e tests to hit the mock server. + baseURL: connectionOverride?.baseURL, + baseWsUrl: connectionOverride?.baseWsUrl, )..chatPersistenceClient = _chatPersistenceClient; } @@ -93,6 +122,14 @@ class AuthController extends ValueNotifier { /// The active client, or `null` before the first [connect]. StreamChatClient? get client => _client; + /// Test-only connection override (see [StreamConnectionOverride]). + /// + /// Set this before the first [connect] in e2e tests to point the client at + /// the local mock server; leave `null` in production. Read once when the + /// client is built, so set it before the app calls [connect]. + @visibleForTesting + StreamConnectionOverride? debugConnectionOverride; + String? _activeApiKey; PushTokenManager? _pushTokenManager; @@ -142,7 +179,10 @@ class AuthController extends ValueNotifier { _client = null; } - final client = _client ??= _buildStreamChatClient(apiKey); + final client = _client ??= _buildStreamChatClient( + apiKey, + connectionOverride: debugConnectionOverride, + ); _activeApiKey = apiKey; try { @@ -157,11 +197,14 @@ class AuthController extends ValueNotifier { ]); } - _pushTokenManager = PushTokenManager( - client: client, - iosPushProvider: _kIosPushProvider, - androidPushProvider: _kAndroidPushProvider, - )..registerDevice(); + // Push relies on Firebase, which isn't initialized under e2e tests. + if (!isE2eTestRun) { + _pushTokenManager = PushTokenManager( + client: client, + iosPushProvider: _kIosPushProvider, + androidPushProvider: _kAndroidPushProvider, + )..registerDevice(); + } value = Authenticated(ownUser); } catch (_) { @@ -194,6 +237,30 @@ class AuthController extends ValueNotifier { client.disconnectUser(flushChatPersistence: flushPersistence).ignore(); } + /// Resets all session state so the next e2e test starts clean. + /// + /// Patrol runs every test in one app process against this process-wide + /// singleton, so credentials, the client, and the connection override would + /// otherwise leak between tests. Unlike [dispose] this keeps the notifier + /// usable. Call from test teardown. + @visibleForTesting + Future debugReset() async { + _pushTokenManager?.dispose().ignore(); + _pushTokenManager = null; + + await _client?.dispose(); + _client = null; + _activeApiKey = null; + debugConnectionOverride = null; + + if (!CurrentPlatform.isWeb) { + 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 55ecce2b6a..6fd4eb79ee 100644 --- a/sample_app/pubspec.yaml +++ b/sample_app/pubspec.yaml @@ -50,9 +50,22 @@ dependencies: dev_dependencies: flutter_launcher_icons: ^0.14.4 + integration_test: + sdk: flutter + patrol: ^4.6.1 flutter: uses-material-design: true assets: - assets/ - pubspec.lock + +# Patrol e2e configuration (config lives in pubspec.yaml, not a separate file). +# Tests live in integration_test/ (Patrol defaults to patrol_test/ otherwise). +patrol: + app_name: Stream Chat + test_directory: integration_test + android: + package_name: io.getstream.chat.android.flutter.sample + ios: + bundle_id: io.getstream.flutter