From e58eaec414ea3f94071c218efb1b64cb26de6519 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me>
Date: Wed, 24 Nov 2021 22:59:07 +0000
Subject: [PATCH] Automatically detect Jest tests to run upon backend changes

---
 .gitlab-ci.yml                                |   1 +
 .gitlab/ci/rules.gitlab-ci.yml                |  10 ++
 .gitlab/ci/setup.gitlab-ci.yml                |  24 +++-
 .gitlab/ci/test-metadata.gitlab-ci.yml        |   3 +-
 scripts/api/get_job_id.rb                     |   8 +-
 scripts/rspec_helpers.sh                      | 103 ++++++++++++++++--
 spec/support/frontend_fixtures.rb             |  16 +++
 .../helpers/javascript_fixtures_helpers.rb    |   6 +
 tooling/bin/find_changes                      |  79 ++++++++++++--
 9 files changed, 223 insertions(+), 27 deletions(-)
 create mode 100644 spec/support/frontend_fixtures.rb

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index e810edd813397..2ea4eb0de6561 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -72,6 +72,7 @@ variables:
   FLAKY_RSPEC_SUITE_REPORT_PATH: rspec_flaky/report-suite.json
   RSPEC_TESTS_MAPPING_PATH: crystalball/mapping.json
   RSPEC_PACKED_TESTS_MAPPING_PATH: crystalball/packed-mapping.json
+  FRONTEND_FIXTURES_MAPPING_PATH: crystalball/frontend_fixtures_mapping.json
 
   ES_JAVA_OPTS: "-Xms256m -Xmx256m"
   ELASTIC_URL: "http://elastic:changeme@elasticsearch:9200"
diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml
index 0b4f7ca1dd9fe..efc65431d5076 100644
--- a/.gitlab/ci/rules.gitlab-ci.yml
+++ b/.gitlab/ci/rules.gitlab-ci.yml
@@ -1694,6 +1694,16 @@
       changes: *code-backstage-patterns
       when: on_success
 
+.setup:rules:generate-frontend-fixtures-mapping:
+  rules:
+    - <<: *if-not-ee
+      when: never
+    - <<: *if-dot-com-ee-2-hourly-schedule
+    - changes:
+        - ".gitlab/ci/setup.gitlab-ci.yml"
+        - ".gitlab/ci/test-metadata.gitlab-ci.yml"
+        - "scripts/rspec_helpers.sh"
+
 .setup:rules:add-jh-folder:
   rules:
     - <<: *if-not-ee
diff --git a/.gitlab/ci/setup.gitlab-ci.yml b/.gitlab/ci/setup.gitlab-ci.yml
index 4c674f3893936..1eb3bd2ea4174 100644
--- a/.gitlab/ci/setup.gitlab-ci.yml
+++ b/.gitlab/ci/setup.gitlab-ci.yml
@@ -68,6 +68,24 @@ verify-tests-yml:
     - install_tff_gem
     - scripts/verify-tff-mapping
 
+generate-frontend-fixtures-mapping:
+  extends:
+    - .setup:rules:generate-frontend-fixtures-mapping
+    - .use-pg12
+    - .rails-cache
+  needs: ["setup-test-env"]
+  stage: prepare
+  before_script:
+    - !reference [.default-before_script, before_script]
+    - source ./scripts/rspec_helpers.sh
+    - run_timed_command "scripts/gitaly-test-spawn"
+  script:
+    - generate_frontend_fixtures_mapping
+  artifacts:
+    expire_in: 7d
+    paths:
+      - ${FRONTEND_FIXTURES_MAPPING_PATH}
+
 .detect-test-base:
   image: ${GITLAB_DEPENDENCY_PROXY}ruby:2.7
   needs: []
@@ -78,17 +96,21 @@ verify-tests-yml:
     - install_gitlab_gem
     - install_tff_gem
     - retrieve_tests_mapping
+    - retrieve_frontend_fixtures_mapping
     - |
       if [ -n "$CI_MERGE_REQUEST_IID" ]; then
         tooling/bin/find_changes ${CHANGES_FILE};
         tooling/bin/find_tests ${CHANGES_FILE} ${MATCHED_TESTS_FILE};
-        echo "related rspec tests: $(cat $MATCHED_TESTS_FILE)";
+        tooling/bin/find_changes ${CHANGES_FILE} ${MATCHED_TESTS_FILE} ${FRONTEND_FIXTURES_MAPPING_PATH};
+        echo "Changed files: $(cat $CHANGES_FILE)";
+        echo "Related rspec tests: $(cat $MATCHED_TESTS_FILE)";
       fi
   artifacts:
     expire_in: 7d
     paths:
       - ${CHANGES_FILE}
       - ${MATCHED_TESTS_FILE}
+      - ${FRONTEND_FIXTURES_MAPPING_PATH}
 
 detect-tests:
   extends:
diff --git a/.gitlab/ci/test-metadata.gitlab-ci.yml b/.gitlab/ci/test-metadata.gitlab-ci.yml
index 2d96fb6d4b07c..d0d45cb929438 100644
--- a/.gitlab/ci/test-metadata.gitlab-ci.yml
+++ b/.gitlab/ci/test-metadata.gitlab-ci.yml
@@ -8,7 +8,7 @@
       - knapsack/
       - rspec_flaky/
       - rspec_profiling/
-      - crystalball/packed-mapping.json.gz
+      - crystalball/
 
 retrieve-tests-metadata:
   extends:
@@ -27,6 +27,7 @@ update-tests-metadata:
   stage: post-test
   dependencies:
     - retrieve-tests-metadata
+    - generate-frontend-fixtures-mapping
     - setup-test-env
     - rspec migration pg12
     - rspec-all frontend_fixture
diff --git a/scripts/api/get_job_id.rb b/scripts/api/get_job_id.rb
index 166c919895109..c32299706ba27 100755
--- a/scripts/api/get_job_id.rb
+++ b/scripts/api/get_job_id.rb
@@ -10,6 +10,7 @@ class JobFinder
     pipeline_query: {}.freeze,
     job_query: {}.freeze
   ).freeze
+  MAX_PIPELINES_TO_ITERATE = 200
 
   def initialize(options)
     @project = options.delete(:project)
@@ -41,8 +42,11 @@ def execute
   def find_job_with_artifact
     return if artifact_path.nil?
 
-    client.pipelines(project, pipeline_query_params).auto_paginate do |pipeline|
+    client.pipelines(project, pipeline_query_params).paginate_with_limit(MAX_PIPELINES_TO_ITERATE) do |pipeline|
+      $stderr.puts "Iterating over #{pipeline}" # rubocop:disable Style/StderrPuts
       client.pipeline_jobs(project, pipeline.id, job_query_params).auto_paginate do |job|
+        next if job_name && !found_job_by_name?(job)
+
         return job if found_job_with_artifact?(job) # rubocop:disable Cop/AvoidReturnFromBlocks
       end
     end
@@ -53,7 +57,7 @@ def find_job_with_artifact
   def find_job_with_filtered_pipelines
     return if pipeline_query.empty?
 
-    client.pipelines(project, pipeline_query_params).auto_paginate do |pipeline|
+    client.pipelines(project, pipeline_query_params).paginate_with_limit(MAX_PIPELINES_TO_ITERATE) do |pipeline|
       client.pipeline_jobs(project, pipeline.id, job_query_params).auto_paginate do |job|
         return job if found_job_by_name?(job) # rubocop:disable Cop/AvoidReturnFromBlocks
       end
diff --git a/scripts/rspec_helpers.sh b/scripts/rspec_helpers.sh
index 8714f1c0060bd..70a63695f5eb6 100644
--- a/scripts/rspec_helpers.sh
+++ b/scripts/rspec_helpers.sh
@@ -16,17 +16,27 @@ function retrieve_tests_metadata() {
     # always target the canonical project here, so the branch must be hardcoded
     local project_path="gitlab-org/gitlab"
     local artifact_branch="master"
+    local username="gitlab-bot"
+    local job_name="update-tests-metadata"
     local test_metadata_job_id
 
     # Ruby
-    test_metadata_job_id=$(scripts/api/get_job_id.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" -q "status=success" -q "ref=${artifact_branch}" -q "username=gitlab-bot" -Q "scope=success" --job-name "update-tests-metadata")
-
-    if [[ ! -f "${KNAPSACK_RSPEC_SUITE_REPORT_PATH}" ]]; then
-      scripts/api/download_job_artifact.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" --job-id "${test_metadata_job_id}" --artifact-path "${KNAPSACK_RSPEC_SUITE_REPORT_PATH}" || echo "{}" > "${KNAPSACK_RSPEC_SUITE_REPORT_PATH}"
-    fi
-
-    if [[ ! -f "${FLAKY_RSPEC_SUITE_REPORT_PATH}" ]]; then
-      scripts/api/download_job_artifact.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" --job-id "${test_metadata_job_id}" --artifact-path "${FLAKY_RSPEC_SUITE_REPORT_PATH}" || echo "{}" > "${FLAKY_RSPEC_SUITE_REPORT_PATH}"
+    test_metadata_job_id=$(scripts/api/get_job_id.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" -q "status=success" -q "ref=${artifact_branch}" -q "username=${username}" -Q "scope=success" --job-name "${job_name}")
+
+    if [[ -n "${test_metadata_job_id}" ]]; then
+      echo "test_metadata_job_id: ${test_metadata_job_id}"
+
+      if [[ ! -f "${KNAPSACK_RSPEC_SUITE_REPORT_PATH}" ]]; then
+        scripts/api/download_job_artifact.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" --job-id "${test_metadata_job_id}" --artifact-path "${KNAPSACK_RSPEC_SUITE_REPORT_PATH}" || echo "{}" > "${KNAPSACK_RSPEC_SUITE_REPORT_PATH}"
+      fi
+
+      if [[ ! -f "${FLAKY_RSPEC_SUITE_REPORT_PATH}" ]]; then
+        scripts/api/download_job_artifact.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" --job-id "${test_metadata_job_id}" --artifact-path "${FLAKY_RSPEC_SUITE_REPORT_PATH}" || echo "{}" > "${FLAKY_RSPEC_SUITE_REPORT_PATH}"
+      fi
+    else
+      echo "test_metadata_job_id couldn't be found!"
+      echo "{}" > "${KNAPSACK_RSPEC_SUITE_REPORT_PATH}"
+      echo "{}" > "${FLAKY_RSPEC_SUITE_REPORT_PATH}"
     fi
   fi
 }
@@ -61,18 +71,63 @@ function retrieve_tests_mapping() {
     # always target the canonical project here, so the branch must be hardcoded
     local project_path="gitlab-org/gitlab"
     local artifact_branch="master"
+    local username="gitlab-bot"
+    local job_name="update-tests-metadata"
     local test_metadata_with_mapping_job_id
 
-    test_metadata_with_mapping_job_id=$(scripts/api/get_job_id.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" -q "status=success" -q "ref=${artifact_branch}" -q "username=gitlab-bot" -Q "scope=success" --job-name "update-tests-metadata" --artifact-path "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz")
+    test_metadata_with_mapping_job_id=$(scripts/api/get_job_id.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" -q "status=success" -q "ref=${artifact_branch}" -q "username=${username}" -Q "scope=success" --job-name "${job_name}")
 
-    if [[ ! -f "${RSPEC_PACKED_TESTS_MAPPING_PATH}" ]]; then
-     (scripts/api/download_job_artifact.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" --job-id "${test_metadata_with_mapping_job_id}" --artifact-path "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz" && gzip -d "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz") || echo "{}" > "${RSPEC_PACKED_TESTS_MAPPING_PATH}"
+    if [[ -n "${test_metadata_with_mapping_job_id}" ]]; then
+      echo "test_metadata_with_mapping_job_id: ${test_metadata_with_mapping_job_id}"
+
+      if [[ ! -f "${RSPEC_PACKED_TESTS_MAPPING_PATH}" ]]; then
+        (scripts/api/download_job_artifact.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" --job-id "${test_metadata_with_mapping_job_id}" --artifact-path "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz" && gzip -d "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz") || echo "{}" > "${RSPEC_PACKED_TESTS_MAPPING_PATH}"
+      fi
+    else
+      echo "test_metadata_with_mapping_job_id couldn't be found!"
+      echo "{}" > "${RSPEC_PACKED_TESTS_MAPPING_PATH}"
     fi
   fi
 
   scripts/unpack-test-mapping "${RSPEC_PACKED_TESTS_MAPPING_PATH}" "${RSPEC_TESTS_MAPPING_PATH}"
 }
 
+function retrieve_frontend_fixtures_mapping() {
+  mkdir -p $(dirname "$FRONTEND_FIXTURES_MAPPING_PATH")
+
+  if [[ -n "${RETRIEVE_TESTS_METADATA_FROM_PAGES}" ]]; then
+    if [[ ! -f "${FRONTEND_FIXTURES_MAPPING_PATH}" ]]; then
+      (curl --location  -o "${FRONTEND_FIXTURES_MAPPING_PATH}" "https://gitlab-org.gitlab.io/gitlab/${FRONTEND_FIXTURES_MAPPING_PATH}") || echo "{}" > "${FRONTEND_FIXTURES_MAPPING_PATH}"
+    fi
+  else
+    # ${CI_DEFAULT_BRANCH} might not be master in other forks but we want to
+    # always target the canonical project here, so the branch must be hardcoded
+    local project_path="gitlab-org/gitlab"
+    local artifact_branch="master"
+    local username="gitlab-bot"
+    local job_name="generate-frontend-fixtures-mapping"
+    local test_metadata_with_mapping_job_id
+
+    # On the MR that introduces 'generate-frontend-fixtures-mapping', we cannot retrieve the file from a master scheduled pipeline, so we take it from a known MR pipeline
+    if [[ "${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}" == "339343-execute-related-jests-specs-for-mrs-with-backend-changes" ]]; then
+      test_metadata_with_mapping_job_id=$(scripts/api/get_job_id.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" --pipeline-id "414921396" -Q "scope=success" --job-name "${job_name}")
+    else
+      test_metadata_with_mapping_job_id=$(scripts/api/get_job_id.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" -q "ref=${artifact_branch}" -q "username=${username}" -Q "scope=success" --job-name "${job_name}")
+    fi
+
+    if [[ $? -eq 0 ]] && [[ -n "${test_metadata_with_mapping_job_id}" ]]; then
+      echo "test_metadata_with_mapping_job_id: ${test_metadata_with_mapping_job_id}"
+
+      if [[ ! -f "${FRONTEND_FIXTURES_MAPPING_PATH}" ]]; then
+        (scripts/api/download_job_artifact.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" --job-id "${test_metadata_with_mapping_job_id}" --artifact-path "${FRONTEND_FIXTURES_MAPPING_PATH}") || echo "{}" > "${FRONTEND_FIXTURES_MAPPING_PATH}"
+      fi
+    else
+      echo "test_metadata_with_mapping_job_id couldn't be found!"
+      echo "{}" > "${FRONTEND_FIXTURES_MAPPING_PATH}"
+    fi
+  fi
+}
+
 function update_tests_mapping() {
   if ! crystalball_rspec_data_exists; then
     echo "No crystalball rspec data found."
@@ -113,7 +168,7 @@ function rspec_simple_job() {
 
   export NO_KNAPSACK="1"
 
-  bin/rspec -Ispec -rspec_helper --color --format documentation --format RspecJunitFormatter --out junit_rspec.xml ${rspec_opts}
+  eval "bin/rspec -Ispec -rspec_helper --color --format documentation --format RspecJunitFormatter --out junit_rspec.xml ${rspec_opts}"
 }
 
 function rspec_db_library_code() {
@@ -256,3 +311,27 @@ function rspec_matched_foss_tests() {
     echo "No impacted FOSS rspec tests to run"
   fi
 }
+
+function generate_frontend_fixtures_mapping() {
+  local pattern=""
+
+  if [[ -d "ee/" ]]; then
+    pattern=",ee/"
+  fi
+
+  if [[ -d "jh/" ]]; then
+    pattern="${pattern},jh/"
+  fi
+
+  if [[ -n "${pattern}" ]]; then
+    pattern="{${pattern}}"
+  fi
+
+  pattern="${pattern}spec/frontend/fixtures/**/*.rb"
+
+  export GENERATE_FRONTEND_FIXTURES_MAPPING="true"
+
+  mkdir -p $(dirname "$FRONTEND_FIXTURES_MAPPING_PATH")
+
+  rspec_simple_job "--pattern \"${pattern}\""
+}
diff --git a/spec/support/frontend_fixtures.rb b/spec/support/frontend_fixtures.rb
new file mode 100644
index 0000000000000..5587d9059dd01
--- /dev/null
+++ b/spec/support/frontend_fixtures.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+return unless ENV['CI']
+return unless ENV['GENERATE_FRONTEND_FIXTURES_MAPPING'] == 'true'
+
+RSpec.configure do |config|
+  config.before(:suite) do
+    $fixtures_mapping = Hash.new { |h, k| h[k] = [] } # rubocop:disable Style/GlobalVars
+  end
+
+  config.after(:suite) do
+    next unless ENV['FRONTEND_FIXTURES_MAPPING_PATH']
+
+    File.write(ENV['FRONTEND_FIXTURES_MAPPING_PATH'], $fixtures_mapping.to_json) # rubocop:disable Style/GlobalVars
+  end
+end
diff --git a/spec/support/helpers/javascript_fixtures_helpers.rb b/spec/support/helpers/javascript_fixtures_helpers.rb
index fb909008f1229..84cd0181533b5 100644
--- a/spec/support/helpers/javascript_fixtures_helpers.rb
+++ b/spec/support/helpers/javascript_fixtures_helpers.rb
@@ -13,6 +13,12 @@ module JavaScriptFixturesHelpers
 
   included do |base|
     base.around do |example|
+      # Don't actually run the example when we're only interested in the `test file -> JSON frontend fixture` mapping
+      if ENV['GENERATE_FRONTEND_FIXTURES_MAPPING'] == 'true'
+        $fixtures_mapping[example.metadata[:file_path].delete_prefix('./')] << File.join(fixture_root_path, example.description) # rubocop:disable Style/GlobalVars
+        next
+      end
+
       # pick an arbitrary date from the past, so tests are not time dependent
       # Also see spec/frontend/__helpers__/fake_date/jest.js
       Timecop.freeze(Time.utc(2015, 7, 3, 10)) { example.run }
diff --git a/tooling/bin/find_changes b/tooling/bin/find_changes
index 20df085879aee..c6b8bafbd8526 100755
--- a/tooling/bin/find_changes
+++ b/tooling/bin/find_changes
@@ -3,19 +3,76 @@
 
 require 'gitlab'
 
-gitlab_token = ENV.fetch('PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE', '')
-gitlab_endpoint = ENV.fetch('CI_API_V4_URL')
-mr_project_path = ENV.fetch('CI_MERGE_REQUEST_PROJECT_PATH')
-mr_iid = ENV.fetch('CI_MERGE_REQUEST_IID')
+class FindChanges # rubocop:disable Gitlab/NamespacedClass
+  def initialize(output_file:, matched_tests_file: nil, frontend_fixtures_mapping_path: nil)
+    @gitlab_token = ENV.fetch('PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE', '')
+    @gitlab_endpoint = ENV.fetch('CI_API_V4_URL')
+    @mr_project_path = ENV.fetch('CI_MERGE_REQUEST_PROJECT_PATH')
+    @mr_iid = ENV.fetch('CI_MERGE_REQUEST_IID')
+    @output_file = output_file
+    @matched_tests_file = matched_tests_file
+    @frontend_fixtures_mapping_path = frontend_fixtures_mapping_path
+  end
 
-output_file = ARGV.shift
+  def execute
+    add_frontend_fixture_files!
+
+    File.write(output_file, file_changes.join(' '))
+  end
+
+  private
+
+  def add_frontend_fixture_files?
+    matched_tests_file && frontend_fixtures_mapping_path
+  end
+
+  def add_frontend_fixture_files!
+    return unless add_frontend_fixture_files?
+
+    # If we have a `test file -> JSON frontend fixture` mapping file, we add the files JSON frontend fixtures
+    # files to the list of changed files so that Jest can automatically run the dependent tests thanks to --findRelatedTests
+    test_files.each do |test_file|
+      file_changes.concat(frontend_fixtures_mapping[test_file]) if frontend_fixtures_mapping.key?(test_file)
+    end
+  end
+
+  def file_changes
+    @file_changes ||=
+      if File.exist?(output_file)
+        File.read(output_file).split(' ')
+      else
+        Gitlab.configure do |config|
+          config.endpoint      = gitlab_endpoint
+          config.private_token = gitlab_token
+        end
+
+        mr_changes = Gitlab.merge_request_changes(mr_project_path, mr_iid)
 
-Gitlab.configure do |config|
-  config.endpoint       = gitlab_endpoint
-  config.private_token  = gitlab_token
+        mr_changes.changes.map { |change| change['new_path'] }
+      end
+  end
+
+  def test_files
+    return [] if !matched_tests_file || !File.exist?(matched_tests_file)
+
+    File.read(matched_tests_file).split(' ')
+  end
+
+  def frontend_fixtures_mapping
+    return {} if !frontend_fixtures_mapping_path || !File.exist?(frontend_fixtures_mapping_path)
+
+    JSON.parse(File.read(frontend_fixtures_mapping_path)) # rubocop:disable Gitlab/Json
+  end
+
+  attr_reader :gitlab_token, :gitlab_endpoint, :mr_project_path, :mr_iid, :output_file, :matched_tests_file, :frontend_fixtures_mapping_path
 end
 
-mr_changes = Gitlab.merge_request_changes(mr_project_path, mr_iid)
-file_changes = mr_changes.changes.map { |change| change['new_path'] }
+output_file = ARGV.shift
+raise ArgumentError, "An path to an output file must be given as first argument of #{__FILE__}." if output_file.nil?
+
+matched_tests_file = ARGV.shift
+frontend_fixtures_mapping_path = ARGV.shift
 
-File.write(output_file, file_changes.join(' '))
+FindChanges
+  .new(output_file: output_file, matched_tests_file: matched_tests_file, frontend_fixtures_mapping_path: frontend_fixtures_mapping_path)
+  .execute
-- 
GitLab