name: Tests

on:
  pull_request:
    paths-ignore:
      - ".github/workflows/migration-tests.yml"
      - ".github/dependabot.yml"
      - ".github/labeler.yml"
      - "migrations/**"
  push:
    branches:
      - main
      - beta
      - stable
      - release/*

concurrency:
  group: tests-${{ format('{0}-{1}', github.head_ref || github.run_number, github.job) }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  build:
    if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
    name: ${{ matrix.target }} ${{ matrix.build_type }}${{ (matrix.target == 'core' && matrix.build_type == 'frontend' && format(' ({0})', matrix.browser)) || '' }}${{ matrix.plugin_compiler == 'legacy' && ' (legacy)' || '' }}
    runs-on: >-
      ${{
        github.repository_owner == 'discourse' && (
          matrix.build_type == 'system' && (matrix.target == 'core' || matrix.target == 'core-plugins' || matrix.target == 'official-plugins')
          && 'cdck-linux-16-core'
          || 'cdck-linux-8-core'
        )
        || 'ubuntu-latest'
      }}
    container: discourse/discourse_test:release
    timeout-minutes: >-
      ${{
        matrix.build_type == 'system' && (matrix.target == 'core-plugins' || matrix.target == 'official-plugins' || matrix.target == 'core')
        && 30
        || 20
      }}

    defaults:
      run:
        shell: bash

    env:
      RAILS_ENV: test
      PGUSER: discourse
      PGPASSWORD: discourse
      USES_PARALLEL_DATABASES: ${{ matrix.build_type == 'backend' || matrix.build_type == 'system' }}
      CAPYBARA_DEFAULT_MAX_WAIT_TIME: 20
      MINIO_RUNNER_LOG_LEVEL: WARN
      DISCOURSE_TURBO_RSPEC_RETRY_AND_LOG_FLAKY_TESTS: ${{ (matrix.build_type == 'system' || matrix.build_type == 'backend') && '1' }}
      CHEAP_SOURCE_MAPS: "1"
      MINIO_RUNNER_INSTALL_DIR: /home/discourse/.minio_runner
      TESTEM_BROWSER: ${{ (startsWith(matrix.browser, 'Firefox') && 'Firefox') || matrix.browser }}
      TESTEM_FIREFOX_PATH: ${{ (matrix.browser == 'Firefox Evergreen') && '/opt/firefox-evergreen/firefox' }}
      EMBER_ENV: development
      LOAD_PLUGINS: ${{ (matrix.target == 'core-plugins' || matrix.target == 'official-plugins' || matrix.target == 'plugins' || matrix.target == 'chat') && '1' || '0' }}
      ACTIONS_STEP_DEBUG: ${{ secrets.ACTIONS_STEP_DEBUG }}
      QUNIT_REUSE_BUILD: 1
      ROLLUP_PLUGIN_COMPILER: ${{ matrix.plugin_compiler == 'rollup' && '1' || '0' }}

    strategy:
      fail-fast: false

      matrix:
        build_type: [backend, frontend, system]
        target: [core, plugins, themes]
        browser: [Chrome]
        plugin_compiler: ["rollup"]
        exclude:
          - build_type: backend
            target: themes
          # Exclude combined plugins from system tests - use split targets instead
          - build_type: system
            target: plugins
        include:
          # Split plugin targets for system tests only
          - build_type: system
            target: core-plugins
            browser: Chrome
          - build_type: system
            target: official-plugins
            browser: Chrome
          - build_type: system
            target: chat
            browser: Chrome
          - build_type: frontend
            target: plugins
            plugin_compiler: "legacy"
          # - build_type: frontend
          #   target: core
          #   browser: Firefox Evergreen
          # - build_type: frontend
          #   target: core
          #   browser: Firefox ESR

    steps:
      - name: Set working directory owner
        run: chown root:root .

      - name: Set PARALLEL_TEST_PROCESSORS for system tests
        if: matrix.build_type == 'system'
        run: |
          echo "PARALLEL_TEST_PROCESSORS=$(($(nproc) / 2))" >> $GITHUB_ENV

      - name: Set QUNIT_PARALLEL for QUnit tests
        if: matrix.build_type == 'frontend'
        run: |
          if [ "${{ matrix.target }}" = "themes" ]; then
            echo "QUNIT_PARALLEL=2" >> $GITHUB_ENV
          else
            echo "QUNIT_PARALLEL=$(($(nproc) / 2))" >> $GITHUB_ENV
          fi

      - uses: actions/checkout@v6
        with:
          fetch-depth: 1

      - name: Setup Git
        run: |
          git config --global user.email "ci@ci.invalid"
          git config --global user.name "Discourse CI"

      - name: Start redis
        run: redis-server /etc/redis/redis.conf &

      - name: Start Postgres
        run: |
          chown -R postgres /var/run/postgresql
          sudo -E -u postgres script/start_test_db.rb
          sudo -u postgres psql -c "CREATE ROLE $PGUSER LOGIN SUPERUSER PASSWORD '$PGPASSWORD';"

      - name: Symlink vendor/bundle from image
        run: |
          ln -s /var/www/discourse/vendor/bundle vendor/bundle

      - name: Setup gems
        run: |
          bundle config --local path vendor/bundle
          bundle config --local deployment true
          bundle config --local without development
          bundle install --jobs $(($(nproc) - 1))
          bundle clean

      - name: pnpm install
        run: pnpm install --frozen-lockfile

      - name: Checkout official plugins
        if: matrix.target == 'official-plugins' || matrix.target == 'plugins'
        run: bin/rake plugin:install_all_official

      - name: Symlinking plugin gems from image
        if: matrix.target == 'plugins' || matrix.target == 'core-plugins' || matrix.target == 'official-plugins'
        run: |
          for dir in /var/www/discourse/plugins/*/gems; do
            plugin_name=$(basename "$(dirname "$dir")")
            plugin_dir="plugins/$plugin_name"
            gem_dir="$plugin_dir/gems"

            if [ ! -d "$plugin_dir" ]; then
              echo "Skipping $plugin_name: Plugin directory does not exist"
            elif [ -d "$gem_dir" ]; then
              echo "Skipping $plugin_name: Source gems directory exists"
            else
              echo "Symlinking $dir to $gem_dir"
              ln -s "$dir" "$gem_dir"
            fi
          done

      - name: Pull compatible versions of plugins
        if: (matrix.target == 'official-plugins' || matrix.target == 'plugins') && (github.ref_name != 'main' && github.base_ref != 'main')
        run: LOAD_PLUGINS=0 bin/rake plugin:pull_compatible_all

      - name: Checkout official themes
        if: matrix.target == 'themes'
        run: bin/rake themes:clone_all_official themes:pull_compatible_all

      - name: Add hosts to /etc/hosts, otherwise Chrome cannot reach minio
        if: matrix.build_type == 'system' && matrix.target == 'core'
        run: |
          echo "127.0.0.1 minio.local" | sudo tee -a /etc/hosts
          echo "127.0.0.1 discoursetest.minio.local" | sudo tee -a /etc/hosts

      - name: Get CPU cores
        id: cpu-info
        run: echo "cpu-cores=$(nproc)" >> $GITHUB_OUTPUT

      - name: Fetch app state cache
        uses: actions/cache@v5
        id: app-cache
        with:
          path: tmp/app-cache
          key: >-
            ${{ runner.os }}-
            ${{ steps.cpu-info.outputs.cpu-cores }}-
            ${{ hashFiles('.github/workflows/tests.yml') }}-
            ${{ hashFiles('db/**/*', 'plugins/**/db/**/*') }}-
            ${{ hashFiles('config/environments/test.rb') }}-
            ${{ env.LOAD_PLUGINS }}-
            ${{ env.USES_PARALLEL_DATABASES }}-
            ${{ env.PARALLEL_TEST_PROCESSORS }}-
            ${{ matrix.target }}-

      - name: Restore database from cache
        if: steps.app-cache.outputs.cache-hit == 'true'
        run: script/silence_successful_output psql --quiet -o /dev/null -f tmp/app-cache/cache.sql postgres

      - name: Restore uploads from cache
        if: steps.app-cache.outputs.cache-hit == 'true'
        run: rm -rf public/uploads && cp -r tmp/app-cache/uploads public/uploads

      - name: Create and migrate database
        run: |
          bin/rake db:create
          script/silence_successful_output bin/rake db:migrate

      - name: Create and migrate parallel databases
        if: env.USES_PARALLEL_DATABASES == 'true' && steps.app-cache.outputs.cache-hit != 'true'
        run: |
          bin/rake parallel:create
          script/silence_successful_output bin/rake parallel:migrate

      - name: Dump database for cache
        if: steps.app-cache.outputs.cache-hit != 'true'
        run: mkdir -p tmp/app-cache && pg_dumpall > tmp/app-cache/cache.sql

      - name: Dump uploads for cache
        if: steps.app-cache.outputs.cache-hit != 'true'
        run: rm -rf tmp/app-cache/uploads && cp -r public/uploads tmp/app-cache/uploads

      - name: Fetch turbo_rspec_runtime.log cache
        uses: actions/cache@v5
        id: test-runtime-cache
        if: matrix.build_type == 'backend' || matrix.build_type == 'system'
        with:
          path: tmp/turbo_rspec_runtime.log
          key: rspec-runtime-${{ matrix.build_type }}-${{ matrix.target }}-${{ github.run_id }}
          restore-keys: rspec-runtime-${{ matrix.build_type }}-${{ matrix.target }}-

      - name: Check Annotations
        if: matrix.build_type == 'backend' && (matrix.target == 'core' || matrix.target == 'plugins')
        run: |
          bin/rake annotate:ensure_all_indexes

          directories=""
          if [ "${{ matrix.target }}" = "core" ]; then
            directories="app/models"
          else
            directories="$(script/list_bundled_plugins | xargs -I{} find {} -type d -path '*/app/models' -maxdepth 3)"
          fi

          bin/annotaterb models --model-dir $(echo "${directories}" | tr '\n' ',')

          if [ ! -z "$(git status --porcelain ${directories})" ]; then
            echo "Core annotations are not up to date. To resolve, run:"
            echo "  bin/rake annotate:clean"
            echo
            echo "Or manually apply the diff printed below:"
            echo "---------------------------------------------"
            git -c color.ui=always diff ${directories}
            exit 1
          fi

      - name: Check Zeitwerk reloading
        if: matrix.build_type == 'backend'
        run: |
          if ! bin/rails runner 'Rails.application.reloader.reload!'; then
            echo
            echo "---------------------------------------------"
            echo
            echo "::error::Zeitwerk reload failed - the app will not be able to reload properly in development."
            echo "To reproduce locally, run \`bin/rails runner 'Rails.application.reloader.reload!'\`."
            echo
            exit 1
          fi

      - name: Check SKIP_DB_AND_REDIS bootability
        if: matrix.build_type == 'backend'
        run: |
          if ! (SKIP_DB_AND_REDIS=1 DISCOURSE_DEV_DB="nonexist" bin/rails runner "puts 'booted successfully'"); then
            echo
            echo "---------------------------------------------"
            echo
            echo "::error::SKIP_DB_AND_REDIS boot failed. Make sure the database is not being accessed during the Rails boot process."
            echo "To reproduce locally, run \`SKIP_DB_AND_REDIS=1 DISCOURSE_DEV_DB='nonexist' bin/rails runner \"puts 'booted successfully'\"\`."
            echo
            exit 1
          fi

      - name: Core RSpec
        if: matrix.build_type == 'backend' && matrix.target == 'core'
        run: bin/turbo_rspec --use-runtime-info --verbose --format=progress --profile=50

      - name: Ember Build
        if: matrix.build_type == 'system' || matrix.build_type == 'frontend'
        run: script/assemble_ember_build.rb

      - name: Core QUnit
        if: matrix.build_type == 'frontend' && matrix.target == 'core'
        run: QUNIT_WRITE_EXECUTION_FILE=1 bin/rake qunit:test

      - name: Asset Processor Tests
        if: matrix.build_type == 'frontend' && matrix.target == 'core'
        working-directory: frontend/asset-processor
        run: pnpm test

      - name: Plugins RSpec
        if: matrix.build_type == 'backend' && matrix.target == 'plugins'
        run: |
          bin/rake plugin:turbo_spec['*','--verbose --format=progress --use-runtime-info --profile=50']

      - name: Plugins QUnit
        if: matrix.build_type == 'frontend' && matrix.target == 'plugins'
        run: bin/rake 'plugin:qunit[*]'

      - name: Theme QUnit
        if: matrix.build_type == 'frontend' && matrix.target == 'themes'
        run: DISCOURSE_DEV_DB=discourse_test bin/rake themes:qunit_all_official

      - uses: actions/upload-artifact@v7
        if: always() && matrix.build_type == 'frontend'
        with:
          name: ember-exam-execution-${{ matrix.target }}-${{ matrix.browser }}-frontend-${{ hashFiles('./frontend/discourse/test-execution-*.json') }}
          path: ./frontend/discourse/test-execution-*.json

      - name: Install playwright
        if: matrix.build_type == 'system'
        run: pnpm playwright install --with-deps --no-shell chromium

      - name: Core System Tests
        if: matrix.build_type == 'system' && matrix.target == 'core'
        env:
          CHECKOUT_TIMEOUT: 10
        run: RAILS_ENABLE_TEST_LOG=1 RAILS_TEST_LOG_LEVEL=error bin/turbo_rspec --use-runtime-info --profile=50 --verbose --format documentation spec/system

      - name: Core Plugins System Tests
        if: matrix.build_type == 'system' && matrix.target == 'core-plugins'
        env:
          CHECKOUT_TIMEOUT: 10
        run: |
          RAILS_ENABLE_TEST_LOG=1 RAILS_TEST_LOG_LEVEL=error bin/turbo_rspec --exclude-pattern="plugins/chat/*" --use-runtime-info --profile=50 --verbose --format documentation plugins/*/spec/system

      - name: Official Plugins System Tests
        if: matrix.build_type == 'system' && matrix.target == 'official-plugins'
        env:
          CHECKOUT_TIMEOUT: 10
        run: |
          RAILS_ENABLE_TEST_LOG=1 RAILS_TEST_LOG_LEVEL=error bin/turbo_rspec --exclude-pattern="$(script/list_bundled_plugins '/*' | paste -sd, -)" --use-runtime-info --profile=50 --verbose --format documentation plugins/*/spec/system

      - name: Chat System Tests
        if: matrix.build_type == 'system' && matrix.target == 'chat'
        env:
          CHECKOUT_TIMEOUT: 10
        run: RAILS_ENABLE_TEST_LOG=1 RAILS_TEST_LOG_LEVEL=error bin/turbo_rspec --use-runtime-info --profile=50 --verbose --format documentation plugins/chat/spec/system

      - name: Theme System Tests
        if: matrix.build_type == 'system' && matrix.target == 'themes'
        env:
          CHECKOUT_TIMEOUT: 10
        run: RAILS_ENABLE_TEST_LOG=1 RAILS_TEST_LOG_LEVEL=error bin/turbo_rspec --profile=50 --verbose --format documentation tmp/themes/*/spec/system themes/*/spec/system

      - name: Check for failed system test artifacts
        id: check-failed-system-test-artifacts
        if: always() && matrix.build_type == 'system'
        run: |
          if [ -d tmp/capybara ] && [ "$(ls -A tmp/capybara/)" ]; then
            echo "exists=true" >> $GITHUB_OUTPUT
          else
            echo "exists=false" >> $GITHUB_OUTPUT
          fi

      - name: Upload failed system test artifacts
        uses: actions/upload-artifact@v7
        if: always() && steps.check-failed-system-test-artifacts.outputs.exists == 'true'
        with:
          name: failed-system-test-artifacts-${{ matrix.build_type }}-${{ matrix.target }}
          path: |
            tmp/capybara/*.png
            tmp/capybara/*.zip
            tmp/capybara/*.webm

      - name: Check for flaky tests report
        id: check-flaky-spec-report
        if: github.repository == 'discourse/discourse' && github.ref_name == 'main' && env.DISCOURSE_TURBO_RSPEC_RETRY_AND_LOG_FLAKY_TESTS == '1'
        run: |
          if [ -f tmp/turbo_rspec_flaky_tests.json ]; then
            echo "exists=true" >> $GITHUB_OUTPUT
          else
            echo "exists=false" >> $GITHUB_OUTPUT
          fi

      - name: Create flaky tests report artifact
        if: steps.check-flaky-spec-report.outputs.exists == 'true'
        run: cp tmp/turbo_rspec_flaky_tests.json tmp/turbo_rspec_flaky_tests-${{ matrix.build_type }}-${{ matrix.target }}-${{ job.check_run_id }}.json

      - name: Upload flaky tests report
        uses: actions/upload-artifact@v7
        if: steps.check-flaky-spec-report.outputs.exists == 'true'
        with:
          name: flaky-test-reports-${{ matrix.build_type }}-${{ matrix.target }}
          path: tmp/turbo_rspec_flaky_tests-${{ matrix.build_type }}-${{ matrix.target }}-${{ job.check_run_id }}.json

  merge:
    if: github.repository == 'discourse/discourse' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Merge Artifacts
        uses: actions/upload-artifact/merge@v7
        with:
          name: failed-system-test-artifacts
          pattern: failed-system-test-artifacts-*
          delete-merged: true
          separate-directories: false
        # Don't fail the job if there aren't any artifacts to merge.
        continue-on-error: true

      - name: Merge Artifacts
        uses: actions/upload-artifact/merge@v7
        with:
          name: flaky-test-reports
          pattern: flaky-test-reports-*
          delete-merged: true
          separate-directories: false
        # Don't fail the job if there aren't any artifacts to merge.
        continue-on-error: true
