name: Tests

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

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 }} # Update fetch-job-id step if changing this
    runs-on: ${{ (github.repository_owner == 'discourse' && 'debian-12') || 'ubuntu-latest' }}
    container: discourse/discourse_test:release
    timeout-minutes: 20

    env:
      DISCOURSE_HOSTNAME: www.example.com
      RAILS_ENV: test
      PGUSER: discourse
      PGPASSWORD: discourse
      USES_PARALLEL_DATABASES: ${{ matrix.build_type == 'backend' || matrix.build_type == 'system' }}
      CAPYBARA_DEFAULT_MAX_WAIT_TIME: 10
      MINIO_RUNNER_LOG_LEVEL: DEBUG
      DISCOURSE_TURBO_RSPEC_RETRY_AND_LOG_FLAKY_TESTS: ${{ (matrix.build_type == 'system' || matrix.build_type == 'backend') && '1' }}
      CHEAP_SOURCE_MAPS: "1"
      TESTEM_DEFAULT_BROWSER: Chrome
      MINIO_RUNNER_INSTALL_DIR: /home/discourse/.minio_runner

    strategy:
      fail-fast: false

      matrix:
        build_type: [backend, frontend, system, annotations]
        target: [core, plugins, themes]
        exclude:
          - build_type: annotations
            target: plugins
          - build_type: annotations
            target: themes
          - build_type: backend
            target: themes
          - build_type: frontend
            target: core # Handled by core_frontend_tests job (below)
        include:
          - build_type: system
            target: chat

    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@v4
        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: Container envs
        id: container-envs
        run: |
          echo "ruby_version=$RUBY_VERSION" >> $GITHUB_OUTPUT
          echo "debian_release=$DEBIAN_RELEASE" >> $GITHUB_OUTPUT
        shell: bash

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

      - name: Setup gems
        run: |
          gem install bundler --conservative -v $(awk '/BUNDLED WITH/ { getline; gsub(/ /,""); print $0 }' Gemfile.lock)
          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 == 'plugins'
        run: bin/rake plugin:install_all_official

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

            if [ -d "$dest" ]; then
              echo "Skipping $plugin_name: Source gems directory exists"
            else
              echo "Symlinking $dir to $dest"
              ln -s "$dir" "$dest"
            fi
          done

      - name: Pull compatible versions of plugins
        if: matrix.target == 'plugins' && (github.ref_name != 'main' && github.base_ref != 'main')
        run: 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@v4
        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.USES_PARALLEL_DATABASES }}-
            ${{ env.PARALLEL_TEST_PROCESSORS }}-

      - 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
        if: steps.app-cache.outputs.cache-hit != 'true'
        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@v4
        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 Zeitwerk reloading
        if: matrix.build_type == 'backend'
        env:
          LOAD_PLUGINS: ${{ (matrix.target == 'plugins') && '1' || '0' }}
        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: Core RSpec
        if: matrix.build_type == 'backend' && matrix.target == 'core'
        run: bin/turbo_rspec --use-runtime-info --verbose --format=documentation --profile=50

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

      - name: Plugin QUnit
        if: matrix.build_type == 'frontend' && matrix.target == 'plugins'
        run: QUNIT_WRITE_EXECUTION_FILE=1 bin/rake plugin:qunit['*']
        timeout-minutes: 30

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

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

      - name: Ember Build for System Tests
        if: matrix.build_type == 'system'
        run: bin/ember-cli --build

      - name: Ensure latest minio binary installed for Core System Tests
        if: matrix.build_type == 'system' && matrix.target == 'core'
        run: bundle exec ruby script/install_minio_binaries.rb

      - 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: Plugin System Tests
        if: matrix.build_type == 'system' && matrix.target == 'plugins'
        env:
          CHECKOUT_TIMEOUT: 10
        run: |
          GLOBIGNORE="plugins/chat/*";
          LOAD_PLUGINS=1 RAILS_ENABLE_TEST_LOG=1 RAILS_TEST_LOG_LEVEL=error bin/turbo_rspec --use-runtime-info --profile=50 --verbose --format documentation plugins/*/spec/system
        shell: bash
        timeout-minutes: 30

      - name: Chat System Tests
        if: matrix.build_type == 'system' && matrix.target == 'chat'
        env:
          CHECKOUT_TIMEOUT: 10
        run: LOAD_PLUGINS=1 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
        timeout-minutes: 30

      - 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
        shell: bash
        timeout-minutes: 30

      - name: Check for failed system test screenshots
        id: check-failed-system-test-screenshots
        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
        shell: bash

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

      - name: Check for flaky tests report
        id: check-flaky-spec-report
        if: github.repository == 'discourse/discourse' && 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
        shell: bash

      - name: Fetch Job ID
        id: fetch-job-id
        if: steps.check-flaky-spec-report.outputs.exists == 'true'
        run: |
          job_id=$(ruby script/get_github_workflow_run_job_id.rb ${{ github.run_id }} ${{ github.run_attempt }} '${{ matrix.target }} ${{ matrix.build_type }}')
          echo "job_id=$job_id" >> $GITHUB_OUTPUT

      - 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 }}-${{ steps.fetch-job-id.outputs.job_id }}.json

      - name: Upload flaky tests report
        uses: actions/upload-artifact@v4
        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 }}-${{ steps.fetch-job-id.outputs.job_id }}.json

      - name: Check Annotations
        if: matrix.build_type == 'annotations'
        run: |
          bin/rake annotate:ensure_all_indexes
          bin/annotate --models --model-dir app/models

          if [ ! -z "$(git status --porcelain app/models/)" ]; 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 app/models/
            exit 1
          fi
        timeout-minutes: 30

  core_frontend_tests:
    if: github.event_name == 'pull_request' || github.repository != 'discourse/discourse-private-mirror'
    name: core frontend (${{ matrix.browser }})
    runs-on: ${{ (github.repository_owner == 'discourse' && 'debian-12') || 'ubuntu-latest' }}
    container:
      image: discourse/discourse_test:release
      options: --user discourse

    timeout-minutes: 35

    strategy:
      fail-fast: false
      matrix:
        browser: ["Chrome", "Firefox ESR", "Firefox Evergreen"]

    env:
      TESTEM_BROWSER: ${{ (startsWith(matrix.browser, 'Firefox') && 'Firefox') || matrix.browser }}
      TESTEM_FIREFOX_PATH: ${{ (matrix.browser == 'Firefox Evergreen') && '/opt/firefox-evergreen/firefox' }}
      CHEAP_SOURCE_MAPS: "1"

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 1

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

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

      - name: Ember Build
        working-directory: ./app/assets/javascripts/discourse
        run: |
          mkdir /tmp/emberbuild
          pnpm ember build --environment=test -o /tmp/emberbuild

      - name: Core QUnit
        working-directory: ./app/assets/javascripts/discourse
        run: |
          pnpm ember exam --path /tmp/emberbuild --load-balance --parallel=$(($(nproc) / 2)) --launch "${{ env.TESTEM_BROWSER }}" --write-execution-file --random
        timeout-minutes: 15

      - uses: actions/upload-artifact@v4
        if: ${{ always() }}
        with:
          name: ember-exam-execution-${{ matrix.browser }}-${{ hashFiles('./app/assets/javascripts/discourse/test-execution-*.json') }}
          path: ./app/assets/javascripts/discourse/test-execution-*.json

  merge:
    if: github.repository == 'discourse/discourse' && github.ref == 'refs/heads/main'
    runs-on: debian-12
    needs: build
    steps:
      - name: Merge Artifacts
        uses: actions/upload-artifact/merge@v4
        with:
          name: failed-system-test-screenshots
          pattern: failed-system-test-screenshots-*
          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@v4
        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