From 88931f38642f4798b96f9a8ee44c25f09e299c3a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me>
Date: Fri, 27 Jan 2023 11:15:34 +0100
Subject: [PATCH] ci: Include test files filtering in Knapsack allocation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Signed-off-by: Rémy Coutable <remy@rymai.me>
---
 scripts/rspec_helpers.sh                      | 10 +-
 .../lib/tooling/parallel_rspec_runner_spec.rb | 96 +++++++++---------
 tooling/lib/tooling/parallel_rspec_runner.rb  | 99 +++++++++++++------
 3 files changed, 119 insertions(+), 86 deletions(-)

diff --git a/scripts/rspec_helpers.sh b/scripts/rspec_helpers.sh
index c6ec773cf7802..729892ec84e33 100644
--- a/scripts/rspec_helpers.sh
+++ b/scripts/rspec_helpers.sh
@@ -137,6 +137,9 @@ function debug_rspec_variables() {
   echoinfo "SKIPPED_FLAKY_TESTS_REPORT_PATH: ${SKIPPED_FLAKY_TESTS_REPORT_PATH}"
 
   echoinfo "CRYSTALBALL: ${CRYSTALBALL:-}"
+
+  echoinfo "RSPEC_TESTS_MAPPING_ENABLED: ${RSPEC_TESTS_MAPPING_ENABLED:-}"
+  echoinfo "RSPEC_TESTS_FILTER_FILE: ${RSPEC_TESTS_FILTER_FILE:-}"
 }
 
 function handle_retry_rspec_in_new_process() {
@@ -194,12 +197,7 @@ function rspec_paralellized_job() {
 
   cp "${KNAPSACK_RSPEC_SUITE_REPORT_PATH}" "${KNAPSACK_REPORT_PATH}"
 
-  export KNAPSACK_TEST_FILE_PATTERN="spec/{,**/}*_spec.rb"
-
-  if [[ "${test_level}" != "foss-impact" ]]; then
-    export KNAPSACK_TEST_FILE_PATTERN=$(ruby -r./tooling/quality/test_level.rb -e "puts Quality::TestLevel.new(${spec_folder_prefixes}).pattern(:${test_level})")
-  fi
-
+  export KNAPSACK_TEST_FILE_PATTERN=$(ruby -r./tooling/quality/test_level.rb -e "puts Quality::TestLevel.new(${spec_folder_prefixes}).pattern(:${test_level})")
   export FLAKY_RSPEC_REPORT_PATH="${rspec_flaky_folder_path}all_${report_name}_report.json"
   export NEW_FLAKY_RSPEC_REPORT_PATH="${rspec_flaky_folder_path}new_${report_name}_report.json"
   export SKIPPED_FLAKY_TESTS_REPORT_PATH="${rspec_flaky_folder_path}skipped_flaky_tests_${report_name}_report.txt"
diff --git a/spec/tooling/lib/tooling/parallel_rspec_runner_spec.rb b/spec/tooling/lib/tooling/parallel_rspec_runner_spec.rb
index 4b44d991d89b9..b7b39c3781933 100644
--- a/spec/tooling/lib/tooling/parallel_rspec_runner_spec.rb
+++ b/spec/tooling/lib/tooling/parallel_rspec_runner_spec.rb
@@ -1,90 +1,84 @@
 # frozen_string_literal: true
 
+require 'tempfile'
+
 require_relative '../../../../tooling/lib/tooling/parallel_rspec_runner'
 
 RSpec.describe Tooling::ParallelRSpecRunner do # rubocop:disable RSpec/FilePath
   describe '#run' do
-    let(:allocator) { instance_double(Knapsack::Allocator) }
-    let(:rspec_args) { '--seed 123' }
-    let(:filter_tests_file) { 'tests.txt' }
-    let(:node_tests) { %w[01_spec.rb 03_spec.rb 05_spec.rb] }
-    let(:filter_tests) { '01_spec.rb 02_spec.rb 03_spec.rb' }
     let(:test_dir) { 'spec' }
+    let(:node_tests) { %w[01_spec.rb 03_spec.rb] }
+    let(:allocator) { instance_double(Knapsack::Allocator, test_dir: test_dir, node_tests: node_tests) }
+    let(:allocator_builder) { double(Knapsack::AllocatorBuilder, allocator: allocator) } # rubocop:disable RSpec/VerifiedDoubles
+
+    let(:filter_tests) { [] }
+    let(:filter_tests_file) { nil }
+    let(:filter_tests_file_path) { nil }
 
     before do
+      allow(Knapsack::AllocatorBuilder).to receive(:new).and_return(allocator_builder)
       allow(Knapsack.logger).to receive(:info)
-      allow(allocator).to receive(:node_tests).and_return(node_tests)
-      allow(allocator).to receive(:test_dir).and_return(test_dir)
-      allow(File).to receive(:exist?).with(filter_tests_file).and_return(true)
-      allow(File).to receive(:read).and_call_original
-      allow(File).to receive(:read).with(filter_tests_file).and_return(filter_tests)
-      allow(subject).to receive(:exec)
     end
 
-    subject { described_class.new(allocator: allocator, filter_tests_file: filter_tests_file, rspec_args: rspec_args) }
-
-    shared_examples 'runs node tests' do
-      it 'runs rspec with tests allocated for this node' do
-        expect_command(%w[bundle exec rspec --seed 123 --default-path spec -- 01_spec.rb 03_spec.rb 05_spec.rb])
-
-        subject.run
+    after do
+      if filter_tests_file.respond_to?(:close)
+        filter_tests_file.close
+        File.unlink(filter_tests_file)
       end
     end
 
-    context 'given filter tests' do
-      it 'reads filter tests file for list of tests' do
-        expect(File).to receive(:read).with(filter_tests_file)
+    subject { described_class.new(filter_tests_file: filter_tests_file_path, rspec_args: rspec_args) }
 
-        subject.run
-      end
+    shared_examples 'runs node tests' do
+      let(:rspec_args) { nil }
 
-      it 'runs rspec filter tests that are allocated for this node' do
-        expect_command(%w[bundle exec rspec --seed 123 --default-path spec -- 01_spec.rb 03_spec.rb])
+      it 'runs rspec with tests allocated for this node' do
+        expect(allocator_builder).to receive(:filter_tests=).with(filter_tests)
+        expect_command(%W[bundle exec rspec#{rspec_args} --] + node_tests)
 
         subject.run
       end
-
-      context 'when there is no intersect between allocated tests and filtered tests' do
-        let(:filter_tests) { '99_spec.rb' }
-
-        it 'does not run rspec' do
-          expect(subject).not_to receive(:exec)
-
-          subject.run
-        end
-      end
     end
 
-    context 'with empty filter tests file' do
-      let(:filter_tests) { '' }
+    context 'without filter_tests_file option' do
+      subject { described_class.new(rspec_args: rspec_args) }
 
       it_behaves_like 'runs node tests'
     end
 
-    context 'without filter_tests_file option' do
-      let(:filter_tests_file) { nil }
+    context 'given filter tests file' do
+      let(:filter_tests_file) do
+        Tempfile.create.tap do |f| # rubocop:disable Rails/SaveBang
+          f.write(filter_tests.join(' '))
+          f.rewind
+        end
+      end
 
-      it_behaves_like 'runs node tests'
-    end
+      let(:filter_tests_file_path) { filter_tests_file.path }
 
-    context 'if filter_tests_file does not exist' do
-      before do
-        allow(File).to receive(:exist?).with(filter_tests_file).and_return(false)
+      context 'when filter_tests_file is empty' do
+        it_behaves_like 'runs node tests'
       end
 
-      it_behaves_like 'runs node tests'
-    end
+      context 'when filter_tests_file does not exist' do
+        let(:filter_tests_file_path) { 'doesnt_exist' }
 
-    context 'without rspec args' do
-      let(:rspec_args) { nil }
+        it_behaves_like 'runs node tests'
+      end
 
-      it 'runs rspec with without extra arguments' do
-        expect_command(%w[bundle exec rspec --default-path spec -- 01_spec.rb 03_spec.rb])
+      context 'when filter_tests_file is not empty' do
+        let(:filter_tests) { %w[01_spec.rb 02_spec.rb 03_spec.rb] }
 
-        subject.run
+        it_behaves_like 'runs node tests'
       end
     end
 
+    context 'with rspec args' do
+      let(:rspec_args) { ' --seed 123' }
+
+      it_behaves_like 'runs node tests'
+    end
+
     def expect_command(cmd)
       expect(subject).to receive(:exec).with(*cmd)
     end
diff --git a/tooling/lib/tooling/parallel_rspec_runner.rb b/tooling/lib/tooling/parallel_rspec_runner.rb
index b1ddc91e831e3..834d9ec23a75d 100644
--- a/tooling/lib/tooling/parallel_rspec_runner.rb
+++ b/tooling/lib/tooling/parallel_rspec_runner.rb
@@ -1,6 +1,60 @@
 # frozen_string_literal: true
 
 require 'knapsack'
+require 'fileutils'
+
+module Knapsack
+  module Distributors
+    class BaseDistributor
+      # Refine https://github.com/KnapsackPro/knapsack/blob/v1.21.1/lib/knapsack/distributors/base_distributor.rb
+      # to take in account the additional filtering we do for predictive jobs.
+      module BaseDistributorWithTestFiltering
+        attr_reader :filter_tests
+
+        def initialize(args = {})
+          super
+
+          @filter_tests = args[:filter_tests]
+        end
+
+        def all_tests
+          @all_tests ||= begin
+            pattern_tests = Dir.glob(test_file_pattern).uniq
+
+            if filter_tests.empty?
+              pattern_tests
+            else
+              pattern_tests & filter_tests
+            end
+          end.sort
+        end
+      end
+
+      prepend BaseDistributorWithTestFiltering
+    end
+  end
+
+  class AllocatorBuilder
+    # Refine https://github.com/KnapsackPro/knapsack/blob/v1.21.1/lib/knapsack/allocator_builder.rb
+    # to take in account the additional filtering we do for predictive jobs.
+    module AllocatorBuilderWithTestFiltering
+      attr_accessor :filter_tests
+
+      def allocator
+        Knapsack::Allocator.new({
+          report: Knapsack.report.open,
+          test_file_pattern: test_file_pattern,
+          ci_node_total: Knapsack::Config::Env.ci_node_total,
+          ci_node_index: Knapsack::Config::Env.ci_node_index,
+          # Additional argument
+          filter_tests: filter_tests
+        })
+      end
+    end
+
+    prepend AllocatorBuilderWithTestFiltering
+  end
+end
 
 # A custom parallel rspec runner based on Knapsack runner
 # which takes in additional option for a file containing
@@ -13,32 +67,26 @@
 # would be executed in the CI node.
 #
 # Reference:
-# https://github.com/ArturT/knapsack/blob/v1.20.0/lib/knapsack/runners/rspec_runner.rb
+# https://github.com/ArturT/knapsack/blob/v1.21.1/lib/knapsack/runners/rspec_runner.rb
 module Tooling
   class ParallelRSpecRunner
     def self.run(rspec_args: nil, filter_tests_file: nil)
       new(rspec_args: rspec_args, filter_tests_file: filter_tests_file).run
     end
 
-    def initialize(allocator: knapsack_allocator, filter_tests_file: nil, rspec_args: nil)
-      @allocator = allocator
+    def initialize(filter_tests_file: nil, rspec_args: nil)
       @filter_tests_file = filter_tests_file
       @rspec_args = rspec_args&.split(' ') || []
     end
 
     def run
-      Knapsack.logger.info
-      Knapsack.logger.info 'Knapsack node specs:'
-      Knapsack.logger.info node_tests
-      Knapsack.logger.info
-      Knapsack.logger.info 'Filter specs:'
-      Knapsack.logger.info filter_tests
-      Knapsack.logger.info
-      Knapsack.logger.info 'Running specs:'
-      Knapsack.logger.info tests_to_run
-      Knapsack.logger.info
-
-      if tests_to_run.empty?
+      if ENV['KNAPSACK_RSPEC_SUITE_REPORT_PATH']
+        knapsack_dir = File.dirname(ENV['KNAPSACK_RSPEC_SUITE_REPORT_PATH'])
+        FileUtils.mkdir_p(knapsack_dir)
+        File.write(File.join(knapsack_dir, 'node_specs.txt'), node_tests.join("\n"))
+      end
+
+      if node_tests.empty?
         Knapsack.logger.info 'No tests to run on this node, exiting.'
         return
       end
@@ -50,26 +98,16 @@ def run
 
     private
 
-    attr_reader :allocator, :filter_tests_file, :rspec_args
+    attr_reader :filter_tests_file, :rspec_args
 
     def rspec_command
       %w[bundle exec rspec].tap do |cmd|
         cmd.push(*rspec_args)
-        cmd.push('--default-path', allocator.test_dir)
         cmd.push('--')
-        cmd.push(*tests_to_run)
+        cmd.push(*node_tests)
       end
     end
 
-    def tests_to_run
-      if filter_tests.empty?
-        Knapsack.logger.info 'Running all node tests without filter'
-        return node_tests
-      end
-
-      @tests_to_run ||= node_tests & filter_tests
-    end
-
     def node_tests
       allocator.node_tests
     end
@@ -85,8 +123,11 @@ def tests_from_file(filter_tests_file)
       File.read(filter_tests_file).split(' ')
     end
 
-    def knapsack_allocator
-      Knapsack::AllocatorBuilder.new(Knapsack::Adapters::RSpecAdapter).allocator
+    def allocator
+      @allocator ||=
+        Knapsack::AllocatorBuilder.new(Knapsack::Adapters::RSpecAdapter).tap do |builder|
+          builder.filter_tests = filter_tests
+        end.allocator
     end
   end
 end
-- 
GitLab