diff --git a/.github/actions/enable-kvm/action.yml b/.github/actions/enable-kvm/action.yml new file mode 100644 index 00000000000..068f309e973 --- /dev/null +++ b/.github/actions/enable-kvm/action.yml @@ -0,0 +1,11 @@ +name: 'Enable KVM' +description: 'Enables hardware accelerated Android virtualization on Actions Linux larger hosted runners' +runs: + using: "composite" + steps: + - name: Enable KVM group perms + shell: bash + 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 diff --git a/.github/actions/gradle-cache/action.yml b/.github/actions/gradle-cache/action.yml index 74678ed24fd..9cf0ce3e169 100644 --- a/.github/actions/gradle-cache/action.yml +++ b/.github/actions/gradle-cache/action.yml @@ -7,7 +7,7 @@ inputs: runs: using: "composite" steps: - - uses: actions/cache@v3.0.11 + - uses: actions/cache@v4 with: path: | ~/.gradle/caches diff --git a/.github/actions/ruby-cache/action.yml b/.github/actions/ruby-cache/action.yml new file mode 100644 index 00000000000..25b8a9a761f --- /dev/null +++ b/.github/actions/ruby-cache/action.yml @@ -0,0 +1,9 @@ +name: 'Ruby Cache' +description: 'Cache Ruby dependencies' +runs: + using: "composite" + steps: + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1 + bundler-cache: true diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index aefc8a312ae..ebc195ff2ab 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -48,7 +48,7 @@ jobs: - name: Set up JDK 17 uses: actions/setup-java@v3.6.0 with: - distribution: adopt + distribution: adopt java-version: 17 - name: Unit tests run: ./scripts/ci-unit-tests.sh @@ -77,8 +77,8 @@ jobs: with: artifact_path: ./stream-chat-android-ui-components-sample/build/outputs/apk/demo/debug/stream-chat-android-ui-components-sample-demo-debug.apk emerge_api_key: ${{ secrets.EMERGE_TOOLS_API_KEY }} - build_type: debug - + build_type: debug + size_check_compose: name: Size Check Compose runs-on: ubuntu-22.04 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 00000000000..adf5098558c --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,114 @@ +name: E2E Tests + +on: + pull_request: + + workflow_dispatch: + +concurrency: + group: ${{ github.ref }} + cancel-in-progress: true + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_PR_NUM: ${{ github.event.pull_request.number }} + +jobs: + build-apks: + name: Build + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4.2.2 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: adopt + java-version: 17 + - uses: ./.github/actions/enable-kvm + - uses: ./.github/actions/ruby-cache + - uses: ./.github/actions/gradle-cache + with: + key-prefix: gradle-test + - name: Build apks + run: bundle exec fastlane build_e2e_test + - name: Upload apks + uses: actions/upload-artifact@v4.4.0 + with: + name: apks + path: | + stream-chat-android-compose-sample/build/outputs/apk/e2e/debug/*.apk + stream-chat-android-compose-sample/build/outputs/apk/androidTest/e2e/debug/*.apk + + allure_testops_launch: + name: Launch Allure TestOps + runs-on: ubuntu-24.04 + needs: build-apks + outputs: + launch_id: ${{ steps.get_launch_id.outputs.launch_id }} + steps: + - uses: actions/checkout@v4.1.1 + - uses: ./.github/actions/ruby-cache + - name: Launch Allure TestOps + run: bundle exec fastlane allure_launch + env: + ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }} + GITHUB_EVENT: ${{ toJson(github.event) }} + - id: get_launch_id + run: echo "launch_id=${{env.LAUNCH_ID}}" >> $GITHUB_OUTPUT + if: env.LAUNCH_ID != '' + + run-tests: + name: Test + runs-on: ubuntu-24.04 + needs: + - build-apks + - allure_testops_launch + env: + LAUNCH_ID: ${{ needs.allure_testops_launch.outputs.launch_id }} + steps: + - name: Connect Bot + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.BOT_SSH_PRIVATE_KEY }} + - uses: actions/checkout@v4.2.2 + - uses: actions/download-artifact@v4.1.8 + continue-on-error: true + with: + name: apks + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: adopt + java-version: 17 + - uses: ./.github/actions/enable-kvm + - uses: ./.github/actions/ruby-cache + - uses: ./.github/actions/gradle-cache + with: + key-prefix: gradle-test + - name: Run tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + disable-animations: true + profile: pixel + arch : x86_64 + emulator-options: -no-snapshot-save -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect -camera-back none -camera-front none + script: bundle exec fastlane run_e2e_test + - name: Allure TestOps Upload + if: env.LAUNCH_ID != '' && (success() || failure()) + run: bundle exec fastlane allure_upload launch_id:$LAUNCH_ID + env: + ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }} + - name: Allure TestOps Launch Removal + if: env.LAUNCH_ID != '' && cancelled() + run: bundle exec fastlane allure_launch_removal launch_id:$LAUNCH_ID + env: + ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }} + - name: Upload test results + uses: actions/upload-artifact@v3.1.0 + if: failure() + with: + name: test_report + path: | + ./**/build/reports/androidTests/* + fastlane/stream-chat-test-mock-server/logs/* diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index ae1db5fd844..006c2e6bb04 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -32,6 +32,7 @@ jobs: - name: Detekt if: always() run: ./gradlew detekt + vale: name: Vale doc linter runs-on: ubuntu-latest @@ -106,4 +107,12 @@ jobs: if: failure() with: name: testDebugUnitTest - path: ./**/build/reports/tests/testDebugUnitTest \ No newline at end of file + path: ./**/build/reports/tests/testDebugUnitTest + + rubocop: + name: Rubocop + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4.2.2 + - uses: ./.github/actions/ruby-cache + - run: bundle exec fastlane rubocop diff --git a/.gitignore b/.gitignore index 63bf1f4538a..50d420ca7f8 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,14 @@ docusaurus/shared .sign/keystore.properties .sign/release.keystore + +# fastlane +!fastlane/.env +fastlane/fastlane.log +fastlane/report.xml +fastlane/screenshots +fastlane/test_output +fastlane/allurectl +fastlane/recordings +allure-results +stream-chat-test-mock-server diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000000..c412a545778 --- /dev/null +++ b/Gemfile @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } + +gem 'fastlane', '2.225.0' +gem 'json' +gem 'rubocop', '1.38', group: :rubocop_dependencies +gem 'sinatra', group: :sinatra_dependencies + +eval_gemfile('fastlane/Pluginfile') + +group :rubocop_dependencies do + gem 'rubocop-performance' + gem 'rubocop-require_tools' +end + +group :sinatra_dependencies do + gem 'eventmachine' + gem 'faye-websocket' + gem 'puma' + gem 'rackup' +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000000..5223ce95a2a --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,288 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.7) + base64 + nkf + rexml + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + artifactory (3.0.17) + ast (2.4.2) + atomos (0.1.3) + aws-eventstream (1.3.0) + aws-partitions (1.1004.0) + aws-sdk-core (3.212.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.95.0) + aws-sdk-core (~> 3, >= 3.210.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.170.1) + aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.10.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.2.0) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + declarative (0.0.20) + digest-crc (0.6.5) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + eventmachine (1.2.7) + excon (0.112.0) + faraday (1.10.4) + 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.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + 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.3) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.3.1) + fastlane (2.225.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + 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.0.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.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (>= 0.1.1, < 1.0.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.3.0) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-plugin-stream_actions (0.3.74) + xctest_list (= 1.2.1) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + faye-websocket (0.11.3) + eventmachine (>= 0.12.0) + websocket-driver (>= 0.5.1) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.7.1) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.4.0) + google-cloud-storage (1.47.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.7) + domain_name (~> 0.5) + httpclient (2.8.3) + jmespath (1.6.2) + json (2.8.1) + jwt (2.9.3) + base64 + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.15.0) + multipart-post (2.4.1) + mustermann (3.0.3) + ruby2_keywords (~> 0.0.1) + nanaimo (0.4.0) + naturally (2.2.1) + nio4r (2.7.4) + nkf (0.2.0) + optparse (0.6.0) + os (1.1.4) + parallel (1.26.3) + parser (3.3.6.0) + ast (~> 2.4.1) + racc + plist (3.7.1) + public_suffix (6.0.1) + puma (6.4.3) + nio4r (~> 2.0) + racc (1.8.1) + rack (3.1.8) + rack-protection (4.0.0) + base64 (>= 0.1.0) + rack (>= 3.0.0, < 4) + rack-session (2.0.0) + rack (>= 3.0.0) + rackup (2.2.0) + rack (>= 3) + rainbow (3.1.1) + rake (13.2.1) + regexp_parser (2.9.2) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.3.9) + rouge (2.0.7) + rubocop (1.38.0) + json (~> 2.3) + parallel (~> 1.10) + parser (>= 3.1.2.1) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.23.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 3.0) + rubocop-ast (1.35.0) + parser (>= 3.3.1.0) + rubocop-performance (1.19.1) + rubocop (>= 1.7.0, < 2.0) + rubocop-ast (>= 0.4.0) + rubocop-require_tools (0.1.2) + rubocop (>= 0.49.1) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + security (0.1.5) + signet (0.19.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + sinatra (4.0.0) + mustermann (~> 3.0) + rack (>= 3.0.0, < 4) + rack-protection (= 4.0.0) + rack-session (>= 2.0.0, < 3) + tilt (~> 2.0) + sysrandom (1.0.5) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + tilt (2.4.0) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.6.0) + websocket-driver (0.7.6) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + 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.3.0) + rouge (~> 2.0.7) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + xctest_list (1.2.1) + +PLATFORMS + arm64-darwin-21 + ruby + +DEPENDENCIES + eventmachine + fastlane (= 2.225.0) + fastlane-plugin-stream_actions (= 0.3.74) + faye-websocket + json + puma + rackup + rubocop (= 1.38) + rubocop-performance + rubocop-require_tools + sinatra + +BUNDLED WITH + 2.5.17 diff --git a/fastlane/.env b/fastlane/.env new file mode 100644 index 00000000000..50432c35867 --- /dev/null +++ b/fastlane/.env @@ -0,0 +1,4 @@ +FASTLANE_SKIP_ACTION_SUMMARY=true +FASTLANE_HIDE_PLUGINS_TABLE=true +FASTLANE_SKIP_UPDATE_CHECK=true +FASTLANE_HIDE_CHANGELOG=true diff --git a/fastlane/.rubocop.yml b/fastlane/.rubocop.yml new file mode 100755 index 00000000000..8fbc9480343 --- /dev/null +++ b/fastlane/.rubocop.yml @@ -0,0 +1,172 @@ +--- +require: +- rubocop/require_tools +- rubocop-performance +AllCops: + TargetRubyVersion: 2.4 + NewCops: enable + Include: + - "**/*.rb" + - "**/*file" +Style/MultipleComparison: + Enabled: false +Style/PercentLiteralDelimiters: + Enabled: false +Style/ClassCheck: + EnforcedStyle: kind_of? +Style/FrozenStringLiteralComment: + Enabled: false +Style/SafeNavigation: + Enabled: false +Performance/RegexpMatch: + Enabled: false +Performance/StringReplacement: + Enabled: false +Performance/CollectionLiteralInLoop: + Enabled: false +Style/NumericPredicate: + Enabled: false +Metrics/BlockLength: + Enabled: false +Metrics/ModuleLength: + Enabled: false +Naming/VariableNumber: + Enabled: false +Style/MissingRespondToMissing: + Enabled: false +Style/MultilineBlockChain: + Enabled: false +Style/NumericLiteralPrefix: + Enabled: false +Style/TernaryParentheses: + Enabled: false +Style/EmptyMethod: + Enabled: false +Lint/UselessAssignment: + Exclude: + - "**/spec/**/*" +Require/MissingRequireStatement: + Enabled: false +Style/RescueModifier: + Enabled: false +Layout/FirstHashElementIndentation: + Enabled: false +Layout/HashAlignment: + Enabled: false +Layout/DotPosition: + Enabled: false +Style/DoubleNegation: + Enabled: false +Style/SymbolArray: + Enabled: false +Layout/HeredocIndentation: + Enabled: false +Style/MixinGrouping: + Exclude: + - "**/spec/**/*" +Lint/SuppressedException: + Enabled: false +Lint/UnusedBlockArgument: + Enabled: false +Lint/AmbiguousBlockAssociation: + Enabled: false +Style/GlobalVars: + Enabled: false +Style/ClassAndModuleChildren: + Enabled: false +Style/SpecialGlobalVars: + Enabled: false +Metrics/AbcSize: + Enabled: false +Metrics/MethodLength: + Enabled: false +Metrics/CyclomaticComplexity: + Enabled: false +Style/WordArray: + MinSize: 19 +Style/SignalException: + Enabled: false +Style/RedundantReturn: + Enabled: false +Style/IfUnlessModifier: + Enabled: false +Style/AndOr: + Enabled: true + EnforcedStyle: conditionals +Metrics/ClassLength: + Max: 320 +Layout/LineLength: + Max: 370 +Metrics/ParameterLists: + Max: 17 +Metrics/PerceivedComplexity: + Max: 20 +Style/GuardClause: + Enabled: false +Style/StringLiterals: + Enabled: false +Style/ConditionalAssignment: + Enabled: false +Style/RedundantSelf: + Enabled: false +Lint/UnusedMethodArgument: + Enabled: false +Lint/ParenthesesAsGroupedExpression: + Exclude: + - "**/spec/**/*" +Naming/PredicateName: + Enabled: false +Style/PerlBackrefs: + Enabled: false +Naming/FileName: + Exclude: + - "**/Dangerfile" + - "**/Brewfile" + - "**/Gemfile" + - "**/Podfile" + - "**/Rakefile" + - "**/Fastfile" + - "**/Scanfile" + - "**/Matchfile" + - "**/Appfile" + - "**/Allurefile" + - "**/Sonarfile" + - "**/Deliverfile" + - "**/Snapfile" + - "**/Pluginfile" + - "**/*.gemspec" +Style/Documentation: + Enabled: false +Style/MutableConstant: + Enabled: false +Style/ZeroLengthPredicate: + Enabled: false +Style/IfInsideElse: + Enabled: false +Style/CollectionMethods: + Enabled: false +Style/MethodCallWithArgsParentheses: + Enabled: true + IgnoredMethods: + - require + - require_relative + - fastlane_require + - gem + - program + - command + - raise + - attr_accessor + - attr_reader + - desc + - lane + - private_lane + - platform + - to + - not_to + - describe + - it + - be + - context + - before + - after + - and diff --git a/fastlane/Allurefile b/fastlane/Allurefile new file mode 100755 index 00000000000..574e4ea1eac --- /dev/null +++ b/fastlane/Allurefile @@ -0,0 +1,66 @@ +#!/usr/bin/env ruby + +allure_project_id = '102' +allure_url = 'https://streamio.testops.cloud' +allure_api_url = "#{allure_url}/api" +allure_regression_testplan = 'Regression Testing' +allure_results_path = 'allure-results' + +desc 'Upload test results to Allure TestOps' +lane :allure_upload do |options| + remove_duplicated_allure_results + allure_args = "-e #{allure_url} --project-id #{allure_project_id} --launch-id #{options[:launch_id]}" + sh("./allurectl launch reopen #{options[:launch_id]} || true") # to prevent allure from uploading results to a closed launch + sh("env BRANCH_NAME='#{current_branch}' ./allurectl upload #{allure_args} #{allure_results_path} || true") + UI.success("Check out test results in Allure TestOps: #{allure_url}/launch/#{options[:launch_id]} 🎉") +end + +desc 'Create launch on Allure TestOps' +lane :allure_launch do |options| + next unless is_check_required(sources: sources_matrix[:e2e], force_check: @force_check) + + launch_id = allure_create_launch( + url: allure_api_url, + project_id: allure_project_id, + github_run_details: github_run_details, + cron: options[:cron] + ) + sh("echo 'LAUNCH_ID=#{launch_id}' >> $GITHUB_ENV") if is_ci +end + +desc 'Remove launch on Allure TestOps' +lane :allure_launch_removal do |options| + allure_api(url: allure_api_url, path: "launch/#{options[:launch_id]}", http_method: 'DELETE') +end + +desc 'Create test-case in Allure TestOps and get its id' +lane :allure_testcase do + allure_create_testcase(url: allure_api_url, project_id: allure_project_id) +end + +desc 'Sync and run regression test-plan on Allure TestOps' +lane :allure_start_regression do |options| + allure_run_testplan( + url: allure_api_url, + project_id: allure_project_id, + release_version: options[:release_version], + testplan: allure_regression_testplan, + jira: options[:jira] + ) +end + +def github_run_details + return nil unless is_ci + + github_path = "#{ENV.fetch('GITHUB_API_URL', nil)}/repos/#{ENV.fetch('GITHUB_REPOSITORY', nil)}/actions/runs/#{ENV.fetch('GITHUB_RUN_ID', nil)}" + output = sh(command: "curl -s -H 'authorization: Bearer #{ENV.fetch('GITHUB_TOKEN', nil)}' -X GET -G #{github_path}") + JSON.parse(output) +end + +desc 'https://github.com/allure-framework/allure-kotlin/issues/73' +lane :remove_duplicated_allure_results do + Dir.glob("#{allure_results_path}/*.json").each do |json_file| + json_data = JSON.parse(File.read(json_file)) + FileUtils.rm(json_file) if json_data['steps'].kind_of?(Array) && json_data['steps'].empty? + end +end diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 00000000000..75eba3b21f8 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,122 @@ +default_platform :android +skip_docs + +require 'json' +require 'net/http' +import 'Allurefile' + +github_repo = ENV['GITHUB_REPOSITORY'] || 'GetStream/stream-chat-android' +test_flavor = 'stream-chat-android-compose-sample' +androidx_test_orchestrator_version = '1.5.1' +androidx_test_services_version = '1.5.0' +allure_ctl_version = '2.15.1' +mock_server_driver_port = 4567 +is_localhost = !is_ci +@force_check = false + +before_all do |lane| + if is_ci + setup_ci + setup_git_config + end +end + +lane :start_mock_server do + stop_mock_server if is_localhost + mock_server_repo = 'stream-chat-test-mock-server' + sh("rm -rf #{mock_server_repo}") if File.directory?(mock_server_repo) + sh("git clone git@github.com:#{github_repo.split('/').first}/#{mock_server_repo}.git") + + Dir.chdir(mock_server_repo) do + FileUtils.mkdir_p('logs') + sh("bundle exec ruby driver.rb > logs/driver.log 2>&1 &") + end +end + +lane :stop_mock_server do + Net::HTTP.get_response(URI("http://localhost:#{mock_server_driver_port}/stop")) rescue nil +end + +lane :build_and_run_e2e_test do |options| + build_e2e_test + run_e2e_test +end + +lane :build_e2e_test do + next unless is_check_required(sources: sources_matrix[:e2e], force_check: @force_check) + + gradle(tasks: [":#{test_flavor}:assembleE2eDebugAndroidTest", ":#{test_flavor}:assembleE2eDebug"]) +end + +lane :run_e2e_test do + next unless is_check_required(sources: sources_matrix[:e2e], force_check: @force_check) + + allure_results_path = 'allure-results' + adb_test_results_path = '/sdcard/googletest/test_outputfiles' + sh("rm -rf #{allure_results_path}") + sh("adb shell rm -rf #{adb_test_results_path}/#{allure_results_path}") + + start_mock_server + install_test_services + + Dir.chdir('..') do + stream_apk_path = is_ci ? '.' : "#{test_flavor}/build/outputs/apk" + sh("adb install -r '#{stream_apk_path}/e2e/debug/stream-chat-android-compose-sample-e2e-debug.apk'") + sh("adb install -r '#{stream_apk_path}/androidTest/e2e/debug/stream-chat-android-compose-sample-e2e-debug-androidTest.apk'") + end + + app_package_name = 'io.getstream.chat.android.compose.sample.e2etest.debug' + test_package_name = "#{app_package_name}.test" + runner_package_name = 'io.qameta.allure.android.runners.AllureAndroidJUnitRunner' + orchestrator_package_name = 'androidx.test.orchestrator/.AndroidTestOrchestrator' + androidx_test_services_path = sh('adb shell pm path androidx.test.services').strip + + result = sh( + "adb shell 'CLASSPATH=#{androidx_test_services_path}' app_process / " \ + 'androidx.test.services.shellexecutor.ShellMain am instrument -w -e clearPackageData true ' \ + "-e targetInstrumentation #{test_package_name}/#{runner_package_name} #{orchestrator_package_name}" + ) + + sh("adb exec-out sh -c 'cd #{adb_test_results_path} && tar cf - #{allure_results_path}' | tar xvf - -C .") + stop_mock_server + + UI.user_error!('Tests have failed!') if result.include?('Failures') +end + +private_lane :install_test_services do + orchestrator_apk_path = "apks/orchestrator.apk" + test_services_apk_path = "apks/test-services.apk" + allure_ctl_path = "allurectl" + allure_ctl_arch = RbConfig::CONFIG['host_os'].include?('darwin') ? 'darwin_amd64' : 'linux_amd64' + maven_repo = 'https://dl.google.com/dl/android/maven2/androidx/test' + + if [orchestrator_apk_path, test_services_apk_path, allure_ctl_path].any? { |f| !File.exist?(f) } + FileUtils.mkdir_p('apks') + sh("wget -O #{orchestrator_apk_path} '#{maven_repo}" \ + "/orchestrator/#{androidx_test_orchestrator_version}/orchestrator-#{androidx_test_orchestrator_version}.apk' 2>/dev/null") + sh("wget -O #{test_services_apk_path} '#{maven_repo}" \ + "/services/test-services/#{androidx_test_services_version}/test-services-#{androidx_test_services_version}.apk' 2>/dev/null") + sh("wget -O #{allure_ctl_path} " \ + "'https://github.com/allure-framework/allurectl/releases/download/#{allure_ctl_version}/allurectl_#{allure_ctl_arch}' 2>/dev/null") + sh('chmod +x allurectl') + end + + device_api_level = sh('adb shell getprop ro.build.version.sdk').strip.to_i + force_queryable_option = device_api_level >= 30 ? '--force-queryable' : '' + sh("adb install #{force_queryable_option} -r #{orchestrator_apk_path}") + sh("adb install #{force_queryable_option} -r #{test_services_apk_path}") +end + +desc 'Run fastlane linting' +lane :rubocop do + next unless is_check_required(sources: sources_matrix[:ruby], force_check: @force_check) + + sh('bundle exec rubocop') +end + +private_lane :sources_matrix do + { + e2e: ['buildSrc', 'stream-chat-android', '.github/workflows/e2e-tests'], + ruby: ['fastlane', 'Gemfile', 'Gemfile.lock'] + } +end diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile new file mode 100644 index 00000000000..5daeada5795 --- /dev/null +++ b/fastlane/Pluginfile @@ -0,0 +1,5 @@ +# Autogenerated by fastlane +# +# Ensure this file is checked in to source control! + +gem 'fastlane-plugin-stream_actions', '0.3.74' diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/StreamTestCase.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/StreamTestCase.kt index ff7629c7fa1..a9a9b12486a 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/StreamTestCase.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/StreamTestCase.kt @@ -26,17 +26,38 @@ import io.getstream.chat.android.compose.uiautomator.grantPermission import io.getstream.chat.android.compose.uiautomator.mockServer import io.getstream.chat.android.compose.uiautomator.startApp import io.getstream.chat.android.e2e.test.robots.ParticipantRobot +import io.getstream.chat.android.e2e.test.rules.RetryRule +import io.qameta.allure.android.rules.LogcatRule +import io.qameta.allure.android.rules.ScreenshotRule +import io.qameta.allure.android.rules.WindowHierarchyRule import org.junit.After import org.junit.Before +import org.junit.Rule +import org.junit.rules.TestName open class StreamTestCase { val userRobot = UserRobot() val participantRobot = ParticipantRobot() + @get:Rule + var testName: TestName = TestName() + + @get:Rule + val screenshotRule = ScreenshotRule(mode = ScreenshotRule.Mode.FAILURE, screenshotName = "screenshot") + + @get:Rule + val logcatRule = LogcatRule() + + @get:Rule + val windowHierarchyRule = WindowHierarchyRule() + + @get:Rule + val retryRule = RetryRule() + @Before fun setUp() { - mockServer.start() + mockServer.start(testName.methodName) device.startApp() grantAppPermissions() } diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/resources/allure.properties b/stream-chat-android-compose-sample/src/androidTestE2eDebug/resources/allure.properties new file mode 100644 index 00000000000..74d355ea269 --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/resources/allure.properties @@ -0,0 +1 @@ +allure.results.useTestStorage=true diff --git a/stream-chat-android-e2e-test/api/stream-chat-android-e2e-test.api b/stream-chat-android-e2e-test/api/stream-chat-android-e2e-test.api index 44ccc7fb7a7..d4dbb5875d3 100644 --- a/stream-chat-android-e2e-test/api/stream-chat-android-e2e-test.api +++ b/stream-chat-android-e2e-test/api/stream-chat-android-e2e-test.api @@ -68,6 +68,11 @@ public final class io/getstream/chat/android/compose/uiautomator/WaitKt { public static synthetic fun waitToDisappear$default (Landroidx/test/uiautomator/BySelector;JILjava/lang/Object;)Landroidx/test/uiautomator/BySelector; } +public class io/getstream/chat/android/e2e/test/helpers/DatabaseOperations { + public fun ()V + public fun clearDatabases ()V +} + public final class io/getstream/chat/android/e2e/test/mockserver/AttachmentType : java/lang/Enum { public static final field FILE Lio/getstream/chat/android/e2e/test/mockserver/AttachmentType; public static final field IMAGE Lio/getstream/chat/android/e2e/test/mockserver/AttachmentType; @@ -83,7 +88,7 @@ public final class io/getstream/chat/android/e2e/test/mockserver/MockServer { public final fun getRequest (Ljava/lang/String;)Lokhttp3/ResponseBody; public final fun postRequest (Ljava/lang/String;Lokhttp3/RequestBody;)Lokhttp3/ResponseBody; public static synthetic fun postRequest$default (Lio/getstream/chat/android/e2e/test/mockserver/MockServer;Ljava/lang/String;Lokhttp3/RequestBody;ILjava/lang/Object;)Lokhttp3/ResponseBody; - public final fun start ()V + public final fun start (Ljava/lang/String;)V public final fun stop ()V } @@ -142,3 +147,19 @@ public final class io/getstream/chat/android/e2e/test/robots/ParticipantRobot { public static synthetic fun uploadAttachmentInThread$default (Lio/getstream/chat/android/e2e/test/robots/ParticipantRobot;Lio/getstream/chat/android/e2e/test/mockserver/AttachmentType;IZILjava/lang/Object;)Lio/getstream/chat/android/e2e/test/robots/ParticipantRobot; } +public abstract interface annotation class io/getstream/chat/android/e2e/test/rules/Retry : java/lang/annotation/Annotation { + public abstract fun times ()I +} + +public final class io/getstream/chat/android/e2e/test/rules/RetryRule : org/junit/rules/TestRule { + public static final field Companion Lio/getstream/chat/android/e2e/test/rules/RetryRule$Companion; + public static final field DEFAULT_RETRIES I + public fun ()V + public fun (I)V + public synthetic fun (IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun apply (Lorg/junit/runners/model/Statement;Lorg/junit/runner/Description;)Lorg/junit/runners/model/Statement; +} + +public final class io/getstream/chat/android/e2e/test/rules/RetryRule$Companion { +} + diff --git a/stream-chat-android-e2e-test/build.gradle b/stream-chat-android-e2e-test/build.gradle index 1ea5c9ad026..913d2277df7 100644 --- a/stream-chat-android-e2e-test/build.gradle +++ b/stream-chat-android-e2e-test/build.gradle @@ -25,6 +25,7 @@ dependencies { implementation(Dependencies.androidxTest) implementation(Dependencies.androidxUiAutomator) implementation(Dependencies.androidxTestMonitor) + implementation(Dependencies.androidxTestJunitKtx) detektPlugins(Dependencies.detektFormatting) } diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/helpers/DatabaseOperations.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/helpers/DatabaseOperations.kt new file mode 100644 index 00000000000..8c28c675e3d --- /dev/null +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/helpers/DatabaseOperations.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.e2e.test.helpers + +import android.database.sqlite.SQLiteDatabase +import io.getstream.chat.android.compose.uiautomator.appContext +import java.io.File + +public open class DatabaseOperations { + + public open fun clearDatabases() { + val databaseOperations = DatabaseOperations() + val dbFiles = databaseOperations.getAllDatabaseFiles().filterNot { shouldIgnoreFile(it.path) } + dbFiles.forEach { clearDatabase(it, databaseOperations) } + } + + private fun shouldIgnoreFile(path: String): Boolean { + val ignoredSuffixes = arrayOf("-journal", "-shm", "-uid", "-wal") + return ignoredSuffixes.any { path.endsWith(it) } + } + + private fun clearDatabase(dbFile: File, dbOperations: DatabaseOperations) { + dbOperations.openDatabase(dbFile).use { database -> + val tablesToClear = dbOperations.getTableNames(database).filterNot { it == "room_master_table" } + tablesToClear.forEach { dbOperations.deleteTableContent(database, it) } + } + } + + private fun getAllDatabaseFiles(): List { + return appContext.let { context -> + context.databaseList().map { context.getDatabasePath(it) } + } + } + + private fun openDatabase(databaseFile: File): SQLiteDatabase { + return SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, 0) + } + + private fun getTableNames(sqLiteDatabase: SQLiteDatabase): List { + sqLiteDatabase.rawQuery("SELECT name FROM sqlite_master WHERE type IN (?, ?)", arrayOf("table", "view")) + .use { cursor -> + val tableNames = ArrayList() + while (cursor.moveToNext()) { + tableNames.add(cursor.getString(0)) + } + return tableNames + } + } + + private fun deleteTableContent(sqLiteDatabase: SQLiteDatabase, tableName: String) { + sqLiteDatabase.delete(tableName, null, null) + } +} diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/mockserver/MockServer.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/mockserver/MockServer.kt index e48d0873bf6..6d23f7c780b 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/mockserver/MockServer.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/mockserver/MockServer.kt @@ -29,8 +29,8 @@ private val okHttp: OkHttpClient = OkHttpClient() public class MockServer { - public fun start() { - val request = Request.Builder().url("$driverUrl/start").build() + public fun start(testName: String) { + val request = Request.Builder().url("$driverUrl/start/$testName").build() val response = okHttp.newCall(request).execute() val mockServerPort = response.body?.string().toString() val driverPort = driverUrl.split(":").last() diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/rules/RetryRule.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/rules/RetryRule.kt new file mode 100644 index 00000000000..bea49209a15 --- /dev/null +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/rules/RetryRule.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.e2e.test.rules + +import io.getstream.chat.android.e2e.test.helpers.DatabaseOperations +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** + * Annotation to specify amount of retries of a specific test + * + * The value in this annotation takes precedence over Retry Rule defaultRetries value + */ +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +public annotation class Retry(val times: Int = 0) + +/** + * Rule to retry failed tests + * + * If @Retry annotation is not specified in the test method, it defaults to DEFAULT_RETRIES + * + * @param defaultRetries amount of retries. + */ +public class RetryRule( + private val defaultRetries: Int = DEFAULT_RETRIES, +) : TestRule { + + override fun apply(base: Statement, description: Description): Statement = statement(base, description) + + private fun statement( + base: Statement, + description: Description, + ): Statement { + return object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + val retryAnnotation: Retry? = description.getAnnotation(Retry::class.java) + val retryCount = retryAnnotation?.times ?: DEFAULT_RETRIES + val databaseOperations = DatabaseOperations() + + System.err.println("retry_count: $retryCount") + + var caughtThrowable: Throwable? = null + + for (i in 0 until retryCount) { + System.err.println("${description.displayName}: run ${(i + 1)}") + + try { + base.evaluate() + return + } catch (t: Throwable) { + caughtThrowable = t + System.err.println("${description.displayName}: run ${(i + 1)} failed.") + databaseOperations.clearDatabases() + } + } + System.err.println("${description.displayName}: Giving up after $retryCount failures.") + throw caughtThrowable ?: IllegalStateException() + } + } + } + + public companion object { + public const val DEFAULT_RETRIES: Int = 3 + } +}