From d889ffa00641e4d84fd08eb83903915c42256aa6 Mon Sep 17 00:00:00 2001
From: David Dieulivol <ddieulivol@gitlab.com>
Date: Wed, 11 Jan 2023 18:10:10 +0000
Subject: [PATCH] Make "jest minimal" CI jobs smarter with an "HTML->JS
 mapping"

---
 .gitlab-ci.yml                                |  25 +-
 .gitlab/ci/frontend.gitlab-ci.yml             |   4 +-
 .gitlab/ci/setup.gitlab-ci.yml                |   7 +-
 .../_saml_group_links.html.haml               |   2 +-
 .../_ldap_group_links.html.haml               |   2 +-
 package.json                                  |   2 +-
 .../lib/tooling/view_to_js_mappings_spec.rb   | 343 ++++++++++++++++++
 tooling/bin/view_to_js_mappings               |  10 +
 tooling/lib/tooling/view_to_js_mappings.rb    |  74 ++++
 9 files changed, 450 insertions(+), 19 deletions(-)
 create mode 100644 spec/tooling/lib/tooling/view_to_js_mappings_spec.rb
 create mode 100755 tooling/bin/view_to_js_mappings
 create mode 100644 tooling/lib/tooling/view_to_js_mappings.rb

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 79c402c7fa7f3..9c7b66676a8ab 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -126,22 +126,23 @@ variables:
   RUBY_VERSION: "2.7"
   GO_VERSION: "1.18"
 
-  TMP_TEST_FOLDER: "${CI_PROJECT_DIR}/tmp/tests"
-  GITLAB_WORKHORSE_FOLDER: "gitlab-workhorse"
-  TMP_TEST_GITLAB_WORKHORSE_PATH: "${TMP_TEST_FOLDER}/${GITLAB_WORKHORSE_FOLDER}"
-  KNAPSACK_RSPEC_SUITE_REPORT_PATH: knapsack/report-master.json
   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
-  RSPEC_PROFILING_FOLDER_PATH: rspec/profiling
   FRONTEND_FIXTURES_MAPPING_PATH: crystalball/frontend_fixtures_mapping.json
-  RSPEC_CHANGED_FILES_PATH: rspec/changed_files.txt
-  RSPEC_MATCHING_TESTS_PATH: rspec/matching_tests.txt
-  RSPEC_MATCHING_TESTS_FOSS_PATH: rspec/matching_tests-foss.txt
-  RSPEC_LAST_RUN_RESULTS_FILE: rspec/rspec_last_run_results.txt
-  RSPEC_FOSS_IMPACT_PIPELINE_YML: rspec-foss-impact-pipeline.yml
+  GITLAB_WORKHORSE_FOLDER: "gitlab-workhorse"
   JUNIT_RESULT_FILE: rspec/junit_rspec.xml
   JUNIT_RETRY_FILE: rspec/junit_rspec-retry.xml
+  KNAPSACK_RSPEC_SUITE_REPORT_PATH: knapsack/report-master.json
+  RSPEC_CHANGED_FILES_PATH: rspec/changed_files.txt
+  RSPEC_FOSS_IMPACT_PIPELINE_YML: rspec-foss-impact-pipeline.yml
+  RSPEC_LAST_RUN_RESULTS_FILE: rspec/rspec_last_run_results.txt
+  RSPEC_MATCHING_JS_FILES_PATH: rspec/js_matching_files.txt
+  RSPEC_MATCHING_TESTS_FOSS_PATH: rspec/matching_tests-foss.txt
+  RSPEC_MATCHING_TESTS_PATH: rspec/matching_tests.txt
+  RSPEC_PACKED_TESTS_MAPPING_PATH: crystalball/packed-mapping.json
+  RSPEC_PROFILING_FOLDER_PATH: rspec/profiling
+  RSPEC_TESTS_MAPPING_PATH: crystalball/mapping.json
+  TMP_TEST_FOLDER: "${CI_PROJECT_DIR}/tmp/tests"
+  TMP_TEST_GITLAB_WORKHORSE_PATH: "${TMP_TEST_FOLDER}/${GITLAB_WORKHORSE_FOLDER}"
 
   ES_JAVA_OPTS: "-Xms256m -Xmx256m"
   ELASTIC_URL: "http://elastic:changeme@elasticsearch:9200"
diff --git a/.gitlab/ci/frontend.gitlab-ci.yml b/.gitlab/ci/frontend.gitlab-ci.yml
index cde023c149a18..a309adb071550 100644
--- a/.gitlab/ci/frontend.gitlab-ci.yml
+++ b/.gitlab/ci/frontend.gitlab-ci.yml
@@ -212,7 +212,7 @@ jest minimal:
     - !reference [jest, needs]
     - "detect-tests"
   script:
-    - if [[ -s "$RSPEC_CHANGED_FILES_PATH" ]]; then run_timed_command "yarn jest:ci:minimal"; fi
+    - if [[ -s "$RSPEC_CHANGED_FILES_PATH" ]] || [[ -s "$RSPEC_MATCHING_JS_FILES_PATH" ]]; then run_timed_command "yarn jest:ci:minimal"; fi
 
 jest as-if-foss:
   extends:
@@ -231,7 +231,7 @@ jest minimal as-if-foss:
     - "rspec-all frontend_fixture as-if-foss"
     - "detect-tests"
   script:
-    - if [[ -s "$RSPEC_CHANGED_FILES_PATH" ]]; then run_timed_command "yarn jest:ci:minimal"; fi
+    - if [[ -s "$RSPEC_CHANGED_FILES_PATH" ]] || [[ -s "$RSPEC_MATCHING_JS_FILES_PATH" ]]; then run_timed_command "yarn jest:ci:minimal"; fi
 
 jest-integration:
   extends:
diff --git a/.gitlab/ci/setup.gitlab-ci.yml b/.gitlab/ci/setup.gitlab-ci.yml
index b7e69314c972b..326b32435b134 100644
--- a/.gitlab/ci/setup.gitlab-ci.yml
+++ b/.gitlab/ci/setup.gitlab-ci.yml
@@ -137,17 +137,20 @@ detect-tests:
         tooling/bin/find_tests ${RSPEC_CHANGED_FILES_PATH} ${RSPEC_MATCHING_TESTS_PATH};
         tooling/bin/find_changes ${RSPEC_CHANGED_FILES_PATH} ${RSPEC_MATCHING_TESTS_PATH} ${FRONTEND_FIXTURES_MAPPING_PATH};
         filter_rspec_matched_foss_tests ${RSPEC_MATCHING_TESTS_PATH} ${RSPEC_MATCHING_TESTS_FOSS_PATH};
+        tooling/bin/view_to_js_mappings ${RSPEC_CHANGED_FILES_PATH} ${RSPEC_MATCHING_JS_FILES_PATH};
         echoinfo "Changed files: $(cat $RSPEC_CHANGED_FILES_PATH)";
         echoinfo "Related RSpec tests: $(cat $RSPEC_MATCHING_TESTS_PATH)";
         echoinfo "Related FOSS RSpec tests: $(cat $RSPEC_MATCHING_TESTS_FOSS_PATH)";
+        echoinfo "Related JS files: $(cat $RSPEC_MATCHING_JS_FILES_PATH)";
       fi
   artifacts:
     expire_in: 7d
     paths:
+      - ${FRONTEND_FIXTURES_MAPPING_PATH}
       - ${RSPEC_CHANGED_FILES_PATH}
-      - ${RSPEC_MATCHING_TESTS_PATH}
+      - ${RSPEC_MATCHING_JS_FILES_PATH}
       - ${RSPEC_MATCHING_TESTS_FOSS_PATH}
-      - ${FRONTEND_FIXTURES_MAPPING_PATH}
+      - ${RSPEC_MATCHING_TESTS_PATH}
 
 detect-previous-failed-tests:
   extends:
diff --git a/ee/app/views/groups/saml_group_links/_saml_group_links.html.haml b/ee/app/views/groups/saml_group_links/_saml_group_links.html.haml
index 3d4507428f191..ef3810936a487 100644
--- a/ee/app/views/groups/saml_group_links/_saml_group_links.html.haml
+++ b/ee/app/views/groups/saml_group_links/_saml_group_links.html.haml
@@ -6,6 +6,6 @@
   - c.body do
     - if group_links.any?
       %ul.content-list
-        = render collection: group_links, partial: 'saml_group_link', locals: { group: group }
+        = render partial: 'saml_group_link', collection: group_links, locals: { group: group }
     - else
       = s_('GroupSAML|No active SAML group links')
diff --git a/ee/app/views/ldap_group_links/_ldap_group_links.html.haml b/ee/app/views/ldap_group_links/_ldap_group_links.html.haml
index 9699b80e031c8..df35e8090455d 100644
--- a/ee/app/views/ldap_group_links/_ldap_group_links.html.haml
+++ b/ee/app/views/ldap_group_links/_ldap_group_links.html.haml
@@ -5,7 +5,7 @@
 
   - if group.ldap_group_links.any?
     %ul.content-list
-      = render collection: group.ldap_group_links, partial: 'ldap_group_links/ldap_group_link', locals: { group: group }
+      = render partial: 'ldap_group_links/ldap_group_link', collection: group.ldap_group_links, locals: { group: group }
   - else
     .card-body
       No LDAP synchronizations
diff --git a/package.json b/package.json
index 92de33c53efcb..1f57e1aaf3366 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,7 @@
     "jest": "jest --config jest.config.js",
     "jest-debug": "node --inspect-brk node_modules/.bin/jest --runInBand",
     "jest:ci": "jest --config jest.config.js --ci --coverage --testSequencer ./scripts/frontend/parallel_ci_sequencer.js",
-    "jest:ci:minimal": "jest --config jest.config.js --ci --coverage --findRelatedTests $(cat $RSPEC_CHANGED_FILES_PATH) --passWithNoTests --testSequencer ./scripts/frontend/parallel_ci_sequencer.js",
+    "jest:ci:minimal": "jest --config jest.config.js --ci --coverage --findRelatedTests $(cat $RSPEC_CHANGED_FILES_PATH) $(cat $RSPEC_MATCHING_JS_FILES_PATH) --passWithNoTests --testSequencer ./scripts/frontend/parallel_ci_sequencer.js",
     "jest:contract": "PACT_DO_NOT_TRACK=true jest --config jest.config.contract.js --runInBand",
     "jest:integration": "jest --config jest.config.integration.js",
     "lint:eslint": "node scripts/frontend/eslint.js",
diff --git a/spec/tooling/lib/tooling/view_to_js_mappings_spec.rb b/spec/tooling/lib/tooling/view_to_js_mappings_spec.rb
new file mode 100644
index 0000000000000..e4c5737df54c6
--- /dev/null
+++ b/spec/tooling/lib/tooling/view_to_js_mappings_spec.rb
@@ -0,0 +1,343 @@
+# frozen_string_literal: true
+
+require 'tempfile'
+require_relative '../../../../tooling/lib/tooling/view_to_js_mappings'
+
+RSpec.describe Tooling::ViewToJsMappings, feature_category: :tooling do
+  # We set temporary folders, and those readers give access to those folder paths
+  attr_accessor :view_base_folder, :js_base_folder
+
+  around do |example|
+    Dir.mktmpdir do |tmp_js_base_folder|
+      Dir.mktmpdir do |tmp_views_base_folder|
+        self.js_base_folder   = tmp_js_base_folder
+        self.view_base_folder = tmp_views_base_folder
+
+        example.run
+      end
+    end
+  end
+
+  describe '#execute' do
+    let(:instance) do
+      described_class.new(
+        view_base_folder: view_base_folder,
+        js_base_folder: js_base_folder
+      )
+    end
+
+    let(:changed_files) { %W[#{view_base_folder}/index.html] }
+
+    subject { instance.execute(changed_files) }
+
+    context 'when no view files have been changed' do
+      before do
+        allow(instance).to receive(:view_files).and_return([])
+      end
+
+      it 'returns nothing' do
+        expect(subject).to match_array([])
+      end
+    end
+
+    context 'when some view files have been changed' do
+      before do
+        File.write("#{view_base_folder}/index.html", index_html_content)
+      end
+
+      context 'when they do not contain the HTML attribute value we search for' do
+        let(:index_html_content) do
+          <<~FILE
+            Beginning of file
+            End of file
+          FILE
+        end
+
+        it 'returns nothing' do
+          expect(subject).to match_array([])
+        end
+      end
+
+      context 'when they contain the HTML attribute value we search for' do
+        let(:index_html_content) do
+          <<~FILE
+            Beginning of file
+
+            <a id="js-some-id">A link</a>
+
+            End of file
+          FILE
+        end
+
+        context 'when no matching JS files are found' do
+          it 'returns nothing' do
+            expect(subject).to match_array([])
+          end
+        end
+
+        context 'when some matching JS files are found' do
+          let(:index_js_content) do
+            <<~FILE
+              Beginning of file
+
+              const isMainAwardsBlock = votesBlock.closest('#js-some-id.some_class').length;
+
+              End of file
+            FILE
+          end
+
+          before do
+            File.write("#{js_base_folder}/index.js", index_js_content)
+          end
+
+          it 'returns the matching JS files' do
+            expect(subject).to match_array(["#{js_base_folder}/index.js"])
+          end
+        end
+      end
+    end
+
+    context 'when rails partials are included in the file' do
+      before do
+        File.write("#{view_base_folder}/index.html", index_html_content)
+        File.write("#{view_base_folder}/_my-partial.html.haml", partial_file_content)
+        File.write("#{js_base_folder}/index.js", index_js_content)
+      end
+
+      let(:index_html_content) do
+        <<~FILE
+          Beginning of file
+
+          = render 'my-partial'
+
+          End of file
+        FILE
+      end
+
+      let(:partial_file_content) do
+        <<~FILE
+          Beginning of file
+
+          <a class="js-some-class">A link with class</a>
+
+          End of file
+        FILE
+      end
+
+      let(:index_js_content) do
+        <<~FILE
+          Beginning of file
+
+          const isMainAwardsBlock = votesBlock.closest('.js-some-class').length;
+
+          End of file
+        FILE
+      end
+
+      it 'scans those partials for the HTML attribute value' do
+        expect(subject).to match_array(["#{js_base_folder}/index.js"])
+      end
+    end
+  end
+
+  describe '#view_files' do
+    subject { described_class.new(view_base_folder: view_base_folder).view_files(changed_files) }
+
+    context 'when no files were changed' do
+      let(:changed_files) { [] }
+
+      it 'returns an empty array' do
+        expect(subject).to match_array([])
+      end
+    end
+
+    context 'when no view files were changed' do
+      let(:changed_files) { ["#{js_base_folder}/index.js"] }
+
+      it 'returns an empty array' do
+        expect(subject).to match_array([])
+      end
+    end
+
+    context 'when view files were changed' do
+      let(:changed_files) { ["#{js_base_folder}/index.js", "#{view_base_folder}/index.html"] }
+
+      it 'returns the path to the view files' do
+        expect(subject).to match_array(["#{view_base_folder}/index.html"])
+      end
+    end
+  end
+
+  describe '#folders_for_available_editions' do
+    let(:base_folder_path) { 'app/views' }
+
+    subject { described_class.new.folders_for_available_editions(base_folder_path) }
+
+    context 'when FOSS' do
+      before do
+        allow(GitlabEdition).to receive(:ee?).and_return(false)
+        allow(GitlabEdition).to receive(:jh?).and_return(false)
+      end
+
+      it 'returns the correct paths' do
+        expect(subject).to match_array([base_folder_path])
+      end
+    end
+
+    context 'when EE' do
+      before do
+        allow(GitlabEdition).to receive(:ee?).and_return(true)
+        allow(GitlabEdition).to receive(:jh?).and_return(false)
+      end
+
+      it 'returns the correct paths' do
+        expect(subject).to eq([base_folder_path, "ee/#{base_folder_path}"])
+      end
+    end
+
+    context 'when JiHu' do
+      before do
+        allow(GitlabEdition).to receive(:ee?).and_return(true)
+        allow(GitlabEdition).to receive(:jh?).and_return(true)
+      end
+
+      it 'returns the correct paths' do
+        expect(subject).to eq([base_folder_path, "ee/#{base_folder_path}", "jh/#{base_folder_path}"])
+      end
+    end
+  end
+
+  describe '#find_partials' do
+    subject { described_class.new(view_base_folder: view_base_folder).find_partials(file_path) }
+
+    let(:file_path) { "#{view_base_folder}/my_html_file.html" }
+
+    before do
+      File.write(file_path, file_content)
+    end
+
+    context 'when the file includes a partial' do
+      context 'when the partial is in the same folder as the view file' do
+        before do
+          File.write("#{view_base_folder}/_my-partial.html.haml", 'Hello from partial')
+        end
+
+        let(:file_content) do
+          <<~FILE
+            Beginning of file
+
+            = render "my-partial"
+
+            End of file
+          FILE
+        end
+
+        it "returns the partial file path" do
+          expect(subject).to match_array(["#{view_base_folder}/_my-partial.html.haml"])
+        end
+      end
+
+      context 'when the partial is in a subfolder' do
+        before do
+          FileUtils.mkdir_p("#{view_base_folder}/subfolder")
+
+          (1..12).each do |i|
+            FileUtils.touch "#{view_base_folder}/subfolder/_my-partial#{i}.html.haml"
+          end
+        end
+
+        let(:file_content) do
+          <<~FILE
+            Beginning of file
+
+            = render("subfolder/my-partial1")
+            = render "subfolder/my-partial2"
+            = render(partial: "subfolder/my-partial3")
+            = render partial: "subfolder/my-partial4"
+            = render(partial:"subfolder/my-partial5", path: 'else')
+            = render partial:"subfolder/my-partial6"
+            = render_if_exist("subfolder/my-partial7", path: 'else')
+            = render_if_exist "subfolder/my-partial8"
+            = render_if_exist(partial: "subfolder/my-partial9", path: 'else')
+            = render_if_exist partial: "subfolder/my-partial10"
+            = render_if_exist(partial:"subfolder/my-partial11", path: 'else')
+            = render_if_exist partial:"subfolder/my-partial12"
+
+            End of file
+          FILE
+        end
+
+        it "returns the partials file path" do
+          expect(subject).to match_array([
+            "#{view_base_folder}/subfolder/_my-partial1.html.haml",
+            "#{view_base_folder}/subfolder/_my-partial2.html.haml",
+            "#{view_base_folder}/subfolder/_my-partial3.html.haml",
+            "#{view_base_folder}/subfolder/_my-partial4.html.haml",
+            "#{view_base_folder}/subfolder/_my-partial5.html.haml",
+            "#{view_base_folder}/subfolder/_my-partial6.html.haml",
+            "#{view_base_folder}/subfolder/_my-partial7.html.haml",
+            "#{view_base_folder}/subfolder/_my-partial8.html.haml",
+            "#{view_base_folder}/subfolder/_my-partial9.html.haml",
+            "#{view_base_folder}/subfolder/_my-partial10.html.haml",
+            "#{view_base_folder}/subfolder/_my-partial11.html.haml",
+            "#{view_base_folder}/subfolder/_my-partial12.html.haml"
+          ])
+        end
+      end
+
+      context 'when the file does not include a partial' do
+        let(:file_content) do
+          <<~FILE
+            Beginning of file
+            End of file
+          FILE
+        end
+
+        it 'returns an empty array' do
+          expect(subject).to match_array([])
+        end
+      end
+    end
+  end
+
+  describe '#find_pattern_in_file' do
+    let(:subject) { described_class.new.find_pattern_in_file(file.path, /pattern/) }
+    let(:file)    { Tempfile.new('find_pattern_in_file') }
+
+    before do
+      file.write(file_content)
+      file.close
+    end
+
+    context 'when the file contains the pattern' do
+      let(:file_content) do
+        <<~FILE
+          Beginning of file
+
+          pattern
+          pattern
+          pattern
+
+          End of file
+        FILE
+      end
+
+      it 'returns the pattern once' do
+        expect(subject).to match_array(%w[pattern])
+      end
+    end
+
+    context 'when the file does not contain the pattern' do
+      let(:file_content) do
+        <<~FILE
+          Beginning of file
+          End of file
+        FILE
+      end
+
+      it 'returns an empty array' do
+        expect(subject).to match_array([])
+      end
+    end
+  end
+end
diff --git a/tooling/bin/view_to_js_mappings b/tooling/bin/view_to_js_mappings
new file mode 100755
index 0000000000000..2cebb91892e75
--- /dev/null
+++ b/tooling/bin/view_to_js_mappings
@@ -0,0 +1,10 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require_relative '../lib/tooling/view_to_js_mappings'
+
+changes       = ARGV.shift
+output_file   = ARGV.shift
+changed_files = File.read(changes).split(' ')
+
+File.write(output_file, Tooling::ViewToJsMappings.new.execute(changed_files).join(' '))
diff --git a/tooling/lib/tooling/view_to_js_mappings.rb b/tooling/lib/tooling/view_to_js_mappings.rb
new file mode 100644
index 0000000000000..48568db382b25
--- /dev/null
+++ b/tooling/lib/tooling/view_to_js_mappings.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require_relative '../../../lib/gitlab_edition'
+
+# Returns JS files that are related to the Rails views files that were changed in the MR.
+module Tooling
+  class ViewToJsMappings
+    # The HTML attribute value pattern we're looking for to match an HTML file to a JS file.
+    HTML_ATTRIBUTE_VALUE_REGEXP = /js-[-\w]+/.freeze
+
+    # Search for Rails partials included in an HTML file
+    RAILS_PARTIAL_INVOCATION_REGEXP = %r{(?:render|render_if_exist)(?: |\()(?:partial: ?)?['"]([\w/-]+)['"]}.freeze
+
+    def initialize(view_base_folder: 'app/views', js_base_folder: 'app/assets/javascripts')
+      @view_base_folders = folders_for_available_editions(view_base_folder)
+      @js_base_folders   = folders_for_available_editions(js_base_folder)
+    end
+
+    def execute(changed_files)
+      changed_view_files = view_files(changed_files)
+
+      partials = changed_view_files.flat_map do |file|
+        find_partials(file)
+      end
+
+      files_to_scan = changed_view_files + partials
+      js_tags = files_to_scan.flat_map do |file|
+        find_pattern_in_file(file, HTML_ATTRIBUTE_VALUE_REGEXP)
+      end
+      js_tags_regexp = Regexp.union(js_tags)
+
+      @js_base_folders.flat_map do |js_base_folder|
+        Dir["#{js_base_folder}/**/*.{js,vue}"].select do |js_file|
+          file_content = File.read(js_file)
+          js_tags_regexp.match?(file_content)
+        end
+      end
+    end
+
+    # Keep the files that are in the @view_base_folders folder
+    def view_files(changed_files)
+      changed_files.select { |filename| filename.start_with?(*@view_base_folders) }
+    end
+
+    def folders_for_available_editions(base_folder)
+      foss_prefix        = base_folder
+      extension_prefixes = ::GitlabEdition.extensions.map { |prefix| "#{prefix}/#{foss_prefix}" }
+      [foss_prefix, *extension_prefixes]
+    end
+
+    # Note: We only search for partials with depth 1. We don't do recursive search, as
+    #       it is probably not necessary for a first iteration.
+    def find_partials(file)
+      partial_paths = find_pattern_in_file(file, RAILS_PARTIAL_INVOCATION_REGEXP)
+      partial_paths.flat_map do |partial_path|
+        view_file_folder        = File.dirname(file)
+        partial_relative_folder = File.dirname(partial_path)
+
+        dirname =
+          if partial_relative_folder == '.' # The partial is in the same folder as the HTML file
+            view_file_folder
+          else
+            File.join(view_file_folder, partial_relative_folder)
+          end
+
+        Dir["#{dirname}/_#{File.basename(partial_path)}.*"]
+      end
+    end
+
+    def find_pattern_in_file(file, pattern)
+      File.read(file).scan(pattern).flatten.uniq
+    end
+  end
+end
-- 
GitLab